@loadbare/app 0.5.6 → 0.6.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/README.md +3 -4
- package/dist/build/assemble.d.ts +1 -1
- package/dist/build/assemble.d.ts.map +1 -1
- package/dist/build/assemble.js +80 -7
- package/dist/build/cli.d.ts +2 -2
- package/dist/build/cli.js +2 -2
- package/dist/build/expand.d.ts.map +1 -1
- package/dist/build/expand.js +19 -32
- package/dist/build/locations.d.ts +3 -3
- package/dist/build/locations.d.ts.map +1 -1
- package/dist/build/locations.js +15 -3
- package/dist/build/origins.d.ts +0 -13
- package/dist/build/origins.d.ts.map +1 -1
- package/dist/build/origins.js +32 -8
- package/dist/build/pages.d.ts +3 -3
- package/dist/build/pages.d.ts.map +1 -1
- package/dist/build/pages.js +9 -7
- package/dist/core/lb-constants.d.ts +15 -13
- package/dist/core/lb-constants.d.ts.map +1 -1
- package/dist/core/lb-constants.js +103 -53
- package/dist/core/lb-types.d.ts +103 -62
- package/dist/core/lb-types.d.ts.map +1 -1
- package/dist/core/lb-types.js +11 -3
- package/dist/hub/lb-apply.d.ts +18 -4
- package/dist/hub/lb-apply.d.ts.map +1 -1
- package/dist/hub/lb-apply.js +184 -34
- package/dist/hub/lb-hub.browser.d.ts +1 -1
- package/dist/hub/lb-hub.browser.d.ts.map +1 -1
- package/dist/hub/lb-hub.browser.js +131 -84
- package/dist/server/lb-express.d.ts +7 -4
- package/dist/server/lb-express.d.ts.map +1 -1
- package/dist/server/lb-express.js +45 -33
- package/dist/server/lb-server.d.ts +67 -45
- package/dist/server/lb-server.d.ts.map +1 -1
- package/dist/server/lb-server.js +55 -18
- package/dist/tests/assemble.test.js +154 -2
- package/dist/tests/expand.test.js +6 -3
- package/dist/tests/helpers/hub.d.ts +73 -0
- package/dist/tests/helpers/hub.d.ts.map +1 -0
- package/dist/tests/helpers/hub.js +151 -0
- package/dist/tests/lb-apply.test.js +86 -62
- package/dist/tests/lb-express.test.d.ts +1 -1
- package/dist/tests/lb-express.test.js +43 -38
- package/dist/tests/lb-hub.test.d.ts +14 -0
- package/dist/tests/lb-hub.test.d.ts.map +1 -0
- package/dist/tests/lb-hub.test.js +319 -0
- package/dist/tests/{lb-rows.test.d.ts → lb-list.test.d.ts} +1 -1
- package/dist/tests/lb-list.test.d.ts.map +1 -0
- package/dist/tests/{lb-rows.test.js → lb-list.test.js} +109 -106
- package/dist/tests/lb-server.test.js +151 -100
- package/dist/tests/origins.test.js +19 -1
- package/dist/tests/pages.test.d.ts +1 -1
- package/dist/tests/pages.test.js +64 -14
- package/docs/TECHREF-1.0.md +1000 -0
- package/docs/reference/builder.md +4 -4
- package/docs/reference/chrome.md +10 -9
- package/docs/reference/custom-elements.md +51 -36
- package/docs/reference/data-binding.md +141 -88
- package/docs/reference/overview.md +1 -1
- package/docs/reference/page-files.md +64 -49
- package/docs/reference/server.md +7 -6
- package/docs/reference/widgets.md +22 -30
- package/docs/roadmap.md +68 -22
- package/docs/testing.md +47 -17
- package/docs/theory.md +2 -2
- package/docs/tutorials/010-pages-and-navigation.md +8 -8
- package/docs/tutorials/040-displaying-data.md +9 -9
- package/docs/tutorials/050-actions.md +5 -5
- package/docs/tutorials/060-custom-element-code.md +1 -1
- package/docs/tutorials/065-conditional-rendering.md +4 -4
- package/docs/tutorials/070-displaying-a-list.md +24 -47
- package/docs/tutorials/072-inserting-into-a-list.md +17 -17
- package/docs/tutorials/074-deleting-from-a-list.md +15 -17
- package/docs/tutorials/076-updating-a-list-item.md +20 -22
- package/docs/tutorials/080-widget-requests.md +27 -23
- package/docs/tutorials/090-using-widget-libraries.md +1 -1
- package/package.json +1 -2
- package/dist/hub/lb-rows.d.ts +0 -18
- package/dist/hub/lb-rows.d.ts.map +0 -1
- package/dist/hub/lb-rows.js +0 -106
- package/dist/tests/lb-rows.test.d.ts.map +0 -1
package/dist/hub/lb-apply.js
CHANGED
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Landing data on a live host — see docs/reference/data-binding.md.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* One containment ladder, and one function per rung. `applyData` lands a
|
|
6
|
+
* whole response, `applyList` lands one list result, `applyRow` fills one
|
|
7
|
+
* scope from one row. Filling a live row and filling a single-row scope are
|
|
8
|
+
* the same operation, and the only difference is how much of the page the
|
|
9
|
+
* root covers.
|
|
10
|
+
*
|
|
11
|
+
* The row machinery used to live in its own file because every list widget
|
|
12
|
+
* imported and called it. The hub reconciles a list scope itself now, so a
|
|
13
|
+
* widget supplies placement and scaffolding through two optional hooks and
|
|
14
|
+
* never sees a whole result. Cloning the template, matching a row to the
|
|
15
|
+
* element already showing it, and filling that element are the same in every
|
|
16
|
+
* list, so no widget can get them wrong.
|
|
11
17
|
*/
|
|
12
|
-
import { ATTR_CELL,
|
|
13
|
-
import {
|
|
18
|
+
import { ATTR_CELL, ATTR_KEY, ATTR_KEY_VALUE, ATTR_LIST, ATTR_ROW, ATTR_ROW_COUNT, ATTR_VALUE, } from "../core/lb-constants";
|
|
19
|
+
import { isPatch, } from "../core/lb-types";
|
|
14
20
|
/**
|
|
15
21
|
* A cell lands one of two ways. A custom element owns whatever control it
|
|
16
22
|
* wraps, so it receives the value as an attribute and renders it itself.
|
|
@@ -23,55 +29,199 @@ import { isProjection, } from "../core/lb-types";
|
|
|
23
29
|
* knowledge of any particular element.
|
|
24
30
|
*/
|
|
25
31
|
function land(el, value) {
|
|
32
|
+
// Untouched, whatever the server sent: the browser decides what a
|
|
33
|
+
// non-string looks like. See Row in core/lb-types.ts.
|
|
26
34
|
if (el.localName.includes("-"))
|
|
27
35
|
el.setAttribute(ATTR_VALUE, value);
|
|
28
36
|
else
|
|
29
37
|
el.textContent = value;
|
|
30
38
|
}
|
|
31
39
|
/**
|
|
32
|
-
*
|
|
40
|
+
* Whether an element under `root` belongs to root's scope, rather than to a
|
|
41
|
+
* scope nested inside it.
|
|
42
|
+
*
|
|
43
|
+
* Both scope attributes scope their DOM children, and a nested one of either
|
|
44
|
+
* kind begins a new scope (docs/reference/data-binding.md). So a descendant
|
|
45
|
+
* is in root's scope unless something between it and root — the element
|
|
46
|
+
* itself included — carries one. Root's own attribute does not count: root is
|
|
47
|
+
* the scope being filled, whatever it carries.
|
|
48
|
+
*/
|
|
49
|
+
function inScope(root, el) {
|
|
50
|
+
for (let node = el; node && node !== root;) {
|
|
51
|
+
if (node.hasAttribute(ATTR_LIST) || node.hasAttribute(ATTR_ROW)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
node = node.parentElement;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** Root if it matches, then every descendant in root's scope that does. */
|
|
59
|
+
function within(root, selector) {
|
|
60
|
+
const found = root.matches(selector) ? [root] : [];
|
|
61
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
62
|
+
if (inScope(root, el))
|
|
63
|
+
found.push(el);
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Fill one scope from one row.
|
|
33
69
|
*
|
|
34
70
|
* The root counts as a cell if it carries one. A `<tr>` holds its cells in
|
|
35
71
|
* `<td>` children, but `<option>`'s content model is text, so an option row
|
|
36
72
|
* has to be the cell it displays. Requiring a wrapper there would require an
|
|
37
73
|
* element HTML does not allow.
|
|
38
74
|
*/
|
|
39
|
-
export function
|
|
40
|
-
for (const [
|
|
41
|
-
const
|
|
42
|
-
if (root.matches(selector))
|
|
43
|
-
land(root, value);
|
|
44
|
-
for (const el of root.querySelectorAll(selector))
|
|
75
|
+
export function applyRow(root, row) {
|
|
76
|
+
for (const [column, value] of Object.entries(row)) {
|
|
77
|
+
for (const el of within(root, `[${ATTR_CELL}="${column}"]`)) {
|
|
45
78
|
land(el, value);
|
|
79
|
+
}
|
|
46
80
|
}
|
|
47
81
|
}
|
|
48
82
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
83
|
+
* The row template: the one the developer wrote inside this list scope, or
|
|
84
|
+
* null when this scope shows nothing.
|
|
85
|
+
*
|
|
86
|
+
* A scope with no template is bound to the list without displaying it — an
|
|
87
|
+
* insert form names the list it adds a row to, and has no rows of its own.
|
|
88
|
+
* That is not a mistake, so it is silent. A template that does not name its
|
|
89
|
+
* key column is a mistake, and says so.
|
|
52
90
|
*/
|
|
53
|
-
function
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
91
|
+
function templateIn(scope) {
|
|
92
|
+
const template = scope.querySelector("template");
|
|
93
|
+
if (!template)
|
|
94
|
+
return null;
|
|
95
|
+
if (!template.getAttribute(ATTR_KEY)) {
|
|
96
|
+
console.error(`lb-hub: the <template> in <${scope.localName}> has no ${ATTR_KEY} ` +
|
|
97
|
+
`naming the column that identifies a row`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return template;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The rows already showing, by the key value each carries. Only a live row
|
|
104
|
+
* carries one: the template names the key column under a different name.
|
|
105
|
+
*/
|
|
106
|
+
function showing(scope) {
|
|
107
|
+
const rows = new Map();
|
|
108
|
+
for (const el of scope.querySelectorAll(`[${ATTR_KEY_VALUE}]`)) {
|
|
109
|
+
rows.set(el.getAttribute(ATTR_KEY_VALUE), el);
|
|
110
|
+
}
|
|
111
|
+
return rows;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Land a list result in a list scope.
|
|
115
|
+
*
|
|
116
|
+
* An array is the whole set, so it decides membership and order: every row is
|
|
117
|
+
* placed in the order given, and a row whose key did not arrive is gone. A
|
|
118
|
+
* patch disturbs only what it names — a row it did not mention keeps its
|
|
119
|
+
* contents and its position.
|
|
120
|
+
*
|
|
121
|
+
* A widget that carries `lbPlaceRow` decides where a row goes, because only
|
|
122
|
+
* it knows whether it sorts or groups. Without it a row lands immediately
|
|
123
|
+
* before the template, so rows accumulate in the order they arrive and the
|
|
124
|
+
* template stays put as the insertion marker.
|
|
125
|
+
*/
|
|
126
|
+
export function applyList(scope, result) {
|
|
127
|
+
const template = templateIn(scope);
|
|
128
|
+
if (!template)
|
|
58
129
|
return;
|
|
130
|
+
const keyColumn = template.getAttribute(ATTR_KEY);
|
|
131
|
+
const shown = showing(scope);
|
|
132
|
+
const host = scope;
|
|
133
|
+
const whole = !isPatch(result);
|
|
134
|
+
const place = (el, row) => {
|
|
135
|
+
if (host.lbPlaceRow)
|
|
136
|
+
host.lbPlaceRow(el, row, template);
|
|
137
|
+
else
|
|
138
|
+
template.parentElement.insertBefore(el, template);
|
|
139
|
+
};
|
|
140
|
+
const upsert = (row) => {
|
|
141
|
+
if (row[keyColumn] === undefined) {
|
|
142
|
+
console.error(`lb-hub: a row for <${scope.localName}> has no '${keyColumn}' column`);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// The key is stored on the row as an attribute and read back from
|
|
146
|
+
// there, so it is compared as the string the attribute holds.
|
|
147
|
+
const key = String(row[keyColumn]);
|
|
148
|
+
let el = shown.get(key);
|
|
149
|
+
const fresh = el === undefined;
|
|
150
|
+
if (!el) {
|
|
151
|
+
el = template.content.firstElementChild.cloneNode(true);
|
|
152
|
+
el.setAttribute(ATTR_KEY_VALUE, key);
|
|
153
|
+
shown.set(key, el);
|
|
154
|
+
}
|
|
155
|
+
// Fill before insertion. The attributes are already there when the row
|
|
156
|
+
// upgrades, which is the same thing that makes hydration and refresh one
|
|
157
|
+
// operation everywhere else.
|
|
158
|
+
applyRow(el, row);
|
|
159
|
+
if (fresh || whole)
|
|
160
|
+
place(el, row);
|
|
161
|
+
return key;
|
|
162
|
+
};
|
|
163
|
+
if (Array.isArray(result)) {
|
|
164
|
+
const arrived = new Set();
|
|
165
|
+
for (const row of result) {
|
|
166
|
+
const key = upsert(row);
|
|
167
|
+
if (key !== null)
|
|
168
|
+
arrived.add(key);
|
|
169
|
+
}
|
|
170
|
+
for (const [key, el] of shown)
|
|
171
|
+
if (!arrived.has(key))
|
|
172
|
+
el.remove();
|
|
59
173
|
}
|
|
60
|
-
|
|
174
|
+
else {
|
|
175
|
+
for (const row of result.rows ?? [])
|
|
176
|
+
upsert(row);
|
|
177
|
+
for (const key of result.drop ?? [])
|
|
178
|
+
shown.get(String(key))?.remove();
|
|
179
|
+
}
|
|
180
|
+
// How many rows are showing, counted from the DOM rather than from either
|
|
181
|
+
// branch above, so a whole set and a patch report the same fact the same
|
|
182
|
+
// way.
|
|
183
|
+
//
|
|
184
|
+
// It is stamped here because only this function knows the count: it is the
|
|
185
|
+
// one conditional a page cannot be sent, since the server answers with rows
|
|
186
|
+
// and says nothing about how many survived reconciliation. A page says what
|
|
187
|
+
// an empty list looks like in a stylesheet, and no list widget carries code
|
|
188
|
+
// for it. See docs/reference/data-binding.md.
|
|
189
|
+
scope.setAttribute(ATTR_ROW_COUNT, String(showing(scope).size));
|
|
190
|
+
// Derived scaffolding — a section heading, an <optgroup> — goes when its
|
|
191
|
+
// last row does, and only the widget knows it exists.
|
|
192
|
+
host.lbRowsLanded?.();
|
|
61
193
|
}
|
|
62
|
-
/** Land a whole response. Every
|
|
194
|
+
/** Land a whole response. Every result arrives through here. */
|
|
63
195
|
export function applyData(root, data) {
|
|
64
|
-
for (const [
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
196
|
+
for (const [name, result] of Object.entries(data)) {
|
|
197
|
+
const lists = root.querySelectorAll(`[${ATTR_LIST}="${name}"]`);
|
|
198
|
+
const rows = root.querySelectorAll(`[${ATTR_ROW}="${name}"]`);
|
|
199
|
+
if (lists.length === 0 && rows.length === 0) {
|
|
200
|
+
console.warn(`lb-hub: no scope for '${name}', skipping`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Cardinality is a property of the name, so one name is a list or a row
|
|
204
|
+
// and never both. Two spellings of it on one page is a mistake in the
|
|
205
|
+
// markup rather than a case to reconcile.
|
|
206
|
+
if (lists.length > 0 && rows.length > 0) {
|
|
207
|
+
console.error(`lb-hub: '${name}' is bound as a list in one place and a row in ` +
|
|
208
|
+
`another; a name answers with one shape`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (lists.length > 0) {
|
|
212
|
+
for (const scope of lists)
|
|
213
|
+
applyList(scope, result);
|
|
68
214
|
continue;
|
|
69
215
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
216
|
+
// The only disagreement visible from here: a patch and a row are both
|
|
217
|
+
// objects, so an array arriving at a row scope is the one case the
|
|
218
|
+
// browser can name. createHub holds the stronger check, because it knows
|
|
219
|
+
// what the query declared.
|
|
220
|
+
if (Array.isArray(result)) {
|
|
221
|
+
console.error(`lb-hub: '${name}' is bound with ${ATTR_ROW} but answered with rows`);
|
|
222
|
+
continue;
|
|
75
223
|
}
|
|
224
|
+
for (const scope of rows)
|
|
225
|
+
applyRow(scope, result);
|
|
76
226
|
}
|
|
77
227
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { applyData,
|
|
1
|
+
export { applyData, applyRow } from "./lb-apply";
|
|
2
2
|
//# sourceMappingURL=lb-hub.browser.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lb-hub.browser.d.ts","sourceRoot":"","sources":["../../hub/lb-hub.browser.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"lb-hub.browser.d.ts","sourceRoot":"","sources":["../../hub/lb-hub.browser.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
|
-
import { ATTR_ACTION, ATTR_CELL,
|
|
2
|
+
import { ACTION_ROW_DELETE, ACTION_ROW_INSERT, ACTION_ROW_UPDATE, ATTR_ACTION, ATTR_CELL, ATTR_ERROR, ATTR_KEY_VALUE, ATTR_LIST, ATTR_NAV_LINK, ATTR_PAGE, ATTR_PENDING, ATTR_ROW, ATTR_UNKNOWN_PAGE, LB_ACTIONS, LB_RESERVED_PREFIX, HUB_TAG_NAME, NAV_CELL_LABEL, NAV_CELL_URI, NAV_ROW, LB_EVENT_NAME, LB_ENDPOINT, LB_REQUEST_TIMEOUT_MS, } from "../core/lb-constants";
|
|
3
3
|
import { applyData } from "./lb-apply";
|
|
4
|
-
export { applyData,
|
|
4
|
+
export { applyData, applyRow } from "./lb-apply";
|
|
5
5
|
/**
|
|
6
6
|
* Where a click happened, in the binding vocabulary. An action carries
|
|
7
7
|
* these and nothing else, so there is no channel for a browser-supplied
|
|
8
8
|
* argument.
|
|
9
|
+
*
|
|
10
|
+
* The scope field is `list` or `row`, whichever attribute scoped the nearest
|
|
11
|
+
* ancestor, so an action fired inside a single-row scope says so and the
|
|
12
|
+
* wire never needs a noun for the addressable thing.
|
|
9
13
|
*/
|
|
10
14
|
function binding(el) {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
15
|
+
const scope = el.closest(`[${ATTR_LIST}], [${ATTR_ROW}]`);
|
|
16
|
+
const list = scope?.getAttribute(ATTR_LIST) ?? null;
|
|
17
|
+
const row = list === null ? (scope?.getAttribute(ATTR_ROW) ?? null) : null;
|
|
18
|
+
const key = el.closest(`[${ATTR_KEY_VALUE}]`)?.getAttribute(ATTR_KEY_VALUE);
|
|
13
19
|
const cell = el.getAttribute(ATTR_CELL);
|
|
14
20
|
return {
|
|
15
|
-
...(
|
|
21
|
+
...(list === null ? {} : { list }),
|
|
22
|
+
...(row === null ? {} : { row }),
|
|
16
23
|
...(key === null || key === undefined ? {} : { key }),
|
|
17
24
|
...(cell === null ? {} : { cell }),
|
|
18
25
|
};
|
|
@@ -61,6 +68,29 @@ function pageNameFor(path) {
|
|
|
61
68
|
const name = path.replace(/^\/+|\/+$/g, "");
|
|
62
69
|
return name === "" ? "index" : name;
|
|
63
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* The `<template>` a page's markup ships in. The builder guarantees one per
|
|
73
|
+
* page name (build/locations.ts refuses two page files sharing a stub), so
|
|
74
|
+
* a second match here means the document was assembled by something else,
|
|
75
|
+
* and is worth saying out loud rather than resolving by document order.
|
|
76
|
+
*/
|
|
77
|
+
function pageHostFor(page) {
|
|
78
|
+
const hosts = [];
|
|
79
|
+
for (const el of document.querySelectorAll(`template[${ATTR_PAGE}]`)) {
|
|
80
|
+
if (el.getAttribute(ATTR_PAGE) === page) {
|
|
81
|
+
hosts.push(el);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (hosts.length === 0) {
|
|
85
|
+
console.error(`lb-hub: no page host for '${page}'`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (hosts.length > 1) {
|
|
89
|
+
console.error(`lb-hub: ${hosts.length} page hosts carry ${ATTR_PAGE}="${page}", ` +
|
|
90
|
+
`using the first`);
|
|
91
|
+
}
|
|
92
|
+
return hosts[0];
|
|
93
|
+
}
|
|
64
94
|
/**
|
|
65
95
|
* Fetch one round trip against the client-side deadline. Throws — rather
|
|
66
96
|
* than swallowing — on a non-ok response, a network failure, or a timeout,
|
|
@@ -68,11 +98,18 @@ function pageNameFor(path) {
|
|
|
68
98
|
* only ever wants the freshest data, like navigate()) can tell the two
|
|
69
99
|
* apart from a genuinely empty result.
|
|
70
100
|
*/
|
|
71
|
-
async function fetchData(
|
|
101
|
+
async function fetchData(page, request) {
|
|
102
|
+
const url = `${LB_ENDPOINT}?page=${encodeURIComponent(page)}`;
|
|
72
103
|
const controller = new AbortController();
|
|
73
104
|
const timeout = setTimeout(() => controller.abort(), LB_REQUEST_TIMEOUT_MS);
|
|
74
105
|
try {
|
|
75
|
-
|
|
106
|
+
// An empty body is how the hub asks for the page's whole query set.
|
|
107
|
+
const res = await fetch(url, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "content-type": "application/json" },
|
|
110
|
+
body: JSON.stringify(request ?? {}),
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
});
|
|
76
113
|
if (!res.ok) {
|
|
77
114
|
throw new Error(`lb-hub: server responded ${res.status} for ${url}`);
|
|
78
115
|
}
|
|
@@ -89,7 +126,7 @@ async function fetchData(url, init) {
|
|
|
89
126
|
class LbHub extends HTMLElement {
|
|
90
127
|
main;
|
|
91
128
|
/**
|
|
92
|
-
* The page currently in <main>.
|
|
129
|
+
* The page currently in <main>. A page declares its own requests, so a request
|
|
93
130
|
* has to say which page it came from. The page rides on the URL rather
|
|
94
131
|
* than in the request, leaving the operation set closed.
|
|
95
132
|
*/
|
|
@@ -98,8 +135,18 @@ class LbHub extends HTMLElement {
|
|
|
98
135
|
this.main = this.querySelector("main");
|
|
99
136
|
this.addEventListener(LB_EVENT_NAME, (e) => {
|
|
100
137
|
const request = e.detail;
|
|
101
|
-
if (!request?.
|
|
102
|
-
console.warn(`lb-hub: event without an
|
|
138
|
+
if (!request?.action) {
|
|
139
|
+
console.warn(`lb-hub: event without an action, ignoring`, request);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// The reserved prefix is the whole of the discriminant, so a reserved
|
|
143
|
+
// name that is not one of the four operations is a typo rather than
|
|
144
|
+
// something to forward. Refused here, where the widget can see why: a
|
|
145
|
+
// server would only report that it declares no such action.
|
|
146
|
+
if (request.action.startsWith(LB_RESERVED_PREFIX) &&
|
|
147
|
+
!LB_ACTIONS.includes(request.action)) {
|
|
148
|
+
console.error(`lb-hub: '${request.action}' begins with '${LB_RESERVED_PREFIX}' ` +
|
|
149
|
+
`but names no operation, ignoring`);
|
|
103
150
|
return;
|
|
104
151
|
}
|
|
105
152
|
// The element that dispatched the request, not e.currentTarget (the
|
|
@@ -108,12 +155,7 @@ class LbHub extends HTMLElement {
|
|
|
108
155
|
const origin = e.target;
|
|
109
156
|
origin?.removeAttribute(ATTR_ERROR);
|
|
110
157
|
origin?.setAttribute(ATTR_PENDING, "");
|
|
111
|
-
|
|
112
|
-
void fetchData(url, {
|
|
113
|
-
method: "POST",
|
|
114
|
-
headers: { "content-type": "application/json" },
|
|
115
|
-
body: JSON.stringify(request),
|
|
116
|
-
})
|
|
158
|
+
void fetchData(this.page, request)
|
|
117
159
|
.then((data) => applyData(this, data))
|
|
118
160
|
.catch((err) => {
|
|
119
161
|
console.error(`lb-hub: request failed`, err);
|
|
@@ -137,73 +179,29 @@ class LbHub extends HTMLElement {
|
|
|
137
179
|
const el = e.target?.closest(`[${ATTR_ACTION}]`);
|
|
138
180
|
if (!el || el.localName.includes("-"))
|
|
139
181
|
return;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
op: "action",
|
|
143
|
-
name: el.getAttribute(ATTR_ACTION),
|
|
144
|
-
...binding(el),
|
|
145
|
-
};
|
|
146
|
-
el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
147
|
-
});
|
|
148
|
-
// The CRUD counterpart to the lb-action delegation above: a native
|
|
149
|
-
// element needs no declared name to delete the row it is in, only its
|
|
150
|
-
// own query/key binding.
|
|
151
|
-
this.addEventListener("click", (e) => {
|
|
152
|
-
const el = e.target?.closest(`[${ATTR_DELETE}]`);
|
|
153
|
-
if (!el || el.localName.includes("-"))
|
|
154
|
-
return;
|
|
155
|
-
e.preventDefault();
|
|
156
|
-
const { query, key } = binding(el);
|
|
157
|
-
if (!query || !key) {
|
|
158
|
-
console.warn(`lb-hub: lb-delete with no query/key binding, ignoring`, el);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
const detail = { op: "tuple-delete", query, key };
|
|
162
|
-
el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
163
|
-
});
|
|
164
|
-
// The CRUD op that gathers rather than binds a single cell: every
|
|
165
|
-
// lb-cell inside the form becomes one entry of the values map. There is
|
|
166
|
-
// no key, because there is no row yet — only the query, found the same
|
|
167
|
-
// way binding() finds one for a click, says what it is inserting
|
|
168
|
-
// into.
|
|
169
|
-
this.addEventListener("submit", (e) => {
|
|
170
|
-
const form = e.target?.closest(`[${ATTR_INSERT}]`);
|
|
171
|
-
if (!form)
|
|
182
|
+
// A form performs its action on submit, whatever was clicked in it.
|
|
183
|
+
if (el instanceof HTMLFormElement)
|
|
172
184
|
return;
|
|
173
185
|
e.preventDefault();
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
const name = el.getAttribute(ATTR_ACTION);
|
|
187
|
+
const detail = this.requestFor(name, el);
|
|
188
|
+
if (detail) {
|
|
189
|
+
el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
178
190
|
}
|
|
179
|
-
const detail = {
|
|
180
|
-
op: "tuple-insert",
|
|
181
|
-
query,
|
|
182
|
-
values: gatherValues(form),
|
|
183
|
-
};
|
|
184
|
-
form.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
185
191
|
});
|
|
186
|
-
// The
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// reads.
|
|
192
|
+
// The same delegation for the event a form owns. A reserved name here
|
|
193
|
+
// gathers the form's cells; a declared one carries the binding, as a
|
|
194
|
+
// click does.
|
|
190
195
|
this.addEventListener("submit", (e) => {
|
|
191
|
-
const form = e.target
|
|
192
|
-
if (!form)
|
|
196
|
+
const form = e.target;
|
|
197
|
+
if (!form || !form.hasAttribute(ATTR_ACTION))
|
|
193
198
|
return;
|
|
194
199
|
e.preventDefault();
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
const name = form.getAttribute(ATTR_ACTION);
|
|
201
|
+
const detail = this.requestFor(name, form);
|
|
202
|
+
if (detail) {
|
|
203
|
+
form.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
199
204
|
}
|
|
200
|
-
const detail = {
|
|
201
|
-
op: "tuple-update",
|
|
202
|
-
query,
|
|
203
|
-
key,
|
|
204
|
-
values: gatherValues(form),
|
|
205
|
-
};
|
|
206
|
-
form.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
|
|
207
205
|
});
|
|
208
206
|
// Only anchors that opted in. Everything else is a real link.
|
|
209
207
|
this.addEventListener("click", (e) => {
|
|
@@ -223,12 +221,61 @@ class LbHub extends HTMLElement {
|
|
|
223
221
|
* already in the document, so selecting a page is getElementById() and
|
|
224
222
|
* there is no fetching mechanism on the host channel.
|
|
225
223
|
*/
|
|
224
|
+
/**
|
|
225
|
+
* What a native element carrying `lb-action` asks for. A reserved name is
|
|
226
|
+
* one of the CRUD operations and takes the binding that operation needs;
|
|
227
|
+
* any other name is an action the page declared, carrying whatever
|
|
228
|
+
* binding is in scope. A reserved name the hub does not turn into an
|
|
229
|
+
* operation here — a cell change, which needs a widget to say what a
|
|
230
|
+
* change is, or a name nobody reserved — is refused rather than sent.
|
|
231
|
+
*/
|
|
232
|
+
requestFor(name, el) {
|
|
233
|
+
const { list, row, key, cell } = binding(el);
|
|
234
|
+
const form = el instanceof HTMLFormElement;
|
|
235
|
+
switch (name) {
|
|
236
|
+
case ACTION_ROW_DELETE:
|
|
237
|
+
if (list && key)
|
|
238
|
+
return { action: ACTION_ROW_DELETE, list, key };
|
|
239
|
+
break;
|
|
240
|
+
case ACTION_ROW_INSERT:
|
|
241
|
+
if (form && list) {
|
|
242
|
+
return {
|
|
243
|
+
action: ACTION_ROW_INSERT,
|
|
244
|
+
list,
|
|
245
|
+
values: gatherValues(el),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
case ACTION_ROW_UPDATE:
|
|
250
|
+
if (form && list && key) {
|
|
251
|
+
return {
|
|
252
|
+
action: ACTION_ROW_UPDATE,
|
|
253
|
+
list,
|
|
254
|
+
key,
|
|
255
|
+
values: gatherValues(el),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
if (!name.startsWith(LB_RESERVED_PREFIX)) {
|
|
261
|
+
return {
|
|
262
|
+
action: name,
|
|
263
|
+
...(list === undefined ? {} : { list }),
|
|
264
|
+
...(row === undefined ? {} : { row }),
|
|
265
|
+
...(key === undefined ? {} : { key }),
|
|
266
|
+
...(cell === undefined ? {} : { cell }),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
console.warn(`lb-hub: ${ATTR_ACTION}="${name}" on <${el.localName}> cannot be ` +
|
|
271
|
+
`sent from here, ignoring`, el);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
226
274
|
async navigate(path) {
|
|
227
275
|
const page = pageNameFor(path);
|
|
228
276
|
this.landNavigation(path, page);
|
|
229
|
-
const template =
|
|
230
|
-
if (!
|
|
231
|
-
console.error(`lb-hub: no page host for '${page}'`);
|
|
277
|
+
const template = pageHostFor(page);
|
|
278
|
+
if (!template) {
|
|
232
279
|
this.reportUnknownPage();
|
|
233
280
|
this.reveal();
|
|
234
281
|
return;
|
|
@@ -241,7 +288,7 @@ class LbHub extends HTMLElement {
|
|
|
241
288
|
this.reveal();
|
|
242
289
|
let data = {};
|
|
243
290
|
try {
|
|
244
|
-
data = await fetchData(
|
|
291
|
+
data = await fetchData(page);
|
|
245
292
|
}
|
|
246
293
|
catch (err) {
|
|
247
294
|
console.error(`lb-hub: failed to load data for '${page}'`, err);
|
|
@@ -258,9 +305,9 @@ class LbHub extends HTMLElement {
|
|
|
258
305
|
document.body.hidden = false;
|
|
259
306
|
}
|
|
260
307
|
/**
|
|
261
|
-
* The hub's own
|
|
308
|
+
* The hub's own row: where the page is, in the vocabulary a server
|
|
262
309
|
* answer arrives in, so a chrome displays it the way it displays anything
|
|
263
|
-
* — `lb-
|
|
310
|
+
* — `lb-row="lb-navigation"` on a subtree, `lb-cell` on what shows a
|
|
264
311
|
* value. Landed before the page host is looked up, so a miss has it too;
|
|
265
312
|
* the unknown-page dialog is one consumer among whatever others the
|
|
266
313
|
* chrome writes.
|
|
@@ -276,7 +323,7 @@ class LbHub extends HTMLElement {
|
|
|
276
323
|
* be warned about.
|
|
277
324
|
*/
|
|
278
325
|
landNavigation(path, page) {
|
|
279
|
-
if (!this.querySelector(`[${
|
|
326
|
+
if (!this.querySelector(`[${ATTR_ROW}="${NAV_ROW}"]`))
|
|
280
327
|
return;
|
|
281
328
|
let label = "";
|
|
282
329
|
for (const a of this.querySelectorAll(`a[${ATTR_NAV_LINK}]`)) {
|
|
@@ -286,7 +333,7 @@ class LbHub extends HTMLElement {
|
|
|
286
333
|
break;
|
|
287
334
|
}
|
|
288
335
|
applyData(this, {
|
|
289
|
-
[
|
|
336
|
+
[NAV_ROW]: { [NAV_CELL_LABEL]: label, [NAV_CELL_URI]: path },
|
|
290
337
|
});
|
|
291
338
|
}
|
|
292
339
|
/**
|
|
@@ -299,7 +346,7 @@ class LbHub extends HTMLElement {
|
|
|
299
346
|
* showModal() without checking the tag here.
|
|
300
347
|
*
|
|
301
348
|
* Only opens it. What the dialog says it gets from landNavigation(),
|
|
302
|
-
* already run, by naming the hub's
|
|
349
|
+
* already run, by naming the hub's row in lb-row like any subtree.
|
|
303
350
|
*
|
|
304
351
|
* Scoped to the hub, like every other lookup here, so that one rule holds
|
|
305
352
|
* without exception: what the hub acts on is inside the hub.
|
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
* Express itself is a peer dependency: nothing outside this file imports it,
|
|
7
7
|
* so an application that hosts Hub some other way never pays for it.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* One endpoint, and no second:
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* POST /lb?page=<name> an empty body asks for the page's whole query
|
|
12
|
+
* set; a body carrying an action runs that action
|
|
13
|
+
* and answers with its refresh set
|
|
13
14
|
*
|
|
14
15
|
* The page rides on the query string rather than in the body, which is what
|
|
15
|
-
* lets the operation set stay closed.
|
|
16
|
+
* lets the operation set stay closed. Every hub call is a POST, so an
|
|
17
|
+
* application's own GET routes never collide with this one and no page name
|
|
18
|
+
* is reserved.
|
|
16
19
|
*/
|
|
17
20
|
import { type Request, type Router } from "express";
|
|
18
21
|
import type { Hub, HubContext } from "./lb-server";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lb-express.d.ts","sourceRoot":"","sources":["../../server/lb-express.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"lb-express.d.ts","sourceRoot":"","sources":["../../server/lb-express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAgB,EAAE,KAAK,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,SAAS,CAAC;AAS7D,OAAO,KAAK,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEnD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,UAAU,CAAC;AAEtD,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,UAAU,EAAE,UAAU,GAAG,MAAM,CAkFlE"}
|