@cosmicdrift/kumiko-renderer 0.336.0 → 0.337.1
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/kumiko-screen.js +104 -18
- package/dist/app/list-facets.js +7 -1
- package/dist/app/return-to.js +4 -1
- package/dist/app/secrets-edit-body.js +22 -2
- package/dist/components/embedded-list-field.d.ts +1 -1
- package/dist/components/embedded-list-field.js +12 -2
- package/dist/components/related-list-section.js +10 -8
- package/dist/components/render-edit-logic.d.ts +4 -1
- package/dist/components/render-edit-logic.js +25 -5
- package/dist/components/render-edit.js +36 -19
- package/dist/components/render-field.js +7 -4
- package/dist/components/render-list.js +12 -3
- package/dist/components/write-form-section.js +2 -3
- package/dist/primitives.d.ts +9 -6
- package/dist/sse/live-events.d.ts +4 -2
- package/package.json +5 -5
- package/src/changes.json +38 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import { buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { applyFormatSpec, buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
5
5
|
import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
|
|
6
6
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { Temporal } from "temporal-polyfill";
|
|
7
8
|
import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
|
|
8
9
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
|
|
9
10
|
import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
|
|
@@ -316,14 +317,9 @@ function formatSummaryValue(value, locale) {
|
|
|
316
317
|
return new Intl.NumberFormat(locale, { style: "currency", currency: value.currency }).format(value.amount);
|
|
317
318
|
}
|
|
318
319
|
if (typeof value === "number")
|
|
319
|
-
return
|
|
320
|
+
return applyFormatSpec({ format: "number", locale }, value);
|
|
320
321
|
if (typeof value === "string" && ISO_DATE_PATTERN.test(value)) {
|
|
321
|
-
return
|
|
322
|
-
year: "numeric",
|
|
323
|
-
month: "2-digit",
|
|
324
|
-
day: "2-digit",
|
|
325
|
-
timeZone: "UTC",
|
|
326
|
-
});
|
|
322
|
+
return applyFormatSpec({ format: "date", locale }, value);
|
|
327
323
|
}
|
|
328
324
|
return value === null || value === undefined ? "" : String(value);
|
|
329
325
|
}
|
|
@@ -365,6 +361,8 @@ function coerceMoneyValue(raw, fieldName, defaultCurrency) {
|
|
|
365
361
|
warnPrefillParam(`money field "${fieldName}" got an object without a usable {amount, currency} shape — the prefill is ignored.`);
|
|
366
362
|
return undefined;
|
|
367
363
|
}
|
|
364
|
+
if (typeof value === "string" && value.trim() === "")
|
|
365
|
+
return undefined;
|
|
368
366
|
const amount = Number(typeof value === "number" ? value : raw);
|
|
369
367
|
if (!Number.isFinite(amount))
|
|
370
368
|
return undefined;
|
|
@@ -373,12 +371,38 @@ function coerceMoneyValue(raw, fieldName, defaultCurrency) {
|
|
|
373
371
|
warnPrefillParam(`money field "${fieldName}" was prefilled with a bare number and no currency is available — pass {"amount":<number>,"currency":"<ISO code>"} as the param value, otherwise the handler's schema rejects the submit.`);
|
|
374
372
|
return amount;
|
|
375
373
|
}
|
|
374
|
+
// Mirrors isRepresentableAtScale in the framework's schema-builder, which the
|
|
375
|
+
// renderer cannot import (server engine). Keep the tolerance identical so a
|
|
376
|
+
// prefilled cell never passes here and fails the handler's schema.
|
|
377
|
+
function isRepresentableAtScale(value, scale) {
|
|
378
|
+
const scaled = value * 10 ** scale;
|
|
379
|
+
const tolerance = Math.abs(scaled) * 8 * Number.EPSILON + Number.EPSILON;
|
|
380
|
+
return Math.abs(scaled - Math.round(scaled)) <= tolerance;
|
|
381
|
+
}
|
|
382
|
+
function isIsoInstant(value) {
|
|
383
|
+
try {
|
|
384
|
+
Temporal.Instant.from(value);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
376
391
|
const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
|
|
377
392
|
function coerceEmbeddedCell(value, cell) {
|
|
378
393
|
switch (cell.type) {
|
|
379
394
|
case "number":
|
|
380
|
-
case "decimal":
|
|
381
395
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
396
|
+
case "decimal":
|
|
397
|
+
return typeof value === "number" &&
|
|
398
|
+
Number.isFinite(value) &&
|
|
399
|
+
(cell.scale === undefined || isRepresentableAtScale(value, cell.scale))
|
|
400
|
+
? value
|
|
401
|
+
: undefined;
|
|
402
|
+
case "date":
|
|
403
|
+
return typeof value === "string" && ISO_DATE_PATTERN.test(value) ? value : undefined;
|
|
404
|
+
case "timestamp":
|
|
405
|
+
return typeof value === "string" && isIsoInstant(value) ? value : undefined;
|
|
382
406
|
case "money":
|
|
383
407
|
// Row cells are signed minor units; the currency lives on the head, so a
|
|
384
408
|
// top-level `{amount, currency}` in major units would land 100x off.
|
|
@@ -428,7 +452,7 @@ function coerceEmbeddedListRows(raw, fieldName, schema, maxItems) {
|
|
|
428
452
|
function coercePrefillString(raw, name, shape, fallback, defaultCurrency, moneyCurrencyOverrides) {
|
|
429
453
|
if (shape.type === "number") {
|
|
430
454
|
const parsed = Number(raw);
|
|
431
|
-
return Number.
|
|
455
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
432
456
|
}
|
|
433
457
|
if (shape.type === "money") {
|
|
434
458
|
const coerced = coerceMoneyValue(raw, name, moneyCurrencyOverrides?.[name] ?? defaultCurrency);
|
|
@@ -706,6 +730,8 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
706
730
|
const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
|
|
707
731
|
// Never return onto the just-deleted record.
|
|
708
732
|
const target = deletedShownRecord && returnTarget?.entityId === entityId ? undefined : returnTarget;
|
|
733
|
+
if (deletedShownRecord)
|
|
734
|
+
onDeleted?.();
|
|
709
735
|
if (action.redirect !== undefined) {
|
|
710
736
|
navigateAfterRedirect({
|
|
711
737
|
nav,
|
|
@@ -718,9 +744,24 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
718
744
|
});
|
|
719
745
|
return;
|
|
720
746
|
}
|
|
721
|
-
navigateToReturnOr(nav, target,
|
|
722
|
-
|
|
723
|
-
|
|
747
|
+
navigateToReturnOr(nav, target, () => {
|
|
748
|
+
if (findEntityListScreen(schema, screen.entity) !== undefined)
|
|
749
|
+
navigateToList();
|
|
750
|
+
else if (onDeleted === undefined)
|
|
751
|
+
void onReload();
|
|
752
|
+
});
|
|
753
|
+
}, [
|
|
754
|
+
nav,
|
|
755
|
+
schema,
|
|
756
|
+
appFeatures,
|
|
757
|
+
returnTarget,
|
|
758
|
+
record,
|
|
759
|
+
entityId,
|
|
760
|
+
navigateToList,
|
|
761
|
+
onDeleted,
|
|
762
|
+
onReload,
|
|
763
|
+
screen.entity,
|
|
764
|
+
]);
|
|
724
765
|
const buildSectionActions = useCallback((actions) => buildRecordActions({
|
|
725
766
|
actions,
|
|
726
767
|
record,
|
|
@@ -959,9 +1000,10 @@ function DrawerHost({ schema, drawerAction, drawerScreen, drawerInitialValues, u
|
|
|
959
1000
|
closeAndReset();
|
|
960
1001
|
}, [hasUnsavedInput, closeAndReset]);
|
|
961
1002
|
// Drawer is an optional Core-Primitive (additive rollout) — same "skip +
|
|
962
|
-
// warn
|
|
963
|
-
//
|
|
964
|
-
//
|
|
1003
|
+
// warn" precedent as rowActions without a mounted DispatcherProvider above,
|
|
1004
|
+
// instead of crashing when a web app hasn't upgraded its createKumikoApp
|
|
1005
|
+
// wiring yet. The warning repeats per mount and whenever the action changes.
|
|
1006
|
+
// Dev-time setup error, so no dedupe.
|
|
965
1007
|
useEffect(() => {
|
|
966
1008
|
if (drawerAction !== null && Drawer === undefined) {
|
|
967
1009
|
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
@@ -1757,20 +1799,29 @@ function primaryHeaderAction(actions) {
|
|
|
1757
1799
|
function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
|
|
1758
1800
|
const t = useTranslation();
|
|
1759
1801
|
const [pendingAction, setPendingAction] = useState(null);
|
|
1802
|
+
// Menu items have no button of their own to carry RenderEditActionButton's
|
|
1803
|
+
// busy state; without it the menu can be reopened mid-write and fire the
|
|
1804
|
+
// same writeHandler twice.
|
|
1805
|
+
const [busyActionId, setBusyActionId] = useState(null);
|
|
1760
1806
|
const trigger = async (action) => {
|
|
1761
1807
|
onError(null);
|
|
1808
|
+
setBusyActionId(action.id);
|
|
1762
1809
|
try {
|
|
1763
1810
|
await action.onPress();
|
|
1764
1811
|
}
|
|
1765
1812
|
catch (e) {
|
|
1766
1813
|
onError(e instanceof Error ? e.message : String(e));
|
|
1767
1814
|
}
|
|
1815
|
+
finally {
|
|
1816
|
+
setBusyActionId(null);
|
|
1817
|
+
}
|
|
1768
1818
|
};
|
|
1769
1819
|
const toMenuItem = (action) => ({
|
|
1770
1820
|
id: action.id,
|
|
1771
1821
|
label: action.label,
|
|
1772
1822
|
...(action.icon !== undefined && { icon: action.icon }),
|
|
1773
1823
|
variant: action.style === "danger" ? "danger" : "default",
|
|
1824
|
+
...(busyActionId !== null && { disabled: true }),
|
|
1774
1825
|
onSelect: () => {
|
|
1775
1826
|
if (needsActionConfirm(action)) {
|
|
1776
1827
|
setPendingAction(action);
|
|
@@ -2078,7 +2129,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2078
2129
|
status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
|
|
2079
2130
|
}), ...(hasHeaderActions && {
|
|
2080
2131
|
actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader }) })),
|
|
2081
|
-
}) }), MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
|
|
2132
|
+
}) }), MetricBand === undefined && headerSubtitleSlot, MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
|
|
2082
2133
|
(MetricBand !== undefined || hasMetrics) &&
|
|
2083
2134
|
metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
|
|
2084
2135
|
...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
|
|
@@ -2309,6 +2360,32 @@ function isWriteOnlyTextField(fieldDef) {
|
|
|
2309
2360
|
function hasStoredValue(stored) {
|
|
2310
2361
|
return stored !== undefined && stored !== null && stored !== "";
|
|
2311
2362
|
}
|
|
2363
|
+
const CONFIG_WRITE_ISSUE_PATH = "value";
|
|
2364
|
+
// write-helpers report every issue at `value`; in a batch that path is
|
|
2365
|
+
// meaningless to the form, so point it at the field whose command failed.
|
|
2366
|
+
function mapFailedConfigCommandIssuesToField(error, failedIndex, commandFieldNames) {
|
|
2367
|
+
const fieldName = commandFieldNames[failedIndex];
|
|
2368
|
+
const fields = error.details?.fields;
|
|
2369
|
+
if (fieldName === undefined || fields === undefined)
|
|
2370
|
+
return error;
|
|
2371
|
+
return {
|
|
2372
|
+
...error,
|
|
2373
|
+
details: {
|
|
2374
|
+
...error.details,
|
|
2375
|
+
fields: fields.map((issue) => {
|
|
2376
|
+
if (issue.path === CONFIG_WRITE_ISSUE_PATH)
|
|
2377
|
+
return { ...issue, path: fieldName };
|
|
2378
|
+
if (issue.path.startsWith(`${CONFIG_WRITE_ISSUE_PATH}.`)) {
|
|
2379
|
+
return {
|
|
2380
|
+
...issue,
|
|
2381
|
+
path: `${fieldName}${issue.path.slice(CONFIG_WRITE_ISSUE_PATH.length)}`,
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
return issue;
|
|
2385
|
+
}),
|
|
2386
|
+
},
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2312
2389
|
function ConfigEditBody({ schema, screen, translate, }) {
|
|
2313
2390
|
const { ConfigCascadeView } = usePrimitives();
|
|
2314
2391
|
const t = useTranslation();
|
|
@@ -2397,6 +2474,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2397
2474
|
// Write durch ist, das Form bleibt dirty.
|
|
2398
2475
|
const customSubmit = useCallback(async (snapshot) => {
|
|
2399
2476
|
const commands = [];
|
|
2477
|
+
const commandFieldNames = [];
|
|
2400
2478
|
for (const [shortName, value] of Object.entries(snapshot.changes)) {
|
|
2401
2479
|
const qualified = screen.configKeys[shortName];
|
|
2402
2480
|
if (qualified === undefined)
|
|
@@ -2406,12 +2484,14 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2406
2484
|
if (isWriteOnlyTextField(screen.fields[shortName])) {
|
|
2407
2485
|
// true (untouched/undone) and "" mean keep the stored secret; null removes it.
|
|
2408
2486
|
if (value === null) {
|
|
2487
|
+
commandFieldNames.push(shortName);
|
|
2409
2488
|
commands.push({
|
|
2410
2489
|
type: "config:write:reset",
|
|
2411
2490
|
payload: { key: qualified, scope: screen.scope },
|
|
2412
2491
|
});
|
|
2413
2492
|
}
|
|
2414
2493
|
else if (typeof value === "string" && value !== "") {
|
|
2494
|
+
commandFieldNames.push(shortName);
|
|
2415
2495
|
commands.push({
|
|
2416
2496
|
type: "config:write:set",
|
|
2417
2497
|
payload: { key: qualified, value, scope: screen.scope },
|
|
@@ -2425,12 +2505,14 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2425
2505
|
// value. set can't do it: deleting the row is irreversible, reset is
|
|
2426
2506
|
// the high-risk handler.
|
|
2427
2507
|
if (ftype === "select" && fieldDef?.optionsQuery !== undefined && value === "") {
|
|
2508
|
+
commandFieldNames.push(shortName);
|
|
2428
2509
|
commands.push({
|
|
2429
2510
|
type: "config:write:reset",
|
|
2430
2511
|
payload: { key: qualified, scope: screen.scope },
|
|
2431
2512
|
});
|
|
2432
2513
|
continue;
|
|
2433
2514
|
}
|
|
2515
|
+
commandFieldNames.push(shortName);
|
|
2434
2516
|
commands.push({
|
|
2435
2517
|
type: "config:write:set",
|
|
2436
2518
|
payload: {
|
|
@@ -2445,7 +2527,11 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2445
2527
|
}
|
|
2446
2528
|
const result = await dispatcher.batch(commands);
|
|
2447
2529
|
if (!result.isSuccess) {
|
|
2448
|
-
return {
|
|
2530
|
+
return {
|
|
2531
|
+
validationBlocked: false,
|
|
2532
|
+
isSuccess: false,
|
|
2533
|
+
error: mapFailedConfigCommandIssuesToField(result.error, result.failedIndex, commandFieldNames),
|
|
2534
|
+
};
|
|
2449
2535
|
}
|
|
2450
2536
|
// Cascade-Disclosure + Maskenwerte nach dem Write nachziehen, sonst
|
|
2451
2537
|
// bleibt die Anzeige stale bis Reload — gleiche refetch-Calls wie onReset.
|
package/dist/app/list-facets.js
CHANGED
|
@@ -239,10 +239,16 @@ function dayBoundInstant(date, bound, timeZone) {
|
|
|
239
239
|
})
|
|
240
240
|
.toString();
|
|
241
241
|
}
|
|
242
|
+
// A shared or hand-built URL can carry from > to; the handler's `from <= to`
|
|
243
|
+
// refine would answer 422, so the bounds are put in order before conversion.
|
|
244
|
+
function swapInvertedRange(range) {
|
|
245
|
+
const inverted = range.from !== "" && range.to !== "" && range.from > range.to;
|
|
246
|
+
return inverted ? { from: range.to, to: range.from } : range;
|
|
247
|
+
}
|
|
242
248
|
export function buildDateRangePayload(specs, urlFilters, timeZone) {
|
|
243
249
|
const out = {};
|
|
244
250
|
for (const spec of specs) {
|
|
245
|
-
const range = readDateRange(urlFilters, spec.field);
|
|
251
|
+
const range = swapInvertedRange(readDateRange(urlFilters, spec.field));
|
|
246
252
|
for (const [bound, param] of [
|
|
247
253
|
["from", spec.fromParam],
|
|
248
254
|
["to", spec.toParam],
|
package/dist/app/return-to.js
CHANGED
|
@@ -105,7 +105,10 @@ export function navigateWithReturnTo(nav, target, host, params) {
|
|
|
105
105
|
* with them, so a chain unwinds one level per jump. */
|
|
106
106
|
export function navigateToReturn(nav, target) {
|
|
107
107
|
const raw = nav.searchParams[RETURN_TO_PARAM];
|
|
108
|
-
|
|
108
|
+
// The state belongs to the host the raw value names; a caller-supplied
|
|
109
|
+
// target elsewhere must not inherit it.
|
|
110
|
+
const returnTo = raw === undefined ? undefined : splitReturnTo(raw);
|
|
111
|
+
const state = returnTo?.path === formatReturnTo(target) ? returnTo.state : NO_RETURN_STATE;
|
|
109
112
|
nav.navigate(target);
|
|
110
113
|
// The explicit null drops this screen's own returnTo on nav impls whose
|
|
111
114
|
// navigate keeps the query.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useMemo, useState } from "react";
|
|
3
3
|
import { useDispatcher } from "../context/dispatcher-context.js";
|
|
4
|
+
import { useUserRoles } from "../context/user-roles-context.js";
|
|
4
5
|
import { useQuery } from "../hooks/use-query.js";
|
|
5
6
|
import { useTranslation } from "../i18n.js";
|
|
6
7
|
import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
|
|
7
8
|
import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
|
|
9
|
+
import { screenAccessAllows } from "./screen-access.js";
|
|
8
10
|
import { dispatcherErrorText } from "./write-failed-error.js";
|
|
9
11
|
function groupSectionsByTitle(sections) {
|
|
10
12
|
const byTitle = new Map();
|
|
@@ -24,6 +26,10 @@ export function SecretsEditBody({ screen, translate }) {
|
|
|
24
26
|
const effectiveTranslate = translate ?? t;
|
|
25
27
|
const dispatcher = useDispatcher();
|
|
26
28
|
const listQuery = useQuery("secrets:query:list", {});
|
|
29
|
+
const userRoles = useUserRoles();
|
|
30
|
+
// Hidden fields are excluded from rendering and submit alike; the server
|
|
31
|
+
// write gate stays authoritative.
|
|
32
|
+
const visibleFieldIds = useMemo(() => new Set(Object.keys(screen.secretKeys).filter((fieldId) => screenAccessAllows(screen.fieldAccess?.[fieldId], userRoles))), [screen.secretKeys, screen.fieldAccess, userRoles]);
|
|
27
33
|
const [drafts, setDrafts] = useState({});
|
|
28
34
|
const [submitting, setSubmitting] = useState(false);
|
|
29
35
|
const [submitError, setSubmitError] = useState(null);
|
|
@@ -40,6 +46,8 @@ export function SecretsEditBody({ screen, translate }) {
|
|
|
40
46
|
}, []);
|
|
41
47
|
const handleSubmit = useCallback(async () => {
|
|
42
48
|
const commands = Object.entries(screen.secretKeys).flatMap(([fieldId, qualified]) => {
|
|
49
|
+
if (!visibleFieldIds.has(fieldId))
|
|
50
|
+
return [];
|
|
43
51
|
const value = drafts[fieldId]?.trim();
|
|
44
52
|
return value ? [{ type: "secrets:write:set", payload: { key: qualified, value } }] : [];
|
|
45
53
|
});
|
|
@@ -64,7 +72,14 @@ export function SecretsEditBody({ screen, translate }) {
|
|
|
64
72
|
finally {
|
|
65
73
|
setSubmitting(false);
|
|
66
74
|
}
|
|
67
|
-
}, [
|
|
75
|
+
}, [
|
|
76
|
+
dispatcher,
|
|
77
|
+
drafts,
|
|
78
|
+
screen.secretKeys,
|
|
79
|
+
visibleFieldIds,
|
|
80
|
+
listQuery.refetch,
|
|
81
|
+
effectiveTranslate,
|
|
82
|
+
]);
|
|
68
83
|
const handleDelete = useCallback(async (qualified) => {
|
|
69
84
|
setDeleting(true);
|
|
70
85
|
try {
|
|
@@ -90,7 +105,12 @@ export function SecretsEditBody({ screen, translate }) {
|
|
|
90
105
|
}
|
|
91
106
|
// The generator emits one section per feature; features without a declared
|
|
92
107
|
// title all fall under the generic "Secrets" heading and share one band.
|
|
93
|
-
const sectionGroups = groupSectionsByTitle(screen.sections)
|
|
108
|
+
const sectionGroups = groupSectionsByTitle(screen.sections)
|
|
109
|
+
.map((group) => ({
|
|
110
|
+
...group,
|
|
111
|
+
fields: group.fields.filter((fieldId) => visibleFieldIds.has(fieldId)),
|
|
112
|
+
}))
|
|
113
|
+
.filter((group) => group.fields.length > 0);
|
|
94
114
|
const unsavedCount = Object.values(drafts).filter((value) => value.trim() !== "").length;
|
|
95
115
|
return (_jsxs(_Fragment, { children: [_jsxs(Form, { onSubmit: () => {
|
|
96
116
|
void handleSubmit();
|
|
@@ -7,4 +7,4 @@ export type EmbeddedListFieldProps = {
|
|
|
7
7
|
readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
8
8
|
readonly featureName: string;
|
|
9
9
|
};
|
|
10
|
-
export declare function EmbeddedListField(
|
|
10
|
+
export declare function EmbeddedListField(props: EmbeddedListFieldProps): ReactNode;
|
|
@@ -99,7 +99,17 @@ function coerceCellValue(column, text, currency) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
// The reference-column queries are hooks called in a loop, so their count must
|
|
103
|
+
// stay fixed per mount. Keying on the reference cells remounts when that set
|
|
104
|
+
// changes (field-level access, in-place entity swap) instead of breaking hook order.
|
|
105
|
+
export function EmbeddedListField(props) {
|
|
106
|
+
const referenceKey = (props.field.embeddedListCells ?? [])
|
|
107
|
+
.filter((c) => c.type === "reference")
|
|
108
|
+
.map((c) => `${c.field}:${c.refFeature ?? ""}:${c.refEntity ?? ""}`)
|
|
109
|
+
.join("|");
|
|
110
|
+
return _jsx(EmbeddedListFieldInner, { ...props }, referenceKey);
|
|
111
|
+
}
|
|
112
|
+
function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }) {
|
|
103
113
|
const { EmbeddedListInput } = usePrimitives();
|
|
104
114
|
const t = useTranslation();
|
|
105
115
|
const [pasteWarning, setPasteWarning] = useState(null);
|
|
@@ -109,7 +119,7 @@ export function EmbeddedListField({ field, id, onChange, allIssues, featureName,
|
|
|
109
119
|
const referenceCells = cells.filter((c) => c.type === "reference");
|
|
110
120
|
const referenceSources = referenceCells.map((cell) => referenceOptionSource(cell, cell.refFeature ?? featureName, cell.refEntity ?? ""));
|
|
111
121
|
const referenceQueries = referenceSources.map((source) =>
|
|
112
|
-
// biome-ignore lint/correctness/useHookAtTopLevel:
|
|
122
|
+
// biome-ignore lint/correctness/useHookAtTopLevel: the wrapper keys this component on the reference-cell set, so the hook count is fixed per mount.
|
|
113
123
|
useQuery(source.queryQn, {
|
|
114
124
|
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
115
125
|
}));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { evalFieldCondition, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { evalFieldCondition, normalizeListColumn, parseRefTarget, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
import { useAppFeatures } from "../app/app-features-context.js";
|
|
5
5
|
import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
|
|
@@ -49,9 +49,12 @@ function warnRelatedListEmptyStateDrawerDropped(actionId) {
|
|
|
49
49
|
}
|
|
50
50
|
function buildRowGrouping(groupBy, translate, locale) {
|
|
51
51
|
return {
|
|
52
|
-
keyOf: (row) =>
|
|
53
|
-
startsCollapsed: (key) => groupBy.collapsedWhen !== undefined && key ===
|
|
52
|
+
keyOf: (row) => relatedListGroupKey(row.values[groupBy.field]),
|
|
53
|
+
startsCollapsed: (key) => groupBy.collapsedWhen !== undefined && key === relatedListGroupKey(groupBy.collapsedWhen),
|
|
54
54
|
headerLabel: (key, rows) => {
|
|
55
|
+
const labelKey = relatedListGroupHeaderLabel(groupBy, key);
|
|
56
|
+
if (labelKey === undefined)
|
|
57
|
+
return undefined;
|
|
55
58
|
const dates = groupBy.dateField === undefined
|
|
56
59
|
? []
|
|
57
60
|
: rows
|
|
@@ -59,7 +62,7 @@ function buildRowGrouping(groupBy, translate, locale) {
|
|
|
59
62
|
.filter((value) => value !== "")
|
|
60
63
|
.sort();
|
|
61
64
|
const latest = dates[dates.length - 1];
|
|
62
|
-
return translate(
|
|
65
|
+
return translate(labelKey, {
|
|
63
66
|
count: rows.length,
|
|
64
67
|
value: key,
|
|
65
68
|
lastDate: latest === undefined
|
|
@@ -164,10 +167,9 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
164
167
|
}, [ownRefetch, onAfterWrite]);
|
|
165
168
|
const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), []);
|
|
166
169
|
const onFilterReset = useCallback(() => setFilters({}), []);
|
|
167
|
-
// Sorted client-side over the already-loaded rows
|
|
168
|
-
// pager (
|
|
169
|
-
//
|
|
170
|
-
// a client-side sort could misleadingly hide (fw#2722).
|
|
170
|
+
// Sorted client-side over the already-loaded rows only. The section has no
|
|
171
|
+
// pager (one-shot fetch); when that fetch is truncated (`nextCursor` set) the
|
|
172
|
+
// truncation banner below tells the user the sorted set is partial.
|
|
171
173
|
const [sort, setSort] = useState(section.defaultSort ?? null);
|
|
172
174
|
const sortAccessors = useMemo(() => {
|
|
173
175
|
const accessors = {};
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type { EditFieldViewModel, EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
1
|
+
import type { DispatcherError, EditFieldViewModel, EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
export declare function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean;
|
|
3
3
|
export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
|
|
4
4
|
export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
|
|
5
|
+
export declare function issueRootField(path: string): string;
|
|
5
6
|
export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
|
|
6
7
|
export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
|
|
8
|
+
export declare function findFirstErroringSectionIndex(sections: readonly EditSectionViewModel[], issuePaths: readonly string[]): number | undefined;
|
|
7
9
|
export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
|
|
10
|
+
export declare function isDispatcherRejection(value: unknown): value is DispatcherError;
|
|
@@ -27,19 +27,29 @@ export function resolveExtensionEntityId(entityIdProp, vmId) {
|
|
|
27
27
|
export function shouldNotifyCaller(result, extensionsPersisted) {
|
|
28
28
|
return !(result.isSuccess && !extensionsPersisted);
|
|
29
29
|
}
|
|
30
|
-
//
|
|
30
|
+
// Embedded-list rows are dotted (e.g. `tasks.2.title`); the form field is the first segment.
|
|
31
|
+
export function issueRootField(path) {
|
|
32
|
+
return path.split(".")[0] ?? path;
|
|
33
|
+
}
|
|
31
34
|
// No issues at all counts as unmatched, so the caller keeps the banner.
|
|
32
35
|
export function hasIssueWithoutRenderedField(issuePaths, sections) {
|
|
33
|
-
const renderedFields = sections
|
|
34
|
-
.filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
|
|
35
|
-
.flatMap((s) => (s.kind === "fields" ? s.fields : []));
|
|
36
|
+
const renderedFields = sections.flatMap((s) => s.kind === "fields" && s.visible ? s.fields : []);
|
|
36
37
|
return hasIssueWithoutVisibleField(issuePaths, renderedFields);
|
|
37
38
|
}
|
|
38
39
|
export function hasIssueWithoutVisibleField(issuePaths, fields) {
|
|
39
40
|
if (issuePaths.length === 0)
|
|
40
41
|
return true;
|
|
41
42
|
const visibleFields = new Set(fields.filter((f) => f.visible).map((f) => f.field));
|
|
42
|
-
return issuePaths.some((path) => !visibleFields.has(
|
|
43
|
+
return issuePaths.some((path) => !visibleFields.has(issueRootField(path)));
|
|
44
|
+
}
|
|
45
|
+
// Same visibility rule as hasIssueWithoutRenderedField: a hidden field shows
|
|
46
|
+
// no error, so jumping to its step would strand the user on an error-less step.
|
|
47
|
+
export function findFirstErroringSectionIndex(sections, issuePaths) {
|
|
48
|
+
const erroredFields = new Set(issuePaths.map(issueRootField));
|
|
49
|
+
const index = sections.findIndex((s) => s.kind === "fields" &&
|
|
50
|
+
s.visible &&
|
|
51
|
+
s.fields.some((f) => f.visible && erroredFields.has(f.field)));
|
|
52
|
+
return index === -1 ? undefined : index;
|
|
43
53
|
}
|
|
44
54
|
// Extension, relatedList and writeForm sections skip the `fields` filter
|
|
45
55
|
// (writeForm's fields belong to its own independent form, not the host's);
|
|
@@ -64,3 +74,13 @@ export function filterEditSections(sections, fieldsFilter) {
|
|
|
64
74
|
}
|
|
65
75
|
return result;
|
|
66
76
|
}
|
|
77
|
+
// `onDelete` is typed `() => void`-compatible, so a caller can return any value
|
|
78
|
+
// at runtime (a WriteResult, `true`); only a real DispatcherError is a rejection.
|
|
79
|
+
export function isDispatcherRejection(value) {
|
|
80
|
+
return (typeof value === "object" &&
|
|
81
|
+
value !== null &&
|
|
82
|
+
"code" in value &&
|
|
83
|
+
typeof value.code === "string" &&
|
|
84
|
+
"i18nKey" in value &&
|
|
85
|
+
typeof value.i18nKey === "string");
|
|
86
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import { computeEditViewModel, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
5
5
|
import { AllFieldsRequiredProvider } from "./render-field.js";
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
@@ -17,7 +17,7 @@ import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header
|
|
|
17
17
|
import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
18
18
|
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
19
19
|
import { RelatedListSection } from "./related-list-section.js";
|
|
20
|
-
import { filterEditSections, hasEditableSection, hasIssueWithoutRenderedField, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
|
|
20
|
+
import { filterEditSections, findFirstErroringSectionIndex, hasEditableSection, hasIssueWithoutRenderedField, isDispatcherRejection, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
|
|
21
21
|
import { WriteFormSection } from "./write-form-section.js";
|
|
22
22
|
// Qualified names of the bundled `form-draft` feature. Hardcoded because the
|
|
23
23
|
// renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
|
|
@@ -95,21 +95,32 @@ function deriveFormFields(screen) {
|
|
|
95
95
|
return out;
|
|
96
96
|
}
|
|
97
97
|
// Dry-run parse against `schema` — NOT controller.validate(), which writes
|
|
98
|
-
// field errors into the snapshot.
|
|
99
|
-
//
|
|
100
|
-
|
|
98
|
+
// field errors into the snapshot. Undefined when the values parse cleanly.
|
|
99
|
+
// Parse once, then narrow per scope with scopedDryRunIssues: the wizard step
|
|
100
|
+
// check would otherwise re-parse the whole form per section on every keystroke.
|
|
101
|
+
function dryRunParse(schema, snapshot) {
|
|
101
102
|
const parsed = schema.safeParse(snapshot.values);
|
|
102
103
|
if (parsed.success)
|
|
103
|
-
return
|
|
104
|
+
return undefined;
|
|
104
105
|
const hiddenFields = new Set(Object.entries(snapshot.fields)
|
|
105
106
|
.filter(([, state]) => !state.visible)
|
|
106
107
|
.map(([fieldKey]) => fieldKey));
|
|
107
|
-
return
|
|
108
|
-
|
|
108
|
+
return { issues: zodErrorToFieldIssues(parsed.error), hiddenFields };
|
|
109
|
+
}
|
|
110
|
+
// Issues are filtered exactly like the controller's validate(): hidden
|
|
111
|
+
// fields never count, `scope` narrows.
|
|
112
|
+
function scopedDryRunIssues(parse, scope, includeRoot) {
|
|
113
|
+
if (parse === undefined)
|
|
114
|
+
return [];
|
|
115
|
+
return relevantFieldIssues(parse.issues, {
|
|
116
|
+
hiddenFields: parse.hiddenFields,
|
|
109
117
|
...(scope !== undefined && { scope }),
|
|
110
118
|
includeRoot,
|
|
111
119
|
});
|
|
112
120
|
}
|
|
121
|
+
function dryRunFieldIssues(schema, snapshot, scope, includeRoot) {
|
|
122
|
+
return scopedDryRunIssues(dryRunParse(schema, snapshot), scope, includeRoot);
|
|
123
|
+
}
|
|
113
124
|
// Index-independent: indices shift when a fields section hides.
|
|
114
125
|
function wizardStepKey(section, index) {
|
|
115
126
|
const declaredId = section.kind === "fields" || section.kind === "extension" ? section.id : undefined;
|
|
@@ -130,11 +141,10 @@ function isEmptyFormValue(value) {
|
|
|
130
141
|
// select/boolean are excluded from the "holds user data" check: they are
|
|
131
142
|
// often pre-filled (defaults, server-set values), so they say nothing about
|
|
132
143
|
// whether the step was filled in. They still count through the validation.
|
|
133
|
-
function isFieldsStepFilled(section,
|
|
144
|
+
function isFieldsStepFilled(section, parse) {
|
|
134
145
|
const fieldNames = section.fields.map((f) => f.field);
|
|
135
|
-
if (
|
|
146
|
+
if (scopedDryRunIssues(parse, fieldNames, false).length > 0)
|
|
136
147
|
return false;
|
|
137
|
-
}
|
|
138
148
|
return section.fields.some((f) => f.visible &&
|
|
139
149
|
!f.readOnly &&
|
|
140
150
|
f.type !== "boolean" &&
|
|
@@ -296,8 +306,9 @@ export function RenderEdit(props) {
|
|
|
296
306
|
// Both stepped layouts show one section at a time and keep the rest mounted
|
|
297
307
|
// but inert. A tabs layout on a host without the Tabs primitive has no strip
|
|
298
308
|
// to switch with, so it falls back to rendering every section stacked rather
|
|
299
|
-
// than stranding the user on section 0 (fw#3134).
|
|
300
|
-
|
|
309
|
+
// than stranding the user on section 0 (fw#3134). Same fallback when the
|
|
310
|
+
// WizardStepGroup wrapper is missing.
|
|
311
|
+
const isStepped = isWizard || (isTabs && Tabs !== undefined && WizardStepGroup !== undefined);
|
|
301
312
|
const fields = useMemo(() => deriveFormFields(screen), [screen]);
|
|
302
313
|
// Must be computed before submitConfig/useForm bakes it in (the controller
|
|
303
314
|
// freezes it on first render). This is the unfiltered field-name set from
|
|
@@ -671,12 +682,14 @@ export function RenderEdit(props) {
|
|
|
671
682
|
const wizardDoneSteps = useMemo(() => {
|
|
672
683
|
if (!isWizard || isCreateMode)
|
|
673
684
|
return undefined;
|
|
674
|
-
const
|
|
685
|
+
const parse = schema === undefined
|
|
686
|
+
? undefined
|
|
687
|
+
: dryRunParse(schema, { values: snapshot.values, fields: snapshot.fields });
|
|
675
688
|
return filteredSections.map((section, index) => {
|
|
676
689
|
if (passedStepKeys.has(wizardStepKey(section, index)))
|
|
677
690
|
return true;
|
|
678
691
|
if (section.kind === "fields")
|
|
679
|
-
return isFieldsStepFilled(section,
|
|
692
|
+
return isFieldsStepFilled(section, parse);
|
|
680
693
|
return (section.kind === "extension" &&
|
|
681
694
|
extensionStepComplete[wizardStepKey(section, index)] === true);
|
|
682
695
|
});
|
|
@@ -856,9 +869,7 @@ export function RenderEdit(props) {
|
|
|
856
869
|
// jump targets by design: they validate through the controlled-mode
|
|
857
870
|
// controls.validate() API and own no field specs to match against.
|
|
858
871
|
function findFirstErroringStep(fieldPaths) {
|
|
859
|
-
|
|
860
|
-
const idx = filteredSections.findIndex((s) => s.kind === "fields" && s.fields.some((f) => erroredFields.has(f.field)));
|
|
861
|
-
return idx === -1 ? undefined : idx;
|
|
872
|
+
return findFirstErroringSectionIndex(filteredSections, fieldPaths);
|
|
862
873
|
}
|
|
863
874
|
async function handleSubmit(options) {
|
|
864
875
|
// Enter in the active step triggers the native form submit (Next is
|
|
@@ -967,6 +978,11 @@ export function RenderEdit(props) {
|
|
|
967
978
|
else {
|
|
968
979
|
const fieldIssues = result.error.details?.fields ?? [];
|
|
969
980
|
const issuePaths = fieldIssues.map((i) => i.path);
|
|
981
|
+
// controller.submit() writes server issues into the controller itself;
|
|
982
|
+
// customSubmit bypasses it, so push them here to reach the fields.
|
|
983
|
+
if (customSubmit !== undefined && fieldIssues.length > 0) {
|
|
984
|
+
controller.setErrors(groupIssuesByPath(fieldIssues));
|
|
985
|
+
}
|
|
970
986
|
// A server field error on a step the form isn't currently showing is
|
|
971
987
|
// otherwise invisible — jump to the first step that contains one of
|
|
972
988
|
// the errored fields. For tabs this is what keeps the submit from
|
|
@@ -1216,6 +1232,7 @@ export function RenderEdit(props) {
|
|
|
1216
1232
|
], children: _jsx(Text, { children: translate(draftCandidates.length === 1
|
|
1217
1233
|
? "kumiko.form.draft.resume-single"
|
|
1218
1234
|
: "kumiko.form.draft.resume-multiple") }) })), isWizard && !stepRail && (_jsxs(_Fragment, { children: [Progress !== undefined && (_jsx(Progress, { value: (currentStep + 1) / (lastStepIndex + 1), testId: "render-edit-wizard-progress" })), StepBar === undefined ? (_jsx(Text, { variant: "small", testId: "render-edit-wizard-step-label", children: compactStepLabel })) : (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" }))] })), isTabs &&
|
|
1235
|
+
isStepped &&
|
|
1219
1236
|
(() => {
|
|
1220
1237
|
// No Tabs primitive registered → every section renders stacked
|
|
1221
1238
|
// (stepHidden below is gated on the same condition), which is the
|
|
@@ -1380,7 +1397,7 @@ export function RenderEdit(props) {
|
|
|
1380
1397
|
const rejection = await onDelete();
|
|
1381
1398
|
// A rejected delete (e.g. a preDelete hook) carries its reason in
|
|
1382
1399
|
// the field issues; the top-level key is only "validation failed".
|
|
1383
|
-
if (rejection
|
|
1400
|
+
if (isDispatcherRejection(rejection)) {
|
|
1384
1401
|
const [issue, ...otherIssues] = rejection.details?.fields ?? [];
|
|
1385
1402
|
setExtraDeleteIssues(otherIssues);
|
|
1386
1403
|
setFormError(issue === undefined
|
|
@@ -446,11 +446,14 @@ function numberValue(v) {
|
|
|
446
446
|
// stored-config coercion) is MAJOR units too — every producer in this repo
|
|
447
447
|
// hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
|
|
448
448
|
// pre-scaled minor units.
|
|
449
|
+
// A stored row can carry a legacy/imported currency that is not an ISO-4217
|
|
450
|
+
// shape; Intl.NumberFormat throws a RangeError on it and would crash the screen.
|
|
451
|
+
const ISO_CURRENCY_CODE = /^[A-Z]{3}$/;
|
|
449
452
|
function resolveMoneyCurrency(value, fieldCurrency) {
|
|
450
|
-
if (typeof value === "object" &&
|
|
451
|
-
|
|
452
|
-
typeof
|
|
453
|
-
|
|
453
|
+
if (typeof value === "object" && value !== null && "currency" in value) {
|
|
454
|
+
const { currency } = value;
|
|
455
|
+
if (typeof currency === "string" && ISO_CURRENCY_CODE.test(currency))
|
|
456
|
+
return currency;
|
|
454
457
|
}
|
|
455
458
|
return fieldCurrency ?? "EUR";
|
|
456
459
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { computeListViewModel } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
5
5
|
import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
|
|
6
6
|
import { useTranslation } from "../i18n.js";
|
|
@@ -30,16 +30,25 @@ export function RenderList(props) {
|
|
|
30
30
|
// mount and can flush after a keystroke landing in the same commit,
|
|
31
31
|
// resetting localQ and swallowing the input.
|
|
32
32
|
const [syncedSearchValue, setSyncedSearchValue] = useState(searchValue);
|
|
33
|
+
// The parent may echo our own debounced emit back late (router/URL round
|
|
34
|
+
// trip); adopting that stale echo would erase keys typed in between.
|
|
35
|
+
const lastEmittedRef = useRef(undefined);
|
|
33
36
|
if (searchValue !== syncedSearchValue) {
|
|
34
37
|
setSyncedSearchValue(searchValue);
|
|
35
|
-
|
|
38
|
+
if (searchValue !== lastEmittedRef.current) {
|
|
39
|
+
setLocalQ(searchValue ?? "");
|
|
40
|
+
lastEmittedRef.current = undefined;
|
|
41
|
+
}
|
|
36
42
|
}
|
|
37
43
|
useEffect(() => {
|
|
38
44
|
if (onSearchChange === undefined)
|
|
39
45
|
return;
|
|
40
46
|
if (localQ === (searchValue ?? ""))
|
|
41
47
|
return;
|
|
42
|
-
const timer = setTimeout(() =>
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
lastEmittedRef.current = localQ;
|
|
50
|
+
onSearchChange(localQ);
|
|
51
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
43
52
|
return () => clearTimeout(timer);
|
|
44
53
|
}, [localQ, searchValue, onSearchChange]);
|
|
45
54
|
const vm = useMemo(() => computeListViewModel({ screen, entity, rows, translate, featureName }), [screen, entity, rows, translate, featureName]);
|
|
@@ -100,8 +100,7 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
100
100
|
headerActions: titleRowActions,
|
|
101
101
|
}, testId: `write-form-${section.title ?? section.handler}`, children: content }));
|
|
102
102
|
}
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// aligned footer treatment instead of stretching full-width inline.
|
|
103
|
+
// Submit plus caller actions share the title row's actions slot (submit is
|
|
104
|
+
// lifted into the footer instead when onFooterAction is set).
|
|
106
105
|
return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), actions: titleRowActions, testId: `write-form-${section.title ?? section.handler}`, children: content }));
|
|
107
106
|
}
|
package/dist/primitives.d.ts
CHANGED
|
@@ -250,13 +250,14 @@ export type InputProps = {
|
|
|
250
250
|
/** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
|
|
251
251
|
readonly icon?: FieldIconKey;
|
|
252
252
|
/** `<input step>`. "any" disables the native stepMismatch constraint
|
|
253
|
-
* (needed for decimal fields — integer fields leave this unset).
|
|
253
|
+
* (needed for decimal fields — integer fields leave this unset).
|
|
254
|
+
* Ignored by the web renderer, whose number input is a text field. */
|
|
254
255
|
readonly step?: number | "any";
|
|
255
256
|
/** BCP-47 tag for the grouped display and the decimal separator when typing. */
|
|
256
257
|
readonly locale?: string;
|
|
257
258
|
/** `false` shows the value without a thousands separator (e.g. a year). */
|
|
258
259
|
readonly grouping?: boolean;
|
|
259
|
-
/** Integer-only entry (bigInt): fractional
|
|
260
|
+
/** Integer-only entry (bigInt): fractional drafts are not emitted via `onChange`. */
|
|
260
261
|
readonly integer?: boolean;
|
|
261
262
|
/** Resolved display suffix (static or from a sibling field) — never
|
|
262
263
|
* part of the numeric value. */
|
|
@@ -584,10 +585,12 @@ export type DataTableDateRangeFacet = {
|
|
|
584
585
|
readonly to: string;
|
|
585
586
|
};
|
|
586
587
|
/** Splits rows into collapsible groups (order of first appearance). A
|
|
587
|
-
* renderer without grouping support shows the flat list.
|
|
588
|
+
* renderer without grouping support shows the flat list. A group whose
|
|
589
|
+
* `headerLabel` is undefined shows its rows without a header and never
|
|
590
|
+
* collapses. */
|
|
588
591
|
export type DataTableRowGrouping = {
|
|
589
592
|
readonly keyOf: (row: ListRowViewModel) => string;
|
|
590
|
-
readonly headerLabel: (groupKey: string, rows: readonly ListRowViewModel[]) => string;
|
|
593
|
+
readonly headerLabel: (groupKey: string, rows: readonly ListRowViewModel[]) => string | undefined;
|
|
591
594
|
readonly startsCollapsed: (groupKey: string) => boolean;
|
|
592
595
|
};
|
|
593
596
|
export type DataTableProps = {
|
|
@@ -713,8 +716,8 @@ export type DataTableProps = {
|
|
|
713
716
|
/** Uses the shared screen padding (wider bottom inset) instead of the
|
|
714
717
|
* table's symmetric embedded inset — for a table that IS the screen body
|
|
715
718
|
* (entityList/projectionList), so a list screen ends at the same footer
|
|
716
|
-
* distance as a form or custom screen (fw#2640).
|
|
717
|
-
* `scrollBody
|
|
719
|
+
* distance as a form or custom screen (fw#2640). Ignored when
|
|
720
|
+
* `scrollBody` is true: the wider bottom inset would compete with
|
|
718
721
|
* `scrollBody`'s flex-fill height budget.
|
|
719
722
|
* Default false: unchanged symmetric inset for an embedded table. */
|
|
720
723
|
readonly screenPadding?: boolean;
|
|
@@ -2,10 +2,12 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
export type LiveEvent = {
|
|
3
3
|
readonly type: string;
|
|
4
4
|
readonly data: {
|
|
5
|
-
|
|
5
|
+
/** Absent on anonymous connections: the signal would reveal hidden rows. */
|
|
6
|
+
readonly id?: string;
|
|
6
7
|
readonly aggregateType: string;
|
|
7
8
|
readonly eventType: string;
|
|
8
|
-
|
|
9
|
+
/** Absent on anonymous connections, like `id`. */
|
|
10
|
+
readonly version?: number;
|
|
9
11
|
readonly createdAt: string;
|
|
10
12
|
};
|
|
11
13
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.337.1",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.337.1",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.337.1",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.337.1",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.337.1"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.337.1",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Secrets screen hides keys the user cannot write",
|
|
6
|
+
"detail": "The generated secrets screen now hides fixed secret keys the current user may not write according to the key's `writeRoles`, and drops sections left without visible keys. The generator emits a per-field `fieldAccess` map on the screen for this. It only affects what the UI shows; the server-side write check is unchanged and still decides."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.337.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "configEdit shows server validation errors at the affected field",
|
|
12
|
+
"detail": "`config:write:set` reports its issues at path `value`. The configEdit submit maps the issues of the failed batch command (`failedIndex`) to that command's field, and RenderEdit puts server field issues from a `customSubmit` into the form controller, the same way `controller.submit()` does. The banner stays for issues no rendered field can show and for network failures.",
|
|
13
|
+
"migration": "No code change needed."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.337.0",
|
|
17
|
+
"type": "breaking",
|
|
18
|
+
"title": "DataTableRowGrouping.headerLabel may return undefined",
|
|
19
|
+
"detail": "`DataTableRowGrouping.headerLabel` returns `string | undefined`. `undefined` means the group has no header: the default web DataTable renders its rows without a header row and never collapses them.",
|
|
20
|
+
"migration": "Custom DataTable primitives that render `rowGrouping` handle `undefined` from `headerLabel` by rendering the group's rows without a header."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.337.0",
|
|
24
|
+
"type": "fix",
|
|
25
|
+
"title": "Tabs fallback, error-tab jump, list search echo and money currency no longer misbehave",
|
|
26
|
+
"detail": "A tabs layout on a host with `Tabs` but no `WizardStepGroup` now falls back to a stacked form instead of throwing. After a server validation error the form jumps to the first tab that actually shows the errored field, skipping tabs where it is hidden. A delete handler that returns a non-error value no longer shows an empty error banner. The list search box keeps keystrokes typed while the parent echoes the previous search value late. A money value whose stored currency is not a three-letter code falls back to the field currency instead of crashing the screen. Embedded-list reference columns remount instead of breaking hook order when the column set changes. The wizard step check parses the form once per change instead of once per step."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.337.0",
|
|
30
|
+
"type": "fix",
|
|
31
|
+
"title": "Header subtitle without MetricBand and navigateToReturn state no longer misbehave",
|
|
32
|
+
"detail": "A projectionDetail header subtitle stays visible on a host with `PageHeader` but no `MetricBand`. `navigateToReturn` applies the restored host state only when the return target is the host the `returnTo` value names, so a caller-supplied target no longer inherits a foreign host's tab, filters and nested `returnTo`. The `screenPadding` and `scrollBody` docs now state that `scrollBody` wins, and the number input docs match the web behavior."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.337.0",
|
|
36
|
+
"type": "fix",
|
|
37
|
+
"title": "Screen actions and URL prefill no longer leave stale, doubled or invalid state",
|
|
38
|
+
"detail": "An entityEdit delete action with a `redirect` now still reports the deletion to its host, and a delete with no entity list screen reloads the record instead of leaving the form on the deleted record. Overflow-menu actions are disabled while one is running, so a second click can no longer fire the write twice. URL prefill ignores an empty money param, non-finite number params, embedded-list cells with too many decimals or invalid dates, and a dateRange filter with `from` after `to` is put in order before it reaches the handler."
|
|
39
|
+
},
|
|
2
40
|
{
|
|
3
41
|
"version": "0.336.0",
|
|
4
42
|
"type": "breaking",
|