@cosmicdrift/kumiko-renderer 0.377.0 → 0.379.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-shim.js +1 -2
- package/dist/app/action-form-support.js +16 -4
- package/dist/app/kumiko-screen.js +51 -38
- package/dist/app/list-empty-content.d.ts +1 -0
- package/dist/app/list-empty-content.js +5 -0
- package/dist/app/row-actions.d.ts +7 -1
- package/dist/app/row-actions.js +6 -1
- package/dist/app/secret-mint-body.js +3 -2
- package/dist/components/embedded-list-field.js +16 -7
- package/dist/components/file-list-field.js +4 -0
- package/dist/components/form-derivation-patch.d.ts +8 -0
- package/dist/components/form-derivation-patch.js +24 -0
- package/dist/components/form-screen-layout.d.ts +13 -0
- package/dist/components/form-screen-layout.js +13 -0
- package/dist/components/grid-cell-for-field.d.ts +4 -1
- package/dist/components/grid-cell-for-field.js +6 -2
- package/dist/components/list-empty-state.d.ts +4 -1
- package/dist/components/list-empty-state.js +4 -1
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +4 -1
- package/dist/components/render-edit-logic.d.ts +1 -1
- package/dist/components/render-edit-logic.js +8 -2
- package/dist/components/render-edit-types.d.ts +7 -1
- package/dist/components/render-edit.js +38 -6
- package/dist/components/render-field.d.ts +3 -1
- package/dist/components/render-field.js +27 -8
- package/dist/components/render-list.d.ts +3 -1
- package/dist/components/render-list.js +29 -8
- package/dist/components/write-form-section.js +4 -1
- package/dist/field-issue-text.d.ts +4 -0
- package/dist/field-issue-text.js +14 -0
- package/dist/file-ids-of.d.ts +1 -0
- package/dist/file-ids-of.js +7 -0
- package/dist/flow-diagram/icons.d.ts +3 -0
- package/dist/flow-diagram/icons.js +14 -0
- package/dist/flow-diagram/index.d.ts +2 -0
- package/dist/flow-diagram/index.js +1 -0
- package/dist/flow-diagram/render.d.ts +3 -0
- package/dist/flow-diagram/render.js +234 -0
- package/dist/flow-diagram/types.d.ts +18 -0
- package/dist/flow-diagram/types.js +1 -0
- package/dist/headless/dispatcher/index.d.ts +1 -0
- package/dist/headless/dispatcher/index.js +1 -0
- package/dist/headless/dispatcher/request-id.d.ts +1 -0
- package/dist/headless/dispatcher/request-id.js +15 -0
- package/dist/headless/form/form-controller.js +16 -1
- package/dist/headless/form/zod-bridge.js +7 -0
- package/dist/headless/index.d.ts +2 -1
- package/dist/headless/index.js +2 -1
- package/dist/headless/plural-key.d.ts +1 -0
- package/dist/headless/plural-key.js +5 -0
- package/dist/headless/view-model/edit.d.ts +8 -1
- package/dist/headless/view-model/edit.js +23 -7
- package/dist/headless/view-model/types.d.ts +11 -3
- package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
- package/dist/hooks/live-refetch-scheduler.js +45 -0
- package/dist/hooks/use-query.js +8 -4
- package/dist/hooks/use-reference-lookup.d.ts +1 -0
- package/dist/hooks/use-reference-lookup.js +6 -3
- package/dist/i18n-defaults.js +28 -3
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/leave-guard-dialog.d.ts +2 -0
- package/dist/leave-guard-dialog.js +28 -0
- package/dist/leave-guard.d.ts +15 -0
- package/dist/leave-guard.js +39 -0
- package/dist/live/dispatcher-live.js +1 -16
- package/dist/primitives.d.ts +39 -0
- package/dist/sse/live-events.d.ts +3 -0
- package/dist/sse/live-events.js +3 -0
- package/dist/web/app/create-app.js +2 -2
- package/dist/web/app/dashboard-body.js +13 -1
- package/dist/web/app/nav.js +51 -9
- package/dist/web/index.d.ts +2 -2
- package/dist/web/index.js +1 -1
- package/dist/web/layout/nav-tree.js +13 -3
- package/dist/web/layout/shell-header.js +3 -3
- package/dist/web/lib/file-upload-form-data.d.ts +2 -0
- package/dist/web/lib/file-upload-form-data.js +2 -0
- package/dist/web/lib/upload-error-key.d.ts +7 -0
- package/dist/web/lib/upload-error-key.js +23 -0
- package/dist/web/lib/upload-file-ref.d.ts +1 -1
- package/dist/web/primitives/embedded-list-input.js +5 -4
- package/dist/web/primitives/file-thumbnail.d.ts +6 -0
- package/dist/web/primitives/file-thumbnail.js +19 -19
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +11 -7
- package/dist/web/primitives/file-value-list.d.ts +3 -0
- package/dist/web/primitives/file-value-list.js +22 -0
- package/dist/web/primitives/files-dropzone.d.ts +16 -0
- package/dist/web/primitives/files-dropzone.js +52 -0
- package/dist/web/primitives/index.js +65 -30
- package/dist/web/primitives/segmented-select.js +8 -2
- package/dist/web/sse/live-events.d.ts +13 -0
- package/dist/web/sse/live-events.js +88 -23
- package/dist/web/widgets/bounded-load-cache.d.ts +1 -0
- package/dist/web/widgets/bounded-load-cache.js +25 -0
- package/dist/web/widgets/drawer.js +37 -2
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +1 -0
- package/dist/web/widgets/pdf-preview.d.ts +10 -0
- package/dist/web/widgets/pdf-preview.js +140 -0
- package/dist/web/widgets/upload-zone.d.ts +11 -2
- package/dist/web/widgets/upload-zone.js +30 -7
- package/package.json +14 -5
- package/src/changes.json +143 -0
- package/src/web/styles.css +13 -9
|
@@ -29,10 +29,14 @@ export function fieldCellWidth(field) {
|
|
|
29
29
|
return "full";
|
|
30
30
|
return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
|
|
31
31
|
}
|
|
32
|
-
export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, }) {
|
|
32
|
+
export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }) {
|
|
33
33
|
// RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
|
|
34
34
|
if (!field.visible)
|
|
35
35
|
return null;
|
|
36
|
+
const hideLabel = sectionTitle !== undefined &&
|
|
37
|
+
field.type === "embedded" &&
|
|
38
|
+
field.embeddedListCells !== undefined &&
|
|
39
|
+
field.label === sectionTitle;
|
|
36
40
|
const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
|
|
37
|
-
return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }) }) }));
|
|
41
|
+
return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }), ...(hideLabel && { hideLabel }) }) }));
|
|
38
42
|
}
|
|
@@ -5,10 +5,13 @@ export type ListEmptyStateContent = {
|
|
|
5
5
|
readonly description?: string;
|
|
6
6
|
readonly action?: RenderEditAction;
|
|
7
7
|
};
|
|
8
|
-
export declare function ListEmptyStateBody({ content, narrowed, createButton, translate, }: {
|
|
8
|
+
export declare function ListEmptyStateBody({ content, narrowed, defaultFilterActive, onShowAll, createButton, translate, }: {
|
|
9
9
|
readonly content: ListEmptyStateContent | undefined;
|
|
10
10
|
/** A search term or changed filter produced the empty result, so "create the first one" would mislead. */
|
|
11
11
|
readonly narrowed: boolean;
|
|
12
|
+
/** A default filter is on and nothing else narrows: rows may exist outside it. */
|
|
13
|
+
readonly defaultFilterActive?: boolean;
|
|
14
|
+
readonly onShowAll?: () => void;
|
|
12
15
|
readonly createButton: ReactNode;
|
|
13
16
|
readonly translate: (key: string) => string;
|
|
14
17
|
}): ReactNode;
|
|
@@ -2,11 +2,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { usePrimitives } from "../primitives.js";
|
|
4
4
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
5
|
-
export function ListEmptyStateBody({ content, narrowed, createButton, translate, }) {
|
|
5
|
+
export function ListEmptyStateBody({ content, narrowed, defaultFilterActive = false, onShowAll, createButton, translate, }) {
|
|
6
6
|
const { Text, Button, Dialog, Banner } = usePrimitives();
|
|
7
7
|
const [actionError, setActionError] = useState(null);
|
|
8
8
|
if (narrowed)
|
|
9
9
|
return _jsx(Text, { children: translate("kumiko.list.empty.filtered") });
|
|
10
|
+
if (defaultFilterActive) {
|
|
11
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: translate("kumiko.list.empty.defaultFiltered") }), onShowAll !== undefined && (_jsx(Button, { variant: "secondary", onClick: onShowAll, testId: "render-list-empty-show-all", children: translate("kumiko.list.empty.showAll") }))] }));
|
|
12
|
+
}
|
|
10
13
|
const description = content === undefined ? translate("kumiko.list.empty.hint") : content.description;
|
|
11
14
|
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-list-empty-state-action-error", children: actionError })), createButton] }));
|
|
12
15
|
}
|
|
@@ -48,5 +48,5 @@ export function ReferenceCreateDialog({ open, onClose, onCreated, featureName, s
|
|
|
48
48
|
};
|
|
49
49
|
if (!open)
|
|
50
50
|
return null;
|
|
51
|
-
return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: currencyLoading ? (_jsx(Banner, { padded: true, variant: "loading", testId: "reference-create-dialog-loading", children: "
|
|
51
|
+
return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: currencyLoading ? (_jsx(Banner, { padded: true, variant: "loading", testId: "reference-create-dialog-loading", children: t("kumiko.widget.loading") })) : (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) })) }));
|
|
52
52
|
}
|
|
@@ -254,10 +254,13 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
254
254
|
: { [section.parentParam ?? "id"]: parentId },
|
|
255
255
|
record,
|
|
256
256
|
host,
|
|
257
|
+
access: { appFeatures, userRoles },
|
|
257
258
|
...(onOpenDrawer !== undefined && {
|
|
258
259
|
openDrawer: (action) => onOpenDrawer(action, undefined),
|
|
259
260
|
}),
|
|
260
261
|
}), [
|
|
262
|
+
appFeatures,
|
|
263
|
+
userRoles,
|
|
261
264
|
section.toolbarActions,
|
|
262
265
|
section.parentParam,
|
|
263
266
|
section.parentFilter,
|
|
@@ -319,7 +322,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
319
322
|
const truncated = rowsQuery.data !== null &&
|
|
320
323
|
typeof rowsQuery.data.nextCursor === "string" &&
|
|
321
324
|
(rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
|
|
322
|
-
const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: "
|
|
325
|
+
const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
|
|
323
326
|
count: sortedRows.length,
|
|
324
327
|
}) })), emptyStateActionError !== null && (_jsx(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: emptyStateActionError })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
|
|
325
328
|
searchable: true,
|
|
@@ -3,7 +3,7 @@ export declare function hasEditableSection(sections: readonly EditSectionViewMod
|
|
|
3
3
|
export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
|
|
4
4
|
export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
|
|
5
5
|
export declare function issueRootField(path: string): string;
|
|
6
|
-
export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
|
|
6
|
+
export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[], derivedTargets?: ReadonlySet<string>): boolean;
|
|
7
7
|
export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
|
|
8
8
|
export declare function findFirstErroringSectionIndex(sections: readonly EditSectionViewModel[], issuePaths: readonly string[]): number | undefined;
|
|
9
9
|
export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
|
|
@@ -32,9 +32,15 @@ export function issueRootField(path) {
|
|
|
32
32
|
return path.split(".")[0] ?? path;
|
|
33
33
|
}
|
|
34
34
|
// No issues at all counts as unmatched, so the caller keeps the banner.
|
|
35
|
-
|
|
35
|
+
// `derivedTargets` are head fields the form computes (actionForm `derive`): an
|
|
36
|
+
// issue on one is a knock-on of another issue (an empty list leaves the total
|
|
37
|
+
// without a value), nobody can fix it there. It is ignored while another issue
|
|
38
|
+
// exists, so the banner does not bury the real one; alone it keeps the banner.
|
|
39
|
+
export function hasIssueWithoutRenderedField(issuePaths, sections, derivedTargets = new Set()) {
|
|
40
|
+
const fixablePaths = issuePaths.filter((path) => !derivedTargets.has(issueRootField(path)));
|
|
41
|
+
const paths = fixablePaths.length > 0 ? fixablePaths : issuePaths;
|
|
36
42
|
const renderedFields = sections.flatMap((s) => s.kind === "fields" && s.visible ? s.fields : []);
|
|
37
|
-
return hasIssueWithoutVisibleField(
|
|
43
|
+
return hasIssueWithoutVisibleField(paths, renderedFields);
|
|
38
44
|
}
|
|
39
45
|
export function hasIssueWithoutVisibleField(issuePaths, fields) {
|
|
40
46
|
if (issuePaths.length === 0)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ActionFormFooterAction, EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import type { ActionFormFooterAction, EntityDefinition, EntityEditScreenDefinition, FormDerivationScreen, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import type * as z from "zod";
|
|
4
4
|
import type { DispatcherError, FormSnapshot, FormValues, SubmitResult, Translate } from "../headless/index.ts";
|
|
@@ -204,6 +204,12 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
204
204
|
* `disabled`, which locks the whole form. For a gate the host owns, e.g.
|
|
205
205
|
* an acknowledgement checkbox in `leadContent`. */
|
|
206
206
|
readonly submitBlocked?: boolean;
|
|
207
|
+
/** actionForm `derive`: derived fields are recomputed into the form state on every
|
|
208
|
+
* change and render read-only. */
|
|
209
|
+
readonly formDerivation?: FormDerivationScreen;
|
|
210
|
+
/** QN of the actionForm screen this form renders; its file fields upload as pending for that
|
|
211
|
+
* screen. Unset for entityEdit and for secretMint/confirm forms, which take no uploads. */
|
|
212
|
+
readonly actionFormScreenQn?: string;
|
|
207
213
|
/** Show the unsaved-changes footer (count, Discard, "Save changes") even
|
|
208
214
|
* without an entity id or a screen-height form — for hosts that edit an
|
|
209
215
|
* existing server-side record through `customSubmit` (configEdit, also when
|
|
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
4
|
import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "../headless/index.js";
|
|
5
|
+
import { changedDerivedValues } from "./form-derivation-patch.js";
|
|
5
6
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
6
7
|
import { AllFieldsRequiredProvider } from "./render-field.js";
|
|
7
8
|
import { useActionMenuItems } from "./use-action-menu-items.js";
|
|
@@ -15,6 +16,7 @@ import { formatWhen } from "../format-when.js";
|
|
|
15
16
|
import { useForm } from "../hooks/use-form.js";
|
|
16
17
|
import { useTranslation } from "../i18n.js";
|
|
17
18
|
import { useInsideDrawer } from "../inside-drawer.js";
|
|
19
|
+
import { useUnsavedInputSource } from "../leave-guard.js";
|
|
18
20
|
import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
|
|
19
21
|
import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
20
22
|
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
@@ -29,7 +31,7 @@ const FORM_DRAFT_GET = "form-draft:query:get";
|
|
|
29
31
|
const FORM_DRAFT_SAVE = "form-draft:write:save";
|
|
30
32
|
const FORM_DRAFT_DISCARD = "form-draft:write:discard";
|
|
31
33
|
const FORM_DRAFT_LIST = "form-draft:query:list";
|
|
32
|
-
// Mirrors bundled-features/src/form-draft/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
|
|
34
|
+
// Mirrors bundled-features/src/form-draft/web/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
|
|
33
35
|
// — same hardcoding rationale as the QNs above (no dependency on that package).
|
|
34
36
|
const FORM_DRAFT_KEY_MAX_LENGTH = 256;
|
|
35
37
|
// Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
|
|
@@ -48,7 +50,7 @@ function newDraftPrefix(screenId) {
|
|
|
48
50
|
return `${screenId}:new:`;
|
|
49
51
|
}
|
|
50
52
|
// `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
|
|
51
|
-
// without a polyfill — guard like dispatcher-
|
|
53
|
+
// without a polyfill — guard like generateRequestId in headless/dispatcher/request-id.ts.
|
|
52
54
|
function mintDraftId() {
|
|
53
55
|
const webCrypto = propertyOf(globalThis, "crypto");
|
|
54
56
|
const randomUUID = propertyOf(webCrypto, "randomUUID");
|
|
@@ -229,7 +231,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
|
|
|
229
231
|
return translate(template, values);
|
|
230
232
|
}
|
|
231
233
|
export function RenderEdit(props) {
|
|
232
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, } = props;
|
|
234
|
+
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, formDerivation, actionFormScreenQn, } = props;
|
|
233
235
|
// View mode is a locked form, so everything keyed on `disabled` follows.
|
|
234
236
|
const disabled = disabledProp || viewMode;
|
|
235
237
|
const i18nScreenId = props.i18nScreenId ?? screen.id;
|
|
@@ -254,6 +256,7 @@ export function RenderEdit(props) {
|
|
|
254
256
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
255
257
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
256
258
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
259
|
+
const [focusFirstErrorKey, setFocusFirstErrorKey] = useState(0);
|
|
257
260
|
const [formError, setFormError] = useState(null);
|
|
258
261
|
// Only a submit's validation/server error is cleared by editing; a delete
|
|
259
262
|
// rejection and a version conflict (reload is the way out) stay put.
|
|
@@ -304,9 +307,12 @@ export function RenderEdit(props) {
|
|
|
304
307
|
// never enters `fields`/`controller.getSnapshot().values` — there is no
|
|
305
308
|
// draft-blob-covered state left for persistExtensions() to compete over.
|
|
306
309
|
const [extensionDirty, setExtensionDirty] = useState(false);
|
|
310
|
+
// Mirror for the leave guard: it must see "saved" before React re-renders.
|
|
311
|
+
const extensionDirtyRef = useRef(false);
|
|
307
312
|
const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
|
|
308
313
|
const [extensionErrorKey, setExtensionErrorKey] = useState(null);
|
|
309
314
|
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(({ anyDirty, hasRegistrations }) => {
|
|
315
|
+
extensionDirtyRef.current = anyDirty;
|
|
310
316
|
setExtensionDirty(anyDirty);
|
|
311
317
|
setHasExtensionRegistrations(hasRegistrations);
|
|
312
318
|
});
|
|
@@ -367,6 +373,16 @@ export function RenderEdit(props) {
|
|
|
367
373
|
...(ctx !== undefined && { ctx }),
|
|
368
374
|
...(submitConfig !== undefined && { submit: submitConfig }),
|
|
369
375
|
});
|
|
376
|
+
// Derived fields are written into form state (not only displayed) so required
|
|
377
|
+
// validation and the submit payload carry them; only a real change is set, which
|
|
378
|
+
// keeps this from looping.
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
if (formDerivation === undefined)
|
|
381
|
+
return;
|
|
382
|
+
const patch = changedDerivedValues(snapshot.values, formDerivation);
|
|
383
|
+
if (patch !== undefined)
|
|
384
|
+
controller.setValues(patch); // @cast-boundary form-values
|
|
385
|
+
}, [snapshot.values, formDerivation, controller]);
|
|
370
386
|
// Wired at the user-input boundary, not in controller.setField: computed
|
|
371
387
|
// fields, prefill, draft restore and action patches also call setValues
|
|
372
388
|
// after a failed submit and must not wipe the errors.
|
|
@@ -541,6 +557,9 @@ export function RenderEdit(props) {
|
|
|
541
557
|
useEffect(() => {
|
|
542
558
|
onDirtyChangeRef.current?.(hasUnsavedInput);
|
|
543
559
|
}, [hasUnsavedInput]);
|
|
560
|
+
// Draft wizards keep their input server-side, so leaving loses nothing.
|
|
561
|
+
const persistsDraft = isWizard && screen.layout.draft === true;
|
|
562
|
+
useUnsavedInputSource(() => !persistsDraft && (controller.getSnapshot().isDirty || extensionDirtyRef.current));
|
|
544
563
|
useEffect(() => {
|
|
545
564
|
// skip: this screen does not persist a draft.
|
|
546
565
|
if (!draftEnabled || dispatcher === undefined)
|
|
@@ -654,7 +673,11 @@ export function RenderEdit(props) {
|
|
|
654
673
|
featureName,
|
|
655
674
|
mode: writeVerb,
|
|
656
675
|
...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
|
|
676
|
+
...(formDerivation !== undefined && { derivation: formDerivation }),
|
|
677
|
+
...(actionFormScreenQn !== undefined && { actionFormScreenQn }),
|
|
657
678
|
}), [
|
|
679
|
+
formDerivation,
|
|
680
|
+
actionFormScreenQn,
|
|
658
681
|
screen,
|
|
659
682
|
entity,
|
|
660
683
|
snapshot.values,
|
|
@@ -962,6 +985,7 @@ export function RenderEdit(props) {
|
|
|
962
985
|
// Same discard as the main path: an extension-only save is still a
|
|
963
986
|
// successful submit, so the draft must not survive it.
|
|
964
987
|
if (await persistExtensions()) {
|
|
988
|
+
extensionDirtyRef.current = false;
|
|
965
989
|
submittedRef.current = true;
|
|
966
990
|
await discardDraft();
|
|
967
991
|
}
|
|
@@ -1023,8 +1047,11 @@ export function RenderEdit(props) {
|
|
|
1023
1047
|
await discardDraft();
|
|
1024
1048
|
}
|
|
1025
1049
|
extensionsPersisted = await persistExtensions();
|
|
1050
|
+
if (extensionsPersisted)
|
|
1051
|
+
extensionDirtyRef.current = false;
|
|
1026
1052
|
}
|
|
1027
1053
|
else if (result.validationBlocked) {
|
|
1054
|
+
setFocusFirstErrorKey((key) => key + 1);
|
|
1028
1055
|
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
1029
1056
|
// — same "jump to the erroring step" treatment as a server field
|
|
1030
1057
|
// error below, sourced from the freshly-validated snapshot.
|
|
@@ -1037,6 +1064,8 @@ export function RenderEdit(props) {
|
|
|
1037
1064
|
else {
|
|
1038
1065
|
const fieldIssues = result.error.details?.fields ?? [];
|
|
1039
1066
|
const issuePaths = fieldIssues.map((i) => i.path);
|
|
1067
|
+
if (fieldIssues.length > 0)
|
|
1068
|
+
setFocusFirstErrorKey((key) => key + 1);
|
|
1040
1069
|
// controller.submit() writes server issues into the controller itself;
|
|
1041
1070
|
// customSubmit bypasses it, so push them here to reach the fields.
|
|
1042
1071
|
if (customSubmit !== undefined && fieldIssues.length > 0) {
|
|
@@ -1056,7 +1085,9 @@ export function RenderEdit(props) {
|
|
|
1056
1085
|
}
|
|
1057
1086
|
setExtraDeleteIssues([]);
|
|
1058
1087
|
setFormErrorSource("submit");
|
|
1059
|
-
setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections
|
|
1088
|
+
setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections, new Set(Object.keys(formDerivation?.derive ?? {})))
|
|
1089
|
+
? result.error
|
|
1090
|
+
: null);
|
|
1060
1091
|
}
|
|
1061
1092
|
// skip: on entity-success-but-extension-failure the extension-error
|
|
1062
1093
|
// banner is showing — don't notify (the caller navigates away on success
|
|
@@ -1291,7 +1322,7 @@ export function RenderEdit(props) {
|
|
|
1291
1322
|
!pageHeaderCompact &&
|
|
1292
1323
|
ActionOverflowMenu !== undefined && {
|
|
1293
1324
|
actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
|
|
1294
|
-
}) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true &&
|
|
1325
|
+
}) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(focusFirstErrorKey > 0 && { focusFirstErrorKey }), ...(hideSectionTitles !== true &&
|
|
1295
1326
|
!titleInShell &&
|
|
1296
1327
|
!titleInSettingsHeader &&
|
|
1297
1328
|
!recordTitleInHeaderRegion && { title: formTitle }), ...(formSubtitle !== undefined &&
|
|
@@ -1431,7 +1462,8 @@ export function RenderEdit(props) {
|
|
|
1431
1462
|
appendixPlacement: fieldAppendixPlacement,
|
|
1432
1463
|
}), ...(fieldDescription !== undefined && {
|
|
1433
1464
|
description: fieldDescription(field.field),
|
|
1434
|
-
}), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(
|
|
1465
|
+
}), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(hideSectionTitles !== true &&
|
|
1466
|
+
section.title !== undefined && { sectionTitle: section.title }), ...((!isCreate || markChangedFields) &&
|
|
1435
1467
|
field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
|
|
1436
1468
|
// Tabs mode: the tab panel is the surface, so field sections render
|
|
1437
1469
|
// as unframed padded panels; group cards inside stay framed.
|
|
@@ -42,6 +42,8 @@ export type RenderFieldProps = {
|
|
|
42
42
|
/** The surrounding form is locked as a whole: the fill button stays visible
|
|
43
43
|
* but inert, unlike a single readOnly field whose fill button keeps working. */
|
|
44
44
|
readonly fillDisabled?: boolean;
|
|
45
|
+
/** Label stays for screen readers only (sr-only). */
|
|
46
|
+
readonly hideLabel?: boolean;
|
|
45
47
|
};
|
|
46
48
|
export declare const AllFieldsRequiredProvider: import("react").Provider<boolean>;
|
|
47
|
-
export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, }: RenderFieldProps): ReactNode;
|
|
49
|
+
export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, hideLabel, }: RenderFieldProps): ReactNode;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { isFormatSpec, SYSTEM_REFERENCE_LABELS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { isFormatSpec, SYSTEM_REFERENCE_LABELS, UNRESOLVED_REFERENCE_LABEL_KEYS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
4
|
import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
5
5
|
import { useAppFeatures } from "../app/app-features-context.js";
|
|
@@ -11,6 +11,7 @@ import { evalRowExtractor } from "../app/row-actions.js";
|
|
|
11
11
|
import { screenAccessAllows } from "../app/screen-access.js";
|
|
12
12
|
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
13
13
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
14
|
+
import { fileIdsOf } from "../file-ids-of.js";
|
|
14
15
|
import { applyFormatSpec, currencyDecimals, EMPTY_VALUE_PLACEHOLDER, } from "../headless/index.js";
|
|
15
16
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
16
17
|
import { useQuery } from "../hooks/use-query.js";
|
|
@@ -25,7 +26,7 @@ import { QueryOptionsSelect } from "./query-options-select.js";
|
|
|
25
26
|
import { ReferenceCreateDialog } from "./reference-create-dialog.js";
|
|
26
27
|
const AllFieldsRequiredContext = createContext(false);
|
|
27
28
|
export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
|
|
28
|
-
export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, }) {
|
|
29
|
+
export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, hideLabel, }) {
|
|
29
30
|
const { Field, Input, Banner, Text, JsonView } = usePrimitives();
|
|
30
31
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
31
32
|
// navigator.language (Browser-Sprache) zurück statt der gewählten
|
|
@@ -48,6 +49,11 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
48
49
|
// Reference-Field rendert eine eigene Component — sie nutzt
|
|
49
50
|
// useQuery() für den Live-Lookup, also muss sie als React-
|
|
50
51
|
// Komponente gemountet werden (nicht als pure render-Call).
|
|
52
|
+
// The list input prints its own list-level issues (empty state included), so
|
|
53
|
+
// the Field wrapper must not print them a second time.
|
|
54
|
+
const listPrintsOwnIssues = field.type === "embedded" &&
|
|
55
|
+
field.embeddedListCells !== undefined &&
|
|
56
|
+
!(field.readOnly && field.renderer !== undefined);
|
|
51
57
|
const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : field.type === "select" &&
|
|
52
58
|
field.selectOptionsAvailabilityQuery !== undefined &&
|
|
53
59
|
!readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) && !isFileFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : field.fillAction !== undefined ? (_jsx(FillActionControl, { field: field, fillAction: field.fillAction, row: row ?? { [field.field]: field.value }, onChange: onChange, disabled: fillDisabled === true, Banner: Banner, renderControl: (fill) => renderInput({
|
|
@@ -76,7 +82,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
76
82
|
t,
|
|
77
83
|
row,
|
|
78
84
|
}));
|
|
79
|
-
return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), testId: `field-${field.field}`, children: control }));
|
|
85
|
+
return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && !listPrintsOwnIssues && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(hideLabel === true && { hideLabel }), testId: `field-${field.field}`, children: control }));
|
|
80
86
|
}
|
|
81
87
|
const NUMERIC_FIELD_TYPES = new Set(["number", "bigInt", "decimal"]);
|
|
82
88
|
// A text field takes a number as its string form; a numeric field takes only a
|
|
@@ -280,7 +286,7 @@ function inputId(field) {
|
|
|
280
286
|
// Doesn't resolve the `string` (cross-feature QN) FieldRenderer variant — the
|
|
281
287
|
// list-column path (DataTableCell) doesn't either, so this stays in parity.
|
|
282
288
|
function FieldRendererOutput({ field, renderer, row, appLocale, }) {
|
|
283
|
-
const { Text, JsonView } = usePrimitives();
|
|
289
|
+
const { Text, JsonView, FileValueList } = usePrimitives();
|
|
284
290
|
const t = useTranslation();
|
|
285
291
|
const componentName = !isFormatSpec(renderer) && typeof renderer === "object" && renderer !== null
|
|
286
292
|
? extensionSectionName(renderer)
|
|
@@ -293,6 +299,12 @@ function FieldRendererOutput({ field, renderer, row, appLocale, }) {
|
|
|
293
299
|
if (renderer.format === "json" && JsonView !== undefined) {
|
|
294
300
|
return (_jsx(JsonView, { value: field.value, indent: renderer.indent, testId: `field-value-${field.field}` }));
|
|
295
301
|
}
|
|
302
|
+
if (renderer.format === "file" && FileValueList !== undefined) {
|
|
303
|
+
const fileIds = fileIdsOf(field.value);
|
|
304
|
+
if (fileIds.length === 0)
|
|
305
|
+
return _jsx(Text, { testId: `field-value-${field.field}`, children: EMPTY_VALUE_PLACEHOLDER });
|
|
306
|
+
return _jsx(FileValueList, { fileIds: fileIds, testId: `field-value-${field.field}` });
|
|
307
|
+
}
|
|
296
308
|
// App locale as default when the FormatSpec declares none of its own —
|
|
297
309
|
// otherwise locale-sensitive formats (timestamp/date/number/decimal/
|
|
298
310
|
// bigInt/unit) fell back to Intl's runtime default instead of the app
|
|
@@ -340,12 +352,17 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
|
|
|
340
352
|
// instead of the raw id.
|
|
341
353
|
const systemLabel = SYSTEM_REFERENCE_LABELS[`${refFeature}:${refEntity}`];
|
|
342
354
|
const parts = ids.map((id) => {
|
|
343
|
-
if (systemLabel !== undefined && id === systemLabel.id) {
|
|
355
|
+
if (systemLabel !== undefined && (id === systemLabel.id || systemLabel.aliases?.includes(id))) {
|
|
344
356
|
return { id, label: t(systemLabel.labelKey), link: undefined };
|
|
345
357
|
}
|
|
346
358
|
const row = rows.find((r) => String(r["id"] ?? "") === id);
|
|
347
|
-
if (row === undefined)
|
|
348
|
-
|
|
359
|
+
if (row === undefined) {
|
|
360
|
+
const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${refFeature}:${refEntity}`];
|
|
361
|
+
const label = unknownKey !== undefined && !queryResult.loading && queryResult.error === null
|
|
362
|
+
? t(unknownKey)
|
|
363
|
+
: id;
|
|
364
|
+
return { id, label, link: undefined };
|
|
365
|
+
}
|
|
349
366
|
return { id, label: String(row[labelKey] ?? id), link: resolveReferenceLink(refEntity, id) };
|
|
350
367
|
});
|
|
351
368
|
return (_jsx(Text, { testId: `field-value-${field.field}`, children: parts.map(({ id, label, link }, index) => (_jsxs(Fragment, { children: [index > 0 && ", ", link === undefined ? (label) : (_jsx(Link, { href: link.href, onPress: link.onPress, testId: `field-link-${field.field}-${id}`, children: label }))] }, id))) }));
|
|
@@ -471,7 +488,9 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
|
|
|
471
488
|
case "image": {
|
|
472
489
|
const kind = field.type === "image" ? "image" : "file";
|
|
473
490
|
const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
|
|
474
|
-
return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.
|
|
491
|
+
return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.uploadActionForm !== undefined && {
|
|
492
|
+
uploadActionForm: field.uploadActionForm,
|
|
493
|
+
}), ...(field.entityId !== undefined && { entityId: field.entityId }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }), ...(field.imageVariant !== undefined && { imageVariant: field.imageVariant }), ...(field.capture !== undefined && { capture: field.capture }) }));
|
|
475
494
|
}
|
|
476
495
|
case "files":
|
|
477
496
|
case "images":
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntityDefinition, type EntityListScreenDefinition, type IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
3
|
import type { ListRowViewModel, Translate } from "../headless/index.ts";
|
|
4
4
|
import type { ListSort } from "../hooks/use-list-url-state.ts";
|
|
@@ -34,6 +34,8 @@ export type RenderListProps = {
|
|
|
34
34
|
readonly emptyContent?: ListEmptyStateContent;
|
|
35
35
|
/** Search or filters changed the result; the empty box then says "no matches". */
|
|
36
36
|
readonly narrowed?: boolean;
|
|
37
|
+
/** Nothing was narrowed by the user, but a default filter is still on; the empty box then offers "show all" instead of "create the first one". */
|
|
38
|
+
readonly defaultFilterActive?: boolean;
|
|
37
39
|
/** Plural-forms key for the footer count noun; default is the entity's `:noun` convention key. */
|
|
38
40
|
readonly itemNounKey?: string;
|
|
39
41
|
/** Aktueller Search-Term (vom URL-State / Parent). RenderList puffert
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { UNRESOLVED_REFERENCE_LABEL_KEYS, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
4
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
4
5
|
import { useReferenceLinkResolver } from "../app/reference-link.js";
|
|
@@ -15,7 +16,7 @@ function isHeaderPromotableToolbarAction(action) {
|
|
|
15
16
|
return action.style === "primary" && !needsActionConfirm(action);
|
|
16
17
|
}
|
|
17
18
|
export function RenderList(props) {
|
|
18
|
-
const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
|
|
19
|
+
const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, defaultFilterActive: defaultFilterActiveProp = false, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
|
|
19
20
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
20
21
|
// wären Column-Header raw i18n-Keys.
|
|
21
22
|
const t = useTranslation();
|
|
@@ -101,6 +102,14 @@ export function RenderList(props) {
|
|
|
101
102
|
if (col.renderer !== undefined)
|
|
102
103
|
return col;
|
|
103
104
|
const map = referenceLookups[col.field];
|
|
105
|
+
const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${col.refFeature ?? featureName}:${col.refEntity ?? ""}`];
|
|
106
|
+
// No raw id for entities that opt in: a placeholder while the lookup
|
|
107
|
+
// loads, the "unknown" label once it found nothing.
|
|
108
|
+
const unresolvedLabel = (id) => unknownKey === undefined
|
|
109
|
+
? id
|
|
110
|
+
: map === undefined
|
|
111
|
+
? EMPTY_VALUE_PLACEHOLDER
|
|
112
|
+
: translate(unknownKey);
|
|
104
113
|
const labelField = col.refLabelField ?? "id";
|
|
105
114
|
const refEntity = col.refEntity ?? "";
|
|
106
115
|
// Only a row the user can see is linkable: resolved by the server
|
|
@@ -126,7 +135,7 @@ export function RenderList(props) {
|
|
|
126
135
|
return value.map((v) => {
|
|
127
136
|
const id = String(v);
|
|
128
137
|
const target = eagerById.get(id);
|
|
129
|
-
const label = String(target?.[labelField] ?? map?.get(id) ?? id);
|
|
138
|
+
const label = String(target?.[labelField] ?? map?.get(id) ?? unresolvedLabel(id));
|
|
130
139
|
// Only a row the user can see is linkable: server eagerload or lookup query.
|
|
131
140
|
const link = target !== undefined || map?.has(id) === true
|
|
132
141
|
? resolveReferenceLink(refEntity, id)
|
|
@@ -151,16 +160,23 @@ export function RenderList(props) {
|
|
|
151
160
|
if (Array.isArray(value)) {
|
|
152
161
|
if (value.length === 0)
|
|
153
162
|
return EMPTY_VALUE_PLACEHOLDER;
|
|
154
|
-
return value.map((v) => map?.get(String(v)) ?? String(v)).join(", ");
|
|
163
|
+
return value.map((v) => map?.get(String(v)) ?? unresolvedLabel(String(v))).join(", ");
|
|
155
164
|
}
|
|
156
165
|
if (value === null || value === undefined || value === "")
|
|
157
166
|
return EMPTY_VALUE_PLACEHOLDER;
|
|
158
167
|
const idStr = String(value);
|
|
159
|
-
return map?.get(idStr) ?? idStr;
|
|
168
|
+
return map?.get(idStr) ?? unresolvedLabel(idStr);
|
|
160
169
|
};
|
|
161
170
|
return { ...col, renderer, linkFor, linkItemsFor };
|
|
162
171
|
});
|
|
163
|
-
}, [
|
|
172
|
+
}, [
|
|
173
|
+
vm.columns,
|
|
174
|
+
referenceColumns,
|
|
175
|
+
referenceLookups,
|
|
176
|
+
resolveReferenceLink,
|
|
177
|
+
featureName,
|
|
178
|
+
translate,
|
|
179
|
+
]);
|
|
164
180
|
const enrichedVm = useMemo(() => ({
|
|
165
181
|
...vm,
|
|
166
182
|
columns: enrichedColumns,
|
|
@@ -205,8 +221,9 @@ export function RenderList(props) {
|
|
|
205
221
|
// Button. Caller kann via emptyState-Prop komplett überschreiben.
|
|
206
222
|
// Wenn weder onCreate noch ein Override gegeben ist, fällt die
|
|
207
223
|
// DataTable auf den DataTable-Default zurück (`kumiko.list.no-entries`).
|
|
224
|
+
const defaultFilterActive = defaultFilterActiveProp && onFilterReset !== undefined;
|
|
208
225
|
const composedEmptyState = emptyState ??
|
|
209
|
-
(onCreate !== undefined || emptyContent !== undefined || narrowed ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, translate: translate, createButton: onCreate !== undefined && !narrowed && !createInPageHeader ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
|
|
226
|
+
(onCreate !== undefined || emptyContent !== undefined || narrowed || defaultFilterActive ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, defaultFilterActive: defaultFilterActive, ...(onFilterReset !== undefined && { onShowAll: onFilterReset }), translate: translate, createButton: onCreate !== undefined && !narrowed && !defaultFilterActive && !createInPageHeader ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
|
|
210
227
|
// Title-Resolution: ein konventioneller i18n-Key `screen:<id>.title`.
|
|
211
228
|
// Wenn das Bundle den Key kennt, schöner Titel; sonst kommt der
|
|
212
229
|
// screenId selber raus (kein "Untitled"-Fallback — der App-Dev sieht
|
|
@@ -254,9 +271,13 @@ function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMa
|
|
|
254
271
|
// useEffect statt direktem call damit setState außerhalb des Render-
|
|
255
272
|
// Pfads passiert (sonst React-Warning "Cannot update a component
|
|
256
273
|
// while rendering a different component").
|
|
274
|
+
// Unpublished while loading or after a failed lookup: the renderer treats a missing map as
|
|
275
|
+
// "not resolved", an empty published map as "nothing found". Only the latter may say "Unknown".
|
|
276
|
+
const resolved = !lookup.loading && !lookup.failed;
|
|
257
277
|
useEffect(() => {
|
|
258
|
-
|
|
259
|
-
|
|
278
|
+
if (resolved)
|
|
279
|
+
onMap(field, lookup.map);
|
|
280
|
+
}, [field, lookup.map, resolved, onMap]);
|
|
260
281
|
return null;
|
|
261
282
|
}
|
|
262
283
|
// ToolbarActionView — pro Toolbar-Action ein Button (+ Confirm-Dialog
|
|
@@ -6,6 +6,7 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
|
6
6
|
import { I18N_KEY_PARAM } from "../headless/index.js";
|
|
7
7
|
import { useForm } from "../hooks/use-form.js";
|
|
8
8
|
import { useTranslation } from "../i18n.js";
|
|
9
|
+
import { useUnsavedInputSource } from "../leave-guard.js";
|
|
9
10
|
import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
|
|
10
11
|
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
11
12
|
import { hasIssueWithoutVisibleField, issuesAfterEdit } from "./render-edit-logic.js";
|
|
@@ -44,6 +45,7 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
44
45
|
submit: { type: section.handler, payloadMode: "values" },
|
|
45
46
|
schema,
|
|
46
47
|
});
|
|
48
|
+
useUnsavedInputSource(() => controller.getSnapshot().isDirty);
|
|
47
49
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
48
50
|
const [error, setError] = useState(null);
|
|
49
51
|
async function handleSubmit() {
|
|
@@ -100,7 +102,8 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
100
102
|
if (remaining !== errors)
|
|
101
103
|
controller.setErrors(remaining);
|
|
102
104
|
setError(null);
|
|
103
|
-
}, GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values
|
|
105
|
+
}, GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values, ...(hideTitle !== true &&
|
|
106
|
+
section.title !== undefined && { sectionTitle: section.title }) }, field.field))) }), error !== null && (_jsx(Banner, { variant: "error", testId: "write-form-section-error", children: error })), onFooterAction === undefined && (_jsx(Card, { options: { framed: false, footerBordered: false, footerInset: false }, slots: { footer: submitButton }, testId: "write-form-section-submit-row" }))] }));
|
|
104
107
|
// Section always flattens to a borderless divider when rendered inside
|
|
105
108
|
// RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
|
|
106
109
|
// stands in, like render-edit.tsx's tabs-mode fields/extension branches.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const INVALID_FORMAT_KEY = "errors.validation.invalid_format";
|
|
2
|
+
// Zod reports every string-format failure under one code; the per-format key
|
|
3
|
+
// (…invalid_format.email) carries a worked example, the generic key stays the
|
|
4
|
+
// fallback for formats without one.
|
|
5
|
+
export function fieldIssueText(t, issue) {
|
|
6
|
+
const format = issue.params?.["format"];
|
|
7
|
+
if (issue.i18nKey === INVALID_FORMAT_KEY && typeof format === "string") {
|
|
8
|
+
const formatKey = `${INVALID_FORMAT_KEY}.${format}`;
|
|
9
|
+
const text = t(formatKey, issue.params);
|
|
10
|
+
if (text !== formatKey)
|
|
11
|
+
return text;
|
|
12
|
+
}
|
|
13
|
+
return t(issue.i18nKey, issue.params);
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fileIdsOf(value: unknown): readonly string[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Inner markup for a 24x24 viewBox, stroked via the `.icon` CSS class. "ai" renders as a text badge instead — see render.ts. */
|
|
2
|
+
export const ICONS = {
|
|
3
|
+
ui: '<rect class="icon" x="3" y="4" width="18" height="15" rx="1.5"/><path class="icon" d="M3 8h18"/>',
|
|
4
|
+
api: '<path class="icon" d="M8 6l-5 6 5 6"/><path class="icon" d="M16 6l5 6-5 6"/>',
|
|
5
|
+
handler: '<path class="icon" d="M8 4l-4 8 4 8"/><path class="icon" d="M16 4l4 8-4 8"/><path class="icon" d="M11 20l2-16"/>',
|
|
6
|
+
"event-store": '<path class="icon" d="M4 6c0-1.5 3.6-2.5 8-2.5S20 4.5 20 6s-3.6 2.5-8 2.5S4 7.5 4 6z"/><path class="icon" d="M4 6v12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5V6"/><path class="icon" d="M4 12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5"/>',
|
|
7
|
+
database: '<path class="icon" d="M4 6c0-1.5 3.6-2.5 8-2.5S20 4.5 20 6s-3.6 2.5-8 2.5S4 7.5 4 6z"/><path class="icon" d="M4 6v12c0 1.5 3.6 2.5 8 2.5s8-1 8-2.5V6"/>',
|
|
8
|
+
inbox: '<path class="icon" d="M3 6h18v13H3z"/><path class="icon" d="M3 6l9 7 9-7"/>',
|
|
9
|
+
broadcast: '<path class="icon" d="M12 20v-1"/><path class="icon" d="M8.5 15.5a5 5 0 017 0"/><path class="icon" d="M5 12a9 9 0 0114 0"/>',
|
|
10
|
+
person: '<circle class="icon" cx="12" cy="8" r="3.2"/><path class="icon" d="M5 20c0-4 3-6 7-6s7 2 7 6"/>',
|
|
11
|
+
check: '<path class="icon" d="M5 12l5 5L20 6"/>',
|
|
12
|
+
stop: '<rect class="icon" x="7" y="7" width="10" height="10" rx="1.5"/>',
|
|
13
|
+
box: '<rect class="icon" x="4" y="4" width="16" height="16" rx="2"/>',
|
|
14
|
+
};
|