Handle forms (JS augmented)
This is a full example of how to handle forms in Gracile, with client-side
JavaScript augmentation.
In this recipe, both approaches will work, so the user can start submitting the
form even if the JS has yet to be parsed! And if it is, that will avoid a
full-page reload by using the JSON API with fetch.
If you haven’t done it yet, you should read the
form recipe without JS before diving into
the progressive enhancement below.
Some principles hold; because if the user interacts with your form before JS is
loaded or if it’s broken, you still have to handle the submission gracefully,
with the PRG pattern etc.
📄 /src/routes/form-js.client.ts
customElementsvar customElements: CustomElementRegistryThe customElements read-only property of the Window interface returns a reference to the CustomElementRegistry object, which can be used to register new custom elements and get information about previously registered custom elements.
.defineCustomElementRegistry.define(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): voidThe define() method of the CustomElementRegistry interface adds a definition for a custom element to the custom element registry, mapping its name to the constructor which will be used to create it.
(
'form-augmented',
class extends HTMLElementvar HTMLElement: {
new (): HTMLElement;
prototype: HTMLElement;
}
The HTMLElement interface represents any HTML element. Some elements directly implement this interface, while others implement it via an interface that inherits it.
{
#form = this.querySelectorParentNode.querySelector<"form">(selectors: "form"): HTMLFormElement | null (+4 overloads)Returns the first element that is a descendant of node that matches selectors.
('form')!;
#debugger = this.querySelectorParentNode.querySelector<Element>(selectors: string): Element | null (+4 overloads)Returns the first element that is a descendant of node that matches selectors.
('#debugger')!;
connectedCallbackfunction (Anonymous class).connectedCallback(): void () {
this.#form.addEventListenerHTMLFormElement.addEventListener<"submit">(type: "submit", listener: (this: HTMLFormElement, ev: SubmitEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
('submit', (eventevent: SubmitEvent ) => {
eventevent: SubmitEvent .preventDefaultEvent.preventDefault(): voidThe preventDefault() method of the Event interface tells the user agent that the event is being explicitly handled, so its default action, such as page scrolling, link navigation, or pasting text, should not be taken.
();
This will re-emit a "formdata" event.
new FormDatavar FormData: new (form?: HTMLFormElement, submitter?: HTMLElement | null) => FormDataThe FormData interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the fetch(), XMLHttpRequest.send() or navigator.sendBeacon() methods. It uses the same format a form would use if the encoding type were set to "multipart/form-data".
(this.#form);
});
this.#form.addEventListenerHTMLFormElement.addEventListener<"formdata">(type: "formdata", listener: (this: HTMLFormElement, ev: FormDataEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
('formdata', (eventevent: FormDataEvent ) =>
this.postfunction (Anonymous class).post(formData: FormData): Promise<void> (eventevent: FormDataEvent .formDataFormDataEvent.formData: FormDataThe formData read-only property of the FormDataEvent interface contains the FormData object representing the data contained in the form when the event was fired.
),
);
}
async postfunction (Anonymous class).post(formData: FormData): Promise<void> (formDataformData: FormData : FormData) {
Inform the server to respond with JSON instead of doing a POST/Redirect/GET.
formDataformData: FormData .setFormData.set(name: string, value: string | Blob): void (+2 overloads)The set() method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist.
('format', 'json');
const resultconst result: any = await fetchfunction fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> ('', { methodRequestInit.method?: string | undefinedA string to set request's method.
: 'POST', bodyRequestInit.body?: BodyInit | null | undefinedA BodyInit object or null to set request's body.
: formDataformData: FormData }).thenPromise<Response>.then<any, never>(onfulfilled?: ((value: Response) => any) | null | undefined, onrejected?: ((reason: any) => PromiseLike<never>) | null | undefined): Promise<any>Attaches callbacks for the resolution and/or rejection of the Promise.
(
(rr: Response ) => rr: Response .jsonBody.json(): Promise<any> (),
);
Do stuff with the result without doing a full page reload…
consolevar console: Console .logConsole.log(...data: any[]): voidThe console.log() static method outputs a message to the console.
({ resultresult: any });
this.#debugger.innerHTMLElement.innerHTML: stringThe innerHTML property of the Element interface gets or sets the HTML or XML markup contained within the element, omitting any shadow roots in both cases.
= JSONvar JSON: JSONAn intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
.stringifyJSON.stringify(value: any, replacer?: (number | string)[] | null, space?: string | number): string (+1 overload)Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
(resultconst result: any , null, 2);
}
},
);
📄 /src/routes/form.ts
import { defineRouteimport defineRoute } from '@gracile/gracile/route';
import { htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
} from 'lit';
import { documentconst document: () => any } from '../document.js';
let myDatalet myData: string = 'untouched';
type Propstype Props = {
success: boolean;
message: string | null;
myData: unknown;
}
= {
successsuccess: boolean : boolean;
messagemessage: string | null : string | null;
myDatamyData: unknown : unknown;
};
export default defineRouteimport defineRoute ({
handlerhandler: {
GET: () => Props;
POST: (context: any) => Promise<Response | Props>;
}
: {
GETtype GET: () => Props : () => {
const propsconst props: Props : Propstype Props = {
success: boolean;
message: string | null;
myData: unknown;
}
= { successsuccess: boolean : true, messagemessage: string | null : null, myDatamyData: unknown };
return propsconst props: Props ;
},
POSTtype POST: (context: any) => Promise<Response | Props> : async (contextcontext: any ) => {
const formDataconst formData: any = await contextcontext: any .request.formData();
const propsconst props: Props : Propstype Props = {
success: boolean;
message: string | null;
myData: unknown;
}
= { successsuccess: boolean : false, messagemessage: string | null : null, myDatamyData: unknown };
const myFieldValueconst myFieldValue: any = formDataconst formData: any .get('my_field')?.toString();
if (!myFieldValueconst myFieldValue: any ) {
contextcontext: any .responseInit.status = 400;
propsconst props: Props .messagemessage: string | null = 'Missing field.';
} else {
propsconst props: Props .successsuccess: boolean = true;
myDatalet myData: string = myFieldValueconst myFieldValue: any ;
propsconst props: Props .myDatamyData: unknown = myDatalet myData: string ;
}
if (formDataconst formData: any .get('format') === 'json') {
return Responsevar Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}
The Response interface of the Fetch API represents the response to a request.
.jsonfunction json(data: any, init?: ResponseInit): ResponseThe json() static method of the Response interface returns a Response that contains the provided JSON data as body, and a Content-Type header which is set to application/json. The response status, status message, and additional headers can also be set.
(propsconst props: Props , contextcontext: any .responseInit);
}
No-JS fallback
if (propsconst props: Props .successsuccess: boolean ) {
return Responsevar Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}
The Response interface of the Fetch API represents the response to a request.
.redirectfunction redirect(url: string | URL, status?: number): ResponseThe redirect() static method of the Response interface returns a Response resulting in a redirect to the specified URL.
(contextcontext: any .url, 303);
}
We want the user data to be repopulated in the page after a failed `POST`.
return propsconst props: Props ;
},
},
documentdocument: (context: any) => any : (contextcontext: any ) => documentfunction document(): any ({ ...contextcontext: any , titletitle: string : 'Form with JS' }),
templatetemplate: (context: any) => TemplateResult<1> : (contextcontext: any ) => {
consolevar console: Console .logConsole.log(...data: any[]): voidThe console.log() static method outputs a message to the console.
(contextcontext: any );
return htmlconst html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>Interprets a template literal as an HTML template that can efficiently
render to and update a container.
const header = (title: string) => html`<h1>${title}</h1>`;
The html tag returns a description of the DOM to render as a value. It is
lazy, meaning no work is done until the template is rendered. When rendering,
if a template comes from the same expression as a previously rendered result,
it's efficiently updated instead of replaced.
`
<code>${contextcontext: any .request.method}</code>
<form-augmented>
<form method="post">
<input type="text" value=${myDatalet myData: string } name="my_field" />
<button>Change field value</button>
</form>
<pre id="debugger">${JSONvar JSON: JSONAn intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
.stringifyJSON.stringify(value: any, replacer?: (number | string)[] | null, space?: string | number): string (+1 overload)Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
(contextcontext: any .props, null, 2)}</pre>
</form-augmented>
`;
},
});