@cosmicdrift/kumiko-renderer 0.327.0 → 0.328.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/action-form-shim.d.ts +14 -0
- package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -44
- package/dist/app/app-features-context.d.ts +8 -0
- package/dist/app/app-features-context.js +16 -0
- package/dist/app/column-renderers.d.ts +30 -0
- package/dist/app/column-renderers.js +34 -0
- package/dist/app/config-edit-shim.d.ts +11 -0
- package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
- package/dist/app/content-editors.d.ts +38 -0
- package/dist/app/content-editors.js +26 -0
- package/dist/app/content-preview.d.ts +11 -0
- package/dist/app/content-preview.js +30 -0
- package/dist/app/custom-screens.d.ts +11 -0
- package/dist/app/custom-screens.js +20 -0
- package/dist/app/dashboard-body.d.ts +16 -0
- package/dist/app/dashboard-body.js +11 -0
- package/dist/app/extension-form-submit.d.ts +32 -0
- package/dist/app/extension-form-submit.js +73 -0
- package/dist/app/extension-sections.d.ts +72 -0
- package/dist/app/extension-sections.js +34 -0
- package/dist/app/feature-schema.d.ts +4 -0
- package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
- package/dist/app/form-schema.d.ts +4 -0
- package/dist/app/form-schema.js +85 -0
- package/dist/app/kumiko-screen.d.ts +43 -0
- package/dist/app/kumiko-screen.js +2206 -0
- package/dist/app/layout-fields.d.ts +3 -0
- package/dist/app/layout-fields.js +20 -0
- package/dist/app/list-facets.d.ts +48 -0
- package/dist/app/list-facets.js +185 -0
- package/dist/app/nav.d.ts +61 -0
- package/dist/app/nav.js +139 -0
- package/dist/app/projection-detail-shim.d.ts +8 -0
- package/dist/app/projection-detail-shim.js +71 -0
- package/dist/app/projection-list-shim.d.ts +13 -0
- package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -38
- package/dist/app/qn.d.ts +7 -0
- package/{src/app/qn.ts → dist/app/qn.js} +9 -11
- package/dist/app/qualify-screen-id.d.ts +2 -0
- package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
- package/dist/app/reference-facet-bridge.d.ts +10 -0
- package/dist/app/reference-facet-bridge.js +22 -0
- package/dist/app/return-to.d.ts +31 -0
- package/dist/app/return-to.js +188 -0
- package/dist/app/row-actions.d.ts +77 -0
- package/dist/app/row-actions.js +416 -0
- package/dist/app/screen-access.d.ts +8 -0
- package/dist/app/screen-access.js +48 -0
- package/dist/app/secret-mint-body.d.ts +10 -0
- package/dist/app/secret-mint-body.js +140 -0
- package/dist/app/secrets-edit-body.d.ts +8 -0
- package/dist/app/secrets-edit-body.js +76 -0
- package/dist/app/use-embedded-screen.d.ts +6 -0
- package/dist/app/use-embedded-screen.js +19 -0
- package/dist/app/variable-chips.d.ts +7 -0
- package/dist/app/variable-chips.js +16 -0
- package/dist/app/write-failed-error.d.ts +6 -0
- package/dist/app/write-failed-error.js +21 -0
- package/dist/components/embedded-list-field.d.ts +10 -0
- package/dist/components/embedded-list-field.js +259 -0
- package/dist/components/grid-cell-for-field.d.ts +25 -0
- package/dist/components/grid-cell-for-field.js +31 -0
- package/dist/components/multi-select-checkboxes.d.ts +14 -0
- package/dist/components/multi-select-checkboxes.js +36 -0
- package/dist/components/reference-create-dialog.d.ts +13 -0
- package/dist/components/reference-create-dialog.js +46 -0
- package/dist/components/related-list-section.d.ts +25 -0
- package/dist/components/related-list-section.js +273 -0
- package/dist/components/render-edit-action-button.d.ts +22 -0
- package/dist/components/render-edit-action-button.js +43 -0
- package/dist/components/render-edit-logic.d.ts +6 -0
- package/dist/components/render-edit-logic.js +63 -0
- package/dist/components/render-edit-types.d.ts +209 -0
- package/dist/components/render-edit-types.js +1 -0
- package/dist/components/render-edit.d.ts +6 -0
- package/dist/components/render-edit.js +1169 -0
- package/dist/components/render-field.d.ts +38 -0
- package/dist/components/render-field.js +544 -0
- package/dist/components/render-list.d.ts +117 -0
- package/dist/components/render-list.js +232 -0
- package/dist/components/write-form-section.d.ts +17 -0
- package/dist/components/write-form-section.js +104 -0
- package/dist/context/dispatcher-context.d.ts +10 -0
- package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
- package/dist/context/draft-storage-context.d.ts +12 -0
- package/dist/context/draft-storage-context.js +18 -0
- package/dist/context/user-roles-context.d.ts +7 -0
- package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
- package/dist/format-when.d.ts +1 -0
- package/{src/format-when.ts → dist/format-when.js} +7 -7
- package/dist/hooks/reference-limits.d.ts +3 -0
- package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
- package/dist/hooks/use-ai-text.d.ts +52 -0
- package/dist/hooks/use-ai-text.js +90 -0
- package/dist/hooks/use-disclosure.d.ts +7 -0
- package/dist/hooks/use-disclosure.js +8 -0
- package/dist/hooks/use-form.d.ts +7 -0
- package/dist/hooks/use-form.js +36 -0
- package/dist/hooks/use-list-url-state.d.ts +43 -0
- package/dist/hooks/use-list-url-state.js +131 -0
- package/dist/hooks/use-mutation.d.ts +9 -0
- package/dist/hooks/use-mutation.js +37 -0
- package/dist/hooks/use-query.d.ts +14 -0
- package/dist/hooks/use-query.js +118 -0
- package/dist/hooks/use-reference-lookup.d.ts +14 -0
- package/dist/hooks/use-reference-lookup.js +63 -0
- package/dist/hooks/use-store.d.ts +3 -0
- package/dist/hooks/use-store.js +38 -0
- package/dist/hooks/use-stream-handler.d.ts +14 -0
- package/dist/hooks/use-stream-handler.js +93 -0
- package/dist/i18n-defaults.d.ts +2 -0
- package/dist/i18n-defaults.js +201 -0
- package/dist/i18n.d.ts +64 -0
- package/dist/i18n.js +166 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +55 -0
- package/dist/inside-drawer.d.ts +2 -0
- package/dist/inside-drawer.js +9 -0
- package/dist/page-header-slot.d.ts +2 -0
- package/dist/page-header-slot.js +9 -0
- package/dist/primitives.d.ts +1351 -0
- package/dist/primitives.js +73 -0
- package/dist/screen-fills-height.d.ts +3 -0
- package/dist/screen-fills-height.js +3 -0
- package/dist/share-links.d.ts +4 -0
- package/dist/share-links.js +11 -0
- package/dist/sort-by-accessor.d.ts +5 -0
- package/dist/sort-by-accessor.js +16 -0
- package/dist/sse/live-events.d.ts +23 -0
- package/dist/sse/live-events.js +28 -0
- package/dist/tokens.d.ts +83 -0
- package/dist/tokens.js +80 -0
- package/package.json +16 -9
- package/src/changes.json +52 -0
- package/src/__tests__/error-i18n-defaults.test.ts +0 -82
- package/src/__tests__/format-when.test.ts +0 -12
- package/src/__tests__/i18n.test.tsx +0 -283
- package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
- package/src/__tests__/qn.test.ts +0 -83
- package/src/__tests__/share-links.test.ts +0 -26
- package/src/__tests__/sort-by-accessor.test.ts +0 -48
- package/src/__tests__/stringify-nav-params.test.ts +0 -33
- package/src/__tests__/use-list-url-state.test.tsx +0 -250
- package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
- package/src/app/__tests__/action-form-shim.test.ts +0 -137
- package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
- package/src/app/__tests__/config-edit-shim.test.ts +0 -57
- package/src/app/__tests__/content-editors.test.tsx +0 -128
- package/src/app/__tests__/content-preview.test.tsx +0 -184
- package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
- package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
- package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
- package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -111
- package/src/app/__tests__/feature-schema.test.ts +0 -40
- package/src/app/__tests__/form-schema.test.ts +0 -247
- package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
- package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
- package/src/app/__tests__/nav.test.ts +0 -198
- package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
- package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
- package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
- package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
- package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
- package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
- package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
- package/src/app/__tests__/qualified-names.test.ts +0 -37
- package/src/app/__tests__/return-to.test.ts +0 -396
- package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
- package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
- package/src/app/__tests__/screen-access-allows.test.ts +0 -34
- package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
- package/src/app/__tests__/variable-chips.test.tsx +0 -70
- package/src/app/app-features-context.tsx +0 -25
- package/src/app/column-renderers.tsx +0 -68
- package/src/app/content-editors.tsx +0 -85
- package/src/app/content-preview.tsx +0 -67
- package/src/app/custom-screens.tsx +0 -29
- package/src/app/dashboard-body.tsx +0 -33
- package/src/app/extension-form-submit.tsx +0 -123
- package/src/app/extension-sections.tsx +0 -111
- package/src/app/form-schema.ts +0 -91
- package/src/app/kumiko-screen.tsx +0 -3614
- package/src/app/layout-fields.ts +0 -31
- package/src/app/list-facets.ts +0 -259
- package/src/app/nav.tsx +0 -258
- package/src/app/projection-detail-shim.ts +0 -85
- package/src/app/reference-facet-bridge.tsx +0 -58
- package/src/app/return-to.tsx +0 -234
- package/src/app/row-actions.ts +0 -639
- package/src/app/screen-access.ts +0 -63
- package/src/app/secret-mint-body.tsx +0 -259
- package/src/app/secrets-edit-body.tsx +0 -194
- package/src/app/use-embedded-screen.ts +0 -27
- package/src/app/variable-chips.tsx +0 -39
- package/src/app/write-failed-error.ts +0 -27
- package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
- package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
- package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
- package/src/components/__tests__/related-list-section.test.tsx +0 -1669
- package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
- package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
- package/src/components/__tests__/render-edit-logic.test.ts +0 -314
- package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
- package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
- package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
- package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
- package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
- package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
- package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
- package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
- package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
- package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
- package/src/components/__tests__/write-form-section.test.tsx +0 -375
- package/src/components/embedded-list-field.tsx +0 -354
- package/src/components/grid-cell-for-field.tsx +0 -60
- package/src/components/multi-select-checkboxes.tsx +0 -93
- package/src/components/reference-create-dialog.tsx +0 -91
- package/src/components/related-list-section.tsx +0 -467
- package/src/components/render-edit-action-button.tsx +0 -106
- package/src/components/render-edit-logic.ts +0 -85
- package/src/components/render-edit-types.ts +0 -216
- package/src/components/render-edit.tsx +0 -1700
- package/src/components/render-field.tsx +0 -942
- package/src/components/render-list.tsx +0 -561
- package/src/components/write-form-section.tsx +0 -199
- package/src/context/draft-storage-context.tsx +0 -39
- package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
- package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
- package/src/hooks/__tests__/use-form.test.tsx +0 -65
- package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
- package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
- package/src/hooks/use-ai-text.ts +0 -172
- package/src/hooks/use-disclosure.ts +0 -20
- package/src/hooks/use-form.ts +0 -78
- package/src/hooks/use-list-url-state.ts +0 -171
- package/src/hooks/use-mutation.ts +0 -61
- package/src/hooks/use-query.ts +0 -132
- package/src/hooks/use-reference-lookup.ts +0 -76
- package/src/hooks/use-store.ts +0 -48
- package/src/hooks/use-stream-handler.ts +0 -134
- package/src/i18n-defaults.ts +0 -220
- package/src/i18n.tsx +0 -286
- package/src/index.ts +0 -272
- package/src/primitives.tsx +0 -1446
- package/src/share-links.ts +0 -12
- package/src/sort-by-accessor.ts +0 -20
- package/src/sse/live-events.tsx +0 -56
- package/src/tokens.tsx +0 -170
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import { useAppFeatures } from "../app/app-features-context.js";
|
|
5
|
+
import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
|
|
6
|
+
import { useNav } from "../app/nav.js";
|
|
7
|
+
import { ReferenceFacetBridges } from "../app/reference-facet-bridge.js";
|
|
8
|
+
import { navigateWithReturnTo, useReturnHost } from "../app/return-to.js";
|
|
9
|
+
import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, runProjectionRowNavigate, } from "../app/row-actions.js";
|
|
10
|
+
import { findEditScreenFor } from "../app/screen-access.js";
|
|
11
|
+
import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
12
|
+
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
13
|
+
import { useUserRoles } from "../context/user-roles-context.js";
|
|
14
|
+
import { useQuery } from "../hooks/use-query.js";
|
|
15
|
+
import { useTranslation } from "../i18n.js";
|
|
16
|
+
import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
|
|
17
|
+
import { usePrimitives } from "../primitives.js";
|
|
18
|
+
import { sortByAccessor } from "../sort-by-accessor.js";
|
|
19
|
+
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
20
|
+
import { RenderList } from "./render-list.js";
|
|
21
|
+
const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
|
|
22
|
+
// Minimal EntityDefinition from the section's own columns — same shape as
|
|
23
|
+
// projection-list-shim's synthesizeProjectionEntity, but sortable is read
|
|
24
|
+
// per-column (ListColumnSpec.sortable) instead of screen-wide, since a
|
|
25
|
+
// relatedList section has no single Zod schema to derive it from.
|
|
26
|
+
function synthesizeRelatedListEntity(columns) {
|
|
27
|
+
const fields = {};
|
|
28
|
+
for (const col of columns) {
|
|
29
|
+
const normalized = normalizeListColumn(col);
|
|
30
|
+
fields[normalized.field] = {
|
|
31
|
+
type: normalized.valueType ?? "text",
|
|
32
|
+
sortable: normalized.sortable === true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { fields };
|
|
36
|
+
}
|
|
37
|
+
// Mirrors row-actions.ts's own warnDrawerActionDropped (module-private
|
|
38
|
+
// there, so not reusable directly) — a drawer-kind emptyState.action with no
|
|
39
|
+
// onOpenDrawer wired is dropped (not rendered) with a dev warning, same as a
|
|
40
|
+
// drawer-kind rowAction/toolbarAction without one.
|
|
41
|
+
function warnRelatedListEmptyStateDrawerDropped(actionId) {
|
|
42
|
+
// biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
|
|
43
|
+
console.warn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
|
|
44
|
+
}
|
|
45
|
+
export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, }) {
|
|
46
|
+
const { Banner, Section, FillContainer, Text, Button, Dialog } = usePrimitives();
|
|
47
|
+
const [emptyStateActionError, setEmptyStateActionError] = useState(null);
|
|
48
|
+
const t = useTranslation();
|
|
49
|
+
const effectiveTranslate = translate ?? t;
|
|
50
|
+
const nav = useNav();
|
|
51
|
+
const host = useReturnHost();
|
|
52
|
+
const dispatcher = useOptionalDispatcher();
|
|
53
|
+
const appFeatures = useAppFeatures();
|
|
54
|
+
const userRoles = useUserRoles();
|
|
55
|
+
const defaultEditScreen = useMemo(() => {
|
|
56
|
+
const targetEntity = section.rowClick?.entity;
|
|
57
|
+
if (targetEntity === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
return findEditScreenFor(targetEntity, appFeatures, userRoles);
|
|
60
|
+
}, [appFeatures, section.rowClick, userRoles]);
|
|
61
|
+
const defaultEditRowAction = useMemo(() => buildDefaultEditRowAction(defaultEditScreen, section.rowClick?.idColumn ?? "id"), [defaultEditScreen, section.rowClick]);
|
|
62
|
+
const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
|
|
63
|
+
const listScreen = useMemo(() => ({
|
|
64
|
+
// Empty id → RenderList's own toolbarTitle resolves to "" (its
|
|
65
|
+
// `screen:${id}.title` lookup misses and falls back to `id`) — this
|
|
66
|
+
// component renders the visible heading itself via `Section` below,
|
|
67
|
+
// so RenderList's toolbar carries none.
|
|
68
|
+
id: "",
|
|
69
|
+
type: "entityList",
|
|
70
|
+
entity: RELATED_LIST_PSEUDO_ENTITY,
|
|
71
|
+
columns: section.columns,
|
|
72
|
+
}), [section.columns]);
|
|
73
|
+
// Local state, not URL state: a section `id` is optional, so there is no
|
|
74
|
+
// stable URL key to namespace against — the section's sort is local for the
|
|
75
|
+
// same reason. Consequence: search/filters reset on reload.
|
|
76
|
+
const [search, setSearch] = useState("");
|
|
77
|
+
const [filters, setFilters] = useState({});
|
|
78
|
+
const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName), [section.facets, effectiveTranslate, featureName]);
|
|
79
|
+
const [referenceFacetOptions, setReferenceFacetOptions] = useState({});
|
|
80
|
+
const handleFacetOptions = useCallback((field, options) => setReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
|
|
81
|
+
const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions), [facetSpecs, referenceFacetOptions]);
|
|
82
|
+
const filterPayload = useMemo(() => buildFilterPayload(filters, (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type), [filters, resolvedFacetSpecs]);
|
|
83
|
+
const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
|
|
84
|
+
const payload = useMemo(() => ({
|
|
85
|
+
// `parentFilter` sends the parent id as a server-side `filter` clause
|
|
86
|
+
// instead of a bespoke top-level key — the generic `<entity>:list`
|
|
87
|
+
// query understands `filter`, not an arbitrary `parentParam` key. Kept
|
|
88
|
+
// separate from `filterPayload` (user facets) below so a facet
|
|
89
|
+
// selection can never clear or overwrite it.
|
|
90
|
+
...(section.parentFilter !== undefined
|
|
91
|
+
? { filter: { field: section.parentFilter.field, op: "eq", value: parentId } }
|
|
92
|
+
: { [section.parentParam ?? "id"]: parentId }),
|
|
93
|
+
...(section.pageSize !== undefined && { limit: section.pageSize }),
|
|
94
|
+
// Gated on the declared capability, not just on state carrying a value —
|
|
95
|
+
// same rule as ProjectionListBody: a param the bound query's Zod schema
|
|
96
|
+
// doesn't accept would 422 the whole section.
|
|
97
|
+
...(section.searchable === true && search !== "" && { search }),
|
|
98
|
+
...(section.facets !== undefined && filterPayload.length > 0 && { filters: filterPayload }),
|
|
99
|
+
}), [
|
|
100
|
+
section.parentFilter,
|
|
101
|
+
section.parentParam,
|
|
102
|
+
section.pageSize,
|
|
103
|
+
section.searchable,
|
|
104
|
+
section.facets,
|
|
105
|
+
parentId,
|
|
106
|
+
search,
|
|
107
|
+
filterPayload,
|
|
108
|
+
]);
|
|
109
|
+
const rowsQuery = useQuery(section.query, payload);
|
|
110
|
+
const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), []);
|
|
111
|
+
const onFilterReset = useCallback(() => setFilters({}), []);
|
|
112
|
+
// Sorted client-side over the already-loaded rows — this section has no
|
|
113
|
+
// pager (see `payload` above: a one-shot fetch, no cursor/offset), so the
|
|
114
|
+
// loaded set already IS the full display set and there is no "other page"
|
|
115
|
+
// a client-side sort could misleadingly hide (fw#2722).
|
|
116
|
+
const [sort, setSort] = useState(section.defaultSort ?? null);
|
|
117
|
+
const sortAccessors = useMemo(() => {
|
|
118
|
+
const accessors = {};
|
|
119
|
+
for (const col of section.columns) {
|
|
120
|
+
const field = normalizeListColumn(col).field;
|
|
121
|
+
accessors[field] = (row) => {
|
|
122
|
+
const value = row[field];
|
|
123
|
+
return typeof value === "number" ? value : String(value ?? "");
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return accessors;
|
|
127
|
+
}, [section.columns]);
|
|
128
|
+
const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors), [rowsQuery.data, sort, sortAccessors]);
|
|
129
|
+
const rowClick = section.rowClick;
|
|
130
|
+
// A row-body click target comes from EITHER the legacy `rowClick` field OR
|
|
131
|
+
// a `rowActions` entry marked rowClick:true — the boot-validator rejects
|
|
132
|
+
// both being set, so this order is just a fallback, not a precedence rule.
|
|
133
|
+
const rowClickAction = section.rowActions?.find((a) => a.kind === "navigate" && a.rowClick === true);
|
|
134
|
+
const onRowClick = rowClick !== undefined
|
|
135
|
+
? (row) => {
|
|
136
|
+
const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
|
|
137
|
+
if (id === "")
|
|
138
|
+
return;
|
|
139
|
+
navigateWithReturnTo(nav, { entity: rowClick.entity, id }, host);
|
|
140
|
+
}
|
|
141
|
+
: rowClickAction !== undefined
|
|
142
|
+
? (row) => runProjectionRowNavigate(nav, rowClickAction, row, host)
|
|
143
|
+
: undefined;
|
|
144
|
+
// Same execution path as projectionList's rowActions (kumiko-screen.tsx) —
|
|
145
|
+
// a relatedList row has the identical "no guaranteed id field" shape a
|
|
146
|
+
// query-projection row has, so navigate/writeHandler dispatch is shared
|
|
147
|
+
// rather than a second implementation (fw editable-detail-screens).
|
|
148
|
+
const rowActions = useMemo(() => buildProjectionRowActions({
|
|
149
|
+
rowActions: section.rowActions,
|
|
150
|
+
translate: effectiveTranslate,
|
|
151
|
+
dispatcher,
|
|
152
|
+
nav,
|
|
153
|
+
refetch: rowsQuery.refetch,
|
|
154
|
+
openDrawer: onOpenDrawer,
|
|
155
|
+
defaultEditRowAction,
|
|
156
|
+
host,
|
|
157
|
+
}), [
|
|
158
|
+
section.rowActions,
|
|
159
|
+
effectiveTranslate,
|
|
160
|
+
dispatcher,
|
|
161
|
+
nav,
|
|
162
|
+
rowsQuery.refetch,
|
|
163
|
+
onOpenDrawer,
|
|
164
|
+
defaultEditRowAction,
|
|
165
|
+
host,
|
|
166
|
+
]);
|
|
167
|
+
// Toolbar actions (e.g. "+ Create" above the tab table) — same schema
|
|
168
|
+
// and dispatch semantics as entityList/projectionList.
|
|
169
|
+
const toolbarActionButtons = useMemo(() => buildProjectionToolbarActions({
|
|
170
|
+
toolbarActions: section.toolbarActions,
|
|
171
|
+
translate: effectiveTranslate,
|
|
172
|
+
dispatcher,
|
|
173
|
+
nav,
|
|
174
|
+
refetch: rowsQuery.refetch,
|
|
175
|
+
navigatePrefill: section.parentFilter !== undefined
|
|
176
|
+
? { [section.parentFilter.field]: parentId }
|
|
177
|
+
: { [section.parentParam ?? "id"]: parentId },
|
|
178
|
+
record,
|
|
179
|
+
host,
|
|
180
|
+
...(onOpenDrawer !== undefined && {
|
|
181
|
+
openDrawer: (action) => onOpenDrawer(action, undefined),
|
|
182
|
+
}),
|
|
183
|
+
}), [
|
|
184
|
+
section.toolbarActions,
|
|
185
|
+
section.parentParam,
|
|
186
|
+
section.parentFilter,
|
|
187
|
+
parentId,
|
|
188
|
+
record,
|
|
189
|
+
effectiveTranslate,
|
|
190
|
+
dispatcher,
|
|
191
|
+
nav,
|
|
192
|
+
host,
|
|
193
|
+
rowsQuery.refetch,
|
|
194
|
+
onOpenDrawer,
|
|
195
|
+
]);
|
|
196
|
+
// emptyState.action is a RowAction — the same record-bound action shape
|
|
197
|
+
// (and evalRowExtractor payload/params evaluation) screen-level and
|
|
198
|
+
// section-level actions already use via buildRecordActions, NOT the
|
|
199
|
+
// toolbar-action builder (whose `payload` is a literal Record, not a
|
|
200
|
+
// RowFieldExtractor — casting a RowAction into it would silently skip the
|
|
201
|
+
// declared `pick`/`map` evaluation). "Row" here is the parent record: a
|
|
202
|
+
// relatedList's emptyState has no row of its own.
|
|
203
|
+
const emptyStateActionButton = useMemo(() => {
|
|
204
|
+
const action = section.emptyState?.action;
|
|
205
|
+
if (action === undefined)
|
|
206
|
+
return undefined;
|
|
207
|
+
if (action.kind === "drawer" && onOpenDrawer === undefined) {
|
|
208
|
+
warnRelatedListEmptyStateDrawerDropped(action.id);
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
return buildRecordActions({
|
|
212
|
+
actions: [action],
|
|
213
|
+
record,
|
|
214
|
+
translate: effectiveTranslate,
|
|
215
|
+
nav,
|
|
216
|
+
host,
|
|
217
|
+
dispatcher,
|
|
218
|
+
openDrawer: onOpenDrawer ?? (() => { }),
|
|
219
|
+
onWriteSuccess: rowsQuery.refetch,
|
|
220
|
+
})?.[0];
|
|
221
|
+
}, [
|
|
222
|
+
section.emptyState,
|
|
223
|
+
record,
|
|
224
|
+
effectiveTranslate,
|
|
225
|
+
nav,
|
|
226
|
+
host,
|
|
227
|
+
dispatcher,
|
|
228
|
+
onOpenDrawer,
|
|
229
|
+
rowsQuery.refetch,
|
|
230
|
+
]);
|
|
231
|
+
const emptyStateContent = section.emptyState !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: section.emptyState.title }), section.emptyState.description !== undefined && (_jsx(Text, { variant: "small", children: section.emptyState.description })), emptyStateActionButton !== undefined && (_jsx(RenderEditActionButton, { action: emptyStateActionButton, Button: Button, Dialog: Dialog, onError: setEmptyStateActionError }))] })) : undefined;
|
|
232
|
+
// A truncated fetch means `sortedRows` is a sort of a partial set, not of
|
|
233
|
+
// the full related-row set — the client-side sort above (or even plain
|
|
234
|
+
// unsorted display) would silently claim "these are the top N" when they
|
|
235
|
+
// are really just "the first N in the server's own order" (fw#2722 review).
|
|
236
|
+
// `nextCursor` is exactly how the paged envelope marks that: non-null means
|
|
237
|
+
// more rows exist server-side beyond what was fetched.
|
|
238
|
+
// A handler that omits `nextCursor` (or reports a `total` the fetched rows
|
|
239
|
+
// already cover) has no further rows — only a real cursor marks truncation.
|
|
240
|
+
const truncated = rowsQuery.data !== null &&
|
|
241
|
+
typeof rowsQuery.data.nextCursor === "string" &&
|
|
242
|
+
(rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
|
|
243
|
+
const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: "Loading\u2026" })) : 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", {
|
|
244
|
+
count: sortedRows.length,
|
|
245
|
+
}) })), 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: featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
|
|
246
|
+
searchable: true,
|
|
247
|
+
searchValue: search,
|
|
248
|
+
onSearchChange: setSearch,
|
|
249
|
+
}), ...(filterFacets.length > 0 && {
|
|
250
|
+
filterFacets,
|
|
251
|
+
filterValues: filters,
|
|
252
|
+
onFilterChange,
|
|
253
|
+
onFilterReset,
|
|
254
|
+
}), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...(hideTitle === true && {
|
|
255
|
+
scrollBody: true,
|
|
256
|
+
screenPadding: false,
|
|
257
|
+
chromeless: true,
|
|
258
|
+
}) }) })] }));
|
|
259
|
+
// hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
|
|
260
|
+
// component's own return value in the same Card frame every other tab
|
|
261
|
+
// kind gets — no card here, or the two would nest. `scrollBody` caps the
|
|
262
|
+
// table to the panel height; `FillContainer` is this section's link in the
|
|
263
|
+
// fillHeight flex chain that Card's `options.fillHeight` continues (see
|
|
264
|
+
// render-edit.tsx). A platform primitive (not a raw `<div>`) because
|
|
265
|
+
// `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
|
|
266
|
+
// Section card and document-flow height since they render a visible title
|
|
267
|
+
// and aren't confined to a tab panel.
|
|
268
|
+
const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleFacetOptions });
|
|
269
|
+
if (hideTitle) {
|
|
270
|
+
return (_jsxs(_Fragment, { children: [bridges, FillContainer !== undefined ? (_jsx(FillContainer, { ...(grow === true && { grow: true }), children: content })) : (content)] }));
|
|
271
|
+
}
|
|
272
|
+
return (_jsxs(_Fragment, { children: [bridges, _jsx(Section, { title: section.title, ...(actions !== undefined && { actions }), testId: `related-list-${section.title}`, children: content })] }));
|
|
273
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { usePrimitives } from "../primitives.js";
|
|
3
|
+
import type { RenderEditAction } from "./render-edit-types.js";
|
|
4
|
+
export declare function needsActionConfirm(action: RenderEditAction): boolean;
|
|
5
|
+
export declare function RenderEditActionConfirmDialog({ action, open, onOpenChange, onConfirm, Dialog, }: {
|
|
6
|
+
readonly action: RenderEditAction;
|
|
7
|
+
readonly open: boolean;
|
|
8
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
9
|
+
readonly onConfirm: () => void | Promise<void>;
|
|
10
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
11
|
+
}): ReactNode;
|
|
12
|
+
export declare function RenderEditActionButton({ action, iconOnly, hideIcon, Button, Dialog, onError, }: {
|
|
13
|
+
readonly action: RenderEditAction;
|
|
14
|
+
/** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
|
|
15
|
+
* effect when this action actually resolved an icon. */
|
|
16
|
+
readonly iconOnly?: boolean;
|
|
17
|
+
/** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
|
|
18
|
+
readonly hideIcon?: boolean;
|
|
19
|
+
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
20
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
21
|
+
readonly onError: (text: string | null) => void;
|
|
22
|
+
}): ReactNode;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
4
|
+
// without an explicit confirm key — unless `confirmRequired` overrides it
|
|
5
|
+
// (schema-driven navigate/drawer actions, where the target form is itself
|
|
6
|
+
// the confirmation).
|
|
7
|
+
export function needsActionConfirm(action) {
|
|
8
|
+
return action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
|
|
9
|
+
}
|
|
10
|
+
export function RenderEditActionConfirmDialog({ action, open, onOpenChange, onConfirm, Dialog, }) {
|
|
11
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: onConfirm, testId: `render-edit-action-${action.id}-dialog` }));
|
|
12
|
+
}
|
|
13
|
+
// One header action + its own busy/confirm state — same pattern as
|
|
14
|
+
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
15
|
+
// independently bound by the caller, there is no shared trigger pipeline
|
|
16
|
+
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
17
|
+
export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, Button, Dialog, onError, }) {
|
|
18
|
+
const [busy, setBusy] = useState(false);
|
|
19
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
20
|
+
const trigger = async () => {
|
|
21
|
+
setBusy(true);
|
|
22
|
+
onError(null);
|
|
23
|
+
try {
|
|
24
|
+
await action.onPress();
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
onError(e instanceof Error ? e.message : String(e));
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
setBusy(false);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const variant = action.style ?? "secondary";
|
|
34
|
+
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
35
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && { size: "icon", ariaLabel: action.label }), onClick: () => {
|
|
36
|
+
if (needsActionConfirm(action)) {
|
|
37
|
+
setConfirmOpen(true);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
void trigger();
|
|
41
|
+
}
|
|
42
|
+
}, testId: `render-edit-action-${action.id}`, children: showIconOnly ? null : action.label }), _jsx(RenderEditActionConfirmDialog, { action: action, open: confirmOpen, onOpenChange: setConfirmOpen, onConfirm: trigger, Dialog: Dialog })] }));
|
|
43
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
export declare function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean;
|
|
3
|
+
export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
|
|
4
|
+
export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
|
|
5
|
+
export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
|
|
6
|
+
export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Hides the Save button when no field is editable and no extension section
|
|
2
|
+
// contributes to the composed form submit. Extensions that persist via their
|
|
3
|
+
// own dispatcher writes must not opt in (fw#2359).
|
|
4
|
+
// Explicit-positive per kind (653/1), not `s.kind !== "fields"` — a future
|
|
5
|
+
// third EditSectionViewModel member would otherwise default to "editable"
|
|
6
|
+
// without anyone deciding that on purpose.
|
|
7
|
+
export function hasEditableSection(sections) {
|
|
8
|
+
return sections.some((s) => (s.kind === "extension" && s.contributesToFormSubmit) ||
|
|
9
|
+
(s.kind === "fields" && s.visible && s.fields.some((f) => !f.readOnly && f.visible)));
|
|
10
|
+
}
|
|
11
|
+
// Single source of truth for the extension-section entity-id. The section mount
|
|
12
|
+
// and persistExtensions MUST resolve the same id — otherwise a section mounts
|
|
13
|
+
// editable against one id (vm.id) while the persist step writes to (or skips)
|
|
14
|
+
// another (null), silently dropping the user's input. An explicit `null` prop
|
|
15
|
+
// forces "no entity" (no extension persistence); an omitted prop (undefined)
|
|
16
|
+
// falls back to vm.id (= values["id"]), which the update form carries for the
|
|
17
|
+
// existing row, so editing custom fields on that row actually persists.
|
|
18
|
+
export function resolveExtensionEntityId(entityIdProp, vmId) {
|
|
19
|
+
return entityIdProp !== undefined ? entityIdProp : vmId;
|
|
20
|
+
}
|
|
21
|
+
// After a submit, decide whether to invoke the caller's onSubmit. The success
|
|
22
|
+
// callback typically navigates away, which unmounts the extension-error banner.
|
|
23
|
+
// Suppress the callback ONLY when the entity write succeeded but an extension-
|
|
24
|
+
// section persist failed: the user must stay on the form to see the banner and
|
|
25
|
+
// retry. Every other case still notifies the caller — entity failures and
|
|
26
|
+
// validation blocks carry information the caller needs.
|
|
27
|
+
export function shouldNotifyCaller(result, extensionsPersisted) {
|
|
28
|
+
return !(result.isSuccess && !extensionsPersisted);
|
|
29
|
+
}
|
|
30
|
+
// Matched on the first path segment (embedded-list rows are dotted, e.g. `tasks.2.title`).
|
|
31
|
+
// No issues at all counts as unmatched, so the caller keeps the banner.
|
|
32
|
+
export function hasIssueWithoutRenderedField(issuePaths, sections) {
|
|
33
|
+
if (issuePaths.length === 0)
|
|
34
|
+
return true;
|
|
35
|
+
const renderedFields = new Set(sections
|
|
36
|
+
.filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
|
|
37
|
+
.flatMap((s) => (s.kind === "fields" ? s.fields.filter((f) => f.visible) : []))
|
|
38
|
+
.map((f) => f.field));
|
|
39
|
+
return issuePaths.some((path) => !renderedFields.has(path.split(".")[0] ?? path));
|
|
40
|
+
}
|
|
41
|
+
// Extension, relatedList and writeForm sections skip the `fields` filter
|
|
42
|
+
// (writeForm's fields belong to its own independent form, not the host's);
|
|
43
|
+
// a `fields` section left with zero fields after filtering is dropped, not
|
|
44
|
+
// rendered empty.
|
|
45
|
+
export function filterEditSections(sections, fieldsFilter) {
|
|
46
|
+
if (fieldsFilter === undefined)
|
|
47
|
+
return sections;
|
|
48
|
+
const filterSet = new Set(fieldsFilter);
|
|
49
|
+
const result = [];
|
|
50
|
+
for (const section of sections) {
|
|
51
|
+
if (section.kind === "extension" ||
|
|
52
|
+
section.kind === "relatedList" ||
|
|
53
|
+
section.kind === "writeForm") {
|
|
54
|
+
result.push(section);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const fields = section.fields.filter((f) => filterSet.has(f.field));
|
|
58
|
+
if (fields.length === 0)
|
|
59
|
+
continue;
|
|
60
|
+
result.push({ ...section, fields });
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { FormSnapshot, FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type * as z from "zod";
|
|
5
|
+
export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
6
|
+
readonly screen: EntityEditScreenDefinition;
|
|
7
|
+
readonly entity: EntityDefinition;
|
|
8
|
+
readonly featureName: string;
|
|
9
|
+
readonly initial: TValues;
|
|
10
|
+
/** Real entity id for extension-section mounts (set-value UI). Mount AND
|
|
11
|
+
* persistExtensions resolve it via `resolveExtensionEntityId(entityIdProp,
|
|
12
|
+
* vm.id)` — the same value, so the section doesn't mount editable against
|
|
13
|
+
* one id while persist writes against another (or none at all).
|
|
14
|
+
* Omitting (undefined) = fallback to `vm.id` (= values["id"]), which the
|
|
15
|
+
* update form carries for the existing row. Explicit `null` = "no entity"
|
|
16
|
+
* (create mode / no extension persistence). */
|
|
17
|
+
readonly entityId?: string | null;
|
|
18
|
+
/** Already-persisted extension values (e.g. `record.customFields`) for
|
|
19
|
+
* extension-section mounts. Lets the section display its current stock
|
|
20
|
+
* during edit. Only the update body provides this. */
|
|
21
|
+
readonly extensionInitialValues?: Readonly<Record<string, unknown>>;
|
|
22
|
+
/** Standard single-write submit path. Ignored when `customSubmit` is set
|
|
23
|
+
* (configEdit screens dispatch multiple writes per submit, where a single
|
|
24
|
+
* writeCommand makes no sense). */
|
|
25
|
+
readonly writeCommand?: string;
|
|
26
|
+
/** Override for the submit pipeline. When set, runs controller.validate()
|
|
27
|
+
* and then customSubmit(snapshot) instead of controller.submit(). On
|
|
28
|
+
* success the form state rebases so that isUnchanged/isDirty become false
|
|
29
|
+
* again — without that, save button and banner stay stale. */
|
|
30
|
+
readonly customSubmit?: (snapshot: FormSnapshot<TValues>) => Promise<SubmitResult<unknown>>;
|
|
31
|
+
readonly translate?: Translate;
|
|
32
|
+
readonly ctx?: TCtx;
|
|
33
|
+
readonly schema?: z.ZodType;
|
|
34
|
+
readonly onSubmit?: (result: SubmitResult<unknown>) => void;
|
|
35
|
+
readonly payloadMode?: "values" | "changes";
|
|
36
|
+
readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
|
|
37
|
+
/** Prefix to strip from server validation issue paths before mapping them
|
|
38
|
+
* onto form fields — see SubmitConfig.serverFieldPathPrefix. Only needed
|
|
39
|
+
* when buildPayload nests the form values under a key. */
|
|
40
|
+
readonly serverFieldPathPrefix?: string;
|
|
41
|
+
readonly onDelete?: () => Promise<void> | void;
|
|
42
|
+
readonly onCancel?: () => void;
|
|
43
|
+
readonly onReload?: () => void;
|
|
44
|
+
/** Fires when the form gains or loses unsaved input (field changes or a
|
|
45
|
+
* dirty extension section). Lets a host such as a drawer guard closing. */
|
|
46
|
+
readonly onDirtyChange?: (dirty: boolean) => void;
|
|
47
|
+
/** Copy-link action (issue #912) — only set in update mode (create mode
|
|
48
|
+
* has no entity id yet, hence no permalink). The callback is already fully
|
|
49
|
+
* bound (URL building + clipboard happen outside, in
|
|
50
|
+
* `@cosmicdrift/kumiko-renderer-web`'s RoutedScreen — this platform-neutral
|
|
51
|
+
* package must not touch `navigator`/`window`, see
|
|
52
|
+
* guard-renderer-boundaries). undefined = no button. */
|
|
53
|
+
readonly onCopyLink?: () => Promise<void> | void;
|
|
54
|
+
/** Header action buttons, rendered before the built-in copy-link/
|
|
55
|
+
* delete/cancel/save controls. Each callback is already fully
|
|
56
|
+
* bound (screen-type/nav/dispatcher resolution happens in the caller,
|
|
57
|
+
* same split as `onCopyLink`) — RenderEdit only wires the button, its
|
|
58
|
+
* busy state and its confirm dialog. */
|
|
59
|
+
readonly actions?: readonly RenderEditAction[];
|
|
60
|
+
/** Opens a relatedList section row's drawer-kind action (fw#2710).
|
|
61
|
+
* RenderEdit has no `schema` to resolve the target actionForm itself —
|
|
62
|
+
* the caller (ProjectionDetailBody, which does have schema) supplies the
|
|
63
|
+
* opener and owns the actual Drawer state/rendering. Only projectionDetail
|
|
64
|
+
* passes this — the boot validator rejects relatedList sections on every
|
|
65
|
+
* other screen type that shares this layout. */
|
|
66
|
+
readonly onRelatedListDrawerAction?: (action: RowActionDrawer, initialValues: Readonly<Record<string, unknown>> | undefined) => void;
|
|
67
|
+
/** i18n key for the submit button. Default: "kumiko.actions.save".
|
|
68
|
+
* Action forms (tier 2.7d) pass their screen.submitLabel here so that
|
|
69
|
+
* "Save" can be replaced by domain-specific strings ("Approve" /
|
|
70
|
+
* "Dispatch" / etc.). */
|
|
71
|
+
readonly submitLabel?: string;
|
|
72
|
+
/** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
|
|
73
|
+
readonly submitVariant?: "primary" | "danger";
|
|
74
|
+
/** Context box above the drawer form (title + optional subtitle, already resolved). */
|
|
75
|
+
readonly summary?: {
|
|
76
|
+
readonly title: string;
|
|
77
|
+
readonly subtitle?: string;
|
|
78
|
+
};
|
|
79
|
+
/** Per-field extra content inline after the label (e.g.
|
|
80
|
+
* ConfigSourceBadge). Called with the field name, returns a ReactNode or
|
|
81
|
+
* undefined. */
|
|
82
|
+
readonly labelAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
83
|
+
/** Per-field extra content below the input (e.g. ConfigCascadeView).
|
|
84
|
+
* Called with the field name, returns a ReactNode or undefined. */
|
|
85
|
+
readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
86
|
+
/** Controlled mode (issue #1887): fires on every values-snapshot change
|
|
87
|
+
* (typing, `patch(...)` from outside) with the current values. `changes`
|
|
88
|
+
* is the delta against the initial values — same semantics as
|
|
89
|
+
* `payloadMode: "changes"` — so a caller never overwrites unseen fields.
|
|
90
|
+
* `valid` is a pure dry-run parse against `schema` (not a
|
|
91
|
+
* `controller.validate()` call), so it does not paint field errors into
|
|
92
|
+
* the UI and can diverge from the currently rendered `snapshot.errors` —
|
|
93
|
+
* always `true` without `schema`. A caller that patches a fresh object
|
|
94
|
+
* reference on every call must not do so unconditionally: `setValues` is
|
|
95
|
+
* a no-op when the merged value is reference-equal to the current one,
|
|
96
|
+
* so only a converging patch settles instead of looping. Without this
|
|
97
|
+
* prop, existing behavior is unchanged. `onControlsReady` is guaranteed
|
|
98
|
+
* to have already fired by the time the mount-time `onChange` call
|
|
99
|
+
* happens, so a caller patching dependent fields from inside `onChange`
|
|
100
|
+
* never has to guard against `controls` being undefined. */
|
|
101
|
+
readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
|
|
102
|
+
/** Controlled mode (issue #1887): called once after mount, hands the
|
|
103
|
+
* caller `patch`/`validate`/`getValues` bound to this RenderEdit
|
|
104
|
+
* instance — addressable from outside without a remount. `patch` merges
|
|
105
|
+
* only the given keys (existing `controller.setValues` semantics),
|
|
106
|
+
* values on unmentioned fields stay untouched. `validate` runs without a
|
|
107
|
+
* write and reports field issues via `snapshot.errors` on the field
|
|
108
|
+
* itself rather than as a summary banner. Without this prop, existing
|
|
109
|
+
* behavior is unchanged. */
|
|
110
|
+
readonly onControlsReady?: (controls: RenderEditControls<TValues>) => void;
|
|
111
|
+
/** Renders only these fields (by `field` name from the layout) — section
|
|
112
|
+
* order, title, and visibility still come from the layout, so the caller
|
|
113
|
+
* doesn't duplicate its shape. A section with no fields left after
|
|
114
|
+
* filtering is dropped entirely (not rendered empty). Submit validation
|
|
115
|
+
* is scoped to the actually-rendered fields the same way — a required
|
|
116
|
+
* field outside this list doesn't block submit. Omitting this prop keeps
|
|
117
|
+
* unchanged behavior. Read once at mount for `controller.submit()`'s
|
|
118
|
+
* validation scope (the underlying `useForm` controller is mount-lived);
|
|
119
|
+
* rendering and `controls.validate()` do stay reactive to later changes. */
|
|
120
|
+
readonly fields?: readonly string[];
|
|
121
|
+
/** Locked state (issue #1896): every rendered field and the submit button
|
|
122
|
+
* go visibly inactive, no write possible. For cases where input becomes
|
|
123
|
+
* moot — e.g. Solon's editor pointing at an existing record instead of
|
|
124
|
+
* creating a new one. Extension sections are out of scope: RenderEdit has
|
|
125
|
+
* no way to force-disable an arbitrary registered component. Omitting
|
|
126
|
+
* this prop keeps unchanged behavior.
|
|
127
|
+
*
|
|
128
|
+
* ponytail: direct-consumer only — kumiko-screen.tsx's RenderEdit call
|
|
129
|
+
* sites pass explicit prop lists without a spread and never forward
|
|
130
|
+
* `disabled`, so a screen-driven app can't set the locked state today.
|
|
131
|
+
* Upgrade path if that's needed: thread a screen-spec flag through to
|
|
132
|
+
* `EntityEditCreateBody`/`EntityEditEditBody`. */
|
|
133
|
+
readonly disabled?: boolean;
|
|
134
|
+
/** Renders the fields without RenderEdit's own action bar (save, cancel,
|
|
135
|
+
* delete, copy-link). For hosts that put those controls into their own
|
|
136
|
+
* chrome — a drawer footer, a wizard shell — and drive the write through
|
|
137
|
+
* `onControlsReady`'s `submit`. Omitting this prop keeps unchanged
|
|
138
|
+
* behavior. */
|
|
139
|
+
readonly hideActions?: boolean;
|
|
140
|
+
/** "form" (default) — every field renders as its Input widget, disabled
|
|
141
|
+
* when `field.readOnly`, unchanged behavior. "text" renders a
|
|
142
|
+
* `field.readOnly` field as plain text instead of a disabled Input
|
|
143
|
+
* (ProjectionDetailBody's read view, fw#2245) — editable fields are
|
|
144
|
+
* unaffected either way, so this only changes forms that already have
|
|
145
|
+
* readOnly fields. */
|
|
146
|
+
readonly valueDisplay?: "form" | "text";
|
|
147
|
+
/** Suppresses every section's own title (fields-section header, relatedList
|
|
148
|
+
* Section title) AND the form's own top-level title/subtitle — for a host
|
|
149
|
+
* that already renders that label itself elsewhere (e.g. a tab strip
|
|
150
|
+
* whose label duplicates it, or a page-level header above RenderEdit).
|
|
151
|
+
* Without this, the form's title (`screen:<id>.title` / `screen.id`)
|
|
152
|
+
* renders unconditionally regardless of section-level suppression — a
|
|
153
|
+
* host relying on this prop to fully own the heading otherwise sees a
|
|
154
|
+
* redundant title above its content. Omitting this prop keeps unchanged
|
|
155
|
+
* behavior. */
|
|
156
|
+
readonly hideSectionTitles?: boolean;
|
|
157
|
+
/** Screen body fills the shell height: the form gets `fillHeight` and
|
|
158
|
+
* `stickyActions` (sections scroll, footer pinned). Set by KumikoScreen for
|
|
159
|
+
* screens with `fillHeight !== false`; dialogs and other embedded hosts
|
|
160
|
+
* omit it and keep document-flow height. */
|
|
161
|
+
readonly fillScreenHeight?: boolean;
|
|
162
|
+
/** Extra content rendered above the card, sharing its left padding and
|
|
163
|
+
* width — for a host with its own header region (title/metrics/tabs)
|
|
164
|
+
* that would otherwise render as unpadded siblings before RenderEdit.
|
|
165
|
+
* Omitting this prop keeps unchanged behavior. */
|
|
166
|
+
readonly headerRegion?: ReactNode | ((headerSlot: ReactNode | undefined) => ReactNode);
|
|
167
|
+
/** resolves an `EditFieldsSection`/`EditExtensionSection`/
|
|
168
|
+
* `EditRelatedListSection`/`EditWriteFormSection`'s own `actions` into
|
|
169
|
+
* already-bound buttons, rendered in that section's title row — same
|
|
170
|
+
* split as `onCopyLink`/header `actions`: RenderEdit has no dispatcher/nav
|
|
171
|
+
* context of its own, so the caller (which does) resolves each RowAction
|
|
172
|
+
* against the record being edited. Returns `undefined`/`[]` to render no
|
|
173
|
+
* actions for that section. */
|
|
174
|
+
readonly buildSectionActions?: (actions: readonly RowAction[]) => readonly RenderEditAction[] | undefined;
|
|
175
|
+
};
|
|
176
|
+
export type RenderEditAction = {
|
|
177
|
+
readonly id: string;
|
|
178
|
+
readonly label: string;
|
|
179
|
+
readonly onPress: () => void | Promise<void>;
|
|
180
|
+
readonly style?: "primary" | "secondary" | "danger";
|
|
181
|
+
readonly confirm?: string;
|
|
182
|
+
readonly confirmLabel?: string;
|
|
183
|
+
/** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
|
|
184
|
+
* navigate/drawer actions set it to false: the colour marks the action as
|
|
185
|
+
* destructive, but the target form is the confirmation. */
|
|
186
|
+
readonly confirmRequired?: boolean;
|
|
187
|
+
/** Resolved icon (author `RowAction.icon` or the id-derived default) —
|
|
188
|
+
* drives both the icon-left-of-text render and the icon-only collapse
|
|
189
|
+
* rule (see `shouldRenderActionsIconOnly`). */
|
|
190
|
+
readonly icon?: IconKey;
|
|
191
|
+
};
|
|
192
|
+
export type RenderEditChangeState<TValues extends FormValues> = {
|
|
193
|
+
readonly values: TValues;
|
|
194
|
+
readonly changes: Partial<TValues>;
|
|
195
|
+
readonly dirty: boolean;
|
|
196
|
+
readonly valid: boolean;
|
|
197
|
+
readonly submitting: boolean;
|
|
198
|
+
};
|
|
199
|
+
export type RenderEditControls<TValues extends FormValues> = {
|
|
200
|
+
readonly patch: (partial: Partial<TValues>) => void;
|
|
201
|
+
readonly validate: () => boolean;
|
|
202
|
+
readonly getValues: () => TValues;
|
|
203
|
+
/** Runs the same pipeline the built-in save button runs: validation,
|
|
204
|
+
* `customSubmit`/`writeCommand`, extension-section persistence, draft
|
|
205
|
+
* discard, state rebase. Unlike the button it carries no unchanged-form
|
|
206
|
+
* guard — a host showing a pre-filled proposal must be able to accept it
|
|
207
|
+
* untouched. */
|
|
208
|
+
readonly submit: () => Promise<void>;
|
|
209
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FormValues } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import type { RenderEditProps } from "./render-edit-types.js";
|
|
3
|
+
export type { RenderEditAction, RenderEditChangeState, RenderEditControls, RenderEditProps, } from "./render-edit-types.js";
|
|
4
|
+
import { type ReactNode } from "react";
|
|
5
|
+
export declare const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
6
|
+
export declare function RenderEdit<TValues extends FormValues, TCtx = unknown>(props: RenderEditProps<TValues, TCtx>): ReactNode;
|