@cosmicdrift/kumiko-renderer 0.377.0 → 0.379.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/dist/app/action-form-shim.js +1 -2
- package/dist/app/action-form-support.js +16 -4
- package/dist/app/kumiko-screen.js +51 -38
- package/dist/app/list-empty-content.d.ts +1 -0
- package/dist/app/list-empty-content.js +5 -0
- package/dist/app/row-actions.d.ts +7 -1
- package/dist/app/row-actions.js +6 -1
- package/dist/app/secret-mint-body.js +3 -2
- package/dist/components/embedded-list-field.js +16 -7
- package/dist/components/file-list-field.js +4 -0
- package/dist/components/form-derivation-patch.d.ts +8 -0
- package/dist/components/form-derivation-patch.js +24 -0
- package/dist/components/form-screen-layout.d.ts +13 -0
- package/dist/components/form-screen-layout.js +13 -0
- package/dist/components/grid-cell-for-field.d.ts +4 -1
- package/dist/components/grid-cell-for-field.js +6 -2
- package/dist/components/list-empty-state.d.ts +4 -1
- package/dist/components/list-empty-state.js +4 -1
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +4 -1
- package/dist/components/render-edit-logic.d.ts +1 -1
- package/dist/components/render-edit-logic.js +8 -2
- package/dist/components/render-edit-types.d.ts +7 -1
- package/dist/components/render-edit.js +38 -6
- package/dist/components/render-field.d.ts +3 -1
- package/dist/components/render-field.js +27 -8
- package/dist/components/render-list.d.ts +3 -1
- package/dist/components/render-list.js +29 -8
- package/dist/components/write-form-section.js +4 -1
- package/dist/field-issue-text.d.ts +4 -0
- package/dist/field-issue-text.js +14 -0
- package/dist/file-ids-of.d.ts +1 -0
- package/dist/file-ids-of.js +7 -0
- package/dist/flow-diagram/icons.d.ts +3 -0
- package/dist/flow-diagram/icons.js +14 -0
- package/dist/flow-diagram/index.d.ts +2 -0
- package/dist/flow-diagram/index.js +1 -0
- package/dist/flow-diagram/render.d.ts +3 -0
- package/dist/flow-diagram/render.js +234 -0
- package/dist/flow-diagram/types.d.ts +18 -0
- package/dist/flow-diagram/types.js +1 -0
- package/dist/headless/dispatcher/index.d.ts +1 -0
- package/dist/headless/dispatcher/index.js +1 -0
- package/dist/headless/dispatcher/request-id.d.ts +1 -0
- package/dist/headless/dispatcher/request-id.js +15 -0
- package/dist/headless/form/form-controller.js +16 -1
- package/dist/headless/form/zod-bridge.js +7 -0
- package/dist/headless/index.d.ts +2 -1
- package/dist/headless/index.js +2 -1
- package/dist/headless/plural-key.d.ts +1 -0
- package/dist/headless/plural-key.js +5 -0
- package/dist/headless/view-model/edit.d.ts +8 -1
- package/dist/headless/view-model/edit.js +23 -7
- package/dist/headless/view-model/types.d.ts +11 -3
- package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
- package/dist/hooks/live-refetch-scheduler.js +45 -0
- package/dist/hooks/use-query.js +8 -4
- package/dist/hooks/use-reference-lookup.d.ts +1 -0
- package/dist/hooks/use-reference-lookup.js +6 -3
- package/dist/i18n-defaults.js +28 -3
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/leave-guard-dialog.d.ts +2 -0
- package/dist/leave-guard-dialog.js +28 -0
- package/dist/leave-guard.d.ts +15 -0
- package/dist/leave-guard.js +39 -0
- package/dist/live/dispatcher-live.js +1 -16
- package/dist/primitives.d.ts +39 -0
- package/dist/sse/live-events.d.ts +3 -0
- package/dist/sse/live-events.js +3 -0
- package/dist/web/app/create-app.js +2 -2
- package/dist/web/app/dashboard-body.js +13 -1
- package/dist/web/app/nav.js +51 -9
- package/dist/web/index.d.ts +2 -2
- package/dist/web/index.js +1 -1
- package/dist/web/layout/nav-tree.js +13 -3
- package/dist/web/layout/shell-header.js +3 -3
- package/dist/web/lib/file-upload-form-data.d.ts +2 -0
- package/dist/web/lib/file-upload-form-data.js +2 -0
- package/dist/web/lib/upload-error-key.d.ts +7 -0
- package/dist/web/lib/upload-error-key.js +23 -0
- package/dist/web/lib/upload-file-ref.d.ts +1 -1
- package/dist/web/primitives/embedded-list-input.js +5 -4
- package/dist/web/primitives/file-thumbnail.d.ts +6 -0
- package/dist/web/primitives/file-thumbnail.js +19 -19
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +11 -7
- package/dist/web/primitives/file-value-list.d.ts +3 -0
- package/dist/web/primitives/file-value-list.js +22 -0
- package/dist/web/primitives/files-dropzone.d.ts +16 -0
- package/dist/web/primitives/files-dropzone.js +52 -0
- package/dist/web/primitives/index.js +65 -30
- package/dist/web/primitives/segmented-select.js +8 -2
- package/dist/web/sse/live-events.d.ts +13 -0
- package/dist/web/sse/live-events.js +88 -23
- package/dist/web/widgets/bounded-load-cache.d.ts +1 -0
- package/dist/web/widgets/bounded-load-cache.js +25 -0
- package/dist/web/widgets/drawer.js +37 -2
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +1 -0
- package/dist/web/widgets/pdf-preview.d.ts +10 -0
- package/dist/web/widgets/pdf-preview.js +140 -0
- package/dist/web/widgets/upload-zone.d.ts +11 -2
- package/dist/web/widgets/upload-zone.js +30 -7
- package/package.json +14 -5
- package/src/changes.json +143 -0
- package/src/web/styles.css +13 -9
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderFlow } from "./render.js";
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { escapeHtml } from "../headless/index.js";
|
|
2
|
+
import { ICONS } from "./icons.js";
|
|
3
|
+
const BADGE = 24;
|
|
4
|
+
const ICON_SIZE = 13;
|
|
5
|
+
const PAD_X = 9.6;
|
|
6
|
+
const GAP_BADGE_TEXT = 8;
|
|
7
|
+
const GAP_RIGHT = 11.2;
|
|
8
|
+
const ROW_GAP = 35;
|
|
9
|
+
const COL_GAP = 22;
|
|
10
|
+
const MIN_CARD_WIDTH = 120;
|
|
11
|
+
const TITLE_SIZE = 11.2;
|
|
12
|
+
const SUB_SIZE = 9.2;
|
|
13
|
+
const STEP_SECONDS = 1.2;
|
|
14
|
+
function textWidth(text, fontSize, bold) {
|
|
15
|
+
return text.length * fontSize * (bold ? 0.62 : 0.56);
|
|
16
|
+
}
|
|
17
|
+
function cardSize(node) {
|
|
18
|
+
const titleWidth = textWidth(node.title, TITLE_SIZE, true);
|
|
19
|
+
const subWidth = node.sub ? textWidth(node.sub, SUB_SIZE, false) : 0;
|
|
20
|
+
const content = Math.max(titleWidth, subWidth);
|
|
21
|
+
const width = Math.max(MIN_CARD_WIDTH, BADGE + GAP_BADGE_TEXT + content + PAD_X + GAP_RIGHT);
|
|
22
|
+
const height = node.sub ? 42 : 32;
|
|
23
|
+
return { width, height };
|
|
24
|
+
}
|
|
25
|
+
function assertEdgesReferenceKnownNodes(spec) {
|
|
26
|
+
const ids = new Set(spec.nodes.map((n) => n.id));
|
|
27
|
+
for (const edge of spec.edges) {
|
|
28
|
+
if (!ids.has(edge.from))
|
|
29
|
+
throw new Error(`FlowSpec: edge references unknown node "${edge.from}"`);
|
|
30
|
+
if (!ids.has(edge.to))
|
|
31
|
+
throw new Error(`FlowSpec: edge references unknown node "${edge.to}"`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function buildAdjacency(spec) {
|
|
35
|
+
const children = new Map();
|
|
36
|
+
const inDegree = new Map();
|
|
37
|
+
for (const node of spec.nodes)
|
|
38
|
+
inDegree.set(node.id, 0);
|
|
39
|
+
for (const edge of spec.edges) {
|
|
40
|
+
children.set(edge.from, [...(children.get(edge.from) ?? []), edge]);
|
|
41
|
+
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
return { children, inDegree };
|
|
44
|
+
}
|
|
45
|
+
/** Longest-path depth per node via Kahn's algorithm; throws if the graph has no root or contains a cycle. */
|
|
46
|
+
function longestPathDepths(spec, children, inDegree) {
|
|
47
|
+
const depth = new Map();
|
|
48
|
+
const queue = spec.nodes.filter((n) => inDegree.get(n.id) === 0).map((n) => n.id);
|
|
49
|
+
if (queue.length === 0 && spec.nodes.length > 0) {
|
|
50
|
+
throw new Error("FlowSpec: no root node found (every node has an incoming edge — check for a cycle)");
|
|
51
|
+
}
|
|
52
|
+
for (const id of queue)
|
|
53
|
+
depth.set(id, 0);
|
|
54
|
+
const remaining = new Map(inDegree);
|
|
55
|
+
let visited = 0;
|
|
56
|
+
for (const id of queue) {
|
|
57
|
+
visited += 1;
|
|
58
|
+
for (const edge of children.get(id) ?? []) {
|
|
59
|
+
const next = Math.max(depth.get(edge.to) ?? 0, (depth.get(id) ?? 0) + 1);
|
|
60
|
+
depth.set(edge.to, next);
|
|
61
|
+
const left = (remaining.get(edge.to) ?? 0) - 1;
|
|
62
|
+
remaining.set(edge.to, left);
|
|
63
|
+
if (left === 0)
|
|
64
|
+
queue.push(edge.to);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (visited < spec.nodes.length) {
|
|
68
|
+
throw new Error("FlowSpec: graph has a cycle — a flow diagram must be acyclic");
|
|
69
|
+
}
|
|
70
|
+
return depth;
|
|
71
|
+
}
|
|
72
|
+
function computeDepths(spec) {
|
|
73
|
+
assertEdgesReferenceKnownNodes(spec);
|
|
74
|
+
const { children, inDegree } = buildAdjacency(spec);
|
|
75
|
+
return longestPathDepths(spec, children, inDegree);
|
|
76
|
+
}
|
|
77
|
+
function layout(spec) {
|
|
78
|
+
const depthById = computeDepths(spec);
|
|
79
|
+
const maxDepth = Math.max(0, ...[...depthById.values()]);
|
|
80
|
+
const rows = Array.from({ length: maxDepth + 1 }, () => []);
|
|
81
|
+
for (const node of spec.nodes)
|
|
82
|
+
rows[depthById.get(node.id) ?? 0]?.push(node);
|
|
83
|
+
// Siblings in the same row share one card size (the row's largest), so a
|
|
84
|
+
// row of 3+ always lands symmetrically under its parent — an odd sibling
|
|
85
|
+
// count with mismatched widths otherwise throws the middle card off-center.
|
|
86
|
+
const sizedRows = rows.map((row) => {
|
|
87
|
+
const sizes = row.map((n) => cardSize(n));
|
|
88
|
+
return {
|
|
89
|
+
row,
|
|
90
|
+
width: Math.max(MIN_CARD_WIDTH, ...sizes.map((s) => s.width)),
|
|
91
|
+
height: Math.max(0, ...sizes.map((s) => s.height)),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
const rowWidths = sizedRows.map(({ row, width }) => row.length * width + COL_GAP * Math.max(0, row.length - 1));
|
|
95
|
+
const canvasWidth = Math.max(...rowWidths, MIN_CARD_WIDTH) + 40;
|
|
96
|
+
const centerX = canvasWidth / 2;
|
|
97
|
+
const placed = new Map();
|
|
98
|
+
let y = 20;
|
|
99
|
+
for (const { row, width, height } of sizedRows) {
|
|
100
|
+
const rowWidth = row.length * width + COL_GAP * Math.max(0, row.length - 1);
|
|
101
|
+
let x = centerX - rowWidth / 2;
|
|
102
|
+
for (const node of row) {
|
|
103
|
+
placed.set(node.id, { node, depth: depthById.get(node.id) ?? 0, x, y, width, height });
|
|
104
|
+
x += width + COL_GAP;
|
|
105
|
+
}
|
|
106
|
+
y += height + ROW_GAP;
|
|
107
|
+
}
|
|
108
|
+
const canvasHeight = y - ROW_GAP + 20;
|
|
109
|
+
return { placed, width: canvasWidth, height: canvasHeight, maxDepth };
|
|
110
|
+
}
|
|
111
|
+
function computeTiming(placed, spec, maxDepth) {
|
|
112
|
+
const totalSeconds = STEP_SECONDS * (maxDepth + 1) + STEP_SECONDS * 0.5;
|
|
113
|
+
const timingByEdge = new Map();
|
|
114
|
+
const arrivalByNode = new Map();
|
|
115
|
+
for (const edge of spec.edges) {
|
|
116
|
+
const from = placed.get(edge.from);
|
|
117
|
+
const to = placed.get(edge.to);
|
|
118
|
+
if (!from || !to)
|
|
119
|
+
continue;
|
|
120
|
+
const span = Math.max(1, to.depth - from.depth);
|
|
121
|
+
const startT = from.depth * STEP_SECONDS;
|
|
122
|
+
const travelT = STEP_SECONDS * 0.75 * span;
|
|
123
|
+
const startFrac = startT / totalSeconds;
|
|
124
|
+
const endFrac = Math.min(0.98, (startT + travelT) / totalSeconds);
|
|
125
|
+
timingByEdge.set(edge, { startFrac, endFrac });
|
|
126
|
+
arrivalByNode.set(edge.to, Math.max(arrivalByNode.get(edge.to) ?? 0, endFrac));
|
|
127
|
+
}
|
|
128
|
+
return { totalSeconds, timingByEdge, arrivalByNode };
|
|
129
|
+
}
|
|
130
|
+
const CORNER_RADIUS = 8; // 10 * 0.8
|
|
131
|
+
function edgePath(from, to) {
|
|
132
|
+
const x1 = from.x + from.width / 2;
|
|
133
|
+
const y1 = from.y + from.height;
|
|
134
|
+
const x2 = to.x + to.width / 2;
|
|
135
|
+
const y2 = to.y;
|
|
136
|
+
if (Math.abs(x1 - x2) < 1) {
|
|
137
|
+
return { d: `M ${x1} ${y1} L ${x2} ${y2}`, midX: x1, midY: (y1 + y2) / 2 };
|
|
138
|
+
}
|
|
139
|
+
const midY = (y1 + y2) / 2;
|
|
140
|
+
const dir = x2 > x1 ? 1 : -1;
|
|
141
|
+
const r = Math.min(CORNER_RADIUS, Math.abs(x2 - x1) / 2, midY - y1, y2 - midY);
|
|
142
|
+
const d = `M ${x1} ${y1} L ${x1} ${midY - r} ` +
|
|
143
|
+
`Q ${x1} ${midY} ${x1 + dir * r} ${midY} ` +
|
|
144
|
+
`L ${x2 - dir * r} ${midY} ` +
|
|
145
|
+
`Q ${x2} ${midY} ${x2} ${midY + r} ` +
|
|
146
|
+
`L ${x2} ${y2}`;
|
|
147
|
+
return { d, midX: (x1 + x2) / 2, midY };
|
|
148
|
+
}
|
|
149
|
+
/** Renders a FlowSpec (a small acyclic node/edge graph) to a self-contained, animated SVG diagram string for embedding in docs pages. */
|
|
150
|
+
export function renderFlow(spec) {
|
|
151
|
+
const { placed, width, height, maxDepth } = layout(spec);
|
|
152
|
+
const { totalSeconds, timingByEdge, arrivalByNode } = computeTiming(placed, spec, maxDepth);
|
|
153
|
+
const wires = [];
|
|
154
|
+
const dots = [];
|
|
155
|
+
const labels = [];
|
|
156
|
+
for (const edge of spec.edges) {
|
|
157
|
+
const from = placed.get(edge.from);
|
|
158
|
+
const to = placed.get(edge.to);
|
|
159
|
+
if (!from || !to)
|
|
160
|
+
continue;
|
|
161
|
+
const { d, midX, midY } = edgePath(from, to);
|
|
162
|
+
// html-ok: d is built in edgePath() from numeric coordinates only, never user input
|
|
163
|
+
wires.push(`<path class="wire" d="${d}"/>`);
|
|
164
|
+
if (edge.label) {
|
|
165
|
+
labels.push(`<text class="edge-label" x="${midX}" y="${midY - 6}" text-anchor="middle">${escapeHtml(edge.label)}</text>`);
|
|
166
|
+
}
|
|
167
|
+
const timing = timingByEdge.get(edge);
|
|
168
|
+
if (!timing)
|
|
169
|
+
continue;
|
|
170
|
+
const { startFrac, endFrac } = timing;
|
|
171
|
+
const fadeIn = Math.min(startFrac + 0.02, endFrac);
|
|
172
|
+
const fadeOut = Math.min(endFrac + 0.02, 1);
|
|
173
|
+
dots.push(`<circle r="3.2" class="dot">` +
|
|
174
|
+
`<animateMotion dur="${totalSeconds}s" repeatCount="indefinite" calcMode="linear" ` +
|
|
175
|
+
`keyPoints="0;0;1;1" keyTimes="0;${startFrac};${endFrac};1" path="${d}"/>` +
|
|
176
|
+
`<animate attributeName="opacity" dur="${totalSeconds}s" repeatCount="indefinite" ` +
|
|
177
|
+
`values="0;0;1;1;0;0" keyTimes="0;${startFrac};${fadeIn};${endFrac};${fadeOut};1"/>` +
|
|
178
|
+
`</circle>`);
|
|
179
|
+
}
|
|
180
|
+
const cards = spec.nodes.map((node) => {
|
|
181
|
+
const p = placed.get(node.id);
|
|
182
|
+
if (!p)
|
|
183
|
+
return "";
|
|
184
|
+
const arrival = arrivalByNode.get(node.id) ?? 0.02;
|
|
185
|
+
const pulseEnd = Math.min(arrival + 0.05, 1);
|
|
186
|
+
const badgeX = p.x + PAD_X;
|
|
187
|
+
const badgeY = p.y + (p.height - BADGE) / 2;
|
|
188
|
+
const textX = badgeX + BADGE + GAP_BADGE_TEXT;
|
|
189
|
+
const isTerminal = node.kind === "terminal";
|
|
190
|
+
const badgeFill = isTerminal
|
|
191
|
+
? "var(--flow-card)"
|
|
192
|
+
: node.icon === "check"
|
|
193
|
+
? "var(--flow-green)"
|
|
194
|
+
: "var(--flow-badge)";
|
|
195
|
+
const badgeClass = isTerminal ? "flow-badge flow-badge-terminal" : "flow-badge";
|
|
196
|
+
const iconGroupClass = isTerminal ? "flow-icon-terminal" : "";
|
|
197
|
+
const iconInset = (BADGE - ICON_SIZE) / 2;
|
|
198
|
+
const iconInner = node.icon === "ai"
|
|
199
|
+
? `<text class="flow-ai-label" x="${BADGE / 2}" y="${BADGE / 2 + 3}" text-anchor="middle">AI</text>`
|
|
200
|
+
: // html-ok: ICONS is a static internal map keyed by the FlowIcon union, not user input
|
|
201
|
+
`<svg class="${iconGroupClass}" x="${iconInset}" y="${iconInset}" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 24 24">${ICONS[node.icon ?? "box"]}</svg>`;
|
|
202
|
+
return `<g>
|
|
203
|
+
<rect class="flow-card" x="${p.x}" y="${p.y}" width="${p.width}" height="${p.height}" rx="9">
|
|
204
|
+
<animate attributeName="stroke" dur="${totalSeconds}s" repeatCount="indefinite"
|
|
205
|
+
values="var(--flow-card-border);var(--flow-card-border);var(--flow-violet);var(--flow-card-border);var(--flow-card-border)"
|
|
206
|
+
keyTimes="0;${Math.max(0, arrival - 0.02)};${arrival};${pulseEnd};1"/>
|
|
207
|
+
</rect>
|
|
208
|
+
<g transform="translate(${badgeX}, ${badgeY})">
|
|
209
|
+
<rect class="${badgeClass}" width="${BADGE}" height="${BADGE}" rx="6" fill="${badgeFill}"/>
|
|
210
|
+
${iconInner}
|
|
211
|
+
</g>
|
|
212
|
+
<text class="flow-title" x="${textX}" y="${node.sub ? p.y + p.height / 2 - 2 : p.y + p.height / 2 + 3.5}">${escapeHtml(node.title)}</text>
|
|
213
|
+
${node.sub ? `<text class="flow-sub" x="${textX}" y="${p.y + p.height / 2 + 11}">${escapeHtml(node.sub)}</text>` : ""}
|
|
214
|
+
</g>`;
|
|
215
|
+
});
|
|
216
|
+
// html-ok: wires/dots/labels/cards hold only fragments built from numeric
|
|
217
|
+
// coordinates or already-escaped text (escapeHtml above), never raw user input
|
|
218
|
+
return `<div class="kumiko-flow">
|
|
219
|
+
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flow diagram">
|
|
220
|
+
${
|
|
221
|
+
// html-ok: see comment above return
|
|
222
|
+
wires.join("\n ")}
|
|
223
|
+
${
|
|
224
|
+
// html-ok: see comment above return
|
|
225
|
+
dots.join("\n ")}
|
|
226
|
+
${
|
|
227
|
+
// html-ok: see comment above return
|
|
228
|
+
labels.join("\n ")}
|
|
229
|
+
${
|
|
230
|
+
// html-ok: see comment above return
|
|
231
|
+
cards.join("\n ")}
|
|
232
|
+
</svg>
|
|
233
|
+
</div>`;
|
|
234
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type FlowIcon = "ui" | "api" | "handler" | "event-store" | "database" | "broadcast" | "ai" | "inbox" | "person" | "check" | "stop" | "box";
|
|
2
|
+
export type FlowNodeKind = "process" | "decision" | "terminal";
|
|
3
|
+
export interface FlowNode {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
sub?: string;
|
|
7
|
+
icon?: FlowIcon;
|
|
8
|
+
kind?: FlowNodeKind;
|
|
9
|
+
}
|
|
10
|
+
export interface FlowEdge {
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface FlowSpec {
|
|
16
|
+
nodes: FlowNode[];
|
|
17
|
+
edges: FlowEdge[];
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
export { generateRequestId } from "./request-id.ts";
|
|
1
2
|
export type { BatchResult, Command, Dispatcher, DispatcherError, DispatcherStatus, FieldIssue, PendingFile, PendingWrite, QueryOpts, QueryResult, StreamOpts, WriteOpts, WriteResult, } from "./types.ts";
|
|
2
3
|
export { StreamFrame, type StreamFrameEvent } from "./types.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateRequestId(): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
// crypto.randomUUID where available (browser, Bun, Node); Math.random
|
|
3
|
+
// fallback for React-Native runtimes without the WebCrypto polyfill.
|
|
4
|
+
// Uniqueness only needs to hold per user within the server's dedup window —
|
|
5
|
+
// this is an idempotency key, not a security token.
|
|
6
|
+
export function generateRequestId() {
|
|
7
|
+
const webCrypto = propertyOf(globalThis, "crypto");
|
|
8
|
+
const randomUUID = propertyOf(webCrypto, "randomUUID");
|
|
9
|
+
if (typeof randomUUID === "function") {
|
|
10
|
+
const id = Reflect.apply(randomUUID, webCrypto, []);
|
|
11
|
+
if (typeof id === "string")
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
15
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateRequestId } from "../dispatcher/index.js";
|
|
1
2
|
import { createStore } from "../store/index.js";
|
|
2
3
|
import { groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues } from "./zod-bridge.js";
|
|
3
4
|
// Paths without the prefix (hook-level errors, "(root)") pass through unchanged.
|
|
@@ -91,6 +92,11 @@ function omitPayloadFields(payload, omitFields) {
|
|
|
91
92
|
return payload;
|
|
92
93
|
return Object.fromEntries(Object.entries(payload).filter(([key]) => !omitFields.includes(key)));
|
|
93
94
|
}
|
|
95
|
+
function isTransportFailure(result) {
|
|
96
|
+
return (!result.isSuccess &&
|
|
97
|
+
result.error.httpStatus === 0 &&
|
|
98
|
+
(result.error.code === "network_error" || result.error.code === "aborted"));
|
|
99
|
+
}
|
|
94
100
|
export function createFormController(options) {
|
|
95
101
|
// Shallow copy so mutating the input after creation doesn't bleed into
|
|
96
102
|
// the controller's internal state. `initial` stays fixed across the
|
|
@@ -115,6 +121,10 @@ export function createFormController(options) {
|
|
|
115
121
|
// coherent (rebasing twice in quick succession would mis-align the
|
|
116
122
|
// baseline with what the server actually saw).
|
|
117
123
|
let submitInFlight = null;
|
|
124
|
+
// Idempotency key of the last attempt that got no server answer. The server
|
|
125
|
+
// caches every answer (errors too) under the requestId, so the key may only
|
|
126
|
+
// be reused for an identical retry after a transport failure.
|
|
127
|
+
let unansweredAttempt = null;
|
|
118
128
|
function buildSnapshot() {
|
|
119
129
|
const changes = valuesDiff(values, initial);
|
|
120
130
|
const isDirty = Object.keys(changes).length > 0;
|
|
@@ -365,7 +375,12 @@ export function createFormController(options) {
|
|
|
365
375
|
};
|
|
366
376
|
}
|
|
367
377
|
const runWrite = async () => {
|
|
368
|
-
const
|
|
378
|
+
const fingerprint = JSON.stringify([submitCfg.type, payload]);
|
|
379
|
+
const requestId = unansweredAttempt?.fingerprint === fingerprint
|
|
380
|
+
? unansweredAttempt.requestId
|
|
381
|
+
: generateRequestId();
|
|
382
|
+
const result = await dispatcher.write(submitCfg.type, payload, { requestId });
|
|
383
|
+
unansweredAttempt = isTransportFailure(result) ? { requestId, fingerprint } : null;
|
|
369
384
|
if (result.isSuccess) {
|
|
370
385
|
// Remember intentional empty clears so post-rebase submits keep them (#1858).
|
|
371
386
|
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
@@ -84,6 +84,13 @@ function extractIssueParams(issue) {
|
|
|
84
84
|
if (bag[key] !== undefined)
|
|
85
85
|
out[key] = bag[key];
|
|
86
86
|
}
|
|
87
|
+
// A custom issue that overrides its i18n key also carries the values the text interpolates.
|
|
88
|
+
if (issue.code === "custom" && typeof issue.params?.[I18N_KEY_PARAM] === "string") {
|
|
89
|
+
for (const [key, value] of Object.entries(issue.params)) {
|
|
90
|
+
if (key !== I18N_KEY_PARAM && value !== undefined)
|
|
91
|
+
out[key] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
87
94
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
88
95
|
}
|
|
89
96
|
// Groups a flat FieldIssue list into the `errors` map carried by a
|
package/dist/headless/index.d.ts
CHANGED
|
@@ -2,12 +2,13 @@ export type { ParsedRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
2
2
|
export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
export type { AssetResolution, AssetResolveContext, AssetResolver, BadgeProps, ButtonProps, CardProps, DatePickerProps, IconProps, LocaleResolver, ModalProps, NumberInputProps, PrimitiveCommonProps, PrimitivesContract, SelectOption, SelectProps, TextInputProps, ToastIntent, ToastProps, ToggleProps, } from "./contracts/index.ts";
|
|
4
4
|
export type { BatchResult, Command, Dispatcher, DispatcherError, DispatcherStatus, FieldIssue, PendingFile, PendingWrite, QueryOpts, QueryResult, StreamOpts, WriteOpts, WriteResult, } from "./dispatcher/index.ts";
|
|
5
|
-
export { StreamFrame, type StreamFrameEvent } from "./dispatcher/index.ts";
|
|
5
|
+
export { generateRequestId, StreamFrame, type StreamFrameEvent } from "./dispatcher/index.ts";
|
|
6
6
|
export type { FieldConditionPredicate, FieldConditions, FieldConditionValue, FieldState, FormController, FormControllerOptions, FormSnapshot, FormValues, SubmitConfig, SubmitPayloadMode, SubmitResult, } from "./form/index.ts";
|
|
7
7
|
export { createFormController, groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.ts";
|
|
8
8
|
export { applyFormatSpec, currencyDecimals, DOCUMENT_FALLBACK_CURRENCY, EMPTY_VALUE_PLACEHOLDER, escapeHtml, escapeHtmlAttr, escapeXml, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, formatMoney, type HtmlValue, html, isSafeHref, RawHtml, raw, resolveSafeMoneyLocale, stripControlChars, toInstant, } from "./format/index.ts";
|
|
9
9
|
export type { NavDefinition, NavNode, NavRegistrySlice, NavTree, ResolveNavigationOptions, } from "./nav/index.ts";
|
|
10
10
|
export { resolveNavigation } from "./nav/index.ts";
|
|
11
|
+
export { pluralKey } from "./plural-key.ts";
|
|
11
12
|
export type { Store, WritableStore } from "./store/index.ts";
|
|
12
13
|
export { createStore, shallowEqual } from "./store/index.ts";
|
|
13
14
|
export type { CellLink, CellLinkItem, ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditFormMode, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeCellLink, RuntimeCellLinkItems, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.ts";
|
package/dist/headless/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Re-Export aus framework/ui-types damit Renderer-Code denselben
|
|
2
2
|
// Parser nutzt wie der Server-Boot-Validator (Cross-Feature-Refs).
|
|
3
3
|
export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
4
|
-
export { StreamFrame } from "./dispatcher/index.js";
|
|
4
|
+
export { generateRequestId, StreamFrame } from "./dispatcher/index.js";
|
|
5
5
|
export { createFormController, groupIssuesByPath, I18N_KEY_PARAM, relevantFieldIssues, zodErrorToFieldIssues, } from "./form/index.js";
|
|
6
6
|
export { applyFormatSpec, currencyDecimals, DOCUMENT_FALLBACK_CURRENCY, EMPTY_VALUE_PLACEHOLDER, escapeHtml, escapeHtmlAttr, escapeXml, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, formatMoney, html, isSafeHref, RawHtml, raw, resolveSafeMoneyLocale, stripControlChars, toInstant, } from "./format/index.js";
|
|
7
7
|
export { resolveNavigation } from "./nav/index.js";
|
|
8
|
+
export { pluralKey } from "./plural-key.js";
|
|
8
9
|
export { createStore, shallowEqual } from "./store/index.js";
|
|
9
10
|
export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function pluralKey(baseKey: string, count: number): string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EditRelatedListSection, EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import type { EditRelatedListSection, EntityDefinition, EntityEditScreenDefinition, FormDerivationScreen } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import type { EditRelatedListSectionViewModel, EditViewModel, Translate } from "./types.ts";
|
|
3
3
|
export type ComputeEditViewModelInput<TValues extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>> = {
|
|
4
4
|
readonly screen: EntityEditScreenDefinition;
|
|
@@ -12,6 +12,13 @@ export type ComputeEditViewModelInput<TValues extends Readonly<Record<string, un
|
|
|
12
12
|
readonly mode?: EditFormMode;
|
|
13
13
|
/** The record being edited; omitted on create. */
|
|
14
14
|
readonly entityId?: string;
|
|
15
|
+
/** actionForm `derive`: computed fields render read-only (and never block submit
|
|
16
|
+
* as required) unless their derivation currently leaves them to the user. */
|
|
17
|
+
readonly derivation?: FormDerivationScreen;
|
|
18
|
+
/** Qualified name of the actionForm screen this form belongs to. File fields then upload
|
|
19
|
+
* as pending for that screen (`uploadActionForm`) instead of for an entity. Set explicitly:
|
|
20
|
+
* secretMint and confirm forms share the pseudo-entity but cannot take uploads. */
|
|
21
|
+
readonly actionFormScreenQn?: string;
|
|
15
22
|
};
|
|
16
23
|
export type EditFormMode = "create" | "update";
|
|
17
24
|
export declare function computeRelatedListSectionViewModel(sectionSpec: EditRelatedListSection, translate: Translate): EditRelatedListSectionViewModel;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { availableSelectOptions, evalFieldCondition, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, parseRefTarget, sectionFieldSpecs, WRITE_FORM_SECTION_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import { availableSelectOptions, evalFieldCondition, isExtensionEditSection, isFieldsEditSection, isFormDerivationInputMode, isUnusedFallbackOperand, isWriteFormEditSection, normalizeEditField, parseRefTarget, sectionFieldSpecs, WRITE_FORM_SECTION_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import { buildOptionLabels, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, } from "./list.js";
|
|
3
3
|
// Resolves a writeForm section's own fieldDefs/fields through the same
|
|
4
4
|
// per-field pipeline as a plain fields section, by reusing the pipeline
|
|
@@ -254,15 +254,20 @@ function deriveImageHints(fieldDef) {
|
|
|
254
254
|
...(fieldDef.capture !== undefined && { capture: fieldDef.capture }),
|
|
255
255
|
};
|
|
256
256
|
}
|
|
257
|
-
function deriveFileFieldHints(fieldDef, entityName, fieldName, entityId) {
|
|
257
|
+
function deriveFileFieldHints(fieldDef, entityName, fieldName, entityId, actionFormScreenQn) {
|
|
258
258
|
if (!isFileFieldType(fieldDef))
|
|
259
259
|
return {};
|
|
260
|
-
const
|
|
260
|
+
const isList = fieldDef.type === "files" || fieldDef.type === "images";
|
|
261
|
+
const maxCount = isList ? fieldDef.maxCount : undefined;
|
|
262
|
+
const fileDisplay = isList ? fieldDef.display : undefined;
|
|
261
263
|
return {
|
|
264
|
+
...(fileDisplay !== undefined && { fileDisplay }),
|
|
262
265
|
...(fieldDef.accept !== undefined && { accept: fieldDef.accept }),
|
|
263
266
|
...(fieldDef.maxSize !== undefined && { maxSize: fieldDef.maxSize }),
|
|
264
267
|
...(maxCount !== undefined && { maxCount }),
|
|
265
|
-
|
|
268
|
+
...(actionFormScreenQn !== undefined
|
|
269
|
+
? { uploadActionForm: actionFormScreenQn }
|
|
270
|
+
: { entityType: entityName }),
|
|
266
271
|
fieldName,
|
|
267
272
|
...(entityId !== undefined && { entityId }),
|
|
268
273
|
...deriveImageHints(fieldDef),
|
|
@@ -354,8 +359,17 @@ function buildExtensionSectionViewModel(sectionSpec, translate) {
|
|
|
354
359
|
// Pure transform from screen-def + entity-def + row-values to the flat
|
|
355
360
|
// section/field tree the renderer draws. FieldConditions are evaluated here
|
|
356
361
|
// so the renderer never re-runs them during React render.
|
|
362
|
+
// A computed field the user can't currently type into, or a fallback operand
|
|
363
|
+
// that the list rows make irrelevant: read-only and never blocking as required.
|
|
364
|
+
function isLockedByDerivation(derivation, values, field) {
|
|
365
|
+
if (derivation === undefined)
|
|
366
|
+
return false;
|
|
367
|
+
const isDerivedAndLocked = derivation.derive?.[field] !== undefined &&
|
|
368
|
+
!isFormDerivationInputMode(derivation, values, field);
|
|
369
|
+
return isDerivedAndLocked || isUnusedFallbackOperand(derivation, values, field);
|
|
370
|
+
}
|
|
357
371
|
export function computeEditViewModel(input) {
|
|
358
|
-
const { screen, entity, values, translate, featureName, mode, entityId } = input;
|
|
372
|
+
const { screen, entity, values, translate, featureName, mode, entityId, derivation, actionFormScreenQn, } = input;
|
|
359
373
|
const excludedFields = new Set(mode === undefined ? [] : screen.writeExcludedFields?.[mode]);
|
|
360
374
|
const isHiddenByExclusion = (fieldSpec) => mode === "create" && excludedFields.has(normalizeEditField(fieldSpec).field);
|
|
361
375
|
const sections = screen.layout.sections.map((sectionSpec) => {
|
|
@@ -399,14 +413,16 @@ export function computeEditViewModel(input) {
|
|
|
399
413
|
// `readOnly` (camelCase) is the name on both sides: EditFieldSpec
|
|
400
414
|
// in the engine, and the view-model emitted here. One convention
|
|
401
415
|
// through the stack beats translating at the boundary.
|
|
416
|
+
const lockedByDerivation = isLockedByDerivation(derivation, values, normalized.field);
|
|
402
417
|
const readOnly = (mode === "update" && excludedFields.has(normalized.field)) ||
|
|
418
|
+
lockedByDerivation ||
|
|
403
419
|
evalCondition(normalized.readOnly, false, values);
|
|
404
420
|
// `required` on the field-spec overrides the entity-default. A
|
|
405
421
|
// field that's required at the entity-level but marked required:
|
|
406
422
|
// false on the screen (e.g. a soft-onboarding wizard that
|
|
407
423
|
// collects less up-front) respects the screen override.
|
|
408
424
|
const entityRequired = fieldDef.required === true;
|
|
409
|
-
const required = evalCondition(normalized.required, entityRequired, values);
|
|
425
|
+
const required = !lockedByDerivation && evalCondition(normalized.required, entityRequired, values);
|
|
410
426
|
// ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
|
|
411
427
|
// framework/src/engine/field-helpers.ts — headless has no dependency
|
|
412
428
|
// on that module, so the literal is duplicated here instead of
|
|
@@ -417,7 +433,7 @@ export function computeEditViewModel(input) {
|
|
|
417
433
|
const dateHints = deriveDateFieldHints(fieldDef);
|
|
418
434
|
const numericHints = deriveNumericFieldHints(fieldDef, resolvedCurrency);
|
|
419
435
|
const referenceHints = deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, featureName);
|
|
420
|
-
const fileHints = deriveFileFieldHints(fieldDef, screen.entity, normalized.field, entityId);
|
|
436
|
+
const fileHints = deriveFileFieldHints(fieldDef, screen.entity, normalized.field, entityId, actionFormScreenQn);
|
|
421
437
|
const embeddedListHints = deriveEmbeddedListHints(fieldDef, translate, featureName, screen.entity, normalized.field, resolvedCurrency, normalized.embeddedList);
|
|
422
438
|
const view = {
|
|
423
439
|
field: normalized.field,
|
|
@@ -210,7 +210,12 @@ export type EditFieldViewModel = {
|
|
|
210
210
|
* upload POST (`/api/files`), so the endpoint can check maxSize/accept
|
|
211
211
|
* against the right field def. */
|
|
212
212
|
readonly entityType?: string;
|
|
213
|
+
/** Only for file fields of an actionForm — the screen QN the upload POST names as
|
|
214
|
+
* `actionForm` instead of `entityType`; the server resolves the field through that screen. */
|
|
215
|
+
readonly uploadActionForm?: string;
|
|
213
216
|
readonly fieldName?: string;
|
|
217
|
+
/** Only for `type: "files" | "images"` — how the field is shown. Absent = list. */
|
|
218
|
+
readonly fileDisplay?: "list" | "dropzone";
|
|
214
219
|
/** Record being edited (update mode only). Sent with the upload so the
|
|
215
220
|
* FileRef is attached to the record. */
|
|
216
221
|
readonly entityId?: string;
|
|
@@ -245,11 +250,14 @@ export type EditFieldViewModel = {
|
|
|
245
250
|
/** Sub-field name → how to compute its cell from other sub-fields of the
|
|
246
251
|
* same row. From EmbeddedFieldDef.derived. */
|
|
247
252
|
readonly embeddedListDerived?: Readonly<Record<string, {
|
|
248
|
-
readonly op: "multiply" | "sum" | "subtract";
|
|
253
|
+
readonly op: "multiply" | "sum" | "subtract" | "percent";
|
|
249
254
|
readonly from: readonly string[];
|
|
250
255
|
}>>;
|
|
251
|
-
/**
|
|
252
|
-
readonly embeddedListTotals?: readonly string
|
|
256
|
+
/** Footer totals: a sub-field name, or a labelled sum of several. From EmbeddedFieldDef.totals. */
|
|
257
|
+
readonly embeddedListTotals?: readonly (string | {
|
|
258
|
+
readonly label: string;
|
|
259
|
+
readonly from: readonly string[];
|
|
260
|
+
})[];
|
|
253
261
|
/** No add, remove, duplicate or move; cells stay editable. From
|
|
254
262
|
* EditFieldSpec.embeddedList.fixedRows. */
|
|
255
263
|
readonly embeddedListFixedRows?: boolean;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const LIVE_REFETCH_COALESCE_MS = 250;
|
|
2
|
+
export type LiveRefetchScheduler = {
|
|
3
|
+
readonly trigger: () => void;
|
|
4
|
+
readonly dispose: () => void;
|
|
5
|
+
};
|
|
6
|
+
export declare function createLiveRefetchScheduler(run: () => Promise<void> | void, windowMs?: number): LiveRefetchScheduler;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const LIVE_REFETCH_COALESCE_MS = 250;
|
|
2
|
+
// A burst of live events collapses into one trailing run. An event that lands
|
|
3
|
+
// while a run is still in flight queues exactly one follow-up after it.
|
|
4
|
+
export function createLiveRefetchScheduler(run, windowMs = LIVE_REFETCH_COALESCE_MS) {
|
|
5
|
+
let timer;
|
|
6
|
+
let inFlight = false;
|
|
7
|
+
let rerunQueued = false;
|
|
8
|
+
let disposed = false;
|
|
9
|
+
const fire = async () => {
|
|
10
|
+
timer = undefined;
|
|
11
|
+
inFlight = true;
|
|
12
|
+
try {
|
|
13
|
+
await run();
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
inFlight = false;
|
|
17
|
+
}
|
|
18
|
+
if (rerunQueued && !disposed) {
|
|
19
|
+
rerunQueued = false;
|
|
20
|
+
await fire();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
trigger: () => {
|
|
25
|
+
// skip: the subscriber is gone
|
|
26
|
+
if (disposed)
|
|
27
|
+
return;
|
|
28
|
+
if (inFlight) {
|
|
29
|
+
rerunQueued = true;
|
|
30
|
+
// skip: the queued follow-up run covers this event
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// skip: a trailing run is already scheduled and will cover this event
|
|
34
|
+
if (timer !== undefined)
|
|
35
|
+
return;
|
|
36
|
+
timer = setTimeout(() => void fire(), windowMs);
|
|
37
|
+
},
|
|
38
|
+
dispose: () => {
|
|
39
|
+
disposed = true;
|
|
40
|
+
if (timer !== undefined)
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
timer = undefined;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/hooks/use-query.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useDispatcher, useOptionalQueryPools } from "../context/dispatcher-context.js";
|
|
3
3
|
import { useLiveEvents } from "../sse/live-events.js";
|
|
4
|
+
import { createLiveRefetchScheduler } from "./live-refetch-scheduler.js";
|
|
4
5
|
// Extract the entity-name from a standard Kumiko query type. Returns
|
|
5
6
|
// undefined for non-conforming types so the live-mode silently skips
|
|
6
7
|
// them instead of subscribing to a channel no event will ever match.
|
|
@@ -125,9 +126,12 @@ export function useQuery(type, payload, options = {}) {
|
|
|
125
126
|
// skip: query type has no mapped entity, nothing to subscribe to
|
|
126
127
|
if (entity === undefined)
|
|
127
128
|
return;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
const scheduler = createLiveRefetchScheduler(() => runFetch(true));
|
|
130
|
+
const unsubscribe = subscribeLive(entity, scheduler.trigger);
|
|
131
|
+
return () => {
|
|
132
|
+
unsubscribe();
|
|
133
|
+
scheduler.dispose();
|
|
134
|
+
};
|
|
135
|
+
}, [live, enabled, type, runFetch, subscribeLive]);
|
|
132
136
|
return { data, error, loading, refetch: run };
|
|
133
137
|
}
|
|
@@ -66,9 +66,12 @@ export function useReferenceLookup(featureName, refEntity, labelField) {
|
|
|
66
66
|
}
|
|
67
67
|
// System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row, so
|
|
68
68
|
// the bulk lookup above never covers them — inject the label directly.
|
|
69
|
-
if (systemLabel !== undefined)
|
|
70
|
-
|
|
69
|
+
if (systemLabel !== undefined) {
|
|
70
|
+
for (const id of [systemLabel.id, ...(systemLabel.aliases ?? [])]) {
|
|
71
|
+
out.set(id, translate(systemLabel.labelKey));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
71
74
|
return out;
|
|
72
75
|
}, [result.data, labelKey, systemLabel, translate]);
|
|
73
|
-
return { map, loading: result.loading };
|
|
76
|
+
return { map, loading: result.loading, failed: result.error !== null };
|
|
74
77
|
}
|