From cf68fb041aa966bb59949846e880439d87900c75 Mon Sep 17 00:00:00 2001 From: Chris Olsen Date: Fri, 5 Dec 2025 14:31:11 -0700 Subject: [PATCH 01/13] fix: refactor public form --- apps/prs/react/src/routes/everything.tsx | 14 - apps/prs/web/src/app/App.svelte | 2 + apps/prs/web/src/routes/public-form.svelte | 376 ++++++++ .../src/lib/components/form/fieldset.spec.ts | 70 -- .../src/lib/components/form/fieldset.ts | 29 - .../form/public-subform-index.spec.ts | 106 --- .../components/form/public-subform.spec.ts | 167 ---- .../src/lib/components/form/task.spec.ts | 94 -- .../src/lib/components/index.ts | 1 - libs/common/src/index.ts | 2 +- libs/common/src/lib/messaging/messaging.ts | 70 +- .../src/lib/public-form-controller.spec.ts | 18 - libs/common/src/lib/public-form-controller.ts | 384 --------- libs/common/src/lib/public-form.ts | 34 + libs/common/src/lib/validators.ts | 40 +- libs/react-components/src/index.ts | 2 - .../src/lib/form/fieldset.tsx | 70 -- .../lib/form/public-subform-index.spec.tsx | 185 ---- .../src/lib/form/public-subform.spec.tsx | 228 ----- .../lib/use-public-form-controller.spec.ts | 128 --- .../src/lib/use-public-form-controller.ts | 107 --- libs/web-components/src/common/utils.ts | 17 +- .../checkbox-list/CheckboxList.svelte | 185 +--- .../src/components/checkbox/Checkbox.svelte | 160 +--- .../components/date-picker/DatePicker.svelte | 108 +-- .../src/components/dropdown/Dropdown.svelte | 67 +- .../src/components/form-item/FormItem.svelte | 82 -- .../src/components/form/Fieldset.svelte | 402 --------- .../src/components/form/Form.svelte | 803 ++++++++---------- .../src/components/form/FormPage.svelte | 260 ++---- .../src/components/form/FormSummary.svelte | 305 +++---- .../src/components/form/SubForm.svelte | 344 -------- .../src/components/form/SubFormIndex.svelte | 81 -- .../src/components/form/Subform.svelte | 70 ++ .../src/components/form/SubformForm.svelte | 11 + .../src/components/form/SubformList.svelte | 11 + .../src/components/input/Input.svelte | 30 +- .../src/components/link/Link.svelte | 4 +- .../components/radio-group/RadioGroup.svelte | 68 +- .../components/radio-item/RadioItem.svelte | 136 ++- .../src/components/text-area/TextArea.svelte | 64 +- libs/web-components/src/index.ts | 4 +- libs/web-components/src/types/relay-types.ts | 229 ----- public-form.md | 58 ++ 44 files changed, 1412 insertions(+), 4214 deletions(-) create mode 100644 apps/prs/web/src/routes/public-form.svelte delete mode 100644 libs/angular-components/src/lib/components/form/fieldset.spec.ts delete mode 100644 libs/angular-components/src/lib/components/form/fieldset.ts delete mode 100644 libs/angular-components/src/lib/components/form/public-subform-index.spec.ts delete mode 100644 libs/angular-components/src/lib/components/form/public-subform.spec.ts delete mode 100644 libs/angular-components/src/lib/components/form/task.spec.ts delete mode 100644 libs/common/src/lib/public-form-controller.spec.ts delete mode 100644 libs/common/src/lib/public-form-controller.ts create mode 100644 libs/common/src/lib/public-form.ts delete mode 100644 libs/react-components/src/lib/form/fieldset.tsx delete mode 100644 libs/react-components/src/lib/form/public-subform-index.spec.tsx delete mode 100644 libs/react-components/src/lib/form/public-subform.spec.tsx delete mode 100644 libs/react-components/src/lib/use-public-form-controller.spec.ts delete mode 100644 libs/react-components/src/lib/use-public-form-controller.ts delete mode 100644 libs/web-components/src/components/form/Fieldset.svelte delete mode 100644 libs/web-components/src/components/form/SubForm.svelte delete mode 100644 libs/web-components/src/components/form/SubFormIndex.svelte create mode 100644 libs/web-components/src/components/form/Subform.svelte create mode 100644 libs/web-components/src/components/form/SubformForm.svelte create mode 100644 libs/web-components/src/components/form/SubformList.svelte create mode 100644 public-form.md diff --git a/apps/prs/react/src/routes/everything.tsx b/apps/prs/react/src/routes/everything.tsx index ecd5d6fac..e03d98b7d 100644 --- a/apps/prs/react/src/routes/everything.tsx +++ b/apps/prs/react/src/routes/everything.tsx @@ -26,7 +26,6 @@ import { GoabDrawer, GoabDropdown, GoabDropdownItem, - GoabFieldset, GoabFileUploadCard, GoabFileUploadInput, GoabFilterChip, @@ -50,13 +49,6 @@ import { GoabPages, GoabPagination, GoabPopover, - GoabPublicForm, - GoabPublicFormPage, - GoabPublicFormSummary, - GoabPublicFormTask, - GoabPublicFormTaskList, - GoabPublicSubform, - GoabPublicSubformIndex, GoabRadioGroup, GoabRadioItem, GoabSideMenu, @@ -103,7 +95,6 @@ import { GoabFileUploadInputOnSelectFileDetail, GoabFileUploadOnCancelDetail, GoabFileUploadOnDeleteDetail, - GoabFormState, GoabFormStepStatus, GoabFormStepperOnChangeDetail, GoabIconButtonVariant, @@ -351,11 +342,6 @@ export function EverythingRoute(): JSX.Element { const [inputTrailingClicks, setInputTrailingClicks] = useState(0); const [numberInputTrailingClicks, setNumberInputTrailingClicks] = useState(0); const [menuAction, setMenuAction] = useState(); - const [publicFormEvents, setPublicFormEvents] = useState([]); - const [fieldsetContinueEvents, setFieldsetContinueEvents] = useState< - GoabFieldsetOnContinueDetail[] - >([]); - const [publicSubformEvents, setPublicSubformEvents] = useState([]); const logEvent = (name: string, detail: unknown) => { console.log(`[everything][react] ${name}`, detail); const entry: EventLogEntry = { name, detail, timestamp: new Date().toISOString() }; diff --git a/apps/prs/web/src/app/App.svelte b/apps/prs/web/src/app/App.svelte index 3f219265f..00244c212 100644 --- a/apps/prs/web/src/app/App.svelte +++ b/apps/prs/web/src/app/App.svelte @@ -3,6 +3,7 @@ import { Router, Route } from "svelte-routing"; import Issue2333 from "../routes/2333.svelte"; import Issue3279 from "../routes/3279.svelte"; + import PublicFormExample from "../routes/public-form.svelte"; @@ -12,4 +13,5 @@ + diff --git a/apps/prs/web/src/routes/public-form.svelte b/apps/prs/web/src/routes/public-form.svelte new file mode 100644 index 000000000..7baf85745 --- /dev/null +++ b/apps/prs/web/src/routes/public-form.svelte @@ -0,0 +1,376 @@ + + +
+ + + + + + + + + + + + + + {#each getPage(_state, "children", []) as child (child._id)} + + + + + {/each} +
{child["first-name"]}{child["last-name"]}Edit + Remove +
+ +
+ + + + + + + + + +
+ +
+
+ + + + This is a page that is read-only + + + Lorem ipsum dolor sit amet consectetur adipisicing elit. Possimus, odit! Voluptatem + dolor soluta aspernatur ipsa dolorem est iure vitae eaque ea, vero architecto + praesentium, quia excepturi, odio porro? Fuga, officia? + + + + + + + + + +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/libs/angular-components/src/lib/components/form/fieldset.spec.ts b/libs/angular-components/src/lib/components/form/fieldset.spec.ts deleted file mode 100644 index fa23cf960..000000000 --- a/libs/angular-components/src/lib/components/form/fieldset.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabFieldset } from "./fieldset"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { GoabFieldsetOnContinueDetail } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabFieldset], - template: ` - -
Test content
-
- `, -}) -class TestFieldsetComponent { - sectionTitle?: string; - dispatchOn: "change" | "continue" = "continue"; - id?: string; - - handleContinue(event: GoabFieldsetOnContinueDetail): void {/** do nothing **/} -} - -describe("GoabFieldSet", () => { - let fixture: ComponentFixture; - let component: TestFieldsetComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GoabFieldset, TestFieldsetComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestFieldsetComponent); - component = fixture.componentInstance; - - component.sectionTitle = "Test Section"; - component.dispatchOn = "continue"; - component.id = "test-fieldset"; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-fieldset")).nativeElement; - - expect(el?.getAttribute("section-title")).toBe(component.sectionTitle); - expect(el?.getAttribute("dispatch-on")).toBe(component.dispatchOn); - expect(el?.getAttribute("id")).toBe(component.id); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should emit onContinue event", () => { - fixture.detectChanges(); - const spy = jest.spyOn(component, "handleContinue"); - - const el = fixture.debugElement.query(By.css("goa-fieldset")).nativeElement; - const detail = { value: "test" }; - - el.dispatchEvent(new CustomEvent("_continue", { detail })); - expect(spy).toHaveBeenCalledWith(detail); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/fieldset.ts b/libs/angular-components/src/lib/components/form/fieldset.ts deleted file mode 100644 index 5428e87c7..000000000 --- a/libs/angular-components/src/lib/components/form/fieldset.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input, Output, EventEmitter } from "@angular/core"; -import { GoabFormDispatchOn, GoabFieldsetOnContinueDetail } from "@abgov/ui-components-common"; - -@Component({ - selector: 'goab-fieldset', - template: ` - - - `, - standalone: true, - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class GoabFieldset { - @Input() id?: string; - @Input() sectionTitle?: string; - @Input() dispatchOn: GoabFormDispatchOn = "continue"; - - @Output() onContinue = new EventEmitter(); - - _onContinue(event: Event) { - const detail = (event as CustomEvent).detail; - this.onContinue.emit(detail); - } -} diff --git a/libs/angular-components/src/lib/components/form/public-subform-index.spec.ts b/libs/angular-components/src/lib/components/form/public-subform-index.spec.ts deleted file mode 100644 index 7f6a995a5..000000000 --- a/libs/angular-components/src/lib/components/form/public-subform-index.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicSubformIndex } from "./public-subform-index"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { Spacing } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabPublicSubformIndex], - template: ` - -
Test content
-
- `, -}) -class TestPublicSubformIndexComponent { - heading = "Test Heading"; - sectionTitle = "Test Section Title"; - actionButtonText = "Add Item"; - buttonVisibility: "visible" | "hidden" = "visible"; - mt = "s" as Spacing; - mr = "m" as Spacing; - mb = "l" as Spacing; - ml = "xl" as Spacing; -} - -describe("GoabPublicSubformIndex", () => { - let fixture: ComponentFixture; - let component: TestPublicSubformIndexComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GoabPublicSubformIndex, TestPublicSubformIndexComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicSubformIndexComponent); - component = fixture.componentInstance; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform-index")).nativeElement; - - expect(el?.getAttribute("heading")).toBe(component.heading); - expect(el?.getAttribute("section-title")).toBe(component.sectionTitle); - expect(el?.getAttribute("action-button-text")).toBe(component.actionButtonText); - expect(el?.getAttribute("button-visibility")).toBe(component.buttonVisibility); - expect(el?.getAttribute("mt")).toBe(component.mt); - expect(el?.getAttribute("mr")).toBe(component.mr); - expect(el?.getAttribute("mb")).toBe(component.mb); - expect(el?.getAttribute("ml")).toBe(component.ml); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should have default values", () => { - const subformIndex = new GoabPublicSubformIndex(); - expect(subformIndex.heading).toBe(""); - expect(subformIndex.sectionTitle).toBe(""); - expect(subformIndex.actionButtonText).toBe(""); - expect(subformIndex.buttonVisibility).toBe("hidden"); - }); - - it("should have the correct slot attribute on host element", () => { - fixture.detectChanges(); - - const hostElement = fixture.debugElement.query(By.css("goab-public-subform-index")).nativeElement; - expect(hostElement.getAttribute("slot")).toBe("subform-index"); - }); - - it("should pass through different property values", () => { - component.heading = "Updated Heading"; - component.sectionTitle = "Updated Section"; - component.actionButtonText = "Add Another"; - component.buttonVisibility = "hidden"; - component.mt = "none"; - component.mr = "xs"; - component.mb = "2xl"; - component.ml = "3xl"; - - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform-index")).nativeElement; - - expect(el?.getAttribute("heading")).toBe("Updated Heading"); - expect(el?.getAttribute("section-title")).toBe("Updated Section"); - expect(el?.getAttribute("action-button-text")).toBe("Add Another"); - expect(el?.getAttribute("button-visibility")).toBe("hidden"); - expect(el?.getAttribute("mt")).toBe("none"); - expect(el?.getAttribute("mr")).toBe("xs"); - expect(el?.getAttribute("mb")).toBe("2xl"); - expect(el?.getAttribute("ml")).toBe("3xl"); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/public-subform.spec.ts b/libs/angular-components/src/lib/components/form/public-subform.spec.ts deleted file mode 100644 index 11d7b3074..000000000 --- a/libs/angular-components/src/lib/components/form/public-subform.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicSubform } from "./public-subform"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { Spacing } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabPublicSubform], - template: ` - -
Test content
-
- `, -}) -class TestPublicSubformComponent { - id = "test-subform"; - name = "test-subform-name"; - continueMsg = "Continue to next step"; - mt = "s" as Spacing; - mr = "m" as Spacing; - mb = "l" as Spacing; - ml = "xl" as Spacing; - - initEventCalled = false; - stateChangeEventCalled = false; - lastInitEvent: Event | null = null; - lastStateChangeEvent: Event | null = null; - - handleInit(event: Event): void { - this.initEventCalled = true; - this.lastInitEvent = event; - } - - handleStateChange(event: Event): void { - this.stateChangeEventCalled = true; - this.lastStateChangeEvent = event; - } -} - -describe("GoabPublicSubform", () => { - let fixture: ComponentFixture; - let component: TestPublicSubformComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GoabPublicSubform, TestPublicSubformComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicSubformComponent); - component = fixture.componentInstance; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - - expect(el?.getAttribute("id")).toBe(component.id); - expect(el?.getAttribute("name")).toBe(component.name); - expect(el?.getAttribute("continue-msg")).toBe(component.continueMsg); - expect(el?.getAttribute("mt")).toBe(component.mt); - expect(el?.getAttribute("mr")).toBe(component.mr); - expect(el?.getAttribute("mb")).toBe(component.mb); - expect(el?.getAttribute("ml")).toBe(component.ml); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should have default values", () => { - const subform = new GoabPublicSubform(); - expect(subform.id).toBe(""); - expect(subform.name).toBe(""); - expect(subform.continueMsg).toBe(""); - }); - - it("should emit onInit event", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - const testEvent = new CustomEvent("_init", { detail: { test: "data" } }); - - el.dispatchEvent(testEvent); - - expect(component.initEventCalled).toBe(true); - expect(component.lastInitEvent).toBeTruthy(); - }); - - it("should emit onStateChange event", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - const testEvent = new CustomEvent("_stateChange", { detail: { state: "changed" } }); - - el.dispatchEvent(testEvent); - - expect(component.stateChangeEventCalled).toBe(true); - expect(component.lastStateChangeEvent).toBeTruthy(); - }); - - it("should pass through different property values", () => { - component.id = "updated-id"; - component.name = "updated-name"; - component.continueMsg = "Updated continue message"; - component.mt = "none"; - component.mr = "xs"; - component.mb = "2xl"; - component.ml = "3xl"; - - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - - expect(el?.getAttribute("id")).toBe("updated-id"); - expect(el?.getAttribute("name")).toBe("updated-name"); - expect(el?.getAttribute("continue-msg")).toBe("Updated continue message"); - expect(el?.getAttribute("mt")).toBe("none"); - expect(el?.getAttribute("mr")).toBe("xs"); - expect(el?.getAttribute("mb")).toBe("2xl"); - expect(el?.getAttribute("ml")).toBe("3xl"); - }); - - it("should handle empty string attributes correctly", () => { - component.id = ""; - component.name = ""; - component.continueMsg = ""; - - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - - expect(el?.getAttribute("id")).toBe(""); - expect(el?.getAttribute("name")).toBe(""); - expect(el?.getAttribute("continue-msg")).toBe(""); - }); - - it("should emit multiple events in sequence", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - - // Reset counters - component.initEventCalled = false; - component.stateChangeEventCalled = false; - - // Emit init event - el.dispatchEvent(new CustomEvent("_init")); - expect(component.initEventCalled).toBe(true); - expect(component.stateChangeEventCalled).toBe(false); - - // Emit state change event - el.dispatchEvent(new CustomEvent("_stateChange")); - expect(component.stateChangeEventCalled).toBe(true); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/task.spec.ts b/libs/angular-components/src/lib/components/form/task.spec.ts deleted file mode 100644 index 45541fec8..000000000 --- a/libs/angular-components/src/lib/components/form/task.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicFormTask } from "./task"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { GoabPublicFormTaskStatus } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabPublicFormTask], - template: ` - -
Task content
-
- `, -}) -class TestPublicFormTaskComponent { - status: GoabPublicFormTaskStatus = "not-started"; -} - -describe("GoabPublicFormTask", () => { - let fixture: ComponentFixture; - let component: TestPublicFormTaskComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestPublicFormTaskComponent, GoabPublicFormTask], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicFormTaskComponent); - component = fixture.componentInstance; - }); - - it("should render with status property", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - - expect(el?.getAttribute("status")).toBe(component.status); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should have undefined status by default", () => { - const task = new GoabPublicFormTask(); - expect(task.status).toBeUndefined(); - }); - - it("should handle all valid status values", () => { - const statuses: GoabPublicFormTaskStatus[] = ["completed", "not-started", "cannot-start"]; - - statuses.forEach(status => { - component.status = status; - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.getAttribute("status")).toBe(status); - }); - }); - - it("should handle status changes", () => { - // Start with not-started - component.status = "not-started"; - fixture.detectChanges(); - - let el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.getAttribute("status")).toBe("not-started"); - - // Change to completed - component.status = "completed"; - fixture.detectChanges(); - - el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.getAttribute("status")).toBe("completed"); - - // Change to cannot-start - component.status = "cannot-start"; - fixture.detectChanges(); - - el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.getAttribute("status")).toBe("cannot-start"); - }); - - it("should render without status attribute when undefined", () => { - component.status = undefined as any; - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.hasAttribute("status")).toBeFalsy(); - }); -}); diff --git a/libs/angular-components/src/lib/components/index.ts b/libs/angular-components/src/lib/components/index.ts index 1204e799a..858e4cb62 100644 --- a/libs/angular-components/src/lib/components/index.ts +++ b/libs/angular-components/src/lib/components/index.ts @@ -37,7 +37,6 @@ export * from "./form/public-subform"; export * from "./form/public-subform-index"; export * from "./form/task"; export * from "./form/task-list"; -export * from "./form/fieldset"; export * from "./form-item/form-item"; export * from "./form-item/form-item-slot"; export * from "./form-step/form-step"; diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 1a4e965dc..68f2191cf 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -1,6 +1,6 @@ export * from "./lib/common"; export * from "./lib/experimental/common"; export * from "./lib/validators"; -export * from "./lib/public-form-controller"; export * from "./lib/temporary-notification-controller/temporary-notification-controller"; export * from "./lib/messaging/messaging"; +export type * from "./lib/public-form"; diff --git a/libs/common/src/lib/messaging/messaging.ts b/libs/common/src/lib/messaging/messaging.ts index df70e7292..6a855849b 100644 --- a/libs/common/src/lib/messaging/messaging.ts +++ b/libs/common/src/lib/messaging/messaging.ts @@ -3,40 +3,62 @@ export function dispatch( el: HTMLElement | Element | null | undefined, eventName: string, detail?: T, - opts?: { bubbles?: boolean }, + opts?: { bubbles?: boolean; cancelable?: boolean; timeout?: number }, ) { - if (!el) { - console.error("dispatch element is null"); - return; + const dispatch = () => { + try { + el?.dispatchEvent?.( + new CustomEvent(eventName, { + composed: true, + bubbles: opts?.bubbles, + cancelable: opts?.cancelable, + detail, + }), + ); + } catch (e) { + console.error("dispatch() error:", e); + } + }; + + if (opts?.timeout) { + setTimeout(dispatch, opts.timeout); + } else { + dispatch(); } - el.dispatchEvent( - new CustomEvent(eventName, { - composed: true, - bubbles: opts?.bubbles, - detail: detail, - }), - ); } -// Public helper function to relay messages export function relay( el: HTMLElement | Element | null | undefined, eventName: string, data?: T, - opts?: { bubbles?: boolean }, + opts?: { bubbles?: boolean; cancelable?: boolean; timeout?: number }, ) { if (!el) { - console.error("dispatch element is null"); + console.warn("relay() el is null | undefined"); return; } - el.dispatchEvent( - new CustomEvent<{ action: string; data?: T }>("msg", { - composed: true, - bubbles: opts?.bubbles, - detail: { - action: eventName, - data, - }, - }), - ); + + const dispatch = () => { + try { + el?.dispatchEvent?.( + new CustomEvent<{ action: string; data?: T }>("msg", { + composed: true, + bubbles: opts?.bubbles, + cancelable: opts?.cancelable, + detail: { + action: eventName, + data, + }, + }), + ); + } catch (e) { + console.error("relay() error:", e); + } + }; + + if (opts?.timeout) { + setTimeout(dispatch, opts.timeout); + } else { + dispatch(); + } } diff --git a/libs/common/src/lib/public-form-controller.spec.ts b/libs/common/src/lib/public-form-controller.spec.ts deleted file mode 100644 index f350fa92c..000000000 --- a/libs/common/src/lib/public-form-controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { PublicFormController } from "./public-form-controller"; - -describe("PublicFormController", () => { - const pfc = new PublicFormController("details"); - - describe("clean", () => { - const data = JSON.parse( - `{"uuid":"5cdcd318-0219-49a8-9dc1-cd2df5f02875","form":{"what-is-your-role":{"data":{"type":"details","fieldsets":{"role":{"name":"role","value":"Recipient","label":"Role","order":1}}}},"children-subform":{"data":{"type":"list","items":[{"uuid":"f47d2410-8ab9-4bb0-9571-9fc5211416a9","form":{"name":{"data":{"type":"details","fieldsets":{"firstName":{"name":"firstName","value":"asdf","label":"First name","order":1},"lastName":{"name":"lastName","value":"asdf","label":"Last name","order":2}}}},"alternate-name":{"data":{"type":"details","fieldsets":{"alternate-name":{"name":"alternate-name","value":"asdf","label":"Alternate name","order":1}}}},"dob":{"heading":""},"complete":{"heading":"Complete"}},"history":["name","alternate-name","dob","complete"],"editting":"","lastModified":"2025-01-28T19:50:56.255Z","status":"not-started"},{"uuid":"9d3c44fe-2a38-4faf-a684-8d93799bbf8b","form":{"name":{"data":{"type":"details","fieldsets":{"firstName":{"name":"firstName","value":"dsfdfgh","label":"First name","order":1},"lastName":{"name":"lastName","value":"dfgh","label":"Last name","order":2}}}},"alternate-name":{"data":{"type":"details","fieldsets":{"alternate-name":{"name":"alternate-name","value":"dfgh","label":"Alternate name","order":1}}}}},"history":["name","alternate-name","dob","complete"],"editting":"","lastModified":"2025-01-28T19:51:02.919Z","status":"not-started"}]}},"address":{"data":{"type":"details","fieldsets":{"city":{"name":"city","value":"dfgh","label":"City","order":1},"address":{"name":"address","value":"dfgh","label":"Address","order":2},"postal-code":{"name":"postal-code","value":"dfg","label":"Postal Code","order":3}}}},"summary":{"heading":"Summary"},"index":{"heading":""}},"history":["children-subform","address","summary"],"editting":"","status":"not-started"}`, - ); - - it("should clean the data", () => { - const cleaned = pfc.clean(data); - expect(data.history.length).toBe(3); - expect(data.history.length).toEqual(Object.keys(cleaned).length); - }); - }); -}); diff --git a/libs/common/src/lib/public-form-controller.ts b/libs/common/src/lib/public-form-controller.ts deleted file mode 100644 index 14f9fb72a..000000000 --- a/libs/common/src/lib/public-form-controller.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { FieldsetItemState, FieldValidator } from "./validators"; -import { - GoabFieldsetItemValue, GoabFormDispatchOn, -} from "./common"; -import { relay, dispatch } from "./messaging/messaging"; - -export type FormStatus = "not-started" | "incomplete" | "complete"; - -// Public type to define the state of the form -export type AppState = { - uuid: string; - form: Record>; - history: string[]; - editting: string; - lastModified?: Date; - status: FormStatus; - currentFieldset?: { id: T; dispatchType: GoabFormDispatchOn }; -}; - -export type Fieldset = { - heading: string; - data: - | { type: "details"; fieldsets: Record } - | { type: "list"; items: AppState[] }; -}; - -export class PublicFormController { - state?: AppState | AppState[]; - _formData?: Record = undefined; - _formRef?: HTMLElement = undefined; - private _isCompleting = false; - - constructor(private type: "details" | "list") {} - - // Obtain reference to the form element - init(e: Event) { - // FIXME: This condition should not be needed, but currently it is the only way to get things working - if (this._formRef) { - console.warn("init: form element has already been set"); - return; - } - this._formRef = (e as CustomEvent).detail.el; - - this.state = { - uuid: crypto.randomUUID(), - form: {}, - history: [], - editting: "", - status: "not-started", - }; - } - - initList(e: Event) { - this._formRef = (e as CustomEvent).detail.el; - this.state = []; - } - - // Public method to allow for the initialization of the state - initState(state?: string | AppState | AppState[], callback?: () => void) { - relay(this._formRef, "external::init:state", state); - - if (typeof state === "string") { - this.state = JSON.parse(state); - } else if (!Array.isArray(state)) { - this.state = state; - } - - if (callback) { - setTimeout(callback, 200); - } - } - - updateListState(e: Event) { - const detail = (e as CustomEvent).detail; - - if (!Array.isArray(detail.data)) { - return; - } - - this.state = detail.data; - } - - #updateObjectListState(detail: { data: AppState[]; index: number; id: string }) { - if (!Array.isArray(detail.data)) { - return; - } - - if (Array.isArray(this.state)) { - return; - } - - this.state = { - ...this.state, - form: { - ...(this.state?.form || {}), - [detail.id]: detail.data, - }, - } as AppState; - } - - updateObjectState(e: Event) { - if (Array.isArray(this.state)) { - return; - } - - const detail = (e as CustomEvent).detail; - if (detail.type === "list") { - // form state being updated with subform array data - this.state = { - ...this.state, - form: { ...(this.state?.form || {}), [detail.id]: detail.data }, - } as AppState; - } else { - // form state being updated with form data - this.state = { - ...this.state, - ...detail.data, - form: { ...(this.state?.form || {}), ...detail.data.form }, - history: detail.data.history, - } as AppState; - } - } - - getStateList(): Record[] { - if (!this.state) { - return []; - } - if (!Array.isArray(this.state)) { - console.warn( - "Utils:getStateList: unable to update the state of a non-multi form type", - this.state, - ); - return []; - } - if (this.state.length === 0) { - return []; - } - - return this.state.map((s) => { - return Object.values(s.form) - .filter((item) => { - return item?.data?.type === "details"; - }) - .map((item) => { - return (item.data.type === "details" && item.data?.fieldsets) || {}; - }) - .reduce( - (acc, item) => { - for (const [key, value] of Object.entries(item)) { - acc[key] = value.value; - } - return acc; - }, - {} as Record, - ); - }); - } - - // getStateItems(group: string): Record[] { - // if (Array.isArray(this.state)) { - // console.error( - // "Utils:getStateItems: unable to update the state of a multi form type", - // ); - // return []; - // } - // if (!this.state) { - // console.error("Utils:getStateItems: state has not yet been set"); - // return []; - // } - // - // const data = this.state.form[group].data; - // if (data.type !== "list") { - // return []; - // } - // - // return data.items.; - // } - - // Public method to allow for the retrieval of the state value - getStateValue(group: string, key: string): string { - if (Array.isArray(this.state)) { - console.error("getStateValue: unable to update the state of a multi form type"); - return ""; - } - if (!this.state) { - console.error("getStateValue: state has not yet been set"); - return ""; - } - - const data = this.state.form[group].data; - if (data.type !== "details") { - return ""; - } - - return data.fieldsets[key].value; - } - - // Public method to allow for the continuing to the next page - continueTo(next: T | undefined) { - if (!next) { - console.error("continueTo [name] is undefined"); - return; - } - // Relay the continue message to the form element which will - // set the visibility of the fieldsets - // FIXME: this makes a call to the subform instead of the form - relay<{ next: T }>(this._formRef, "external::continue", { next }); - } - - // Public method to perform validation and send the appropriate messages to the form elements - validate( - e: Event, - field: string, - validators: FieldValidator[], - options?: { grouped: boolean }, - ): [boolean, GoabFieldsetItemValue] { - const { el, state, cancelled } = (e as CustomEvent).detail; - const value = state?.[field]?.value; - - window.scrollTo({ top: 0, behavior: "smooth" }); - - if (cancelled) { - return [true, value]; - } - - for (const validator of validators) { - const msg = validator(value); - this.#dispatchError(el, field, msg, options); - if (msg) { - return [false, ""]; - } - } - return [true, value]; - } - - /** - * Validates a group of fields ensuring that at least `minPassCount` of the items within the group - * passes. This is useful in the scenario when n number fields are required out of n+m number of fields. - * - * @param {string[]} fields - An array of field names to be validated. - * @param {Event} e - The event object associated with the validation trigger. - * @param {FieldValidator[]} validators - An array of validator functions to apply to the fields. - * @return {[number, Record]} - Returns back the number of fields that passed and a record of the fields and their pass status. - */ - validateGroup( - e: Event, - fields: string[], - validators: FieldValidator[], - ): [number, Record] { - let passCount = 0; - const validGroups = {} as Record; - - for (const field of fields) { - const [_valid] = this.validate(e, field, validators, { grouped: true }); - if (_valid) { - validGroups[field] = true; - passCount++; - } - } - - return [passCount, validGroups]; - } - - edit(index: number) { - relay(this._formRef, "external::alter:state", { index, operation: "edit" }); - } - - remove(index: number) { - relay(this._formRef, "external::alter:state", { - index, - operation: "remove", - }); - } - - /** - * Completes the form and triggers the onComplete callback. - * This method should be used when you want to complete a form without navigating to a summary page. - * - * @important Developers must validate the form before calling this method. - * - * @example - * // Validate first, then complete - * const [isValid] = this.validate(e, "firstName", [ - * requiredValidator("First name is required.") - * ]); - * if (isValid) { - * this.complete(); - * } - * @returns void - */ - complete() { - if (!this._formRef) { - console.error("complete: form ref is not set"); - return; - } - - if (this._isCompleting) { - console.warn("complete: completion already in progress"); - return; - } - - this._isCompleting = true; - relay(this._formRef, "fieldset::submit", null, { bubbles: true }); - this._isCompleting = false; - } - - /** - * Completes a subform and returns control to the parent form. - * This method should be used when working with subforms that need to complete without a summary page. - * - * @important Developers must validate the subform before calling this method. - * - * @example - * // Validate first, then complete the subform - * const [isValid] = this._childFormController.validate(e, "fullName", [ - * requiredValidator("Please enter the dependent's full name.") - * ]); - * if (isValid) { - * this._childFormController.completeSubform(); - * } - * @returns void - */ - completeSubform() { - if (!this._formRef) { - console.error("completeSubform: form ref is not set"); - return; - } - - if (this._isCompleting) { - console.warn("completeSubform: completion already in progress"); - return; - } - // Capture form reference to avoid TypeScript undefined error in closures - const formRef = this._formRef; - - // Set flag to prevent multiple calls - this._isCompleting = true; - - const stateChangeHandler = (e: Event) => { - formRef.removeEventListener('_stateChange', stateChangeHandler); - - // Now we know state is updated, safe to complete - // The _formRef points to the inner form within the SubForm - // We need to trigger the form's completion which will be caught by SubForm's onChildFormComplete - dispatch(formRef, "_complete", {}, { bubbles: true }); - this._isCompleting = false; - }; - - formRef.addEventListener('_stateChange', stateChangeHandler); - - dispatch(formRef, "_continue", null, { bubbles: true }); - } - - // Private method to dispatch the error message to the form element - #dispatchError( - el: HTMLElement, - name: string, - msg: string, - options?: { grouped: boolean }, - ) { - el.dispatchEvent( - new CustomEvent("msg", { - composed: true, - detail: { - action: "external::set:error", - data: { - name, - msg, - grouped: options?.grouped, - }, - }, - }), - ); - } - - // removes any data collected that doesn't correspond with the final history path - clean(data: AppState) { - return data.history.reduce>((acc, fieldsetId) => { - acc[fieldsetId] = data.form[fieldsetId]; - return acc; - }, {}); - } -} - diff --git a/libs/common/src/lib/public-form.ts b/libs/common/src/lib/public-form.ts new file mode 100644 index 000000000..1a5b8c496 --- /dev/null +++ b/libs/common/src/lib/public-form.ts @@ -0,0 +1,34 @@ +import { FieldValidator } from "./validators"; + +// TODO: right now the value is a string, but for the subform we need to allow for an array +export type PFPage = { + _id?: `${string}-${string}-${string}-${string}-${string}` | undefined; + [key: string]: string | undefined; +}; + +export type PFState = { + data: Record; + dataBuffer: PFPage; // Record; + history: string[]; +}; + +export type PFSummary = Omit & { + outline: Omit; +}; + +export type PFField = { + label: string; + formatter?: (input: string) => string; + hideInSummary: "always" | "ifBlank" | "never"; +}; + +export type PFOutline = Record; + +export type PFOutlineItem = { + subform: boolean; + props: Record; + fields: Record; + summarize?: (page: PFPage) => Record; + next: string | ((state: PFState) => string); + validators: Record; +}; diff --git a/libs/common/src/lib/validators.ts b/libs/common/src/lib/validators.ts index 451d19b09..b08daf930 100644 --- a/libs/common/src/lib/validators.ts +++ b/libs/common/src/lib/validators.ts @@ -35,10 +35,10 @@ export class FormValidator { } } -export function birthDayValidator(): FieldValidator[] { +export function BirthDayValidator(): FieldValidator[] { return [ - requiredValidator("Day is required"), - numericValidator({ + RequiredValidator("Day is required"), + NumericValidator({ min: 1, max: 31, minMsg: "Day must be between 1 and 31", @@ -47,10 +47,10 @@ export function birthDayValidator(): FieldValidator[] { ]; } -export function birthMonthValidator(): FieldValidator[] { +export function BirthMonthValidator(): FieldValidator[] { return [ - requiredValidator("Month is required"), - numericValidator({ + RequiredValidator("Month is required"), + NumericValidator({ min: 0, max: 11, minMsg: "Month must be between Jan and Dec", @@ -59,11 +59,11 @@ export function birthMonthValidator(): FieldValidator[] { ]; } -export function birthYearValidator(): FieldValidator[] { +export function BirthYearValidator(): FieldValidator[] { const maxYear = new Date().getFullYear(); return [ - requiredValidator("Year is required"), - numericValidator({ + RequiredValidator("Year is required"), + NumericValidator({ min: 1900, max: maxYear, minMsg: "Year must be greater than 1900", @@ -72,7 +72,7 @@ export function birthYearValidator(): FieldValidator[] { ]; } -export function requiredValidator(msg?: string): FieldValidator { +export function RequiredValidator(msg?: string): FieldValidator { return (value: unknown) => { msg = msg || "Required"; @@ -86,17 +86,17 @@ export function requiredValidator(msg?: string): FieldValidator { }; } -export function phoneNumberValidator(msg?: string): FieldValidator { +export function PhoneNumberValidator(msg?: string): FieldValidator { const regex = new RegExp(/^\+?[\d-() ]{10,18}$/); - return regexValidator(regex, msg || "Invalid phone number"); + return RegexValidator(regex, msg || "Invalid phone number"); } -export function emailValidator(msg?: string): FieldValidator { +export function EmailValidator(msg?: string): FieldValidator { // emailregex.com const regex = new RegExp( /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, ); - return regexValidator(regex, msg || "Invalid email address"); + return RegexValidator(regex, msg || "Invalid email address"); } // SIN# Generator: https://singen.ca @@ -132,14 +132,14 @@ export function SINValidator(): FieldValidator { }; } -export function postalCodeValidator(): FieldValidator { - return regexValidator( +export function PostalCodeValidator(): FieldValidator { + return RegexValidator( /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z][ -]?\d[ABCEGHJ-NPRSTV-Z]\d$/i, "Invalid postal code", ); } -export function regexValidator(regex: RegExp, msg: string): FieldValidator { +export function RegexValidator(regex: RegExp, msg: string): FieldValidator { return (value: unknown) => { if (!value) { return ""; @@ -160,7 +160,7 @@ interface DateValidatorOptions { max?: Date; } -export function dateValidator({ +export function DateValidator({ invalidMsg, minMsg, maxMsg, @@ -207,7 +207,7 @@ interface NumericValidatorOptions { max?: number; } -export function numericValidator({ +export function NumericValidator({ invalidTypeMsg, minMsg, maxMsg, @@ -251,7 +251,7 @@ interface LengthValidatorOptions { min?: number; } -export function lengthValidator({ +export function LengthValidator({ invalidTypeMsg, minMsg, maxMsg, diff --git a/libs/react-components/src/index.ts b/libs/react-components/src/index.ts index 693d58c97..86ef45e0c 100644 --- a/libs/react-components/src/index.ts +++ b/libs/react-components/src/index.ts @@ -25,7 +25,6 @@ export * from "./lib/file-upload-input/file-upload-input"; export * from "./lib/footer/footer"; export * from "./lib/footer-meta-section/footer-meta-section"; export * from "./lib/footer-nav-section/footer-nav-section"; -export * from "./lib/form/fieldset"; export * from "./lib/form/public-form-page"; export * from "./lib/form/public-form-summary"; export * from "./lib/form/public-form"; @@ -73,4 +72,3 @@ export * from "./lib/three-column-layout/three-column-layout"; export * from "./lib/tooltip/tooltip"; export * from "./lib/two-column-layout/two-column-layout"; export * from "./lib/filter-chip/filter-chip"; -export * from "./lib/use-public-form-controller"; diff --git a/libs/react-components/src/lib/form/fieldset.tsx b/libs/react-components/src/lib/form/fieldset.tsx deleted file mode 100644 index 3710714c1..000000000 --- a/libs/react-components/src/lib/form/fieldset.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { ReactNode, useEffect, useRef, type JSX } from "react"; -import { - DataAttributes, - GoabFieldsetOnContinueDetail, - GoabFormDispatchOn, -} from "@abgov/ui-components-common"; -import { transformProps, kebab } from "../common/extract-props"; - -interface WCProps { - id?: string; - "section-title"?: string; - "dispatch-on"?: string; -} - -declare module "react" { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - interface IntrinsicElements { - "goa-fieldset": WCProps & React.HTMLAttributes & { - ref: React.RefObject; - }; - } - } -} - -interface GoabFieldsetProps extends DataAttributes { - id?: string; - sectionTitle?: string; - dispatchOn?: GoabFormDispatchOn; - onContinue?: (event: GoabFieldsetOnContinueDetail) => void; - children: ReactNode; -} - -export function GoabFieldset({ - onContinue, - children, - ...rest -}: GoabFieldsetProps): JSX.Element { - const ref = useRef(null); - - const _props = transformProps(rest, kebab); - - useEffect(() => { - if (!ref.current) return; - const current = ref.current; - - const continueListener = (e: Event) => { - const event = (e as CustomEvent).detail; - return onContinue?.(event); - }; - - if (onContinue) { - current.addEventListener("_continue", continueListener); - } - - return () => { - if (onContinue) { - current.removeEventListener("_continue", continueListener); - } - }; - }, [ref, onContinue]); - - return ( - - {children} - - ); -} - -export default GoabFieldset; diff --git a/libs/react-components/src/lib/form/public-subform-index.spec.tsx b/libs/react-components/src/lib/form/public-subform-index.spec.tsx deleted file mode 100644 index 9f792d493..000000000 --- a/libs/react-components/src/lib/form/public-subform-index.spec.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { GoabPublicSubformIndex } from "./public-subform-index"; - -describe("GoabPublicSubformIndex", () => { - it("renders with all properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("heading")).toBe("Test Heading"); - expect(el?.getAttribute("section-title")).toBe("Test Section Title"); - expect(el?.getAttribute("action-button-text")).toBe("Add Item"); - expect(el?.getAttribute("button-visibility")).toBe("visible"); - expect(el?.getAttribute("slot")).toBe("subform-index"); - expect(el?.getAttribute("mt")).toBe("s"); - expect(el?.getAttribute("mr")).toBe("m"); - expect(el?.getAttribute("mb")).toBe("l"); - expect(el?.getAttribute("ml")).toBe("xl"); - - // Content is rendered - expect(baseElement.querySelector("[data-testid='test-content']")).toBeTruthy(); - }); - - it("renders with default values", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("heading")).toBe(""); - expect(el?.getAttribute("section-title")).toBe(""); - expect(el?.getAttribute("action-button-text")).toBe(""); - expect(el?.getAttribute("button-visibility")).toBe("hidden"); - expect(el?.getAttribute("slot")).toBe("subform-index"); - }); - - it("renders with hidden button visibility", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("button-visibility")).toBe("hidden"); - }); - - it("renders complex children content", () => { - const { baseElement } = render( - -

- Please add information about your dependents. -

- - - - - - - - - - - - - -
NameActions
John Doe - - -
-
- ); - - expect(baseElement.querySelector("[data-testid='description']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='dependents-table']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='edit-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='remove-btn']")).toBeTruthy(); - }); - - it("handles empty string properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("heading")).toBe(""); - expect(el?.getAttribute("section-title")).toBe(""); - expect(el?.getAttribute("action-button-text")).toBe(""); - }); - - it("handles special characters in text properties", () => { - const specialTexts = { - heading: "Dependents & Family", - sectionTitle: "Section > Details", - actionButtonText: "Add \"New\" Item", - }; - - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("heading")).toBe(specialTexts.heading); - expect(el?.getAttribute("section-title")).toBe(specialTexts.sectionTitle); - expect(el?.getAttribute("action-button-text")).toBe(specialTexts.actionButtonText); - }); - - it("always renders with slot='subform-index' attribute", () => { - const { baseElement } = render( - -
Any content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("slot")).toBe("subform-index"); - }); - - it("renders nested components correctly", () => { - const { baseElement } = render( - -
- Please complete the following tasks: -
-
- Task 1: Complete profile - -
-
- Task 2: Upload documents - -
-
- ); - - expect(baseElement.querySelector("[data-testid='text-content']")?.textContent) - .toContain("Please complete the following tasks:"); - expect(baseElement.querySelector("[data-testid='task-item-1']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='task-item-2']")).toBeTruthy(); - expect(baseElement.querySelectorAll("button")).toHaveLength(2); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); diff --git a/libs/react-components/src/lib/form/public-subform.spec.tsx b/libs/react-components/src/lib/form/public-subform.spec.tsx deleted file mode 100644 index f0aa1ec9e..000000000 --- a/libs/react-components/src/lib/form/public-subform.spec.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { render, cleanup } from "@testing-library/react"; -import { describe, it, expect, afterEach } from "vitest"; -import { GoabPublicSubform } from "./public-subform"; - -describe("GoabPublicSubform", () => { - afterEach(() => { - cleanup(); - }); - - it("renders with all properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("id")).toBe("test-subform"); - expect(el?.getAttribute("name")).toBe("test-subform-name"); - expect(el?.getAttribute("continue-msg")).toBe("Continue to next step"); - expect(el?.getAttribute("mt")).toBe("s"); - expect(el?.getAttribute("mr")).toBe("m"); - expect(el?.getAttribute("mb")).toBe("l"); - expect(el?.getAttribute("ml")).toBe("xl"); - - // Content is rendered - expect(baseElement.querySelector("[data-testid='test-content']")).toBeTruthy(); - }); - - it("renders with default values", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("id")).toBe(""); - expect(el?.getAttribute("name")).toBe(""); - expect(el?.getAttribute("continue-msg")).toBe(""); - }); - - it("renders without margin attributes when undefined", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.hasAttribute("mt")).toBe(false); - expect(el?.hasAttribute("mr")).toBe(false); - expect(el?.hasAttribute("mb")).toBe(false); - expect(el?.hasAttribute("ml")).toBe(false); - }); - - it("renders complex children content", () => { - const { baseElement } = render( - -
-

Dependents List

- - - - - - - - - - - - - - - -
NameAgeActions
John Doe10 - - -
-
-
-

Add New Dependent

- - - -
-
- ); - - expect(baseElement.querySelector("[data-testid='subform-index']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='form-page']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='edit-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='remove-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='name-input']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='save-btn']")).toBeTruthy(); - }); - - it("handles empty string properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("id")).toBe(""); - expect(el?.getAttribute("name")).toBe(""); - expect(el?.getAttribute("continue-msg")).toBe(""); - }); - - it("renders with all margin values", () => { - const marginValues = ["none", "3xs", "2xs", "xs", "s", "m", "l", "xl", "2xl", "3xl"]; - - marginValues.forEach(margin => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("mt")).toBe(margin); - expect(el?.getAttribute("mr")).toBe(margin); - expect(el?.getAttribute("mb")).toBe(margin); - expect(el?.getAttribute("ml")).toBe(margin); - - cleanup(); - }); - }); - - it("handles special characters in text properties", () => { - const specialTexts = { - id: "subform-id-123", - name: "form & subform", - continueMsg: "Continue > Next Step", - }; - - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("id")).toBe(specialTexts.id); - expect(el?.getAttribute("name")).toBe(specialTexts.name); - expect(el?.getAttribute("continue-msg")).toBe(specialTexts.continueMsg); - }); - - it("handles camelCase to kebab-case conversion correctly", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("continue-msg")).toBe("Test message"); - expect(el?.hasAttribute("continueMsg")).toBe(false); - }); - - it("renders nested subform components correctly", () => { - const { baseElement } = render( - -
-

List View

-
-
Item 1
-
Item 2
-
-
-
-
-

Page 1

- -
-
-

Page 2

- -
-
-
- ); - - expect(baseElement.querySelector("[data-testid='subform-index']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='form-pages']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='item-list']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='page-1']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='page-2']")).toBeTruthy(); - expect(baseElement.querySelectorAll("input")).toHaveLength(1); - expect(baseElement.querySelectorAll("textarea")).toHaveLength(1); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); diff --git a/libs/react-components/src/lib/use-public-form-controller.spec.ts b/libs/react-components/src/lib/use-public-form-controller.spec.ts deleted file mode 100644 index 1a460aad4..000000000 --- a/libs/react-components/src/lib/use-public-form-controller.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { usePublicFormController } from "./use-public-form-controller"; - -describe("usePublicFormController", () => { - const mockFormElement = document.createElement("form"); - const mockInitEvent = { - el: mockFormElement - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should initialize with empty state", () => { - const { result } = renderHook(() => usePublicFormController("details")); - expect(result.current.state).toBe(undefined); - }); - - it("should handle null element during initialization", () => { - const consoleError = vi.spyOn(console, "error"); - const { result } = renderHook(() => usePublicFormController("details")); - - result.current.init({ el: undefined as unknown as HTMLFormElement }); - - expect(consoleError).toHaveBeenCalledWith("El is null during initialization"); - }); - - it("should initialize list state", () => { - const { result } = renderHook(() => usePublicFormController("details")); - const mockEvent = new CustomEvent("init", { - detail: { el: mockFormElement } - }); - - result.current.initList(mockEvent); - - expect(result.current.getStateList()).toEqual([]); - }); - - it("should handle null element during list initialization", () => { - const consoleError = vi.spyOn(console, "error"); - const { result } = renderHook(() => usePublicFormController("details")); - const mockEvent = new CustomEvent("init", { - detail: { el: null } - }); - - result.current.initList(mockEvent); - - expect(consoleError).toHaveBeenCalledWith("El is null during list initialization"); - }); - - it("should initialize state with callback", () => { - const { result } = renderHook(() => usePublicFormController("details")); - const mockCallback = vi.fn(); - const mockState = { - uuid: "test-uuid", - form: { - testForm: { - heading: "Test Form", - data: { - type: "details" as const, - fieldsets: { - testField: { - name: "testField", - label: "Test Field", - value: "test value", - order: 1 - } - } - } - } - }, - history: [], - editting: "", - status: "not-started" as const - }; - - result.current.init(mockInitEvent); - - result.current.initState(mockState, mockCallback); - - return new Promise((resolve) => { - setTimeout(() => { - expect(mockCallback).toHaveBeenCalled(); - resolve(); - }, 300); - }); - }); - - it("should handle state initialization without form reference", () => { - const consoleError = vi.spyOn(console, "error"); - const { result } = renderHook(() => usePublicFormController("details")); - const mockState = { - uuid: "test-uuid", - form: {}, - history: [], - editting: "", - status: "not-started" as const - }; - - result.current.initState(mockState); - - expect(consoleError).toHaveBeenCalledWith("Form ref not set."); - }); - - it("should validate field value", () => { - const { result } = renderHook(() => usePublicFormController("details")); - const mockValidator = vi.fn().mockReturnValue([true, "test value"]); - const mockEvent = { - el: document.createElement("div"), - state: { - testField: { - name: "testField", - label: "Test Field", - value: "test value", - order: 1 - } - }, - cancelled: false - }; - - const [isValid, value] = result.current.validate(mockEvent, "testField", [mockValidator]); - - expect(isValid).toBe(true); - expect(value).toBe("test value"); - expect(mockValidator).toHaveBeenCalledWith("test value"); - }); -}); diff --git a/libs/react-components/src/lib/use-public-form-controller.ts b/libs/react-components/src/lib/use-public-form-controller.ts deleted file mode 100644 index f18497b2b..000000000 --- a/libs/react-components/src/lib/use-public-form-controller.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useRef, useEffect, useState, useCallback } from 'react'; -import { - AppState, - GoabFieldsetItemValue, - FieldValidator, PublicFormController, -} from "@abgov/ui-components-common"; - -function usePublicFormController(type: "details" | "list" = "details") { - const controllerRef = useRef>(new PublicFormController(type)); - const [state, setState] = useState | AppState[] | undefined>(undefined); - - useEffect(() => { - // Create a proxy that updates React state when controller's state changes - const originalStateGetter = Object.getOwnPropertyDescriptor( - Object.getPrototypeOf(controllerRef.current), - 'state' - )?.get; - - const originalStateSetter = Object.getOwnPropertyDescriptor( - Object.getPrototypeOf(controllerRef.current), - 'state' - )?.set; - - if (originalStateGetter && originalStateSetter) { - Object.defineProperty(controllerRef.current, 'state', { - get: function() { - return originalStateGetter.call(this); - }, - set: function(value) { - originalStateSetter.call(this, value); - setState(value); - }, - configurable: true - }); - } - }, []); - - - const init = useCallback((e: Event) => { - if (!(e as CustomEvent).detail?.el) { - console.error('El is null during initialization'); - return; - } - controllerRef.current.init(e); - }, []); - - const initList = useCallback((e: Event) => { - const customEvent = e as CustomEvent; - if (!customEvent?.detail?.el) { - console.error('El is null during list initialization'); - return; - } - controllerRef.current.initList(e); - }, []); - - const initState = useCallback((state?: string | AppState | AppState[], callback?: () => void) => { - if (!controllerRef.current._formRef) { - console.error('Form ref not set.'); - return; - } - controllerRef.current.initState(state, callback); - }, []); - - const continueTo = useCallback((next: T | undefined) => { - controllerRef.current.continueTo(next); - }, []); - - const validate = useCallback(( - e: Event, - field: string, - validators: FieldValidator[] - ): [boolean, GoabFieldsetItemValue] => { - return controllerRef.current.validate(e, field, validators); - }, []); - - const getStateValue = useCallback((group: string, key: string): string => { - return controllerRef.current.getStateValue(group, key); - }, []); - - const getStateList = useCallback((): Record[] => { - return controllerRef.current.getStateList(); - }, []); - - const complete = useCallback(() => { - controllerRef.current.complete(); - }, []); - - const completeSubform = useCallback(() => { - controllerRef.current.completeSubform(); - }, []); - - return { - state, - init, - initList, - initState, - continueTo, - validate, - getStateValue, - getStateList, - complete, - completeSubform, - controller: controllerRef.current - }; -} - -export { usePublicFormController }; diff --git a/libs/web-components/src/common/utils.ts b/libs/web-components/src/common/utils.ts index 40b5d52af..a1e5d45cb 100644 --- a/libs/web-components/src/common/utils.ts +++ b/libs/web-components/src/common/utils.ts @@ -3,7 +3,7 @@ // non-key properties change. Using the `watch` function provides the control and makes // it clear what the function is watching export function watch(fn: () => void, _: unknown[]) { - fn() + fn(); } // Creates a style string from a list of styles. @@ -53,7 +53,7 @@ export function receive( const listener = (e: Event) => { const ce = e as CustomEvent; handler(ce.detail.action, ce.detail.data, e); - } + }; el?.addEventListener("msg", listener); @@ -290,9 +290,14 @@ export function isPointInRectangle( rectX: number, rectY: number, rectWidth: number, - rectHeight: number + rectHeight: number, ): boolean { - return x >= rectX && x <= rectX + rectWidth && y >= rectY && y <= rectY + rectHeight; + return ( + x >= rectX && + x <= rectX + rectWidth && + y >= rectY && + y <= rectY + rectHeight + ); } export function ensureSlotExists(el: HTMLElement) { @@ -372,7 +377,7 @@ export function getLocalDateValues(input: string | Date): { year: +matches[1], month: +matches[2], day: +matches[3], - } + }; } } @@ -381,7 +386,7 @@ export function getLocalDateValues(input: string | Date): { year: input.getFullYear(), month: input.getMonth() + 1, day: input.getDate(), - } + }; } return null; diff --git a/libs/web-components/src/components/checkbox-list/CheckboxList.svelte b/libs/web-components/src/components/checkbox-list/CheckboxList.svelte index 54d0ee30f..b45e65670 100644 --- a/libs/web-components/src/components/checkbox-list/CheckboxList.svelte +++ b/libs/web-components/src/components/checkbox-list/CheckboxList.svelte @@ -1,4 +1,9 @@ - + @@ -267,8 +178,8 @@ class:v2={version === "2"} class:compact={size === "compact"} style={` -${calculateMargin(mt, mr, mb, ml)} -max-width: ${maxwidth}; + ${calculateMargin(mt, mr, mb, ml)} + max-width: ${maxwidth}; `} > + {#if $$slots.description || description}
@@ -454,7 +362,7 @@ max-width: ${maxwidth}; } .container::before { - content: ''; + content: ""; position: absolute; width: 44px; height: 44px; diff --git a/libs/web-components/src/components/date-picker/DatePicker.svelte b/libs/web-components/src/components/date-picker/DatePicker.svelte index 6a532edc3..c8b06b5d7 100644 --- a/libs/web-components/src/components/date-picker/DatePicker.svelte +++ b/libs/web-components/src/components/date-picker/DatePicker.svelte @@ -3,6 +3,7 @@ tag: "goa-date-picker", props: { error: { attribute: "error", type: "String", reflect: true }, + value: { attribute: "value", type: "String", reflect: true }, }, }} /> @@ -11,18 +12,7 @@ import { onMount, tick } from "svelte"; import type { Spacing } from "../../common/styling"; import { toBoolean } from "../../common/utils"; - import { receive, dispatch, relay } from "../../common/utils"; - import { - FieldsetSetValueMsg, - FieldsetSetValueRelayDetail, - FieldsetSetErrorMsg, - FieldsetResetErrorsMsg, - FormFieldMountMsg, - FormFieldMountRelayDetail, - FieldsetErrorRelayDetail, - FieldsetResetFieldsMsg, - FormItemMountMsg, - } from "../../types/relay-types"; + import { dispatch } from "../../common/utils"; import { CalendarDate } from "../../common/calendar-date"; type OnChangeDetail = { @@ -66,9 +56,7 @@ export let ml: Spacing = null; let _error: boolean = toBoolean(error); - let _senderEl: HTMLElement; let _rootEl: HTMLElement; - let _date: CalendarDate = CalendarDate.init(); $: isDisabled = toBoolean(disabled); @@ -79,11 +67,21 @@ onMount(async () => { await tick(); // for Angular's delay setDate(value); - addRelayListener(); - sendMountedMessage(); showDeprecationWarnings(); + bindReset(_rootEl); }); + function bindReset(el: HTMLElement) { + el.addEventListener("goa:reset", () => { + if (value) { + value = ""; + _date = CalendarDate.init(); + dispatchValue(); + } + }); + dispatch(el, "goa:bind", el, { bubbles: true }); + } + function showDeprecationWarnings() { if (relative != "") { console.warn( @@ -92,67 +90,14 @@ } } - // Listen for relayed messages - function addRelayListener() { - receive(_rootEl, (action, data, event) => { - switch (action) { - case FieldsetSetValueMsg: - onSetValue(data as FieldsetSetValueRelayDetail); - break; - case FieldsetSetErrorMsg: - setError(data as FieldsetErrorRelayDetail); - break; - case FieldsetResetErrorsMsg: - error = "false"; - break; - case FieldsetResetFieldsMsg: - onSetValue({ name, value: "" }); - break; - - // prevent child fields from mounting/registering themselves with parent components - case FormItemMountMsg: - case FormFieldMountMsg: - event.stopPropagation(); - break; - } - }); - } - - function setError(detail: FieldsetErrorRelayDetail) { - error = detail.error ? "true" : "false"; - } - - function onSetValue(detail: FieldsetSetValueRelayDetail) { - // @ts-expect-error - value = detail.value; - dispatch( - _rootEl, - "_change", - { name, value: detail.value }, - { bubbles: true }, - ); - } - - // Notify the Form that this component has been mounted - function sendMountedMessage() { - relay( - _senderEl, - FormFieldMountMsg, - { name, el: _rootEl }, - { bubbles: true, timeout: 5 }, - ); - } - function setDate(value: string) { - if (type === "calendar") { - if (value) { - _date = new CalendarDate(value); - if (!_date.isValid) { - _date = new CalendarDate(0); - } - } else { + if (value) { + _date = new CalendarDate(value); + if (!_date.isValid) { _date = new CalendarDate(0); } + } else { + _date = new CalendarDate(0); } } @@ -173,7 +118,7 @@ name, value: _date.date, valueStr: value, - }); + }, { bubbles: true }); } function hideCalendar() { @@ -234,21 +179,24 @@ function onInputChange(e: Event) { e.stopPropagation(); - const { name: elName, value } = ( + const { name: elName, value: newVal } = ( e as CustomEvent<{ name: string; value: string }> ).detail; if (elName === "day") { - _date.setDay(+value); + _date.setDay(+newVal); } else if (elName === "month") { - _date.setMonth(+value); + _date.setMonth(+newVal); } else if (elName === "year") { - _date.setYear(+value); + _date.setYear(+newVal); } // invalid dates need to emitted too const output = _date.isValid() ? _date.toString() : ""; + // update exposed prop, without this it won't be able to be reset + value = output; + dispatch( _rootEl, "_change", @@ -258,7 +206,6 @@ } -
{#if type === "calendar"} {#if width && width.includes("%")}
@@ -344,6 +291,7 @@ @@ -34,16 +35,6 @@ toBoolean, } from "../../common/utils"; import { calculateMargin } from "../../common/styling"; - import { - FieldsetErrorRelayDetail, - FieldsetResetErrorsMsg, - FieldsetSetErrorMsg, - FormFieldMountMsg, - FormFieldMountRelayDetail, - FieldsetSetValueMsg, - FieldsetSetValueRelayDetail, - FieldsetResetFieldsMsg, - } from "../../types/relay-types"; interface EventHandler { handleKeyUp: (e: KeyboardEvent) => void; @@ -173,7 +164,6 @@ onMount(() => { ensureSlotExists(_rootEl); addRelayListener(); - sendMountedMessage(); setupPopoverListeners(); if (disableGlobalClosePopover) { @@ -183,8 +173,20 @@ ? new ComboboxKeyUpHandler(_inputEl) : new DropdownKeyUpHandler(_inputEl); showDeprecationWarnings(); + + bindReset(_rootEl); }); + function bindReset(el: HTMLElement) { + el.addEventListener("goa:reset", () => { + if (value) { + value = ""; + dispatch(el, "_change", { name, value }, { bubbles: true }) ; + } + }); + dispatch(el, "goa:bind", el, { bubbles: true }); + } + // // Functions // @@ -220,11 +222,11 @@ } function setupPopoverListeners() { - _popoverEl?.addEventListener("_open", (e) => { + _popoverEl?.addEventListener("_open", (_e) => { _isMenuVisible = true; }); - _popoverEl?.addEventListener("_close", (e) => { + _popoverEl?.addEventListener("_close", (_e) => { _isMenuVisible = false; }); } @@ -238,20 +240,8 @@ } function addRelayListener() { - receive(_rootEl, (action, data, event) => { + receive(_rootEl, (action, data, _event) => { switch (action) { - case FieldsetSetValueMsg: - onSetValue(data as FieldsetSetValueRelayDetail); - break; - case FieldsetSetErrorMsg: - setError(data as FieldsetErrorRelayDetail); - break; - case FieldsetResetErrorsMsg: - error = "false"; - break; - case FieldsetResetFieldsMsg: - onSetValue({ name, value: "" }); - break; case DropdownItemMountedMsg: onChildMounted(data as DropdownItemMountedRelayDetail); break; @@ -262,25 +252,6 @@ }); } - function setError(detail: FieldsetErrorRelayDetail) { - error = detail.error ? "true" : "false"; - } - - function onSetValue(detail: FieldsetSetValueRelayDetail) { - // @ts-expect-error - value = detail.value; - dispatch(_rootEl, "_change", { name, value }, { bubbles: true }); - } - - function sendMountedMessage() { - relay( - _rootEl, - FormFieldMountMsg, - { name, el: _rootEl }, - { bubbles: true, timeout: 10 }, - ); - } - /** * Called when a new child option is added to the slot. This component must send * a reference to itself back to the child to allow for the child to send messages @@ -527,7 +498,7 @@ } // Auto-select matching option from input after browser autofill/autocomplete or paste from clipboard. - function onInputChange(e: Event) { + function onInputChange(_e: Event) { if (_disabled || !_filterable) return; const isAutofilled = @@ -606,7 +577,7 @@ setDisplayedValue(); } - function onFocus(e: Event) { + function onFocus(_e: Event) { dispatch(_rootEl, "help-text::announce", undefined, { bubbles: true }); } @@ -917,7 +888,7 @@ data-value={option.value} role="option" style="display: block" - on:click={(e) => { + on:click={(_e) => { onFilteredOptionClick(option); _inputEl?.focus(); }} diff --git a/libs/web-components/src/components/form-item/FormItem.svelte b/libs/web-components/src/components/form-item/FormItem.svelte index ed05b0a7f..bfb2cc53b 100644 --- a/libs/web-components/src/components/form-item/FormItem.svelte +++ b/libs/web-components/src/components/form-item/FormItem.svelte @@ -1,8 +1,5 @@ @@ -18,23 +15,10 @@ import { calculateMargin } from "../../common/styling"; import { receive, - relay, generateRandomId, typeValidator, announceToScreenReader, } from "../../common/utils"; - import { - FieldsetResetErrorsMsg, - FieldsetSetErrorMsg, - FormFieldMountMsg, - FormItemMountMsg, - } from "../../types/relay-types"; - - import type { - FieldsetErrorRelayDetail, - FormFieldMountRelayDetail, - FormItemMountRelayDetail, - } from "../../types/relay-types"; // Validators const [REQUIREMENT_TYPES, validateRequirementType] = typeValidator( @@ -89,11 +73,6 @@ /** Specifies the input type for appropriate message spacing. Used with checkbox-list or radio-group. */ export let type: InputType = ""; - /** Overrides the label value within the form-summary. For public-form use only. */ - export let name: string = "blank"; - /** Sets the display order within the form summary. For public-form use only. */ - export let publicFormSummaryOrder: number = 0; - let _rootEl: HTMLElement; let _inputEl: HTMLElement; let _errorId = `error-${generateRandomId()}`; @@ -110,44 +89,10 @@ validateVersion(version); validateType(type); - receive(_rootEl, (action, data) => { - switch (action) { - case FormFieldMountMsg: - onInputMount(data as FormFieldMountRelayDetail); - break; - case FieldsetSetErrorMsg: - onSetError(data as FieldsetErrorRelayDetail); - break; - case FieldsetResetErrorsMsg: - error = ""; - break; - } - }); - - _rootEl?.addEventListener("form-field::bind", handleInputMounted); _rootEl?.addEventListener("error::change", handleErrorChange); _rootEl?.addEventListener("help-text::announce", handleAnnounceHelperText); }); - function handleInputMounted(e: Event) { - const ce = e as CustomEvent; - _inputEl = ce.detail.el; - - // Check if aria-label is present and has a value in the child element - const ariaLabel = _inputEl.getAttribute("aria-label"); - if (!ariaLabel || ariaLabel.trim() === "") { - _inputEl.setAttribute("aria-label", label); - } - - // Set aria-required - _inputEl.setAttribute( - "aria-required", - requirement === "required" ? "true" : "false", - ); - - updateAriaDescribedBy(); - } - function handleErrorChange(e: Event) { const ce = e as CustomEvent<{ isError: boolean }>; if (_hasError !== ce.detail.isError) { @@ -176,33 +121,6 @@ _inputEl.setAttribute("aria-describedby", ""); } } - - function onSetError(d: FieldsetErrorRelayDetail) { - error = (d as Record)["error"]; - } - - function onInputMount(props: FormFieldMountRelayDetail) { - const { el, name } = props; - - // Check if aria-label is present and has a value in the child element - const ariaLabel = el.getAttribute("aria-label"); - if (!ariaLabel || ariaLabel.trim() === "") { - el.setAttribute("aria-label", label); - } - - sendMountedMessage(name); - } - - // Allows binding to Fieldset components. The `_name` value is what was obtained from the "input" element's - // event, which ensures that the requirement of the "input" and formitem having the same name will be met. - function sendMountedMessage(_name: string) { - relay( - _rootEl, - FormItemMountMsg, - { id: _name, label: name !== "blank" ? name : label, el: _rootEl, order: publicFormSummaryOrder }, - { bubbles: true, timeout: 10 }, - ); - } diff --git a/libs/web-components/src/components/form/Fieldset.svelte b/libs/web-components/src/components/form/Fieldset.svelte deleted file mode 100644 index eca5416ae..000000000 --- a/libs/web-components/src/components/form/Fieldset.svelte +++ /dev/null @@ -1,402 +0,0 @@ - - - - -
- {#if Object.values(_errors).filter((err) => !!err).length} - - - - {/if} - - - - - -
- - diff --git a/libs/web-components/src/components/form/Form.svelte b/libs/web-components/src/components/form/Form.svelte index 1420246b9..052e49734 100644 --- a/libs/web-components/src/components/form/Form.svelte +++ b/libs/web-components/src/components/form/Form.svelte @@ -4,540 +4,471 @@ }} /> + + -
-
-
+ + +
diff --git a/libs/web-components/src/components/form/FormPage.svelte b/libs/web-components/src/components/form/FormPage.svelte index 661c3e8a8..9fd135e20 100644 --- a/libs/web-components/src/components/form/FormPage.svelte +++ b/libs/web-components/src/components/form/FormPage.svelte @@ -2,227 +2,99 @@ customElement={{ tag: "goa-public-form-page", props: { + id: { type: "String", reflect: true }, + error: { type: "String", reflect: true }, + editting: { type: "Boolean", attribute: "data-pf-editting", reflect: true }, buttonText: { type: "String", attribute: "button-text" }, - buttonVisibility: { type: "String", attribute: "button-visibility" }, subHeading: { type: "String", attribute: "sub-heading" }, sectionTitle: { type: "String", attribute: "section-title" }, - backUrl: { attribute: "back-url", type: "String" }, - summaryHeading: { attribute: "summary-heading", type: "String" }, - } + backVisibility: { attribute: "back-visibility", type: "String" }, + }, }} /> -
-
- {#if _editting !== id} - {#if backUrl} - - {#if backUrl === "#"} - Back - {:else} - Back - {/if} - - {/if} - - {#if !backUrl && type === "step"} - - Back - - {/if} - {/if} - - {#if sectionTitle} - {sectionTitle} - {/if} - {#if heading} - {heading} - {/if} - {#if subHeading} - {subHeading} - {/if} - - - - {#if type !== "multistep"} - - {#if _editting === id} - dispatchContinueMsg(true)} type="secondary"> - Cancel - - {/if} - - {#if type === "summary"} - dispatchCompletion()} type="primary"> - {buttonText || "Confirm"} - - {:else} - {#if buttonVisibility === "visible"} - dispatchContinueMsg()} type="primary"> - {buttonText || "Continue"} - - {/if} - {/if} - +
+
+ {#if !editting && backVisibility === "visible"} + + Back + + {/if} + + {#if sectionTitle} + {sectionTitle} + {/if} + {#if heading} + {heading} + {/if} + {#if subHeading} + {subHeading} + {/if} + + {#if error} + {error} + {/if} + + + + + {#if editting} + + Cancel + {/if} -
+ + {buttonText || "Continue"} + +
diff --git a/libs/web-components/src/components/form/FormSummary.svelte b/libs/web-components/src/components/form/FormSummary.svelte index 6fcf76430..08a2efffe 100644 --- a/libs/web-components/src/components/form/FormSummary.svelte +++ b/libs/web-components/src/components/form/FormSummary.svelte @@ -2,162 +2,110 @@ @@ -167,64 +115,87 @@ >{heading} {/if} - {#if _state} - {#each _state.history as page} - {#if _state.form[page]} + {#if Object.keys(_state || {}).length > 0} + {#each _state?.history as page} + {#if showInSummary(page)}
{#if getHeading(page)} - {getHeading(page)} + + {getHeading(page)} + {/if} - -
- {#if _state.form[page]?.data?.type} - {#if _state.form[page]?.data?.type === "details"} + {#if isSubform(page)} +
+ {#each getDataItems(page) as item, index} - {#each Object.entries(getData(_state, page)) as [_, data]} + + {#if getOutlineItem(page).summarize} + {#each Object.entries(getSummary(page)) as [key, value]} + + + + + {/each} + + {:else} + {#each Object.entries(item) as [key, value]} + {#if + getField(page, key).hideInSummary !== "always" + && !(getField(page, key).hideInSummary === "ifBlank" && value === "") + && key !== "_id" + } + + + + + {/if} + {/each} + {/if} +
{getLabel(page, key)} + {format(value)} +
{getLabel(page, key)} + {format(value, getField(page, key).formatter)} +
+ {#if getDataItems(page).length-1 !== index} + + {/if} + {/each} +
+ {:else} +
+ + + {#if getOutlineItem(page).summarize} + {#each Object.entries(getSummary(page)) as [key, value]} - - + {/each} -
{data.label} - {#if Array.isArray(formatValue(data.value, data.valueLabel, data.labels))} - {#each formatValue(data.value, data.valueLabel, data.labels) as label} -
{label}
- {/each} - {:else} - {formatValue(data.value, data.valueLabel, data.labels)} - {/if} +
{getLabel(page, key)} + {format(value)}
- {:else} - {#each getDataList(_state, page) as item, index} - - {#each Object.entries(item) as [_, data]} + + {:else} + {#each Object.entries(getDataItem(page)) as [key, value]} + {#if getField(page, key).hideInSummary !== "always" + && !(getField(page, key).hideInSummary === "ifBlank" && value === "") + } - - + - {/each} -
{data.label} - {#if Array.isArray(formatValue(data.value, data.valueLabel, data.labels))} - {#each formatValue(data.value, data.valueLabel, data.labels) as label} -
{label}
- {/each} - {:else} - {formatValue(data.value, data.valueLabel, data.labels)} - {/if} +
{getLabel(page, key)} + {format(value, getField(page, key).formatter)}
- {#if index < getDataList(_state, page).length - 1} - - {/if} - {/each} - {/if} - {/if} -
+ {/if} + {/each} + {/if} + +
+ {/if}
diff --git a/libs/web-components/src/components/form/SubForm.svelte b/libs/web-components/src/components/form/SubForm.svelte deleted file mode 100644 index 38751f02a..000000000 --- a/libs/web-components/src/components/form/SubForm.svelte +++ /dev/null @@ -1,344 +0,0 @@ - - - - -
-
- -
- - - - -
diff --git a/libs/web-components/src/components/form/SubFormIndex.svelte b/libs/web-components/src/components/form/SubFormIndex.svelte deleted file mode 100644 index f215a4775..000000000 --- a/libs/web-components/src/components/form/SubFormIndex.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - - - -
- - - {actionButtonText} - -
-
diff --git a/libs/web-components/src/components/form/Subform.svelte b/libs/web-components/src/components/form/Subform.svelte new file mode 100644 index 000000000..415afc408 --- /dev/null +++ b/libs/web-components/src/components/form/Subform.svelte @@ -0,0 +1,70 @@ + + + + +
+ + + + Cancel + + Save + + + + + Add +
diff --git a/libs/web-components/src/components/form/SubformForm.svelte b/libs/web-components/src/components/form/SubformForm.svelte new file mode 100644 index 000000000..d21aa6da9 --- /dev/null +++ b/libs/web-components/src/components/form/SubformForm.svelte @@ -0,0 +1,11 @@ + + + diff --git a/libs/web-components/src/components/form/SubformList.svelte b/libs/web-components/src/components/form/SubformList.svelte new file mode 100644 index 000000000..edb388dc1 --- /dev/null +++ b/libs/web-components/src/components/form/SubformList.svelte @@ -0,0 +1,11 @@ + + + diff --git a/libs/web-components/src/components/input/Input.svelte b/libs/web-components/src/components/input/Input.svelte index 359a744be..37bbd32af 100644 --- a/libs/web-components/src/components/input/Input.svelte +++ b/libs/web-components/src/components/input/Input.svelte @@ -15,8 +15,6 @@ import { typeValidator, toBoolean, - relay, - receive, dispatch, styles, } from "../../common/utils"; @@ -24,16 +22,7 @@ import type { Spacing } from "../../common/styling"; import { calculateMargin } from "../../common/styling"; import { onMount } from "svelte"; - import { - FieldsetErrorRelayDetail, - FieldsetResetErrorsMsg, - FieldsetResetFieldsMsg, - FieldsetSetErrorMsg, - FormFieldMountMsg, - FormFieldMountRelayDetail, - FieldsetSetValueMsg, - FieldsetSetValueRelayDetail, - } from "../../types/relay-types"; + // Validators const [Types, validateType] = typeValidator("Input type", [ "text", @@ -187,16 +176,29 @@ validateType(type); validateAutoCapitalize(autocapitalize); validateTextAlign(textalign); - addRelayListener(); showDeprecationWarnings(); checkSlots(); - sendMountedMessage(); const { containerStyle, inputWidth } = handleWidth(width, type); _containerStyle = containerStyle; _inputWidth = inputWidth; + + bindReset(_rootEl); }); + function bindReset(el: HTMLElement) { + el.addEventListener("goa:reset", (e) => { + // TODO: ensure all other rese events stop the events + e.stopPropagation(); + if (value) { + value = ""; + dispatch(el, "_change", { name, value }, { bubbles: true }) ; + } + }); + + dispatch(el, "goa:bind", el, { bubbles: true }); + } + // ========= // Functions // ========= diff --git a/libs/web-components/src/components/link/Link.svelte b/libs/web-components/src/components/link/Link.svelte index dc702b0dc..baf4263ee 100644 --- a/libs/web-components/src/components/link/Link.svelte +++ b/libs/web-components/src/components/link/Link.svelte @@ -57,12 +57,13 @@ }) function handleClick(e: Event) { + dispatch(_rootEl, action, actionArg || actionArgs, { bubbles: true }); e.preventDefault(); - dispatch(e.target as Element, action, actionArg || actionArgs, { bubbles: true }); }