Skip to content

Commit 02d74b5

Browse files
committed
fix(#3072): angular reset and value binding issue
1 parent ed95740 commit 02d74b5

File tree

14 files changed

+438
-65
lines changed

14 files changed

+438
-65
lines changed

libs/angular-components/src/lib/components/base.component.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { Spacing } from "@abgov/ui-components-common";
3-
import { booleanAttribute, Component, Input } from "@angular/core";
3+
import {
4+
booleanAttribute,
5+
Component,
6+
Input,
7+
ElementRef,
8+
ViewChild,
9+
Renderer2,
10+
} from "@angular/core";
411
import { ControlValueAccessor } from "@angular/forms";
512

613
@Component({
@@ -28,9 +35,25 @@ export abstract class GoabBaseComponent {
2835
* - Supports `disabled="true"` and `error="true` attribute bindings for convenience.
2936
* - Handles form control value changes and touch events via `ControlValueAccessor` methods.
3037
* - Allows for flexible value types (`unknown`), making it suitable for various data types like integers, dates, or booleans.
38+
* - Uses ViewChild to capture a reference to the native GOA web component element via `#goaComponentRef`.
39+
* - Uses Renderer2 for safe DOM manipulation (compatible with SSR and security best practices).
3140
*
3241
* ## Usage
33-
* Extend this class to create custom form controls. Implement additional functionality as needed for your specific use case.
42+
* Extend this class to create custom form controls. Child components must:
43+
* 1. Add `#goaComponentRef` template reference to their `goa-*` element in the template
44+
* 2. Inject `Renderer2` in their constructor and pass it to `super(renderer)`
45+
*
46+
* ### Example:
47+
* ```typescript
48+
* @Component({
49+
* template: `<goa-input #goaComponentRef [value]="value" ...></goa-input>`
50+
* })
51+
* export class GoabInput extends GoabControlValueAccessor {
52+
* constructor(private cdr: ChangeDetectorRef, renderer: Renderer2) {
53+
* super(renderer); // Required: pass Renderer2 to base class
54+
* }
55+
* }
56+
* ```
3457
*
3558
* ## Properties
3659
* - `id?`: An optional identifier for the component.
@@ -40,10 +63,11 @@ export abstract class GoabBaseComponent {
4063
*
4164
* ## Methods
4265
* - `markAsTouched()`: Marks the component as touched and triggers the `fcTouched` callback if defined.
43-
* - `writeValue(value: unknown)`: Writes a new value to the form control.
66+
* - `writeValue(value: unknown)`: Writes a new value to the form control (can be overridden for special behavior like checkbox).
4467
* - `registerOnChange(fn: any)`: Registers a function to handle changes in the form control value.
4568
* - `registerOnTouched(fn: any)`: Registers a function to handle touch events on the form control.
4669
* - `setDisabledState?(isDisabled: boolean)`: Sets the disabled state of the component.
70+
* - `convertValueToString(value: unknown)`: Converts a value to a string for DOM attribute assignment (can be overridden).
4771
*
4872
* ## Callbacks
4973
* - `fcChange?`: A function to handle changes in the form control value.
@@ -87,12 +111,42 @@ export abstract class GoabControlValueAccessor
87111
}
88112
}
89113

114+
/**
115+
* Reference to the native GOA web component element.
116+
* Child templates should declare `#goaComponentRef` on the `goa-*` element.
117+
* The base class captures it here so children don't need their own ViewChild.
118+
*/
119+
@ViewChild("goaComponentRef", { static: false, read: ElementRef })
120+
protected goaComponentRef?: ElementRef;
121+
122+
constructor(protected renderer: Renderer2) {
123+
super();
124+
}
125+
126+
/**
127+
* Convert an arbitrary value into a string for DOM attribute assignment.
128+
* Child classes can override when they need special formatting.
129+
* @param value The value to convert
130+
* @returns string representation or empty string for nullish/empty
131+
*/
132+
protected convertValueToString(value: unknown): string {
133+
if (value === null || value === undefined || value === "") {
134+
return "";
135+
}
136+
return String(value);
137+
}
138+
90139
/**
91140
* Writes a new value to the form control.
92141
* @param {unknown} value - The value to write.
93142
*/
94143
public writeValue(value: unknown): void {
95144
this.value = value;
145+
const el = this.goaComponentRef?.nativeElement as HTMLElement | undefined;
146+
if (el) {
147+
const stringValue = this.convertValueToString(value);
148+
this.renderer.setAttribute(el, "value", stringValue);
149+
}
96150
}
97151

98152
/**

libs/angular-components/src/lib/components/checkbox-list/checkbox-list.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
forwardRef,
99
OnInit,
1010
ChangeDetectorRef,
11+
Renderer2,
1112
} from "@angular/core";
1213
import { NG_VALUE_ACCESSOR } from "@angular/forms";
1314
import { CommonModule } from "@angular/common";
@@ -51,8 +52,11 @@ export class GoabCheckboxList extends GoabControlValueAccessor implements OnInit
5152
// Override value to handle string arrays consistently
5253
@Input() override value?: string[];
5354

54-
constructor(private cdr: ChangeDetectorRef) {
55-
super();
55+
constructor(
56+
private cdr: ChangeDetectorRef,
57+
renderer: Renderer2,
58+
) {
59+
super(renderer);
5660
}
5761

5862
ngOnInit(): void {

libs/angular-components/src/lib/components/checkbox/checkbox.spec.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe("GoabCheckbox", () => {
9393
expect(checkboxElement.getAttribute("maxwidth")).toBe("480px");
9494
});
9595

96-
it("should handle onChange event", fakeAsync(() => {
96+
it("should handle onChange event", async () => {
9797
const onChange = jest.spyOn(component, "onChange");
9898

9999
const checkboxElement = fixture.debugElement.query(
@@ -108,7 +108,50 @@ describe("GoabCheckbox", () => {
108108
);
109109

110110
expect(onChange).toHaveBeenCalled();
111-
}));
111+
});
112+
113+
describe("writeValue", () => {
114+
it("should set checked attribute to true when value is truthy", () => {
115+
const checkboxComponent = fixture.debugElement.query(By.css("goab-checkbox")).componentInstance;
116+
const checkboxElement = fixture.debugElement.query(By.css("goa-checkbox")).nativeElement;
117+
118+
checkboxComponent.writeValue(true);
119+
expect(checkboxElement.getAttribute("checked")).toBe("true");
120+
121+
checkboxComponent.writeValue("some value");
122+
expect(checkboxElement.getAttribute("checked")).toBe("true");
123+
124+
checkboxComponent.writeValue(1);
125+
expect(checkboxElement.getAttribute("checked")).toBe("true");
126+
});
127+
128+
it("should set checked attribute to false when value is falsy", () => {
129+
const checkboxComponent = fixture.debugElement.query(By.css("goab-checkbox")).componentInstance;
130+
const checkboxElement = fixture.debugElement.query(By.css("goa-checkbox")).nativeElement;
131+
132+
checkboxComponent.writeValue(false);
133+
expect(checkboxElement.getAttribute("checked")).toBe("false");
134+
135+
checkboxComponent.writeValue(null);
136+
expect(checkboxElement.getAttribute("checked")).toBe("false");
137+
138+
checkboxComponent.writeValue(undefined);
139+
expect(checkboxElement.getAttribute("checked")).toBe("false");
140+
141+
checkboxComponent.writeValue("");
142+
expect(checkboxElement.getAttribute("checked")).toBe("false");
143+
});
144+
145+
it("should update component value property", () => {
146+
const checkboxComponent = fixture.debugElement.query(By.css("goab-checkbox")).componentInstance;
147+
148+
checkboxComponent.writeValue(true);
149+
expect(checkboxComponent.value).toBe(true);
150+
151+
checkboxComponent.writeValue(null);
152+
expect(checkboxComponent.value).toBe(null);
153+
});
154+
});
112155
});
113156

114157
@Component({
@@ -136,7 +179,7 @@ describe("Checkbox with description slot", () => {
136179

137180
it("should render with slot description", fakeAsync(() => {
138181
TestBed.configureTestingModule({
139-
imports: [GoabCheckbox, ReactiveFormsModule, TestCheckboxWithDescriptionSlotComponent],
182+
imports: [TestCheckboxWithDescriptionSlotComponent, GoabCheckbox, ReactiveFormsModule],
140183
schemas: [CUSTOM_ELEMENTS_SCHEMA],
141184
}).compileComponents();
142185

@@ -155,7 +198,7 @@ describe("Checkbox with description slot", () => {
155198

156199
@Component({
157200
standalone: true,
158-
imports: [GoabCheckbox, ReactiveFormsModule],
201+
imports: [GoabCheckbox],
159202
template: `
160203
<goab-checkbox
161204
name="test"
@@ -177,7 +220,7 @@ describe("Checkbox with reveal slot", () => {
177220

178221
beforeEach(fakeAsync(() => {
179222
TestBed.configureTestingModule({
180-
imports: [GoabCheckbox, ReactiveFormsModule, TestCheckboxWithRevealSlotComponent],
223+
imports: [TestCheckboxWithRevealSlotComponent, GoabCheckbox, ReactiveFormsModule],
181224
schemas: [CUSTOM_ELEMENTS_SCHEMA],
182225
}).compileComponents();
183226

libs/angular-components/src/lib/components/checkbox/checkbox.ts

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
booleanAttribute,
1111
OnInit,
1212
ChangeDetectorRef,
13+
Renderer2,
1314
} from "@angular/core";
1415
import { NG_VALUE_ACCESSOR } from "@angular/forms";
1516
import { NgTemplateOutlet, CommonModule } from "@angular/common";
@@ -19,34 +20,35 @@ import { GoabControlValueAccessor } from "../base.component";
1920
standalone: true,
2021
selector: "goab-checkbox",
2122
template: ` <goa-checkbox
22-
*ngIf="isReady"
23-
[attr.name]="name"
24-
[checked]="checked"
25-
[disabled]="disabled"
26-
[attr.indeterminate]="indeterminate ? 'true' : undefined"
27-
[attr.error]="error"
28-
[attr.text]="text"
29-
[value]="value"
30-
[attr.testid]="testId"
31-
[attr.arialabel]="ariaLabel"
32-
[attr.description]="getDescriptionAsString()"
33-
[attr.revealarialabel]="revealArialLabel"
34-
[id]="id"
35-
[attr.maxwidth]="maxWidth"
36-
[attr.mt]="mt"
37-
[attr.mb]="mb"
38-
[attr.ml]="ml"
39-
[attr.mr]="mr"
40-
(_change)="_onChange($event)"
41-
>
42-
<ng-content />
43-
<div slot="description">
44-
<ng-container [ngTemplateOutlet]="getDescriptionAsTemplate()"></ng-container>
45-
</div>
46-
<div slot="reveal">
47-
<ng-container *ngIf="reveal" [ngTemplateOutlet]="reveal"></ng-container>
48-
</div>
49-
</goa-checkbox>`,
23+
#goaComponentRef
24+
*ngIf="isReady"
25+
[attr.name]="name"
26+
[checked]="checked"
27+
[disabled]="disabled"
28+
[attr.indeterminate]="indeterminate ? 'true' : undefined"
29+
[attr.error]="error"
30+
[attr.text]="text"
31+
[value]="value"
32+
[attr.testid]="testId"
33+
[attr.arialabel]="ariaLabel"
34+
[attr.description]="getDescriptionAsString()"
35+
[attr.revealarialabel]="revealArialLabel"
36+
[id]="id"
37+
[attr.maxwidth]="maxWidth"
38+
[attr.mt]="mt"
39+
[attr.mb]="mb"
40+
[attr.ml]="ml"
41+
[attr.mr]="mr"
42+
(_change)="_onChange($event)"
43+
>
44+
<ng-content />
45+
<div slot="description">
46+
<ng-container [ngTemplateOutlet]="getDescriptionAsTemplate()"></ng-container>
47+
</div>
48+
<div slot="reveal">
49+
<ng-container *ngIf="reveal" [ngTemplateOutlet]="reveal"></ng-container>
50+
</div>
51+
</goa-checkbox>`,
5052
schemas: [CUSTOM_ELEMENTS_SCHEMA],
5153
providers: [
5254
{
@@ -60,8 +62,11 @@ import { GoabControlValueAccessor } from "../base.component";
6062
export class GoabCheckbox extends GoabControlValueAccessor implements OnInit {
6163
isReady = false;
6264

63-
constructor(private cdr: ChangeDetectorRef) {
64-
super();
65+
constructor(
66+
private cdr: ChangeDetectorRef,
67+
renderer: Renderer2,
68+
) {
69+
super(renderer);
6570
}
6671

6772
ngOnInit(): void {
@@ -78,7 +83,7 @@ export class GoabCheckbox extends GoabControlValueAccessor implements OnInit {
7883
@Input({ transform: booleanAttribute }) indeterminate?: boolean;
7984
@Input() text?: string;
8085
// ** NOTE: can we just use the base component for this?
81-
@Input() override value?: string | number | boolean;
86+
@Input() override value?: string | number | boolean | null;
8287
@Input() ariaLabel?: string;
8388
@Input() description!: string | TemplateRef<any>;
8489
@Input() reveal?: TemplateRef<any>;
@@ -104,4 +109,15 @@ export class GoabCheckbox extends GoabControlValueAccessor implements OnInit {
104109
this.markAsTouched();
105110
this.fcChange?.(detail.binding === "check" ? detail.checked : detail.value || "");
106111
}
112+
113+
// Checkbox is a special case: it uses `checked` instead of `value`.
114+
override writeValue(value: string | number | boolean | null): void {
115+
this.value = value;
116+
this.checked = !!value;
117+
118+
const el = this.goaComponentRef?.nativeElement as HTMLElement | undefined;
119+
if (el) {
120+
this.renderer.setAttribute(el, "checked", this.checked ? "true" : "false");
121+
}
122+
}
107123
}

libs/angular-components/src/lib/components/date-picker/date-picker.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { GoabDatePickerInputType, GoabDatePickerOnChangeDetail } from "@abgov/ui-components-common";
1+
import {
2+
GoabDatePickerInputType,
3+
GoabDatePickerOnChangeDetail,
4+
} from "@abgov/ui-components-common";
25
import {
36
CUSTOM_ELEMENTS_SCHEMA,
47
Component,
@@ -10,6 +13,7 @@ import {
1013
HostListener,
1114
OnInit,
1215
ChangeDetectorRef,
16+
Renderer2,
1317
} from "@angular/core";
1418
import { NG_VALUE_ACCESSOR } from "@angular/forms";
1519
import { CommonModule } from "@angular/common";
@@ -78,8 +82,12 @@ export class GoabDatePicker extends GoabControlValueAccessor implements OnInit {
7882
this.fcChange?.(detail.value);
7983
}
8084

81-
constructor(protected elementRef: ElementRef, private cdr: ChangeDetectorRef) {
82-
super();
85+
constructor(
86+
protected elementRef: ElementRef,
87+
private cdr: ChangeDetectorRef,
88+
renderer: Renderer2,
89+
) {
90+
super(renderer);
8391
}
8492

8593
ngOnInit(): void {

0 commit comments

Comments
 (0)