diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index 6b04ef177..a527320b0 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -89,6 +89,7 @@ export function App() { v2 header icons 3137 Work Side Menu Group 3306 Custom slug value for tabs + Public form A diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx index aa2d0bbd4..9cb2f3273 100644 --- a/apps/prs/react/src/main.tsx +++ b/apps/prs/react/src/main.tsx @@ -68,6 +68,7 @@ import { Feat3241Route } from "./routes/features/feat3241"; import { FeatV2IconsRoute } from "./routes/features/featV2Icons"; import { Feat3137Route } from "./routes/features/feat3137"; import Feat3306Route from "./routes/features/feat3306"; +import { PublicFormRoute } from "./routes/features/public-form"; const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); @@ -144,6 +145,7 @@ root.render( } /> } /> } /> + } /> 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/react/src/routes/features/public-form.tsx b/apps/prs/react/src/routes/features/public-form.tsx new file mode 100644 index 000000000..4185042da --- /dev/null +++ b/apps/prs/react/src/routes/features/public-form.tsx @@ -0,0 +1,218 @@ +import { GoabFormItem, GoabInput, GoabPublicForm, GoabPublicFormPage, GoabPublicFormSummary, GoabRadioGroup, GoabRadioItem } from "@abgov/react-components"; +import { LengthValidator, NumericValidator, PFState, PFOutline, PFPage, RequiredValidator, SINValidator } from "@abgov/ui-components-common"; +import React, { useState } from "react"; + +const outline: PFOutline = { + role: { + subform: false, + props: { + heading: "What is your role in the court order?", + "section-title": "Support order details", + }, + fields: { + role: { + label: "What is your role?", + formatter: (val: string) => val.toUpperCase(), + hideInSummary: "never", + }, + }, + next: (state: PFState) => { + const role = state.dataBuffer["role"]; + return role === "Payor" ? "salary" : "identification"; + }, + validators: { + role: [RequiredValidator("Role is required")], + }, + }, + + salary: { + subform: false, + props: { + heading: "Payor salary", + }, + fields: { + salary: { label: "Yearly income", hideInSummary: "never" }, + }, + next: "summary", + validators: { + salary: [NumericValidator({ min: 0 })], + }, + }, + + identification: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Do you know any of the identifiers about the other party?", + }, + fields: { + sin: { + label: "Social Insurance #", + formatter: (val: string) => val.match(/(.{3})/g)?.join(" ") || val, + hideInSummary: "never", + }, + ahcn: { + label: "Alberta Health Care #", + formatter: (val: string) => val.match(/(.{4})/g)?.join("-") || val, + hideInSummary: "never", + }, + info: { label: "Additional information", hideInSummary: "never" }, + }, + next: (state: PFState): string => { + const sin = state.dataBuffer["sin"]; + const ahcn = state.dataBuffer["ahcn"]; + + if (!sin && !ahcn) { + throw "Either sin or ahcn is required"; + } + + return "address"; + }, + validators: { + sin: [SINValidator()], + ahcn: [LengthValidator({ min: 8 })], + }, + }, + + payor: { + subform: false, + props: { + heading: "Payor Name", + }, + fields: { + firstName: { label: "First name", hideInSummary: "never" }, + lastName: { label: "Last name", hideInSummary: "never" }, + }, + summarize: (page: PFPage) => ({ + "Full name": `${page["firstName"]} ${page["lastName"]}`.trim(), + }), + next: "address", + validators: {}, + }, + + address: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Your current address", + }, + fields: { + city: { label: "City/Town", hideInSummary: "never" }, + street: { label: "Street #", hideInSummary: "never" }, + "postal-code": { label: "Postal code", hideInSummary: "never" }, + }, + next: "summary", + validators: {}, + }, + + summary: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Summary", + }, + fields: {}, + next: (state: PFState): string => { + console.log("submit to backend here", state); + return ""; + }, + validators: {}, + }, +}; + +export function PublicFormRoute() { + const [state, setState] = useState(undefined); + + const handleInit = (initFn: any) => { + // Initialize with restored state + const initialState = initFn(null, { outline }); + setState(initialState); + }; + + const handleChange = (detail: any) => { + console.log("onChange", detail); + }; + + const handleNext = (newState: PFState) => { + setState(newState); + console.log("onNext", newState); + }; + + const handleSubformChange = (newState: PFState) => { + setState({ ...newState }); + console.log("onSubformChange", newState); + }; + + const getPage = (pageId: string, defaultValue: unknown) => { + return state?.data?.[pageId] || defaultValue; + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} 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..7c63f13c6 --- /dev/null +++ b/apps/prs/web/src/routes/public-form.svelte @@ -0,0 +1,395 @@ + + +
+ + + + + + + + + + + + + + {#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-form-page.spec.ts b/libs/angular-components/src/lib/components/form/public-form-page.spec.ts deleted file mode 100644 index 805f87e8d..000000000 --- a/libs/angular-components/src/lib/components/form/public-form-page.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicFormPage } from "./public-form-page"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { - GoabPublicFormPageStep, - GoabPublicFormPageButtonVisibility, - Spacing, -} from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabPublicFormPage], - template: ` - -
Test content
-
- `, -}) -class TestPublicFormPageComponent { - id = "test-page"; - heading = "Test Heading"; - subHeading = "Test Subheading"; - summaryHeading = "Test Summary"; - sectionTitle = "Test Section"; - backUrl = "/back"; - type: GoabPublicFormPageStep = "step"; - buttonText = "Continue"; - buttonVisibility: GoabPublicFormPageButtonVisibility = "visible"; - mt = "s" as Spacing; - mr = "m" as Spacing; - mb = "l" as Spacing; - ml = "xl" as Spacing; - - handleContinue(event: Event): void {/** do nothing **/} -} - -describe("GoabPublicFormPage", () => { - let fixture: ComponentFixture; - let component: TestPublicFormPageComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GoabPublicFormPage, TestPublicFormPageComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicFormPageComponent); - component = fixture.componentInstance; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-page")).nativeElement; - - expect(el?.getAttribute("heading")).toBe(component.heading); - expect(el?.getAttribute("sub-heading")).toBe(component.subHeading); - expect(el?.getAttribute("summary-heading")).toBe(component.summaryHeading); - expect(el?.getAttribute("section-title")).toBe(component.sectionTitle); - expect(el?.getAttribute("back-url")).toBe(component.backUrl); - expect(el?.getAttribute("type")).toBe(component.type); - expect(el?.getAttribute("button-text")).toBe(component.buttonText); - 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 emit onContinue event", () => { - fixture.detectChanges(); - const spy = jest.spyOn(component, "handleContinue"); - - const el = fixture.debugElement.query(By.css("goa-public-form-page")).nativeElement; - const detail = { - el: document.createElement("form"), - state: { - "field1": { - name: "field1", - label: "Field 1", - value: "test", - order: 1 - } - }, - cancelled: false - }; - - el.dispatchEvent(new CustomEvent("_continue", { detail })); - expect(spy).toHaveBeenCalledWith(expect.any(CustomEvent)); - }); - - it("should have default values", () => { - const page = new GoabPublicFormPage(); - expect(page.id).toBe(""); - expect(page.heading).toBe(""); - expect(page.subHeading).toBe(""); - expect(page.summaryHeading).toBe(""); - expect(page.sectionTitle).toBe(""); - expect(page.backUrl).toBe(""); - expect(page.type).toBe("step"); - expect(page.buttonText).toBe(""); - expect(page.buttonVisibility).toBe("visible"); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/public-form-page.ts b/libs/angular-components/src/lib/components/form/public-form-page.ts deleted file mode 100644 index bc3b6623f..000000000 --- a/libs/angular-components/src/lib/components/form/public-form-page.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - Component, - CUSTOM_ELEMENTS_SCHEMA, - EventEmitter, - Input, - Output, -} from "@angular/core"; -import { GoabBaseComponent } from "../base.component"; -import { - GoabPublicFormPageButtonVisibility, - GoabPublicFormPageStep, -} from "@abgov/ui-components-common"; - -@Component({ - selector: "goab-public-form-page", - standalone: true, - template: ` - - - - `, - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class GoabPublicFormPage extends GoabBaseComponent { - @Input() id = ""; - @Input() heading = ""; - @Input() subHeading = ""; - @Input() summaryHeading = ""; - @Input() sectionTitle = ""; - @Input() backUrl = ""; - @Input() type: GoabPublicFormPageStep = "step"; - @Input() buttonText = ""; - @Input() buttonVisibility : GoabPublicFormPageButtonVisibility = "visible"; - - /** - * triggers when the form page continues to the next step - */ - @Output() onContinue = new EventEmitter(); - - _onContinue(event: Event) { - this.onContinue.emit(event); - } -} diff --git a/libs/angular-components/src/lib/components/form/public-form-summary.spec.ts b/libs/angular-components/src/lib/components/form/public-form-summary.spec.ts deleted file mode 100644 index 4518b473e..000000000 --- a/libs/angular-components/src/lib/components/form/public-form-summary.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicFormSummary } from "./public-form-summary"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; - -@Component({ - standalone: true, - imports: [GoabPublicFormSummary], - template: ` - -
Test content
-
- `, -}) -class TestPublicFormSummaryComponent { - heading = "Test Summary Heading"; -} - -describe("GoabPublicFormSummary", () => { - let fixture: ComponentFixture; - let component: TestPublicFormSummaryComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GoabPublicFormSummary, TestPublicFormSummaryComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicFormSummaryComponent); - component = fixture.componentInstance; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-summary")).nativeElement; - - expect(el?.getAttribute("heading")).toBe(component.heading); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should have default values", () => { - const summary = new GoabPublicFormSummary(); - expect(summary.heading).toBeUndefined(); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/public-form-summary.ts b/libs/angular-components/src/lib/components/form/public-form-summary.ts deleted file mode 100644 index dfc281b27..000000000 --- a/libs/angular-components/src/lib/components/form/public-form-summary.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from "@angular/core"; - -@Component({ - selector: "goab-public-form-summary", - standalone: true, - template: ` - - - - `, - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class GoabPublicFormSummary { - @Input() heading?: string; -} diff --git a/libs/angular-components/src/lib/components/form/public-form.spec.ts b/libs/angular-components/src/lib/components/form/public-form.spec.ts deleted file mode 100644 index ce4696f37..000000000 --- a/libs/angular-components/src/lib/components/form/public-form.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicForm } from "./public-form"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { GoabFormState, GoabPublicFormStatus } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabPublicForm], - template: ` - -
Test content
-
- `, -}) -class TestPublicFormComponent { - status: GoabPublicFormStatus = "complete"; - name = "test-form"; - - handleInit(event: Event): void {/** do nothing **/} - handleComplete(event: GoabFormState): void {/** do nothing **/} - handleStateChange(event: GoabFormState): void {/** do nothing **/} -} - -describe("GoabPublicForm", () => { - let fixture: ComponentFixture; - let component: TestPublicFormComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestPublicFormComponent, GoabPublicForm], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicFormComponent); - component = fixture.componentInstance; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form")).nativeElement; - - expect(el?.getAttribute("status")).toBe(component.status); - expect(el?.getAttribute("name")).toBe(component.name); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should emit onInit event", () => { - fixture.detectChanges(); - const spy = jest.spyOn(component, "handleInit"); - - const el = fixture.debugElement.query(By.css("goa-public-form")).nativeElement; - const detail = { - el: document.createElement("form") - }; - - el.dispatchEvent(new CustomEvent("_init", { detail })); - expect(spy).toHaveBeenCalledWith(expect.any(CustomEvent)); - }); - - it("should emit onComplete event", () => { - fixture.detectChanges(); - const spy = jest.spyOn(component, "handleComplete"); - - const el = fixture.debugElement.query(By.css("goa-public-form")).nativeElement; - const detail: GoabFormState = { - uuid: "test-uuid", - form: {}, - history: [], - editting: "", - status: "complete" - }; - - el.dispatchEvent(new CustomEvent("_complete", { detail })); - expect(spy).toHaveBeenCalledWith(detail); - }); - - it("should emit onStateChange event", () => { - fixture.detectChanges(); - const spy = jest.spyOn(component, "handleStateChange"); - - const el = fixture.debugElement.query(By.css("goa-public-form")).nativeElement; - const formState: GoabFormState = { - uuid: "test-uuid", - form: {}, - history: [], - editting: "", - status: "complete" - }; - const detail = { data: formState }; - - el.dispatchEvent(new CustomEvent("_stateChange", { detail })); - expect(spy).toHaveBeenCalledWith(formState); - }); - - it("should set default status to complete", () => { - const publicForm = new GoabPublicForm(); - expect(publicForm.status).toBe("complete"); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/public-form.ts b/libs/angular-components/src/lib/components/form/public-form.ts deleted file mode 100644 index f07e3127a..000000000 --- a/libs/angular-components/src/lib/components/form/public-form.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, EventEmitter, Input, Output } from "@angular/core"; -import { GoabFormState, GoabPublicFormStatus } from "@abgov/ui-components-common"; - -@Component({ - selector: "goab-public-form", - standalone: true, - template: ` - - - - `, - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class GoabPublicForm { - @Input() status?: GoabPublicFormStatus = "complete"; - @Input() name?: string; - - @Output() onInit = new EventEmitter(); - @Output() onComplete = new EventEmitter(); - @Output() onStateChange = new EventEmitter(); - - - _onInit(e: Event) { - this.onInit.emit(e); - } - - _onComplete(e: Event) { - const detail = (e as CustomEvent).detail; - this.onComplete.emit(detail); - } - - _onStateChange(e: Event) { - const detail = (e as CustomEvent).detail; - this.onStateChange.emit(detail.data); - } -} 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-index.ts b/libs/angular-components/src/lib/components/form/public-subform-index.ts deleted file mode 100644 index fc973427e..000000000 --- a/libs/angular-components/src/lib/components/form/public-subform-index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from "@angular/core"; -import { GoabBaseComponent } from "../base.component"; - -@Component({ - selector: "goab-public-subform-index", - standalone: true, - host: { - 'slot': 'subform-index' - }, - template: ` - - - - `, - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class GoabPublicSubformIndex extends GoabBaseComponent { - @Input() heading?: string = ""; - @Input() sectionTitle?: string = ""; - @Input() actionButtonText?: string = ""; - @Input() buttonVisibility?: "visible" | "hidden" = "hidden"; -} 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/public-subform.ts b/libs/angular-components/src/lib/components/form/public-subform.ts deleted file mode 100644 index db51ceaba..000000000 --- a/libs/angular-components/src/lib/components/form/public-subform.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, EventEmitter, Input, Output } from "@angular/core"; -import { GoabBaseComponent } from "../base.component"; - -@Component({ - selector: "goab-public-subform", - standalone: true, - template: ` - - - - `, - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class GoabPublicSubform extends GoabBaseComponent { - @Input() id?: string = ""; - @Input() name?: string = ""; - @Input() continueMsg?: string = ""; - - @Output() onInit = new EventEmitter(); - @Output() onStateChange = new EventEmitter(); - - _onInit(e: Event) { - this.onInit.emit(e); - } - - _onStateChange(e: Event) { - this.onStateChange.emit(e); - } -} diff --git a/libs/angular-components/src/lib/components/form/task-list.spec.ts b/libs/angular-components/src/lib/components/form/task-list.spec.ts deleted file mode 100644 index d2737519e..000000000 --- a/libs/angular-components/src/lib/components/form/task-list.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicFormTaskList } from "./task-list"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; - -@Component({ - standalone: true, - imports: [GoabPublicFormTaskList], - template: ` - -
Task 1
-
Task 2
-
- `, -}) -class TestPublicFormTaskListComponent { - heading = "Required Tasks"; -} - -describe("GoabPublicFormTaskList", () => { - let fixture: ComponentFixture; - let component: TestPublicFormTaskListComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestPublicFormTaskListComponent, GoabPublicFormTaskList], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicFormTaskListComponent); - component = fixture.componentInstance; - }); - - it("should render with heading property", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task-list")).nativeElement; - - expect(el?.getAttribute("heading")).toBe(component.heading); - - // Content is rendered - expect(el?.querySelector("[data-testid='task-item-1']")).toBeTruthy(); - expect(el?.querySelector("[data-testid='task-item-2']")).toBeTruthy(); - }); - - it("should have undefined heading by default", () => { - const taskList = new GoabPublicFormTaskList(); - expect(taskList.heading).toBeUndefined(); - }); - - it("should handle heading changes", () => { - // Initial heading - fixture.detectChanges(); - - let el = fixture.debugElement.query(By.css("goa-public-form-task-list")).nativeElement; - expect(el?.getAttribute("heading")).toBe("Required Tasks"); - - // Change heading - component.heading = "Updated Task List"; - fixture.detectChanges(); - - el = fixture.debugElement.query(By.css("goa-public-form-task-list")).nativeElement; - expect(el?.getAttribute("heading")).toBe("Updated Task List"); - - // Empty heading - component.heading = ""; - fixture.detectChanges(); - - el = fixture.debugElement.query(By.css("goa-public-form-task-list")).nativeElement; - expect(el?.getAttribute("heading")).toBe(""); - }); - - it("should render without heading attribute when undefined", () => { - component.heading = undefined as any; - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task-list")).nativeElement; - expect(el?.hasAttribute("heading")).toBeFalsy(); - }); - - it("should handle multiple nested elements", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task-list")).nativeElement; - const taskItems = el?.querySelectorAll("[data-testid^='task-item']"); - - expect(taskItems?.length).toBe(2); - expect(taskItems?.[0]?.textContent).toContain("Task 1"); - expect(taskItems?.[1]?.textContent).toContain("Task 2"); - }); - - it("should handle special characters in heading", () => { - const specialHeadings = [ - "Tasks & Requirements", - "Tasks > 5", - "Tasks < 10", - "Tasks with \"quotes\"", - "Tasks with 'apostrophes'", - ]; - - specialHeadings.forEach(heading => { - component.heading = heading; - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task-list")).nativeElement; - expect(el?.getAttribute("heading")).toBe(heading); - }); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/task-list.ts b/libs/angular-components/src/lib/components/form/task-list.ts deleted file mode 100644 index e57402ac5..000000000 --- a/libs/angular-components/src/lib/components/form/task-list.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from "@angular/core"; -import { GoabBaseComponent } from "../base.component"; - -@Component({ - selector: "goab-public-form-task-list", - standalone: true, - template: ` - - - - `, - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class GoabPublicFormTaskList extends GoabBaseComponent { - @Input() heading?: string; -} \ No newline at end of file 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/form/task.ts b/libs/angular-components/src/lib/components/form/task.ts deleted file mode 100644 index 7f610db36..000000000 --- a/libs/angular-components/src/lib/components/form/task.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from "@angular/core"; -import { GoabPublicFormTaskStatus } from "@abgov/ui-components-common"; - -@Component({ - selector: "goab-public-form-task", - standalone: true, - template: ` - - - - `, - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class GoabPublicFormTask { - @Input() status?: GoabPublicFormTaskStatus; -} \ No newline at end of file diff --git a/libs/angular-components/src/lib/components/index.ts b/libs/angular-components/src/lib/components/index.ts index 1204e799a..b6174fc02 100644 --- a/libs/angular-components/src/lib/components/index.ts +++ b/libs/angular-components/src/lib/components/index.ts @@ -30,14 +30,6 @@ export * from "./footer-meta-section/footer-meta-section"; export * from "./footer-meta-section/footer-meta-section"; export * from "./footer-nav-section/footer-nav-section"; export * from "./footer-nav-section/footer-nav-section"; -export * from "./form/public-form"; -export * from "./form/public-form-page"; -export * from "./form/public-form-summary"; -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/common.ts b/libs/common/src/lib/common.ts index 014d50c81..80f432808 100644 --- a/libs/common/src/lib/common.ts +++ b/libs/common/src/lib/common.ts @@ -1129,69 +1129,8 @@ export type GoabTextBodySize = "body-l" | "body-m" | "body-s" | "body-xs"; export type GoabTextSize = GoabTextHeadingSize | GoabTextBodySize; export type GoabTextColor = "primary" | "secondary"; -// Simple Form -export type GoabFormField = { - label: string; - value: string; -}; - -export type GoabFormStorageType = "none" | "local" | "session"; -export type GoabFormOnMountDetail = { - fn: (next: string) => void; -}; -export type GoabFormOnStateChange = { - id: string; - state: Record>; -}; - -// Common types to use between public form components -export type GoabFormStatus = - | "not-started" - | "cannot-start-yet" - | "in-progress" - | "submitted" - | "update-needed" - | "complete"; -export type GoabFormState = { - uuid: string; - form: Record; - history: string[]; - editting: string; - lastModified?: Date; - status: GoabFormStatus; -}; -export type GoabFormDispatchOn = "change" | "continue"; -export type GoabFieldsetItemValue = string | number | Date; -export type GoabFieldsetItemState = { - name: string; - label: string; - value: GoabFieldsetItemValue; - order: number; - valueLabel?: string; -}; - -// Fieldset component -export type GoabFieldsetData = - | { type: "details"; fieldsets: Record } - | { type: "list"; items: GoabFormState[] }; - -export type GoabFieldsetSchema = { - heading?: string; - data?: GoabFieldsetData; -}; - -export interface GoabFieldsetOnContinueDetail { - el: HTMLElement; - state: Record; - cancelled: boolean; -} - -// Public form component -export type GoabPublicFormStatus = "initializing" | "complete"; -export type GoabPublicFormPageStep = "step" | "summary" | "multistep"; -export type GoabPublicFormPageButtonVisibility = "visible" | "hidden"; - // Public form Task +// TODO: remove this later. It is kept in now as a reminder export type GoabPublicFormTaskStatus = "completed" | "not-started" | "cannot-start"; // Drawer 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/specs/datepicker.browser.spec.tsx b/libs/react-components/specs/datepicker.browser.spec.tsx index 2cd6fbb8d..479c2e0d6 100644 --- a/libs/react-components/specs/datepicker.browser.spec.tsx +++ b/libs/react-components/specs/datepicker.browser.spec.tsx @@ -329,7 +329,7 @@ describe("Date Picker input type", () => { const result = render(); const datePickerMonth = result.getByTestId("input-month"); - const datePickerMonthMarch = result.getByTestId("dropdown-item-3"); + const datePickerMonthMarch = result.getByTestId("dropdown-item-3"); // march = 3 const datePickerDay = result.getByTestId("input-day"); const datePickerYear = result.getByTestId("input-year"); @@ -352,7 +352,7 @@ describe("Date Picker input type", () => { rootElChangeHandler.mockClear(); // Input day - await datePickerDay.click(); + await userEvent.click(datePickerDay); await userEvent.type(datePickerDay, "1"); // Select month diff --git a/libs/react-components/specs/public-form.browser.spec.tsx b/libs/react-components/specs/public-form.browser.spec.tsx new file mode 100644 index 000000000..9632b5281 --- /dev/null +++ b/libs/react-components/specs/public-form.browser.spec.tsx @@ -0,0 +1,415 @@ +import { render } from "vitest-browser-react"; +import { + GoabPublicForm, + GoabPublicFormPage, + GoabPublicFormSummary, + GoabFormItem, + GoabRadioGroup, + GoabRadioItem, + GoabInput, +} from "../src"; +import { expect, describe, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import { useState } from "react"; +import { + RequiredValidator, + LengthValidator, + SINValidator, + NumericValidator, +} from "@abgov/ui-components-common"; + +import type { + PFState, + PFOutline, + PFPage, +} from "@abgov/ui-components-common"; + +const outline: PFOutline = { + role: { + subform: false, + props: { + heading: "What is your role in the court order?", + "section-title": "Support order details", + }, + fields: { + role: { + label: "What is your role?", + formatter: (val: string) => val.toUpperCase(), + hideInSummary: "never", + }, + }, + next: (state: PFState) => { + const role = state.dataBuffer["role"]; + return role === "Payor" ? "salary" : "children"; + }, + validators: { + role: [RequiredValidator("Role is required")], + }, + }, + + children: { + subform: true, + props: { + heading: "Do you have children", + }, + fields: { + "first-name": { + label: "First name", + formatter: (val: string) => val[0]?.toUpperCase() + val.substring(1), + hideInSummary: "never", + }, + "last-name": { + label: "Last name", + formatter: (val: string) => val[0]?.toUpperCase() + val.substring(1), + hideInSummary: "never", + }, + birthdate: { label: "Birthdate", hideInSummary: "never" }, + }, + next: "identification", + validators: {}, + }, + + salary: { + subform: false, + props: { + heading: "Payor salary", + }, + fields: { + salary: { label: "Yearly income", hideInSummary: "never" }, + }, + next: "summary", + validators: { + salary: [NumericValidator({ min: 0 })], + }, + }, + + identification: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Do you know any of the identifiers about the other party?", + }, + fields: { + sin: { + label: "Social Insurance #", + formatter: (val: string) => val.match(/(.{3})/g)?.join(" ") || val, + hideInSummary: "never", + }, + ahcn: { + label: "Alberta Health Care #", + formatter: (val: string) => val.match(/(.{4})/g)?.join("-") || val, + hideInSummary: "never", + }, + info: { label: "Additional information", hideInSummary: "never" }, + }, + next: (state: PFState): string => { + const sin = state.dataBuffer["sin"]; + const ahcn = state.dataBuffer["ahcn"]; + + if (!sin && !ahcn) { + throw "Either sin or ahcn is required"; + } + + return "address"; + }, + validators: { + sin: [SINValidator()], + ahcn: [LengthValidator({ min: 8 })], + }, + }, + + payor: { + subform: false, + props: { + heading: "Payor Name", + }, + fields: { + firstName: { label: "First name", hideInSummary: "never" }, + lastName: { label: "Last name", hideInSummary: "never" }, + }, + summarize: (page: PFPage) => ({ + "Full name": `${page["firstName"]} ${page["lastName"]}`.trim(), + }), + next: "address", + validators: {}, + }, + + address: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Your current address", + }, + fields: { + city: { label: "City/Town", hideInSummary: "never" }, + street: { label: "Street #", hideInSummary: "never" }, + "postal-code": { label: "Postal code", hideInSummary: "never" }, + }, + next: "summary", + validators: {}, + }, + + summary: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Summary", + }, + fields: {}, + next: (state: PFState): string => { + console.log("submit to backend here", state); + return ""; + }, + validators: {}, + }, +}; + +function PublicFormTestComponent() { + const [state, setState] = useState(undefined); + + const handleInit = (initFn: any) => { + // Initialize with restored state + const initialState = initFn(null, { outline }); + setState(initialState); + }; + + const handleChange = (detail: any) => { + console.log("onChange", detail); + }; + + const handleNext = (newState: PFState) => { + setState(newState); + console.log("onNext", newState); + }; + + const handleSubformChange = (newState: PFState) => { + setState({ ...newState }); + console.log("onSubformChange", newState); + }; + + const getPage = (pageId: string, defaultValue: unknown) => { + return state?.data?.[pageId] || defaultValue; + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +describe("PublicForm Browser Tests", () => { + + it("should pass", async () => { + const { getByLabelText } = render(<> + + + ); + + const name = getByLabelText("Full name"); + const nickName = getByLabelText("nick-name"); + + await vi.waitFor(() => { + expect(name.length).toBe(1); + expect(nickName.length).toBe(1); + // console.log(name.element()); + }) + }) + + it("initializes the form", async () => { + const { getByRole, getByLabelText } = render(); + + const roles = getByRole("radiogroup"); + const sin = getByLabelText("sin"); + + // Wait for form to initialize + await vi.waitFor(() => { + // on first page, so it should be visible + expect(roles.length).toBe(1); + + // sin should not be visible yet + expect(sin.length).toBe(1); + expect(sin).not.toBeVisible(); + }); + }); + + it.only("can select a radio button and continue to next page", async () => { + const { getByLabelText, getByRole, getByText } = render(); + + const radios = getByRole("radio"); + const receiverOption = getByLabelText("Recipient"); + + // Wait for radio items to be present + await vi.waitFor( () => { + expect(radios.length).toBe(2); + expect(receiverOption.length).toBe(1); + }); + + // FAILS HERE: saying that it can't click it as it is not visible or not stable + // Select the Recipient radio button + // await userEvent.click(receiverOption.element()); + // await radios.first().click(); + // await radios[0].click(); + await receiverOption.click(); + + // Click continue button + const continueButton = getByRole("button").and(getByText("Continue")); + await continueButton.click(); + + // // Should navigate to children page (for Recipient role) + const nextHeader = getByText("Support order details"); + await vi.waitFor(() => { + expect(nextHeader).toBeTruthy(); + }); + }); + + it("validates required fields and shows errors", async () => { + const result = render(); + + await vi.waitFor(() => { + const form = result.container.querySelector("goa-public-form"); + expect(form).toBeTruthy(); + }); + + // Wait for form to initialize + await vi.waitFor( + () => { + const continueButton = result.container.querySelector( + 'goa-button[type="primary"]' + ); + expect(continueButton).toBeTruthy(); + }, + { timeout: 3000 } + ); + + // Try to click continue without selecting a role + const continueButton = result.container.querySelector( + 'goa-button[type="primary"]' + ) as HTMLElement; + await userEvent.click(continueButton); + + // Should show validation error + await vi.waitFor( + () => { + const formItem = result.container.querySelector("goa-form-item[error]"); + expect(formItem).toBeTruthy(); + }, + { timeout: 2000 } + ); + }); + + it("supports conditional navigation based on form data", async () => { + const result = render(); + + await vi.waitFor(() => { + const form = result.container.querySelector("goa-public-form"); + expect(form).toBeTruthy(); + }); + + // Wait for radio items + await vi.waitFor( + () => { + const payorRadio = result.container.querySelector( + 'goa-radio-item[value="Payor"]' + ); + expect(payorRadio).toBeTruthy(); + }, + { timeout: 3000 } + ); + + // Select Payor (should go to salary page instead of children) + const payorRadio = result.container.querySelector( + 'goa-radio-item[value="Payor"]' + ) as HTMLElement; + await userEvent.click(payorRadio); + + // Click continue + const continueButton = result.container.querySelector( + 'goa-button[type="primary"]' + ) as HTMLElement; + await userEvent.click(continueButton); + + // Should navigate to salary page (not children) + await vi.waitFor( + () => { + const salaryPage = result.container.querySelector( + 'goa-public-form-page[id="salary"]' + ); + expect(salaryPage).toBeTruthy(); + }, + { timeout: 2000 } + ); + }); + + it("renders the summary page with form data", async () => { + const result = render(); + + await vi.waitFor(() => { + const summary = result.container.querySelector("goa-public-form-summary"); + expect(summary).toBeTruthy(); + }); + }); +}); diff --git a/libs/react-components/src/index.ts b/libs/react-components/src/index.ts index 693d58c97..b08a6f59f 100644 --- a/libs/react-components/src/index.ts +++ b/libs/react-components/src/index.ts @@ -25,12 +25,10 @@ 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"; export * from "./lib/form/public-subform"; -export * from "./lib/form/public-subform-index"; export * from "./lib/form/task"; export * from "./lib/form/task-list"; export * from "./lib/form-item/form-item"; @@ -73,4 +71,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.spec.tsx b/libs/react-components/src/lib/form/fieldset.spec.tsx deleted file mode 100644 index df8faa3ac..000000000 --- a/libs/react-components/src/lib/form/fieldset.spec.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { GoabFieldset } from "./fieldset"; -import { GoabFieldsetOnContinueDetail, GoabFieldsetItemState } from "@abgov/ui-components-common"; - -describe("GoabFieldset", () => { - it("renders with all properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-fieldset"); - expect(el?.getAttribute("id")).toBe("testFieldset"); - expect(el?.getAttribute("section-title")).toBe("Test Section"); - expect(el?.getAttribute("dispatch-on")).toBe("change"); - expect(el?.textContent).toBe("Test content"); - }); - - it("handles onContinue event", () => { - const handleContinue = vi.fn(); - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-fieldset"); - const detail: GoabFieldsetOnContinueDetail = { - el: el as HTMLElement, - state: { - field1: { - name: "field1", - label: "Field 1", - value: "test", - order: 1 - } - }, - cancelled: false - }; - const event = new CustomEvent("_continue", { detail }); - el?.dispatchEvent(event); - - expect(handleContinue).toHaveBeenCalledWith(detail); - }); - - it("removes event listeners on unmount", () => { - const handleContinue = vi.fn(); - const { baseElement, unmount } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-fieldset"); - unmount(); - - // After unmount, events should not trigger callbacks - const mockData: Record = { - field1: { - name: "field1", - label: "Field 1", - value: "test", - order: 1 - } - }; - const continueEvent = new CustomEvent("_continue", { - detail: { - el: el as HTMLElement, - state: mockData, - cancelled: false - } - }); - el?.dispatchEvent(continueEvent); - - expect(handleContinue).not.toHaveBeenCalled(); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-fieldset"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); 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-form-page.spec.tsx b/libs/react-components/src/lib/form/public-form-page.spec.tsx deleted file mode 100644 index 97463b9b7..000000000 --- a/libs/react-components/src/lib/form/public-form-page.spec.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { GoabPublicFormPage } from "./public-form-page"; -import { - GoabFieldsetItemState -} from "@abgov/ui-components-common"; - -describe("GoabPublicFormPage", () => { - it("renders with all properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-form-page"); - expect(el?.getAttribute("id")).toBe("testPage"); - expect(el?.getAttribute("heading")).toBe("Test Heading"); - expect(el?.getAttribute("sub-heading")).toBe("Test Subheading"); - expect(el?.getAttribute("summary-heading")).toBe("Test Summary"); - expect(el?.getAttribute("section-title")).toBe("Test Section"); - expect(el?.getAttribute("back-url")).toBe("/back"); - expect(el?.getAttribute("type")).toBe("step"); - expect(el?.getAttribute("button-text")).toBe("Continue"); - expect(el?.getAttribute("button-visibility")).toBe("visible"); - expect(el?.getAttribute("mt")).toBe("s"); - expect(el?.getAttribute("mr")).toBe("m"); - expect(el?.getAttribute("mb")).toBe("l"); - expect(el?.getAttribute("ml")).toBe("xl"); - expect(el?.textContent).toBe("Test content"); - }); - - it("handles onContinue event", () => { - const handleContinue = vi.fn(); - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-form-page"); - const mockState: Record = { - field1: { - name: "field1", - label: "Field 1", - value: "test", - order: 1 - } - }; - const detail = { - el: el as HTMLElement, - state: mockState, - cancelled: false - }; - const event = new CustomEvent("_continue", { detail }); - el?.dispatchEvent(event); - - expect(handleContinue).toHaveBeenCalledWith(event); - }); - - it("removes event listeners on unmount", () => { - const handleContinue = vi.fn(); - - const { baseElement, unmount } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-form-page"); - unmount(); - - // After unmount, events should not trigger callbacks - const mockState: Record = { - field1: { - name: "field1", - label: "Field 1", - value: "test", - order: 1 - } - }; - - const continueEvent = new CustomEvent("_continue", { - detail: { - el: el as HTMLElement, - state: mockState, - cancelled: false - } - }); - - el?.dispatchEvent(continueEvent); - - expect(handleContinue).not.toHaveBeenCalled(); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-public-form-page"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); diff --git a/libs/react-components/src/lib/form/public-form-page.tsx b/libs/react-components/src/lib/form/public-form-page.tsx index 1c38ccfe2..6f5cee3db 100644 --- a/libs/react-components/src/lib/form/public-form-page.tsx +++ b/libs/react-components/src/lib/form/public-form-page.tsx @@ -1,85 +1,61 @@ -import { ReactNode, useEffect, useRef } from "react"; -import { - GoabPublicFormPageButtonVisibility, - GoabPublicFormPageStep, - Margins, DataAttributes, -} from "@abgov/ui-components-common"; +import { ReactNode } from "react"; +import { DataAttributes } from "@abgov/ui-components-common"; import { transformProps, kebab } from "../common/extract-props"; -interface WCProps extends Margins { +interface WCProps { id?: string; heading?: string; "sub-heading"?: string; "section-title"?: string; - "back-url"?: string; - type?: string; "button-text"?: string; - "button-visibility"?: string; - "summary-heading"?: string; + "back-visibility"?: string; + error?: string; + "data-pf-editting"?: string; } declare module "react" { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { interface IntrinsicElements { - "goa-public-form-page": WCProps & React.HTMLAttributes & { - ref: React.RefObject; - }; + "goa-public-form-page": WCProps & React.HTMLAttributes; } } } -interface GoabPublicFormPageProps extends Margins, DataAttributes { +interface GoabPublicFormPageProps extends DataAttributes { id?: string; heading?: string; subHeading?: string; - summaryHeading?: string; sectionTitle?: string; - backUrl?: string; - type?: GoabPublicFormPageStep; buttonText?: string; - buttonVisibility?: GoabPublicFormPageButtonVisibility; - /** - * Triggered when the form page continues to the next step - * @param event - The continue event details - */ - onContinue?: (event: Event) => void; + backVisibility?: "visible" | "hidden"; + error?: string; + editting?: boolean; children: ReactNode; } export function GoabPublicFormPage({ - onContinue, + subHeading, + sectionTitle, + buttonText, + backVisibility, + editting, children, ...rest }: GoabPublicFormPageProps) { - const ref = useRef(null); - - const _props = transformProps(rest, kebab); - - useEffect(() => { - if (!ref.current) return; - const current = ref.current; - - const continueListener = (e: Event) => { - onContinue?.(e); - }; - - if (onContinue) { - current.addEventListener("_continue", continueListener); - } - - return () => { - if (onContinue) { - current.removeEventListener("_continue", continueListener); - } - }; - }, [ref, onContinue]); - - return ( - - {children} - + const _props = transformProps( + { + ...rest, + "sub-heading": subHeading, + "section-title": sectionTitle, + "button-text": buttonText, + "back-visibility": backVisibility, + "data-pf-editting": editting ? "true" : undefined, + }, + kebab ); + + return {children}; } export default GoabPublicFormPage; diff --git a/libs/react-components/src/lib/form/public-form-summary.spec.tsx b/libs/react-components/src/lib/form/public-form-summary.spec.tsx deleted file mode 100644 index 2af53223b..000000000 --- a/libs/react-components/src/lib/form/public-form-summary.spec.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { GoabPublicFormSummary } from "./public-form-summary"; - -describe("GoabPublicFormSummary", () => { - it("renders with default properties", () => { - const { baseElement } = render( - - ); - - const el = baseElement.querySelector("goa-public-form-summary"); - expect(el).toBeTruthy(); - expect(el?.getAttribute("heading")).toBe(""); - }); - - it("renders with custom heading", () => { - const heading = "Test Heading"; - const { baseElement } = render( - - ); - - const el = baseElement.querySelector("goa-public-form-summary"); - expect(el).toBeTruthy(); - expect(el?.getAttribute("heading")).toBe(heading); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - - ); - const el = baseElement.querySelector("goa-public-form-summary"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); diff --git a/libs/react-components/src/lib/form/public-form.spec.tsx b/libs/react-components/src/lib/form/public-form.spec.tsx deleted file mode 100644 index c146dd2c5..000000000 --- a/libs/react-components/src/lib/form/public-form.spec.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { GoabPublicForm } from "./public-form"; -import { GoabFormState } from "@abgov/ui-components-common"; - -describe("GoabPublicForm", () => { - it("renders with all properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-form"); - expect(el?.getAttribute("status")).toBe("complete"); - expect(el?.getAttribute("name")).toBe("testForm"); - expect(el?.textContent).toBe("Test content"); - }); - - it("handles onInit event", () => { - const handleInit = vi.fn(); - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-form"); - const mockFormElement = document.createElement("form"); - const detail = { - el: mockFormElement - }; - const event = new CustomEvent("_init", { detail }); - el?.dispatchEvent(event); - - expect(handleInit).toHaveBeenCalledWith(event); - }); - - it("handles onComplete event", () => { - const handleComplete = vi.fn(); - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-form"); - const mockFormState: GoabFormState = { - uuid: "test-uuid", - form: {}, - history: [], - editting: "", - status: "complete" - }; - const event = new CustomEvent("_complete", { detail: mockFormState }); - el?.dispatchEvent(event); - - expect(handleComplete).toHaveBeenCalledWith(mockFormState); - }); - - it("handles onStateChange event", () => { - const handleStateChange = vi.fn(); - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-form"); - const mockFormState: GoabFormState = { - uuid: "test-uuid", - form: {}, - history: [], - editting: "", - status: "complete" - }; - const event = new CustomEvent("_stateChange", { - detail: { data: mockFormState } - }); - el?.dispatchEvent(event); - - expect(handleStateChange).toHaveBeenCalledWith(mockFormState); - }); - - it("removes event listeners on unmount", () => { - const handleInit = vi.fn(); - const handleComplete = vi.fn(); - const handleStateChange = vi.fn(); - - const { baseElement, unmount } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-form"); - unmount(); - - // After unmount, events should not trigger callbacks - const mockFormElement = document.createElement("form"); - const initEvent = new CustomEvent("_init", { - detail: { el: mockFormElement } - }); - - const mockFormState: GoabFormState = { - uuid: "test-uuid", - form: {}, - history: [], - editting: "", - status: "complete" - }; - - const completeEvent = new CustomEvent("_complete", { - detail: mockFormState - }); - - const stateChangeEvent = new CustomEvent("_stateChange", { - detail: { data: mockFormState } - }); - - el?.dispatchEvent(initEvent); - el?.dispatchEvent(completeEvent); - el?.dispatchEvent(stateChangeEvent); - - expect(handleInit).not.toHaveBeenCalled(); - expect(handleComplete).not.toHaveBeenCalled(); - expect(handleStateChange).not.toHaveBeenCalled(); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-public-form"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); diff --git a/libs/react-components/src/lib/form/public-form.tsx b/libs/react-components/src/lib/form/public-form.tsx index 3a81fa305..4a1ceb5d1 100644 --- a/libs/react-components/src/lib/form/public-form.tsx +++ b/libs/react-components/src/lib/form/public-form.tsx @@ -1,16 +1,14 @@ -import { ReactNode, useRef, useLayoutEffect } from "react"; +import { ReactNode, useRef, useEffect } from "react"; import { DataAttributes, - GoabFormState, - GoabPublicFormStatus, + Margins, + PFState, + PFOutline, } from "@abgov/ui-components-common"; import { transformProps, lowercase } from "../common/extract-props"; -interface WCProps { - status?: string; - name?: string; -} +interface WCProps extends Margins {} declare module "react" { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -23,71 +21,70 @@ declare module "react" { } } -interface GoabPublicFormProps extends DataAttributes { - status?: GoabPublicFormStatus; - name?: string; - onInit?: (event: Event) => void; - onComplete?: (event: GoabFormState) => void; - onStateChange?: (event: GoabFormState) => void; +type InitFunction = (data: PFState, props: { outline: PFOutline }) => PFState; + +interface GoabPublicFormChangeDetail { + state: PFState; + name: string; + value: string; +} + +interface GoabPublicFormProps extends Margins, DataAttributes { + onInit?: (initFn: InitFunction) => void; + onChange?: (detail: GoabPublicFormChangeDetail) => void; + onNext?: (state: PFState) => void; + onSubformChange?: (state: PFState) => void; children: ReactNode; } export function GoabPublicForm({ onInit, - onComplete, - onStateChange, + onChange, + onNext, + onSubformChange, children, ...rest }: GoabPublicFormProps) { const ref = useRef(null); - const initialized = useRef(false); const _props = transformProps(rest, lowercase); - // Use useLayoutEffect to set up listeners before the component mounts - useLayoutEffect(() => { + useEffect(() => { if (!ref.current) return; const current = ref.current; const initListener = (e: Event) => { - onInit?.(e); + const initFn = (e as CustomEvent).detail; + onInit?.(initFn); }; - // First time initialization, add init listener immediately - if (onInit && !initialized.current) { - current.addEventListener("_init", initListener); - } - - const completeListener = (e: Event) => { - const detail = (e as CustomEvent).detail; - onComplete?.(detail); + const changeListener = (e: Event) => { + const detail = (e as CustomEvent).detail; + onChange?.(detail); }; - const stateChangeListener = (e: Event) => { - const detail = (e as CustomEvent).detail; - onStateChange?.(detail.data); + const nextListener = (e: Event) => { + const state = (e as CustomEvent).detail; + onNext?.(state); }; - if (onComplete) { - current.addEventListener("_complete", completeListener); - } + const subformChangeListener = (e: Event) => { + const state = (e as CustomEvent).detail; + onSubformChange?.(state); + }; - if (onStateChange) { - current.addEventListener("_stateChange", stateChangeListener); - } + current.addEventListener("_init", initListener); + current.addEventListener("_change", changeListener); + current.addEventListener("_next", nextListener); + current.addEventListener("_subformChange", subformChangeListener); return () => { - if (onInit) { - current.removeEventListener("_init", initListener); - } - if (onComplete) { - current.removeEventListener("_complete", completeListener); - } - if (onStateChange) { - current.removeEventListener("_stateChange", stateChangeListener); - } + current.removeEventListener("_init", initListener); + current.removeEventListener("_change", changeListener); + current.removeEventListener("_next", nextListener); + current.removeEventListener("_subformChange", subformChangeListener); }; - }, [onInit, onComplete, onStateChange]); + }, [onInit, onChange, onNext, onSubformChange]); return ( 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-index.tsx b/libs/react-components/src/lib/form/public-subform-index.tsx deleted file mode 100644 index b540c7677..000000000 --- a/libs/react-components/src/lib/form/public-subform-index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { ReactNode } from "react"; -import { Margins, DataAttributes } from "@abgov/ui-components-common"; -import { transformProps, kebab } from "../common/extract-props"; - -interface WCProps extends Margins { - heading?: string; - "section-title"?: string; - "action-button-text"?: string; - "button-visibility"?: string; -} - -declare module "react" { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - interface IntrinsicElements { - "goa-public-subform-index": WCProps & React.HTMLAttributes; - } - } -} - -interface GoabPublicSubformIndexProps extends Margins, DataAttributes { - heading?: string; - sectionTitle?: string; - actionButtonText?: string; - buttonVisibility?: "visible" | "hidden"; - children: ReactNode; -} - -export function GoabPublicSubformIndex({ - heading = "", - sectionTitle = "", - actionButtonText = "", - buttonVisibility = "hidden", - children, - ...rest -}: GoabPublicSubformIndexProps) { - const _props = transformProps( - { heading, "section-title": sectionTitle, "action-button-text": actionButtonText, "button-visibility": buttonVisibility, ...rest }, - kebab - ); - - return ( - - {children} - - ); -} - -export default GoabPublicSubformIndex; 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/form/public-subform.tsx b/libs/react-components/src/lib/form/public-subform.tsx index 30b3f78e9..fdc582d50 100644 --- a/libs/react-components/src/lib/form/public-subform.tsx +++ b/libs/react-components/src/lib/form/public-subform.tsx @@ -1,83 +1,36 @@ -import { ReactNode, useEffect, useRef } from "react"; -import { Margins, DataAttributes } from "@abgov/ui-components-common"; -import { transformProps, kebab } from "../common/extract-props"; +import { ReactNode } from "react"; +import { DataAttributes } from "@abgov/ui-components-common"; -interface WCProps extends Margins { - id?: string; - name?: string; - "continue-msg"?: string; -} +interface WCProps {} declare module "react" { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { interface IntrinsicElements { - "goa-public-subform": WCProps & React.HTMLAttributes & { - ref: React.RefObject; - }; + "goa-pf-subform": WCProps & + React.HTMLAttributes & { + children?: React.ReactNode; + }; } } } -interface GoabPublicSubformProps extends Margins, DataAttributes { - id?: string; - name?: string; - continueMsg?: string; - onInit?: (event: Event) => void; - onStateChange?: (event: Event) => void; - children: ReactNode; +interface GoabPfSubformProps extends DataAttributes { + formContent?: ReactNode; + children?: ReactNode; } -export function GoabPublicSubform({ - id = "", - name = "", - continueMsg = "", - onInit, - onStateChange, +export function GoabPfSubform({ + formContent, children, ...rest -}: GoabPublicSubformProps) { - const ref = useRef(null); - - const _props = transformProps( - { id, name, "continue-msg": continueMsg, ...rest }, - kebab - ); - - useEffect(() => { - if (!ref.current) return; - const current = ref.current; - - const initListener = (e: Event) => { - onInit?.(e); - }; - - const stateChangeListener = (e: Event) => { - onStateChange?.(e); - }; - - if (onInit) { - current.addEventListener("_init", initListener); - } - if (onStateChange) { - current.addEventListener("_stateChange", stateChangeListener); - } - - return () => { - if (onInit) { - current.removeEventListener("_init", initListener); - } - if (onStateChange) { - current.removeEventListener("_stateChange", stateChangeListener); - } - }; - }, [ref, onInit, onStateChange]); - +}: GoabPfSubformProps) { return ( - + + {formContent &&
{formContent}
} {children} -
+ ); } -export default GoabPublicSubform; +export default GoabPfSubform; diff --git a/libs/react-components/src/lib/form/task-list.spec.tsx b/libs/react-components/src/lib/form/task-list.spec.tsx deleted file mode 100644 index 072754f28..000000000 --- a/libs/react-components/src/lib/form/task-list.spec.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { GoabPublicFormTaskList } from "./task-list"; - -describe("GoabPublicFormTaskList", () => { - it("renders with heading property", () => { - const { baseElement } = render( - -
Task 1
-
Task 2
-
- ); - - const el = baseElement.querySelector("goa-public-form-task-list"); - expect(el?.getAttribute("heading")).toBe("Required Tasks"); - - // Content is rendered - expect(baseElement.querySelector("[data-testid='task-item-1']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='task-item-2']")).toBeTruthy(); - }); - - it("renders with different heading values", () => { - const headings = [ - "Application Tasks", - "Required Steps", - "To-Do Items", - "Checklist", - ]; - - headings.forEach(heading => { - const { baseElement, unmount } = render( - -
Task content
-
- ); - - const el = baseElement.querySelector("goa-public-form-task-list"); - expect(el?.getAttribute("heading")).toBe(heading); - - unmount(); - }); - }); - - it("renders complex children content with task items", () => { - const { baseElement } = render( - -
- ✓ Complete profile information - Completed -
-
- 📝 Upload documents - In Progress -
-
- ⏳ Submit application - Pending -
-
- ); - - expect(baseElement.querySelector("[data-testid='task-completed']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='task-in-progress']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='task-pending']")).toBeTruthy(); - expect(baseElement.querySelectorAll(".task-item")).toHaveLength(3); - expect(baseElement.querySelectorAll(".status")).toHaveLength(3); - }); - - it("renders nested task components", () => { - const { baseElement } = render( - -
-

Personal Information

-
    -
  • Fill out name fields
  • -
  • Provide contact details
  • -
-
-
-

Document Upload

- -
-
- ); - - expect(baseElement.querySelector("[data-testid='task-1']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='task-2']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='subtask-1']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='subtask-2']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='upload-btn']")).toBeTruthy(); - expect(baseElement.querySelectorAll("h3")).toHaveLength(2); - expect(baseElement.querySelectorAll("li")).toHaveLength(2); - }); - - it("handles special characters in heading", () => { - const specialHeadings = [ - "Tasks & Requirements", - "Steps > Complete", - "Items < 5", - 'Tasks with "quotes"', - "Tasks with 'apostrophes'", - "Tasks with numbers: 123", - "Tasks with symbols: @#$%", - ]; - - specialHeadings.forEach(heading => { - const { baseElement, unmount } = render( - -
Task content
-
- ); - - const el = baseElement.querySelector("goa-public-form-task-list"); - expect(el?.getAttribute("heading")).toBe(heading); - - unmount(); - }); - }); - - it("renders empty task list", () => { - const { baseElement } = render( - - {null} - - ); - - const el = baseElement.querySelector("goa-public-form-task-list"); - expect(el).toBeTruthy(); - expect(el?.getAttribute("heading")).toBe("Empty List"); - }); - - it("renders single task item", () => { - const { baseElement } = render( - -
Complete registration
-
- ); - - const el = baseElement.querySelector("goa-public-form-task-list"); - expect(el?.getAttribute("heading")).toBe("Single Task"); - expect(baseElement.querySelector("[data-testid='only-task']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='only-task']")?.textContent).toBe("Complete registration"); - }); - - it("renders with interactive elements", () => { - const { baseElement } = render( - -
- Review application - - -
-
- Submit documents - Need help? -
-
- -
-
- ); - - expect(baseElement.querySelector("[data-testid='edit-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='delete-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='help-link']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='checkbox']")).toBeTruthy(); - expect(baseElement.querySelectorAll("button")).toHaveLength(2); - expect(baseElement.querySelectorAll("a")).toHaveLength(1); - expect(baseElement.querySelectorAll("input")).toHaveLength(1); - }); - - it("renders with long heading text", () => { - const longHeading = "This is a very long heading that might wrap to multiple lines and should still be handled correctly by the component"; - - const { baseElement } = render( - -
Task content
-
- ); - - const el = baseElement.querySelector("goa-public-form-task-list"); - expect(el?.getAttribute("heading")).toBe(longHeading); - }); - - it("renders with multiple task groups", () => { - const { baseElement } = render( - -
-

Personal Details

-
Enter full name
-
Provide email
-
-
-

Professional Details

-
Add work experience
-
Upload resume
-
-
-

Final Steps

-
Review information
-
Submit application
-
-
- ); - - expect(baseElement.querySelectorAll("[data-testid^='group-']")).toHaveLength(3); - expect(baseElement.querySelectorAll("h4")).toHaveLength(3); - expect(baseElement.querySelectorAll("[data-testid^='task-']")).toHaveLength(6); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-public-form-task-list"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); \ No newline at end of file diff --git a/libs/react-components/src/lib/form/task.spec.tsx b/libs/react-components/src/lib/form/task.spec.tsx deleted file mode 100644 index bbe5eb745..000000000 --- a/libs/react-components/src/lib/form/task.spec.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { GoabPublicFormTask } from "./task"; -import { GoabPublicFormTaskStatus } from "@abgov/ui-components-common"; - -describe("GoabPublicFormTask", () => { - it("renders with status property", () => { - const { baseElement } = render( - -
Complete profile
-
- ); - - const el = baseElement.querySelector("goa-public-form-task"); - expect(el?.getAttribute("status")).toBe("completed"); - - // Content is rendered - expect(baseElement.querySelector("[data-testid='task-content']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='task-content']")?.textContent).toBe("Complete profile"); - }); - - it("handles all valid status values", () => { - const statuses: GoabPublicFormTaskStatus[] = ["completed", "not-started", "cannot-start"]; - - statuses.forEach(status => { - const { baseElement, unmount } = render( - -
Task content
-
- ); - - const el = baseElement.querySelector("goa-public-form-task"); - expect(el?.getAttribute("status")).toBe(status); - - unmount(); - }); - }); - - it("renders with completed status", () => { - const { baseElement } = render( - - ✓ Application submitted - - ); - - const el = baseElement.querySelector("goa-public-form-task"); - expect(el?.getAttribute("status")).toBe("completed"); - expect(baseElement.textContent).toContain("✓ Application submitted"); - }); - - it("renders with not-started status", () => { - const { baseElement } = render( - - 📝 Upload documents - - ); - - const el = baseElement.querySelector("goa-public-form-task"); - expect(el?.getAttribute("status")).toBe("not-started"); - expect(baseElement.textContent).toContain("📝 Upload documents"); - }); - - it("renders with cannot-start status", () => { - const { baseElement } = render( - - 🔒 Final submission (complete other tasks first) - - ); - - const el = baseElement.querySelector("goa-public-form-task"); - expect(el?.getAttribute("status")).toBe("cannot-start"); - expect(baseElement.textContent).toContain("🔒 Final submission (complete other tasks first)"); - }); - - it("renders complex children content", () => { - const { baseElement } = render( - -
-

Complete Personal Information

-

Fill out all required fields in your profile

-
-
- - -
-
- ); - - expect(baseElement.querySelector("[data-testid='task-header']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='task-actions']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='start-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='skip-btn']")).toBeTruthy(); - expect(baseElement.querySelector("h3")?.textContent).toBe("Complete Personal Information"); - expect(baseElement.querySelector("p")?.textContent).toBe("Fill out all required fields in your profile"); - }); - - it("renders with interactive elements", () => { - const { baseElement } = render( - -
- Review application details -
- - - More details -
-
-
- ); - - expect(baseElement.querySelector("[data-testid='edit-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='view-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='details-link']")).toBeTruthy(); - expect(baseElement.querySelectorAll("button")).toHaveLength(2); - expect(baseElement.querySelectorAll("a")).toHaveLength(1); - }); - - it("renders with form elements", () => { - const { baseElement } = render( - -
- -
- - -
-
-
- ); - - expect(baseElement.querySelector("[data-testid='agree-checkbox']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='name-input']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='country-select']")).toBeTruthy(); - expect(baseElement.querySelectorAll("input")).toHaveLength(2); - expect(baseElement.querySelectorAll("select")).toHaveLength(1); - }); - - it("renders nested task components", () => { - const { baseElement } = render( - -
-

Application Process

-
-
- Step 1: Personal Information - Required -
-
- Step 2: Document Upload - Optional -
-
- Step 3: Review & Submit - Required -
-
-
-
- ); - - expect(baseElement.querySelector("[data-testid='main-task']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='subtasks']")).toBeTruthy(); - expect(baseElement.querySelectorAll("[data-testid^='subtask-']")).toHaveLength(3); - expect(baseElement.querySelectorAll(".subtask")).toHaveLength(3); - expect(baseElement.querySelectorAll(".status")).toHaveLength(3); - expect(baseElement.querySelector("h2")?.textContent).toBe("Application Process"); - }); - - it("renders with text content only", () => { - const { baseElement } = render( - - Complete your profile information by filling out all required fields - - ); - - const el = baseElement.querySelector("goa-public-form-task"); - expect(el?.getAttribute("status")).toBe("completed"); - expect(el?.textContent).toBe("Complete your profile information by filling out all required fields"); - }); - - it("renders with mixed content types", () => { - const { baseElement } = render( - -
- Task description with bold text and italic text -
-
    -
  • First requirement
  • -
  • Second requirement
  • -
  • Third requirement
  • -
-
- Progress: 0/3 completed -
-
- ); - - expect(baseElement.querySelector("strong")?.textContent).toBe("bold text"); - expect(baseElement.querySelector("em")?.textContent).toBe("italic text"); - expect(baseElement.querySelector("[data-testid='task-list']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='progress']")?.textContent).toBe("0/3 completed"); - expect(baseElement.querySelectorAll("li")).toHaveLength(3); - }); - - it("renders empty children", () => { - const { baseElement } = render( - - {null} - - ); - - const el = baseElement.querySelector("goa-public-form-task"); - expect(el).toBeTruthy(); - expect(el?.getAttribute("status")).toBe("completed"); - expect(el?.textContent).toBe(""); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-public-form-task"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); \ No newline at end of file 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/button/Button.svelte b/libs/web-components/src/components/button/Button.svelte index a7f655d3e..7b2f64983 100644 --- a/libs/web-components/src/components/button/Button.svelte +++ b/libs/web-components/src/components/button/Button.svelte @@ -2,6 +2,7 @@ customElement={{ tag: "goa-button", props: { + type: { reflect: true }, actionArg: { type: "String", attribute: "action-arg" }, actionArgs: { type: "Object", attribute: "action-args" }, }, @@ -155,7 +156,7 @@ {#if leadingicon} @@ -166,7 +167,7 @@ {#if trailingicon} @@ -235,7 +236,10 @@ button.compact { height: var(--goa-button-height-compact); font: var(--goa-button-text-compact); - padding: var(--goa-button-padding-compact, var(--goa-button-padding-lr-compact)); + padding: var( + --goa-button-padding-compact, + var(--goa-button-padding-lr-compact) + ); gap: var(--goa-button-compact-gap); } @@ -419,7 +423,7 @@ } button.v2.primary:disabled { - background-color: var(--goa-button-primary-disabled-color-bg) + background-color: var(--goa-button-primary-disabled-color-bg); } button.v2.secondary.destructive { @@ -443,7 +447,6 @@ border: var(--goa-button-tertiary-inverse-border); } - button.v2.tertiary.inverse:hover { border: var(--goa-button-tertiary-inverse-hover-border); } @@ -453,7 +456,7 @@ } button.v2.tertiary.destructive:hover { - border: var(--goa-button-tertiary-destructive-hover-border) + border: var(--goa-button-tertiary-destructive-hover-border); } button.v2.tertiary:disabled { 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 @@ - + -
{#if type === "calendar"} {#if width && width.includes("%")}
@@ -345,6 +297,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..45673ecc7 100644 --- a/libs/web-components/src/components/form-item/FormItem.svelte +++ b/libs/web-components/src/components/form-item/FormItem.svelte @@ -1,9 +1,8 @@ - +
- {#if ($$slots.error || error) || ($$slots.helptext || helptext)} + {#if $$slots.error || error || $$slots.helptext || helptext}
{#if $$slots.error || error}