@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,117 @@
|
|
|
1
|
+
import type { EntityDefinition, EntityListScreenDefinition, IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
import type { ListSort } from "../hooks/use-list-url-state.js";
|
|
5
|
+
import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableRowAction, type DataTableRowActionMode } from "../primitives.js";
|
|
6
|
+
export type RenderListProps = {
|
|
7
|
+
readonly screen: EntityListScreenDefinition;
|
|
8
|
+
readonly entity: EntityDefinition;
|
|
9
|
+
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
10
|
+
readonly featureName: string;
|
|
11
|
+
readonly translate?: Translate;
|
|
12
|
+
readonly onRowClick?: (row: ListRowViewModel) => void;
|
|
13
|
+
/** Override der Default-Empty-Box. Wenn gesetzt, wird der Auto-CTA
|
|
14
|
+
* via `onCreate` ignoriert — Caller-Inhalt gewinnt. */
|
|
15
|
+
readonly emptyState?: ReactNode;
|
|
16
|
+
/** Setzt einen "+ Neu" Button in die Toolbar UND dient als CTA in
|
|
17
|
+
* der Default-Empty-Box. Caller liefert die Action selber (z. B.
|
|
18
|
+
* navigate auf den Edit-Screen). */
|
|
19
|
+
readonly onCreate?: () => void;
|
|
20
|
+
/** Label für den + Neu Button. Default kommt aus dem i18n-Bundle
|
|
21
|
+
* (`kumiko.actions.create`). Caller kann durch eigenen String
|
|
22
|
+
* überschreiben. */
|
|
23
|
+
readonly createLabel?: string;
|
|
24
|
+
/** Aktiviert ein Search-Input in der Toolbar. Server-side Filter
|
|
25
|
+
* via Caller's onSearchChange — RenderList filtert NICHT lokal. */
|
|
26
|
+
readonly searchable?: boolean;
|
|
27
|
+
/** Placeholder für das Search-Input. Default kommt aus dem i18n-
|
|
28
|
+
* Bundle (`kumiko.list.search-placeholder`). */
|
|
29
|
+
readonly searchPlaceholder?: string;
|
|
30
|
+
/** Hint at the left of the toolbar when there is no search box (already translated). */
|
|
31
|
+
readonly description?: string;
|
|
32
|
+
/** Plural-forms key for the footer count noun; default is the entity's `:noun` convention key. */
|
|
33
|
+
readonly itemNounKey?: string;
|
|
34
|
+
/** Aktueller Search-Term (vom URL-State / Parent). RenderList puffert
|
|
35
|
+
* Tipps lokal mit 300ms Debounce, bevor onSearchChange gefeuert
|
|
36
|
+
* wird — sonst macht jeder Tastendruck einen Server-Roundtrip. */
|
|
37
|
+
readonly searchValue?: string;
|
|
38
|
+
/** Wird gerufen wenn der debounced Search-Term sich ändert. Caller
|
|
39
|
+
* setzt damit URL-State (?<id>.q=…) und triggert ein refetch. */
|
|
40
|
+
readonly onSearchChange?: (next: string) => void;
|
|
41
|
+
/** Aktuelle Sortierung (oder null = Server-Default-Order). */
|
|
42
|
+
readonly sort?: ListSort | null;
|
|
43
|
+
/** Wird gerufen mit dem nächsten Sort-State nach einem Header-Klick. */
|
|
44
|
+
readonly onSortChange?: (next: ListSort | null) => void;
|
|
45
|
+
/** Pager-Props für pagination="pages". Wenn undefined, kein Pager-
|
|
46
|
+
* UI — Default oder infinite-Mode. KumikoScreen liefert das je nach
|
|
47
|
+
* screen.pagination. */
|
|
48
|
+
readonly pager?: {
|
|
49
|
+
readonly page: number;
|
|
50
|
+
readonly limit: number;
|
|
51
|
+
readonly total: number;
|
|
52
|
+
readonly onPageChange: (next: number) => void;
|
|
53
|
+
};
|
|
54
|
+
/** Infinite-Scroll-Wiring für pagination="infinite". KumikoScreen
|
|
55
|
+
* hält accumulated rows + cursor, RenderList reicht die Callbacks
|
|
56
|
+
* einfach durch an DataTable. */
|
|
57
|
+
readonly onReachEnd?: () => void;
|
|
58
|
+
readonly loadingMore?: boolean;
|
|
59
|
+
readonly hasMore?: boolean;
|
|
60
|
+
/** Pro-Row-Aktionen — Resolved-Form (KumikoScreen baut das aus
|
|
61
|
+
* EntityListScreenDefinition.rowActions: handler-QN → dispatcher-Call,
|
|
62
|
+
* i18n-Keys → translated Strings). */
|
|
63
|
+
readonly rowActions?: readonly DataTableRowAction[];
|
|
64
|
+
/** How the row-action column renders (see `DataTableProps.rowActionMode`).
|
|
65
|
+
* KumikoScreen derives it from the actions themselves; without it the
|
|
66
|
+
* DataTable falls back to its adaptive default. */
|
|
67
|
+
readonly rowActionMode?: DataTableRowActionMode;
|
|
68
|
+
/** Toolbar-Aktionen im List-Header — Resolved-Form (KumikoScreen baut
|
|
69
|
+
* das aus EntityListScreenDefinition.toolbarActions: navigate-target
|
|
70
|
+
* → useNav, handler-QN → dispatcher-Call). RenderList rendert die
|
|
71
|
+
* Buttons rechts in der Toolbar, vor "+ Neu". */
|
|
72
|
+
readonly toolbarActions?: readonly ToolbarActionButton[];
|
|
73
|
+
/** Faceted-Filter-Dropdowns — KumikoScreen baut sie aus den filterable
|
|
74
|
+
* select/boolean-Feldern; RenderList reicht sie an die DataTable durch. */
|
|
75
|
+
readonly filterFacets?: readonly DataTableFacet[];
|
|
76
|
+
/** Aktuell gewählte Facet-Werte je Feld (vom URL-State). */
|
|
77
|
+
readonly filterValues?: Readonly<Record<string, readonly string[]>>;
|
|
78
|
+
/** Toggle eines Facet-Werts (URL-State setzen + refetch). */
|
|
79
|
+
readonly onFilterChange?: (field: string, values: readonly string[]) => void;
|
|
80
|
+
/** Reset aller aktiven Facets. */
|
|
81
|
+
readonly onFilterReset?: () => void;
|
|
82
|
+
/** Time-range filters (fw#3104) — forwarded to the DataTable. */
|
|
83
|
+
readonly dateRangeFacets?: readonly DataTableDateRangeFacet[];
|
|
84
|
+
/** One bound changed (set URL state + refetch). */
|
|
85
|
+
readonly onDateRangeChange?: (field: string, bound: "from" | "to", value: string) => void;
|
|
86
|
+
/** Forwarded to `DataTableProps.chromeless` — drops the table's own card
|
|
87
|
+
* frame for a host that already provides one (relatedList in a tabs-mode
|
|
88
|
+
* section, fw#2722). Default false. */
|
|
89
|
+
readonly chromeless?: boolean;
|
|
90
|
+
/** Forwarded to `DataTableProps.scrollBody` — bounds the table height and
|
|
91
|
+
* scrolls rows internally instead of growing the page (relatedList in a
|
|
92
|
+
* tabs-mode section, fw#2722). Default false. */
|
|
93
|
+
readonly scrollBody?: boolean;
|
|
94
|
+
/** Forwarded to `DataTableProps.screenPadding` — `true`: the table carries
|
|
95
|
+
* the shared screen padding because it is the screen body (entityList/
|
|
96
|
+
* projectionList, fw#2640). Explicit `false`: no padding at all — the
|
|
97
|
+
* host already pads its own children (relatedList in a tabs-mode Card,
|
|
98
|
+
* fw#3234). Omitted: legacy `p-6` fallback for a standalone table with no
|
|
99
|
+
* padded host. */
|
|
100
|
+
readonly screenPadding?: boolean;
|
|
101
|
+
};
|
|
102
|
+
export type ToolbarActionButton = {
|
|
103
|
+
readonly id: string;
|
|
104
|
+
readonly label: string;
|
|
105
|
+
readonly style?: "primary" | "secondary" | "danger";
|
|
106
|
+
readonly confirm?: string;
|
|
107
|
+
readonly confirmLabel?: string;
|
|
108
|
+
/** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
|
|
109
|
+
* navigate/drawer actions set it to false: the colour marks the action as
|
|
110
|
+
* destructive, but the target form is the confirmation. */
|
|
111
|
+
readonly confirmRequired?: boolean;
|
|
112
|
+
readonly onTrigger: () => Promise<void> | void;
|
|
113
|
+
/** Id-derived default icon (ACTION_ICON_BY_ID in kumiko-screen.tsx) —
|
|
114
|
+
* ToolbarAction has no author-declared icon field, unlike RowAction. */
|
|
115
|
+
readonly icon?: IconKey;
|
|
116
|
+
};
|
|
117
|
+
export declare function RenderList(props: RenderListProps): ReactNode;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { computeListViewModel } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
5
|
+
import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
|
|
6
|
+
import { useTranslation } from "../i18n.js";
|
|
7
|
+
import { usePageHeaderSlotAvailable } from "../page-header-slot.js";
|
|
8
|
+
import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
9
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
10
|
+
export function RenderList(props) {
|
|
11
|
+
const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, } = props;
|
|
12
|
+
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
13
|
+
// wären Column-Header raw i18n-Keys.
|
|
14
|
+
const t = useTranslation();
|
|
15
|
+
const translate = translateProp ?? t;
|
|
16
|
+
const { DataTable, Button, Dialog, Input, Text, Banner, PageHeader } = usePrimitives();
|
|
17
|
+
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
18
|
+
const createInPageHeader = onCreate !== undefined && PageHeader !== undefined && pageHeaderSlotAvailable;
|
|
19
|
+
// Local search buffer + debounce. External changes (browser back,
|
|
20
|
+
// cross-component reset) are mirrored back; typing only fires
|
|
21
|
+
// onSearchChange after 300ms without further keystrokes.
|
|
22
|
+
const [localQ, setLocalQ] = useState(searchValue ?? "");
|
|
23
|
+
// Sync during render, not in a useEffect: the passive effect also fires on
|
|
24
|
+
// mount and can flush after a keystroke landing in the same commit,
|
|
25
|
+
// resetting localQ and swallowing the input.
|
|
26
|
+
const [syncedSearchValue, setSyncedSearchValue] = useState(searchValue);
|
|
27
|
+
if (searchValue !== syncedSearchValue) {
|
|
28
|
+
setSyncedSearchValue(searchValue);
|
|
29
|
+
setLocalQ(searchValue ?? "");
|
|
30
|
+
}
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (onSearchChange === undefined)
|
|
33
|
+
return;
|
|
34
|
+
if (localQ === (searchValue ?? ""))
|
|
35
|
+
return;
|
|
36
|
+
const timer = setTimeout(() => onSearchChange(localQ), SEARCH_DEBOUNCE_MS);
|
|
37
|
+
return () => clearTimeout(timer);
|
|
38
|
+
}, [localQ, searchValue, onSearchChange]);
|
|
39
|
+
const vm = useMemo(() => computeListViewModel({ screen, entity, rows, translate, featureName }), [screen, entity, rows, translate, featureName]);
|
|
40
|
+
// Tier 2.7e-4: Reference-Field-Eagerload via Bridge-Component-Pattern.
|
|
41
|
+
// Hooks-Rule-Konflikt: pro reference-Spalte muss useReferenceLookup
|
|
42
|
+
// gerufen werden, aber die Anzahl varriiert per Schema. Lösung —
|
|
43
|
+
// ReferenceLookupBridge-Komponenten pro Spalte gemountet, die ihre
|
|
44
|
+
// Map über onMap-Callback in einen lokalen State veröffentlichen.
|
|
45
|
+
// Anzahl der Hook-Calls pro Bridge-Component ist konstant (1), und
|
|
46
|
+
// React verwaltet die mounted/unmounted Lifecycles beim Schema-
|
|
47
|
+
// Wechsel über die key-Property automatisch.
|
|
48
|
+
const referenceColumns = useMemo(() => vm.columns
|
|
49
|
+
.filter((c) => c.type === "reference" && c.refEntity !== undefined)
|
|
50
|
+
.map((c) => ({
|
|
51
|
+
field: c.field,
|
|
52
|
+
refEntity: c.refEntity ?? "",
|
|
53
|
+
// Tier 2.7e Cross-Feature: refFeature kommt aus parseRefTarget
|
|
54
|
+
// im ViewModel (default = current featureName).
|
|
55
|
+
refFeature: c.refFeature ?? featureName,
|
|
56
|
+
labelField: c.refLabelField ?? "id",
|
|
57
|
+
})), [vm.columns, featureName]);
|
|
58
|
+
const [referenceLookups, setReferenceLookups] = useState({});
|
|
59
|
+
const handleLookupMap = useCallback((field, map) => {
|
|
60
|
+
setReferenceLookups((prev) => {
|
|
61
|
+
// skip-update wenn die Map identisch ist (Render-Loop-Schutz —
|
|
62
|
+
// Bridge-Component re-rendert sonst und schickt die gleiche Map
|
|
63
|
+
// wieder rein bis React in eine Endlosschleife geht).
|
|
64
|
+
if (prev[field] === map)
|
|
65
|
+
return prev;
|
|
66
|
+
return { ...prev, [field]: map };
|
|
67
|
+
});
|
|
68
|
+
}, []);
|
|
69
|
+
const enrichedColumns = useMemo(() => {
|
|
70
|
+
if (referenceColumns.length === 0)
|
|
71
|
+
return vm.columns;
|
|
72
|
+
return vm.columns.map((col) => {
|
|
73
|
+
if (col.type !== "reference")
|
|
74
|
+
return col;
|
|
75
|
+
// Author-deklarierter Renderer übersteuert immer — Default greift
|
|
76
|
+
// nur wenn keiner gesetzt ist.
|
|
77
|
+
if (col.renderer !== undefined)
|
|
78
|
+
return col;
|
|
79
|
+
const map = referenceLookups[col.field];
|
|
80
|
+
const labelField = col.refLabelField ?? "id";
|
|
81
|
+
const renderer = (value, row) => {
|
|
82
|
+
// Tier 2.7e Server-Eagerload: wenn der Server _refs mit-
|
|
83
|
+
// schickt, lesen wir den Display-Wert direkt aus der
|
|
84
|
+
// resolved Row — kein Roundtrip durch die Bridge-Map nötig
|
|
85
|
+
// und keine limit:200-Constraint. EagerloadedRow-Type aus
|
|
86
|
+
// @cosmicdrift/kumiko-framework/db pinnt die Form von _refs.
|
|
87
|
+
const eagerloadedRow = row;
|
|
88
|
+
const resolved = eagerloadedRow?._refs?.[col.field];
|
|
89
|
+
if (Array.isArray(resolved) && resolved.length > 0) {
|
|
90
|
+
return resolved.map((r) => String(r[labelField] ?? r["id"] ?? "")).join(", ");
|
|
91
|
+
}
|
|
92
|
+
if (resolved !== undefined && !Array.isArray(resolved)) {
|
|
93
|
+
const single = resolved; // @cast-boundary render-helper
|
|
94
|
+
return String(single[labelField] ?? single["id"] ?? "");
|
|
95
|
+
}
|
|
96
|
+
// Renderer-Side-Fallback (kein Server-Eagerload aktiv).
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
if (value.length === 0)
|
|
99
|
+
return "—";
|
|
100
|
+
return value.map((v) => map?.get(String(v)) ?? String(v)).join(", ");
|
|
101
|
+
}
|
|
102
|
+
if (value === null || value === undefined || value === "")
|
|
103
|
+
return "—";
|
|
104
|
+
const idStr = String(value);
|
|
105
|
+
return map?.get(idStr) ?? idStr;
|
|
106
|
+
};
|
|
107
|
+
return { ...col, renderer };
|
|
108
|
+
});
|
|
109
|
+
}, [vm.columns, referenceColumns, referenceLookups]);
|
|
110
|
+
const enrichedVm = useMemo(() => ({
|
|
111
|
+
...vm,
|
|
112
|
+
columns: enrichedColumns,
|
|
113
|
+
}), [vm, enrichedColumns]);
|
|
114
|
+
// i18n-Defaults für Toolbar/Empty-State Strings — Caller kann jeden
|
|
115
|
+
// einzeln per Prop überschreiben, sonst kommen die Framework-Bundles
|
|
116
|
+
// (kumiko.actions.create, kumiko.list.search-placeholder, …).
|
|
117
|
+
const effectiveCreateLabel = translate(createLabel ?? screen.createLabel ?? "kumiko.actions.create");
|
|
118
|
+
const effectiveSearchPlaceholder = translate(searchPlaceholder ?? screen.searchPlaceholder ?? "kumiko.list.search-placeholder");
|
|
119
|
+
const nounKey = itemNounKey ?? `${featureName}:entity:${screen.entity}:noun`;
|
|
120
|
+
const itemNoun = (itemNounKey !== undefined || !screen.entity.startsWith("__")) &&
|
|
121
|
+
translate(nounKey, { count: 2 }) !== nounKey
|
|
122
|
+
? (count) => translate(nounKey, { count })
|
|
123
|
+
: undefined;
|
|
124
|
+
const toolbarStart = searchable ? (_jsx(Input, { kind: "text", id: "render-list-search", name: "search", value: localQ, onChange: setLocalQ, placeholder: effectiveSearchPlaceholder })) : undefined;
|
|
125
|
+
// Toolbar-End-Slot: optionaler Header-Slot (z.B. Cap-Counter, links im
|
|
126
|
+
// rechten Cluster) + Toolbar-Actions (List-Header-Buttons aus dem Schema)
|
|
127
|
+
// + optional "+ Neu" am rechten Edge. Der Header-Slot lebt IN der Toolbar-
|
|
128
|
+
// Zeile (gleicher Card-Rahmen), nicht als loser Text über dem Screen-Titel
|
|
129
|
+
// (Bug-Bash 3 #12). Reihenfolge = Array-Reihenfolge (Schema-deklariert),
|
|
130
|
+
// "+ Neu" zuletzt weil das die häufigste/auffälligste CTA ist.
|
|
131
|
+
const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
|
|
132
|
+
const hasHeaderSlot = screen.slots?.header !== undefined;
|
|
133
|
+
const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
|
|
134
|
+
const createButton = onCreate !== undefined ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-create", className: "px-3.5", children: effectiveCreateLabel })) : undefined;
|
|
135
|
+
const toolbarEnd = hasHeaderSlot || hasToolbarActions || (onCreate !== undefined && !createInPageHeader) ? (_jsxs(_Fragment, { children: [hasHeaderSlot && _jsx(ListHeaderSlotMount, { screen: screen }), hasToolbarActions &&
|
|
136
|
+
toolbarActions.map((a) => (_jsx(ToolbarActionView, { action: a, iconOnly: toolbarIconOnly, Button: Button, Dialog: Dialog, Banner: Banner }, a.id))), !createInPageHeader && createButton] })) : undefined;
|
|
137
|
+
// Empty-State: Default zeigt Heading + Description + optional CTA-
|
|
138
|
+
// Button. Caller kann via emptyState-Prop komplett überschreiben.
|
|
139
|
+
// Wenn weder onCreate noch ein Override gegeben ist, fällt die
|
|
140
|
+
// DataTable auf den DataTable-Default zurück (`kumiko.list.no-entries`).
|
|
141
|
+
const composedEmptyState = emptyState ??
|
|
142
|
+
(onCreate !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: translate("kumiko.list.empty.title") }), _jsx(Text, { variant: "small", children: translate("kumiko.list.empty.hint") }), _jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })] })) : undefined);
|
|
143
|
+
// Title-Resolution: ein konventioneller i18n-Key `screen:<id>.title`.
|
|
144
|
+
// Wenn das Bundle den Key kennt, schöner Titel; sonst kommt der
|
|
145
|
+
// screenId selber raus (kein "Untitled"-Fallback — der App-Dev sieht
|
|
146
|
+
// dann dass die Übersetzung fehlt).
|
|
147
|
+
const titleKey = `screen:${screen.id}.title`;
|
|
148
|
+
const resolvedTitle = translate(titleKey);
|
|
149
|
+
const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
|
|
150
|
+
// ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
|
|
151
|
+
return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), testId: "render-list-table" })] }));
|
|
152
|
+
}
|
|
153
|
+
// Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
|
|
154
|
+
// eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
|
|
155
|
+
// dieselbe ExtensionSectionsProvider-Registry wie entityEdit-Sections.
|
|
156
|
+
// entityId ist null (Listen-Kontext, keine Row); die Component lädt ihre
|
|
157
|
+
// Daten selbst (z.B. ein Cap-Counter aus einer usage-Query). Eigene
|
|
158
|
+
// Component wegen Rules-of-Hooks — useExtensionSectionComponent darf nicht
|
|
159
|
+
// konditional im RenderList-Body laufen.
|
|
160
|
+
function ListHeaderSlotMount({ screen, }) {
|
|
161
|
+
const header = screen.slots?.header;
|
|
162
|
+
const name = header !== undefined ? extensionSectionName(header) : undefined;
|
|
163
|
+
const Component = useExtensionSectionComponent(name);
|
|
164
|
+
// A declared-but-unregistered header slot renders nothing; warn so the author
|
|
165
|
+
// finds the typo / missing clientFeatures.extensionSectionComponents entry,
|
|
166
|
+
// mirroring the diagnostic banner in render-edit's ExtensionSectionMount
|
|
167
|
+
// (the toolbar has no room for a banner, so a dev-warn it is).
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (name !== undefined && Component === undefined) {
|
|
170
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
|
|
171
|
+
console.warn(`[kumiko] List header slot component "${name}" on screen "${screen.id}" is not registered in clientFeatures.extensionSectionComponents — the header slot renders nothing.`);
|
|
172
|
+
}
|
|
173
|
+
}, [name, Component, screen.id]);
|
|
174
|
+
if (Component === undefined)
|
|
175
|
+
return null;
|
|
176
|
+
// screenId lets a header control drive this list's URL-filter state
|
|
177
|
+
// (useListUrlState) — e.g. a tag filter narrowing the list to an id-set.
|
|
178
|
+
return _jsx(Component, { entityName: screen.entity, entityId: null, screenId: screen.id });
|
|
179
|
+
}
|
|
180
|
+
// Tier 2.7e-4: Bridge-Component pro reference-Spalte. Mounted für jede
|
|
181
|
+
// Spalte einmal, ruft useReferenceLookup unconditional (Hook-Rules
|
|
182
|
+
// happy), und published die Map über onMap an den Parent. React
|
|
183
|
+
// verwaltet das Mounting/Unmounting beim Schema-Wechsel über die
|
|
184
|
+
// key-Prop im map-Loop des Parents.
|
|
185
|
+
function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMap, }) {
|
|
186
|
+
const lookup = useReferenceLookup(featureName, refEntity, labelField);
|
|
187
|
+
// useEffect statt direktem call damit setState außerhalb des Render-
|
|
188
|
+
// Pfads passiert (sonst React-Warning "Cannot update a component
|
|
189
|
+
// while rendering a different component").
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
onMap(field, lookup.map);
|
|
192
|
+
}, [field, lookup.map, onMap]);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
// ToolbarActionView — pro Toolbar-Action ein Button (+ Confirm-Dialog
|
|
196
|
+
// wenn confirm gesetzt). Same Pattern wie RowAction (Inline-Variante)
|
|
197
|
+
// aber ohne row-Context. busy-State während async onTrigger; Dialog
|
|
198
|
+
// öffnet sich vor dem Trigger wenn confirm/danger gesetzt.
|
|
199
|
+
function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }) {
|
|
200
|
+
const [busy, setBusy] = useState(false);
|
|
201
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
202
|
+
// Toolbar-Actions haben (anders als rowActions, die über die DataTable-
|
|
203
|
+
// Primitive laufen) keine Toast-Primitive zur Verfügung — ein inline
|
|
204
|
+
// Error-Banner ist der surfacing-Pfad hier. Gleicher Grund wie bei
|
|
205
|
+
// rowActions (Prod-Bug 2026-06-07): ein verschlucktes Failure-Result
|
|
206
|
+
// sieht für den User wie "nichts passiert" aus.
|
|
207
|
+
const [errorText, setErrorText] = useState(null);
|
|
208
|
+
const trigger = async () => {
|
|
209
|
+
setBusy(true);
|
|
210
|
+
setErrorText(null);
|
|
211
|
+
try {
|
|
212
|
+
await action.onTrigger();
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
setErrorText(e instanceof Error ? e.message : String(e));
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
setBusy(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const variant = action.style ?? "secondary";
|
|
222
|
+
const needsConfirm = action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
|
|
223
|
+
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
224
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "sm", ...(showIconOnly && { ariaLabel: action.label }), onClick: () => {
|
|
225
|
+
if (needsConfirm) {
|
|
226
|
+
setConfirmOpen(true);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
void trigger();
|
|
230
|
+
}
|
|
231
|
+
}, testId: `render-list-toolbar-action-${action.id}`, children: showIconOnly ? null : action.label }), _jsx(Dialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: trigger, testId: `render-list-toolbar-action-${action.id}-dialog` }), errorText !== null && (_jsx(Banner, { variant: "error", testId: `render-list-toolbar-action-${action.id}-error`, children: errorText }))] }));
|
|
232
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EditWriteFormSectionViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
export type WriteFormSectionProps = {
|
|
4
|
+
readonly section: EditWriteFormSectionViewModel;
|
|
5
|
+
readonly featureName: string;
|
|
6
|
+
readonly translate?: Translate;
|
|
7
|
+
readonly hideTitle?: boolean;
|
|
8
|
+
/** Fired after a successful submit — projectionDetail reloads its own
|
|
9
|
+
* record (a new one now exists) via a full RenderEdit remount, see
|
|
10
|
+
* ProjectionDetailBody's reloadNonce/key in kumiko-screen.tsx. */
|
|
11
|
+
readonly onSubmitted: () => void;
|
|
12
|
+
/** section.actions, already resolved into buttons by the caller —
|
|
13
|
+
* rendered alongside (before) the section's own submit button in the
|
|
14
|
+
* title-row actions slot. */
|
|
15
|
+
readonly actions?: ReactNode;
|
|
16
|
+
};
|
|
17
|
+
export declare function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, }: WriteFormSectionProps): ReactNode;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema.js";
|
|
6
|
+
import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
7
|
+
import { useForm } from "../hooks/use-form.js";
|
|
8
|
+
import { useTranslation } from "../i18n.js";
|
|
9
|
+
import { usePrimitives } from "../primitives.js";
|
|
10
|
+
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
11
|
+
// Same "has a value" rule as buildFormSchema's isPresent (app/form-schema.ts)
|
|
12
|
+
// — duplicated because that helper walks raw EditFieldSpec + EntityDefinition,
|
|
13
|
+
// unavailable here: this section only ever sees computeEditViewModel's
|
|
14
|
+
// already-resolved EditFieldViewModel[] (required/readOnly/visible are plain
|
|
15
|
+
// booleans by the time render-edit.tsx hands the section to this component).
|
|
16
|
+
function isPresent(value) {
|
|
17
|
+
if (value === undefined || value === null || value === "")
|
|
18
|
+
return false;
|
|
19
|
+
if (Array.isArray(value) && value.length === 0)
|
|
20
|
+
return false;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
function buildWriteFormSchema(fields) {
|
|
24
|
+
return z
|
|
25
|
+
.object({})
|
|
26
|
+
.passthrough()
|
|
27
|
+
.superRefine((values, ctx) => {
|
|
28
|
+
const record = values;
|
|
29
|
+
for (const field of fields) {
|
|
30
|
+
if (field.readOnly || !field.visible || !field.required)
|
|
31
|
+
continue;
|
|
32
|
+
if (isPresent(record[field.field]))
|
|
33
|
+
continue;
|
|
34
|
+
ctx.addIssue({
|
|
35
|
+
code: "custom",
|
|
36
|
+
path: [field.field],
|
|
37
|
+
message: `"${field.field}" is required.`,
|
|
38
|
+
params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// A self-persisting form section for projectionDetail (see
|
|
44
|
+
// EditWriteFormSection's doc). Deliberately NOT a nested <RenderEdit> — that
|
|
45
|
+
// would render a second, invalid nested <form> inside the host's own form
|
|
46
|
+
// element — so this mounts its own independent useForm() controller and
|
|
47
|
+
// submits via a plain button click instead of native form submission.
|
|
48
|
+
export function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, }) {
|
|
49
|
+
const { Section, Card, Grid, GridCell, Button, Banner } = usePrimitives();
|
|
50
|
+
const t = useTranslation();
|
|
51
|
+
const effectiveTranslate = translate ?? t;
|
|
52
|
+
const initial = useMemo(() => Object.fromEntries(section.fields.map((f) => [f.field, f.value])), [section.fields]);
|
|
53
|
+
const schema = useMemo(() => buildWriteFormSchema(section.fields), [section.fields]);
|
|
54
|
+
const { controller, snapshot } = useForm({
|
|
55
|
+
initial,
|
|
56
|
+
submit: { type: section.handler, payloadMode: "values" },
|
|
57
|
+
schema,
|
|
58
|
+
});
|
|
59
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
60
|
+
const [error, setError] = useState(null);
|
|
61
|
+
async function handleSubmit() {
|
|
62
|
+
if (isSubmitting)
|
|
63
|
+
return;
|
|
64
|
+
setIsSubmitting(true);
|
|
65
|
+
try {
|
|
66
|
+
const result = await controller.submit();
|
|
67
|
+
if (result.isSuccess) {
|
|
68
|
+
setError(null);
|
|
69
|
+
onSubmitted();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (result.validationBlocked)
|
|
73
|
+
return;
|
|
74
|
+
// Field-level issues already surface inline via snapshot.errors (same
|
|
75
|
+
// GridCellForField/RenderField path RenderEdit uses) — the banner is
|
|
76
|
+
// only for form-level errors nothing else would show (fw#1901 pattern).
|
|
77
|
+
const fieldIssues = result.error.details?.fields ?? [];
|
|
78
|
+
setError(fieldIssues.length === 0 ? dispatcherErrorText(result.error, effectiveTranslate) : null);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
setIsSubmitting(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const content = (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: section.columns, children: section.fields.map((field) => (_jsx(GridCellForField, { field: field, columns: section.columns, issues: snapshot.errors[field.field], onChange: (v) => controller.setField(field.field, v), GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values }, field.field))) }), error !== null && (_jsx(Banner, { variant: "error", testId: "write-form-section-error", children: error }))] }));
|
|
85
|
+
// type="button" (not "submit") is load-bearing: this section is deliberately
|
|
86
|
+
// NOT a nested <form> (see the component doc above), so a "submit" type
|
|
87
|
+
// would instead trigger the host RenderEdit's own form submit.
|
|
88
|
+
const submitButton = (_jsx(Button, { type: "button", variant: "primary", disabled: isSubmitting, loading: isSubmitting, onClick: () => void handleSubmit(), testId: "write-form-section-submit", children: section.submitLabel ?? effectiveTranslate("kumiko.actions.save") }));
|
|
89
|
+
const titleRowActions = actions !== undefined ? (_jsxs(_Fragment, { children: [actions, submitButton] })) : (submitButton);
|
|
90
|
+
// Section always flattens to a borderless divider when rendered inside
|
|
91
|
+
// RenderEdit's own <Form> in tabs mode (hideTitle) — Card frames it there
|
|
92
|
+
// instead, same reason render-edit.tsx's tabs-mode fields/extension
|
|
93
|
+
// branches stay on Card.
|
|
94
|
+
if (hideTitle) {
|
|
95
|
+
return (_jsx(Card, { slots: {
|
|
96
|
+
...(section.description !== undefined && { subtitle: section.description }),
|
|
97
|
+
headerActions: titleRowActions,
|
|
98
|
+
}, testId: `write-form-${section.title ?? "section"}`, children: content }));
|
|
99
|
+
}
|
|
100
|
+
// Routed through Section's `actions` slot (same mechanism render-edit.tsx
|
|
101
|
+
// uses via Form's `actions`) so the button gets the established right-
|
|
102
|
+
// aligned footer treatment instead of stretching full-width inline.
|
|
103
|
+
return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), actions: titleRowActions, testId: `write-form-${section.title ?? "section"}`, children: content }));
|
|
104
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Dispatcher, DispatcherStatus } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
export type DispatcherProviderProps = {
|
|
4
|
+
readonly dispatcher: Dispatcher;
|
|
5
|
+
readonly children: ReactNode;
|
|
6
|
+
};
|
|
7
|
+
export declare function DispatcherProvider({ dispatcher, children }: DispatcherProviderProps): ReactNode;
|
|
8
|
+
export declare function useDispatcher(): Dispatcher;
|
|
9
|
+
export declare function useOptionalDispatcher(): Dispatcher | undefined;
|
|
10
|
+
export declare function useDispatcherStatus(): DispatcherStatus;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { createContext,
|
|
3
|
-
import { useStore } from "../hooks/use-store";
|
|
4
|
-
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
import { useStore } from "../hooks/use-store.js";
|
|
5
4
|
// React Context threading the Dispatcher through the tree. An app
|
|
6
5
|
// wraps its root in <DispatcherProvider dispatcher={createLiveDispatcher()}>
|
|
7
6
|
// and every hook below reaches into that context instead of taking the
|
|
@@ -11,41 +10,29 @@ import { useStore } from "../hooks/use-store";
|
|
|
11
10
|
// The context holds the Dispatcher INSTANCE, not a factory — a single
|
|
12
11
|
// dispatcher per app (rebuilding would wipe its in-flight tracking and
|
|
13
12
|
// status listeners). Tests wire a fake dispatcher in directly.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export type DispatcherProviderProps = {
|
|
18
|
-
readonly dispatcher: Dispatcher;
|
|
19
|
-
readonly children: ReactNode;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export function DispatcherProvider({ dispatcher, children }: DispatcherProviderProps): ReactNode {
|
|
23
|
-
return <DispatcherContext value={dispatcher}>{children}</DispatcherContext>;
|
|
13
|
+
const DispatcherContext = createContext(null);
|
|
14
|
+
export function DispatcherProvider({ dispatcher, children }) {
|
|
15
|
+
return _jsx(DispatcherContext, { value: dispatcher, children: children });
|
|
24
16
|
}
|
|
25
|
-
|
|
26
17
|
// Reads the ambient Dispatcher. Throws instead of returning null when
|
|
27
18
|
// no provider is mounted — a dispatcher-less hook is always a
|
|
28
19
|
// developer error (the app forgot to wrap its root) and surfacing it
|
|
29
20
|
// early beats debugging "why did my write silently do nothing" later.
|
|
30
|
-
export function useDispatcher()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
return dispatcher;
|
|
21
|
+
export function useDispatcher() {
|
|
22
|
+
const dispatcher = useContext(DispatcherContext);
|
|
23
|
+
if (!dispatcher) {
|
|
24
|
+
throw new Error("useDispatcher: no <DispatcherProvider> mounted above this component. Wrap your app root with <DispatcherProvider dispatcher={createLiveDispatcher()}>.");
|
|
25
|
+
}
|
|
26
|
+
return dispatcher;
|
|
38
27
|
}
|
|
39
|
-
|
|
40
28
|
// Soft-Variante: null wenn kein Provider mounted statt throw. Für
|
|
41
29
|
// Components die einen Dispatcher OPTIONAL brauchen (z.B. KumikoScreen
|
|
42
30
|
// rendert mit/ohne rowActions; die Test-Suite mountet kein Dispatcher
|
|
43
31
|
// wenn der Screen keine Mutation triggert). Caller ist zuständig dass
|
|
44
32
|
// er bei null einen sinnvollen Fallback hat (typisch: kein Action-Wiring).
|
|
45
|
-
export function useOptionalDispatcher()
|
|
46
|
-
|
|
33
|
+
export function useOptionalDispatcher() {
|
|
34
|
+
return useContext(DispatcherContext) ?? undefined;
|
|
47
35
|
}
|
|
48
|
-
|
|
49
36
|
// Subscribes to online/offline/syncing transitions. The dispatcher
|
|
50
37
|
// exposes `statusStore: Store<DispatcherStatus>` directly; useStore is
|
|
51
38
|
// the canonical React-binding for that contract.
|
|
@@ -54,6 +41,6 @@ export function useOptionalDispatcher(): Dispatcher | undefined {
|
|
|
54
41
|
// returns the same snapshot the client sees on hydration — for the
|
|
55
42
|
// live-dispatcher that's "online" (the optimistic boot default), so
|
|
56
43
|
// no flash on hydration.
|
|
57
|
-
export function useDispatcherStatus()
|
|
58
|
-
|
|
44
|
+
export function useDispatcherStatus() {
|
|
45
|
+
return useStore(useDispatcher().statusStore);
|
|
59
46
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type DraftStorage = {
|
|
3
|
+
readonly getDraftId: (screenId: string) => string | null;
|
|
4
|
+
readonly setDraftId: (screenId: string, draftId: string) => void;
|
|
5
|
+
readonly clearDraftId: (screenId: string) => void;
|
|
6
|
+
};
|
|
7
|
+
export type DraftStorageProviderProps = {
|
|
8
|
+
readonly value: DraftStorage;
|
|
9
|
+
readonly children: ReactNode;
|
|
10
|
+
};
|
|
11
|
+
export declare function DraftStorageProvider({ value, children }: DraftStorageProviderProps): ReactNode;
|
|
12
|
+
export declare function useDraftStorage(): DraftStorage;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
// No-op default: without a mounted provider (unwired platform, or a test
|
|
4
|
+
// that doesn't care about drafts) same-tab reload just loses draftId
|
|
5
|
+
// persistence — RenderEdit falls back to its `form-draft:query:list`
|
|
6
|
+
// resume path, the same one a cleared sessionStorage takes on the web.
|
|
7
|
+
const noopDraftStorage = {
|
|
8
|
+
getDraftId: () => null,
|
|
9
|
+
setDraftId: () => { },
|
|
10
|
+
clearDraftId: () => { },
|
|
11
|
+
};
|
|
12
|
+
const DraftStorageContext = createContext(noopDraftStorage);
|
|
13
|
+
export function DraftStorageProvider({ value, children }) {
|
|
14
|
+
return _jsx(DraftStorageContext, { value: value, children: children });
|
|
15
|
+
}
|
|
16
|
+
export function useDraftStorage() {
|
|
17
|
+
return useContext(DraftStorageContext);
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type UserRolesProviderProps = {
|
|
3
|
+
readonly roles: readonly string[] | undefined;
|
|
4
|
+
readonly children: ReactNode;
|
|
5
|
+
};
|
|
6
|
+
export declare function UserRolesProvider({ roles, children }: UserRolesProviderProps): ReactNode;
|
|
7
|
+
export declare function useUserRoles(): readonly string[] | undefined;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
3
|
// Threads the current user's roles through the tree so KumikoScreen can
|
|
4
4
|
// gate role-restricted screens at render time (see #1203 — nav filtering
|
|
5
5
|
// alone doesn't stop a direct-URL/screenQn hit on a role-gated screen).
|
|
@@ -10,18 +10,10 @@ import { createContext, type ReactNode, useContext } from "react";
|
|
|
10
10
|
// unknown" — deliberately distinct from `[]` ("authenticated, no
|
|
11
11
|
// roles"). Both deny role-gated screens; only `undefined` also lets an
|
|
12
12
|
// app without any role-gated screens skip wiring this entirely.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export type UserRolesProviderProps = {
|
|
17
|
-
readonly roles: readonly string[] | undefined;
|
|
18
|
-
readonly children: ReactNode;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export function UserRolesProvider({ roles, children }: UserRolesProviderProps): ReactNode {
|
|
22
|
-
return <UserRolesContext value={roles}>{children}</UserRolesContext>;
|
|
13
|
+
const UserRolesContext = createContext(undefined);
|
|
14
|
+
export function UserRolesProvider({ roles, children }) {
|
|
15
|
+
return _jsx(UserRolesContext, { value: roles, children: children });
|
|
23
16
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return useContext(UserRolesContext);
|
|
17
|
+
export function useUserRoles() {
|
|
18
|
+
return useContext(UserRolesContext);
|
|
27
19
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function formatWhen(value: string): string;
|