@tiaforge-ui/sinter 0.3.1 → 0.3.3
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/dist/compiler/compile.js +19 -9
- package/dist/compiler/expr.js +7 -1
- package/dist/compiler/vocabulary.js +1 -1
- package/dist/ir.d.ts +6 -2
- package/dist/ir.js +15 -4
- package/dist/runtime/app.d.ts +2 -0
- package/dist/runtime/app.js +13 -11
- package/dist/sse.js +73 -59
- package/html-custom-data.json +4 -1
- package/llms.txt +35 -9
- package/package.json +1 -1
package/dist/compiler/compile.js
CHANGED
|
@@ -749,7 +749,8 @@ class Compiler {
|
|
|
749
749
|
let event = null;
|
|
750
750
|
if (attrName.startsWith('data-sn-on-'))
|
|
751
751
|
event = attrName.slice('data-sn-on-'.length);
|
|
752
|
-
|
|
752
|
+
// an <sn-event> handler runs for a server event, not a DOM event
|
|
753
|
+
else if ((o.mode !== 'value' || o.magic?.includes('$event')) && el.tagName !== 'sn-event') {
|
|
753
754
|
event = attr(el, 'data-sn-trigger') ?? (el.tagName === 'form' ? 'submit' : FORM_CONTROLS.has(el.tagName) ? 'change' : 'click');
|
|
754
755
|
}
|
|
755
756
|
const e = {
|
|
@@ -1239,33 +1240,37 @@ class Compiler {
|
|
|
1239
1240
|
this.report(el, a.name, 'SN101', `\`${a.name}\` is not an attribute of \`<sn-event>\`.${didYouMean(a.name, EVENT_ATTRS)}`, 0);
|
|
1240
1241
|
}
|
|
1241
1242
|
const name = attr(el, 'name');
|
|
1242
|
-
const resource = attr(el, 'resource');
|
|
1243
|
-
const op = attr(el, 'op');
|
|
1243
|
+
const resource = attr(el, 'resource') ?? null;
|
|
1244
|
+
const op = (attr(el, 'op') ?? null);
|
|
1244
1245
|
if (!name) {
|
|
1245
1246
|
this.report(el, null, 'SN401', '`<sn-event>` needs `name`', 0);
|
|
1246
1247
|
return null;
|
|
1247
1248
|
}
|
|
1248
|
-
|
|
1249
|
+
// with `then`, an event may only run its handler
|
|
1250
|
+
const only = resource === null && op === null && attr(el, 'then') != null;
|
|
1251
|
+
if (!only && (!resource || !this.page.resources.has(resource))) {
|
|
1249
1252
|
this.report(el, 'resource', 'SN301', `resource \`${resource ?? ''}\` is not declared on this page.${didYouMean(resource ?? '', this.page.resources.keys())}`, 0);
|
|
1250
1253
|
return null;
|
|
1251
1254
|
}
|
|
1252
|
-
if (!op || !['upsert', 'patch', 'remove', 'refetch'].includes(op)) {
|
|
1255
|
+
if (!only && (!op || !['upsert', 'patch', 'remove', 'refetch'].includes(op))) {
|
|
1253
1256
|
this.report(el, 'op', 'SN401', '`op` is `upsert`, `patch`, `remove` or `refetch`', 0);
|
|
1254
1257
|
return null;
|
|
1255
1258
|
}
|
|
1256
1259
|
const scope = ctx.scope;
|
|
1257
1260
|
const typeAttr = attr(el, 'type')?.trim() || null;
|
|
1258
1261
|
const payloadType = typeAttr && typeAttr !== 'any' ? { kind: 'ts', text: typeAttr, loc: this.locOf(el, 'type') } : null;
|
|
1259
|
-
if (this.check && !typeAttr && ['item', 'set', 'when'].some((n) => attr(el, n) != null)) {
|
|
1262
|
+
if (this.check && !typeAttr && ['item', 'set', 'when', 'then'].some((n) => attr(el, n) != null)) {
|
|
1260
1263
|
this.report(el, null, 'SN506', `event \`${name}\` has no type: expressions reading its payload are not checked`, 0, 'add `type="…"` with the payload type from the types module, or `type="any"` to leave it unchecked');
|
|
1261
1264
|
}
|
|
1265
|
+
let writes = [];
|
|
1262
1266
|
const fnOf = (n, object = false) => {
|
|
1263
1267
|
if (attr(el, n) == null)
|
|
1264
1268
|
return null;
|
|
1265
|
-
const r = this.expr(el, n, scope, { mode: 'value', payload: true, payloadState: true });
|
|
1269
|
+
const r = this.expr(el, n, scope, { mode: n === 'then' ? 'handler' : 'value', payload: true, payloadState: true });
|
|
1270
|
+
writes = r.writes;
|
|
1266
1271
|
this.typeLast({
|
|
1267
1272
|
payload: payloadType,
|
|
1268
|
-
...(n === 'item' ? { returns: { kind: 'item', resource } } : n === 'set' ? { returns: { kind: 'item', resource, partial: true } } : {}),
|
|
1273
|
+
...(!resource ? {} : n === 'item' ? { returns: { kind: 'item', resource } } : n === 'set' ? { returns: { kind: 'item', resource, partial: true } } : {}),
|
|
1269
1274
|
});
|
|
1270
1275
|
if (object && r.ast && r.ast.type !== 'ObjectExpression')
|
|
1271
1276
|
this.report(el, n, 'SN401', `\`${n}\` must be an object literal`, 0);
|
|
@@ -1282,7 +1287,12 @@ class Compiler {
|
|
|
1282
1287
|
const fallback = attr(el, 'fallback');
|
|
1283
1288
|
if (fallback != null && fallback !== 'refetch')
|
|
1284
1289
|
this.report(el, 'fallback', 'SN401', '`fallback` takes `refetch`', 0);
|
|
1285
|
-
|
|
1290
|
+
const when = fnOf('when');
|
|
1291
|
+
const then = fnOf('then');
|
|
1292
|
+
return {
|
|
1293
|
+
name, resource, op, item, set, when, fallback: fallback === 'refetch' ? 'refetch' : null,
|
|
1294
|
+
...(then !== null ? { then, ...(writes.length ? { w: writes } : {}) } : {}),
|
|
1295
|
+
};
|
|
1286
1296
|
}
|
|
1287
1297
|
injectScript(doc) {
|
|
1288
1298
|
const html = doc.childNodes.find((n) => isElement(n) && n.tagName === 'html');
|
package/dist/compiler/expr.js
CHANGED
|
@@ -149,7 +149,8 @@ export function analyze(src, scope, page, opts) {
|
|
|
149
149
|
return { kind: 'resource' };
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
|
-
|
|
152
|
+
// an <sn-event> `then` calls store functions; its value expressions do not
|
|
153
|
+
if (STORE_FNS.includes(name) && (!opts.payload || mode === 'handler'))
|
|
153
154
|
return { kind: 'store' };
|
|
154
155
|
if (page.namespaces.has(name))
|
|
155
156
|
return { kind: 'ns' };
|
|
@@ -613,6 +614,11 @@ export function analyze(src, scope, page, opts) {
|
|
|
613
614
|
return;
|
|
614
615
|
}
|
|
615
616
|
const first = n.arguments[0];
|
|
617
|
+
// an <sn-event> handler has no element: no nearest dialog or form, nothing to dispatch from
|
|
618
|
+
if (opts.payload && (name === 'reset' || name === 'emit' || (name === 'close' && !first))) {
|
|
619
|
+
report('SN203', `\`${name}()\` needs an element; an \`<sn-event>\` handler has none${name === 'close' ? `: name the dialog, \`close('id')\`` : ''}`, n.start);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
616
622
|
if (name === 'showModal' || (name === 'close' && first)) {
|
|
617
623
|
if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
|
|
618
624
|
report('SN309', `\`${name}()\` takes the dialog's \`id\` as a string literal`, n.start);
|
|
@@ -39,6 +39,6 @@ export const DEBOUNCE_ON_DOC = { name: 'debounce-<event>', value: 'milliseconds'
|
|
|
39
39
|
export const ELEMENTS = [
|
|
40
40
|
{ name: 'sn-resource', attrs: ['name', 'src', 'call', 'each', 'key', 'items', 'total', 'order', 'when', 'paginate', 'page-size', 'size-param', 'cursor', 'direction-param', 'direction-values', 'page-param', 'offset-param', 'type', 'cache'], doc: 'a named, page-wide cache of server data; `cache` (or `cache="10m"`) shows the data from the last page load at once, then revalidates' },
|
|
41
41
|
{ name: 'sn-sync', attrs: ['src'], doc: 'one server event stream' },
|
|
42
|
-
{ name: 'sn-event', attrs: ['name', 'resource', 'op', 'item', 'set', 'when', 'fallback', 'type'], doc: 'maps one server event to a change message; child of <sn-sync
|
|
42
|
+
{ name: 'sn-event', attrs: ['name', 'resource', 'op', 'item', 'set', 'when', 'fallback', 'then', 'type'], doc: 'maps one server event to a change message; child of <sn-sync>. `then` is a handler run after the change (payload fields and the state around the sync in scope), e.g. close(\'detail\'); with `then`, `resource` and `op` may be left out' },
|
|
43
43
|
{ name: 'sn-include', attrs: ['src'], doc: 'inlines a partial at build time' },
|
|
44
44
|
];
|
package/dist/ir.d.ts
CHANGED
|
@@ -44,12 +44,16 @@ export interface ResourceDef {
|
|
|
44
44
|
}
|
|
45
45
|
export interface EventDef {
|
|
46
46
|
name: string;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
/** null for an event that only runs `then` */
|
|
48
|
+
resource: string | null;
|
|
49
|
+
op: 'upsert' | 'patch' | 'remove' | 'refetch' | null;
|
|
49
50
|
item: number | null;
|
|
50
51
|
set: number | null;
|
|
51
52
|
when: number | null;
|
|
52
53
|
fallback: 'refetch' | null;
|
|
54
|
+
/** `then`: handler run after the change is applied; `w`: the state paths it writes */
|
|
55
|
+
then?: number;
|
|
56
|
+
w?: string[];
|
|
53
57
|
}
|
|
54
58
|
export interface SyncDef {
|
|
55
59
|
src: UrlTemplate;
|
package/dist/ir.js
CHANGED
|
@@ -54,13 +54,24 @@ export function validateIR(ir, fnCount) {
|
|
|
54
54
|
url(s.src, `syncs[${i}].src`);
|
|
55
55
|
s.events.forEach((e, j) => {
|
|
56
56
|
const at = `syncs[${i}].events[${j}]`;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// without a resource and op, the event only runs `then`
|
|
58
|
+
if (e.resource === null && e.op === null) {
|
|
59
|
+
if (e.then === undefined)
|
|
60
|
+
errs.push(`${at}: needs a resource or then`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
if (!names.has(e.resource))
|
|
64
|
+
errs.push(`${at}: unknown resource ${e.resource}`);
|
|
65
|
+
if (!['upsert', 'patch', 'remove', 'refetch'].includes(e.op))
|
|
66
|
+
errs.push(`${at}: bad op`);
|
|
67
|
+
}
|
|
61
68
|
fn(e.item, `${at}.item`);
|
|
62
69
|
fn(e.set, `${at}.set`);
|
|
63
70
|
fn(e.when, `${at}.when`);
|
|
71
|
+
fn(e.then, `${at}.then`);
|
|
72
|
+
for (const d of e.w ?? [])
|
|
73
|
+
if (typeof d !== 'string' || !d)
|
|
74
|
+
errs.push(`${at}: bad path`);
|
|
64
75
|
});
|
|
65
76
|
});
|
|
66
77
|
const kinds = new Set(['text', 'attr', 'class', 'style', 'show', 'model', 'prop', 'for', 'if', 'else', 'on']);
|
package/dist/runtime/app.d.ts
CHANGED
|
@@ -144,6 +144,8 @@ export interface App {
|
|
|
144
144
|
record(change: () => void): () => void;
|
|
145
145
|
storeFns(el: Element | null): Record<string, (...a: any[]) => unknown>;
|
|
146
146
|
serverEvent(name: string, payload: unknown): void;
|
|
147
|
+
/** runs handler `f` in `scope` and invalidates the paths it writes */
|
|
148
|
+
run(f: number, scope: any, writes?: string[]): void;
|
|
147
149
|
addCleanup(fn: () => void): void;
|
|
148
150
|
}
|
|
149
151
|
/** The instance mount() created on `root`; throws if none. */
|
package/dist/runtime/app.js
CHANGED
|
@@ -316,6 +316,18 @@ export function mount(root, o) {
|
|
|
316
316
|
console.error(e);
|
|
317
317
|
}
|
|
318
318
|
},
|
|
319
|
+
run(f, s, writes) {
|
|
320
|
+
try {
|
|
321
|
+
fns[f](s);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
console.error(err);
|
|
325
|
+
}
|
|
326
|
+
for (const w of writes ?? [])
|
|
327
|
+
app.mark(w, s);
|
|
328
|
+
if (writes?.some((w) => w.startsWith('$url')))
|
|
329
|
+
writeUrl();
|
|
330
|
+
},
|
|
319
331
|
// after destroy(), nothing waits for the next one: run it now
|
|
320
332
|
addCleanup: (fn) => void (destroyed ? fn() : cleanups.push(fn)),
|
|
321
333
|
};
|
|
@@ -894,17 +906,7 @@ export function mount(root, o) {
|
|
|
894
906
|
}
|
|
895
907
|
/** Runs a handler expression and invalidates what it may have changed. */
|
|
896
908
|
function handle(rb, f, e, writes, extra) {
|
|
897
|
-
|
|
898
|
-
try {
|
|
899
|
-
fns[f](s);
|
|
900
|
-
}
|
|
901
|
-
catch (err) {
|
|
902
|
-
console.error(err);
|
|
903
|
-
}
|
|
904
|
-
for (const w of writes ?? [])
|
|
905
|
-
app.mark(w, s);
|
|
906
|
-
if (writes?.some((w) => w.startsWith('$url')))
|
|
907
|
-
writeUrl();
|
|
909
|
+
app.run(f, child(rb.s, { $el: rb.el, $event: e, $f: app.storeFns(rb.el), ...extra }), writes);
|
|
908
910
|
}
|
|
909
911
|
/** A debounced handler: runs once `ms` after the last event, with that event. */
|
|
910
912
|
function later(rb, e) {
|
package/dist/sse.js
CHANGED
|
@@ -34,25 +34,21 @@ export function eventSourceTransport() {
|
|
|
34
34
|
close() { es?.close(); es = null; },
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
/** One stream URL's connection, shared by every `<sn-sync>` on the page that resolves to it. */
|
|
37
38
|
class Connection {
|
|
38
39
|
app;
|
|
39
|
-
|
|
40
|
+
url;
|
|
40
41
|
t = null;
|
|
41
42
|
backoff = MIN_BACKOFF;
|
|
42
43
|
timer = null;
|
|
43
44
|
closed = false;
|
|
44
45
|
hidden = false;
|
|
45
46
|
everOpen = false;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
base;
|
|
50
|
-
constructor(app, def) {
|
|
47
|
+
/** each mapped event with the scope its expressions are evaluated in: its `<sn-sync>`'s state, or the page */
|
|
48
|
+
subs = [];
|
|
49
|
+
constructor(app, url) {
|
|
51
50
|
this.app = app;
|
|
52
|
-
this.
|
|
53
|
-
this.base = app.scopeOf(def.scope ?? -1);
|
|
54
|
-
this.url = app.url(def.src, this.base);
|
|
55
|
-
this.fed = [...new Set(def.events.map((e) => e.resource))];
|
|
51
|
+
this.url = url;
|
|
56
52
|
}
|
|
57
53
|
start() {
|
|
58
54
|
const env = this.app.env;
|
|
@@ -68,9 +64,6 @@ class Connection {
|
|
|
68
64
|
this.app.addCleanup(() => { this.closed = true; this.stop(); });
|
|
69
65
|
this.open();
|
|
70
66
|
}
|
|
71
|
-
names() {
|
|
72
|
-
return [...new Set(this.def.events.map((e) => e.name))];
|
|
73
|
-
}
|
|
74
67
|
open() {
|
|
75
68
|
if (this.closed && !this.hidden)
|
|
76
69
|
return;
|
|
@@ -78,6 +71,7 @@ class Connection {
|
|
|
78
71
|
const t = custom ?? eventSourceTransport();
|
|
79
72
|
this.t = t;
|
|
80
73
|
const reconnect = this.everOpen;
|
|
74
|
+
const subs = this.subs.map(([e]) => e);
|
|
81
75
|
t.connect(this.url, (name, data) => this.event(name, data), (state) => {
|
|
82
76
|
if (this.t !== t)
|
|
83
77
|
return;
|
|
@@ -88,15 +82,16 @@ class Connection {
|
|
|
88
82
|
this.app.streamOpened?.(this.url);
|
|
89
83
|
// events sent while disconnected are lost: refetch everything this stream feeds
|
|
90
84
|
if (reconnect)
|
|
91
|
-
for (const name of
|
|
92
|
-
|
|
85
|
+
for (const name of new Set(subs.map((e) => e.resource)))
|
|
86
|
+
if (name)
|
|
87
|
+
void this.app.resource(name).refetch();
|
|
93
88
|
}
|
|
94
89
|
else {
|
|
95
90
|
t.close();
|
|
96
91
|
this.t = null;
|
|
97
92
|
void this.probe();
|
|
98
93
|
}
|
|
99
|
-
},
|
|
94
|
+
}, [...new Set(subs.map((e) => e.name))]);
|
|
100
95
|
}
|
|
101
96
|
stop() {
|
|
102
97
|
this.t?.close();
|
|
@@ -135,59 +130,66 @@ class Connection {
|
|
|
135
130
|
this.timer = this.app.env.setTimeout(() => { this.timer = null; fn(); }, delay, 'backoff');
|
|
136
131
|
}
|
|
137
132
|
event(name, payload) {
|
|
138
|
-
for (const def of this.
|
|
133
|
+
for (const [def, base] of this.subs)
|
|
139
134
|
if (def.name === name)
|
|
140
|
-
this.apply(def, payload);
|
|
135
|
+
this.apply(def, base, payload);
|
|
141
136
|
this.app.serverEvent(name, payload);
|
|
142
137
|
}
|
|
143
|
-
apply(def, payload) {
|
|
138
|
+
apply(def, base, payload) {
|
|
144
139
|
const app = this.app;
|
|
145
|
-
const scope = Object.create(
|
|
140
|
+
const scope = Object.create(base);
|
|
146
141
|
scope.$p = payload && typeof payload === 'object' ? payload : {};
|
|
147
|
-
const r = app.resource(def.resource);
|
|
148
142
|
try {
|
|
149
143
|
if (def.when != null && !app.fns[def.when](scope))
|
|
150
144
|
return;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
// `then` runs once the change is applied (a refetch: requested), unless the payload could not be applied
|
|
146
|
+
if (def.resource && !this.change(def, def.resource, scope, payload))
|
|
147
|
+
return;
|
|
148
|
+
if (def.then != null)
|
|
149
|
+
app.run(def.then, scope, def.w);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
app.warn(`event \`${def.name}\` could not be applied: ${e.message}`, payload);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Applies the event's change message; false when the payload lacks what it needs. */
|
|
156
|
+
change(def, name, scope, payload) {
|
|
157
|
+
const app = this.app;
|
|
158
|
+
const r = app.resource(name);
|
|
159
|
+
const paginated = !!app.ir.resources.find((x) => x.name === name)?.paginate;
|
|
160
|
+
switch (def.op) {
|
|
161
|
+
case 'refetch':
|
|
162
|
+
void r.refetch(scope.$p);
|
|
163
|
+
break;
|
|
164
|
+
case 'upsert': {
|
|
165
|
+
const item = def.item != null ? app.fns[def.item](scope) : payload;
|
|
166
|
+
if (item == null || typeof item !== 'object') {
|
|
167
|
+
if (def.fallback !== 'refetch')
|
|
168
|
+
return false;
|
|
154
169
|
void r.refetch(scope.$p);
|
|
155
|
-
return;
|
|
156
|
-
case 'upsert': {
|
|
157
|
-
const item = def.item != null ? app.fns[def.item](scope) : payload;
|
|
158
|
-
if (item == null || typeof item !== 'object') {
|
|
159
|
-
if (def.fallback === 'refetch')
|
|
160
|
-
void r.refetch(scope.$p);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
if (paginated && !r.has(item)) {
|
|
164
|
-
void r.refetch();
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
r.upsert(clean(item));
|
|
168
|
-
return;
|
|
169
170
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
else if (paginated && !r.has(item))
|
|
172
|
+
void r.refetch();
|
|
173
|
+
else
|
|
174
|
+
r.upsert(clean(item));
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case 'patch': {
|
|
178
|
+
if (r.isCollection && !this.hasKey(r.key, scope.$p, def))
|
|
179
|
+
return false;
|
|
180
|
+
const key = r.keyOf(scope.$p);
|
|
181
|
+
if (!r.isCollection || r.has(key))
|
|
176
182
|
r.patch(key, clean(app.fns[def.set](scope)));
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case 'remove': {
|
|
186
|
+
if (r.isCollection && !this.hasKey(r.key, scope.$p, def))
|
|
187
|
+
return false;
|
|
188
|
+
r.remove(r.keyOf(scope.$p));
|
|
189
|
+
break;
|
|
186
190
|
}
|
|
187
191
|
}
|
|
188
|
-
|
|
189
|
-
app.warn(`event \`${def.name}\` could not be applied: ${e.message}`, payload);
|
|
190
|
-
}
|
|
192
|
+
return true;
|
|
191
193
|
}
|
|
192
194
|
hasKey(fields, payload, def) {
|
|
193
195
|
const missing = fields.filter((f) => payload[f] === undefined);
|
|
@@ -203,9 +205,21 @@ const sse = {
|
|
|
203
205
|
let alive = true;
|
|
204
206
|
app.addCleanup(() => { alive = false; });
|
|
205
207
|
app.env.queueMicrotask(() => {
|
|
206
|
-
if (alive)
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
if (!alive)
|
|
209
|
+
return;
|
|
210
|
+
// one connection per resolved stream URL, however many <sn-sync> elements use it
|
|
211
|
+
const conns = new Map();
|
|
212
|
+
for (const def of app.ir.syncs) {
|
|
213
|
+
const base = app.scopeOf(def.scope ?? -1);
|
|
214
|
+
const url = app.url(def.src, base);
|
|
215
|
+
let c = conns.get(url);
|
|
216
|
+
if (!c)
|
|
217
|
+
conns.set(url, c = new Connection(app, url));
|
|
218
|
+
for (const e of def.events)
|
|
219
|
+
c.subs.push([e, base]);
|
|
220
|
+
}
|
|
221
|
+
for (const c of conns.values())
|
|
222
|
+
c.start();
|
|
209
223
|
});
|
|
210
224
|
},
|
|
211
225
|
};
|
package/html-custom-data.json
CHANGED
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"name": "sn-event",
|
|
78
|
-
"description": "maps one server event to a change message; child of <sn-sync
|
|
78
|
+
"description": "maps one server event to a change message; child of <sn-sync>. `then` is a handler run after the change (payload fields and the state around the sync in scope), e.g. close('detail'); with `then`, `resource` and `op` may be left out",
|
|
79
79
|
"attributes": [
|
|
80
80
|
{
|
|
81
81
|
"name": "name"
|
|
@@ -98,6 +98,9 @@
|
|
|
98
98
|
{
|
|
99
99
|
"name": "fallback"
|
|
100
100
|
},
|
|
101
|
+
{
|
|
102
|
+
"name": "then"
|
|
103
|
+
},
|
|
101
104
|
{
|
|
102
105
|
"name": "type"
|
|
103
106
|
}
|
package/llms.txt
CHANGED
|
@@ -45,13 +45,14 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
|
|
|
45
45
|
|
|
46
46
|
- `<sn-resource>` `name`, `src`, `call`, `each`, `key`, `items`, `total`, `order`, `when`, `paginate`, `page-size`, `size-param`, `cursor`, `direction-param`, `direction-values`, `page-param`, `offset-param`, `type`, `cache`: a named, page-wide cache of server data; `cache` (or `cache="10m"`) shows the data from the last page load at once, then revalidates
|
|
47
47
|
- `<sn-sync>` `src`: one server event stream
|
|
48
|
-
- `<sn-event>` `name`, `resource`, `op`, `item`, `set`, `when`, `fallback`, `type`: maps one server event to a change message; child of <sn-sync
|
|
48
|
+
- `<sn-event>` `name`, `resource`, `op`, `item`, `set`, `when`, `fallback`, `then`, `type`: maps one server event to a change message; child of <sn-sync>. `then` is a handler run after the change (payload fields and the state around the sync in scope), e.g. close('detail'); with `then`, `resource` and `op` may be left out
|
|
49
49
|
- `<sn-include>` `src`: inlines a partial at build time
|
|
50
50
|
|
|
51
51
|
## Expressions and scope
|
|
52
52
|
|
|
53
53
|
- Attribute values are JavaScript expressions, checked at build time. Value expressions (text, attr, class, show, for, if, key, body, state, URL interpolations) cannot assign, use `++`, `delete`, `await` or `import()`.
|
|
54
54
|
- Handlers (`data-sn-on-*`, `then`, `catch`) are statements separated by `;`: assignments to state, `$url.<param>` and `$locale`, and calls. No declarations or loops.
|
|
55
|
+
- `then` on `<sn-event>` is a handler run after the event's change is applied: payload fields are bare names, the state around the `<sn-sync>` is in scope and assignable, store functions work (`close('id')` names its dialog: there is no element). With `then`, `resource` and `op` may be left out.
|
|
55
56
|
- `data-sn-optimistic` may only call `patch`, `upsert`, `remove`.
|
|
56
57
|
- A name resolves innermost first: loop variables, then the nearest `data-sn-state`, then resources, then magic names, helpers and store functions, then JavaScript globals. Browser APIs (`window`, `document`, `fetch`, timers, …) are refused: use an island.
|
|
57
58
|
- Resources are read-only: copy an item with `structuredClone(c)` to edit it, write with `patch()`.
|
|
@@ -180,14 +181,21 @@ test('saves the draft and upserts the result', async () => {
|
|
|
180
181
|
```html
|
|
181
182
|
<sn-resource name="people" src="/api/people" key="id" order="name"></sn-resource>
|
|
182
183
|
|
|
183
|
-
<sn-
|
|
184
|
-
<sn-
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
<main data-sn-state="{ sel: null }">
|
|
185
|
+
<sn-sync src="/api/events">
|
|
186
|
+
<sn-event name="person_updated" resource="people" op="upsert" item="person" fallback="refetch"></sn-event>
|
|
187
|
+
<!-- after the removal: close the dialog if it shows that person -->
|
|
188
|
+
<sn-event name="person_removed" resource="people" op="remove"
|
|
189
|
+
then="sel?.id === id && (sel = null, close('person'))"></sn-event>
|
|
190
|
+
</sn-sync>
|
|
187
191
|
|
|
188
|
-
<ul>
|
|
189
|
-
|
|
190
|
-
|
|
192
|
+
<ul>
|
|
193
|
+
<template data-sn-for="p in people">
|
|
194
|
+
<li><button data-sn-on-click="sel = p; showModal('person')" data-sn-text="p.name"></button></li>
|
|
195
|
+
</template>
|
|
196
|
+
</ul>
|
|
197
|
+
<dialog id="person"><h2 data-sn-text="sel?.name"></h2></dialog>
|
|
198
|
+
</main>
|
|
191
199
|
```
|
|
192
200
|
|
|
193
201
|
```ts
|
|
@@ -207,6 +215,24 @@ test('applies server events to the list', async () => {
|
|
|
207
215
|
await flush();
|
|
208
216
|
expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada']);
|
|
209
217
|
});
|
|
218
|
+
|
|
219
|
+
test('a removed person closes the dialog that shows them', async () => {
|
|
220
|
+
const requests = mockRequests({ 'GET /api/people': [{ id: 1, name: 'Bob' }, { id: 2, name: 'Ada' }] });
|
|
221
|
+
const stream = mockStream();
|
|
222
|
+
const { root, flush, settle } = await render(page, { requests, streams: { '/api/events': stream } });
|
|
223
|
+
await settle();
|
|
224
|
+
root.querySelector('button')!.click(); // Ada
|
|
225
|
+
await flush();
|
|
226
|
+
const dialog = root.querySelector('dialog')!;
|
|
227
|
+
expect(dialog.open).toBe(true);
|
|
228
|
+
|
|
229
|
+
stream.emit('person_removed', { id: 1 });
|
|
230
|
+
await flush();
|
|
231
|
+
expect(dialog.open).toBe(true);
|
|
232
|
+
stream.emit('person_removed', { id: 2 });
|
|
233
|
+
await flush();
|
|
234
|
+
expect(dialog.open).toBe(false);
|
|
235
|
+
});
|
|
210
236
|
```
|
|
211
237
|
|
|
212
238
|
### Optimistic toggle
|
|
@@ -616,7 +642,7 @@ test('sinter check: arguments are checked against the actions', () => {
|
|
|
616
642
|
- `sinter({ openapi: 'openapi.yml' })`: resources without `type` are matched to a GET operation by `src` (server prefix stripped, `{…}` matches a path parameter) and typed from its JSON response at `items`. Every build then checks `items`/`total` against the schema (SN308) and query parameter names (SN505).
|
|
617
643
|
- State takes a JSDoc cast where the literal is not enough: `data-sn-state="{ editing: /** @type {Contact|null} */ (null) }"`.
|
|
618
644
|
- Actions matched in the spec: `data-sn-body` is checked against the request schema, `$result` in `then` has the response type.
|
|
619
|
-
- `<sn-event … type="ContactEvent">` types the payload; `item` must fit the resource's item, `set` part of it; `cursor` reads the item. No `type` on an event with payload expressions is warning SN506.
|
|
645
|
+
- `<sn-event … type="ContactEvent">` types the payload; `item` must fit the resource's item, `set` part of it, and `then` is checked against it; `cursor` reads the item. No `type` on an event with payload expressions is warning SN506.
|
|
620
646
|
- Errors are SN208 at the HTML position, with TypeScript's message. Null checks are off. In tests: `checkPage(html, { files, openapi })` from `@tiaforge-ui/sinter/check`.
|
|
621
647
|
|
|
622
648
|
## Deploying streams
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiaforge-ui/sinter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Bind JSON APIs to plain HTML: keyed resources, SSE sync, template bindings and optimistic mutations, compiled at build time.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tia Heinrich",
|