@cosmicdrift/kumiko-renderer 0.383.1 → 0.385.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-body.d.ts +50 -0
- package/dist/app/action-form-body.js +275 -0
- package/dist/app/client-actions.js +2 -2
- package/dist/app/config-edit-body.d.ts +9 -0
- package/dist/app/config-edit-body.js +340 -0
- package/dist/app/entity-edit-prefill.d.ts +27 -0
- package/dist/app/entity-edit-prefill.js +377 -0
- package/dist/app/entity-edit-screen.d.ts +11 -0
- package/dist/app/entity-edit-screen.js +395 -0
- package/dist/app/entity-list-screen.d.ts +10 -0
- package/dist/app/entity-list-screen.js +547 -0
- package/dist/app/kumiko-screen.d.ts +1 -36
- package/dist/app/kumiko-screen.js +16 -2855
- package/dist/app/projection-detail-body.d.ts +12 -0
- package/dist/app/projection-detail-body.js +464 -0
- package/dist/app/projection-detail-shim.d.ts +1 -1
- package/dist/app/projection-detail-shim.js +5 -1
- package/dist/app/projection-list-body.d.ts +10 -0
- package/dist/app/projection-list-body.js +245 -0
- package/dist/app/projection-list-shim.d.ts +2 -1
- package/dist/app/projection-list-shim.js +4 -0
- package/dist/app/reference-facet-bridge.js +2 -2
- package/dist/app/row-actions.js +3 -4
- package/dist/app/screen-shared.d.ts +72 -0
- package/dist/app/screen-shared.js +325 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/components/edit-mounts.d.ts +33 -0
- package/dist/components/edit-mounts.js +53 -0
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +2 -2
- package/dist/components/render-edit-helpers.d.ts +29 -0
- package/dist/components/render-edit-helpers.js +121 -0
- package/dist/components/render-edit.d.ts +1 -4
- package/dist/components/render-edit.js +41 -443
- package/dist/components/render-field.js +3 -4
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-wizard-form-draft.d.ts +51 -0
- package/dist/components/use-wizard-form-draft.js +291 -0
- package/dist/dev-console.d.ts +3 -0
- package/dist/dev-console.js +15 -0
- package/dist/headless/contracts/primitives.js +14 -17
- package/dist/headless/format/index.js +25 -6
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +16 -17
- package/dist/headless/view-model/list.d.ts +1 -1
- package/dist/headless/view-model/list.js +3 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -5
- package/dist/live/error-mapping.js +3 -4
- package/dist/web/app/create-app.js +5 -8
- package/dist/web/app/dashboard-body.js +4 -599
- package/dist/web/app/dashboard-panels.d.ts +53 -0
- package/dist/web/app/dashboard-panels.js +373 -0
- package/dist/web/app/dashboard-shared.d.ts +90 -0
- package/dist/web/app/dashboard-shared.js +171 -0
- package/dist/web/app/dashboard-text.d.ts +6 -0
- package/dist/web/app/dashboard-text.js +65 -0
- package/dist/web/layout/nav-tree.js +3 -4
- package/dist/web/layout/target-resolver-stub.js +2 -2
- package/dist/web/primitives/banner.d.ts +4 -0
- package/dist/web/primitives/banner.js +28 -0
- package/dist/web/primitives/button.d.ts +7 -0
- package/dist/web/primitives/button.js +48 -0
- package/dist/web/primitives/calendar-day-picker.d.ts +13 -0
- package/dist/web/primitives/calendar-day-picker.js +38 -0
- package/dist/web/primitives/calendar-popover.d.ts +1 -1
- package/dist/web/primitives/calendar-popover.js +5 -39
- package/dist/web/primitives/card-styles.d.ts +8 -0
- package/dist/web/primitives/card-styles.js +18 -0
- package/dist/web/primitives/card.d.ts +4 -0
- package/dist/web/primitives/card.js +41 -0
- package/dist/web/primitives/contexts.d.ts +22 -0
- package/dist/web/primitives/contexts.js +50 -0
- package/dist/web/primitives/data-table-cell.d.ts +76 -0
- package/dist/web/primitives/data-table-cell.js +403 -0
- package/dist/web/primitives/data-table.d.ts +4 -0
- package/dist/web/primitives/data-table.js +279 -0
- package/dist/web/primitives/default-form.d.ts +4 -0
- package/dist/web/primitives/default-form.js +79 -0
- package/dist/web/primitives/facet-filter.d.ts +14 -0
- package/dist/web/primitives/facet-filter.js +39 -0
- package/dist/web/primitives/field.d.ts +63 -0
- package/dist/web/primitives/field.js +181 -0
- package/dist/web/primitives/file-thumbnail.js +69 -7
- package/dist/web/primitives/form.d.ts +53 -0
- package/dist/web/primitives/form.js +252 -0
- package/dist/web/primitives/index.d.ts +7 -30
- package/dist/web/primitives/index.js +27 -2005
- package/dist/web/primitives/input.d.ts +4 -0
- package/dist/web/primitives/input.js +166 -0
- package/dist/web/primitives/layout-elements.d.ts +14 -0
- package/dist/web/primitives/layout-elements.js +194 -0
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/pager.d.ts +22 -0
- package/dist/web/primitives/pager.js +89 -0
- package/dist/web/primitives/row-actions.d.ts +13 -0
- package/dist/web/primitives/row-actions.js +178 -0
- package/dist/web/primitives/secret-reveal.d.ts +4 -0
- package/dist/web/primitives/secret-reveal.js +67 -0
- package/dist/web/tokens.js +2 -1
- package/package.json +5 -5
- package/src/changes.json +24 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { rendererWarn } from "../dev-console.js";
|
|
4
|
+
// Qualified names of the bundled `form-draft` feature. Hardcoded because the
|
|
5
|
+
// renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
|
|
6
|
+
// with `layout.draft: true` and that feature unmounted is a boot error, so
|
|
7
|
+
// these can never dangle silently.
|
|
8
|
+
const FORM_DRAFT_GET = "form-draft:query:get";
|
|
9
|
+
const FORM_DRAFT_SAVE = "form-draft:write:save";
|
|
10
|
+
const FORM_DRAFT_DISCARD = "form-draft:write:discard";
|
|
11
|
+
const FORM_DRAFT_LIST = "form-draft:query:list";
|
|
12
|
+
// Mirrors bundled-features/src/form-draft/web/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
|
|
13
|
+
// — same hardcoding rationale as the QNs above (no dependency on that package).
|
|
14
|
+
const FORM_DRAFT_KEY_MAX_LENGTH = 256;
|
|
15
|
+
// Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
|
|
16
|
+
// patch() call (VIN-decode, an extension section) should not save immediately
|
|
17
|
+
// per call, but patch() can also fire from inside onChange on every keystroke
|
|
18
|
+
// (the #1888 derived-field shape) — 500ms collapses a typing burst into one
|
|
19
|
+
// save instead of one per keystroke, while still saving well before a user
|
|
20
|
+
// abandons the tab.
|
|
21
|
+
export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
22
|
+
// draftKey convention for a create-mode draft (framework-wizard-mode.md,
|
|
23
|
+
// lookup.ts): `${screenId}:new:${draftId}`. Shared by the mount-time list
|
|
24
|
+
// fallback (strip the prefix to recover a candidate's draftId) and the
|
|
25
|
+
// list query itself (filter out edit-mode drafts, which share the same
|
|
26
|
+
// `${screenId}:%` LIKE-prefix scan server-side).
|
|
27
|
+
function newDraftPrefix(screenId) {
|
|
28
|
+
return `${screenId}:new:`;
|
|
29
|
+
}
|
|
30
|
+
// `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
|
|
31
|
+
// without a polyfill — guard like generateRequestId in headless/dispatcher/request-id.ts.
|
|
32
|
+
function mintDraftId() {
|
|
33
|
+
const webCrypto = propertyOf(globalThis, "crypto");
|
|
34
|
+
const randomUUID = propertyOf(webCrypto, "randomUUID");
|
|
35
|
+
if (typeof randomUUID === "function") {
|
|
36
|
+
const id = Reflect.apply(randomUUID, webCrypto, []);
|
|
37
|
+
if (typeof id === "string")
|
|
38
|
+
return id;
|
|
39
|
+
}
|
|
40
|
+
return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
41
|
+
}
|
|
42
|
+
export function useWizardFormDraftState({ screenId, entityId, isCreateMode, draftStorage, }) {
|
|
43
|
+
// Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
|
|
44
|
+
// on mount so a same-tab reload finds the right one of several parallel
|
|
45
|
+
// create-sessions on this screen; `null` until the first step change
|
|
46
|
+
// mints one (saveDraft), or the mount-time `form-draft:query:list`
|
|
47
|
+
// fallback below adopts an existing one. Edit-mode never touches this —
|
|
48
|
+
// its draftKey is `${screenId}:${entityId}` unconditionally.
|
|
49
|
+
const [draftId, setDraftId] = useState(() => isCreateMode ? draftStorage.getDraftId(screenId) : null);
|
|
50
|
+
// Marks a draftId this instance minted itself (saveDraft, first step
|
|
51
|
+
// change) so the restore effect below doesn't immediately re-fetch what
|
|
52
|
+
// it just wrote — there is nothing to restore, the row is brand new.
|
|
53
|
+
const mintedDraftIdRef = useRef(null);
|
|
54
|
+
// Marks that the mount-time `form-draft:query:list` fallback already ran
|
|
55
|
+
// once for this mount. Without this, a create-mode discard resets `draftId`
|
|
56
|
+
// to `null` (see discardDraft below) and would re-arm the list effect —
|
|
57
|
+
// silently adopting an unrelated parallel draft on the same screen right
|
|
58
|
+
// after the user just submitted (#1908).
|
|
59
|
+
const didListRef = useRef(false);
|
|
60
|
+
const [draftCandidates, setDraftCandidates] = useState(null);
|
|
61
|
+
// Derived from the screen id + the host entity id only — never from
|
|
62
|
+
// `vm.id`, which lives in the form values a restore mutates (load under one
|
|
63
|
+
// key, save under another). Edit-mode: `${screenId}:${entityId}`,
|
|
64
|
+
// unchanged. Create-mode: `${screenId}:new:${draftId}` once a draftId
|
|
65
|
+
// exists (resumed, adopted, or minted), `undefined` before that — two
|
|
66
|
+
// parallel create sessions on the same screen must never collapse onto
|
|
67
|
+
// the same key (kumiko-framework#1908).
|
|
68
|
+
const draftKey = useMemo(() => isCreateMode
|
|
69
|
+
? draftId !== null
|
|
70
|
+
? `${screenId}:new:${draftId}`
|
|
71
|
+
: undefined
|
|
72
|
+
: `${screenId}:${entityId}`, [screenId, entityId, isCreateMode, draftId]);
|
|
73
|
+
return {
|
|
74
|
+
draftId,
|
|
75
|
+
setDraftId,
|
|
76
|
+
draftCandidates,
|
|
77
|
+
setDraftCandidates,
|
|
78
|
+
mintedDraftIdRef,
|
|
79
|
+
didListRef,
|
|
80
|
+
draftKey,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function useWizardFormDraftRestore({ screenId, isCreateMode, draftEnabled, dispatcher, controller, hasNavigatedRef, setRawStep, draftState: { draftId, draftKey, mintedDraftIdRef, didListRef, setDraftCandidates }, }) {
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
// skip: this screen does not persist a draft.
|
|
86
|
+
if (!draftEnabled || dispatcher === undefined)
|
|
87
|
+
return;
|
|
88
|
+
// skip: create-mode with no draftId yet — nothing to restore, either
|
|
89
|
+
// the list-fallback effect below finds one or the first step change
|
|
90
|
+
// mints a fresh one.
|
|
91
|
+
if (draftKey === undefined)
|
|
92
|
+
return;
|
|
93
|
+
// skip: this draftId was just minted by saveDraft — the row it wrote
|
|
94
|
+
// is exactly the current form state, re-fetching it would be a no-op
|
|
95
|
+
// round-trip at best and a stale-echo clobber at worst.
|
|
96
|
+
if (draftId !== null && draftId === mintedDraftIdRef.current)
|
|
97
|
+
return;
|
|
98
|
+
let cancelled = false;
|
|
99
|
+
void (async () => {
|
|
100
|
+
try {
|
|
101
|
+
const result = await dispatcher.query(FORM_DRAFT_GET, { draftKey });
|
|
102
|
+
// skip: superseded by a newer mount, or the draft lookup failed — an
|
|
103
|
+
// unreachable draft store must not break the form itself.
|
|
104
|
+
if (cancelled || !result.isSuccess)
|
|
105
|
+
return;
|
|
106
|
+
const draft = result.data?.draft ?? null;
|
|
107
|
+
// skip: nothing saved yet for this key.
|
|
108
|
+
if (draft === null)
|
|
109
|
+
return;
|
|
110
|
+
// skip: the user already typed or wizard-navigated while the lookup
|
|
111
|
+
// was in flight — their input wins over the stored draft.
|
|
112
|
+
if (controller.getSnapshot().isDirty || hasNavigatedRef.current)
|
|
113
|
+
return;
|
|
114
|
+
// @cast-boundary form-values draft blob: `values` round-trips through an opaque
|
|
115
|
+
// jsonb column and comes back untyped.
|
|
116
|
+
controller.setValues(draft.values);
|
|
117
|
+
setRawStep(Math.max(draft.stepIndex, 0));
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
rendererWarn("render-edit: draft restore query failed", err);
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
return () => {
|
|
124
|
+
cancelled = true;
|
|
125
|
+
};
|
|
126
|
+
}, [
|
|
127
|
+
draftEnabled,
|
|
128
|
+
draftKey,
|
|
129
|
+
draftId,
|
|
130
|
+
dispatcher,
|
|
131
|
+
controller,
|
|
132
|
+
mintedDraftIdRef,
|
|
133
|
+
hasNavigatedRef,
|
|
134
|
+
setRawStep,
|
|
135
|
+
]);
|
|
136
|
+
// Mount-time fallback (issue #1913) for create-mode when no draftId
|
|
137
|
+
// survived in storage (new tab, cleared storage): ask the server for this
|
|
138
|
+
// screen's open drafts and let the user choose via a picker (below) —
|
|
139
|
+
// even for exactly one candidate. Auto-adopting a lone candidate would
|
|
140
|
+
// silently hand this tab someone else's in-progress draft from a parallel
|
|
141
|
+
// session on the same screen (cross-tab hijack); the picker's "start new"
|
|
142
|
+
// action covers the common case (it really was this tab's own draft) with
|
|
143
|
+
// one extra click. Zero candidates → stay null, saveDraft mints a fresh
|
|
144
|
+
// draftId on the first step change same as any other fresh create.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!draftEnabled ||
|
|
147
|
+
!isCreateMode ||
|
|
148
|
+
draftId !== null ||
|
|
149
|
+
didListRef.current ||
|
|
150
|
+
dispatcher === undefined) {
|
|
151
|
+
// skip: this screen does not persist a draft, this is edit-mode, a
|
|
152
|
+
// draftId is already known (from storage or an earlier adoption), or
|
|
153
|
+
// this mount already ran the list lookup once (didListRef).
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let cancelled = false;
|
|
157
|
+
void (async () => {
|
|
158
|
+
try {
|
|
159
|
+
const result = await dispatcher.query(FORM_DRAFT_LIST, { screenId: screenId });
|
|
160
|
+
// skip: superseded by a newer mount, or the lookup failed — an
|
|
161
|
+
// unreachable draft store must not break a fresh create.
|
|
162
|
+
if (cancelled || !result.isSuccess)
|
|
163
|
+
return;
|
|
164
|
+
didListRef.current = true;
|
|
165
|
+
const prefix = newDraftPrefix(screenId);
|
|
166
|
+
// list's LIKE-prefix scan also matches edit-mode drafts
|
|
167
|
+
// (`${screenId}:${entityId}`) — keep only create-mode ones.
|
|
168
|
+
const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
|
|
169
|
+
// skip: no open drafts for this screen — stay null, saveDraft mints a
|
|
170
|
+
// fresh draftId on the first step change same as any other create.
|
|
171
|
+
if (candidates.length === 0)
|
|
172
|
+
return;
|
|
173
|
+
setDraftCandidates(candidates);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
rendererWarn("render-edit: draft list query failed", err);
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
return () => {
|
|
180
|
+
cancelled = true;
|
|
181
|
+
};
|
|
182
|
+
}, [draftEnabled, isCreateMode, draftId, dispatcher, screenId, didListRef, setDraftCandidates]);
|
|
183
|
+
}
|
|
184
|
+
export function buildWizardDraftActions({ screenId, isCreateMode, draftEnabled, disabled, dispatcher, draftStorage, controller, sections, draftSaveTimerRef, draftState: { draftId, draftKey, mintedDraftIdRef, setDraftId, setDraftCandidates }, }) {
|
|
185
|
+
// User picked one of several open drafts from the mount-time picker
|
|
186
|
+
// (see draftCandidates below) — adopt it the same way a single
|
|
187
|
+
// auto-adopted candidate would be.
|
|
188
|
+
function adoptDraft(candidate) {
|
|
189
|
+
// Locked state (#1896/fw#1909): `disabled` means "no write possible" —
|
|
190
|
+
// repointing draftKey and patching in the candidate's values is exactly
|
|
191
|
+
// that, so a locked form must not adopt a draft.
|
|
192
|
+
// skip: a locked form keeps its current draft (reason above).
|
|
193
|
+
if (disabled)
|
|
194
|
+
return;
|
|
195
|
+
const adoptedId = candidate.draftKey.slice(newDraftPrefix(screenId).length);
|
|
196
|
+
draftStorage.setDraftId(screenId, adoptedId);
|
|
197
|
+
setDraftId(adoptedId);
|
|
198
|
+
setDraftCandidates(null);
|
|
199
|
+
}
|
|
200
|
+
// Step transitions only — never per keystroke. Deliberately not awaited: a
|
|
201
|
+
// failed draft save must not block the step change.
|
|
202
|
+
//
|
|
203
|
+
// Create-mode, first draft save (step change OR a debounced patch() from
|
|
204
|
+
// controlled mode / extension sections, see patchAndScheduleDraftSave
|
|
205
|
+
// above — both call this on step 0 too): mints the draftId here (issue
|
|
206
|
+
// #1913) rather than at mount, so a form nobody ever interacted with
|
|
207
|
+
// never claims a draftId or writes a row. `draftKey`/`draftId` state
|
|
208
|
+
// won't reflect the mint until the next render, so the just-minted key is
|
|
209
|
+
// computed inline instead of read from the memoized `draftKey`.
|
|
210
|
+
function saveDraft(stepIndex) {
|
|
211
|
+
// skip: this screen does not persist a draft.
|
|
212
|
+
if (!draftEnabled || dispatcher === undefined)
|
|
213
|
+
return;
|
|
214
|
+
let key = draftKey;
|
|
215
|
+
if (isCreateMode && draftId === null) {
|
|
216
|
+
const mintedId = mintDraftId();
|
|
217
|
+
mintedDraftIdRef.current = mintedId;
|
|
218
|
+
draftStorage.setDraftId(screenId, mintedId);
|
|
219
|
+
setDraftId(mintedId);
|
|
220
|
+
key = `${screenId}:new:${mintedId}`;
|
|
221
|
+
// A stale picker from the mount-time list fallback must not survive a
|
|
222
|
+
// mint — picking a candidate afterwards would repoint draftKey at an
|
|
223
|
+
// unrelated draft mid-edit and overwrite it (#1908).
|
|
224
|
+
setDraftCandidates(null);
|
|
225
|
+
}
|
|
226
|
+
// skip: create-mode with a draftId that failed to mint — unreachable
|
|
227
|
+
// in practice (mint above always produces one), kept as a type-level
|
|
228
|
+
// guard against a stale `undefined` key ever reaching the write.
|
|
229
|
+
if (key === undefined)
|
|
230
|
+
return;
|
|
231
|
+
// draftKeySchema.max(FORM_DRAFT_KEY_MAX_LENGTH) rejects this server-side —
|
|
232
|
+
// catch it here instead of losing the save silently to the fire-and-forget
|
|
233
|
+
// write below (the user would only notice on resume, with nothing to resume).
|
|
234
|
+
if (key.length > FORM_DRAFT_KEY_MAX_LENGTH) {
|
|
235
|
+
rendererWarn(`RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screenId or entity id.`);
|
|
236
|
+
// skip: warned above, the oversized key would be rejected by the server anyway.
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const secretFieldNames = new Set(sections.flatMap((section) => section.kind === "fields"
|
|
240
|
+
? section.fields
|
|
241
|
+
.filter((f) => f.format === "password" || f.writeOnly === true || f.sensitive === true)
|
|
242
|
+
.map((f) => f.field)
|
|
243
|
+
: []));
|
|
244
|
+
// A password, writeOnly or sensitive field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
|
|
245
|
+
const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !secretFieldNames.has(field)));
|
|
246
|
+
void dispatcher
|
|
247
|
+
.write(FORM_DRAFT_SAVE, {
|
|
248
|
+
draftKey: key,
|
|
249
|
+
values: draftValues,
|
|
250
|
+
stepIndex,
|
|
251
|
+
})
|
|
252
|
+
.catch((err) => {
|
|
253
|
+
rendererWarn("render-edit: draft save failed", err);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async function discardDraft() {
|
|
257
|
+
// skip: this screen does not persist a draft.
|
|
258
|
+
if (!draftEnabled || dispatcher === undefined)
|
|
259
|
+
return;
|
|
260
|
+
// A pending debounced patch-save must not fire after discard — it would
|
|
261
|
+
// resurrect the draft it just deleted.
|
|
262
|
+
if (draftSaveTimerRef.current !== null) {
|
|
263
|
+
clearTimeout(draftSaveTimerRef.current);
|
|
264
|
+
draftSaveTimerRef.current = null;
|
|
265
|
+
}
|
|
266
|
+
// skip: create-mode, no step change happened yet — no draftId was ever
|
|
267
|
+
// minted, so no row exists to discard.
|
|
268
|
+
if (draftKey === undefined)
|
|
269
|
+
return;
|
|
270
|
+
// Best-effort: a rejected write (e.g. network error) must not propagate
|
|
271
|
+
// out of handleSubmit AFTER the entity write already succeeded — that
|
|
272
|
+
// would break onSubmit/navigation/toast and risk a duplicate submit on
|
|
273
|
+
// retry. An orphaned draft row is swept later by cleanup.job.ts. Local
|
|
274
|
+
// cleanup below still runs on failure so this instance doesn't try to
|
|
275
|
+
// resume a draft it already considers submitted.
|
|
276
|
+
try {
|
|
277
|
+
await dispatcher.write(FORM_DRAFT_DISCARD, { draftKey });
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// best-effort, see comment above.
|
|
281
|
+
}
|
|
282
|
+
// A successful submit ends this draftId's life — a subsequent create on
|
|
283
|
+
// the same screen (new mount) must mint its own, not resume this one.
|
|
284
|
+
if (isCreateMode) {
|
|
285
|
+
draftStorage.clearDraftId(screenId);
|
|
286
|
+
mintedDraftIdRef.current = null;
|
|
287
|
+
setDraftId(null);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { adoptDraft, saveDraft, discardDraft };
|
|
291
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The renderer has no logger; the browser console is the developer-facing
|
|
2
|
+
// channel. Resolved at call time (not bound at import) so tests can spy on
|
|
3
|
+
// console.* per test.
|
|
4
|
+
export function rendererWarn(...args) {
|
|
5
|
+
// biome-ignore lint/suspicious/noConsole: renderer has no logger, the browser console is the developer-facing channel
|
|
6
|
+
console.warn(...args);
|
|
7
|
+
}
|
|
8
|
+
export function rendererError(...args) {
|
|
9
|
+
// biome-ignore lint/suspicious/noConsole: renderer has no logger, the browser console is the developer-facing channel
|
|
10
|
+
console.error(...args);
|
|
11
|
+
}
|
|
12
|
+
export function rendererDebug(...args) {
|
|
13
|
+
// biome-ignore lint/suspicious/noConsole: renderer has no logger, the browser console is the developer-facing channel
|
|
14
|
+
console.debug(...args);
|
|
15
|
+
}
|
|
@@ -1,21 +1,18 @@
|
|
|
1
|
-
// Primitives-Contract — the minimal set of UI building blocks
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// contract and the app wires one of them into the renderer via context.
|
|
1
|
+
// Primitives-Contract — the minimal set of UI building blocks the
|
|
2
|
+
// standard-renderers (entityList, entityEdit, etc.) call into without knowing
|
|
3
|
+
// which implementation provides them. It is the DOM-free seam through which
|
|
4
|
+
// apps override primitives and tests substitute fakes, both via
|
|
5
|
+
// PrimitivesProvider.
|
|
7
6
|
//
|
|
8
|
-
// Why a contract
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// from entity-schema.
|
|
7
|
+
// Why a contract in headless: the view-model builder and form-controller
|
|
8
|
+
// produce metadata (field-type, validation-state, label, placeholder) that the
|
|
9
|
+
// renderer translates into primitive invocations. Having the contract shape
|
|
10
|
+
// here means the metadata can carry references to primitives by name
|
|
11
|
+
// ("text-input", "select") and the translation is a pure map-lookup on the
|
|
12
|
+
// renderer side, not a switch statement that re-derives UI intent from
|
|
13
|
+
// entity-schema.
|
|
16
14
|
//
|
|
17
15
|
// Runtime-free: every primitive is expressed as its input contract only
|
|
18
|
-
// (what props it takes). Concrete components live in
|
|
19
|
-
//
|
|
20
|
-
// imports them.
|
|
16
|
+
// (what props it takes). Concrete components live in web/primitives and
|
|
17
|
+
// headless never imports them.
|
|
21
18
|
export {};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Pure format utilities — no web or platform dependencies.
|
|
2
|
-
// Shared between renderer/web
|
|
2
|
+
// Shared between renderer/web and server-side tests.
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
4
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
|
+
import { rendererWarn } from "../../dev-console.js";
|
|
5
6
|
export const EMPTY_VALUE_PLACEHOLDER = "—";
|
|
6
7
|
function toPlainDate(raw) {
|
|
7
8
|
try {
|
|
@@ -68,11 +69,11 @@ function formatDateCell(value, type, opts) {
|
|
|
68
69
|
// No `locale` fallback to a browser API here (headless has none) — passing
|
|
69
70
|
// `undefined` to Intl.NumberFormat resolves the runtime's default locale,
|
|
70
71
|
// the same value a browser's navigator.language-based guess would produce.
|
|
71
|
-
function formatNumberCell(value, locale, grouping = true) {
|
|
72
|
+
function formatNumberCell(value, locale, grouping = true, fractionDigits = {}) {
|
|
72
73
|
if (typeof value !== "number" || !Number.isFinite(value))
|
|
73
74
|
return String(value);
|
|
74
75
|
try {
|
|
75
|
-
return new Intl.NumberFormat(locale, { useGrouping: grouping }).format(value);
|
|
76
|
+
return new Intl.NumberFormat(locale, { useGrouping: grouping, ...fractionDigits }).format(value);
|
|
76
77
|
}
|
|
77
78
|
catch {
|
|
78
79
|
// Malformed BCP-47 locale tag — mirrors formatDateCell's fallback above.
|
|
@@ -152,6 +153,25 @@ function stringSpec(spec, key) {
|
|
|
152
153
|
const v = spec[key];
|
|
153
154
|
return typeof v === "string" ? v : undefined;
|
|
154
155
|
}
|
|
156
|
+
const MAX_FRACTION_DIGITS = 20;
|
|
157
|
+
function fractionDigitSpec(spec, key) {
|
|
158
|
+
const v = spec[key];
|
|
159
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= MAX_FRACTION_DIGITS
|
|
160
|
+
? v
|
|
161
|
+
: undefined;
|
|
162
|
+
}
|
|
163
|
+
// Out-of-range or inverted options fall back to the Intl default instead of
|
|
164
|
+
// throwing: a bad schema option must not crash the screen it renders in.
|
|
165
|
+
function fractionDigitsSpec(spec) {
|
|
166
|
+
const min = fractionDigitSpec(spec, "minimumFractionDigits");
|
|
167
|
+
const max = fractionDigitSpec(spec, "maximumFractionDigits");
|
|
168
|
+
if (min !== undefined && max !== undefined && min > max)
|
|
169
|
+
return {};
|
|
170
|
+
return {
|
|
171
|
+
...(min !== undefined && { minimumFractionDigits: min }),
|
|
172
|
+
...(max !== undefined && { maximumFractionDigits: max }),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
155
175
|
function booleanSpec(spec, key) {
|
|
156
176
|
const v = spec[key];
|
|
157
177
|
return typeof v === "boolean" ? v : undefined;
|
|
@@ -197,7 +217,7 @@ export function applyFormatSpec(spec, value, translate) {
|
|
|
197
217
|
case "number":
|
|
198
218
|
case "decimal":
|
|
199
219
|
case "bigInt":
|
|
200
|
-
return formatNumberCell(value, stringSpec(spec, "locale"), booleanSpec(spec, "grouping") ?? true);
|
|
220
|
+
return formatNumberCell(value, stringSpec(spec, "locale"), booleanSpec(spec, "grouping") ?? true, spec.format === "bigInt" ? {} : fractionDigitsSpec(spec));
|
|
201
221
|
case "unit":
|
|
202
222
|
return formatUnitCell(value, stringSpec(spec, "unit"), stringSpec(spec, "locale"), unitDisplaySpec(spec));
|
|
203
223
|
case "priority": {
|
|
@@ -229,8 +249,7 @@ export function applyFormatSpec(spec, value, translate) {
|
|
|
229
249
|
return stringifyJsonOrString(value, typeof spec["indent"] === "number" ? spec["indent"] : 2);
|
|
230
250
|
default:
|
|
231
251
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
232
|
-
|
|
233
|
-
console.warn(`[kumiko] applyFormatSpec: unknown format key "${spec.format}" — registered via FieldFormatRegistry module augmentation?`);
|
|
252
|
+
rendererWarn(`[kumiko] applyFormatSpec: unknown format key "${spec.format}" — registered via FieldFormatRegistry module augmentation?`);
|
|
234
253
|
}
|
|
235
254
|
return typeof value === "string" ? value : String(value);
|
|
236
255
|
}
|
|
@@ -2,7 +2,7 @@ import type { EditRelatedListSection, EntityDefinition, EntityEditScreenDefiniti
|
|
|
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;
|
|
5
|
-
readonly entity: EntityDefinition
|
|
5
|
+
readonly entity: Pick<EntityDefinition, "fields" | "defaultCurrency">;
|
|
6
6
|
readonly values: TValues;
|
|
7
7
|
readonly translate: Translate;
|
|
8
8
|
readonly featureName: string;
|
|
@@ -42,6 +42,12 @@ function computeWriteFormSectionViewModel(sectionSpec, screenId, values, transla
|
|
|
42
42
|
...(sectionSpec.actions !== undefined && { actions: sectionSpec.actions }),
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
// A section whose only content is an action (a conditional field plus a delete button) stays on the screen.
|
|
46
|
+
function hasVisibleContent(fields, actions, values) {
|
|
47
|
+
if (fields.some((field) => field.visible))
|
|
48
|
+
return true;
|
|
49
|
+
return (actions ?? []).some((action) => action.visible === undefined || evalFieldCondition(action.visible, values));
|
|
50
|
+
}
|
|
45
51
|
function wasEmptiedByWriteExclusion(declaredFieldSpecs, remainingFields) {
|
|
46
52
|
return declaredFieldSpecs.length > 0 && remainingFields.length === 0;
|
|
47
53
|
}
|
|
@@ -178,11 +184,12 @@ function deriveTextFieldHints(fieldDef) {
|
|
|
178
184
|
...("sensitive" in fieldDef && fieldDef.sensitive === true && { sensitive: true }),
|
|
179
185
|
};
|
|
180
186
|
}
|
|
187
|
+
// files/images field defs carry no `required` property.
|
|
188
|
+
function isRequiredByEntity(fieldDef) {
|
|
189
|
+
return "required" in fieldDef && fieldDef.required === true;
|
|
190
|
+
}
|
|
181
191
|
function deriveDateFieldHints(fieldDef) {
|
|
182
|
-
const wallClock = fieldDef.type === "timestamp" &&
|
|
183
|
-
fieldDef.locatedBy !== undefined
|
|
184
|
-
? true
|
|
185
|
-
: undefined;
|
|
192
|
+
const wallClock = fieldDef.type === "timestamp" && fieldDef.locatedBy !== undefined ? true : undefined;
|
|
186
193
|
const dateBounds = fieldDef.type === "date" ||
|
|
187
194
|
fieldDef.type === "timestamp" ||
|
|
188
195
|
fieldDef.type === "locatedTimestamp"
|
|
@@ -206,9 +213,7 @@ function deriveNumericFieldHints(fieldDef, resolvedCurrency) {
|
|
|
206
213
|
// Declared reference metadata (EditFieldSpec.refEntity) takes priority over
|
|
207
214
|
// fieldDef.type — see computeEditViewModel's effectiveType, same reasoning.
|
|
208
215
|
function deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, featureName) {
|
|
209
|
-
const refRaw = declaredRefTarget === undefined && fieldDef.type === "reference"
|
|
210
|
-
? fieldDef.entity
|
|
211
|
-
: undefined;
|
|
216
|
+
const refRaw = declaredRefTarget === undefined && fieldDef.type === "reference" ? fieldDef.entity : undefined;
|
|
212
217
|
const refTarget = declaredRefTarget ?? (refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined);
|
|
213
218
|
const refLabelField = declaredRefTarget !== undefined
|
|
214
219
|
? (normalized.refLabelField ?? "id")
|
|
@@ -226,9 +231,7 @@ function deriveReferenceFieldHints(fieldDef, normalized, declaredRefTarget, feat
|
|
|
226
231
|
// (it targets row-meta/derived fields, always single-valued and resolved
|
|
227
232
|
// through the target entity) — only a real ReferenceFieldDef carries them.
|
|
228
233
|
function deriveReferenceMultiHints(fieldDef, declaredRefTarget) {
|
|
229
|
-
const ownReferenceDef = declaredRefTarget === undefined && fieldDef.type === "reference"
|
|
230
|
-
? fieldDef
|
|
231
|
-
: undefined;
|
|
234
|
+
const ownReferenceDef = declaredRefTarget === undefined && fieldDef.type === "reference" ? fieldDef : undefined;
|
|
232
235
|
const refMultiple = ownReferenceDef === undefined ? undefined : (ownReferenceDef.multiple ?? false);
|
|
233
236
|
return {
|
|
234
237
|
...(ownReferenceDef?.optionsQuery !== undefined && {
|
|
@@ -312,11 +315,7 @@ function buildEmbeddedListCell(subFieldName, subField, translate, featureName, e
|
|
|
312
315
|
// apart by whether embeddedListCells is set, not by `type` (which stays
|
|
313
316
|
// "embedded" either way).
|
|
314
317
|
function deriveEmbeddedListHints(fieldDef, translate, featureName, entityName, fieldName, resolvedCurrency, listSpec) {
|
|
315
|
-
const
|
|
316
|
-
fieldDef.multiple === true;
|
|
317
|
-
const embeddedListDef = isEmbeddedList
|
|
318
|
-
? fieldDef
|
|
319
|
-
: undefined;
|
|
318
|
+
const embeddedListDef = fieldDef.type === "embedded" && fieldDef.multiple === true ? fieldDef : undefined;
|
|
320
319
|
const embeddedListCells = embeddedListDef !== undefined
|
|
321
320
|
? Object.entries(embeddedListDef.schema).map(([subFieldName, subField]) => buildEmbeddedListCell(subFieldName, subField, translate, featureName, entityName, fieldName, listSpec))
|
|
322
321
|
: undefined;
|
|
@@ -421,7 +420,7 @@ export function computeEditViewModel(input) {
|
|
|
421
420
|
// field that's required at the entity-level but marked required:
|
|
422
421
|
// false on the screen (e.g. a soft-onboarding wizard that
|
|
423
422
|
// collects less up-front) respects the screen override.
|
|
424
|
-
const entityRequired = fieldDef
|
|
423
|
+
const entityRequired = isRequiredByEntity(fieldDef);
|
|
425
424
|
const required = !lockedByDerivation && evalCondition(normalized.required, entityRequired, values);
|
|
426
425
|
// ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
|
|
427
426
|
// framework/src/engine/field-helpers.ts — headless has no dependency
|
|
@@ -459,7 +458,7 @@ export function computeEditViewModel(input) {
|
|
|
459
458
|
});
|
|
460
459
|
// The boot-validator rejects declared-empty sections (screens.ts), but a
|
|
461
460
|
// create-excluded field list can still empty one here: then `visible` is false.
|
|
462
|
-
const visible = fields.
|
|
461
|
+
const visible = hasVisibleContent(fields, sectionSpec.actions, values);
|
|
463
462
|
// Tabs mode renders this section as its own panel, where two columns reads
|
|
464
463
|
// better than the single-column default a stacked form keeps. Applies to
|
|
465
464
|
// entityEdit tabs as well since fw#3134, not just projectionDetail.
|
|
@@ -2,7 +2,7 @@ import type { EntityDefinition, EntityListScreenDefinition } from "@cosmicdrift/
|
|
|
2
2
|
import type { ListViewModel, Translate } from "./types.ts";
|
|
3
3
|
export type ComputeListViewModelInput = {
|
|
4
4
|
readonly screen: EntityListScreenDefinition;
|
|
5
|
-
readonly entity: EntityDefinition
|
|
5
|
+
readonly entity: Pick<EntityDefinition, "fields" | "derivedFields">;
|
|
6
6
|
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
7
7
|
readonly translate: Translate;
|
|
8
8
|
readonly featureName: string;
|
|
@@ -113,15 +113,11 @@ export function computeListViewModel(input) {
|
|
|
113
113
|
// sein. parseRefTarget gibt (featureName, entityName), der
|
|
114
114
|
// Renderer baut die Lookup-QN als
|
|
115
115
|
// `<refFeature>:query:<refEntity>:list`.
|
|
116
|
-
const refRaw = fieldDef.type === "reference"
|
|
117
|
-
? fieldDef.entity
|
|
118
|
-
: undefined;
|
|
116
|
+
const refRaw = fieldDef.type === "reference" ? fieldDef.entity : undefined;
|
|
119
117
|
const refTarget = refRaw !== undefined ? parseRefTarget(refRaw, featureName) : undefined;
|
|
120
118
|
const refEntity = refTarget?.entityName;
|
|
121
119
|
const refFeature = refTarget?.featureName;
|
|
122
|
-
const refLabelField = fieldDef.type === "reference"
|
|
123
|
-
? (fieldDef.labelField ?? "id")
|
|
124
|
-
: undefined;
|
|
120
|
+
const refLabelField = fieldDef.type === "reference" ? (fieldDef.labelField ?? "id") : undefined;
|
|
125
121
|
// For select and multiSelect fields: bake in translated option labels.
|
|
126
122
|
// Convention matches the form path → one translation source for list +
|
|
127
123
|
// form. A missing key returns the key itself per convention; the
|
|
@@ -224,6 +220,5 @@ export function buildOptionLabels(translate, keyFor, options) {
|
|
|
224
220
|
// (ORDER BY safety) and by the UI (column header click indicator). All
|
|
225
221
|
// field types can bear the flag; it's off by default.
|
|
226
222
|
function fieldIsSortable(field) {
|
|
227
|
-
|
|
228
|
-
return flag === true;
|
|
223
|
+
return "sortable" in field && field.sortable === true;
|
|
229
224
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type { AppSchema, FeatureSchema, QualifiedContentCollection, WorkspaceSch
|
|
|
23
23
|
export { isAppSchema, toAppSchema } from "./app/feature-schema.ts";
|
|
24
24
|
export { HeaderActionsBar } from "./app/header-actions-bar.tsx";
|
|
25
25
|
export type { KumikoScreenProps } from "./app/kumiko-screen.tsx";
|
|
26
|
-
export { KumikoScreen, qualifyNavId, qualifyScreenId
|
|
26
|
+
export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen.tsx";
|
|
27
27
|
export type { DateRangeBound, DateRangeValue, ResolvedDateRangeFacet } from "./app/list-facets.ts";
|
|
28
28
|
export { buildDateRangePayload, clampDateRange, readDateRange, resolveDateRangeFacets, } from "./app/list-facets.ts";
|
|
29
29
|
export type { NavApi, NavigateOptions, NavProviderProps, NavRoute, NavTarget, ObjectTarget, ScreenTarget, } from "./app/nav.tsx";
|
|
@@ -31,6 +31,7 @@ export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, use
|
|
|
31
31
|
export { lastSegment } from "./app/qn.ts";
|
|
32
32
|
export type { ReturnHost, ReturnTo } from "./app/return-to.tsx";
|
|
33
33
|
export { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, RETURN_TO_PARAM, ReturnHostProvider, resolveReturnTarget, splitReturnTo, useReturnHost, useReturnTarget, } from "./app/return-to.tsx";
|
|
34
|
+
export { ScreenMissingState } from "./app/screen-shared.tsx";
|
|
34
35
|
export type { EmbeddedScreenTarget } from "./app/use-embedded-screen.ts";
|
|
35
36
|
export { useEmbeddedScreen } from "./app/use-embedded-screen.ts";
|
|
36
37
|
export { useRecordActions } from "./app/use-record-actions.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Platform-agnostic React renderer for Kumiko screens. Das ist der
|
|
2
|
-
// Shared-Layer: Components, Hooks, Contexts, Types.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// in diesen Baum.
|
|
2
|
+
// Shared-Layer: Components, Hooks, Contexts, Types. Die Web-DOM-Impls
|
|
3
|
+
// leben unter ./web und reichen ihre konkreten Primitives/Nav/SSE-Impls via
|
|
4
|
+
// Provider in diesen Baum.
|
|
6
5
|
//
|
|
7
6
|
// Direct consumers of this layer: platform bindings
|
|
8
7
|
// (@cosmicdrift/kumiko-renderer/web) or an app writing its own bootstrap.
|
|
@@ -22,11 +21,12 @@ export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/ex
|
|
|
22
21
|
export { ExtensionSectionsProvider, extensionSectionName, useExtensionSectionComponent, } from "./app/extension-sections.js";
|
|
23
22
|
export { isAppSchema, toAppSchema } from "./app/feature-schema.js";
|
|
24
23
|
export { HeaderActionsBar } from "./app/header-actions-bar.js";
|
|
25
|
-
export { KumikoScreen, qualifyNavId, qualifyScreenId
|
|
24
|
+
export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen.js";
|
|
26
25
|
export { buildDateRangePayload, clampDateRange, readDateRange, resolveDateRangeFacets, } from "./app/list-facets.js";
|
|
27
26
|
export { formatPath, hasDetailScreen, NavProvider, parsePath, resolveTarget, useNav, useNavigateWithInitialValues, useOptionalNav, } from "./app/nav.js";
|
|
28
27
|
export { lastSegment } from "./app/qn.js";
|
|
29
28
|
export { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, RETURN_TO_PARAM, ReturnHostProvider, resolveReturnTarget, splitReturnTo, useReturnHost, useReturnTarget, } from "./app/return-to.js";
|
|
29
|
+
export { ScreenMissingState } from "./app/screen-shared.js";
|
|
30
30
|
export { useEmbeddedScreen } from "./app/use-embedded-screen.js";
|
|
31
31
|
export { useRecordActions } from "./app/use-record-actions.js";
|
|
32
32
|
export { VariableChips } from "./app/variable-chips.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { rendererWarn } from "../dev-console.js";
|
|
2
3
|
// Narrow cast into the DispatcherError shape. Pass-through for everything
|
|
3
4
|
// except `details.fields`, which we normalize: the server uses
|
|
4
5
|
// ValidationFieldIssue (path/code/i18nKey/params), which is structurally
|
|
@@ -33,16 +34,14 @@ function normalizeDetails(details) {
|
|
|
33
34
|
// Server sent a non-object entry in details.fields — contract
|
|
34
35
|
// breach (ValidationFieldIssue shape is required). Warn so the
|
|
35
36
|
// skip doesn't hide a broken server build.
|
|
36
|
-
|
|
37
|
-
console.warn("[dispatcher-live] dropping malformed field issue (not an object):", r);
|
|
37
|
+
rendererWarn("[dispatcher-live] dropping malformed field issue (not an object):", r);
|
|
38
38
|
continue;
|
|
39
39
|
}
|
|
40
40
|
if (typeof r["path"] !== "string" ||
|
|
41
41
|
typeof r["code"] !== "string" ||
|
|
42
42
|
typeof r["i18nKey"] !== "string") {
|
|
43
43
|
// Entry is an object but missing required keys — same reasoning.
|
|
44
|
-
|
|
45
|
-
console.warn("[dispatcher-live] dropping malformed field issue (missing keys):", r);
|
|
44
|
+
rendererWarn("[dispatcher-live] dropping malformed field issue (missing keys):", r);
|
|
46
45
|
continue;
|
|
47
46
|
}
|
|
48
47
|
mappedFields.push({
|