web: extract form processing (#7298)

* web: break circular dependency between AKElement & Interface.

This commit changes the way the root node of the web application shell is
discovered by child components, such that the base class shared by both
no longer results in a circular dependency between the two models.

I've run this in isolation and have seen no failures of discovery; the identity
token exists as soon as the Interface is constructed and is found by every item
on the page.

* web: fix broken typescript references

This built... and then it didn't?  Anyway, the current fix is to
provide type information the AkInterface for the data that consumers
require.

* web: extract the form processing from the form submission process

Our forms have a lot of customized value handling, and the function `serializeForm` takes
our input structures and creates a JSON object ready for submission across the wire for
the various models provided by the API.

That function was embedded in the `ak-form` object, but it has no actual dependencies on
the state of that object; aside from identifying the input elements, which is done at the
very start of processing, this large block of code stands alone.  Separating out the
"processing the form" from "identifying the form" allows us to customize our form handling
and preserve form information on the client for transactional purposes such as our wizard.

w
This commit is contained in:
Ken Sternberg 2023-10-25 14:39:50 -07:00 committed by GitHub
parent 7f2d03dcd0
commit a52e4a3262
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1 changed files with 93 additions and 88 deletions

View File

@ -31,6 +31,93 @@ export interface KeyUnknown {
[key: string]: unknown;
}
/**
* Recursively assign `value` into `json` while interpreting the dot-path of `element.name`
*/
function assignValue(element: HTMLInputElement, value: unknown, json: KeyUnknown): void {
let parent = json;
if (!element.name?.includes(".")) {
parent[element.name] = value;
return;
}
const nameElements = element.name.split(".");
for (let index = 0; index < nameElements.length - 1; index++) {
const nameEl = nameElements[index];
// Ensure all nested structures exist
if (!(nameEl in parent)) parent[nameEl] = {};
parent = parent[nameEl] as { [key: string]: unknown };
}
parent[nameElements[nameElements.length - 1]] = value;
}
/**
* Convert the elements of the form to JSON.[4]
*
*/
export function serializeForm<T extends KeyUnknown>(
elements: NodeListOf<HorizontalFormElement>,
): T | undefined {
const json: { [key: string]: unknown } = {};
elements.forEach((element) => {
element.requestUpdate();
const inputElement = element.querySelector<HTMLInputElement>("[name]");
if (element.hidden || !inputElement) {
return;
}
// Skip elements that are writeOnly where the user hasn't clicked on the value
if (element.writeOnly && !element.writeOnlyActivated) {
return;
}
if (
inputElement.tagName.toLowerCase() === "select" &&
"multiple" in inputElement.attributes
) {
const selectElement = inputElement as unknown as HTMLSelectElement;
assignValue(
inputElement,
Array.from(selectElement.selectedOptions).map((v) => v.value),
json,
);
} else if (inputElement.tagName.toLowerCase() === "input" && inputElement.type === "date") {
assignValue(inputElement, inputElement.valueAsDate, json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "datetime-local"
) {
assignValue(inputElement, new Date(inputElement.valueAsNumber), json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
"type" in inputElement.dataset &&
inputElement.dataset["type"] === "datetime-local"
) {
// Workaround for Firefox <93, since 92 and older don't support
// datetime-local fields
assignValue(inputElement, new Date(inputElement.value), json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "checkbox"
) {
assignValue(inputElement, inputElement.checked, json);
} else if ("selectedFlow" in inputElement) {
assignValue(inputElement, inputElement.value, json);
} else if (inputElement.tagName.toLowerCase() === "ak-search-select") {
const select = inputElement as unknown as SearchSelect<unknown>;
try {
const value = select.toForm();
assignValue(inputElement, value, json);
} catch (exc) {
if (exc instanceof PreventFormSubmit) {
throw new PreventFormSubmit(exc.message, element);
}
throw exc;
}
} else {
assignValue(inputElement, inputElement.value, json);
}
});
return json as unknown as T;
}
/**
* Form
*
@ -177,95 +264,13 @@ export abstract class Form<T> extends AKElement {
*
*/
serializeForm(): T | undefined {
const elements =
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
"ak-form-element-horizontal",
) || [];
const json: { [key: string]: unknown } = {};
elements.forEach((element) => {
element.requestUpdate();
const inputElement = element.querySelector<HTMLInputElement>("[name]");
if (element.hidden || !inputElement) {
return;
}
// Skip elements that are writeOnly where the user hasn't clicked on the value
if (element.writeOnly && !element.writeOnlyActivated) {
return;
}
if (
inputElement.tagName.toLowerCase() === "select" &&
"multiple" in inputElement.attributes
) {
const selectElement = inputElement as unknown as HTMLSelectElement;
this.assignValue(
inputElement,
Array.from(selectElement.selectedOptions).map((v) => v.value),
json,
);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "date"
) {
this.assignValue(inputElement, inputElement.valueAsDate, json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "datetime-local"
) {
this.assignValue(inputElement, new Date(inputElement.valueAsNumber), json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
"type" in inputElement.dataset &&
inputElement.dataset["type"] === "datetime-local"
) {
// Workaround for Firefox <93, since 92 and older don't support
// datetime-local fields
this.assignValue(inputElement, new Date(inputElement.value), json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "checkbox"
) {
this.assignValue(inputElement, inputElement.checked, json);
} else if ("selectedFlow" in inputElement) {
this.assignValue(inputElement, inputElement.value, json);
} else if (inputElement.tagName.toLowerCase() === "ak-search-select") {
const select = inputElement as unknown as SearchSelect<unknown>;
try {
const value = select.toForm();
this.assignValue(inputElement, value, json);
} catch (exc) {
if (exc instanceof PreventFormSubmit) {
throw new PreventFormSubmit(exc.message, element);
}
throw exc;
}
} else {
this.assignValue(inputElement, inputElement.value, json);
}
});
return json as unknown as T;
}
/**
* Recursively assign `value` into `json` while interpreting the dot-path of `element.name`
*/
private assignValue(
element: HTMLInputElement,
value: unknown,
json: { [key: string]: unknown },
): void {
let parent = json;
if (!element.name?.includes(".")) {
parent[element.name] = value;
return;
const elements = this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
"ak-form-element-horizontal",
);
if (!elements) {
return {} as T;
}
const nameElements = element.name.split(".");
for (let index = 0; index < nameElements.length - 1; index++) {
const nameEl = nameElements[index];
// Ensure all nested structures exist
if (!(nameEl in parent)) parent[nameEl] = {};
parent = parent[nameEl] as { [key: string]: unknown };
}
parent[nameElements[nameElements.length - 1]] = value;
return serializeForm(elements) as T;
}
/**