Skip to content

Commit

Permalink
#44 Input phone component (#45)
Browse files Browse the repository at this point in the history
* #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
wouter-adriaens authored Jul 11, 2023
1 parent 74d01d4 commit 40ffc7a
Show file tree
Hide file tree
Showing 10 changed files with 455 additions and 4 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"ag-grid-vue3": "^29.3.5",
"axios": "^1.4.0",
"date-fns": "^2.30.0",
"libphonenumber-js": "^1.10.37",
"lodash": "^4.17.21",
"pyoes": "https://gitpkg.now.sh/OnroerendErfgoed/pyoes/npm-packages/pyoes",
"vue": "^3.3.4",
Expand Down
202 changes: 202 additions & 0 deletions src/__tests__/InputPhone.cy.ts
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();
};
155 changes: 155 additions & 0 deletions src/components/dumb/InputPhone.vue
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>
2 changes: 2 additions & 0 deletions src/components/dumb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import FilterInput from './FilterInput.vue';
import FilterRadio from './FilterRadio.vue';
import FilterSelect from './FilterSelect.vue';
import FilterText from './FilterText.vue';
import InputPhone from './InputPhone.vue';
import OeAutocomplete from './OeAutocomplete.vue';
import OeButton from './OeButton.vue';
import OeContainer from './OeContainer.vue';
Expand All @@ -18,6 +19,7 @@ export {
FilterRadio,
FilterSelect,
FilterText,
InputPhone,
OeAutocomplete,
OeButton,
OeContainer,
Expand Down
1 change: 1 addition & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './status';
export * from './system-fields';
export * from './grid';
export * from './wizard';
export * from './input-phone';
Loading

0 comments on commit 40ffc7a

Please sign in to comment.