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: CustomElementRegistry
Defines a new custom element, mapping the given name to the given constructor as an autonomous custom element.
.defineCustomElementRegistry.define(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): void
(
'form-augmented',
class extends HTMLElementvar HTMLElement: {
new (): HTMLElement;
prototype: HTMLElement;
}
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)
Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
('submit', (eventevent: SubmitEvent
) => {
eventevent: SubmitEvent
.preventDefaultEvent.preventDefault(): void
If invoked when the cancelable attribute value is true, and while executing a listener for the event with passive set to false, signals to the operation that caused event to be dispatched that it needs to be canceled.
();
This will re-emit a "formdata" event.
new FormDatavar FormData: new (form?: HTMLFormElement, submitter?: HTMLElement | null) => FormData
Provides a way to easily construct a set of key/value pairs representing form fields and their values, which can then be easily sent using the XMLHttpRequest.send() method. 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)
Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
Appends an event listener for events whose type attribute value is type. The callback argument sets the callback that will be invoked when the event is dispatched.
The options argument sets listener-specific options. For compatibility this can be a boolean, in which case the method behaves exactly as if the value was specified as options's capture.
When set to true, options's capture prevents callback from being invoked when the event's eventPhase attribute value is BUBBLING_PHASE. When false (or not present), callback will not be invoked when event's eventPhase attribute value is CAPTURING_PHASE. Either way, callback will be invoked if event's eventPhase attribute value is AT_TARGET.
When set to true, options's passive indicates that the callback will not cancel the event by invoking preventDefault(). This is used to enable performance optimizations described in § 2.8 Observing event listeners.
When set to true, options's once indicates that the callback will only be invoked once after which the event listener will be removed.
If an AbortSignal is passed for options's signal, then the event listener will be removed when signal is aborted.
The event listener is appended to target's event listener list and is not appended if it has the same type, callback, and capture.
('formdata', (eventevent: FormDataEvent
) =>
this.postfunction (Anonymous class).post(formData: FormData): Promise<void>
(eventevent: FormDataEvent
.formDataFormDataEvent.formData: FormData
Returns a FormData object representing names and values of elements associated to the target form. Operations on the FormData object will affect form data to be submitted.
),
);
}
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)
('format', 'json');
const resultconst result: any
= await fetchfunction fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
('', { methodRequestInit.method?: string | undefined
A string to set request's method.
: 'POST', bodyRequestInit.body?: BodyInit | null | undefined
A 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
The console
module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console
class with methods such as console.log()
, console.error()
and console.warn()
that can be used to write to any Node.js stream.
- A global
console
instance configured to write to process.stdout
and
process.stderr
. The global console
can be used without importing the node:console
module.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O
for
more information.
Example using the global console
:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console
class:
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
.logConsole.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to stdout
with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()
).
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
See util.format()
for more information.
({ resultresult: any
});
this.#debugger.innerHTMLInnerHTML.innerHTML: string
= JSONvar JSON: JSON
An 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 { defineRoutefunction defineRoute<GetHandlerData extends R.HandlerDataHtml = undefined, PostHandlerData extends R.HandlerDataHtml = undefined, CatchAllHandlerData extends R.HandlerDataHtml = undefined, StaticPathOptions extends R.StaticPathOptionsGeneric | undefined = undefined, RouteContext extends R.RouteContextGeneric = {
...;
}>(options: {
handler?: StaticPathOptions extends object ? never : R.Handler<CatchAllHandlerData> | {
GET?: R.Handler<GetHandlerData>;
POST?: R.Handler<PostHandlerData>;
QUERY?: R.Handler<Response>;
PUT?: R.Handler<Response>;
PATCH?: R.Handler<Response>;
DELETE?: R.Handler<Response>;
HEAD?: R.Handler<Response>;
OPTIONS?: R.Handler<Response>;
} | undefined;
staticPaths?: () => R.MaybePromise<StaticPathOptions[]> | undefined;
prerender?: boolean | undefined;
document?: R.DocumentTemplate<RouteContext> | undefined;
template?: R.BodyTemplate<RouteContext> | undefined;
}): (RouteModule: typeof R.RouteModule) => R.RouteModule
Defines a file-based route for Gracile to consume.
} 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: () => ServerRenderedTemplate
} 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 defineRoutedefineRoute<Props, Response | Props, undefined, undefined, {
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}>(options: {
handler?: Handler<...> | {
...;
} | undefined;
staticPaths?: (() => MaybePromise<...> | undefined) | undefined;
prerender?: boolean | undefined;
document?: DocumentTemplate<...> | undefined;
template?: BodyTemplate<...> | undefined;
}): (RouteModule: typeof RouteModule) => RouteModule
Defines a file-based route for Gracile to consume.
({
handlerhandler?: Handler<undefined> | {
GET?: Handler<Props> | undefined;
POST?: Handler<Response | Props> | undefined;
QUERY?: Handler<Response> | undefined;
... 4 more ...;
OPTIONS?: Handler<...> | undefined;
} | undefined
A function or an object containing functions named after HTTP methods.
A handler can return either a standard Response
that will terminate the
request pipeline, or any object to populate the current route template
and document contexts.
: {
GETGET?: Handler<Props> | undefined
: () => {
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
;
},
POSTPOST?: Handler<Response | Props> | undefined
: async (contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
) => {
const formDataconst formData: FormData
= await contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
.requestrequest: Request
.formDataBody.formData(): Promise<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: string | undefined
= formDataconst formData: FormData
.getFormData.get(name: string): FormDataEntryValue | null
('my_field')?.toStringfunction toString(): string
Returns a string representation of a string.
();
if (!myFieldValueconst myFieldValue: string | undefined
) {
contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
.responseInitresponseInit: ResponseInit
Let you mutate the downstream page response.
It doesn't take effect if you're returning the
response yourself before (within your request handler).
.statusResponseInit.status?: number | undefined
= 400;
propsconst props: Props
.messagemessage: string | null
= 'Missing field.';
} else {
propsconst props: Props
.successsuccess: boolean
= true;
myDatalet myData: string
= myFieldValueconst myFieldValue: string
;
propsconst props: Props
.myDatamyData: unknown
= myDatalet myData: string
;
}
if (formDataconst formData: FormData
.getFormData.get(name: string): FormDataEntryValue | null
('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;
}
This Fetch API interface represents the response to a request.
.jsonfunction json(data: any, init?: ResponseInit): Response
(propsconst props: Props
, contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
.responseInitresponseInit: ResponseInit
Let you mutate the downstream page response.
It doesn't take effect if you're returning the
response yourself before (within your request handler).
);
}
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;
}
This Fetch API interface represents the response to a request.
.redirectfunction redirect(url: string | URL, status?: number): Response
(contextcontext: {
url: URL;
params: Parameters;
request: Request;
locals: Gracile.Locals;
responseInit: ResponseInit;
}
.urlurl: URL
, 303);
}
We want the user data to be repopulated in the page after a failed `POST`.
return propsconst props: Props
;
},
},
documentdocument?: DocumentTemplate<{
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}> | undefined
A function that returns a server only template.
Route context is provided at runtime during the build.
: (contextcontext: {
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}
) => documentfunction document(): ServerRenderedTemplate
({ ...contextcontext: {
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}
, titletitle: string
: 'Form with JS' }),
templatetemplate?: BodyTemplate<{
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}> | undefined
A function that returns a server only or a Lit client hydratable template.
Route context is provided at runtime during the build.
: (contextcontext: {
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}
) => {
consolevar console: Console
The console
module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console
class with methods such as console.log()
, console.error()
and console.warn()
that can be used to write to any Node.js stream.
- A global
console
instance configured to write to process.stdout
and
process.stderr
. The global console
can be used without importing the node:console
module.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O
for
more information.
Example using the global console
:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console
class:
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
.logConsole.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to stdout
with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()
).
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
See util.format()
for more information.
(contextcontext: {
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}
);
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: {
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}
.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: JSON
An 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: {
url: URL;
props: {
GET: Props;
POST: Props;
};
params: Parameters;
}
.propsprops: {
GET: Props;
POST: Props;
}
, null, 2)}</pre>
</form-augmented>
`;
},
});