-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* #44 Add phone input component * #44 Export + css fix * #44 Provide 2-way binding * #44 Add invalid modifier + fix to empty input * #44 Add input-phone component tests * #44 Update tests * #44 Add missing export * #44 Add id prop * #44 Extend validators + error handling * #44 Fix issue where GB number was recognised as BE number * #44 Expose validation * #44 Tweak docs * #44 Fix comments * #44 Fix comments - adapt validation messages
- Loading branch information
1 parent
74d01d4
commit 40ffc7a
Showing
10 changed files
with
455 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,202 @@ | ||
import InputPhone from '@components/dumb/InputPhone.vue'; | ||
import type { CountryCode } from 'libphonenumber-js'; | ||
import { defineComponent, ref } from 'vue'; | ||
|
||
const TestComponent = defineComponent({ | ||
components: { InputPhone }, | ||
setup() { | ||
const phoneNumber = ref(''); | ||
return { phoneNumber: phoneNumber }; | ||
}, | ||
template: '<input-phone id="id" v-model="phoneNumber"/>', | ||
}); | ||
|
||
const generateTestSuiteNonDefaultCountry = ( | ||
countryCode: CountryCode, | ||
phonePlaceholder: string, | ||
phonePrefix: string, | ||
expectedInput: string | ||
) => { | ||
describe(countryCode, () => { | ||
const phoneInput = phonePlaceholder.replace(/\s/g, ''); | ||
const internationalFormat = `${phonePrefix}${expectedInput}`; | ||
const randomInput = '15484184181595599'; | ||
|
||
it('renders a placeholder according to selected country', () => { | ||
cy.mount(TestComponent); | ||
changeCountryCode(countryCode); | ||
|
||
cy.dataCy('input-phone').should('have.attr', 'placeholder', phonePlaceholder); | ||
}); | ||
|
||
it('accepts a phone number without leading country code when country code is selected and formats accordingly', () => { | ||
cy.mount(TestComponent); | ||
changeCountryCode(countryCode); | ||
|
||
cy.dataCy('input-phone').type(phoneInput); | ||
|
||
checkFlagAndPrefix(countryCode, phonePrefix); | ||
checkPhoneNumberInput(expectedInput); | ||
}); | ||
|
||
it('accepts a phone number with leading country code, automatically derives country code and formats accordingly', () => { | ||
cy.mount(TestComponent); | ||
|
||
cy.dataCy('input-phone').type(internationalFormat); | ||
|
||
checkFlagAndPrefix(countryCode, phonePrefix); | ||
checkPhoneNumberInput(expectedInput); | ||
}); | ||
|
||
it('accepts a value prop and sets the country code and phone number', () => { | ||
cy.mount(TestComponent).then(({ component }) => { | ||
component.phoneNumber = internationalFormat; | ||
|
||
checkFlagAndPrefix(countryCode, phonePrefix); | ||
checkPhoneNumberInput(expectedInput); | ||
}); | ||
}); | ||
|
||
it('emits an update:modelValue event', () => { | ||
const onUpdateModelValueSpy = cy.spy().as('onUpdateModelValueSpy'); | ||
cy.mount(TestComponent, { props: { 'onUpdate:modelValue': onUpdateModelValueSpy } }).then(({ component }) => { | ||
changeCountryCode(countryCode); | ||
cy.dataCy('input-phone') | ||
.type(phoneInput) | ||
.then(() => { | ||
expect(component.phoneNumber).to.equal(internationalFormat); | ||
cy.get('@onUpdateModelValueSpy').should('have.been.calledWith', internationalFormat); | ||
}); | ||
}); | ||
}); | ||
|
||
it('marks the field as invalid when an invalid phone number is entered for the selected country code and does not emit an update:modelValue event', () => { | ||
const onUpdateModelValueSpy = cy.spy().as('onUpdateModelValueSpy'); | ||
cy.mount(TestComponent, { props: { 'onUpdate:modelValue': onUpdateModelValueSpy } }).then(() => { | ||
changeCountryCode(countryCode); | ||
cy.dataCy('input-phone') | ||
.type(randomInput) | ||
.then(() => { | ||
cy.get('@onUpdateModelValueSpy').should('not.have.been.calledWith', randomInput); | ||
checkError(phonePlaceholder); | ||
}); | ||
}); | ||
}); | ||
|
||
it('clears the phone number when the country code is changed', () => { | ||
cy.mount(TestComponent).then(({ component }) => { | ||
component.phoneNumber = internationalFormat; | ||
|
||
checkFlagAndPrefix(countryCode, phonePrefix); | ||
checkPhoneNumberInput(expectedInput); | ||
|
||
changeCountryCode('be'); | ||
checkPhoneNumberInput(''); | ||
}); | ||
}); | ||
}); | ||
}; | ||
|
||
describe('InputPhone', () => { | ||
it('renders', () => { | ||
cy.mount(TestComponent); | ||
}); | ||
|
||
describe('BE', () => { | ||
it('renders a placeholder according to selected country', () => { | ||
cy.mount(TestComponent); | ||
cy.dataCy('input-phone').should('have.attr', 'placeholder', '0470 12 34 56'); | ||
}); | ||
|
||
it('accepts a phone number with leading 0 and formats accordingly', () => { | ||
cy.mount(TestComponent); | ||
cy.dataCy('input-phone').type('0497668811'); | ||
checkFlagAndPrefix('be', '+32'); | ||
checkPhoneNumberInput('497668811'); | ||
}); | ||
|
||
it('accepts a phone number with leading country code, sets country code and formats accordingly', () => { | ||
cy.mount(TestComponent); | ||
cy.dataCy('input-phone').type('+32497668811'); | ||
checkFlagAndPrefix('be', '+32'); | ||
checkPhoneNumberInput('497668811'); | ||
}); | ||
|
||
it('accepts a value prop and sets the country code and phone number', () => { | ||
cy.mount(TestComponent).then(({ component }) => { | ||
component.phoneNumber = '+32497668811'; | ||
|
||
checkFlagAndPrefix('be', '+32'); | ||
checkPhoneNumberInput('497668811'); | ||
}); | ||
}); | ||
|
||
it('emits an update:modelValue event', () => { | ||
const onUpdateModelValueSpy = cy.spy().as('onUpdateModelValueSpy'); | ||
cy.mount(TestComponent, { props: { 'onUpdate:modelValue': onUpdateModelValueSpy } }).then(({ component }) => { | ||
cy.dataCy('input-phone') | ||
.type('0497668811') | ||
.then(() => { | ||
expect(component.phoneNumber).to.equal('+32497668811'); | ||
cy.get('@onUpdateModelValueSpy').should('have.been.calledWith', '+32497668811'); | ||
}); | ||
}); | ||
}); | ||
|
||
it('marks the field as invalid when an invalid phone number is entered for the selected country code and does not emit an update:modelValue event', () => { | ||
const onUpdateModelValueSpy = cy.spy().as('onUpdateModelValueSpy'); | ||
cy.mount(TestComponent, { props: { 'onUpdate:modelValue': onUpdateModelValueSpy } }).then(() => { | ||
cy.dataCy('input-phone') | ||
.type('15484184181') | ||
.then(() => { | ||
cy.get('@onUpdateModelValueSpy').should('not.have.been.calledWith', '15484184181'); | ||
checkError('0470 12 34 56'); | ||
}); | ||
}); | ||
}); | ||
|
||
it('clears the phone number when the country code is changed', () => { | ||
cy.mount(TestComponent).then(({ component }) => { | ||
component.phoneNumber = '+32497668811'; | ||
|
||
checkFlagAndPrefix('be', '+32'); | ||
checkPhoneNumberInput('497668811'); | ||
|
||
changeCountryCode('de'); | ||
checkPhoneNumberInput(''); | ||
}); | ||
}); | ||
}); | ||
|
||
generateTestSuiteNonDefaultCountry('DE', '01512 3456789', '+49', '15123456789'); | ||
generateTestSuiteNonDefaultCountry('FR', '06 12 34 56 78', '+33', '612345678'); | ||
generateTestSuiteNonDefaultCountry('GB', '07400 123456', '+44', '7400123456'); | ||
generateTestSuiteNonDefaultCountry('NL', '06 12345678', '+31', '612345678'); | ||
generateTestSuiteNonDefaultCountry('LU', '628 123 456', '+352', '628123456'); | ||
}); | ||
|
||
const checkFlagAndPrefix = (countryCode: string, prefix: string) => { | ||
cy.dataCy('country-code') | ||
.find('.multiselect__single span') | ||
.should('have.class', 'flag') | ||
.should('have.class', countryCode.toLowerCase()) | ||
.invoke('text') | ||
.should('equal', prefix); | ||
}; | ||
|
||
const checkPhoneNumberInput = (phoneNumber: string) => { | ||
cy.dataCy('input-phone').should('have.value', phoneNumber); | ||
}; | ||
|
||
const checkError = (expectedFormat: string) => { | ||
cy.dataCy('input-phone').blur(); | ||
cy.dataCy('input-phone').should('have.class', 'vl-input-field--error'); | ||
cy.dataCy('input-error') | ||
.should('exist') | ||
.invoke('text') | ||
.should('equal', `Ongeldige waarde, gebruik formaat vb. ${expectedFormat}`); | ||
}; | ||
|
||
const changeCountryCode = (countryCode: string) => { | ||
cy.dataCy('country-code').click().find(`.flag.${countryCode.toLowerCase()}`).click(); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
<template> | ||
<div class="input-phone vl-grid" data-cy="input-phone-wrapper"> | ||
<div class="vl-col--1-6 vl-col--2-6--m vl-col--3-6--xs"> | ||
<vl-multiselect | ||
v-bind="$attrs" | ||
v-model="countryCode" | ||
data-cy="country-code" | ||
class="vl-u-spacer-right--xxsmall" | ||
:allow-empty="false" | ||
:searchable="false" | ||
:mod-multiple="false" | ||
:options="countryCodeList" | ||
:custom-label="(cc: ICountryCode) => cc.value" | ||
@select="emit('update:modelValue', '')" | ||
> | ||
<template #singleLabel="properties"> | ||
<span class="flag" :class="properties.option.code.toLowerCase()">{{ properties.option.value }}</span> | ||
</template> | ||
|
||
<template #option="properties"> | ||
<span class="flag" :class="properties.option.code.toLowerCase()">{{ properties.option.description }}</span> | ||
</template> | ||
</vl-multiselect> | ||
</div> | ||
<vl-input-field | ||
v-bind="$attrs" | ||
:id="props.id" | ||
v-model="phoneNumberValue" | ||
data-cy="input-phone" | ||
:mod-error="(phoneNumberValue && inputTouched && !phoneNumberParsed?.isValid()) || $attrs['mod-error']" | ||
:placeholder="phoneNumberExample" | ||
class="vl-col--5-6 vl-col--4-6--m vl-col--3-6--xs" | ||
type="tel" | ||
@blur="inputTouched = true" | ||
></vl-input-field> | ||
<vl-form-message-error | ||
v-if="phoneNumberValue && inputTouched && !phoneNumberParsed?.isValid()" | ||
data-cy="input-error" | ||
>Ongeldige waarde, gebruik formaat vb. {{ phoneNumberExample }} | ||
</vl-form-message-error> | ||
</div> | ||
</template> | ||
<script setup lang="ts"> | ||
import { VlFormMessageError, VlInputField, VlMultiselect } from '@govflanders/vl-ui-design-system-vue3'; | ||
import type { ICountryCode, IInputPhoneProps } from '@models/input-phone'; | ||
import parsePhoneNumber, { | ||
formatNumber, | ||
getExampleNumber, | ||
type CountryCode, | ||
type ParsedNumber, | ||
type PhoneNumber, | ||
} from 'libphonenumber-js'; | ||
import examples from 'libphonenumber-js/mobile/examples'; | ||
import { computed, ref, watch } from 'vue'; | ||
const DEFAULT_COUNTRY_CODE = 'BE'; | ||
const inputTouched = ref(false); | ||
const props = withDefaults(defineProps<IInputPhoneProps>(), { | ||
id: '', | ||
modelValue: '', | ||
}); | ||
const emit = defineEmits(['update:modelValue']); | ||
// Country code | ||
const countryCodeList = ref<ICountryCode[]>([ | ||
{ value: '+32', description: '(+32) België', code: 'BE' }, | ||
{ value: '+49', description: '(+49) Duitsland', code: 'DE' }, | ||
{ value: '+33', description: '(+33) Frankrijk', code: 'FR' }, | ||
{ value: '+44', description: '(+44) Groot-Brittannië', code: 'GB' }, | ||
{ value: '+31', description: '(+31) Nederland', code: 'NL' }, | ||
{ value: '+352', description: '(+352) Luxemburg', code: 'LU' }, | ||
]); | ||
const defaultCountryCode = ref(countryCodeList.value.find((c) => c.code === DEFAULT_COUNTRY_CODE)); | ||
const countryCode = ref(defaultCountryCode.value); | ||
// Phone number | ||
const phoneNumberParsed = ref<PhoneNumber>(); | ||
const phoneNumberValue = computed({ | ||
get() { | ||
return parsePhoneNumber(props.modelValue, DEFAULT_COUNTRY_CODE)?.nationalNumber || ''; | ||
}, | ||
set(value) { | ||
phoneNumberParsed.value = parsePhoneNumber(value, countryCode.value?.code); | ||
if (value.startsWith('+') || value.startsWith('00')) { | ||
if (phoneNumberParsed.value?.country) { | ||
emit('update:modelValue', phoneNumberParsed.value?.number); | ||
} | ||
} else { | ||
emit('update:modelValue', phoneNumberParsed.value?.number); | ||
} | ||
}, | ||
}); | ||
const phoneNumberExample = computed(() => { | ||
const example = getExampleNumber(countryCode.value?.code as CountryCode, examples)?.number; | ||
// Formatter works but has typing issue | ||
return formatNumber(example as unknown as ParsedNumber, 'NATIONAL'); | ||
}); | ||
watch( | ||
phoneNumberValue, | ||
(newValue) => { | ||
if (newValue) { | ||
phoneNumberParsed.value = parsePhoneNumber(props.modelValue, DEFAULT_COUNTRY_CODE); | ||
if (phoneNumberParsed.value?.country) { | ||
countryCode.value = countryCodeList.value.find((cc) => cc.code === phoneNumberParsed.value?.country); | ||
} | ||
} | ||
}, | ||
{ immediate: true } | ||
); | ||
// Validation | ||
const isValid = computed(() => phoneNumberParsed.value?.isValid()); | ||
defineExpose({ isValid }); | ||
</script> | ||
<style lang="scss" scoped> | ||
.input-phone { | ||
:deep(.multiselect__content-wrapper) { | ||
width: auto; | ||
} | ||
:deep(.vl-multiselect .multiselect--active:not(.multiselect--above) .multiselect__tags) { | ||
padding: 0px 45px 0 6px; | ||
} | ||
span.flag { | ||
background-repeat: no-repeat; | ||
background-size: 25px 20px; | ||
padding-left: 30px; | ||
&.be { | ||
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NTAiIGhlaWdodD0iMzkwIj4KPHJlY3Qgd2lkdGg9IjQ1MCIgaGVpZ2h0PSIzOTAiIGZpbGw9IiNFRDI5MzkiLz4KPHJlY3Qgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzOTAiIGZpbGw9IiNGQUUwNDIiLz4KPHJlY3Qgd2lkdGg9IjE1MCIgaGVpZ2h0PSIzOTAiLz4KPC9zdmc+); | ||
} | ||
&.de { | ||
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIKCSJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMDAiIGhlaWdodD0iNjAwIiB2aWV3Qm94PSIwIDAgNSAzIj4KCTxkZXNjPkZsYWcgb2YgR2VybWFueTwvZGVzYz4KCTxyZWN0IGlkPSJibGFja19zdHJpcGUiIHdpZHRoPSI1IiBoZWlnaHQ9IjMiIHk9IjAiIHg9IjAiIGZpbGw9IiMwMDAiLz4KCTxyZWN0IGlkPSJyZWRfc3RyaXBlIiB3aWR0aD0iNSIgaGVpZ2h0PSIyIiB5PSIxIiB4PSIwIiBmaWxsPSIjRDAwIi8+Cgk8cmVjdCBpZD0iZ29sZF9zdHJpcGUiIHdpZHRoPSI1IiBoZWlnaHQ9IjEiIHk9IjIiIHg9IjAiIGZpbGw9IiNGRkNFMDAiLz4KPC9zdmc+); | ||
} | ||
&.fr { | ||
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5MDAiIGhlaWdodD0iNjAwIj48cmVjdCB3aWR0aD0iOTAwIiBoZWlnaHQ9IjYwMCIgZmlsbD0iI0VEMjkzOSIvPjxyZWN0IHdpZHRoPSI2MDAiIGhlaWdodD0iNjAwIiBmaWxsPSIjZmZmIi8+PHJlY3Qgd2lkdGg9IjMwMCIgaGVpZ2h0PSI2MDAiIGZpbGw9IiMwMDIzOTUiLz48L3N2Zz4=); | ||
} | ||
&.gb { | ||
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2MCAzMCIgd2lkdGg9IjEyMDAiIGhlaWdodD0iNjAwIj4KPGNsaXBQYXRoIGlkPSJ0Ij4KCTxwYXRoIGQ9Ik0zMCwxNSBoMzAgdjE1IHogdjE1IGgtMzAgeiBoLTMwIHYtMTUgeiB2LTE1IGgzMCB6Ii8+CjwvY2xpcFBhdGg+CjxwYXRoIGQ9Ik0wLDAgdjMwIGg2MCB2LTMwIHoiIGZpbGw9IiMwMDI0N2QiLz4KPHBhdGggZD0iTTAsMCBMNjAsMzAgTTYwLDAgTDAsMzAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSI2Ii8+CjxwYXRoIGQ9Ik0wLDAgTDYwLDMwIE02MCwwIEwwLDMwIiBjbGlwLXBhdGg9InVybCgjdCkiIHN0cm9rZT0iI2NmMTQyYiIgc3Ryb2tlLXdpZHRoPSI0Ii8+CjxwYXRoIGQ9Ik0zMCwwIHYzMCBNMCwxNSBoNjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxMCIvPgo8cGF0aCBkPSJNMzAsMCB2MzAgTTAsMTUgaDYwIiBzdHJva2U9IiNjZjE0MmIiIHN0cm9rZS13aWR0aD0iNiIvPgo8L3N2Zz4=); | ||
} | ||
&.nl { | ||
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5MDAiIGhlaWdodD0iNjAwIiB2aWV3Qm94PSIwIDAgOSA2Ij4KPHJlY3QgZmlsbD0iIzIxNDY4QiIJd2lkdGg9IjkiIGhlaWdodD0iNiIvPgo8cmVjdCBmaWxsPSIjRkZGIiB3aWR0aD0iOSIgaGVpZ2h0PSI0Ii8+CjxyZWN0IGZpbGw9IiNBRTFDMjgiCXdpZHRoPSI5IiBoZWlnaHQ9IjIiLz4KPC9zdmc+); | ||
} | ||
&.lu { | ||
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAwIiBoZWlnaHQ9IjYwMCI+CjxyZWN0IHdpZHRoPSIxMDAwIiBoZWlnaHQ9IjMwMCIgeT0iMzAwIiBmaWxsPSIjMDBBMURFCiIvPgo8cmVjdCB3aWR0aD0iMTAwMCIgaGVpZ2h0PSIzMDAiIGZpbGw9IiNlZDI5MzkiLz4KPHJlY3Qgd2lkdGg9IjEwMDAiIGhlaWdodD0iMjAwIiB5PSIyMDAiIGZpbGw9IiNmZmYiLz4KPC9zdmc+); | ||
} | ||
} | ||
} | ||
</style> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.