@cosmicdrift/kumiko-renderer 0.352.0 → 0.354.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/form-schema.d.ts +1 -0
- package/dist/app/form-schema.js +14 -1
- package/dist/app/kumiko-screen.d.ts +2 -1
- package/dist/app/kumiko-screen.js +61 -25
- package/dist/app/layout-fields.d.ts +2 -1
- package/dist/app/layout-fields.js +15 -0
- package/dist/app/projection-list-shim.d.ts +4 -3
- package/dist/app/projection-list-shim.js +9 -4
- package/dist/app/secret-mint-body.js +35 -10
- package/dist/components/render-edit-types.d.ts +8 -0
- package/dist/components/render-edit.js +28 -10
- package/dist/hooks/use-reference-lookup.js +2 -1
- package/dist/i18n-defaults.js +4 -0
- package/dist/i18n.d.ts +3 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/primitives.d.ts +14 -9
- package/dist/primitives.js +8 -1
- package/package.json +5 -5
- package/src/changes.json +43 -0
|
@@ -2,4 +2,5 @@ import type { EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
export declare function isPresent(value: unknown): boolean;
|
|
4
4
|
export declare const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
|
|
5
|
+
export declare const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailable";
|
|
5
6
|
export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition, unchangedFrom?: Readonly<Record<string, unknown>>): z.ZodType;
|
package/dist/app/form-schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { evalFieldCondition, NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import { evalFieldCondition, findUnavailableSelectOptions, NO_WIDGET_FIELD_TYPES, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { layoutEditFields } from "./layout-fields.js";
|
|
@@ -16,6 +16,7 @@ function isEmbeddedListField(field) {
|
|
|
16
16
|
}
|
|
17
17
|
// Renders raw to the user without a de+en default in i18n-defaults.ts.
|
|
18
18
|
export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
|
|
19
|
+
export const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailable";
|
|
19
20
|
// Client-side presence validation for the auto-wired entityEdit path —
|
|
20
21
|
// checks that every rendered required field HAS a value, not that the
|
|
21
22
|
// value has the right shape. Format/range/type validation stays server-
|
|
@@ -54,6 +55,7 @@ export function buildFormSchema(entity, screen, unchangedFrom) {
|
|
|
54
55
|
// its keys — the runtime object always carries every form field.
|
|
55
56
|
// @cast-boundary form-values
|
|
56
57
|
const record = values;
|
|
58
|
+
const selectFieldsToCheck = {};
|
|
57
59
|
for (const spec of fields) {
|
|
58
60
|
const field = entity.fields[spec.field];
|
|
59
61
|
if (!field)
|
|
@@ -62,6 +64,7 @@ export function buildFormSchema(entity, screen, unchangedFrom) {
|
|
|
62
64
|
// same reason as the NO_WIDGET_FIELD_TYPES check below.
|
|
63
65
|
if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record))
|
|
64
66
|
continue;
|
|
67
|
+
selectFieldsToCheck[spec.field] = field;
|
|
65
68
|
// Screen-spec `required` overrides the entity default, mirroring
|
|
66
69
|
// `view-model/edit.ts` — the rendered form is the reference, and a
|
|
67
70
|
// presence check stricter than the form blocks the user for nothing.
|
|
@@ -90,5 +93,15 @@ export function buildFormSchema(entity, screen, unchangedFrom) {
|
|
|
90
93
|
params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
|
|
91
94
|
});
|
|
92
95
|
}
|
|
96
|
+
// Against `unchangedFrom` the helper still checks a stored value whose
|
|
97
|
+
// discriminator changed — the server validates the merged row.
|
|
98
|
+
for (const issue of findUnavailableSelectOptions(selectFieldsToCheck, record, unchangedFrom)) {
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: "custom",
|
|
101
|
+
path: [issue.field],
|
|
102
|
+
message: `"${issue.field}" is not available for the current selection.`,
|
|
103
|
+
params: { [I18N_KEY_PARAM]: OPTION_NOT_AVAILABLE_I18N_KEY, allowed: issue.allowed },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
93
106
|
});
|
|
94
107
|
}
|
|
@@ -22,9 +22,10 @@ export declare function KumikoScreen({ schema, qn, translate, entityId, onRowCli
|
|
|
22
22
|
export declare function buildInitialValues(fields: Readonly<Record<string, unknown>>, defaultCurrency?: string, moneyCurrencyOverrides?: Readonly<Record<string, string>>): Readonly<Record<string, unknown>>;
|
|
23
23
|
export declare function tenantCurrencyMoneyFieldNames(fields: Readonly<Record<string, unknown>>): readonly string[];
|
|
24
24
|
export declare function literalCurrencyOverrides(fields: Readonly<Record<string, unknown>>): Readonly<Record<string, string>>;
|
|
25
|
-
export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string): {
|
|
25
|
+
export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string | undefined): {
|
|
26
26
|
readonly overrides: Readonly<Record<string, string>> | undefined;
|
|
27
27
|
readonly loading: boolean;
|
|
28
|
+
readonly unresolved: boolean;
|
|
28
29
|
};
|
|
29
30
|
export type InitialValueSources = {
|
|
30
31
|
readonly searchParams: Readonly<Record<string, string>>;
|
|
@@ -30,7 +30,7 @@ import { useDashboardBody } from "./dashboard-body.js";
|
|
|
30
30
|
import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
|
|
31
31
|
import { FacetCountBridges } from "./facet-count-bridge.js";
|
|
32
32
|
import { buildFormSchema } from "./form-schema.js";
|
|
33
|
-
import { layoutFieldNames } from "./layout-fields.js";
|
|
33
|
+
import { hiddenRequiredFieldsFilled, layoutFieldNames } from "./layout-fields.js";
|
|
34
34
|
import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
|
|
35
35
|
import { useInitialValuesHandoff, useNav } from "./nav.js";
|
|
36
36
|
import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
|
|
@@ -202,12 +202,16 @@ function pageSizeOptionsFor(currentLimit) {
|
|
|
202
202
|
? PAGE_SIZE_OPTIONS
|
|
203
203
|
: [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
|
|
204
204
|
}
|
|
205
|
-
function useNavigateToCreateFor(schema, entityName, createScreen) {
|
|
205
|
+
function useNavigateToCreateFor(schema, entityName, createScreen, createUnavailable) {
|
|
206
206
|
const nav = useNav();
|
|
207
207
|
const host = useReturnHost();
|
|
208
208
|
const editScreenId = useMemo(() => {
|
|
209
209
|
if (createScreen !== undefined)
|
|
210
210
|
return createScreen;
|
|
211
|
+
// The role may not open the declared create target; falling back to the
|
|
212
|
+
// entity's edit form would bypass the flow the app prescribes (e.g. a wizard).
|
|
213
|
+
if (createUnavailable === true)
|
|
214
|
+
return undefined;
|
|
211
215
|
// allowCreate:false = update-only Edit-Screen (Create läuft über einen
|
|
212
216
|
// Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
|
|
213
217
|
// öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
|
|
@@ -217,7 +221,7 @@ function useNavigateToCreateFor(schema, entityName, createScreen) {
|
|
|
217
221
|
s.allowCreate !== false &&
|
|
218
222
|
s.singleton !== true);
|
|
219
223
|
return edit !== undefined ? lastSegment(edit.id) : undefined;
|
|
220
|
-
}, [schema.screens, entityName, createScreen]);
|
|
224
|
+
}, [schema.screens, entityName, createScreen, createUnavailable]);
|
|
221
225
|
const navigate = useCallback(() => {
|
|
222
226
|
if (editScreenId !== undefined)
|
|
223
227
|
navigateWithReturnTo(nav, { screenId: editScreenId }, host);
|
|
@@ -283,17 +287,20 @@ export function tenantCurrencyMoneyFieldNames(fields) {
|
|
|
283
287
|
function resolveTenantCurrency(fieldNames, query, fallback) {
|
|
284
288
|
if (fieldNames.length === 0)
|
|
285
289
|
return { status: "not-needed" };
|
|
286
|
-
if (query.error)
|
|
287
|
-
return
|
|
290
|
+
if (query.error) {
|
|
291
|
+
return fallback === undefined
|
|
292
|
+
? { status: "unresolved" }
|
|
293
|
+
: { status: "ready", currency: fallback };
|
|
294
|
+
}
|
|
288
295
|
if (query.data === null)
|
|
289
296
|
return { status: "loading" };
|
|
290
297
|
const raw = query.data[TENANT_CURRENCY_CONFIG_KEY]?.value;
|
|
291
|
-
|
|
298
|
+
if (typeof raw === "string" && raw !== "")
|
|
299
|
+
return { status: "ready", currency: raw };
|
|
300
|
+
return fallback === undefined
|
|
301
|
+
? { status: "unresolved" }
|
|
302
|
+
: { status: "ready", currency: fallback };
|
|
292
303
|
}
|
|
293
|
-
// An entity-less form screen has no `entity.defaultCurrency` to fall back on,
|
|
294
|
-
// so a tenant-declared field whose config query errors or returns no value
|
|
295
|
-
// lands here — same last resort as the entityEdit path's `?? "EUR"`.
|
|
296
|
-
const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
|
|
297
304
|
const HEADER_CARD_DEFAULT_WIDTH = "4xl";
|
|
298
305
|
// Shared by the update payload shape and the server-error path prefix so both can't drift.
|
|
299
306
|
const UPDATE_CHANGES_KEY = "changes";
|
|
@@ -318,8 +325,11 @@ export function literalCurrencyOverrides(fields) {
|
|
|
318
325
|
// on a fast click submit, the wrong currency. Callers pass the tenant field
|
|
319
326
|
// names themselves because an edit form only needs the fetch for fields the
|
|
320
327
|
// record has no stored value for.
|
|
321
|
-
export function useMoneyCurrencyOverrides(fields, tenantFieldNames,
|
|
322
|
-
|
|
328
|
+
export function useMoneyCurrencyOverrides(fields, tenantFieldNames,
|
|
329
|
+
// `undefined` = no safe last resort (entity-less forms): an unresolvable
|
|
330
|
+
// tenant currency is reported via `unresolved` instead of guessing a code.
|
|
331
|
+
fallback) {
|
|
332
|
+
const query = useQuery("config:query:values", { keys: [TENANT_CURRENCY_CONFIG_KEY] }, { enabled: tenantFieldNames.length > 0 });
|
|
323
333
|
const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
|
|
324
334
|
const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
|
|
325
335
|
const overrides = useMemo(() => {
|
|
@@ -330,7 +340,11 @@ export function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
|
|
|
330
340
|
}
|
|
331
341
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
332
342
|
}, [fields, tenantFieldNames, resolvedTenantCurrency]);
|
|
333
|
-
return {
|
|
343
|
+
return {
|
|
344
|
+
overrides,
|
|
345
|
+
loading: resolution.status === "loading",
|
|
346
|
+
unresolved: resolution.status === "unresolved",
|
|
347
|
+
};
|
|
334
348
|
}
|
|
335
349
|
function multiSelectOptionValues(shape) {
|
|
336
350
|
if (shape.options === undefined || shape.options.length === 0)
|
|
@@ -822,6 +836,9 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
822
836
|
onReload,
|
|
823
837
|
screen.entity,
|
|
824
838
|
]);
|
|
839
|
+
const sameEntityScreenId = useCallback((targetScreen) => isEntityEditScreenOfOtherEntity(screen.entity, targetScreen, schema, appFeatures)
|
|
840
|
+
? undefined
|
|
841
|
+
: entityId, [screen.entity, schema, appFeatures, entityId]);
|
|
825
842
|
const buildSectionActions = useCallback((actions) => buildRecordActions({
|
|
826
843
|
actions,
|
|
827
844
|
record,
|
|
@@ -832,7 +849,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
832
849
|
openDrawer,
|
|
833
850
|
onWriteSuccess: onReload,
|
|
834
851
|
onRecordLeft: handleRecordLeft,
|
|
835
|
-
|
|
852
|
+
sameEntityScreenId,
|
|
836
853
|
defaultWritePayloadId: entityId,
|
|
837
854
|
}), [
|
|
838
855
|
record,
|
|
@@ -843,6 +860,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
843
860
|
openDrawer,
|
|
844
861
|
onReload,
|
|
845
862
|
handleRecordLeft,
|
|
863
|
+
sameEntityScreenId,
|
|
846
864
|
entityId,
|
|
847
865
|
]);
|
|
848
866
|
const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
|
|
@@ -1105,7 +1123,7 @@ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
|
|
|
1105
1123
|
function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
1106
1124
|
const isEmbeddedScreen = useIsEmbeddedScreen();
|
|
1107
1125
|
const featureName = schema.featureName;
|
|
1108
|
-
const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen);
|
|
1126
|
+
const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen, screen.createUnavailable);
|
|
1109
1127
|
const { Banner } = usePrimitives();
|
|
1110
1128
|
const queryType = entityQueryCommand(featureName, screen.entity, "list");
|
|
1111
1129
|
const nav = useNav();
|
|
@@ -1322,9 +1340,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
1322
1340
|
// Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
|
|
1323
1341
|
// entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
|
|
1324
1342
|
// bekäme ein Cross-Entity-Edit-Screen die falsche row.id injiziert.
|
|
1325
|
-
const targetIsEntityEdit =
|
|
1326
|
-
s.entity === screen.entity &&
|
|
1327
|
-
lastSegment(s.id) === action.screen);
|
|
1343
|
+
const targetIsEntityEdit = isEntityEditScreenOfEntity(screen.entity, action.screen, schema, appFeatures);
|
|
1328
1344
|
const explicit = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1329
1345
|
const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
|
|
1330
1346
|
const entityId = explicit ?? fallback;
|
|
@@ -1339,7 +1355,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
1339
1355
|
const params = navigateActionSearchParams(action, row.values);
|
|
1340
1356
|
navigateWithReturnTo(nav, target, host, params);
|
|
1341
1357
|
}
|
|
1342
|
-
}, [nav, schema
|
|
1358
|
+
}, [nav, schema, appFeatures, screen.entity, host]);
|
|
1343
1359
|
const rowActions = useMemo(() => {
|
|
1344
1360
|
const declared = screen.rowActions ?? [];
|
|
1345
1361
|
// Prepended unless a declared rowAction already has id "edit" — declared wins.
|
|
@@ -1830,7 +1846,8 @@ function headerStatusTone(header, record) {
|
|
|
1830
1846
|
const declared = header.statusTones !== undefined && Object.hasOwn(header.statusTones, value)
|
|
1831
1847
|
? header.statusTones[value]
|
|
1832
1848
|
: undefined;
|
|
1833
|
-
|
|
1849
|
+
const declaredTone = declared !== undefined ? statusToneForOptionTone(declared) : undefined;
|
|
1850
|
+
return declaredTone ?? statusToneForValue(value);
|
|
1834
1851
|
}
|
|
1835
1852
|
function resolveSubtitleHref(header, record) {
|
|
1836
1853
|
if (header?.subtitleHref === undefined)
|
|
@@ -2229,6 +2246,20 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2229
2246
|
function redirectScreenTarget(redirect) {
|
|
2230
2247
|
return typeof redirect === "string" ? redirect : redirect.screen;
|
|
2231
2248
|
}
|
|
2249
|
+
// Same-entity detection for navigate defaults: only an entityEdit screen of the
|
|
2250
|
+
// SAME entity may receive the current record's id (it opens in UPDATE mode, which
|
|
2251
|
+
// is what the boot validator assumes). Searched across all features.
|
|
2252
|
+
function isEntityEditScreenOfEntity(entity, targetScreen, schema, appFeatures) {
|
|
2253
|
+
if (entity === undefined)
|
|
2254
|
+
return false;
|
|
2255
|
+
return [schema, ...appFeatures].some((feature) => feature.screens.some((s) => s.type === "entityEdit" && s.entity === entity && lastSegment(s.id) === targetScreen));
|
|
2256
|
+
}
|
|
2257
|
+
// Record-scoped targets (actionForm, secretMint) take the shown record's id; an
|
|
2258
|
+
// entityEdit of ANOTHER entity must not, or it would open that entity's UPDATE
|
|
2259
|
+
// mode on the wrong id instead of create.
|
|
2260
|
+
function isEntityEditScreenOfOtherEntity(entity, targetScreen, schema, appFeatures) {
|
|
2261
|
+
return [schema, ...appFeatures].some((feature) => feature.screens.some((s) => s.type === "entityEdit" && s.entity !== entity && lastSegment(s.id) === targetScreen));
|
|
2262
|
+
}
|
|
2232
2263
|
// A qualified redirect names its feature, so the search space is THAT feature
|
|
2233
2264
|
// only — the current schema's own screens must not shadow it with a same
|
|
2234
2265
|
// short-id screen of another type. An unqualified redirect searches this
|
|
@@ -2330,11 +2361,11 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
|
|
|
2330
2361
|
const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
|
|
2331
2362
|
// This screen has no entity, so a money field here declares its own currency
|
|
2332
2363
|
// source (fw#2839, boot-enforced) — without one the form would seed a bare
|
|
2333
|
-
// `0` the handler's schema rejects.
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2364
|
+
// `0` the handler's schema rejects. When a tenant-declared field's config
|
|
2365
|
+
// query errors or holds no value there is no entity default to use, and
|
|
2366
|
+
// submitting a guessed currency would mis-book the amount — block instead.
|
|
2336
2367
|
const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(screen.fields), [screen.fields]);
|
|
2337
|
-
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(screen.fields, tenantCurrencyFieldNames,
|
|
2368
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading, unresolved: currencyUnresolved, } = useMoneyCurrencyOverrides(screen.fields, tenantCurrencyFieldNames, undefined);
|
|
2338
2369
|
const initial = useMemo(() => mergeSearchParamsIntoInitial(screen.fields, {
|
|
2339
2370
|
searchParams: nav.searchParams,
|
|
2340
2371
|
urlPrefillFields: screen.urlPrefillFields,
|
|
@@ -2423,12 +2454,17 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
|
|
|
2423
2454
|
if (currencyLoading) {
|
|
2424
2455
|
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
2425
2456
|
}
|
|
2457
|
+
if (currencyUnresolved) {
|
|
2458
|
+
return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-currency-unresolved", children: "The tenant currency could not be loaded, so this form cannot be submitted." }));
|
|
2459
|
+
}
|
|
2426
2460
|
return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial,
|
|
2427
2461
|
// Extension-Sections im actionForm sehen die initialen Form-Values
|
|
2428
2462
|
// (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
|
|
2429
2463
|
// — anders als im entityEdit gibt es hier keinen record, aus dem
|
|
2430
2464
|
// sie Kontext ziehen könnten.
|
|
2431
|
-
extensionInitialValues: initial, ...(
|
|
2465
|
+
extensionInitialValues: initial, ...(screen.submitPrefilled === true && {
|
|
2466
|
+
submitWithoutChanges: hiddenRequiredFieldsFilled(synthScreen, screen.fields, initial),
|
|
2467
|
+
}), ...(successText !== undefined && {
|
|
2432
2468
|
leadContent: (_jsx(Banner, { variant: "info", testId: "action-form-success", children: successText })),
|
|
2433
2469
|
}), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
|
|
2434
2470
|
submitLabel: screen.submitLabel ?? submitLabelFallback,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
import type { EditFieldSpec, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import type { EditFieldSpec, EntityEditScreenDefinition, SecretMintScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
export declare function layoutEditFields(screen: EntityEditScreenDefinition): readonly Exclude<EditFieldSpec, string>[];
|
|
3
3
|
export declare function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string>;
|
|
4
|
+
export declare function hiddenRequiredFieldsFilled(screen: EntityEditScreenDefinition, fields: SecretMintScreenDefinition["fields"], initial: Readonly<Record<string, unknown>>): boolean;
|
|
@@ -18,3 +18,18 @@ export function layoutEditFields(screen) {
|
|
|
18
18
|
export function layoutFieldNames(screen) {
|
|
19
19
|
return new Set(layoutEditFields(screen).map((spec) => spec.field));
|
|
20
20
|
}
|
|
21
|
+
function isRequiredField(field) {
|
|
22
|
+
return field !== undefined && "required" in field && field.required === true;
|
|
23
|
+
}
|
|
24
|
+
function hasValue(value) {
|
|
25
|
+
if (value === undefined || value === null || value === "")
|
|
26
|
+
return false;
|
|
27
|
+
return !(Array.isArray(value) && value.length === 0);
|
|
28
|
+
}
|
|
29
|
+
// A layout-hidden field can never be edited, so a required one without a seeded
|
|
30
|
+
// value would leave the form permanently unsubmittable.
|
|
31
|
+
export function hiddenRequiredFieldsFilled(screen, fields, initial) {
|
|
32
|
+
return layoutEditFields(screen).every((spec) => spec.visible !== false ||
|
|
33
|
+
!isRequiredField(fields[spec.field]) ||
|
|
34
|
+
hasValue(initial[spec.field]));
|
|
35
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { EntityDefinition, EntityListScreenDefinition, ListColumnSpec, ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
/** Minimal EntityDefinition from the column list: every field is a text
|
|
3
|
-
* field. `sortable` applies
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* field. `sortable` applies to all columns — buildAppSchema derives it from
|
|
4
|
+
* the query's Zod schema (`screen.sortable`, fw#2165); the query itself has
|
|
5
|
+
* no per-field server-sort guarantee, so a column opts out with
|
|
6
|
+
* `sortable: false` when the handler cannot order by it.
|
|
6
7
|
* computeListViewModel only reads `fields[<col>].type` — text is enough,
|
|
7
8
|
* presentation comes from the column renderer + explicit label. */
|
|
8
9
|
export declare function synthesizeProjectionEntity(columns: readonly ListColumnSpec[], sortable: boolean): EntityDefinition;
|
|
@@ -16,15 +16,20 @@
|
|
|
16
16
|
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
17
|
const PROJECTION_PSEUDO_ENTITY = "__projection__";
|
|
18
18
|
/** Minimal EntityDefinition from the column list: every field is a text
|
|
19
|
-
* field. `sortable` applies
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* field. `sortable` applies to all columns — buildAppSchema derives it from
|
|
20
|
+
* the query's Zod schema (`screen.sortable`, fw#2165); the query itself has
|
|
21
|
+
* no per-field server-sort guarantee, so a column opts out with
|
|
22
|
+
* `sortable: false` when the handler cannot order by it.
|
|
22
23
|
* computeListViewModel only reads `fields[<col>].type` — text is enough,
|
|
23
24
|
* presentation comes from the column renderer + explicit label. */
|
|
24
25
|
export function synthesizeProjectionEntity(columns, sortable) {
|
|
25
26
|
const fields = {};
|
|
26
27
|
for (const col of columns) {
|
|
27
|
-
|
|
28
|
+
const normalized = normalizeListColumn(col);
|
|
29
|
+
fields[normalized.field] = {
|
|
30
|
+
type: "text",
|
|
31
|
+
sortable: sortable && normalized.sortable !== false,
|
|
32
|
+
};
|
|
28
33
|
}
|
|
29
34
|
return { fields };
|
|
30
35
|
}
|
|
@@ -4,8 +4,8 @@ import { RenderEdit } from "../components/render-edit.js";
|
|
|
4
4
|
import { useTranslation } from "../i18n.js";
|
|
5
5
|
import { usePrimitives } from "../primitives.js";
|
|
6
6
|
import { synthesizeActionFormEntity, synthesizeActionFormScreen, synthesizeSecretMintConfirmScreen, } from "./action-form-shim.js";
|
|
7
|
-
import { buildInitialValues,
|
|
8
|
-
import { layoutFieldNames } from "./layout-fields.js";
|
|
7
|
+
import { buildInitialValues, mergeSearchParamsIntoInitial, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "./kumiko-screen.js";
|
|
8
|
+
import { hiddenRequiredFieldsFilled, layoutFieldNames } from "./layout-fields.js";
|
|
9
9
|
import { useInitialValuesHandoff, useNav } from "./nav.js";
|
|
10
10
|
import { lastSegment } from "./qn.js";
|
|
11
11
|
import { navigateToReturnOr, useReturnTarget } from "./return-to.js";
|
|
@@ -26,6 +26,9 @@ function extractCarriedValues(data, carry) {
|
|
|
26
26
|
}
|
|
27
27
|
return carried;
|
|
28
28
|
}
|
|
29
|
+
// Entity-less forms have no `entity.defaultCurrency`; same fallback as actionForm.
|
|
30
|
+
const SECRET_MINT_CURRENCY_FALLBACK = "EUR";
|
|
31
|
+
const NO_FIELDS = {};
|
|
29
32
|
function isBlank(value) {
|
|
30
33
|
if (value === undefined || value === null || value === "")
|
|
31
34
|
return true;
|
|
@@ -50,17 +53,26 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
50
53
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
51
54
|
const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
|
|
52
55
|
const handoffValues = useInitialValuesHandoff(screen.id);
|
|
56
|
+
const mintTenantCurrencyFields = useMemo(() => tenantCurrencyMoneyFieldNames(screen.fields), [screen.fields]);
|
|
57
|
+
const { overrides: mintCurrencyOverrides, loading: mintCurrencyLoading } = useMoneyCurrencyOverrides(screen.fields, mintTenantCurrencyFields, SECRET_MINT_CURRENCY_FALLBACK);
|
|
53
58
|
const initial = useMemo(() => mergeSearchParamsIntoInitial(screen.fields, {
|
|
54
59
|
searchParams: nav.searchParams,
|
|
55
60
|
urlPrefillFields: screen.urlPrefillFields,
|
|
56
61
|
renderableFields: layoutFieldNames(synthScreen),
|
|
57
62
|
// A mint form has no entity either, so its money fields name their own
|
|
58
|
-
// currency source (fw#2839).
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
// currency source (fw#2839, fw#2933).
|
|
64
|
+
...(mintCurrencyOverrides !== undefined && {
|
|
65
|
+
moneyCurrencyOverrides: mintCurrencyOverrides,
|
|
66
|
+
}),
|
|
62
67
|
...(handoffValues !== undefined && { handoffValues }),
|
|
63
|
-
}), [
|
|
68
|
+
}), [
|
|
69
|
+
screen.fields,
|
|
70
|
+
screen.urlPrefillFields,
|
|
71
|
+
nav.searchParams,
|
|
72
|
+
synthScreen,
|
|
73
|
+
handoffValues,
|
|
74
|
+
mintCurrencyOverrides,
|
|
75
|
+
]);
|
|
64
76
|
const [revealed, setRevealed] = useState(null);
|
|
65
77
|
const [done, setDone] = useState(false);
|
|
66
78
|
// The secret was minted server-side but none of the declared reveal fields
|
|
@@ -72,9 +84,12 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
72
84
|
const confirm = screen.confirm;
|
|
73
85
|
const confirmEntity = useMemo(() => (confirm !== undefined ? synthesizeActionFormEntity(confirm.fields) : undefined), [confirm]);
|
|
74
86
|
const confirmScreen = useMemo(() => (confirm !== undefined ? synthesizeSecretMintConfirmScreen(screen, confirm) : undefined), [screen, confirm]);
|
|
87
|
+
const confirmFields = confirm?.fields ?? NO_FIELDS;
|
|
88
|
+
const confirmTenantCurrencyFields = useMemo(() => tenantCurrencyMoneyFieldNames(confirmFields), [confirmFields]);
|
|
89
|
+
const { overrides: confirmCurrencyOverrides, loading: confirmCurrencyLoading } = useMoneyCurrencyOverrides(confirmFields, confirmTenantCurrencyFields, SECRET_MINT_CURRENCY_FALLBACK);
|
|
75
90
|
const confirmInitial = useMemo(() => confirm !== undefined
|
|
76
|
-
? buildInitialValues(confirm.fields, undefined,
|
|
77
|
-
: undefined, [confirm]);
|
|
91
|
+
? buildInitialValues(confirm.fields, undefined, confirmCurrencyOverrides)
|
|
92
|
+
: undefined, [confirm, confirmCurrencyOverrides]);
|
|
78
93
|
const handleSubmitted = useCallback((result) => {
|
|
79
94
|
if (!result.isSuccess)
|
|
80
95
|
return;
|
|
@@ -166,9 +181,19 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
166
181
|
// keys stop the mint and confirm forms sharing one instance (and its
|
|
167
182
|
// submit state) across phases.
|
|
168
183
|
if (confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined) {
|
|
184
|
+
// Hold until a tenant-declared currency lands, else the form would seed (and
|
|
185
|
+
// on a fast click submit) the fallback currency (fw#2933).
|
|
186
|
+
if (confirmCurrencyLoading) {
|
|
187
|
+
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
188
|
+
}
|
|
169
189
|
return (_jsx(RenderEdit, { screen: confirmScreen, i18nScreenId: screen.id, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, onCancel: handleCancel ?? restartMint, fillScreenHeight: true, leadContent: _jsx(Section, { testId: "kumiko-screen-secret-mint-card", children: revealContent }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) }, "confirm"));
|
|
170
190
|
}
|
|
171
191
|
return (_jsxs(Card, { options: { screenBody: true }, testId: "kumiko-screen-secret-mint-card", children: [revealContent, _jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") })] }));
|
|
172
192
|
}
|
|
173
|
-
|
|
193
|
+
if (mintCurrencyLoading) {
|
|
194
|
+
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
195
|
+
}
|
|
196
|
+
return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, ...(screen.submitPrefilled === true && {
|
|
197
|
+
submitWithoutChanges: hiddenRequiredFieldsFilled(synthScreen, screen.fields, initial),
|
|
198
|
+
}), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, fillScreenHeight: true, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }, "mint"));
|
|
174
199
|
}
|
|
@@ -76,6 +76,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
76
76
|
* "Save" can be replaced by domain-specific strings ("Approve" /
|
|
77
77
|
* "Dispatch" / etc.). */
|
|
78
78
|
readonly submitLabel?: string;
|
|
79
|
+
/** Set by submitPrefilled screens: defined shows the submit button even when no field is
|
|
80
|
+
* editable (all hidden or read-only); `true` also enables it while the form is unchanged. */
|
|
81
|
+
readonly submitWithoutChanges?: boolean;
|
|
79
82
|
/** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
|
|
80
83
|
readonly submitVariant?: "primary" | "danger";
|
|
81
84
|
/** Extra footer buttons (actionForm `footerActions`): each sets its `patch`
|
|
@@ -241,4 +244,9 @@ export type RenderEditControls<TValues extends FormValues> = {
|
|
|
241
244
|
* guard — a host showing a pre-filled proposal must be able to accept it
|
|
242
245
|
* untouched. */
|
|
243
246
|
readonly submit: () => Promise<void>;
|
|
247
|
+
/** Wizard only (no-op elsewhere): validates the current step and advances,
|
|
248
|
+
* like the built-in Next button. A `hideActions` host steps with this. */
|
|
249
|
+
readonly next: () => void;
|
|
250
|
+
/** Wizard only (no-op elsewhere): goes back one step without validating. */
|
|
251
|
+
readonly back: () => void;
|
|
244
252
|
};
|
|
@@ -223,7 +223,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
|
|
|
223
223
|
return translate(template, values);
|
|
224
224
|
}
|
|
225
225
|
export function RenderEdit(props) {
|
|
226
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
|
|
226
|
+
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
|
|
227
227
|
const i18nScreenId = props.i18nScreenId ?? screen.id;
|
|
228
228
|
const { customSubmit } = props;
|
|
229
229
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
@@ -398,6 +398,8 @@ export function RenderEdit(props) {
|
|
|
398
398
|
// snapshot/extensionDirty, while the onControlsReady effect fires once per
|
|
399
399
|
// mount — the ref lets controls.submit() always reach the current one.
|
|
400
400
|
const handleSubmitRef = useRef(async () => { });
|
|
401
|
+
const wizardNextRef = useRef(() => { });
|
|
402
|
+
const wizardBackRef = useRef(() => { });
|
|
401
403
|
const currentStepRef = useRef(0);
|
|
402
404
|
const draftSaveTimerRef = useRef(null);
|
|
403
405
|
useEffect(() => {
|
|
@@ -452,7 +454,11 @@ export function RenderEdit(props) {
|
|
|
452
454
|
patch: patchAndScheduleDraftSave,
|
|
453
455
|
validate: scopedValidate,
|
|
454
456
|
getValues: () => controller.getSnapshot().values,
|
|
455
|
-
|
|
457
|
+
// saveNow: a host-driven submit must save on a wizard's intermediate
|
|
458
|
+
// steps too instead of silently advancing; stepping is next()/back().
|
|
459
|
+
submit: () => handleSubmitRef.current({ saveNow: true }),
|
|
460
|
+
next: () => wizardNextRef.current(),
|
|
461
|
+
back: () => wizardBackRef.current(),
|
|
456
462
|
});
|
|
457
463
|
// Dep on boolean presence only — inline-arrow identity must not redeliver.
|
|
458
464
|
}, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
@@ -752,10 +758,10 @@ export function RenderEdit(props) {
|
|
|
752
758
|
}
|
|
753
759
|
const secretFieldNames = new Set(vm.sections.flatMap((section) => section.kind === "fields"
|
|
754
760
|
? section.fields
|
|
755
|
-
.filter((f) => f.format === "password" || f.writeOnly === true)
|
|
761
|
+
.filter((f) => f.format === "password" || f.writeOnly === true || f.sensitive === true)
|
|
756
762
|
.map((f) => f.field)
|
|
757
763
|
: []));
|
|
758
|
-
// A password or
|
|
764
|
+
// A password, writeOnly or sensitive field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
|
|
759
765
|
const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !secretFieldNames.has(field)));
|
|
760
766
|
void dispatcher
|
|
761
767
|
.write(FORM_DRAFT_SAVE, {
|
|
@@ -1011,6 +1017,8 @@ export function RenderEdit(props) {
|
|
|
1011
1017
|
}
|
|
1012
1018
|
}
|
|
1013
1019
|
handleSubmitRef.current = handleSubmit;
|
|
1020
|
+
wizardNextRef.current = isWizard ? handleWizardNext : () => { };
|
|
1021
|
+
wizardBackRef.current = isWizard ? handleWizardBack : () => { };
|
|
1014
1022
|
// Screen forms (entityEdit/actionForm filling the shell height) drop the card;
|
|
1015
1023
|
// the title then lives in the shell header when the shell offers a slot.
|
|
1016
1024
|
const isScreenForm = fillScreenHeight === true && hideSectionTitles !== true && !insideDrawer;
|
|
@@ -1089,7 +1097,10 @@ export function RenderEdit(props) {
|
|
|
1089
1097
|
// draw a no-op Save. Extension registrations still get their Save, since
|
|
1090
1098
|
// persistExtensions() runs on the customSubmit path too.
|
|
1091
1099
|
const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
|
|
1092
|
-
const showsSubmit = isFormEditable ||
|
|
1100
|
+
const showsSubmit = isFormEditable ||
|
|
1101
|
+
hasExtensionRegistrations ||
|
|
1102
|
+
isFieldlessSubmitForm ||
|
|
1103
|
+
submitWithoutChanges !== undefined;
|
|
1093
1104
|
const footerSlot = screen.slots?.footer;
|
|
1094
1105
|
// An unregistered footer component renders null, so only a resolvable one may
|
|
1095
1106
|
// count as an action (else read-only screens get an empty footer strip).
|
|
@@ -1131,7 +1142,11 @@ export function RenderEdit(props) {
|
|
|
1131
1142
|
footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
|
|
1132
1143
|
controller.setValues(action.patch); // @cast-boundary form-values
|
|
1133
1144
|
void handleSubmit();
|
|
1134
|
-
}, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })), showSubmit && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged &&
|
|
1145
|
+
}, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })), showSubmit && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged &&
|
|
1146
|
+
!extensionDirty &&
|
|
1147
|
+
!isFieldlessSubmitForm &&
|
|
1148
|
+
!insideDrawer &&
|
|
1149
|
+
submitWithoutChanges !== true) ||
|
|
1135
1150
|
isSubmitting ||
|
|
1136
1151
|
disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
|
|
1137
1152
|
(isWizard
|
|
@@ -1367,10 +1382,13 @@ export function RenderEdit(props) {
|
|
|
1367
1382
|
}
|
|
1368
1383
|
// No section title here (fw#3218) — the Tab strip right above
|
|
1369
1384
|
// already names this panel, so a repeated Card title would just
|
|
1370
|
-
// duplicate it. The
|
|
1371
|
-
//
|
|
1372
|
-
const cardEl = (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
|
|
1373
|
-
slots: {
|
|
1385
|
+
// duplicate it. The header row only exists to carry the section
|
|
1386
|
+
// description and actions, never the title text.
|
|
1387
|
+
const cardEl = (_jsx(Card, { options: { framed: false }, ...((sectionActionsEl !== undefined || section.description !== undefined) && {
|
|
1388
|
+
slots: {
|
|
1389
|
+
...(section.description !== undefined && { subtitle: section.description }),
|
|
1390
|
+
...(sectionActionsEl !== undefined && { headerActions: sectionActionsEl }),
|
|
1391
|
+
},
|
|
1374
1392
|
}), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`) }, sectionKey));
|
|
1375
1393
|
return wrapWizardStep(sectionKey, cardEl);
|
|
1376
1394
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// internen Cache shared zwischen List + Edit-Form für die gleiche
|
|
18
18
|
// Entity, Live-Updates kommen via SSE (use-query-live).
|
|
19
19
|
import { REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
20
|
+
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
20
21
|
import { useMemo } from "react";
|
|
21
22
|
import { toKebab } from "../app/qn.js";
|
|
22
23
|
import { useTranslation } from "../i18n.js";
|
|
@@ -61,7 +62,7 @@ export function useReferenceLookup(featureName, refEntity, labelField) {
|
|
|
61
62
|
continue;
|
|
62
63
|
const idStr = String(id);
|
|
63
64
|
const label = row[labelKey] ?? id;
|
|
64
|
-
out.set(idStr, String(label));
|
|
65
|
+
out.set(idStr, label === PII_ERASED_SENTINEL ? translate("kumiko.pii.erased") : String(label));
|
|
65
66
|
}
|
|
66
67
|
// System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row, so
|
|
67
68
|
// the bulk lookup above never covers them — inject the label directly.
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -41,6 +41,7 @@ export const kumikoDefaultTranslations = {
|
|
|
41
41
|
"kumiko.field.unsupported": "This field type can't be edited here yet.",
|
|
42
42
|
"kumiko.field.writeOnly.remove": "Remove stored value",
|
|
43
43
|
"kumiko.field.writeOnly.set": "Set",
|
|
44
|
+
"kumiko.pii.erased": "Deleted",
|
|
44
45
|
"kumiko.field.writeOnly.setPlaceholder": "Set — leave empty to keep it",
|
|
45
46
|
"kumiko.field.writeOnly.undo": "Undo",
|
|
46
47
|
"kumiko.field.writeOnly.willRemove": "Removed on save",
|
|
@@ -107,6 +108,8 @@ export const kumikoDefaultTranslations = {
|
|
|
107
108
|
"kumiko.dashboard.panel.error.title": "{label} could not be loaded",
|
|
108
109
|
"kumiko.dashboard.time-range": "Time range",
|
|
109
110
|
"kumiko.screen.unavailable": "This page is not available right now.",
|
|
111
|
+
"kumiko.dashboard.brush-end": "Window end",
|
|
112
|
+
"kumiko.dashboard.brush-start": "Window start",
|
|
110
113
|
"kumiko.dashboard.today": "Today",
|
|
111
114
|
"kumiko.dashboard.updated-at": "As of {time}",
|
|
112
115
|
"kumiko.app-boot.loading": "Loading…",
|
|
@@ -206,6 +209,7 @@ export const kumikoDefaultTranslations = {
|
|
|
206
209
|
"kumiko.secretMint.copied": "Copied",
|
|
207
210
|
"kumiko.secretMint.done": "Done.",
|
|
208
211
|
"kumiko.validation.required": "Required.",
|
|
212
|
+
"kumiko.validation.optionNotAvailable": "Not available for the current selection.",
|
|
209
213
|
"kumiko.validation.invalid": "Invalid value.",
|
|
210
214
|
"kumiko.validation.too-short": "Too short (at least {min} characters).",
|
|
211
215
|
"kumiko.validation.too-long": "Too long (at most {max} characters).",
|
package/dist/i18n.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export type TranslationsByLocale = Readonly<Record<string, TranslationBundle>>;
|
|
|
11
11
|
export type TranslationsByKey = Readonly<Record<string, Readonly<Record<string, TranslationValue>>>>;
|
|
12
12
|
/** Pivot key-first server translations to locale-first client bundles. */
|
|
13
13
|
export declare function translationsByLocaleFromKeys(source: TranslationsByKey): TranslationsByLocale;
|
|
14
|
+
/** Inverse of `translationsByLocaleFromKeys`: pivot locale-first bundles to the key-first
|
|
15
|
+
* shape `r.translations({ keys })` expects, so one source feeds server and client. */
|
|
16
|
+
export declare function translationsByKeyFromLocales(source: TranslationsByLocale): TranslationsByKey;
|
|
14
17
|
/** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
|
|
15
18
|
* die Locales werden zusammengeführt. Standard-Baustein für Client-
|
|
16
19
|
* Plugins, die App-Overrides über ihre Default-Bundles legen. */
|
package/dist/i18n.js
CHANGED
|
@@ -27,6 +27,18 @@ export function translationsByLocaleFromKeys(source) {
|
|
|
27
27
|
}
|
|
28
28
|
return out;
|
|
29
29
|
}
|
|
30
|
+
/** Inverse of `translationsByLocaleFromKeys`: pivot locale-first bundles to the key-first
|
|
31
|
+
* shape `r.translations({ keys })` expects, so one source feeds server and client. */
|
|
32
|
+
export function translationsByKeyFromLocales(source) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [locale, bundle] of Object.entries(source)) {
|
|
35
|
+
for (const [key, value] of Object.entries(bundle)) {
|
|
36
|
+
out[key] ??= {};
|
|
37
|
+
out[key][locale] = value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
30
42
|
/** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
|
|
31
43
|
* die Locales werden zusammengeführt. Standard-Baustein für Client-
|
|
32
44
|
* Plugins, die App-Overrides über ihre Default-Bundles legen. */
|
package/dist/index.d.ts
CHANGED
|
@@ -67,12 +67,12 @@ export { useStore, useStoreSelector } from "./hooks/use-store.js";
|
|
|
67
67
|
export type { StreamStatus, UseStreamHandlerOptions, UseStreamHandlerResult, } from "./hooks/use-stream-handler.js";
|
|
68
68
|
export { useStreamHandler } from "./hooks/use-stream-handler.js";
|
|
69
69
|
export type { LocaleProviderProps, TranslationBundle, TranslationsByKey, TranslationsByLocale, } from "./i18n.js";
|
|
70
|
-
export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
|
|
70
|
+
export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByKeyFromLocales, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
|
|
71
71
|
export { kumikoDefaultTranslations } from "./i18n-defaults.js";
|
|
72
72
|
export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
|
|
73
73
|
export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
|
|
74
74
|
export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FooterActionMarker, FooterActionRole, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputProps, JsonViewProps, LightboxImage, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, TrailingInputAction, WizardStepGroupProps, } from "./primitives.js";
|
|
75
|
-
export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, usePrimitives, } from "./primitives.js";
|
|
75
|
+
export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useOptionalPrimitives, usePrimitives, } from "./primitives.js";
|
|
76
76
|
export type { SessionEndedSignal, SessionEndedSignalProviderProps, } from "./session/session-ended.js";
|
|
77
77
|
export { createSessionEndedSignal, isSessionEndedError, SESSION_ENDED_ERROR_CODES, SessionEndedSignalProvider, useSessionEndedSignal, withSessionEndedDetection, } from "./session/session-ended.js";
|
|
78
78
|
export { buildWhatsAppShareUrl } from "./share-links.js";
|
package/dist/index.js
CHANGED
|
@@ -48,11 +48,11 @@ export { entityFromQueryType, useQuery } from "./hooks/use-query.js";
|
|
|
48
48
|
export { useReportStepComplete } from "./hooks/use-report-step-complete.js";
|
|
49
49
|
export { useStore, useStoreSelector } from "./hooks/use-store.js";
|
|
50
50
|
export { useStreamHandler } from "./hooks/use-stream-handler.js";
|
|
51
|
-
export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
|
|
51
|
+
export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByKeyFromLocales, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
|
|
52
52
|
export { kumikoDefaultTranslations } from "./i18n-defaults.js";
|
|
53
53
|
export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
|
|
54
54
|
export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
|
|
55
|
-
export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, usePrimitives, } from "./primitives.js";
|
|
55
|
+
export { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useOptionalPrimitives, usePrimitives, } from "./primitives.js";
|
|
56
56
|
export { createSessionEndedSignal, isSessionEndedError, SESSION_ENDED_ERROR_CODES, SessionEndedSignalProvider, useSessionEndedSignal, withSessionEndedDetection, } from "./session/session-ended.js";
|
|
57
57
|
export { buildWhatsAppShareUrl } from "./share-links.js";
|
|
58
58
|
export { sortByAccessor } from "./sort-by-accessor.js";
|
package/dist/primitives.d.ts
CHANGED
|
@@ -28,7 +28,14 @@ export type FooterActionMarker = {
|
|
|
28
28
|
* und sollte mit `disabled` kombiniert werden, wenn die Action wirklich
|
|
29
29
|
* blockiert bis das Loading durch ist (z.B. async submit). Native-
|
|
30
30
|
* Impls können den Spinner als Activity-Indicator rendern. */
|
|
31
|
-
export type ButtonProps = {
|
|
31
|
+
export type ButtonProps = ButtonBaseProps & ({
|
|
32
|
+
readonly children: ReactNode;
|
|
33
|
+
} | {
|
|
34
|
+
readonly children?: undefined;
|
|
35
|
+
readonly icon: NavIconKey;
|
|
36
|
+
readonly ariaLabel: string;
|
|
37
|
+
});
|
|
38
|
+
type ButtonBaseProps = {
|
|
32
39
|
readonly type?: "button" | "submit";
|
|
33
40
|
readonly onClick?: () => void | Promise<void>;
|
|
34
41
|
readonly disabled?: boolean;
|
|
@@ -59,11 +66,6 @@ export type ButtonProps = {
|
|
|
59
66
|
* die Container-Breite (Karten/Panels). Andere Breiten sind Layout-Sache
|
|
60
67
|
* des Containers, kein Button-Prop (Kit hält arbiträres Sizing draußen). */
|
|
61
68
|
readonly width?: "full" | "auto";
|
|
62
|
-
/** Optional for icon-only buttons (`size="icon"` with a resolved `icon`)
|
|
63
|
-
* — the icon carries the content then, `ariaLabel` the accessible name.
|
|
64
|
-
* Required in practice otherwise: without children and without `icon`
|
|
65
|
-
* the button stays empty. */
|
|
66
|
-
readonly children?: ReactNode;
|
|
67
69
|
readonly testId?: string;
|
|
68
70
|
/** Layout extras — Web merges via cn(), native impls ignore it
|
|
69
71
|
* (precedent: LinkProps.className). */
|
|
@@ -1034,8 +1036,9 @@ export type DialogProps = {
|
|
|
1034
1036
|
readonly cancelLabel?: string;
|
|
1035
1037
|
/** `default` = Confirm primary, `danger` = Confirm danger. */
|
|
1036
1038
|
readonly variant?: "default" | "danger";
|
|
1037
|
-
/** Which button takes focus on open. Default: Confirm for a bare dialog
|
|
1038
|
-
*
|
|
1039
|
+
/** Which button takes focus on open. Default: Confirm for a bare dialog
|
|
1040
|
+
* (Cancel when `variant` is `danger`), the browser/Radix default when
|
|
1041
|
+
* `children` are present. `"cancel"` for
|
|
1039
1042
|
* dialogs where the safe choice must be the one Enter triggers. */
|
|
1040
1043
|
readonly initialFocus?: "confirm" | "cancel";
|
|
1041
1044
|
/** Wird gefeuert wenn der User Confirm drückt. Async-Funktion ist
|
|
@@ -1270,7 +1273,7 @@ export type StatusBadgeProps = {
|
|
|
1270
1273
|
readonly tone?: StatusTone;
|
|
1271
1274
|
readonly testId?: string;
|
|
1272
1275
|
};
|
|
1273
|
-
export declare function statusToneForOptionTone(tone: SelectOptionTone): StatusTone;
|
|
1276
|
+
export declare function statusToneForOptionTone(tone: SelectOptionTone): StatusTone | undefined;
|
|
1274
1277
|
export declare function statusToneForValue(value: string): StatusTone | undefined;
|
|
1275
1278
|
/** Compact label/value tile (record-detail metrics band). `testId` is the
|
|
1276
1279
|
* tile's own id — the impl derives `${testId}-label`/`${testId}-value` for
|
|
@@ -1523,4 +1526,6 @@ export type PrimitivesProviderProps = {
|
|
|
1523
1526
|
readonly value: PrimitivesRegistry;
|
|
1524
1527
|
};
|
|
1525
1528
|
export declare function PrimitivesProvider({ children, value }: PrimitivesProviderProps): ReactNode;
|
|
1529
|
+
/** Like `usePrimitives`, but `undefined` outside a provider — for components that also render standalone. */
|
|
1530
|
+
export declare function useOptionalPrimitives(): PrimitivesRegistry | undefined;
|
|
1526
1531
|
export declare function usePrimitives(): PrimitivesRegistry;
|
package/dist/primitives.js
CHANGED
|
@@ -52,8 +52,11 @@ const STATUS_TONE_BY_OPTION_TONE = {
|
|
|
52
52
|
bad: "bad",
|
|
53
53
|
neutral: "muted",
|
|
54
54
|
};
|
|
55
|
+
// Own-key check: an unknown tone from an untyped definition must yield undefined so callers fall back to the value heuristic.
|
|
55
56
|
export function statusToneForOptionTone(tone) {
|
|
56
|
-
return STATUS_TONE_BY_OPTION_TONE
|
|
57
|
+
return Object.hasOwn(STATUS_TONE_BY_OPTION_TONE, tone)
|
|
58
|
+
? STATUS_TONE_BY_OPTION_TONE[tone]
|
|
59
|
+
: undefined;
|
|
57
60
|
}
|
|
58
61
|
export function statusToneForValue(value) {
|
|
59
62
|
const slug = value
|
|
@@ -69,6 +72,10 @@ const PrimitivesContext = createContext(undefined);
|
|
|
69
72
|
export function PrimitivesProvider({ children, value }) {
|
|
70
73
|
return _jsx(PrimitivesContext.Provider, { value: value, children: children });
|
|
71
74
|
}
|
|
75
|
+
/** Like `usePrimitives`, but `undefined` outside a provider — for components that also render standalone. */
|
|
76
|
+
export function useOptionalPrimitives() {
|
|
77
|
+
return useContext(PrimitivesContext);
|
|
78
|
+
}
|
|
72
79
|
export function usePrimitives() {
|
|
73
80
|
const registry = useContext(PrimitivesContext);
|
|
74
81
|
if (registry === undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.354.0",
|
|
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.354.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.354.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.354.0",
|
|
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.354.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,47 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Navigate no longer injects the current id into entityEdit targets of another entity",
|
|
6
|
+
"detail": "Navigate actions no longer inject the current record's id into entityEdit targets of another entity; entityList rowActions now recognise same-entity entityEdit targets across features and open them in UPDATE mode"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.354.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "translationsByKeyFromLocales export and email-template subpath exports",
|
|
12
|
+
"detail": "Renderer exports translationsByKeyFromLocales (inverse of translationsByLocaleFromKeys); bundled-features exposes the auth-email-password and user-data-rights email-templates as subpath exports"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.354.0",
|
|
16
|
+
"type": "breaking",
|
|
17
|
+
"title": "Grid columns is number | \"auto\"",
|
|
18
|
+
"detail": "Final review batch F: renderer, renderer-web, types\nThe boot validator rejects a `listScreenId` that matches no mounted screen, a `sortable` column that the list cannot sort (entityList: any value; projectionList: `true`) and an authored `urlPrefillFields`. `TreeAction` is a union: exactly one of `screen` or `target`. `Button` without children needs `icon` and `ariaLabel`. `RenderEditControls` gains `next` and `back`, and `controls.submit()` saves on an intermediate wizard step. An actionForm money field with a tenant currency that cannot be loaded shows an error instead of submitting with a guessed EUR. The MFA setup and recovery-code buttons sit below the content. The boot context passes Redis to the delivery service, so `notify` with an `idempotencyKey` dedupes. `user:query:user:detail` skips the tenants label when the tenant feature is not mounted.",
|
|
19
|
+
"migration": "Pass a number or \"auto\" to Grid columns; other values no longer type-check."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.354.0",
|
|
23
|
+
"type": "breaking",
|
|
24
|
+
"title": "Button without children requires icon and ariaLabel",
|
|
25
|
+
"migration": "A custom wrapper that forwards ButtonProps must pass either children or both icon and ariaLabel."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.354.0",
|
|
29
|
+
"type": "fix",
|
|
30
|
+
"title": "RenderEditControls has next and back; submit saves on an intermediate wizard step",
|
|
31
|
+
"migration": "No action needed. A host that renders its own wizard buttons with hideActions can call controls.next(), controls.back() and controls.submit()."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.354.0",
|
|
35
|
+
"type": "fix",
|
|
36
|
+
"title": "actionForm money fields with a tenant currency block the form when the currency cannot be loaded",
|
|
37
|
+
"migration": "No action needed. secretMint and the reference create dialog keep their fallback currency."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"version": "0.353.0",
|
|
41
|
+
"type": "fix",
|
|
42
|
+
"title": "secretMint with tenant currency waits for the tenant currency in mint and confirm step",
|
|
43
|
+
"detail": "Number inputs no longer keep a typed prefix, secretMint honours tenant currency, refEntity respects real field types\nA `NumberInput` draft that does not parse (\"12abc\", \"12,5\" on an integer field) now clears the value instead of leaving the last parseable prefix (\"12\") to be submitted. A `secretMint` screen with `currency: { kind: \"tenant\" }` money fields now waits for the tenant currency in both the mint and the confirm step, like `actionForm`, instead of seeding a bare `0`. `refEntity` on an `entityList` column or `entityEdit` field no longer overrides a real non-text entity field (a multi-reference stays multi, a sortable column stays sortable). The Tailwind scan skips compiled `__tests__` in `dist`."
|
|
44
|
+
},
|
|
2
45
|
{
|
|
3
46
|
"version": "0.352.0",
|
|
4
47
|
"type": "improvement",
|