Skip to main content

max / makenotwork

8.0 KB · 203 lines History Blame Raw
1 // HTMX lifecycle glue, ported from mnw.js: CSRF header injection (25),
2 // response-error toast (281), loading buttons (350), success flash (471), and
3 // the plain (non-HTMX) form-submit loading + bfcache reset (431).
4
5 import { resolveHtmxLoadingButton } from './loading.ts';
6 import { csrfHeaders } from './net.ts';
7 import { showErrorToast, showToast } from './toast.ts';
8 import { revertMs } from './timing.ts';
9
10 /** htmx 4 hangs the whole request on `detail.ctx`, where htmx 2 spread it
11 * across the detail itself as `elt`, `xhr`, `headers` and `successful`. Only
12 * the fields this file reads are named. */
13 interface HxDetail {
14 ctx: {
15 sourceElement: HTMLElement;
16 request: { headers: Record<string, string> };
17 response?: { status: number; headers: Headers };
18 text?: string;
19 };
20 }
21
22 /** The element the request came from. `detail.elt` in htmx 2. */
23 function source(e: Event): HTMLElement {
24 return (e as CustomEvent<HxDetail>).detail.ctx.sourceElement;
25 }
26
27 function restoreLoadingButton(e: Event): void {
28 const btn = resolveHtmxLoadingButton(source(e));
29 if (btn && btn.dataset.origText) {
30 btn.textContent = btn.dataset.origText;
31 btn.disabled = false;
32 delete btn.dataset.origText;
33 }
34 }
35
36 /** Install all delegated HTMX lifecycle listeners. Call once at startup. */
37 export function initHtmxGlue(): void {
38 const body = document.body;
39
40 // Attach the CSRF token live on every request. It rotates mid-session, so a
41 // snapshot would go stale and 403 every mutation.
42 body.addEventListener('htmx:config:request', (e) => {
43 const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
44 if (token) (e as CustomEvent<HxDetail>).detail.ctx.request.headers['X-CSRF-Token'] = token;
45 });
46
47 // Error toast: prefer the HX-Error header (present on page routes too), then
48 // a JSON {error}, then a generic string. Never render raw JSON. The element
49 // itself is `toast.ts`'s, like every other toast on the page; what is decided
50 // here is the text and what Retry means.
51 body.addEventListener('htmx:response:error', (e) => {
52 const evt = e as CustomEvent<HxDetail>;
53 let text = 'An error occurred.';
54 const headerMsg = evt.detail.ctx.response?.headers.get('HX-Error');
55 if (headerMsg) {
56 text = headerMsg;
57 } else {
58 const bodyText = evt.detail.ctx.text;
59 if (bodyText) {
60 try {
61 const parsed = JSON.parse(bodyText) as { error?: unknown };
62 if (parsed && typeof parsed.error === 'string' && parsed.error) text = parsed.error;
63 } catch {
64 /* non-JSON body (e.g. an HTML error page); keep the fallback */
65 }
66 }
67 }
68 // A click, whatever the element's `hx-trigger` says. The branch here used
69 // to dispatch `htmx:trigger` for anything under an `hx-trigger`, which htmx
70 // emits and has never listened for, so that half was always a no-op.
71 showErrorToast(text, () => evt.detail.ctx.sourceElement?.click());
72 });
73
74 // Loading button on request start; restore on every terminal event.
75 body.addEventListener('htmx:before:request', (e) => {
76 const btn = resolveHtmxLoadingButton(source(e));
77 if (btn && !btn.dataset.origText) {
78 btn.dataset.origText = btn.textContent ?? '';
79 btn.textContent = btn.dataset.loadingText || 'Saving...';
80 btn.disabled = true;
81 }
82 });
83 // One terminal event instead of the four htmx 2 needed. It fires whether the
84 // request succeeded, failed, timed out or was cancelled, which is the whole
85 // condition for putting the button back.
86 body.addEventListener('htmx:finally:request', restoreLoadingButton);
87
88 // Success flash: data-success-toast (for hx-swap="none") + data-success-text.
89 body.addEventListener('htmx:after:request', (e) => {
90 const evt = e as CustomEvent<HxDetail>;
91 // `detail.successful` in htmx 2. The status is what it summarised, and 4
92 // fires this event for error responses too.
93 if ((evt.detail.ctx.response?.status ?? 0) >= 400) return;
94 const elt = evt.detail.ctx.sourceElement;
95
96 const toastEl = elt && elt.closest && elt.closest<HTMLElement>('[data-success-toast]');
97 if (toastEl) showToast(toastEl.dataset.successToast ?? '', 'info');
98
99 const btn = resolveHtmxLoadingButton(elt);
100 if (!btn || !btn.dataset.successText) return;
101 const successText = btn.dataset.successText;
102 const restoreTo = btn.dataset.origText || btn.textContent || '';
103 btn.textContent = successText;
104 btn.disabled = true;
105 delete btn.dataset.origText;
106 // The same temporary label a copy button flashes, so the same intent.
107 setTimeout(() => {
108 if (btn.textContent === successText) {
109 btn.textContent = restoreTo;
110 btn.disabled = false;
111 }
112 }, revertMs());
113 });
114
115 // data-saves: the answer is a file the reader keeps, not a view.
116 //
117 // Emitted by the description layer from `Action::saving(name)`. It replaces
118 // window.exportCsvButton, which said the same thing as a class name plus two
119 // positional arguments and had to be repeated per button. A described screen
120 // says what it means and this performs it once for all of them.
121 //
122 // htmx cannot do this itself: it swaps a response into the DOM, and a CSV is
123 // not markup. So the request is cancelled here and reissued as a fetch whose
124 // body becomes a download. A read never reaches this at all, because a link
125 // carries a `download` attribute instead and the browser does the whole job.
126 body.addEventListener('htmx:before:request', (e) => {
127 const evt = e as CustomEvent<HxDetail>;
128 const elt = evt.detail.ctx.sourceElement;
129 const saveAs = elt?.dataset?.saves;
130 if (!saveAs) return;
131 evt.preventDefault();
132
133 const url = elt.getAttribute('hx-post') ?? elt.getAttribute('hx-put') ?? '';
134 if (!url) return;
135 const restore = elt.textContent ?? '';
136 elt.textContent = 'Exporting...';
137 if (elt instanceof HTMLButtonElement) elt.disabled = true;
138 const done = (): void => {
139 elt.textContent = restore;
140 if (elt instanceof HTMLButtonElement) elt.disabled = false;
141 };
142
143 void fetch(url, { method: 'POST', headers: csrfHeaders() })
144 .then((r) => {
145 if (!r.ok) throw new Error(String(r.status));
146 return r.blob();
147 })
148 .then((blob) => {
149 const href = URL.createObjectURL(blob);
150 const link = document.createElement('a');
151 link.href = href;
152 link.download = saveAs;
153 link.click();
154 // Or the blob is held for the life of the document.
155 URL.revokeObjectURL(href);
156 done();
157 })
158 .catch(() => {
159 done();
160 showToast('Export failed', 'error');
161 });
162 });
163
164 // Plain (non-HTMX) form submit: swap the label while the browser round-trips
165 // (e.g. Stripe checkout). Opt in via data-loading-text on the submit button.
166 body.addEventListener(
167 'submit',
168 (evt) => {
169 if (evt.defaultPrevented) return;
170 const form = evt.target as HTMLFormElement | null;
171 if (!form || form.tagName !== 'FORM') return;
172 if (
173 form.hasAttribute('hx-post') ||
174 form.hasAttribute('hx-get') ||
175 form.hasAttribute('hx-put') ||
176 form.hasAttribute('hx-delete') ||
177 form.hasAttribute('hx-patch')
178 )
179 return;
180 const btn = form.querySelector<HTMLButtonElement>('[data-loading-text]');
181 if (!btn || btn.dataset.origText) return;
182 btn.dataset.origText = btn.textContent ?? '';
183 btn.textContent = btn.dataset.loadingText ?? '';
184 // Defer disabling so the button's name/value still enters the form body.
185 setTimeout(() => {
186 btn.disabled = true;
187 }, 0);
188 },
189 true,
190 );
191
192 // bfcache restore: a back-nav can restore a button stuck in its "Redirecting…"
193 // state; reset it so the page is usable again.
194 window.addEventListener('pageshow', (evt) => {
195 if (!(evt as PageTransitionEvent).persisted) return;
196 document.querySelectorAll<HTMLButtonElement>('button[data-orig-text]').forEach((btn) => {
197 btn.textContent = btn.dataset.origText ?? '';
198 btn.disabled = false;
199 delete btn.dataset.origText;
200 });
201 });
202 }
203