@cosmicdrift/kumiko-renderer 0.384.0 → 0.386.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/action-form-body.d.ts +50 -0
- package/dist/app/action-form-body.js +275 -0
- package/dist/app/client-actions.js +2 -2
- package/dist/app/config-edit-body.d.ts +9 -0
- package/dist/app/config-edit-body.js +340 -0
- package/dist/app/entity-edit-prefill.d.ts +27 -0
- package/dist/app/entity-edit-prefill.js +377 -0
- package/dist/app/entity-edit-screen.d.ts +11 -0
- package/dist/app/entity-edit-screen.js +395 -0
- package/dist/app/entity-list-screen.d.ts +10 -0
- package/dist/app/entity-list-screen.js +547 -0
- package/dist/app/kumiko-screen.d.ts +1 -36
- package/dist/app/kumiko-screen.js +16 -2855
- package/dist/app/projection-detail-body.d.ts +12 -0
- package/dist/app/projection-detail-body.js +464 -0
- package/dist/app/projection-detail-shim.d.ts +1 -1
- package/dist/app/projection-detail-shim.js +5 -1
- package/dist/app/projection-list-body.d.ts +10 -0
- package/dist/app/projection-list-body.js +245 -0
- package/dist/app/projection-list-shim.d.ts +2 -1
- package/dist/app/projection-list-shim.js +4 -0
- package/dist/app/reference-facet-bridge.js +2 -2
- package/dist/app/row-actions.js +3 -4
- package/dist/app/screen-shared.d.ts +72 -0
- package/dist/app/screen-shared.js +325 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/components/edit-mounts.d.ts +33 -0
- package/dist/components/edit-mounts.js +53 -0
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +29 -6
- package/dist/components/render-edit-helpers.d.ts +29 -0
- package/dist/components/render-edit-helpers.js +121 -0
- package/dist/components/render-edit.d.ts +1 -4
- package/dist/components/render-edit.js +41 -443
- package/dist/components/render-field.js +3 -4
- package/dist/components/render-list.d.ts +1 -1
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-wizard-form-draft.d.ts +51 -0
- package/dist/components/use-wizard-form-draft.js +291 -0
- package/dist/dev-console.d.ts +3 -0
- package/dist/dev-console.js +15 -0
- package/dist/headless/contracts/primitives.js +14 -17
- package/dist/headless/format/index.js +3 -3
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +9 -16
- package/dist/headless/view-model/list.d.ts +1 -1
- package/dist/headless/view-model/list.js +3 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -5
- package/dist/live/error-mapping.js +3 -4
- package/dist/web/app/create-app.js +5 -8
- package/dist/web/app/dashboard-body.js +4 -599
- package/dist/web/app/dashboard-panels.d.ts +53 -0
- package/dist/web/app/dashboard-panels.js +373 -0
- package/dist/web/app/dashboard-shared.d.ts +90 -0
- package/dist/web/app/dashboard-shared.js +171 -0
- package/dist/web/app/dashboard-text.d.ts +6 -0
- package/dist/web/app/dashboard-text.js +65 -0
- package/dist/web/layout/nav-tree.js +3 -4
- package/dist/web/layout/target-resolver-stub.js +2 -2
- package/dist/web/primitives/banner.d.ts +4 -0
- package/dist/web/primitives/banner.js +28 -0
- package/dist/web/primitives/button.d.ts +7 -0
- package/dist/web/primitives/button.js +48 -0
- package/dist/web/primitives/card-styles.d.ts +8 -0
- package/dist/web/primitives/card-styles.js +18 -0
- package/dist/web/primitives/card.d.ts +4 -0
- package/dist/web/primitives/card.js +41 -0
- package/dist/web/primitives/contexts.d.ts +22 -0
- package/dist/web/primitives/contexts.js +50 -0
- package/dist/web/primitives/data-table-cell.d.ts +76 -0
- package/dist/web/primitives/data-table-cell.js +403 -0
- package/dist/web/primitives/data-table.d.ts +4 -0
- package/dist/web/primitives/data-table.js +279 -0
- package/dist/web/primitives/default-form.d.ts +4 -0
- package/dist/web/primitives/default-form.js +79 -0
- package/dist/web/primitives/facet-filter.d.ts +14 -0
- package/dist/web/primitives/facet-filter.js +39 -0
- package/dist/web/primitives/field.d.ts +63 -0
- package/dist/web/primitives/field.js +181 -0
- package/dist/web/primitives/form.d.ts +53 -0
- package/dist/web/primitives/form.js +252 -0
- package/dist/web/primitives/index.d.ts +7 -30
- package/dist/web/primitives/index.js +27 -2006
- package/dist/web/primitives/input.d.ts +4 -0
- package/dist/web/primitives/input.js +166 -0
- package/dist/web/primitives/layout-elements.d.ts +14 -0
- package/dist/web/primitives/layout-elements.js +194 -0
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/pager.d.ts +22 -0
- package/dist/web/primitives/pager.js +89 -0
- package/dist/web/primitives/row-actions.d.ts +13 -0
- package/dist/web/primitives/row-actions.js +178 -0
- package/dist/web/primitives/secret-reveal.d.ts +4 -0
- package/dist/web/primitives/secret-reveal.js +67 -0
- package/package.json +5 -5
- 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;
|