diff --git a/.vscode/launch.json b/.vscode/launch.json index 177bc7dfa..00695f097 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,15 +1,10 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", "configurations": [ - { - "type": "pwa-chrome", - "request": "launch", - "name": "Launch Chrome against localhost", - "url": "http://localhost:8080", - "webRoot": "${workspaceFolder}" - } + { + "name": "Attach to Chrome", + "port": 9222, + "request": "attach", + "type": "chrome" + } ] } diff --git a/libs/angular-components/src/lib/public-form-utils.ts b/libs/angular-components/src/lib/public-form-utils.ts index d2f87ae68..d896f5879 100644 --- a/libs/angular-components/src/lib/public-form-utils.ts +++ b/libs/angular-components/src/lib/public-form-utils.ts @@ -2,50 +2,235 @@ import { FieldValidator } from "./validation"; export type FormStatus = "not-started" | "incomplete" | "complete"; -export class PublicFormComponent { - state: AppState = { - form: {}, - history: [], - editting: "", - status: "not-started", - }; +// 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: "change" | "continue" }; +}; + +export type Fieldset = { + heading: string; + skipSummary?: boolean; + data: + | { type: "details"; fieldsets: Record } + | { type: "list"; items: AppState[] }; +}; + +// Public type to define the state of the fieldset items +export type FieldsetItemState = { + name: string; + label: string; + value: string; +}; + +export class PublicFormController { + state?: AppState | AppState[]; _formData?: Record = undefined; _formRef?: HTMLElement = undefined; + constructor(private type: "details" | "list") { + console.log("constructor", this.type); + // nothing here + } + + // 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.error("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; - relay(this._formRef, "init", {}); + this.state = []; } - initState(state: string | AppState) { + // Public method to allow for the initialization of the state + initState(state: string | AppState | AppState[]) { relay(this._formRef, "external::init:state", state); } - updateState(e: Event) { - const state = (e as CustomEvent).detail as AppState; + // ISSUE: + // The issue is that all the logic below is happening outside the form component, thereby resulting + // in the "fixed" data not being in sync with the data within the form component. + // + + // Public method to allow for the updating of the state + // updateState(e: Event) { + // console.debug( + // "Utils:updateState", + // this.type, + // { state: this.state }, + // (e as CustomEvent).detail, + // ); + // if (!this.state) { + // console.error("updateState: state has not yet been set"); + // return; + // } + // + // const detail = (e as CustomEvent).detail; + // } else if (this.type === "details" && Array.isArray(detail.data)) { + // this.#updateObjectListState(detail); + // } else { + // this.#updateObjectState(detail); + // } + // } + + 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: state.form, - currentFieldset: state.currentFieldset, - }; + form: { + ...(this.state?.form || {}), + [detail.id]: detail.data, + }, + } as AppState; } - getStateValue(group: string, key: string): string { - const data = this.state.form[group].data as Record[]; - // @ts-expect-error "ignore" - return (data as Record)[key]?.value ?? ""; + 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 }, + // currentFieldset: newState.currentFieldset, + // history: detail.data., + } as AppState; + } else { + // form state being updated with form data + this.state = { + ...this.state, + form: { ...(this.state?.form || {}), ...detail.data.form }, + // currentFieldset: newState.currentFieldset, + history: detail.data.history, + } as AppState; + } } - continueTo(name: T | undefined) { - if (!name) { + 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 []; + // } + + // console.debug("Utils:getStateItems", this.state, { group }); + // return (this.state.form[group]?.data ?? []) as Record[]; + // } + + // 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 as Record[]; + // // @ts-expect-error "ignore" + // return (data as Record)[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<{ next: T }>(this._formRef, "external::continue", { - next: name, + // Relay the continue message to the form element which will + // set the visibility of the fieldsets + console.log("continueTo: TYPE", { + type: this.type, + state: this.state, + formRef: this._formRef, + next, }); + // 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(field: string, e: Event, validators: FieldValidator[]): [boolean, string] { const { el, state } = (e as CustomEvent).detail; const value = state?.[field]?.value; @@ -60,6 +245,15 @@ export class PublicFormComponent { return [true, value]; } + edit(index: number) { + relay(this._formRef, "external::alter:state", { index, operation: "edit" }); + } + + remove(index: number) { + relay(this._formRef, "external::alter:state", { index, operation: "remove" }); + } + + // Private method to dispatch the error message to the form element #dispatchError(el: HTMLElement, name: string, msg: string) { el.dispatchEvent( new CustomEvent("msg", { @@ -76,21 +270,7 @@ export class PublicFormComponent { } } -export type AppState = { - form: Record[] }>; - history: string[]; - editting: string; - lastModified?: Date; - status: FormStatus; - currentFieldset?: { id: T; dispatchType: "change" | "continue" }; -}; - -export type FieldsetItemState = { - name: string; - label: string; - value: string; -}; - +// Public helper function to dispatch messages export function dispatch( el: HTMLElement | Element | null | undefined, eventName: string, @@ -110,6 +290,7 @@ export function dispatch( ); } +// Public helper function to relay messages export function relay( el: HTMLElement | Element | null | undefined, eventName: string, @@ -120,7 +301,6 @@ export function relay( console.error("dispatch element is null"); return; } - // console.log(`RELAY(${eventName}):`, data, el); el.dispatchEvent( new CustomEvent<{ action: string; data?: T }>("msg", { composed: true, diff --git a/libs/angular-components/src/lib/validation.spec.ts b/libs/angular-components/src/lib/validation.spec.ts index b9f197dfb..c75db71be 100644 --- a/libs/angular-components/src/lib/validation.spec.ts +++ b/libs/angular-components/src/lib/validation.spec.ts @@ -1,7 +1,11 @@ import { describe, it, expect } from "vitest"; -import { lengthValidator, SINValidator } from "./validation"; -import { emailValidator, postalCodeValidator } from "./validation.ts"; +import { + lengthValidator, + SINValidator, + emailValidator, + postalCodeValidator, +} from "./validation"; describe("Validation", () => { describe("Email", () => { diff --git a/libs/react-components/src/lib/fieldset/fieldset.tsx b/libs/react-components/src/lib/fieldset/fieldset.tsx index cc32c0da0..94047f108 100644 --- a/libs/react-components/src/lib/fieldset/fieldset.tsx +++ b/libs/react-components/src/lib/fieldset/fieldset.tsx @@ -15,7 +15,7 @@ declare global { namespace JSX { // eslint-disable-next-line @typescript-eslint/no-empty-interface interface IntrinsicElements { - "goa-fieldset": WCProps & React.HTMLAttributes; + "goa-public-form-page": WCProps & React.HTMLAttributes; } } } @@ -63,7 +63,7 @@ export function GoabFieldset({ }, [ref.current, onContinue]) return ( - {children} - + ); } diff --git a/libs/web-components/src/common/utils.spec.ts b/libs/web-components/src/common/utils.spec.ts index 4013e7564..3dea260ed 100644 --- a/libs/web-components/src/common/utils.spec.ts +++ b/libs/web-components/src/common/utils.spec.ts @@ -1,4 +1,5 @@ -import { getTimestamp } from "./utils"; +import { waitFor } from "@testing-library/svelte"; +import { getTimestamp, performOnce } from "./utils"; import { it, describe } from "vitest"; describe("getTimestamp", () => { @@ -48,3 +49,18 @@ describe("getTimestamp", () => { expect(getTimestamp(new Date(2023, 1, 1, 0, 23))).toContain("12:23 AM") }) }) + +describe("performOnce", () => { + it("calls the action only once", async () => { + let count = 0; + let timeoutId: any; + + for (let i = 0; i < 10; i++) { + timeoutId = performOnce(timeoutId, () => count++); + } + + await waitFor(() => { + expect(count).toBe(1); + }); + }) +}) diff --git a/libs/web-components/src/common/utils.ts b/libs/web-components/src/common/utils.ts index 064063389..fe66f3efa 100644 --- a/libs/web-components/src/common/utils.ts +++ b/libs/web-components/src/common/utils.ts @@ -36,7 +36,7 @@ export const msg = { export function receive( el: HTMLElement | Element | null | undefined, - handler: (action: string, data: Record, event: Event) => void, + handler: (action: string, data: unknown, event: Event) => void, ) { if (!el) { console.warn("receive() el is null | undefined"); @@ -83,8 +83,6 @@ export function dispatch( detail?: T, opts?: { bubbles?: boolean; cancelable?: boolean; timeout?: number }, ) { - // console.log(`DISPATCH(${eventName}):`, detail, el); - const dispatch = () => { el?.dispatchEvent( new CustomEvent(eventName, { @@ -113,8 +111,10 @@ export function getSlottedChildren( } else { // for unit tests only if (parentTestSelector) { - // @ts-expect-error testing - return [...rootEl.querySelector(parentTestSelector).children] as Element[]; + return [ + // @ts-expect-error testing + ...rootEl.querySelector(parentTestSelector).children, + ] as Element[]; } // @ts-expect-error testing return [...rootEl.children] as Element[]; @@ -142,7 +142,10 @@ export function isValidDate(d: Date): boolean { return !isNaN(d.getDate()); } -export function validateRequired(componentName: string, props: Record) { +export function validateRequired( + componentName: string, + props: Record, +) { Object.entries(props).forEach((prop) => { if (!prop[1]) { console.warn(`${componentName}: ${prop[0]} is required`); @@ -235,6 +238,17 @@ export function padLeft( return `${padding}${value}`; } +export function performOnce( + timeoutId: any, + action: () => void, + delay = 100, +): any { + if (timeoutId) { + clearTimeout(timeoutId); + } + return setTimeout(action, delay); +} + export function ensureSlotExists(el: HTMLElement) { if (!el.querySelector("slot")) { el.appendChild(document.createElement("slot")); diff --git a/libs/web-components/src/components/checkbox/Checkbox.svelte b/libs/web-components/src/components/checkbox/Checkbox.svelte index 485ca064a..e434552c3 100644 --- a/libs/web-components/src/components/checkbox/Checkbox.svelte +++ b/libs/web-components/src/components/checkbox/Checkbox.svelte @@ -7,8 +7,8 @@ import { dispatch, fromBoolean, receive, relay, toBoolean } from "../../common/utils"; import { - FormSetValueMsg, - FormSetValueRelayDetail, + FieldsetSetValueMsg, + FieldsetSetValueRelayDetail, FieldsetSetErrorMsg, FieldsetResetErrorsMsg, FormFieldMountRelayDetail, @@ -75,10 +75,9 @@ function addRelayListener() { receive(_rootEl, (action, data) => { - // console.log(` RECEIVE(Form => ${action}):`, action, data); switch (action) { - case FormSetValueMsg: - onSetValue(data as FormSetValueRelayDetail); + case FieldsetSetValueMsg: + onSetValue(data as FieldsetSetValueRelayDetail); break; case FieldsetSetErrorMsg: setError(data as FieldsetErrorRelayDetail); @@ -132,7 +131,7 @@ } } - function onSetValue(detail: FormSetValueRelayDetail) { + function onSetValue(detail: FieldsetSetValueRelayDetail) { // @ts-expect-error value = detail.value; checked = detail.value ? "true" : "false"; diff --git a/libs/web-components/src/components/date-picker/DatePicker.svelte b/libs/web-components/src/components/date-picker/DatePicker.svelte index 6ebfe983f..cb5385a5f 100644 --- a/libs/web-components/src/components/date-picker/DatePicker.svelte +++ b/libs/web-components/src/components/date-picker/DatePicker.svelte @@ -13,8 +13,8 @@ import { padLeft, toBoolean } from "../../common/utils"; import { receive, dispatch, relay } from "../../common/utils"; import { - FormSetValueMsg, - FormSetValueRelayDetail, + FieldsetSetValueMsg, + FieldsetSetValueRelayDetail, FieldsetSetErrorMsg, FieldsetResetErrorsMsg, FormFieldMountMsg, @@ -86,8 +86,8 @@ function addRelayListener() { receive(_rootEl, (action, data) => { switch (action) { - case FormSetValueMsg: - onSetValue(data as FormSetValueRelayDetail); + case FieldsetSetValueMsg: + onSetValue(data as FieldsetSetValueRelayDetail); break; case FieldsetSetErrorMsg: setError(data as FieldsetErrorRelayDetail); @@ -103,7 +103,7 @@ error = detail.error ? "true" : "false"; } - function onSetValue(detail: FormSetValueRelayDetail) { + function onSetValue(detail: FieldsetSetValueRelayDetail) { // @ts-expect-error value = detail.value; dispatch( diff --git a/libs/web-components/src/components/dropdown/Dropdown.svelte b/libs/web-components/src/components/dropdown/Dropdown.svelte index a7d0d8aea..5f2b1bbdb 100644 --- a/libs/web-components/src/components/dropdown/Dropdown.svelte +++ b/libs/web-components/src/components/dropdown/Dropdown.svelte @@ -5,12 +5,14 @@ import type { GoAIconType } from "../icon/Icon.svelte"; import type { Spacing } from "../../common/styling"; + import type { + DropdownItemDestroyRelayDetail, + DropdownItemMountedRelayDetail, + Option, + } from "./DropdownItem.svelte"; import { DropdownItemDestroyMsg, - DropdownItemDestroyRelayDetail, DropdownItemMountedMsg, - DropdownItemMountedRelayDetail, - type Option, } from "./DropdownItem.svelte"; import { dispatch, @@ -22,12 +24,13 @@ } from "../../common/utils"; import { calculateMargin } from "../../common/styling"; import { + FieldsetErrorRelayDetail, FieldsetResetErrorsMsg, FieldsetSetErrorMsg, FormFieldMountMsg, FormFieldMountRelayDetail, - FormSetValueMsg, - FormSetValueRelayDetail, + FieldsetSetValueMsg, + FieldsetSetValueRelayDetail, } from "../../types/relay-types"; interface EventHandler { @@ -145,11 +148,11 @@ function addRelayListener() { receive(_rootEl, (action, data, event) => { switch (action) { - case FormSetValueMsg: - onSetValue(data as FormSetValueRelayDetail); + case FieldsetSetValueMsg: + onSetValue(data as FieldsetSetValueRelayDetail); break; case FieldsetSetErrorMsg: - error = "true"; + setError(data as FieldsetErrorRelayDetail); break; case FieldsetResetErrorsMsg: error = "false"; @@ -164,8 +167,12 @@ }); } - function onSetValue(detail: FormSetValueRelayDetail) { - // @ts-expect-error ignore + 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 }); } diff --git a/libs/web-components/src/components/form-item/FormItem.svelte b/libs/web-components/src/components/form-item/FormItem.svelte index f7aa93958..eeeaf73ae 100644 --- a/libs/web-components/src/components/form-item/FormItem.svelte +++ b/libs/web-components/src/components/form-item/FormItem.svelte @@ -57,7 +57,7 @@ export let requirement: RequirementType = ""; export let maxwidth: string = "none"; - // **For the public-form only** + // **For the public-form only** // Overrides the label value within the form-summary to provide a shorter description of the value export let name: string = "blank"; @@ -72,7 +72,6 @@ validateLabelSize(labelsize); receive(_rootEl, (action, data) => { - // console.log(` RECEIVE(FormItem => ${action}):`, data); switch (action) { case FormFieldMountMsg: onInputMount(data as FormFieldMountRelayDetail); @@ -168,11 +167,12 @@ el.setAttribute("aria-label", label); } - bindElement(name); + sendMountedMessage(name); } - // Allows binding to Fieldset components - function bindElement(_name: string) { + // Allows binding to Fieldset components. The `_name` value is what was obtained from the "input" element's + // event, which ensures that the requirement of the "input" and formitem having the same name will be met. + function sendMountedMessage(_name: string) { relay( _rootEl, FormItemMountMsg, diff --git a/libs/web-components/src/components/form/Fieldset.svelte b/libs/web-components/src/components/form/Fieldset.svelte index 37436afb2..ac5e26317 100644 --- a/libs/web-components/src/components/form/Fieldset.svelte +++ b/libs/web-components/src/components/form/Fieldset.svelte @@ -1,19 +1,25 @@ - -
- + +
+ +
diff --git a/libs/web-components/src/components/form/FormSummary.svelte b/libs/web-components/src/components/form/FormSummary.svelte index 5a8b99c41..e8881eec5 100644 --- a/libs/web-components/src/components/form/FormSummary.svelte +++ b/libs/web-components/src/components/form/FormSummary.svelte @@ -3,6 +3,7 @@
@@ -67,26 +99,45 @@ {/if} {#if _state} {#each _state.history as page} - {#if _state.form[page]} + {#if _state.form[page] && !_state.form[page]?.skipSummary} -
{#if getHeading(page)} {getHeading(page)} {/if} - - {#each getData(page) as [_key, data]} - - - - - {/each} -
{data.label}{data.value}
+ +
+ {#if _state.form[page]?.data?.type} + {#if _state.form[page]?.data?.type === "details"} + + {#each Object.entries(getData(_state, page)) as [_, data]} + + + + + {/each} +
{data.label}{data.value}
+ {:else} + {#each getDataList(_state, page) as item, index} + + {#each Object.entries(item) as [_, data]} + + + + + {/each} +
{data.label}{data.value}
+ {#if index < getDataList(_state, page).length - 1} + + {/if} + {/each} + {/if} + {/if} +
changePage(e, page)}>Change
-
{/if} {/each} @@ -101,11 +152,11 @@ display: block; grid-template-rows: min-content auto; grid-template-columns: auto; - grid-template-areas: + grid-template-areas: "top top top" } - .data tr:last-of-type { + .data tr:last-of-type { padding-bottom: var(--goa-space-m); } .data td:first-of-type { @@ -125,15 +176,17 @@ display: grid; grid-auto-rows: 1fr; grid-template-columns: 1fr min-content; - grid-template-areas: + grid-template-areas: "data action" } .summary-with-header { display: grid; - grid-auto-rows: min-content auto; + /*grid-auto-rows: min-content auto;*/ + grid-auto-rows: auto; + /*grid-auto-rows: minmax(100px, auto);*/ grid-template-columns: 1fr min-content; - grid-template-areas: + grid-template-areas: "heading action" "data ." } @@ -151,7 +204,11 @@ .data { grid-area: data; } - + + table { + width: 100%; + } + .label { width: 50%; font: var(--goa-typography-heading-s); diff --git a/libs/web-components/src/components/form/SubForm.svelte b/libs/web-components/src/components/form/SubForm.svelte new file mode 100644 index 000000000..310aaeeab --- /dev/null +++ b/libs/web-components/src/components/form/SubForm.svelte @@ -0,0 +1,379 @@ + + + + +
+
+ +
+ + + +
diff --git a/libs/web-components/src/components/form/SubFormIndex.svelte b/libs/web-components/src/components/form/SubFormIndex.svelte new file mode 100644 index 000000000..0fc989023 --- /dev/null +++ b/libs/web-components/src/components/form/SubFormIndex.svelte @@ -0,0 +1,63 @@ + + + + +
+ + + {actionButtonText} + {#if continueButtonVisibility === "visible"} + {continueButtonText} + {/if} + +
diff --git a/libs/web-components/src/components/input/Input.spec.ts b/libs/web-components/src/components/input/Input.spec.ts index 024db9059..91062ba58 100644 --- a/libs/web-components/src/components/input/Input.spec.ts +++ b/libs/web-components/src/components/input/Input.spec.ts @@ -352,9 +352,7 @@ describe("GoAInput Component", () => { type: "text", suffix: "per item", }); - expect(container.querySelector(".suffix")?.innerHTML).toContain( - "per item", - ); + expect(container.querySelector(".suffix")?.innerHTML).toContain("per item"); await waitFor(() => { expect(console.warn["mock"].calls.length).toBeGreaterThan(0); }); @@ -393,9 +391,7 @@ describe("GoAInput Component", () => { const el = render(GoAInputWrapper, { leadingContent: content }); expect(el.container.innerHTML).toContain(content); - const leadingContent = el.container.querySelector( - "[slot=leadingContent]", - ); + const leadingContent = el.container.querySelector("[slot=leadingContent]"); expect(leadingContent).toBeTruthy(); expect(leadingContent?.innerHTML).toContain(content); }); @@ -403,9 +399,7 @@ describe("GoAInput Component", () => { it("should have a slot for the trailing content", async () => { const content = "items"; const el = render(GoAInputWrapper, { trailingContent: content }); - const trailingContent = el.container.querySelector( - "[slot=trailingContent]", - ); + const trailingContent = el.container.querySelector("[slot=trailingContent]"); expect(el.container.innerHTML).toContain(content); expect(trailingContent?.innerHTML).toContain(content); diff --git a/libs/web-components/src/components/input/Input.svelte b/libs/web-components/src/components/input/Input.svelte index 838ed877f..fcdd8353b 100644 --- a/libs/web-components/src/components/input/Input.svelte +++ b/libs/web-components/src/components/input/Input.svelte @@ -23,8 +23,8 @@ FieldsetSetErrorMsg, FormFieldMountMsg, FormFieldMountRelayDetail, - FormSetValueMsg, - FormSetValueRelayDetail, + FieldsetSetValueMsg, + FieldsetSetValueRelayDetail, } from "../../types/relay-types"; // Validators @@ -147,8 +147,8 @@ function addRelayListener() { receive(_inputEl, (action, data) => { switch (action) { - case FormSetValueMsg: - setValue(data as FormSetValueRelayDetail); + case FieldsetSetValueMsg: + setValue(data as FieldsetSetValueRelayDetail); break; case FieldsetSetErrorMsg: setError(data as FieldsetErrorRelayDetail); @@ -167,12 +167,15 @@ error = detail.error ? "true" : "false"; } - function setValue(detail: FormSetValueRelayDetail) { + function setValue(detail: FieldsetSetValueRelayDetail) { // @ts-expect-error value = detail.value; - dispatch(_inputEl, "_change", { name, value }, { bubbles: true }); + + // dispatch the change event to the form-item element to ensure the state is in sync + // dispatch(_inputEl, "_change", { name, value }, { bubbles: true }); } + // Relay message up the chain to allow any parent element to have a reference to the input element function sendMountedMessage() { relay( _rootEl, @@ -237,7 +240,6 @@ } function doClick() { - // @ts-ignore this.dispatchEvent( new CustomEvent("_trailingIconClick", { composed: true }), ); diff --git a/libs/web-components/src/components/link/Link.svelte b/libs/web-components/src/components/link/Link.svelte index bc30f2099..4829e038d 100644 --- a/libs/web-components/src/components/link/Link.svelte +++ b/libs/web-components/src/components/link/Link.svelte @@ -14,8 +14,8 @@ -
{ switch (action) { - case FormSetValueMsg: - onSetValue(data as FormSetValueRelayDetail); + case FieldsetSetValueMsg: + onSetValue(data as FieldsetSetValueRelayDetail); break; case FieldsetSetErrorMsg: setError(data as FieldsetErrorRelayDetail); @@ -114,7 +114,7 @@ error = detail.error ? "true" : "false"; } - function onSetValue(detail: FormSetValueRelayDetail) { + function onSetValue(detail: FieldsetSetValueRelayDetail) { // @ts-expect-error value = detail.value; dispatch(_rootEl, "_change", { name, value }, { bubbles: true }); diff --git a/libs/web-components/src/components/radio-item/RadioItem.spec.ts b/libs/web-components/src/components/radio-item/RadioItem.spec.ts index 6602b1984..970de60e4 100644 --- a/libs/web-components/src/components/radio-item/RadioItem.spec.ts +++ b/libs/web-components/src/components/radio-item/RadioItem.spec.ts @@ -34,8 +34,11 @@ describe("RadioItem", () => { radioDescriptionDiv?.getAttribute("id"), ); - const radioContainerDiv = result.container.querySelector("[data-testid=root]"); - expect(radioContainerDiv?.getAttribute("style")).toContain("max-width: 480px;"); + const radioContainerDiv = + result.container.querySelector("[data-testid=root]"); + expect(radioContainerDiv?.getAttribute("style")).toContain( + "max-width: 480px;", + ); }); it("should render the radio item with slot description", async () => { @@ -44,9 +47,9 @@ describe("RadioItem", () => { value: "foobar", }); await waitFor(() => { - expect(result.container.querySelector("[slot=description]")?.innerHTML).toContain( - "Radio Item 1 description", - ); + expect( + result.container.querySelector("[slot=description]")?.innerHTML, + ).toContain("Radio Item 1 description"); }); }); @@ -75,10 +78,18 @@ describe("RadioItem", () => { const radio = baseElement.container.querySelector("[data-testid=root]"); expect(radio).toBeTruthy(); - expect(radio?.getAttribute("style")).toContain("margin-top:var(--goa-space-s)"); - expect(radio?.getAttribute("style")).toContain("margin-right:var(--goa-space-m)"); - expect(radio?.getAttribute("style")).toContain("margin-bottom:var(--goa-space-l)"); - expect(radio?.getAttribute("style")).toContain("margin-left:var(--goa-space-xl)"); + expect(radio?.getAttribute("style")).toContain( + "margin-top:var(--goa-space-s)", + ); + expect(radio?.getAttribute("style")).toContain( + "margin-right:var(--goa-space-m)", + ); + expect(radio?.getAttribute("style")).toContain( + "margin-bottom:var(--goa-space-l)", + ); + expect(radio?.getAttribute("style")).toContain( + "margin-left:var(--goa-space-xl)", + ); }); it("should handle the change event and emit _click event", async () => { diff --git a/libs/web-components/src/components/table/Table.svelte b/libs/web-components/src/components/table/Table.svelte index fdf6a2533..879a8517d 100644 --- a/libs/web-components/src/components/table/Table.svelte +++ b/libs/web-components/src/components/table/Table.svelte @@ -77,7 +77,6 @@ // relay state to all children headings.forEach((child) => { if (child.getAttribute("name") === sortBy) { - // @ts-expect-error const direction = child["direction"] as GoATableSortDirection; // starting direction is asc const newDirection = direction === "asc" ? "desc" : "asc"; @@ -96,7 +95,6 @@ // dispatch the default sort params if initially set const initialSortBy = heading.getAttribute("name"); - // @ts-ignore const initialDirection = heading["direction"] as GoATableSortDirection; if (initialSortBy && initialDirection && initialDirection !== "none") { setTimeout(() => { diff --git a/libs/web-components/src/components/text-area/TextArea.svelte b/libs/web-components/src/components/text-area/TextArea.svelte index c75f850a1..aa2da45b2 100644 --- a/libs/web-components/src/components/text-area/TextArea.svelte +++ b/libs/web-components/src/components/text-area/TextArea.svelte @@ -15,7 +15,15 @@ type Spacing, } from "../../common/styling"; import { onMount } from "svelte"; - import { FieldsetErrorRelayDetail, FieldsetResetErrorsMsg, FieldsetSetErrorMsg, FormFieldMountMsg, FormFieldMountRelayDetail, FormSetValueMsg, FormSetValueRelayDetail } from "../../types/relay-types"; + import { + FieldsetErrorRelayDetail, + FieldsetResetErrorsMsg, + FieldsetSetErrorMsg, + FormFieldMountMsg, + FormFieldMountRelayDetail, + FieldsetSetValueMsg, + FieldsetSetValueRelayDetail, + } from "../../types/relay-types"; export let name: string; export let value: string = ""; @@ -79,8 +87,8 @@ function addRelayListener() { receive(_textareaEl, (action, data) => { switch (action) { - case FormSetValueMsg: - onSetValue(data as FormSetValueRelayDetail); + case FieldsetSetValueMsg: + onSetValue(data as FieldsetSetValueRelayDetail); break; case FieldsetSetErrorMsg: setError(data as FieldsetErrorRelayDetail); @@ -96,7 +104,7 @@ error = detail.error ? "true" : "false"; } - function onSetValue(detail: FormSetValueRelayDetail) { + function onSetValue(detail: FieldsetSetValueRelayDetail) { // @ts-expect-error value = detail.value; dispatch(_textareaEl, "_change", { name, value }, { bubbles: true }); diff --git a/libs/web-components/src/index.ts b/libs/web-components/src/index.ts index 4f357fe69..715fe5575 100644 --- a/libs/web-components/src/index.ts +++ b/libs/web-components/src/index.ts @@ -33,6 +33,9 @@ export { default as FooterMetaSection } from "./components/footer-meta-section/F export { default as FooterNavSection } from "./components/footer-nav-section/FooterNavSection.svelte"; export { default as Fieldset } from "./components/form/Fieldset.svelte"; export { default as Form } from "./components/form/Form.svelte"; +export { default as FormSummary } from "./components/form/FormSummary.svelte"; +export { default as SubForm } from "./components/form/SubForm.svelte"; +export { default as SubFormIndex } from "./components/form/SubFormIndex.svelte"; export { default as Task } from "./components/form/Task.svelte"; export { default as TaskList } from "./components/form/TaskList.svelte"; export { default as FormItem } from "./components/form-item/FormItem.svelte"; diff --git a/libs/web-components/src/types/relay-types.ts b/libs/web-components/src/types/relay-types.ts index fcd0e5de1..576f73484 100644 --- a/libs/web-components/src/types/relay-types.ts +++ b/libs/web-components/src/types/relay-types.ts @@ -1,36 +1,63 @@ export type FormStatus = "not-started" | "incomplete" | "complete"; -// FIXME: Can the existing AppState be used in place of this export type FormState = { - form: Record; + uuid: string; + // TODO: rename form to "data" or "detail" + form: Record; history: string[]; editting: string; lastModified?: Date; status: FormStatus; }; -export type FieldsetData = { - heading: string; - data: Record | Record[]; +export type Fieldset = { + heading?: string; + skipSummary?: boolean; + data?: FieldsetData; }; +export type FieldsetData = + | { type: "details"; fieldsets: Record } + | { type: "list"; items: FormState[] }; // TODO: rename `items` to `form` + +// =========== +// StateChange +// =========== + +export const StateChangeEvent = "_stateChange"; +export type StateChangeRelayDetail = + | { + type: "details"; + data: FormState; + } + | { + id: string; + type: "list"; + data: FormState[]; + }; + // ==== // Form // ==== export const FormResetErrorsMsg = "form::reset:errors"; export const FormSetFieldsetMsg = "form::set:fieldset"; -export const FormSetValueMsg = "form::set:value"; export const FormDispatchStateMsg = "form::dispatch:state"; export const FormToggleActiveMsg = "form::toggle:active"; export const FormStateChangeMsg = "form::state:change"; -export const FormBindMsg = "form::bind"; export const FormBackUrlMsg = "form::back-url"; +export const FormResetFormMsg = "form::reset:form"; + +// Message to allow forms to register themselves with their parent form to allow for +// form data to be passed down to the child form +export const FormBindMsg = "form::bind"; export type FormBindRelayDetail = { + id: string; el: HTMLElement; }; -export type FormStateChangeRelayDetail = FieldsetData; + +export type FormStateChangeRelayDetail = Fieldset; export type FormToggleActiveRelayDetail = { first: boolean; @@ -39,20 +66,40 @@ export type FormToggleActiveRelayDetail = { export type FormSetFieldsetRelayDetail = { name: string; - value: Record | Record[]; -}; - -export type FormSetValueRelayDetail = { - name: string; - value: string | number | Date; + value: FieldsetData; }; export type FormDispatchStateRelayDetail = FormState; +export type FormDispatchStateRelayDetailList = FormState[]; + +export const FormDispatchEditMsg = "form::edit"; +export type FormDispatchEditRelayDetail = { id: string }; export type FormBackUrlDetail = { url: string; }; +// ====== +// Subform +// ====== + +export type SubFormDeleteDataRelayDetail = { + data: FormState[]; + id: string; +}; +export const SubFormDeleteDataMsg = "subform::delete:data"; + +export const SubFormBindMsg = "subform::bind"; +export type SubFormBindRelayDetail = { + el: HTMLElement; +}; +// ============ +// SubformIndex +// ============ +export const SubFormIndexContinueToParentMsg = "subform::indexContinueToParent"; +export const SubFormIndexContinueToSubFormMsg = + "subform::indexContinueToSubForm"; + // ======== // Fieldset // ======== @@ -61,7 +108,7 @@ export const FieldsetToggleActiveMsg = "fieldset::toggle-active"; export const FieldsetResetErrorsMsg = "fieldset::reset:errors"; export const FieldsetResetFieldsMsg = "fieldset::reset:fields"; export const FieldsetBindMsg = "fieldset::bind"; -export const FieldsetSubmitMsg = "fieldset::submit"; +export const FieldsetCompleteMsg = "fieldset::submit"; export const FieldsetSetErrorMsg = "fieldset::set:error"; // Sent after fieldset handles _change events from goa input-like components export const FieldsetChangeMsg = "fieldset::change"; @@ -70,6 +117,7 @@ export const FieldsetMountFormItemMsg = "fieldset::bind:form-item"; export type FieldsetBindRelayDetail = { id: string; + skipSummary?: boolean; heading: string; el: HTMLElement; }; @@ -103,6 +151,14 @@ export type FieldsetItemState = { export type FieldsetValidationRelayDetail = { el: HTMLElement; state: Record; + first: boolean; + last: boolean; +}; + +export const FieldsetSetValueMsg = "fieldset::set:value"; +export type FieldsetSetValueRelayDetail = { + name: string; + value: string | number | Date; }; // ======== @@ -125,7 +181,6 @@ export const ExternalSetErrorMsg = "external::set:error"; export const ExternalContinueMsg = "external::continue"; export const ExternalAppendDataMsg = "external::append:state"; export const ExternalAlterDataMsg = "external::alter:state"; -export const ExternalResetStateMsg = "external::reset:state"; export const ExternalInitStateMsg = "external::init:state"; export type ExternalAlterDataRelayDetail = @@ -138,7 +193,6 @@ export type ExternalAlterDataRelayDetail = id: string; operation: "edit"; index: number; - data: FieldsetData; }; export type ExternalContinueRelayDetail = { diff --git a/package-lock.json b/package-lock.json index 436e8850c..9b9d68d5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,9 +29,9 @@ "zone.js": "0.14.3" }, "devDependencies": { - "@abgov/design-tokens": "^1.4.2", - "@abgov/nx-release": "8.0.0", - "@angular-devkit/build-angular": "18.2.8", + "@abgov/design-tokens": "^1.4.3", + "@abgov/nx-release": "^8.1.4", + "@angular-devkit/build-angular": "18.2.2", "@angular-devkit/core": "18.1.4", "@angular-devkit/schematics": "18.1.4", "@angular-eslint/eslint-plugin": "18.0.1", diff --git a/package.json b/package.json index 5269d80de..b0f512cc2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "nx affected --base=alpha -t build --exclude=angular --exclude=react --exclude=web", "build:vscode-doc": "node libs/web-components/custom-element-manifest-analyze.js", "pretest": "npx nx run common:build", - "test": "vitest --run && nx test angular-components && nx test react-components", + "test": "vitest && nx test angular-components && nx test react-components", "test:web-components": "vitest -w", "test:angular": "nx test angular-components --watch", "test:react": "nx test react-components --watch", @@ -25,9 +25,9 @@ }, "private": true, "devDependencies": { - "@abgov/design-tokens": "^1.4.2", - "@abgov/nx-release": "8.0.0", - "@angular-devkit/build-angular": "18.2.8", + "@abgov/design-tokens": "^1.4.3", + "@abgov/nx-release": "^8.1.4", + "@angular-devkit/build-angular": "18.2.2", "@angular-devkit/core": "18.1.4", "@angular-devkit/schematics": "18.1.4", "@angular-eslint/eslint-plugin": "18.0.1",