Skip to content

Commit 5a72cdf

Browse files
committed
fix(#3072): angular reset and value binding issue
1 parent 1029f83 commit 5a72cdf

File tree

12 files changed

+417
-6
lines changed

12 files changed

+417
-6
lines changed

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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 { booleanAttribute, Component, Input, ElementRef } from "@angular/core";
44
import { ControlValueAccessor } from "@angular/forms";
55

66
@Component({
@@ -87,12 +87,71 @@ export abstract class GoabControlValueAccessor
8787
}
8888
}
8989

90+
/**
91+
* Returns the CSS selector for the native element that this component wraps.
92+
* Used by writeValue to update the element's value attribute.
93+
* Override this method in subclasses that use the default writeValue implementation.
94+
* @returns {string} The CSS selector (e.g., "goa-input", "goa-textarea").
95+
*/
96+
protected getElementSelector(): string {
97+
return "";
98+
}
99+
100+
/**
101+
* Returns the ElementRef for this component.
102+
* Override this method in subclasses that use the default writeValue implementation.
103+
* @returns {ElementRef} The ElementRef instance.
104+
*/
105+
protected getElementRef(): ElementRef {
106+
return null as any;
107+
}
108+
109+
/**
110+
* Returns the name of the attribute to set when writing a value.
111+
* Defaults to "value". Override to use a different attribute (e.g., "checked" for checkboxes).
112+
* @returns {string} The attribute name.
113+
*/
114+
protected getValueAttributeName(): string {
115+
return "value";
116+
}
117+
118+
/**
119+
* Converts the value to a string for setting the attribute.
120+
* Override this method to customize how values are converted (e.g., for boolean checkboxes).
121+
* @param {unknown} value - The value to convert.
122+
* @returns {string} The string representation of the value.
123+
*/
124+
protected convertValueToAttribute(value: unknown): string {
125+
if (value === null || value === undefined || value === "") {
126+
return "";
127+
}
128+
return String(value);
129+
}
130+
131+
/**
132+
* Hook called before setting the DOM attribute.
133+
* Override to perform additional actions (e.g., setting this.checked for checkboxes).
134+
* @param {unknown} _value - The value being written.
135+
*/
136+
protected beforeWriteValue(_value: unknown): void {
137+
// Default: no action
138+
}
139+
90140
/**
91141
* Writes a new value to the form control.
92142
* @param {unknown} value - The value to write.
93143
*/
94144
public writeValue(value: unknown): void {
95145
this.value = value;
146+
this.beforeWriteValue(value);
147+
148+
const el: HTMLElement | null = this.getElementRef()?.nativeElement?.querySelector(this.getElementSelector());
149+
150+
if (el) {
151+
const attributeName = this.getValueAttributeName();
152+
const attributeValue = this.convertValueToAttribute(value);
153+
el.setAttribute(attributeName, attributeValue);
154+
}
96155
}
97156

98157
/**

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,49 @@ describe("GoabCheckbox", () => {
106106

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

111154
@Component({

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
forwardRef,
99
TemplateRef,
1010
booleanAttribute,
11+
ElementRef,
1112
} from "@angular/core";
1213
import { NG_VALUE_ACCESSOR } from "@angular/forms";
1314
import { NgIf, NgTemplateOutlet } from "@angular/common";
@@ -60,7 +61,7 @@ export class GoabCheckbox extends GoabControlValueAccessor {
6061
@Input({ transform: booleanAttribute }) indeterminate?: boolean;
6162
@Input() text?: string;
6263
// ** NOTE: can we just use the base component for this?
63-
@Input() override value?: string | number | boolean;
64+
@Input() override value?: string | number | boolean | null;
6465
@Input() ariaLabel?: string;
6566
@Input() description!: string | TemplateRef<any>;
6667
@Input() reveal?: TemplateRef<any>;
@@ -69,6 +70,34 @@ export class GoabCheckbox extends GoabControlValueAccessor {
6970

7071
@Output() onChange = new EventEmitter<GoabCheckboxOnChangeDetail>();
7172

73+
constructor(protected elementRef: ElementRef) {
74+
super();
75+
}
76+
77+
protected override getElementSelector(): string {
78+
return "goa-checkbox";
79+
}
80+
81+
protected override getElementRef(): ElementRef {
82+
return this.elementRef;
83+
}
84+
85+
protected override getValueAttributeName(): string {
86+
return "checked";
87+
}
88+
89+
protected override convertValueToAttribute(value: unknown): string {
90+
if (value === null || value === undefined || value === false || value === "") {
91+
return "false";
92+
}
93+
return "true";
94+
}
95+
96+
protected override beforeWriteValue(value: unknown): void {
97+
this.value = value as string | number | boolean | null;
98+
this.checked = !!value;
99+
}
100+
72101
getDescriptionAsString(): string {
73102
return typeof this.description === "string" ? this.description : "";
74103
}

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,79 @@ describe("GoABDropdown", () => {
149149
);
150150
expect(onChangeMock).toHaveBeenCalled();
151151
});
152+
153+
describe("writeValue", () => {
154+
it("should set value attribute when writeValue is called with a value", () => {
155+
const dropdownComponent = fixture.debugElement.query(By.css("goab-dropdown")).componentInstance;
156+
const dropdownElement = fixture.debugElement.query(By.css("goa-dropdown")).nativeElement;
157+
158+
dropdownComponent.writeValue("red");
159+
expect(dropdownElement.getAttribute("value")).toBe("red");
160+
161+
dropdownComponent.writeValue("blue");
162+
expect(dropdownElement.getAttribute("value")).toBe("blue");
163+
});
164+
165+
it("should set value attribute to empty string when writeValue is called with null", () => {
166+
const dropdownComponent = fixture.debugElement.query(By.css("goab-dropdown")).componentInstance;
167+
const dropdownElement = fixture.debugElement.query(By.css("goa-dropdown")).nativeElement;
168+
169+
// First set a value
170+
dropdownComponent.writeValue("red");
171+
expect(dropdownElement.getAttribute("value")).toBe("red");
172+
173+
// Then clear it
174+
dropdownComponent.writeValue(null);
175+
expect(dropdownElement.getAttribute("value")).toBe("");
176+
});
177+
178+
it("should update component value property", () => {
179+
const dropdownComponent = fixture.debugElement.query(By.css("goab-dropdown")).componentInstance;
180+
181+
dropdownComponent.writeValue("yellow");
182+
expect(dropdownComponent.value).toBe("yellow");
183+
184+
dropdownComponent.writeValue(null);
185+
expect(dropdownComponent.value).toBe(null);
186+
});
187+
});
188+
189+
describe("_onChange", () => {
190+
it("should update component value when user selects an option", () => {
191+
const dropdownComponent = fixture.debugElement.query(By.css("goab-dropdown")).componentInstance;
192+
const dropdownElement = fixture.debugElement.query(By.css("goa-dropdown")).nativeElement;
193+
194+
fireEvent(
195+
dropdownElement,
196+
new CustomEvent("_change", {
197+
detail: { name: component.name, value: "yellow" },
198+
}),
199+
);
200+
201+
expect(dropdownComponent.value).toBe("yellow");
202+
});
203+
204+
it("should update value to null when cleared", () => {
205+
const dropdownComponent = fixture.debugElement.query(By.css("goab-dropdown")).componentInstance;
206+
const dropdownElement = fixture.debugElement.query(By.css("goa-dropdown")).nativeElement;
207+
208+
// Set initial value
209+
fireEvent(
210+
dropdownElement,
211+
new CustomEvent("_change", {
212+
detail: { name: component.name, value: "red" },
213+
}),
214+
);
215+
expect(dropdownComponent.value).toBe("red");
216+
217+
// Clear value
218+
fireEvent(
219+
dropdownElement,
220+
new CustomEvent("_change", {
221+
detail: { name: component.name, value: "" },
222+
}),
223+
);
224+
expect(dropdownComponent.value).toBe(null);
225+
});
226+
});
152227
});

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Output,
88
booleanAttribute,
99
forwardRef,
10+
ElementRef,
1011
} from "@angular/core";
1112
import { NG_VALUE_ACCESSOR } from "@angular/forms";
1213
import { GoabControlValueAccessor } from "../base.component";
@@ -18,7 +19,7 @@ import { GoabControlValueAccessor } from "../base.component";
1819
template: `
1920
<goa-dropdown
2021
[attr.name]="name"
21-
[value]="value"
22+
[attr.value]="value"
2223
[attr.arialabel]="ariaLabel"
2324
[attr.arialabelledby]="ariaLabelledBy"
2425
[disabled]="disabled"
@@ -73,11 +74,26 @@ export class GoabDropdown extends GoabControlValueAccessor {
7374

7475
@Output() onChange = new EventEmitter<GoabDropdownOnChangeDetail>();
7576

77+
constructor(protected elementRef: ElementRef) {
78+
super();
79+
}
80+
81+
protected override getElementSelector(): string {
82+
return "goa-dropdown";
83+
}
84+
85+
protected override getElementRef(): ElementRef {
86+
return this.elementRef;
87+
}
88+
7689
_onChange(e: Event) {
7790
const detail = (e as CustomEvent<GoabDropdownOnChangeDetail>).detail;
91+
92+
// Update the local value first to stay in sync
93+
this.value = detail.value || null;
7894
this.onChange.emit(detail);
7995

8096
this.markAsTouched();
8197
this.fcChange?.(detail.value || "");
8298
}
83-
}
99+
}

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,52 @@ describe("GoABInput", () => {
261261
expect(trailingContent).toBeTruthy();
262262
expect(trailingContent.textContent).toContain("Trailing Content");
263263
});
264+
265+
describe("writeValue", () => {
266+
it("should set value attribute when writeValue is called", () => {
267+
const inputComponent = fixture.debugElement.query(By.css("goab-input")).componentInstance;
268+
const inputElement = fixture.debugElement.query(By.css("goa-input")).nativeElement;
269+
270+
inputComponent.writeValue("new value");
271+
expect(inputElement.getAttribute("value")).toBe("new value");
272+
273+
inputComponent.writeValue("another value");
274+
expect(inputElement.getAttribute("value")).toBe("another value");
275+
});
276+
277+
it("should set value attribute to empty string when writeValue is called with null or empty", () => {
278+
const inputComponent = fixture.debugElement.query(By.css("goab-input")).componentInstance;
279+
const inputElement = fixture.debugElement.query(By.css("goa-input")).nativeElement;
280+
281+
// First set a value
282+
inputComponent.writeValue("some value");
283+
expect(inputElement.getAttribute("value")).toBe("some value");
284+
285+
// Then clear it with null
286+
inputComponent.writeValue(null);
287+
expect(inputElement.getAttribute("value")).toBe("");
288+
289+
// Set again and clear with undefined
290+
inputComponent.writeValue("test");
291+
inputComponent.writeValue(undefined);
292+
expect(inputElement.getAttribute("value")).toBe("");
293+
294+
// Set again and clear with empty string
295+
inputComponent.writeValue("test2");
296+
inputComponent.writeValue("");
297+
expect(inputElement.getAttribute("value")).toBe("");
298+
});
299+
300+
it("should update component value property", () => {
301+
const inputComponent = fixture.debugElement.query(By.css("goab-input")).componentInstance;
302+
303+
inputComponent.writeValue("updated");
304+
expect(inputComponent.value).toBe("updated");
305+
306+
inputComponent.writeValue(null);
307+
expect(inputComponent.value).toBe(null);
308+
});
309+
});
264310
});
265311

266312
@Component({

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
booleanAttribute,
1919
numberAttribute,
2020
TemplateRef,
21+
ElementRef,
2122
} from "@angular/core";
2223
import { NG_VALUE_ACCESSOR } from "@angular/forms";
2324
import { GoabControlValueAccessor } from "../base.component";
@@ -139,13 +140,25 @@ export class GoabInput extends GoabControlValueAccessor implements OnInit {
139140

140141
handleTrailingIconClick = false;
141142

143+
constructor(protected elementRef: ElementRef) {
144+
super();
145+
}
146+
142147
ngOnInit() {
143148
this.handleTrailingIconClick = this.onTrailingIconClick.observed;
144149
if (typeof this.value === "number") {
145150
console.warn("For numeric values use goab-input-number.");
146151
}
147152
}
148153

154+
protected override getElementSelector(): string {
155+
return "goa-input";
156+
}
157+
158+
protected override getElementRef(): ElementRef {
159+
return this.elementRef;
160+
}
161+
149162
_onTrailingIconClick(_: Event) {
150163
if (this.handleTrailingIconClick) {
151164
this.onTrailingIconClick.emit();

0 commit comments

Comments
 (0)