sygnal 6.6.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +258 -0
- package/README.md +2 -0
- package/dist/astro/index.cjs.js +32 -9
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +32 -9
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +32 -43
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +32 -43
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +154 -6
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +153 -7
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/accessibility.md +3 -1
- package/dist/guide/ai-chat.md +6 -6
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/element-commands.md +4 -2
- package/dist/guide/forms-reference.md +89 -3
- package/dist/guide/forms.md +8 -8
- package/dist/guide/http.md +77 -7
- package/dist/guide/migrating-to-7.md +314 -0
- package/dist/guide/recipes/ai-summarize.md +2 -1
- package/dist/guide/recipes/i18n.md +1 -1
- package/dist/guide/resources.md +2 -0
- package/dist/guide/timers.md +3 -3
- package/dist/guide/ui/tabs.md +5 -4
- package/dist/guide/ui/tooltip.md +24 -0
- package/dist/index.cjs.js +954 -388
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +300 -46
- package/dist/index.esm.js +953 -389
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui.cjs.js +96 -11
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +96 -12
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +19 -9
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +19 -9
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +23 -22
- package/package.json +1 -1
- package/src/astro/index.d.ts +8 -0
- package/src/astro/index.ts +18 -0
- package/src/core/actions.ts +28 -21
- package/src/core/define.ts +2 -0
- package/src/core/hooks.ts +2 -2
- package/src/core/hosts/collection.ts +4 -3
- package/src/core/hosts/switchable.ts +26 -8
- package/src/core/instance.ts +66 -17
- package/src/core/runtime.ts +5 -1
- package/src/core/statics.ts +5 -5
- package/src/extra/ai/chat/behavior.ts +3 -3
- package/src/extra/ai/commandBar.ts +3 -3
- package/src/extra/ai/compose/behavior.ts +3 -3
- package/src/extra/ai/transports/shared.ts +10 -2
- package/src/extra/behaviors.ts +24 -3
- package/src/extra/diagnostics/checks/fetch.ts +21 -1
- package/src/extra/diagnostics/checks/forms.ts +4 -3
- package/src/extra/diagnostics/checks/index.ts +1 -0
- package/src/extra/diagnostics/checks/next.ts +49 -3
- package/src/extra/diagnostics/checks/props.ts +6 -1
- package/src/extra/diagnostics/checks/public.d.ts +12 -0
- package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
- package/src/extra/diagnostics/codes.ts +24 -0
- package/src/extra/fetchDriver.ts +115 -7
- package/src/extra/fetchEventSource.ts +117 -0
- package/src/extra/form.ts +68 -26
- package/src/extra/formHelpers.ts +3 -1
- package/src/extra/lens.ts +3 -2
- package/src/extra/queryCache.ts +4 -3
- package/src/extra/socketDriver.ts +60 -9
- package/src/extra/ssr.ts +30 -41
- package/src/extra/testing.ts +100 -18
- package/src/extra/timers.ts +5 -2
- package/src/index.d.ts +299 -45
- package/src/index.ts +3 -0
- package/src/shared.ts +3 -0
- package/src/ui/dialog.ts +3 -2
- package/src/ui/tooltips.ts +92 -0
- package/src/ui.d.ts +35 -0
- package/src/ui.ts +1 -0
- package/src/vite/graph.ts +11 -3
- package/src/vite/plugin.d.ts +7 -0
- package/src/vite/plugin.ts +10 -5
|
@@ -146,6 +146,8 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
146
146
|
SYG111: 'Controlled input has no input listener',
|
|
147
147
|
SYG115: 'Unknown DOM event shorthand',
|
|
148
148
|
SYG116: 'EVENTS value has no string type',
|
|
149
|
+
// PLAN-13 N-1: dev entry (checks/next.ts onNext) and sygnal-check
|
|
150
|
+
SYG117: 'next() names an action with no model entry',
|
|
149
151
|
SYG124: 'Component used as a control or selector',
|
|
150
152
|
SYG125: 'Control given .intent, .model or .initialState',
|
|
151
153
|
SYG126: 'Control rendered but never listened to',
|
|
@@ -181,6 +183,8 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
181
183
|
// PLAN-5 V-1: VirtualCollection (checks/virtual.ts)
|
|
182
184
|
SYG430: 'VirtualCollection has no bounded height',
|
|
183
185
|
SYG431: 'VirtualCollection items without ids',
|
|
186
|
+
// PLAN-13 F-3: dev entry (checks/next.ts onRender) and sygnal-check
|
|
187
|
+
SYG429: 'Switchable state map names no page',
|
|
184
188
|
SYG432: 'VirtualCollection item does not render one element',
|
|
185
189
|
SYG433: 'VirtualCollection scroll target not in the list',
|
|
186
190
|
SYG434: 'Invalid VirtualCollection estimateSize or overscan',
|
|
@@ -196,6 +200,8 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
196
200
|
SYG202: 'STATE reducer returned undefined',
|
|
197
201
|
// SYG3xx streams & intent construction (1A)
|
|
198
202
|
SYG301: 'RxJS operator used on an xstream stream',
|
|
203
|
+
// PLAN-13 X-1: static only (sygnal-check)
|
|
204
|
+
SYG302: "Default import from 'xstream'",
|
|
199
205
|
// SYG4xx collections / switchable / sub-components / context (1A)
|
|
200
206
|
SYG401: "Collection 'from' field is missing or not an array",
|
|
201
207
|
// SYG5xx strict mode / canonical forms (2A; dev-plans/PLAN-1-canonical-forms.md)
|
|
@@ -310,6 +316,8 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
310
316
|
SYG634: 'latest: true with a computed key',
|
|
311
317
|
// PLAN-3 5-5 (D88)
|
|
312
318
|
SYG635: 'Caching asked for without a queryCache',
|
|
319
|
+
// PLAN-11 U-1: progress requests (dev entry, fetch.ts)
|
|
320
|
+
SYG636: "progress that can't be reported as asked",
|
|
313
321
|
// PLAN-4 GS-2: element commands (reported by the dev entry, checks/elementCommands.ts)
|
|
314
322
|
SYG640: 'Element command target not found',
|
|
315
323
|
SYG641: 'Unknown element command',
|
|
@@ -369,6 +377,9 @@ export const CODE_TITLES: Record<string, string> = {
|
|
|
369
377
|
// PLAN-9 X-2: static only (sygnal-check rules/syg253-catalog.js)
|
|
370
378
|
SYG253: 'Misspelled catalog declaration key',
|
|
371
379
|
SYG254: 'Catalog descriptions or options overlap',
|
|
380
|
+
SYG256: 'Output guard rejects the updated state',
|
|
381
|
+
SYG257: 'Output reads a field its action changed (7.0 timing audit)',
|
|
382
|
+
SYG454: 'State binding changed without a new component identity',
|
|
372
383
|
// PLAN-9 H: chat client tools and ask (src/extra/ai/chat/behavior.ts, compose/ask.ts, compose/render.ts)
|
|
373
384
|
SYG160: 'Result part rendered outside a card',
|
|
374
385
|
SYG684: 'Client tool shadows an agent tool',
|
|
@@ -421,6 +432,7 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
|
|
|
421
432
|
SYG112: 'error',
|
|
422
433
|
SYG115: 'warn',
|
|
423
434
|
SYG116: 'error',
|
|
435
|
+
SYG117: 'warn',
|
|
424
436
|
// PLAN-4 1-D: controls, static (sygnal-check)
|
|
425
437
|
SYG124: 'error',
|
|
426
438
|
SYG125: 'error',
|
|
@@ -432,6 +444,10 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
|
|
|
432
444
|
SYG129: 'warn',
|
|
433
445
|
// PLAN-10 X-1: static only (sygnal-check)
|
|
434
446
|
SYG170: 'warn',
|
|
447
|
+
// PLAN-13 X-1: static only (sygnal-check)
|
|
448
|
+
SYG302: 'info',
|
|
449
|
+
// PLAN-13 F-3 (checks/next.ts)
|
|
450
|
+
SYG429: 'warn',
|
|
435
451
|
SYG171: 'warn',
|
|
436
452
|
SYG221: 'error',
|
|
437
453
|
// PLAN-4 GS-4 (state.ts)
|
|
@@ -481,6 +497,8 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
|
|
|
481
497
|
SYG634: 'info',
|
|
482
498
|
// PLAN-3 5-5 (fetch.ts)
|
|
483
499
|
SYG635: 'warn',
|
|
500
|
+
// PLAN-11 U-1 (fetch.ts)
|
|
501
|
+
SYG636: 'warn',
|
|
484
502
|
// PLAN-4 GS-2 (checks/elementCommands.ts, through the core bridge; renderComponent too)
|
|
485
503
|
SYG640: 'warn',
|
|
486
504
|
SYG641: 'error',
|
|
@@ -597,6 +615,12 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
|
|
|
597
615
|
// PLAN-9 X-2: static only (sygnal-check); listed so the entry knows their titles
|
|
598
616
|
SYG253: 'warn',
|
|
599
617
|
SYG254: 'info',
|
|
618
|
+
// PLAN-12: static only; proven after-state output guard contradiction.
|
|
619
|
+
SYG256: 'warn',
|
|
620
|
+
// 7.0: the opt-in timing audit (checks/next.ts; configureTimingAudit)
|
|
621
|
+
SYG257: 'info',
|
|
622
|
+
// PLAN-12: static only for legacy (6.x) binding contracts. Sygnal 7 remounts known targets.
|
|
623
|
+
SYG454: 'warn',
|
|
600
624
|
}
|
|
601
625
|
|
|
602
626
|
export function getCodeInfo(code: string): DiagnosticCodeInfo | undefined {
|
package/src/extra/fetchDriver.ts
CHANGED
|
@@ -9,8 +9,11 @@ import {validateWith} from './standardSchema';
|
|
|
9
9
|
* declarations in src/index.d.ts and docs/guide/drivers.md. Summary:
|
|
10
10
|
*
|
|
11
11
|
* - Request (sink value): `{ url, ok?, error?, key?, category?, method?, headers?, query?, json?,
|
|
12
|
-
* body?, latest?, timeoutMs?, parse?, init? }` or a URL string. `json` is stringified
|
|
13
|
-
* content-type;
|
|
12
|
+
* multipart?, body?, latest?, timeoutMs?, parse?, init? }` or a URL string. `json` is stringified
|
|
13
|
+
* with a JSON content-type; PLAN-11 U-5: `multipart` (an object) becomes a FormData body (a
|
|
14
|
+
* File / Blob a file part, an array repeats the name, null / undefined skipped, an object JSON,
|
|
15
|
+
* anything else a string), the content-type left to the browser (its boundary); method defaults
|
|
16
|
+
* to POST with a body/json/multipart, else GET. `query` is appended as a
|
|
14
17
|
* query string (null/undefined values skipped, arrays repeat the key, before any #fragment).
|
|
15
18
|
* Other fetch options (credentials, mode, cache, ...) go under `init` (R4-5); other top-level
|
|
16
19
|
* keys are the app's own: not sent, but returned on `request`.
|
|
@@ -87,6 +90,15 @@ import {validateWith} from './standardSchema';
|
|
|
87
90
|
* the request itself) into requests sent again in parallel and combined, at most 3 deep. One
|
|
88
91
|
* failure fails the request once (`error.status` / `error.body`). No cache entry sharing, no
|
|
89
92
|
* per-part retry. The test fake (`_whole`) sends and answers it as one request. decide() uses it.
|
|
93
|
+
* - PLAN-11 U-1 (D555): `progress: 'ACTION'` replies to the sender with each progress step,
|
|
94
|
+
* `progress: true` (or a string with no sender) sends it down `progress(category?)`:
|
|
95
|
+
* `{ phase: 'upload' | 'download', loaded, total, fraction, category, request }` (`total` /
|
|
96
|
+
* `fraction` null when the length is unknown). Such a request goes over XMLHttpRequest (the
|
|
97
|
+
* only transport with upload progress in every engine; S-1), turned back into a Response so
|
|
98
|
+
* parsing, status, retry and timeouts are the same. Steps are throttled to one per 50 ms per
|
|
99
|
+
* phase, keeping the first and the last. Not for resources or `batch`; never shares a fetch.
|
|
100
|
+
* No XMLHttpRequest (Node, a service worker): plain fetch, no steps. The transport is
|
|
101
|
+
* `options.XMLHttpRequest`, else the global one, read at request time.
|
|
90
102
|
* - dispose / sink completion aborts everything in flight.
|
|
91
103
|
* - `fetch` is read at request time (options.fetch, else globalThis.fetch), so test stubs work.
|
|
92
104
|
* - PLAN-3 5-1 (H-9): renderComponent's HTTP fake runs this driver over an in-memory fetch;
|
|
@@ -177,6 +189,70 @@ const mergeHeaders = (...all: any[]) => {
|
|
|
177
189
|
return out;
|
|
178
190
|
};
|
|
179
191
|
|
|
192
|
+
// PLAN-11 U-1: statuses whose Response must have a null body
|
|
193
|
+
const NULL_BODY = [101, 103, 204, 205, 304];
|
|
194
|
+
const abortError = () => typeof DOMException == 'function'
|
|
195
|
+
? new DOMException('The operation was aborted.', 'AbortError')
|
|
196
|
+
: Object.assign(new Error('The operation was aborted.'), {name: 'AbortError'});
|
|
197
|
+
/**
|
|
198
|
+
* PLAN-11 U-1 (S-1): a fetch-shaped call over XMLHttpRequest that reports progress:
|
|
199
|
+
* `on(phase, loaded, total)` (total 0 when unknown), at most every 50 ms per phase, always the
|
|
200
|
+
* first and the final step. Resolves with a real Response (blob body, parsed headers); network
|
|
201
|
+
* errors and timeouts reject with a TypeError, an abort with an AbortError, like fetch.
|
|
202
|
+
*/
|
|
203
|
+
export const xhrFetch = (X: any, url: string, init: any, on: (phase: string, loaded: number, total: number) => void) => new Promise<any>((res, rej) => {
|
|
204
|
+
const x = new X();
|
|
205
|
+
const last: any = {};
|
|
206
|
+
const step = (phase: string, loaded: number, total: number, final?: any) => {
|
|
207
|
+
const l = last[phase], now = Date.now();
|
|
208
|
+
if (l && (l.loaded === loaded || (!final && now - l.at < 50 && !(total && loaded >= total)))) return;
|
|
209
|
+
last[phase] = {loaded, at: now};
|
|
210
|
+
on(phase, loaded, total);
|
|
211
|
+
};
|
|
212
|
+
const ev = (phase: string) => (e: any) => step(phase, e.loaded, e.lengthComputable ? e.total : 0);
|
|
213
|
+
x.open(init.method || 'GET', url);
|
|
214
|
+
x.responseType = 'blob';
|
|
215
|
+
const h = init.headers || {};
|
|
216
|
+
Object.keys(h).forEach(k => x.setRequestHeader(k, h[k]));
|
|
217
|
+
if (init.credentials == 'include') x.withCredentials = true;
|
|
218
|
+
const sig = init.signal;
|
|
219
|
+
if (sig) {
|
|
220
|
+
if (sig.aborted) return rej(abortError());
|
|
221
|
+
sig.addEventListener?.('abort', () => x.abort(), {once: true});
|
|
222
|
+
}
|
|
223
|
+
if (init.body != null && x.upload) {
|
|
224
|
+
x.upload.onprogress = ev('upload');
|
|
225
|
+
x.upload.onload = (e: any) => step('upload', e.loaded, e.lengthComputable ? e.total : e.loaded, 1);
|
|
226
|
+
}
|
|
227
|
+
x.onprogress = ev('download');
|
|
228
|
+
x.onload = () => {
|
|
229
|
+
const size = x.response?.size ?? last.download?.loaded ?? 0;
|
|
230
|
+
step('download', size, size, 1);
|
|
231
|
+
const headers: any = typeof Headers == 'function' ? new Headers() : undefined;
|
|
232
|
+
String(x.getAllResponseHeaders?.() || '').trim().split(/[\r\n]+/).forEach((line: string) => {
|
|
233
|
+
const i = line.indexOf(':');
|
|
234
|
+
if (i > 0) try { headers?.append(line.slice(0, i).trim(), line.slice(i + 1).trim()); } catch (_) {}
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
res(new Response(NULL_BODY.includes(x.status) ? null : x.response, {status: x.status, statusText: x.statusText, headers}));
|
|
238
|
+
} catch (e) { rej(e); }
|
|
239
|
+
};
|
|
240
|
+
x.onerror = x.ontimeout = () => rej(new TypeError(`Network request failed: ${url}`));
|
|
241
|
+
x.onabort = () => rej(abortError());
|
|
242
|
+
x.send(init.body ?? null);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** PLAN-11 U-5: a request's `multipart` object as FormData (see the header) */
|
|
246
|
+
export const toFormData = (parts: any) => {
|
|
247
|
+
const fd = new FormData();
|
|
248
|
+
Object.keys(parts || {}).forEach(k => [].concat(parts[k]).forEach((v: any) => {
|
|
249
|
+
if (v == null) return;
|
|
250
|
+
if (typeof Blob != 'undefined' && v instanceof Blob) fd.append(k, v, (v as any).name ?? 'blob');
|
|
251
|
+
else fd.append(k, typeof v == 'object' ? JSON.stringify(v) : String(v));
|
|
252
|
+
}));
|
|
253
|
+
return fd;
|
|
254
|
+
};
|
|
255
|
+
|
|
180
256
|
const IDEMPOTENT = /^(GET|HEAD)$/i;
|
|
181
257
|
// D80: an invalidate value matches a request by tag, URL prefix ('/…') or predicate
|
|
182
258
|
export const hit = (x: any, q: any) => {
|
|
@@ -199,6 +275,13 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
199
275
|
// q (the request), timer, f (the fetch it waits on), ok / ko (its outcomes) }
|
|
200
276
|
const inflight = new Map<number, any>();
|
|
201
277
|
const subs = new Set<{l: any; sel: any; err: boolean; ns: any[]}>();
|
|
278
|
+
// PLAN-11 U-1: progress(category?) listeners (not held while nobody listens)
|
|
279
|
+
const psubs = new Set<{l: any; sel: any; ns: any[]}>();
|
|
280
|
+
const emitProgress = (v: any) => psubs.forEach(s => {
|
|
281
|
+
let m = false;
|
|
282
|
+
try { m = matches(s, v); } catch (_) {}
|
|
283
|
+
if (m) try { s.l.next(v); } catch (e) { console.error('[Sygnal] makeFetchDriver: a listener threw', e); }
|
|
284
|
+
});
|
|
202
285
|
// R4-4: replies held until the first select() listener, failures until the first errors()
|
|
203
286
|
// listener (or the next macrotask, when they're logged if nothing listens)
|
|
204
287
|
const started = [false, false];
|
|
@@ -246,6 +329,10 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
246
329
|
return {
|
|
247
330
|
select: make(false),
|
|
248
331
|
errors: make(true),
|
|
332
|
+
progress: (sel?: any) => {
|
|
333
|
+
let sub: any;
|
|
334
|
+
return xs.create<any>({start: l => { psubs.add((sub = {l, sel, ns})); }, stop: () => { psubs.delete(sub); }});
|
|
335
|
+
},
|
|
249
336
|
isolateSource: (_: any, scope: any) => source(ns.concat(scope)),
|
|
250
337
|
isolateSink: (sink$: any, scope: any) => sink$.map((req: any) => tagRequest(req, scope)),
|
|
251
338
|
// PLAN-4.6: the same per value (the core scopes a request without a stream per instance)
|
|
@@ -344,6 +431,9 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
344
431
|
if (E && 'v' in E && !q.validate) write(s, n, R, 'success', E.v, undefined, go);
|
|
345
432
|
else if ((same || q.keepPrevious) && L && SETTLED.test(L.status)) write(s, n, R, L.status, L.data, L.error, true);
|
|
346
433
|
else write(s, n, R, 'loading');
|
|
434
|
+
// R-1: the write is dispatched synchronously; when it changed the request (a navigation
|
|
435
|
+
// recomputed the statics and a nested load sent the new one) or paused it, q is stale
|
|
436
|
+
if (R.q !== q || R.p) return;
|
|
347
437
|
if (go || q.validate) R.i = send(q, s, n, after, R);
|
|
348
438
|
};
|
|
349
439
|
|
|
@@ -351,7 +441,7 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
351
441
|
const send = (req: any, sender = senderOf(req), rn?: string, after = -1, R?: any, pf?: any): any => {
|
|
352
442
|
if (typeof req == 'string') req = {url: req};
|
|
353
443
|
if (!req || typeof req != 'object' || disposed || !allowed(req, 'makeFetchDriver')) return;
|
|
354
|
-
const {url, category, method, headers, query, json, body, latest, timeoutMs, parse, abort, init, ok, error, key, resources, refresh, invalidates, updates, retry, validate, prefetch} = req;
|
|
444
|
+
const {url, category, method, headers, query, json, body, latest, timeoutMs, parse, abort, init, ok, error, key, resources, refresh, invalidates, updates, retry, validate, prefetch, progress} = req;
|
|
355
445
|
if ('invalidate' in req) return inval(req.invalidate);
|
|
356
446
|
if (prefetch) return C && send(prefetch, 0, rn, -1, R, 1);
|
|
357
447
|
if (resources || refresh) {
|
|
@@ -389,7 +479,9 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
389
479
|
}
|
|
390
480
|
const scope = scopeKey(req);
|
|
391
481
|
// reply actions: keyed per (sender, key ?? ok ?? error); a resource per (sender, '\0' + name)
|
|
392
|
-
|
|
482
|
+
// PLAN-11 U-1: a progress action is a reply action too (abort on dispose, its own lane)
|
|
483
|
+
const pr = !pf && rn === undefined && !req.batch && progress;
|
|
484
|
+
const rk = rn !== undefined ? '\0' + rn : !pf && sender !== undefined && (ok || error || typeof pr == 'string') ? key ?? ok ?? error ?? pr : undefined;
|
|
393
485
|
if (sender !== undefined && (typeof abort == 'string' || (abort && key !== undefined)))
|
|
394
486
|
return cancel(r => r.sender === sender && r.rk === (key ?? abort));
|
|
395
487
|
// per (scope, category): another component's requests are never cancelled
|
|
@@ -398,12 +490,16 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
398
490
|
|
|
399
491
|
const base = options.init;
|
|
400
492
|
// a request's URL and fetch() init (the batch's sub-requests inherit the request's fields)
|
|
401
|
-
const prep = ({url, method, headers, query, json, body, init: own}: any) => {
|
|
493
|
+
const prep = ({url, method, headers, query, json, multipart, body, init: own}: any) => {
|
|
402
494
|
const h = mergeHeaders(options.headers, base?.headers, own?.headers, headers);
|
|
403
495
|
let b = body ?? own?.body;
|
|
404
496
|
if (json !== undefined) {
|
|
405
497
|
b = JSON.stringify(json);
|
|
406
498
|
if (!Object.keys(h).some(k => k.toLowerCase() == 'content-type')) h['content-type'] = 'application/json';
|
|
499
|
+
} else if (multipart !== undefined) {
|
|
500
|
+
b = toFormData(multipart);
|
|
501
|
+
// the browser writes multipart/form-data with its boundary; a content-type set here would break it
|
|
502
|
+
Object.keys(h).forEach(k => { if (k.toLowerCase() == 'content-type') delete h[k]; });
|
|
407
503
|
}
|
|
408
504
|
return [withQuery((options.baseUrl || '') + (url ?? ''), query), {...pickInit(base), ...pickInit(own), method: method || own?.method || base?.method || (b !== undefined ? 'POST' : 'GET'), headers: h, body: b}];
|
|
409
505
|
};
|
|
@@ -413,7 +509,8 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
413
509
|
// parts, at most 3 deep), their parsed bodies merged by `combine` into one reply. The test
|
|
414
510
|
// fake (_whole) answers such a request as one
|
|
415
511
|
const {batch, split, combine = (x: any) => x} = options._whole ? {} as any : req;
|
|
416
|
-
|
|
512
|
+
// PLAN-11 U-1: a progress request never shares a fetch (each sender gets its own steps)
|
|
513
|
+
const k = pr ? undefined : C?.key(req, rn !== undefined || pf);
|
|
417
514
|
const id = ++seq;
|
|
418
515
|
const r: any = {category, scope, sender, rk};
|
|
419
516
|
inflight.set(id, r);
|
|
@@ -449,7 +546,18 @@ export function makeFetchDriver(options: any = {}) {
|
|
|
449
546
|
const parser = parserOf(parse || options.parse);
|
|
450
547
|
// the global fetch is called as a method: a detached window.fetch throws "Illegal invocation"
|
|
451
548
|
const g: any = globalThis;
|
|
452
|
-
|
|
549
|
+
let doFetch = options.fetch || (typeof g.fetch == 'function' && ((u: string, i: any) => g.fetch(u, i)));
|
|
550
|
+
// PLAN-11 U-1: a progress request over XMLHttpRequest (renderComponent's fake: `_progress`)
|
|
551
|
+
const X = options.XMLHttpRequest ?? g.XMLHttpRequest;
|
|
552
|
+
if (pr && (options._progress || typeof X == 'function')) {
|
|
553
|
+
const step = (phase: string, loaded: number, total: number) => {
|
|
554
|
+
if (!inflight.has(id) || disposed) return;
|
|
555
|
+
const t = total > 0 ? total : null;
|
|
556
|
+
const v = {phase, loaded, total: t, fraction: t ? Math.min(1, loaded / t) : null, category, request: req};
|
|
557
|
+
typeof pr == 'string' && sender !== undefined ? reply(sender, pr, v) : emitProgress(v);
|
|
558
|
+
};
|
|
559
|
+
doFetch = options._progress ? (u: string, i: any) => options._progress(u, i, step, req) : (u: string, i: any) => xhrFetch(X, u, i, step);
|
|
560
|
+
}
|
|
453
561
|
// the outcome, to every request waiting on this fetch (the failure once, after the last attempt)
|
|
454
562
|
const end = (e: any, x?: any, status?: number) => {
|
|
455
563
|
done(F);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {sse} from './ai/transports/shared';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* PLAN-11 E-1: an EventSource over fetch, for server-sent events that need request headers
|
|
5
|
+
* (`Authorization`), which the native EventSource can't send. makeSocketDriver uses it for an
|
|
6
|
+
* `sse` connection with `headers`. It behaves as EventSource does where the driver relies on it:
|
|
7
|
+
*
|
|
8
|
+
* - connects on construction (a microtask later, so handlers can be set first); `readyState`
|
|
9
|
+
* CONNECTING (0) → OPEN (1) on a 2xx `text/event-stream` response;
|
|
10
|
+
* - `onmessage` and `addEventListener(type)` get `{ type, data, lastEventId }`;
|
|
11
|
+
* - the stream ending or a network error: `error`, readyState CONNECTING, a reconnect after
|
|
12
|
+
* `retry` ms (the stream's `retry:` field, default 3000) with `Last-Event-ID`;
|
|
13
|
+
* - a non-2xx status or another content type: `error` with readyState CLOSED (no retry; the
|
|
14
|
+
* driver's reconnect policy decides);
|
|
15
|
+
* - `close()` aborts the request and stops retrying.
|
|
16
|
+
* - PLAN-13 S-1: `init.lastEventId` seeds the id sent as `Last-Event-ID` (a driver reconnect that
|
|
17
|
+
* resumes); `onbeat` (not EventSource's) is called for any bytes read, heartbeat comments too.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const NAMES = ['CONNECTING', 'OPEN', 'CLOSED'];
|
|
21
|
+
|
|
22
|
+
export class FetchEventSource {
|
|
23
|
+
static CONNECTING = 0;
|
|
24
|
+
static OPEN = 1;
|
|
25
|
+
static CLOSED = 2;
|
|
26
|
+
readyState = 0;
|
|
27
|
+
url: string;
|
|
28
|
+
withCredentials: boolean;
|
|
29
|
+
onopen: any = null;
|
|
30
|
+
onmessage: any = null;
|
|
31
|
+
onerror: any = null;
|
|
32
|
+
onbeat: any = null;
|
|
33
|
+
private ls = new Map<string, Set<(e: any) => void>>();
|
|
34
|
+
private meta: {id?: string; retry?: number; beat?: () => void} = {id: '', retry: 3000, beat: () => this.onbeat?.()};
|
|
35
|
+
private ctl: any;
|
|
36
|
+
private timer: any;
|
|
37
|
+
private init: any;
|
|
38
|
+
|
|
39
|
+
constructor(url: string, init: {withCredentials?: boolean; headers?: Record<string, string>; fetch?: any; lastEventId?: string} = {}) {
|
|
40
|
+
this.url = url;
|
|
41
|
+
this.withCredentials = !!init.withCredentials;
|
|
42
|
+
this.init = init;
|
|
43
|
+
if (init.lastEventId) this.meta.id = init.lastEventId;
|
|
44
|
+
queueMicrotask(() => this.connect());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
addEventListener(type: string, f: (e: any) => void) {
|
|
48
|
+
let s = this.ls.get(type);
|
|
49
|
+
if (!s) this.ls.set(type, (s = new Set()));
|
|
50
|
+
s.add(f);
|
|
51
|
+
}
|
|
52
|
+
removeEventListener(type: string, f: (e: any) => void) { this.ls.get(type)?.delete(f); }
|
|
53
|
+
|
|
54
|
+
close() {
|
|
55
|
+
this.readyState = 2;
|
|
56
|
+
clearTimeout(this.timer);
|
|
57
|
+
try { this.ctl?.abort(); } catch (_) {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private emit(type: string, ev: any) {
|
|
61
|
+
if (type == 'message') this.onmessage?.(ev);
|
|
62
|
+
else if (type == 'open') this.onopen?.(ev);
|
|
63
|
+
else if (type == 'error') this.onerror?.(ev);
|
|
64
|
+
this.ls.get(type)?.forEach(f => { try { f(ev); } catch (e) { console.error(e); } });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async connect() {
|
|
68
|
+
if (this.readyState == 2) return;
|
|
69
|
+
const g: any = globalThis;
|
|
70
|
+
const f = this.init.fetch || (typeof g.fetch == 'function' && ((u: string, i: any) => g.fetch(u, i)));
|
|
71
|
+
if (typeof f != 'function') return this.fail(new Error('fetch is not available in this environment'));
|
|
72
|
+
const ctl: any = this.ctl = typeof AbortController == 'function' ? new AbortController() : {};
|
|
73
|
+
const headers: Record<string, string> = {accept: 'text/event-stream', ...this.init.headers};
|
|
74
|
+
if (this.meta.id) headers['last-event-id'] = this.meta.id;
|
|
75
|
+
let res: any;
|
|
76
|
+
try {
|
|
77
|
+
res = await f(this.url, {headers, credentials: this.withCredentials ? 'include' : 'same-origin', cache: 'no-store', signal: ctl.signal});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return this.retry(error);
|
|
80
|
+
}
|
|
81
|
+
if (this.readyState == 2 || this.ctl !== ctl) return;
|
|
82
|
+
const type = res.headers?.get?.('content-type') || '';
|
|
83
|
+
if (!(res.status >= 200 && res.status < 300) || !/^text\/event-stream/i.test(type) || !res.body) {
|
|
84
|
+
try { res.body?.cancel?.(); } catch (_) {}
|
|
85
|
+
return this.fail(Object.assign(new Error(`SSE ${res.status}${type ? ' ' + type : ''}: ${this.url}`), {status: res.status}));
|
|
86
|
+
}
|
|
87
|
+
this.readyState = 1;
|
|
88
|
+
this.emit('open', {type: 'open'});
|
|
89
|
+
try {
|
|
90
|
+
for await (const fr of sse(res, this.meta)) {
|
|
91
|
+
if (this.readyState == 2 || this.ctl !== ctl) return;
|
|
92
|
+
this.emit(fr.event, {type: fr.event, data: fr.data, lastEventId: this.meta.id});
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (this.readyState == 2 || this.ctl !== ctl) return;
|
|
96
|
+
return this.retry(error);
|
|
97
|
+
}
|
|
98
|
+
if (this.readyState != 2 && this.ctl === ctl) this.retry();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** a drop: error, CONNECTING, try again after `retry` ms (unless the error handler closed it) */
|
|
102
|
+
private retry(error?: any) {
|
|
103
|
+
if (this.readyState == 2) return;
|
|
104
|
+
this.readyState = 0;
|
|
105
|
+
this.emit('error', {type: 'error', error});
|
|
106
|
+
if (this.readyState == 2) return;
|
|
107
|
+
this.timer = setTimeout(() => this.connect(), this.meta.retry);
|
|
108
|
+
}
|
|
109
|
+
/** a failed connection: error, CLOSED */
|
|
110
|
+
private fail(error: any) {
|
|
111
|
+
this.readyState = 2;
|
|
112
|
+
this.emit('error', {type: 'error', error});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** the state's name (debugging) */
|
|
116
|
+
get state() { return NAMES[this.readyState]; }
|
|
117
|
+
}
|
package/src/extra/form.ts
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* field.
|
|
14
14
|
*
|
|
15
15
|
* Options: `values` (the start values; D239: or a function of the host's state, called when the
|
|
16
|
-
* form starts), `submit` (host action), `
|
|
16
|
+
* form starts), `submit` (host action), `request` (PLAN-11 F-3: the form sends the valid submit
|
|
17
|
+
* itself on the `http` sink: a request, or `(data, hostState) => request`, completed with the
|
|
18
|
+
* body (`multipart: data` when a top-level value is a File / Blob or an array of them, else
|
|
19
|
+
* `json: data`, unless it sets json / multipart / body), `progress: '<key>.PROGRESS'` (unless set)
|
|
20
|
+
* and its own `ok: '<key>.DONE'`, `error: '<key>.ERRORS'`; with `submit` too, that host action is
|
|
21
|
+
* still dispatched (for state), and should send no request), `check` (async per-field checks through a driver: a
|
|
17
22
|
* request with a reply action, `latest`), `show` ('blur' default: a schema error shows once its
|
|
18
23
|
* field was blurred; 'input'; 'submit'), `form` (selector), `http` (the driver sink of the
|
|
19
24
|
* checks, 'HTTP'), `resetOnShow` (D239), `tool` (PLAN-6 A-3, experimental: `formTool({ … })` from
|
|
@@ -32,6 +37,8 @@
|
|
|
32
37
|
* before that (a child's first render) the fields come from calling it with a blank state.
|
|
33
38
|
*
|
|
34
39
|
* Slice (state.form): values, initial, errors (all current schema errors by name), touched,
|
|
40
|
+
* progress (PLAN-11 F-2: the submit's last progress step or null), resets (counts RESET and
|
|
41
|
+
* resetOnShow starts: key a file input with it, since a file input can't be cleared by state),
|
|
35
42
|
* server (server errors), remote (async check results: '' passed), pending (name -> value being
|
|
36
43
|
* checked), submitting, submitted, submitCount, queued (a submit waits for a check or an async
|
|
37
44
|
* schema), validating (an async schema runs), validated (the schema has answered since the start
|
|
@@ -41,11 +48,14 @@
|
|
|
41
48
|
*
|
|
42
49
|
* Actions: form.CHANGE ({ name, value, item? }: G-376: a checkbox gives `checked`, and `item`, its
|
|
43
50
|
* value, which toggles membership when the field is an array; <select multiple> an array of the
|
|
44
|
-
* selected values;
|
|
51
|
+
* selected values; PLAN-11 F-2: a file input its File, `multiple` an array of them, none null),
|
|
52
|
+
* form.SET ({ values }: G-647, field name -> value, several
|
|
45
53
|
* fields changed at once as if typed: one validation, `touched` as for CHANGE, unknown names
|
|
46
54
|
* skipped; e.g. an AI fill), form.BLUR (name), form.SUBMIT, form.ADD ({ field,
|
|
47
55
|
* value }), form.REMOVE ({ field, id }), form.ERRORS (an error reply or a map: server errors),
|
|
48
|
-
* form.DONE (saved), form.RESET (values?)
|
|
56
|
+
* form.DONE (saved), form.RESET (values?), form.PROGRESS (PLAN-11 F-2: a progress step of the
|
|
57
|
+
* submit request, `progress: 'form.PROGRESS'`: kept as `progress` while submitting, cleared by
|
|
58
|
+
* DONE / ERRORS / RESET); internal: form.RESULT (an async schema's result),
|
|
49
59
|
* form.CHECKED_<name> (a check's reply; a failed check passes: the server checks on submit),
|
|
50
60
|
* form.VALIDATE (the first validation, when the host starts: G-375; `valid` is false until the
|
|
51
61
|
* schema answered; with resetOnShow also each time the form element appears, D239).
|
|
@@ -70,13 +80,20 @@
|
|
|
70
80
|
* run when the host is created.
|
|
71
81
|
*/
|
|
72
82
|
import xs from './xstreamCompat'
|
|
73
|
-
import {defineBehavior} from './behaviors'
|
|
83
|
+
import {defineBehavior, was} from './behaviors'
|
|
74
84
|
import {ABORT} from '../shared'
|
|
75
85
|
import {isStandardSchema} from './standardSchema'
|
|
76
|
-
import {checkForm, fieldNames, getField, hasField, setField, replyErrors, focusInvalid} from './formHelpers'
|
|
86
|
+
import {checkForm, fieldNames, getField, hasField, setField, replyErrors, focusInvalid, isFile} from './formHelpers'
|
|
87
|
+
|
|
88
|
+
// PLAN-11 F-3: a submit's data goes as multipart when a top-level value is a file (or files)
|
|
89
|
+
const hasFile = (d: any) => !!d && typeof d == 'object' && Object.values(d).some(x => isFile(x) || Array.isArray(x) && x.some(isFile))
|
|
77
90
|
|
|
78
91
|
const dev = (...a: any[]): any => (globalThis as any).__SYGNAL_DIAGNOSTICS__?.form?.(...a)
|
|
79
92
|
const keys = (o: any) => Object.keys(o).filter(k => o[k])
|
|
93
|
+
// PLAN-11 F-2: values compared as JSON, a File by identity-ish fields (JSON gives every File {})
|
|
94
|
+
const fileIds = new WeakMap<object, number>()
|
|
95
|
+
let fileSeq = 0
|
|
96
|
+
const json = (v: any) => JSON.stringify(v, (_k, x) => isFile(x) ? `\0file:${fileIds.get(x) ?? (fileIds.set(x, ++fileSeq), fileSeq)}` : x)
|
|
80
97
|
const drop = (o: any, p: string) => {
|
|
81
98
|
const r: any = {}
|
|
82
99
|
for (const k in o) if (k != p && !k.startsWith(p + '.')) r[k] = o[k]
|
|
@@ -85,7 +102,7 @@ const drop = (o: any, p: string) => {
|
|
|
85
102
|
|
|
86
103
|
export const form = (schema: any, o: any = {}): any => {
|
|
87
104
|
isStandardSchema(schema) || dev(231, schema)
|
|
88
|
-
const {values: vo = {}, submit, check: checks = {}, show = 'blur', http = 'HTTP', form: sel = 'form', resetOnShow: again} = o
|
|
105
|
+
const {values: vo = {}, submit, request: rq, check: checks = {}, show = 'blur', http = 'HTTP', form: sel = 'form', resetOnShow: again} = o
|
|
89
106
|
// D239: `values` can be a function of the host's state, called when the form starts (and each
|
|
90
107
|
// show, resetOnShow). Before that (the host's first render, before its first action) the
|
|
91
108
|
// fields come from calling it with a blank state: every read gives '' in the result
|
|
@@ -137,10 +154,11 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
137
154
|
: Object.keys(s.pending).length ? {s: {...s, queued: true}}
|
|
138
155
|
: {s: req ? {...s, queued: false, submitting: true} : done({...s, queued: false}), send: 1}
|
|
139
156
|
}
|
|
140
|
-
const base = (vals: any) => ({initial: vals, values: vals, touched: {}, server: {}, remote: {}, pending: {},
|
|
141
|
-
submitting: false, submitted: false, submitCount: 0, queued: false, errors: {}, validated: false})
|
|
142
|
-
|
|
143
|
-
const
|
|
157
|
+
const base = (vals: any, resets = 0) => ({initial: vals, values: vals, touched: {}, server: {}, remote: {}, pending: {},
|
|
158
|
+
submitting: false, submitted: false, submitCount: 0, queued: false, errors: {}, validated: false, progress: null, resets})
|
|
159
|
+
// a start over (RESET, resetOnShow): `resets` counts them, so a file input keyed by it is made anew
|
|
160
|
+
const fresh = (vals: any, s?: any) => edit(base(vals, s ? (s.resets || 0) + 1 : 0), vals)
|
|
161
|
+
const done = (s: any) => ({...s, submitting: false, submitted: true, initial: s.values, server: {}, touched: {}, progress: null})
|
|
144
162
|
const group = (cur: any, d: any) => {
|
|
145
163
|
if (!Array.isArray(cur) || !('item' in d)) return d.value
|
|
146
164
|
const r = cur.filter(x => x !== d.item)
|
|
@@ -149,9 +167,9 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
149
167
|
|
|
150
168
|
// fields changed as typed ones ([name, value] pairs; unknown names skipped, SYG230): their
|
|
151
169
|
// server errors, check results and running checks go, `touched` as show 'input' says; one validation
|
|
152
|
-
const put = (s: any, d: any[]) => {
|
|
170
|
+
const put = (s: any, d: any[], quiet?: any) => {
|
|
153
171
|
let vals = s.values, server = drop(s.server, ''), remote = s.remote, pending = s.pending, touched = s.touched
|
|
154
|
-
for (const [n, y] of d) if (known(s, n)) {
|
|
172
|
+
for (const [n, y] of d) if (known(s, n, quiet)) {
|
|
155
173
|
vals = setField(vals, n, y), server = drop(server, n), remote = drop(remote, n), pending = drop(pending, n)
|
|
156
174
|
if (show == 'input') touched = {...touched, [n]: true}
|
|
157
175
|
}
|
|
@@ -161,7 +179,9 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
161
179
|
const steps: Record<string, (s: any, d: any, k: string, h?: any) => any> = {
|
|
162
180
|
// G-376: a checkbox (`item`: its value) on an array field is one of a group: checked adds
|
|
163
181
|
// its value, unchecked removes it
|
|
164
|
-
|
|
182
|
+
// PLAN-11 F-2: a file input whose name isn't in values is left alone, quietly (an app that reads
|
|
183
|
+
// its files in its own intent, as before 6.7)
|
|
184
|
+
CHANGE: (s, d) => put(s, d?.name ? [[d.name, group(getField(s.values, d.name), d)]] : [], d?.file),
|
|
165
185
|
BLUR: (s, n, k) => {
|
|
166
186
|
if (!known(s, n, 1)) return null
|
|
167
187
|
const t = show == 'submit' || s.touched[n] ? s.touched : {...s.touched, [n]: true}, x = {...s, touched: t}
|
|
@@ -192,16 +212,20 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
192
212
|
SET: (s, d) => put(s, Object.entries(d?.values || {})),
|
|
193
213
|
ERRORS: (s, d) => {
|
|
194
214
|
const e = replyErrors(d, s.values)
|
|
195
|
-
return {s: {...s, server: e, submitting: false, queued: false}, focus: focusInvalid(e, sel)}
|
|
215
|
+
return {s: {...s, server: e, submitting: false, queued: false, progress: null}, focus: focusInvalid(e, sel)}
|
|
196
216
|
},
|
|
197
217
|
DONE: (s) => ({s: done(s)}),
|
|
198
|
-
|
|
218
|
+
// PLAN-11 F-2: a progress step of the submit's request (`progress: 'form.PROGRESS'`), while it is pending
|
|
219
|
+
PROGRESS: (s, d) => s.submitting && d && typeof d == 'object'
|
|
220
|
+
? {s: {...s, progress: {phase: d.phase, loaded: d.loaded, total: d.total ?? null, fraction: d.fraction ?? null}}} : null,
|
|
221
|
+
// PLAN-11: only a plain object is new values (a click event from `DOM.click('.reset')` is not)
|
|
222
|
+
RESET: (s, d) => fresh(d && typeof d == 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(d)) ? d : s.initial, s),
|
|
199
223
|
// G-375: the first validation, when the host starts (form() doesn't validate at module load).
|
|
200
224
|
// G-575: a slice kept in parent state from a host that unmounted mid-submit starts unstuck.
|
|
201
225
|
// D239: with resetOnShow (also each time the form element appears again), or a `values`
|
|
202
226
|
// function not called yet, the slice starts over from the start values (h: the host state)
|
|
203
|
-
VALIDATE: (s, _, _k, h) => again || fn && s.values === values ? fresh(fn ? vo(h) : vo)
|
|
204
|
-
: edit({...s, submitting: false, queued: false, pending: {}}, s.values),
|
|
227
|
+
VALIDATE: (s, _, _k, h) => again || fn && s.values === values ? fresh(fn ? vo(h) : vo, again && s)
|
|
228
|
+
: edit({...s, submitting: false, queued: false, pending: {}, progress: null}, s.values),
|
|
205
229
|
}
|
|
206
230
|
// a check's reply (ok and error: an error reply carries an Error and passes)
|
|
207
231
|
for (const f of checked) steps['CHECKED_' + f] = (s, d, k) => {
|
|
@@ -210,6 +234,14 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
210
234
|
return s.queued ? attempt(n, k) : {s: n}
|
|
211
235
|
}
|
|
212
236
|
|
|
237
|
+
// PLAN-11 F-3: the `request` option's request for a valid submit (data: the schema's output)
|
|
238
|
+
const send = (vals: any, h: any, k: string) => {
|
|
239
|
+
const data = v(vals).value, q = typeof rq == 'function' ? rq(data, h) : rq
|
|
240
|
+
if (!q || q === ABORT) return ABORT
|
|
241
|
+
const own = 'json' in q || 'multipart' in q || 'body' in q
|
|
242
|
+
return {...(!own && (hasFile(data) ? {multipart: data} : {json: data})), progress: `${k}.PROGRESS`, ...q, ok: `${k}.DONE`, error: `${k}.ERRORS`}
|
|
243
|
+
}
|
|
244
|
+
|
|
213
245
|
const model: any = {}
|
|
214
246
|
for (const a in steps) {
|
|
215
247
|
let last: any[] = []
|
|
@@ -219,19 +251,27 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
219
251
|
// VALIDATE: on the host's state (HOST), for a `values` function; the slice's own steps on it
|
|
220
252
|
...a == 'VALIDATE' ? {HOST: (h: any, d: any, _n: any, _p: any, _o: any, k: string) => { const x = out(h[k], d, k, h); return x ? {...h, [k]: x.s} : h }}
|
|
221
253
|
: {STATE: (s: any, d: any, _n: any, _p: any, _o: any, k: string) => out(s, d, k)?.s || ABORT},
|
|
222
|
-
|
|
254
|
+
// outputs share STATE's transition: computed from the slice before the action
|
|
255
|
+
EFFECT: (s: any, d: any, next: any, p: any, _o: any, k: string) => {
|
|
256
|
+
s = was(s, p, k)
|
|
223
257
|
const x = out(s, d, k), w = x?.wait
|
|
224
258
|
if (x?.send) {
|
|
225
259
|
const r = v(x.s.values).value
|
|
226
260
|
dev(233, s.values, r)
|
|
227
261
|
// G-577: a submit named after one of the form's own actions goes to that one
|
|
228
262
|
submit in steps && dev(234, submit, k, b)
|
|
229
|
-
|
|
263
|
+
// PLAN-11 F-3: with `request`, `submit` is optional (dispatched for state when given)
|
|
264
|
+
if (submit !== undefined || !rq) next(submit, r, 0)
|
|
230
265
|
}
|
|
231
266
|
w && Promise.resolve(v(w.values)).then(() => next('RESULT', w, 0))
|
|
232
267
|
},
|
|
233
|
-
ELEMENT: (s: any, d: any, _n: any,
|
|
234
|
-
|
|
268
|
+
ELEMENT: (s: any, d: any, _n: any, p: any, _o: any, k: string) => out(was(s, p, k), d, k)?.focus || ABORT,
|
|
269
|
+
// the checks' requests, and (PLAN-11 F-3) the `request` option's for a valid submit; the
|
|
270
|
+
// step from the slice before the action (7.0: outputs get the updated one), the host state as committed
|
|
271
|
+
...((checked.length || rq) && {[http]: (s: any, d: any, _n: any, p: any, _o: any, k: string) => {
|
|
272
|
+
const x = out(was(s, p, k), d, k)
|
|
273
|
+
return x?.req || (x?.send && rq ? send(x.s.values, p?.state, k) : ABORT)
|
|
274
|
+
}}),
|
|
235
275
|
}
|
|
236
276
|
}
|
|
237
277
|
|
|
@@ -248,9 +288,11 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
248
288
|
VALIDATE: again ? xs.merge(xs.of(0), f.elements().filter((e: any) => { const r = e.length && !on; on = e.length; return r })) : xs.of(0),
|
|
249
289
|
// G-376: a checkbox (native, or a hyphenated tag with a boolean `checked`: a
|
|
250
290
|
// form-associated custom checkbox / switch) gives `checked` and its value as `item`; a
|
|
251
|
-
// <select multiple> the selected values;
|
|
252
|
-
|
|
253
|
-
|
|
291
|
+
// <select multiple> the selected values; PLAN-11 F-2: a file input its File (`multiple`:
|
|
292
|
+
// an array of them; none: null); others give `value`
|
|
293
|
+
CHANGE: f.events('input').map(({target: t}: any) =>
|
|
294
|
+
t.type == 'file' ? {name: t.name, value: t.multiple || t.getAttribute?.('multiple') != null ? [...(t.files || [])] : t.files?.[0] ?? null, file: true}
|
|
295
|
+
: t.type == 'checkbox' || /-/.test(t.tagName) && typeof t.checked == 'boolean' ? {name: t.name, value: t.checked, item: t.value}
|
|
254
296
|
: {name: t.name, value: t.type == 'select-multiple' ? [...t.selectedOptions].map((o: any) => o.value) : t.value}),
|
|
255
297
|
BLUR: f.events('focusout').map((e: any) => e.target.name),
|
|
256
298
|
SUBMIT: f.events('submit', {preventDefault: true}),
|
|
@@ -269,7 +311,7 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
269
311
|
},
|
|
270
312
|
// G-382: an async schema re-validating keeps the last answer's validity (no flicker)
|
|
271
313
|
valid: (s: any) => s.validated && !keys(s.errors).length && !keys(s.remote).length,
|
|
272
|
-
dirty: (s: any) =>
|
|
314
|
+
dirty: (s: any) => json(s.values) != json(s.initial),
|
|
273
315
|
error: (s: any) => s.server[''] || (s.submitCount && s.errors['']) || '',
|
|
274
316
|
},
|
|
275
317
|
})({...o, values}), merge = b.merge
|
|
@@ -277,7 +319,7 @@ export const form = (schema: any, o: any = {}): any => {
|
|
|
277
319
|
o.tool?.$?.(b, v)
|
|
278
320
|
b.merge = (c: any, k: string) => {
|
|
279
321
|
const e = c.model?.[submit]
|
|
280
|
-
req = !!e && typeof e == 'object' && http in e
|
|
322
|
+
req = !!rq || !!e && typeof e == 'object' && http in e
|
|
281
323
|
merge(c, k)
|
|
282
324
|
}
|
|
283
325
|
return b
|
package/src/extra/formHelpers.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type FieldErrors = Record<string, string>
|
|
|
21
21
|
export type FormCheck = {errors: FieldErrors; value: any}
|
|
22
22
|
|
|
23
23
|
const isRow = (r: any) => r?.id != null
|
|
24
|
+
/** PLAN-11 F-2: a File / Blob is a field value (a leaf), not an object of fields */
|
|
25
|
+
export const isFile = (v: any) => typeof Blob != 'undefined' && v instanceof Blob
|
|
24
26
|
// the key of segment `s` in `v`: an array's row with that id, else the index unless the element
|
|
25
27
|
// there is a row (G-380: a row is named by its id only; -1, no element), an object's key
|
|
26
28
|
const key = (v: any, s: string): any => {
|
|
@@ -70,7 +72,7 @@ export const setField = (values: any, name: string, value: any): any => {
|
|
|
70
72
|
* array-level error such as "at least one address"); a row's `id` is not a field
|
|
71
73
|
*/
|
|
72
74
|
export const fieldNames = (v: any, at = '', row?: boolean): string[] => {
|
|
73
|
-
if (!v || typeof v != 'object') return at ? [at] : []
|
|
75
|
+
if (!v || typeof v != 'object' || isFile(v)) return at ? [at] : []
|
|
74
76
|
const a = Array.isArray(v), out = a ? [at] : []
|
|
75
77
|
for (const k in v) {
|
|
76
78
|
const x = (v as any)[k]
|