Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,50 @@
## 2.5.4 (2026-10-13)

Fixes `common-phone-number` storing the wrong number when a user types the
country code. The field already shows a `+1 ` prefix, but the mask accepted any
digit in the first position, so typing `+1 (234) 567-8978` stored `1234567897`:
the typed 1 became the area code and the last digit was dropped. The validator
only counted digits, so the control stayed valid and the bad number reached the
app. In MSP DEAM this failed submission with
`should match pattern "^[2-9]([0-9]{9})$"`. The mask and prefix have behaved
this way since 1.0.0; no release flagged the shifted number as invalid.

### Breaking

- `common-phone-number`: a value whose first digit is 0 or 1 is now invalid
(`incompleteValue`, "<label> does not appear to be valid."), including a
value set programmatically or loaded from saved data. With the default mask,
such a value also loses its first digit when it is written to the control,
and once the mask has run the 9 remaining digits are written back to the
model, so the control stays invalid. A value made only of 0s and 1s (for
example a numeric `0`) is dropped entirely and becomes empty, which is valid
on an optional field.
No North American area code starts with 0 or 1, but check stored phone and
fax data before upgrading.
- `common-phone-number`: `N` is now a mask token (a digit 2-9). A custom
`[mask]` that uses a literal `N` will treat it as that token. No app in the
BC MOH enrolment repos passes a custom `[mask]`.

### Added

- None.

### Fixed

- `PhoneNumberComponent` (`common-phone-number`): the default mask is now
`(N00) 000-0000`, where `N` accepts 2-9 only, so a typed leading 1 or 0 is
ignored and `+1 (234) 567-8978` stores `2345678978`. The validator now
requires `PhoneNumberRegEx` (10 digits, first digit 2-9) as well as the digit
count. The stock ngx-mask tokens are kept, so a custom `[mask]` without `N`
behaves as before.

### Consumer action required

- Apps using `common-phone-number`: upgrade to 2.5.4. If the app loads saved
phone or fax numbers into the control, confirm none start with 0 or 1.
- An app pinned by its lockfile must update the lockfile
(`npm install moh-common-lib-angular@2.5.4`).

## 2.5.3 (2026-10-13)

Fixes PDF uploads in `common-file-uploader`. In an app that never sets
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "moh-common-lib-angular",
"version": "2.5.3",
"version": "2.5.4",
"scripts": {
"ng": "ng",
"start": "ng serve",
Expand Down
2 changes: 1 addition & 1 deletion projects/common-lib/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "moh-common-lib-angular",
"version": "2.5.3",
"version": "2.5.4",
"description": "Shared Angular component library for BC Ministry of Health applications.",
"keywords": [
"bcgov",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ const meta: Meta<FileUploaderComponent> = {
title: 'Components/Upload/FileUploader',
component: FileUploaderComponent,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Drag-and-drop or browse upload for images and PDFs. Each PDF page ' +
'becomes one image thumbnail. PDF support needs pdfjs-dist, an ' +
'optional peer dependency. PdfService.workerSrc is optional: set it ' +
'to load the pdfjs worker from a URL, or leave it unset and the ' +
'worker is bundled with the app and runs on the main thread. The ' +
'component shows no message for a file it cannot read: it emits ' +
'(errorDocument) with a CommonImage whose error is CannotOpenPDF, ' +
'and the host app decides what to display.',
},
},
},
decorators: [
moduleMetadata({
imports: [FormsModule],
Expand Down Expand Up @@ -37,7 +52,9 @@ export const Empty: Story = {
id: 'docs',
images: [],
required: true,
instructionText: 'Please upload required ID documents.',
instructionText:
'Please upload required ID documents. Drop a PDF here to see one ' +
'thumbnail per page.',
},
};

Expand Down
4 changes: 3 additions & 1 deletion src/lib/components/phone-number/phone-number.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
(blur)="onBlur($event)"
[placeholder]="placeholder"
[mask]="mask"
[patterns]="patterns"
[prefix]="allowInternational ? '+1 ' : ''"
[required]="required"
[disabled]="disabled" />
Expand All @@ -20,7 +21,8 @@
[displayError]="
(controlDir &&
!disabled &&
(controlDir.dirty || controlDir.touched) && !!controlDir.errors) ??
(controlDir.dirty || controlDir.touched) &&
!!controlDir.errors) ??
false
">
<div *ngIf="controlDir?.errors?.['required']">
Expand Down
144 changes: 132 additions & 12 deletions src/lib/components/phone-number/phone-number.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,23 @@ describe('Phone-Number.Component self validation', () => {
fixture.detectChanges();
}

// One input event per character, caret at the end, as a keyboard produces.
async function typeKeys(text: string) {
const inputEl: HTMLInputElement =
fixture.nativeElement.querySelector('input');
inputEl.focus();
for (const ch of text) {
inputEl.value += ch;
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
inputEl.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.detectChanges();
}
inputEl.dispatchEvent(new Event('blur'));
await fixture.whenStable();
fixture.detectChanges();
}

function renderedError(): string | null {
const el = fixture.nativeElement.querySelector('common-error-container');
return el ? el.textContent.trim() : null;
Expand Down Expand Up @@ -397,21 +414,75 @@ describe('Phone-Number.Component self validation', () => {
expect(ngForm.valid).toBe(true);
});

['0', '000'].forEach((zeros) => {
it(`rejects the partial number ${zeros} with a numeric model`, async () => {
await setup(true, (host) => (host.phone = 2501234567));
// Observed: without this extra settle, the 0 and 000 tests pass on a
// validator that treats 0 as empty.
await fixture.whenStable();
fixture.detectChanges();
it('rejects a numeric 0 model as a partial number, not as empty', async () => {
// With the mask displayed, the N token drops the 0, the model becomes ''
// and an empty optional field is valid, so this case runs unmasked.
await setup(true, (host) => {
host.displayMask = false;
host.phone = 0;
Comment thread
istevens507 marked this conversation as resolved.
});
await fixture.whenStable();
fixture.detectChanges();
fixture.nativeElement
.querySelector('input')
.dispatchEvent(new Event('blur'));
fixture.detectChanges();

await typeDigits(zeros);
expect(ngForm.controls['phone'].errors).toEqual({
incompleteValue: true,
});
expect(ngForm.valid).toBe(false);
expect(renderedError()).toBe('Phone Number does not appear to be valid.');
});

expect(ngForm.controls['phone'].errors).toEqual({
incompleteValue: true,
describe('when typed key by key', () => {
it('keeps the first digit after +1 as the area code start', async () => {
await setup();
await typeKeys('+1 (234) 567-8978');

const inputEl: HTMLInputElement =
fixture.nativeElement.querySelector('input');
expect(inputEl.value).toBe('+1 (234) 567-8978');
expect(ngForm.controls['phone'].value).toBe('2345678978');
expect(ngForm.controls['phone'].errors).toBeNull();
});

['1', '0'].forEach((lead) => {
it(`ignores a leading ${lead} before the area code`, async () => {
await setup();
await typeKeys(`${lead}2345678978`);

const inputEl: HTMLInputElement =
fixture.nativeElement.querySelector('input');
expect(inputEl.value).toBe('+1 (234) 567-8978');
expect(ngForm.controls['phone'].value).toBe('2345678978');
expect(ngForm.controls['phone'].errors).toBeNull();
});
});
});

describe('when the value is set without typing', () => {
['1234567897', '0234567890'].forEach((value) => {
it(`rejects ${value} because the area code starts with ${value[0]}`, async () => {
await setup();
const control = ngForm.controls['phone'];
control.setValue(value);
control.markAsTouched();
fixture.detectChanges();

expect(control.errors).toEqual({ incompleteValue: true });
expect(ngForm.valid).toBe(false);
expect(renderedError()).toBe(
'Phone Number does not appear to be valid.'
);

// Once the mask has run, it drops the first digit and writes the 9
// remaining digits back to the model, which stays invalid.
await fixture.whenStable();
fixture.detectChanges();
expect(control.value).toBe(value.slice(1));
expect(control.errors).toEqual({ incompleteValue: true });
});
expect(ngForm.valid).toBe(false);
expect(renderedError()).toBe('Phone Number does not appear to be valid.');
});
});

Expand All @@ -428,6 +499,16 @@ describe('Phone-Number.Component self validation', () => {
expect(ngForm.valid).toBe(true);
});

it('rejects ten digits starting with 1', async () => {
await setup(true, unmasked);
await typeDigits('1234567890');

expect(ngForm.controls['phone'].errors).toEqual({
incompleteValue: true,
});
expect(ngForm.valid).toBe(false);
});

it('rejects a value with no digits', async () => {
await setup(true, unmasked);
await typeDigits('abcdefghij');
Expand All @@ -439,3 +520,42 @@ describe('Phone-Number.Component self validation', () => {
});
});
});

@Component({
template: `
<form #f="ngForm">
<common-phone-number
name="phone"
mask="SS-000"
[(ngModel)]="phone"></common-phone-number>
</form>
`,
imports: [PhoneNumberComponent, FormsModule],
})
class PhoneCustomMaskHostComponent {
phone = '';
}

describe('Phone-Number.Component with a custom mask', () => {
it('keeps the stock ngx-mask letter token S', async () => {
TestBed.configureTestingModule({
imports: [PhoneCustomMaskHostComponent],
providers: [provideNgxMask()],
});
const fixture = TestBed.createComponent(PhoneCustomMaskHostComponent);
fixture.detectChanges();
await fixture.whenStable();
const inputEl: HTMLInputElement =
fixture.nativeElement.querySelector('input');
inputEl.focus();
for (const ch of 'AB-123') {
inputEl.value += ch;
inputEl.dispatchEvent(new Event('input'));
await fixture.whenStable();
fixture.detectChanges();
}

expect(inputEl.value).toBe('+1 AB-123');
expect(fixture.componentInstance.phone).toBe('AB123');
});
});
65 changes: 65 additions & 0 deletions src/lib/components/phone-number/phone-number.component.stories.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
import { FormsModule } from '@angular/forms';
import { PhoneNumberComponent } from './phone-number.component';

// Self-validation (incompleteValue) only registers once the component has an
// ngModel or formControl attached to it, so the Validation story wires one up
// through a render() template rather than relying on Storybook's default
// arg-to-input binding.
const meta: Meta<PhoneNumberComponent> = {
title: 'Components/Contact/PhoneNumber',
component: PhoneNumberComponent,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [FormsModule],
}),
],
parameters: {
docs: {
description: {
component:
'A North American phone number. To be valid, a non-empty value ' +
'must contain exactly 10 digits and the first digit (the area code) ' +
'must be 2-9; an empty value is valid unless the field is required. ' +
'With the default mask the model holds the digits only. The "+1 " ' +
'shown when allowInternational is true is display only and is ' +
'never stored, so typing +1, or a leading 1 or 0, is ignored: ' +
'"+1 (234) 567-8978" stores 2345678978. With displayMask false the ' +
'model keeps what was typed (250.123.4567) and only validation ' +
'reads the digits. A partial number, or one starting with 0 or 1, ' +
'is invalid and shows "<label> does not appear to be valid."; ' +
'a value made only of 0s and 1s is dropped and becomes empty. In a ' +
'custom [mask], N means a digit 2-9 and the stock ngx-mask tokens ' +
'(0 any digit, S a letter, A a letter or digit) still work; the ' +
'default mask is (N00) 000-0000.',
},
},
},
};

export default meta;
Expand All @@ -28,8 +60,41 @@ export const Prefilled: Story = {
};

export const NoMask: Story = {
parameters: {
docs: {
description: {
story:
'With displayMask false the field takes free text, but a bound ' +
'control still needs exactly 10 digits starting 2-9. Other ' +
'characters are ignored, so 250.123.4567 is valid and ' +
'+1 250 123 4567 (11 digits) is not.',
},
},
},
args: {
label: 'Mobile',
displayMask: false,
},
};

export const Validation: Story = {
parameters: {
docs: {
description: {
story:
'Bound with [(ngModel)], so the component validates itself. Type ' +
'+1 (234) 567-8978 to see the +1 ignored and the stored value ' +
'below. Type a partial number and tab out to see "Mobile does not ' +
'appear to be valid." appear.',
},
},
},
render: () => ({
props: { value: '' },
template: `
<common-phone-number name="mobile" label="Mobile"
[(ngModel)]="value"></common-phone-number>
<p>Stored value: "{{ value }}"</p>
`,
}),
};
Loading
Loading