@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/app/action-form-body.d.ts +50 -0
  2. package/dist/app/action-form-body.js +275 -0
  3. package/dist/app/client-actions.js +2 -2
  4. package/dist/app/config-edit-body.d.ts +9 -0
  5. package/dist/app/config-edit-body.js +340 -0
  6. package/dist/app/entity-edit-prefill.d.ts +27 -0
  7. package/dist/app/entity-edit-prefill.js +377 -0
  8. package/dist/app/entity-edit-screen.d.ts +11 -0
  9. package/dist/app/entity-edit-screen.js +395 -0
  10. package/dist/app/entity-list-screen.d.ts +10 -0
  11. package/dist/app/entity-list-screen.js +547 -0
  12. package/dist/app/kumiko-screen.d.ts +1 -36
  13. package/dist/app/kumiko-screen.js +16 -2855
  14. package/dist/app/projection-detail-body.d.ts +12 -0
  15. package/dist/app/projection-detail-body.js +464 -0
  16. package/dist/app/projection-detail-shim.d.ts +1 -1
  17. package/dist/app/projection-detail-shim.js +5 -1
  18. package/dist/app/projection-list-body.d.ts +10 -0
  19. package/dist/app/projection-list-body.js +245 -0
  20. package/dist/app/projection-list-shim.d.ts +2 -1
  21. package/dist/app/projection-list-shim.js +4 -0
  22. package/dist/app/reference-facet-bridge.js +2 -2
  23. package/dist/app/row-actions.js +3 -4
  24. package/dist/app/screen-shared.d.ts +72 -0
  25. package/dist/app/screen-shared.js +325 -0
  26. package/dist/app/secret-mint-body.js +3 -3
  27. package/dist/components/edit-mounts.d.ts +33 -0
  28. package/dist/components/edit-mounts.js +53 -0
  29. package/dist/components/error-boundary.js +2 -2
  30. package/dist/components/reference-create-dialog.js +1 -1
  31. package/dist/components/related-list-section.js +2 -2
  32. package/dist/components/render-edit-helpers.d.ts +29 -0
  33. package/dist/components/render-edit-helpers.js +121 -0
  34. package/dist/components/render-edit.d.ts +1 -4
  35. package/dist/components/render-edit.js +41 -443
  36. package/dist/components/render-field.js +3 -4
  37. package/dist/components/render-list.js +2 -2
  38. package/dist/components/use-wizard-form-draft.d.ts +51 -0
  39. package/dist/components/use-wizard-form-draft.js +291 -0
  40. package/dist/dev-console.d.ts +3 -0
  41. package/dist/dev-console.js +15 -0
  42. package/dist/headless/contracts/primitives.js +14 -17
  43. package/dist/headless/format/index.js +3 -3
  44. package/dist/headless/view-model/edit.d.ts +1 -1
  45. package/dist/headless/view-model/edit.js +9 -16
  46. package/dist/headless/view-model/list.d.ts +1 -1
  47. package/dist/headless/view-model/list.js +3 -8
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +5 -5
  50. package/dist/live/error-mapping.js +3 -4
  51. package/dist/web/app/create-app.js +5 -8
  52. package/dist/web/app/dashboard-body.js +4 -599
  53. package/dist/web/app/dashboard-panels.d.ts +53 -0
  54. package/dist/web/app/dashboard-panels.js +373 -0
  55. package/dist/web/app/dashboard-shared.d.ts +90 -0
  56. package/dist/web/app/dashboard-shared.js +171 -0
  57. package/dist/web/app/dashboard-text.d.ts +6 -0
  58. package/dist/web/app/dashboard-text.js +65 -0
  59. package/dist/web/layout/nav-tree.js +3 -4
  60. package/dist/web/layout/target-resolver-stub.js +2 -2
  61. package/dist/web/primitives/banner.d.ts +4 -0
  62. package/dist/web/primitives/banner.js +28 -0
  63. package/dist/web/primitives/button.d.ts +7 -0
  64. package/dist/web/primitives/button.js +48 -0
  65. package/dist/web/primitives/card-styles.d.ts +8 -0
  66. package/dist/web/primitives/card-styles.js +18 -0
  67. package/dist/web/primitives/card.d.ts +4 -0
  68. package/dist/web/primitives/card.js +41 -0
  69. package/dist/web/primitives/contexts.d.ts +22 -0
  70. package/dist/web/primitives/contexts.js +50 -0
  71. package/dist/web/primitives/data-table-cell.d.ts +76 -0
  72. package/dist/web/primitives/data-table-cell.js +403 -0
  73. package/dist/web/primitives/data-table.d.ts +4 -0
  74. package/dist/web/primitives/data-table.js +279 -0
  75. package/dist/web/primitives/default-form.d.ts +4 -0
  76. package/dist/web/primitives/default-form.js +79 -0
  77. package/dist/web/primitives/facet-filter.d.ts +14 -0
  78. package/dist/web/primitives/facet-filter.js +39 -0
  79. package/dist/web/primitives/field.d.ts +63 -0
  80. package/dist/web/primitives/field.js +181 -0
  81. package/dist/web/primitives/form.d.ts +53 -0
  82. package/dist/web/primitives/form.js +252 -0
  83. package/dist/web/primitives/index.d.ts +7 -30
  84. package/dist/web/primitives/index.js +27 -2006
  85. package/dist/web/primitives/input.d.ts +4 -0
  86. package/dist/web/primitives/input.js +166 -0
  87. package/dist/web/primitives/layout-elements.d.ts +14 -0
  88. package/dist/web/primitives/layout-elements.js +194 -0
  89. package/dist/web/primitives/modal.js +1 -1
  90. package/dist/web/primitives/pager.d.ts +22 -0
  91. package/dist/web/primitives/pager.js +89 -0
  92. package/dist/web/primitives/row-actions.d.ts +13 -0
  93. package/dist/web/primitives/row-actions.js +178 -0
  94. package/dist/web/primitives/secret-reveal.d.ts +4 -0
  95. package/dist/web/primitives/secret-reveal.js +67 -0
  96. package/package.json +5 -5
  97. package/src/changes.json +6 -0
@@ -0,0 +1,395 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { evalFieldCondition, isRecordLocked } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
+ import { useCallback, useMemo, useRef, useState } from "react";
5
+ import { extractCreatedId } from "../components/reference-create-dialog.js";
6
+ import { RenderEdit, } from "../components/render-edit.js";
7
+ import { useDispatcher } from "../context/dispatcher-context.js";
8
+ import { useUserRoles } from "../context/user-roles-context.js";
9
+ import { useOptionalCurrentUser } from "../current-user.js";
10
+ import { useQuery } from "../hooks/use-query.js";
11
+ import { useOptionalTimeZone, useTranslation } from "../i18n.js";
12
+ import { usePrimitives } from "../primitives.js";
13
+ import { screenFillsHeight } from "../screen-fills-height.js";
14
+ import { DrawerHost, useDrawerAction } from "./action-form-body.js";
15
+ import { useAppFeatures } from "./app-features-context.js";
16
+ import { useClientActionRuntime } from "./client-actions.js";
17
+ import { applyEditPrefill } from "./edit-prefill.js";
18
+ import { buildInitialValues, mergeSearchParamsIntoInitial, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, versionOf, } from "./entity-edit-prefill.js";
19
+ import { buildFormSchema } from "./form-schema.js";
20
+ import { layoutEditFields, layoutFieldNames } from "./layout-fields.js";
21
+ import { useInitialValuesHandoff, useNav } from "./nav.js";
22
+ import { lastSegment, toKebab } from "./qn.js";
23
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
24
+ import { navigateToReturn, navigateToReturnOr, useReturnHost, useReturnTarget, } from "./return-to.js";
25
+ import { buildRecordActions } from "./row-actions.js";
26
+ import { entityQueryCommand, entityWriteCommand, findEntityListScreen, isEntityEditScreenOfOtherEntity, navigateAfterRedirect, navigateToRedirectTarget, overlayUpdatedProjection, RecordNotFoundState, resolveRedirectTarget, returnToWinsOverRedirect, useNavigateToListAfter, } from "./screen-shared.js";
27
+ // Shared by the update payload shape and the server-error path prefix so both can't drift.
28
+ const UPDATE_CHANGES_KEY = "changes";
29
+ export function EntityEditScreen({ schema, screen, translate, entityId, onCopyLink, }) {
30
+ const { Banner, Text } = usePrimitives();
31
+ const entity = schema.entities[screen.entity];
32
+ if (!entity) {
33
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-entity-missing", children: ["Entity ", _jsx(Text, { variant: "code", children: screen.entity }), " referenced by screen", " ", _jsx(Text, { variant: "code", children: screen.id }), " not registered in the schema."] }));
34
+ }
35
+ // Split into create-body / update-body so the update-only hooks
36
+ // (useQuery(detail)) don't fire in create mode, and vice versa.
37
+ // Same shape as EntityListScreen.
38
+ if (entityId !== undefined) {
39
+ return (_jsx(EntityEditUpdateBody, { schema: schema, screen: screen, entity: entity, entityId: entityId, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }) }));
40
+ }
41
+ if (screen.singleton === true) {
42
+ return (_jsx(EntityEditSingletonBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }) }));
43
+ }
44
+ return (_jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }) }));
45
+ }
46
+ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
47
+ const nav = useNav();
48
+ const handoffValues = useInitialValuesHandoff(screen.id);
49
+ const appFeatures = useAppFeatures();
50
+ const returnTargetParam = useReturnTarget(screen.id);
51
+ // A singleton stays on its own screen after saving (onSaved/refetch) —
52
+ // returnTo would fight that.
53
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
54
+ const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
55
+ // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
56
+ // its empty-value currency from the tenant-settings config key instead of
57
+ // entityDefaultCurrency — `enabled` keeps the query a no-op for entities
58
+ // without any such field.
59
+ const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(entity.fields), [entity.fields]);
60
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
61
+ const currentUser = useOptionalCurrentUser();
62
+ const fallbackTimeZone = useOptionalTimeZone();
63
+ const initial = useMemo(() => applyEditPrefill(mergeSearchParamsIntoInitial(entity.fields, {
64
+ searchParams: nav.searchParams,
65
+ urlPrefillFields: screen.urlPrefillFields,
66
+ renderableFields: layoutFieldNames(screen),
67
+ defaultCurrency: entityDefaultCurrency,
68
+ ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
69
+ ...(handoffValues !== undefined && { handoffValues }),
70
+ }), layoutEditFields(screen), { now: new Date(), fallbackTimeZone, user: currentUser }), [
71
+ entity.fields,
72
+ nav.searchParams,
73
+ screen,
74
+ entityDefaultCurrency,
75
+ moneyCurrencyOverrides,
76
+ handoffValues,
77
+ currentUser,
78
+ fallbackTimeZone,
79
+ ]);
80
+ const formSchema = useMemo(() => buildFormSchema(entity, screen, "create"), [entity, screen]);
81
+ const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
82
+ const navigateToList = useNavigateToListAfter(schema, screen.entity);
83
+ const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
84
+ // Create's write-handler success payload doesn't flatly expose a parent FK
85
+ // (`{ kind, id, data, … }`), so an object-form redirect's `idFrom` falls
86
+ // back to the values just submitted to the handler.
87
+ const controlsRef = useRef(null);
88
+ const handleControlsReady = useCallback((controls) => {
89
+ controlsRef.current = controls;
90
+ }, []);
91
+ const handleSubmitted = useCallback((result) => {
92
+ if (!result.isSuccess)
93
+ return;
94
+ const redirect = screen.redirect;
95
+ if (redirect !== undefined) {
96
+ if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
97
+ navigateToReturn(nav, returnTarget);
98
+ }
99
+ else if (typeof redirect === "string") {
100
+ // String form unchanged: always carries the newly created
101
+ // record's own id, regardless of the target screen's type. The
102
+ // object form resolves like actionForm's — a child record's own id
103
+ // is useless for a parent-detail redirect.
104
+ const entityId = extractCreatedId(result.data);
105
+ nav.navigate({
106
+ screenId: lastSegment(redirect),
107
+ ...(entityId !== undefined && { entityId }),
108
+ });
109
+ }
110
+ else {
111
+ navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, result.data, schema, appFeatures, controlsRef.current?.getValues()));
112
+ }
113
+ }
114
+ else {
115
+ navigateToReturnOr(nav, returnTarget, navigateToList);
116
+ onSaved?.();
117
+ }
118
+ }, [nav, screen.redirect, schema, appFeatures, navigateToList, onSaved, returnTarget]);
119
+ // Never seed a tenant-declared money field with entityDefaultCurrency while
120
+ // its real tenant currency is still in flight (fw#2933) — wait instead.
121
+ if (currencyLoading) {
122
+ return _jsx(QueryLoadingBanner, {});
123
+ }
124
+ // Deliberately no `actions` prop here: `screen.actions` targets an
125
+ // EXISTING record (publish/archive/duplicate and friends), which the
126
+ // create branch has none of yet — see EntityEditUpdateForm for the
127
+ // wired-up counterpart.
128
+ return (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial, schema: formSchema, writeCommand: writeCommand, onSubmit: handleSubmitted, onControlsReady: handleControlsReady, onCancel: handleCancel, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }) }));
129
+ }
130
+ // Update body: loads the existing record via `<feature>:query:<entity>:detail`,
131
+ // then mounts a form pre-filled with the server values and dispatches
132
+ // `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
133
+ // snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
134
+ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onCopyLink, autoReloadOnSave, onDeleted, }) {
135
+ const t = useTranslation();
136
+ const effectiveTranslate = translate ?? t;
137
+ const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
138
+ const detailQuery = useQuery(detailQn, { id: entityId });
139
+ const nav = useNav();
140
+ const navigateToList = useNavigateToListAfter(schema, screen.entity);
141
+ const returnTargetParam = useReturnTarget(screen.id);
142
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
143
+ if (detailQuery.loading && detailQuery.data === null) {
144
+ return _jsx(QueryLoadingBanner, {});
145
+ }
146
+ if (detailQuery.error && detailQuery.data === null) {
147
+ return (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch }));
148
+ }
149
+ // A failed refetch keeps the last good data on screen.
150
+ const refetchErrorBanner = detailQuery.error ? (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch })) : null;
151
+ const record = detailQuery.data;
152
+ if (!record) {
153
+ const onBack = returnTarget !== undefined || findEntityListScreen(schema, screen.entity) !== undefined
154
+ ? () => navigateToReturnOr(nav, returnTarget, navigateToList)
155
+ : undefined;
156
+ return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(onBack !== undefined && { onBack }) }));
157
+ }
158
+ // Record version as React key: on "Reload" the detail query refetches,
159
+ // returns a new version and the form remounts completely. Without the
160
+ // key change the useForm controller (lifetime-scoped) would stay on the
161
+ // original version, buildPayload would keep stamping the stale version
162
+ // and the conflict recovery would be broken.
163
+ const recordKey = versionOf(record);
164
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditUpdateForm, { schema: schema, screen: screen, entity: entity, entityId: entityId, record: record, onReload: detailQuery.refetch, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(autoReloadOnSave === true && { onSaved: () => void detailQuery.refetch() }), ...(onDeleted !== undefined && { onDeleted }) }, `${entityId}:${recordKey}`)] }));
165
+ }
166
+ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onReload, translate, onCopyLink, onSaved, onDeleted, }) {
167
+ // Seed the form with the server values for the entity's declared
168
+ // fields; anything else (id, tenant_id, created_at…) stays out of
169
+ // the form and lives in the closure. The record's `version` is
170
+ // captured once and stamped into every update payload — if a
171
+ // concurrent writer bumps it, the server returns a version-conflict
172
+ // error and the user reloads.
173
+ const recordVersion = versionOf(record);
174
+ const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
175
+ // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
176
+ // its empty-value currency from the tenant-settings config key instead of
177
+ // entityDefaultCurrency — `enabled` keeps the query a no-op for entities
178
+ // without any such field. A record's own stored value (below) always wins
179
+ // over this, regardless of the declaration.
180
+ //
181
+ // Only fields the record itself has no value for need the fetch at all —
182
+ // an already-set tenant-declared field keeps its own stored currency and
183
+ // must never block the form on this query.
184
+ const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(entity.fields).filter((name) => record[name] === null || record[name] === undefined), [entity.fields, record]);
185
+ const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
186
+ const initial = useMemo(() => {
187
+ const out = {};
188
+ for (const name of Object.keys(entity.fields)) {
189
+ out[name] =
190
+ record[name] ??
191
+ buildInitialValues({ [name]: entity.fields[name] }, entityDefaultCurrency, moneyCurrencyOverrides)[name];
192
+ }
193
+ return out;
194
+ }, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
195
+ const formSchema = useMemo(() => buildFormSchema(entity, screen, "update", initial), [entity, screen, initial]);
196
+ // Hand extension values (e.g. customFields jsonb) to the extension sections
197
+ // so they show the existing data on edit instead of being write-only.
198
+ const extensionInitialValues = useMemo(() => {
199
+ const cf = record["customFields"];
200
+ return isPlainObject(cf) ? cf : undefined;
201
+ }, [record]);
202
+ const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "update");
203
+ const deleteCommand = entityWriteCommand(schema.featureName, screen.entity, "delete");
204
+ const buildPayload = useMemo(() => (snap) => ({
205
+ id: entityId,
206
+ version: recordVersion,
207
+ [UPDATE_CHANGES_KEY]: snap.changes,
208
+ }), [entityId, recordVersion]);
209
+ const [formDirty, setFormDirty] = useState(false);
210
+ const nav = useNav();
211
+ const appFeatures = useAppFeatures();
212
+ const dispatcher = useDispatcher();
213
+ const clientActions = useClientActionRuntime();
214
+ const t = useTranslation();
215
+ const effectiveTranslate = translate ?? t;
216
+ const navigateToList = useNavigateToListAfter(schema, screen.entity);
217
+ const userRoles = useUserRoles();
218
+ const host = useReturnHost();
219
+ const returnTargetParam = useReturnTarget(screen.id);
220
+ // A singleton stays on its own screen after saving (onSaved/refetch) —
221
+ // returnTo would fight that.
222
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
223
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
224
+ // Header action buttons (fw entityEdit-actions) — same shape/dispatch
225
+ // pattern as ProjectionDetailBody.headerActions above (the edited record
226
+ // stands in for the "row"), minus the cross-feature defaultEditAction
227
+ // lookup that doesn't apply here (this screen already IS the edit form).
228
+ const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
229
+ // Never return onto the just-deleted record.
230
+ const target = deletedShownRecord && returnTarget?.entityId === entityId ? undefined : returnTarget;
231
+ if (deletedShownRecord)
232
+ onDeleted?.();
233
+ if (action.redirect !== undefined) {
234
+ navigateAfterRedirect({
235
+ nav,
236
+ schema,
237
+ appFeatures,
238
+ returnTarget: target,
239
+ redirect: action.redirect,
240
+ resultData,
241
+ record,
242
+ });
243
+ return;
244
+ }
245
+ navigateToReturnOr(nav, target, () => {
246
+ if (findEntityListScreen(schema, screen.entity) !== undefined)
247
+ navigateToList();
248
+ else if (onDeleted === undefined)
249
+ void onReload();
250
+ });
251
+ }, [
252
+ nav,
253
+ schema,
254
+ appFeatures,
255
+ returnTarget,
256
+ record,
257
+ entityId,
258
+ navigateToList,
259
+ onDeleted,
260
+ onReload,
261
+ screen.entity,
262
+ ]);
263
+ const sameEntityScreenId = useCallback((targetScreen) => isEntityEditScreenOfOtherEntity(screen.entity, targetScreen, schema, appFeatures)
264
+ ? undefined
265
+ : entityId, [screen.entity, schema, appFeatures, entityId]);
266
+ const buildSectionActions = useCallback((actions) => buildRecordActions({
267
+ actions,
268
+ record,
269
+ translate: effectiveTranslate,
270
+ nav,
271
+ host,
272
+ dispatcher,
273
+ openDrawer,
274
+ onWriteSuccess: onReload,
275
+ onRecordLeft: handleRecordLeft,
276
+ sameEntityScreenId,
277
+ clientActions,
278
+ defaultWritePayloadId: entityId,
279
+ ...(formDirty && {
280
+ discardChanges: {
281
+ confirm: effectiveTranslate("kumiko.form.unsavedChangesActionConfirm"),
282
+ confirmLabel: effectiveTranslate("kumiko.form.unsavedChangesActionConfirmLabel"),
283
+ },
284
+ }),
285
+ }), [
286
+ formDirty,
287
+ record,
288
+ effectiveTranslate,
289
+ nav,
290
+ host,
291
+ dispatcher,
292
+ openDrawer,
293
+ onReload,
294
+ handleRecordLeft,
295
+ sameEntityScreenId,
296
+ clientActions,
297
+ entityId,
298
+ ]);
299
+ const headerActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement !== "menu")), [buildSectionActions, screen.actions]);
300
+ const headerMenuActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement === "menu")), [buildSectionActions, screen.actions]);
301
+ const handleSubmitted = useCallback((result) => {
302
+ if (!result.isSuccess)
303
+ return;
304
+ const redirect = screen.redirect;
305
+ if (redirect !== undefined) {
306
+ // The update handler's success payload usually reports only this
307
+ // record's own id (event-store-executor-write.ts), so a parent FK
308
+ // named by `idFrom` falls back to the already-loaded `record`.
309
+ navigateAfterRedirect({
310
+ nav,
311
+ schema,
312
+ appFeatures,
313
+ returnTarget,
314
+ redirect,
315
+ resultData: result.data,
316
+ record: overlayUpdatedProjection(record, result.data),
317
+ });
318
+ }
319
+ else {
320
+ navigateToReturnOr(nav, returnTarget, navigateToList);
321
+ onSaved?.();
322
+ }
323
+ }, [nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget]);
324
+ const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
325
+ const handleDelete = useCallback(async () => {
326
+ const res = await dispatcher.write(deleteCommand, { id: entityId });
327
+ if (!res.isSuccess)
328
+ return res.error;
329
+ // Never return onto the just-deleted record.
330
+ const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
331
+ navigateToReturnOr(nav, target, navigateToList);
332
+ onDeleted?.();
333
+ return undefined;
334
+ }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
335
+ // Never seed a tenant-declared money field with entityDefaultCurrency while
336
+ // its real tenant currency is still in flight (fw#2933) — wait instead. A
337
+ // stored (non-empty) value is unaffected — it never reaches this branch's
338
+ // fallback because `initial` above already prefers `record[name]`.
339
+ if (currencyLoading) {
340
+ return _jsx(QueryLoadingBanner, {});
341
+ }
342
+ // Evaluated against the stored record only, so typing in a form can never
343
+ // flip the screen between editable and view.
344
+ // Either condition shows the view: the server rejects saves of a locked record
345
+ // whatever the screen says, so a screen condition can only add read-only, never lift it.
346
+ // A record without the lock field counts as locked (isRecordLocked), matching the server.
347
+ const viewMode = (screen.readOnly !== undefined && evalFieldCondition(screen.readOnly, record)) ||
348
+ isRecordLocked(entity.lockedWhen, record);
349
+ return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial, ...(viewMode && { viewMode: true }),
350
+ // Real route id for the extension section (set-value UI): the update
351
+ // form deliberately keeps `id` out of the form values, so the section
352
+ // needs it explicitly — otherwise it renders in create mode.
353
+ entityId: entityId,
354
+ // Stored customFields for the extension section, so editing shows the
355
+ // persisted values instead of behaving write-only.
356
+ extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, onDirtyChange: setFormDirty, ...(screen.allowDelete !== false && !viewMode && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), ...(headerMenuActions !== undefined && { menuActions: headerMenuActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
357
+ closeDrawer();
358
+ void onReload();
359
+ } })] }));
360
+ }
361
+ // Singleton entities (`singleton: true`, exactly one record per tenant):
362
+ // EntityEditScreen reaches here only without an entityId. Resolve the
363
+ // existing record via list(limit:1) before deciding create vs update —
364
+ // otherwise every visit without an id would create another record.
365
+ function EntityEditSingletonBody({ schema, screen, entity, translate, onCopyLink, }) {
366
+ const t = useTranslation();
367
+ const effectiveTranslate = translate ?? t;
368
+ const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
369
+ const listQuery = useQuery(listQn, { limit: 1 });
370
+ if (listQuery.loading && listQuery.data === null) {
371
+ return _jsx(QueryLoadingBanner, {});
372
+ }
373
+ if (listQuery.error && listQuery.data === null) {
374
+ return (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch }));
375
+ }
376
+ // A failed refetch keeps the last good data on screen.
377
+ const refetchErrorBanner = listQuery.error ? (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch })) : null;
378
+ const rawExistingId = listQuery.data?.rows[0]?.["id"];
379
+ const existingId = typeof rawExistingId === "string" ? rawExistingId : undefined;
380
+ if (existingId !== undefined) {
381
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditUpdateBody, { schema: schema, screen: screen, entity: entity, entityId: existingId, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), autoReloadOnSave: true, onDeleted: () => void listQuery.refetch() })] }));
382
+ }
383
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), onSaved: () => void listQuery.refetch() })] }));
384
+ }
385
+ // Shared no-entityId tail for both the plain create path and the
386
+ // singleton path's empty-table fallback: `allowCreate: false` blocks
387
+ // both the same way, since a create submit there would hit an
388
+ // unregistered `<entity>:create` handler.
389
+ function EntityEditCreateOrDisabled({ schema, screen, entity, translate, onSaved, }) {
390
+ const { Banner, Text } = usePrimitives();
391
+ if (screen.allowCreate === false) {
392
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-create-disabled", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " is update-only (allowCreate: false) \u2014 open it from a row action with an entity id."] }));
393
+ }
394
+ return (_jsx(EntityEditCreateBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onSaved !== undefined && { onSaved }) }));
395
+ }
@@ -0,0 +1,10 @@
1
+ import type { EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ReactNode } from "react";
3
+ import type { ListRowViewModel, Translate } from "../headless/index.ts";
4
+ import type { FeatureSchema } from "./feature-schema.ts";
5
+ export declare function EntityListScreen({ schema, screen, translate, onRowClick, }: {
6
+ readonly schema: FeatureSchema;
7
+ readonly screen: EntityListScreenDefinition;
8
+ readonly translate?: Translate;
9
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
10
+ }): ReactNode;