@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/app/action-form-body.d.ts +50 -0
  2. package/dist/app/action-form-body.js +275 -0
  3. package/dist/app/client-actions.js +2 -2
  4. package/dist/app/config-edit-body.d.ts +9 -0
  5. package/dist/app/config-edit-body.js +340 -0
  6. package/dist/app/entity-edit-prefill.d.ts +27 -0
  7. package/dist/app/entity-edit-prefill.js +377 -0
  8. package/dist/app/entity-edit-screen.d.ts +11 -0
  9. package/dist/app/entity-edit-screen.js +395 -0
  10. package/dist/app/entity-list-screen.d.ts +10 -0
  11. package/dist/app/entity-list-screen.js +547 -0
  12. package/dist/app/kumiko-screen.d.ts +1 -36
  13. package/dist/app/kumiko-screen.js +16 -2855
  14. package/dist/app/projection-detail-body.d.ts +12 -0
  15. package/dist/app/projection-detail-body.js +464 -0
  16. package/dist/app/projection-detail-shim.d.ts +1 -1
  17. package/dist/app/projection-detail-shim.js +5 -1
  18. package/dist/app/projection-list-body.d.ts +10 -0
  19. package/dist/app/projection-list-body.js +245 -0
  20. package/dist/app/projection-list-shim.d.ts +2 -1
  21. package/dist/app/projection-list-shim.js +4 -0
  22. package/dist/app/reference-facet-bridge.js +2 -2
  23. package/dist/app/row-actions.js +3 -4
  24. package/dist/app/screen-shared.d.ts +72 -0
  25. package/dist/app/screen-shared.js +325 -0
  26. package/dist/app/secret-mint-body.js +3 -3
  27. package/dist/components/edit-mounts.d.ts +33 -0
  28. package/dist/components/edit-mounts.js +53 -0
  29. package/dist/components/error-boundary.js +2 -2
  30. package/dist/components/reference-create-dialog.js +1 -1
  31. package/dist/components/related-list-section.js +2 -2
  32. package/dist/components/render-edit-helpers.d.ts +29 -0
  33. package/dist/components/render-edit-helpers.js +121 -0
  34. package/dist/components/render-edit.d.ts +1 -4
  35. package/dist/components/render-edit.js +41 -443
  36. package/dist/components/render-field.js +3 -4
  37. package/dist/components/render-list.js +2 -2
  38. package/dist/components/use-wizard-form-draft.d.ts +51 -0
  39. package/dist/components/use-wizard-form-draft.js +291 -0
  40. package/dist/dev-console.d.ts +3 -0
  41. package/dist/dev-console.js +15 -0
  42. package/dist/headless/contracts/primitives.js +14 -17
  43. package/dist/headless/format/index.js +3 -3
  44. package/dist/headless/view-model/edit.d.ts +1 -1
  45. package/dist/headless/view-model/edit.js +9 -16
  46. package/dist/headless/view-model/list.d.ts +1 -1
  47. package/dist/headless/view-model/list.js +3 -8
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +5 -5
  50. package/dist/live/error-mapping.js +3 -4
  51. package/dist/web/app/create-app.js +5 -8
  52. package/dist/web/app/dashboard-body.js +4 -599
  53. package/dist/web/app/dashboard-panels.d.ts +53 -0
  54. package/dist/web/app/dashboard-panels.js +373 -0
  55. package/dist/web/app/dashboard-shared.d.ts +90 -0
  56. package/dist/web/app/dashboard-shared.js +171 -0
  57. package/dist/web/app/dashboard-text.d.ts +6 -0
  58. package/dist/web/app/dashboard-text.js +65 -0
  59. package/dist/web/layout/nav-tree.js +3 -4
  60. package/dist/web/layout/target-resolver-stub.js +2 -2
  61. package/dist/web/primitives/banner.d.ts +4 -0
  62. package/dist/web/primitives/banner.js +28 -0
  63. package/dist/web/primitives/button.d.ts +7 -0
  64. package/dist/web/primitives/button.js +48 -0
  65. package/dist/web/primitives/card-styles.d.ts +8 -0
  66. package/dist/web/primitives/card-styles.js +18 -0
  67. package/dist/web/primitives/card.d.ts +4 -0
  68. package/dist/web/primitives/card.js +41 -0
  69. package/dist/web/primitives/contexts.d.ts +22 -0
  70. package/dist/web/primitives/contexts.js +50 -0
  71. package/dist/web/primitives/data-table-cell.d.ts +76 -0
  72. package/dist/web/primitives/data-table-cell.js +403 -0
  73. package/dist/web/primitives/data-table.d.ts +4 -0
  74. package/dist/web/primitives/data-table.js +279 -0
  75. package/dist/web/primitives/default-form.d.ts +4 -0
  76. package/dist/web/primitives/default-form.js +79 -0
  77. package/dist/web/primitives/facet-filter.d.ts +14 -0
  78. package/dist/web/primitives/facet-filter.js +39 -0
  79. package/dist/web/primitives/field.d.ts +63 -0
  80. package/dist/web/primitives/field.js +181 -0
  81. package/dist/web/primitives/form.d.ts +53 -0
  82. package/dist/web/primitives/form.js +252 -0
  83. package/dist/web/primitives/index.d.ts +7 -30
  84. package/dist/web/primitives/index.js +27 -2006
  85. package/dist/web/primitives/input.d.ts +4 -0
  86. package/dist/web/primitives/input.js +166 -0
  87. package/dist/web/primitives/layout-elements.d.ts +14 -0
  88. package/dist/web/primitives/layout-elements.js +194 -0
  89. package/dist/web/primitives/modal.js +1 -1
  90. package/dist/web/primitives/pager.d.ts +22 -0
  91. package/dist/web/primitives/pager.js +89 -0
  92. package/dist/web/primitives/row-actions.d.ts +13 -0
  93. package/dist/web/primitives/row-actions.js +178 -0
  94. package/dist/web/primitives/secret-reveal.d.ts +4 -0
  95. package/dist/web/primitives/secret-reveal.js +67 -0
  96. package/package.json +5 -5
  97. package/src/changes.json +6 -0
@@ -0,0 +1,547 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { evalFieldCondition, parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
4
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { RenderList } from "../components/render-list.js";
7
+ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
8
+ import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
9
+ import { useUserRoles } from "../context/user-roles-context.js";
10
+ import { rendererWarn } from "../dev-console.js";
11
+ import { computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, } from "../headless/index.js";
12
+ import { useListUrlState } from "../hooks/use-list-url-state.js";
13
+ import { useQuery } from "../hooks/use-query.js";
14
+ import { useTranslation } from "../i18n.js";
15
+ import { usePrimitives } from "../primitives.js";
16
+ import { screenFillsHeight } from "../screen-fills-height.js";
17
+ import { DrawerHost, useDrawerAction } from "./action-form-body.js";
18
+ import { useAppFeatures } from "./app-features-context.js";
19
+ import { useClientActionRuntime } from "./client-actions.js";
20
+ import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
21
+ import { FacetCountBridges } from "./facet-count-bridge.js";
22
+ import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./list-empty-content.js";
23
+ import { buildEntityFilterFacets, buildFacetCountRequests, buildFilterPayload, mergeReferenceFacetOptions, normalizeDefaultFilters, } from "./list-facets.js";
24
+ import { useNav } from "./nav.js";
25
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
26
+ import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
27
+ import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
28
+ import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionToolbarActions, evalRowExtractor, findRowClickAction, navigateActionSearchParams, refetchAfterWrite, runDrawerRowAction, } from "./row-actions.js";
29
+ import { findEditScreenFor } from "./screen-access.js";
30
+ import { buildListQueryPayload, entityQueryCommand, isEntityEditScreenOfEntity, pageSizeOptionsFor, useNavigateToCreateFor, } from "./screen-shared.js";
31
+ import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
32
+ // entityList adapter — one DataTableFacet per filterable select/boolean
33
+ // entity field, labels via the standard field/option i18n convention.
34
+ function resolveEntityFacetSpecs(fields, featureName, entityName, translate) {
35
+ const out = [];
36
+ for (const [field, rawDef] of Object.entries(fields)) {
37
+ // entity.fields is weakly typed at the renderer layer (Record<string,
38
+ // unknown>, deserialized from the schema); boundary cast like buildInitialValues.
39
+ const def = rawDef;
40
+ if (def.filterable !== true)
41
+ continue;
42
+ const label = translate(fieldLabelKey(featureName, entityName, field));
43
+ if (def.type === "select" && Array.isArray(def.options)) {
44
+ out.push({
45
+ field,
46
+ type: "select",
47
+ label,
48
+ options: def.options.map((value) => ({
49
+ value,
50
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, value)),
51
+ })),
52
+ });
53
+ }
54
+ else if (def.type === "boolean") {
55
+ out.push({
56
+ field,
57
+ type: "boolean",
58
+ label,
59
+ options: [
60
+ {
61
+ value: "true",
62
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, "true")),
63
+ },
64
+ {
65
+ value: "false",
66
+ label: translate(fieldOptionLabelKey(featureName, entityName, field, "false")),
67
+ },
68
+ ],
69
+ });
70
+ }
71
+ else if (def.type === "reference" && def.entity !== undefined) {
72
+ const target = parseRefTarget(def.entity, featureName);
73
+ out.push({
74
+ field,
75
+ type: "reference",
76
+ label,
77
+ options: [],
78
+ reference: {
79
+ refEntity: target.entityName,
80
+ refFeature: target.featureName,
81
+ labelField: def.labelField ?? "id",
82
+ },
83
+ });
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ export function EntityListScreen({ schema, screen, translate, onRowClick, }) {
89
+ const { Banner, Text } = usePrimitives();
90
+ const entity = schema.entities[screen.entity];
91
+ if (!entity) {
92
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-entity-missing", children: ["Entity ", _jsx(Text, { variant: "code", children: screen.entity }), " referenced by screen", " ", _jsx(Text, { variant: "code", children: screen.id }), " not registered in the schema."] }));
93
+ }
94
+ // Entity resolved — mount the inner component so useQuery only fires
95
+ // when there's actually something to render. A missing entity is a
96
+ // dev-error state; no point hitting the server for it.
97
+ return (_jsx(EntityListBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onRowClick !== undefined && { onRowClick }) }));
98
+ }
99
+ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
100
+ const isEmbeddedScreen = useIsEmbeddedScreen();
101
+ const featureName = schema.featureName;
102
+ const resolvedCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen, screen.createUnavailable);
103
+ const onCreate = screen.allowCreate === false ? undefined : resolvedCreate;
104
+ const queryType = entityQueryCommand(featureName, screen.entity, "list");
105
+ const nav = useNav();
106
+ const host = useReturnHost();
107
+ const userRoles = useUserRoles();
108
+ const appFeatures = useAppFeatures();
109
+ const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
110
+ const defaultEditScreen = useMemo(() => findEditScreenFor(screen.entity, appFeatures, userRoles), [appFeatures, screen.entity, userRoles]);
111
+ // URL state: sort/dir/q/page live under the screen.id namespace
112
+ // (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`) so two
113
+ // lists on the same route do not fight over the same query keys. The default
114
+ // sort from the screen definition only wins when the URL has no sort:
115
+ // author default vs user choice.
116
+ const defaultFilters = useMemo(() => normalizeDefaultFilters(screen.defaultFilters), [screen.defaultFilters]);
117
+ const urlState = useListUrlState(screen.id, defaultFilters);
118
+ const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
119
+ const limit = urlState.pageSize ?? screen.pageSize ?? 50;
120
+ const paginationMode = screen.pagination ?? "pages";
121
+ const usePager = paginationMode === "pages";
122
+ const useInfinite = paginationMode === "infinite";
123
+ // Infinite scroll: local state accumulates rows across several useQuery
124
+ // calls (instead of replacing them like the pager). The cursor changes when
125
+ // the user reaches the bottom sentinel; on a sort/q change everything is
126
+ // reset, since the insert order of the accumulated rows would otherwise be
127
+ // inconsistent with the new sorting.
128
+ const [accumulated, setAccumulated] = useState([]);
129
+ const [cursor, setCursor] = useState(undefined);
130
+ const [hasMore, setHasMore] = useState(true);
131
+ // Ref so the useEffect cleanup does not see stale state.
132
+ const sortQRef = useRef("");
133
+ const sortQKey = `${urlState.q}|${effectiveSort?.field ?? ""}|${effectiveSort?.dir ?? ""}`;
134
+ // Reset rows + cursor on a sort/q change, before the next useQuery so the
135
+ // reload starts with cursor=undefined.
136
+ useEffect(() => {
137
+ if (!useInfinite)
138
+ return;
139
+ if (sortQRef.current === sortQKey)
140
+ return;
141
+ sortQRef.current = sortQKey;
142
+ setAccumulated([]);
143
+ setCursor(undefined);
144
+ setHasMore(true);
145
+ }, [useInfinite, sortQKey]);
146
+ // User-selected faceted filters from the URL state -> payload.filters.
147
+ // typeOf reads the field type straight from entity.fields (not gated on
148
+ // filterable, deliberately permissive; see the buildFilterPayload doc).
149
+ const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => {
150
+ // entity.fields is weakly typed at the renderer layer (deserialized from
151
+ // the schema); boundary cast like buildInitialValues.
152
+ const type = propertyOf(entity.fields[field], "type");
153
+ return typeof type === "string" ? type : undefined;
154
+ }), [urlState.filters, entity.fields]);
155
+ // Entity-only additions (screen.filter, faceted filters) layer on top of
156
+ // the shared buildListQueryPayload — projectionList has neither.
157
+ const queryPayload = useMemo(() => {
158
+ const payload = buildListQueryPayload({
159
+ limit,
160
+ search: urlState.q,
161
+ sort: effectiveSort,
162
+ usePager,
163
+ page: urlState.page,
164
+ useInfinite,
165
+ cursor,
166
+ });
167
+ // Screen filter (tier 2.7c), declared by the author on the schema and
168
+ // independent of the user's q search. Several buckets of the same entity
169
+ // ("Upcoming" / "Active" / "Past") use different filters with the same
170
+ // query handler.
171
+ if (screen.filter !== undefined) {
172
+ payload["filter"] = screen.filter;
173
+ }
174
+ if (filterPayload.length > 0) {
175
+ payload["filters"] = filterPayload;
176
+ }
177
+ return payload;
178
+ }, [
179
+ limit,
180
+ urlState.q,
181
+ effectiveSort,
182
+ screen.filter,
183
+ filterPayload,
184
+ usePager,
185
+ urlState.page,
186
+ useInfinite,
187
+ cursor,
188
+ ]);
189
+ const rowsQuery = useQuery(queryType, queryPayload, { live: true });
190
+ // Facet counts come from their own queries, which a write leaves untouched.
191
+ const [facetCountRefreshNonce, setFacetCountRefreshNonce] = useState(0);
192
+ const refreshFacetCounts = useCallback(() => setFacetCountRefreshNonce((n) => n + 1), []);
193
+ const refreshRowsAfterWrite = useCallback(async () => {
194
+ refreshFacetCounts();
195
+ if (useInfinite) {
196
+ setAccumulated([]);
197
+ setCursor(undefined);
198
+ setHasMore(true);
199
+ return;
200
+ }
201
+ await refetchAfterWrite(rowsQuery.refetch);
202
+ }, [useInfinite, rowsQuery.refetch, refreshFacetCounts]);
203
+ // Infinite scroll: on every successful result append the rows and derive
204
+ // hasMore from nextCursor. Live updates (postgres NOTIFY) and initial loads
205
+ // both pass through here; the useEffect dependency array pins the dedup to
206
+ // the last processed data pointer.
207
+ const lastDataRef = useRef(null);
208
+ useEffect(() => {
209
+ if (!useInfinite)
210
+ return;
211
+ const data = rowsQuery.data;
212
+ if (data === null)
213
+ return;
214
+ if (data === lastDataRef.current)
215
+ return;
216
+ lastDataRef.current = data;
217
+ setAccumulated((prev) => {
218
+ // When cursor is undefined this is the first page after a sort/q change:
219
+ // replace everything instead of appending. Otherwise dedupe on id in case
220
+ // live updates bring in an entry on the next page that was already appended.
221
+ if (cursor === undefined)
222
+ return data.rows;
223
+ const seen = new Set(prev.map((r) => r["id"]));
224
+ const fresh = data.rows.filter((r) => !seen.has(r["id"]));
225
+ return [...prev, ...fresh];
226
+ });
227
+ setHasMore(data.nextCursor !== null);
228
+ }, [rowsQuery.data, useInfinite, cursor]);
229
+ const loadMore = useCallback(() => {
230
+ if (!useInfinite)
231
+ return;
232
+ if (rowsQuery.loading)
233
+ return;
234
+ const data = rowsQuery.data;
235
+ if (data?.nextCursor === undefined || data.nextCursor === null)
236
+ return;
237
+ setCursor(data.nextCursor);
238
+ }, [useInfinite, rowsQuery.loading, rowsQuery.data]);
239
+ // RowActions: schema form (handler QN + i18n key) -> resolved form
240
+ // (dispatcher call + translated strings). dispatcher.write knows the user
241
+ // internally (JWT cookie). The schema may contain raw strings as well as
242
+ // i18n keys; translate() returns the key when the bundle does not know it
243
+ // (convention everywhere in the renderer).
244
+ // Hook order: ALL hooks before the early return for loading/error,
245
+ // otherwise the hook slots collide between renders.
246
+ const t = useTranslation();
247
+ const effectiveTranslate = translate ?? t;
248
+ // Faceted filter: one dropdown per filterable select/boolean/reference field.
249
+ // Labels + select option labels use the same i18n convention as the column
250
+ // headers (fieldLabelKey / :option:<value>); reference fields load their
251
+ // options asynchronously through ReferenceFacetBridges.
252
+ const entityFacetSpecs = useMemo(() => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate), [entity.fields, featureName, screen.entity, effectiveTranslate]);
253
+ const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
254
+ const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
255
+ const [facetCounts, setFacetCounts] = useState({});
256
+ const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
257
+ const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
258
+ const facetCountQueries = useMemo(() => buildFacetCountRequests(entityFacetSpecs, screen.facets, effectiveTranslate).map((request) => {
259
+ const filters = buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type);
260
+ return {
261
+ key: request.key,
262
+ payload: {
263
+ limit: 1,
264
+ totalCount: true,
265
+ ...(urlState.q !== "" && { search: urlState.q }),
266
+ ...(screen.filter !== undefined && { filter: screen.filter }),
267
+ ...(filters.length > 0 && { filters }),
268
+ },
269
+ };
270
+ }), [
271
+ entityFacetSpecs,
272
+ screen.facets,
273
+ screen.filter,
274
+ effectiveTranslate,
275
+ urlState.filters,
276
+ urlState.q,
277
+ entity.fields,
278
+ ]);
279
+ // Soft dispatcher: in tests that mount without a DispatcherProvider,
280
+ // rowActions stays undefined instead of crashing. Real apps have the
281
+ // provider via createKumikoApp; if it is missing it is most likely a setup
282
+ // error, so warn once so the author finds it (otherwise the action column
283
+ // silently renders nothing and the "why are my buttons gone?" debugging is
284
+ // expensive).
285
+ const dispatcher = useOptionalDispatcher();
286
+ const clientActions = useClientActionRuntime();
287
+ const hasRowActions = screen.rowActions !== undefined && screen.rowActions.length > 0;
288
+ useEffect(() => {
289
+ if (hasRowActions && dispatcher === undefined) {
290
+ rendererWarn(`[kumiko] Screen "${screen.id}" deklariert rowActions, aber kein <DispatcherProvider> ist mounted — die Action-Spalte wird nicht gerendert. createKumikoApp() wired den Provider automatisch.`);
291
+ }
292
+ }, [hasRowActions, dispatcher, screen.id]);
293
+ // Discriminated union: writeHandler (default) dispatches a server
294
+ // handler, navigate calls nav.navigate(), possibly with URL search params
295
+ // from params(row). nav lives further down in the EntityListBody scope.
296
+ // Shared navigate execution: both the "..." action menu (rowActions) and the
297
+ // row body click (rowClick) go through it, one path so the two cannot
298
+ // drift apart.
299
+ const runNavigate = useCallback((action, row) => {
300
+ if (action.entity !== undefined) {
301
+ // Entity targets (fw#2228) only resolve in the NavApi implementation
302
+ // against ALL features (resolveTarget in renderer/web/nav.tsx); this
303
+ // package only knows `schema` (the one calling feature) and cannot look up
304
+ // detailFor across features itself.
305
+ const explicit = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
306
+ // Same-entity only — cross-entity targets must declare entityId (boot-validator).
307
+ const fallback = action.entity === screen.entity ? String(row.values["id"] ?? "") : undefined;
308
+ const id = explicit ?? fallback ?? "";
309
+ if (id === "")
310
+ return;
311
+ const params = navigateActionSearchParams(action, row.values);
312
+ navigateWithReturnTo(nav, { entity: action.entity, id }, host, params);
313
+ }
314
+ else if (action.screen !== undefined) {
315
+ // Default entityId for entityEdit targets: row["id"] when no explicit
316
+ // entityId field name is set. Only for targets of the SAME entity, otherwise
317
+ // a cross-entity edit screen would get the wrong row.id injected.
318
+ const targetIsEntityEdit = isEntityEditScreenOfEntity(screen.entity, action.screen, schema, appFeatures);
319
+ const explicit = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
320
+ const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
321
+ const entityId = explicit ?? fallback;
322
+ const target = {
323
+ screenId: action.screen,
324
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
325
+ };
326
+ // setSearchParams takes string|null, so complex values become strings (the
327
+ // reader only knows strings via the URL). Known edge: if the action targets
328
+ // the CURRENT pathname, the params merge onto the old ?-string (practically
329
+ // unreachable for row actions, the path differs).
330
+ const params = navigateActionSearchParams(action, row.values);
331
+ navigateWithReturnTo(nav, target, host, params);
332
+ }
333
+ }, [nav, schema, appFeatures, screen.entity, host]);
334
+ const rowActions = useMemo(() => {
335
+ const declared = screen.rowActions ?? [];
336
+ // Prepended unless a declared rowAction already has id "edit" — declared wins.
337
+ const declaredHasEdit = declared.some((a) => a.id === "edit");
338
+ const defaultEditRowAction = declaredHasEdit
339
+ ? undefined
340
+ : buildDefaultEditRowAction(defaultEditScreen);
341
+ const effectiveActions = defaultEditRowAction !== undefined ? [defaultEditRowAction, ...declared] : declared;
342
+ if (effectiveActions.length === 0)
343
+ return undefined;
344
+ return effectiveActions
345
+ .map((action) => {
346
+ // The navigate variant needs no dispatcher; nav is always present
347
+ // (provider from createKumikoApp).
348
+ if (action.kind === "navigate") {
349
+ const navigateAction = action;
350
+ const actionVisible = action.visible;
351
+ const actionIcon = resolveActionIcon(action.id, action.icon);
352
+ return {
353
+ id: action.id,
354
+ label: effectiveTranslate(action.label),
355
+ ...(action.style !== undefined && { style: action.style }),
356
+ ...(action.display !== undefined && { display: action.display }),
357
+ confirmRequired: false,
358
+ ...(navigateAction.rowClick === true && { rowClick: true }),
359
+ ...(actionIcon !== undefined && { icon: actionIcon }),
360
+ onTrigger: (row) => runNavigate(navigateAction, row),
361
+ ...(actionVisible !== undefined && {
362
+ isVisible: (row) => evalFieldCondition(actionVisible, row.values),
363
+ }),
364
+ };
365
+ }
366
+ if (action.kind === "drawer") {
367
+ return buildDrawerRowAction(action, effectiveTranslate, openDrawer);
368
+ }
369
+ if (action.kind === "clientAction") {
370
+ return buildClientActionRowAction(action, effectiveTranslate, clientActions, refreshRowsAfterWrite);
371
+ }
372
+ if (dispatcher === undefined)
373
+ return null;
374
+ const writeAction = action;
375
+ const writeActionVisible = writeAction.visible;
376
+ return {
377
+ id: writeAction.id,
378
+ label: effectiveTranslate(writeAction.label),
379
+ style: writeAction.style,
380
+ icon: resolveActionIcon(writeAction.id, writeAction.icon),
381
+ confirm: writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
382
+ confirmLabel: writeAction.confirmLabel !== undefined
383
+ ? effectiveTranslate(writeAction.confirmLabel)
384
+ : undefined,
385
+ onTrigger: async (row) => {
386
+ const buildPayload = writeAction.payload;
387
+ const payload = buildPayload !== undefined
388
+ ? evalRowExtractor(buildPayload, row.values)
389
+ : { id: row.values["id"] };
390
+ const result = await dispatcher.write(writeAction.handler, payload);
391
+ // write() does not throw: a failure result MUST become an error here,
392
+ // otherwise the confirm dialog closes silently and the user sees "nothing
393
+ // happened" (prod bug 2026-06-07).
394
+ if (!result.isSuccess) {
395
+ throw new WriteFailedError(result.error, dispatcherErrorText(result.error, effectiveTranslate));
396
+ }
397
+ await refreshRowsAfterWrite();
398
+ },
399
+ isVisible: writeActionVisible !== undefined
400
+ ? (row) => evalFieldCondition(writeActionVisible, row.values)
401
+ : undefined,
402
+ };
403
+ })
404
+ .filter((a) => a !== null);
405
+ }, [
406
+ screen.rowActions,
407
+ defaultEditScreen,
408
+ effectiveTranslate,
409
+ dispatcher,
410
+ clientActions,
411
+ runNavigate,
412
+ refreshRowsAfterWrite,
413
+ openDrawer,
414
+ ]);
415
+ // ToolbarActions: Schema → Resolved-Form (analog rowActions), shared with
416
+ // projectionList and relatedList sections via buildProjectionToolbarActions.
417
+ const toolbarActions = useMemo(() => buildProjectionToolbarActions({
418
+ toolbarActions: screen.toolbarActions,
419
+ translate: effectiveTranslate,
420
+ dispatcher,
421
+ nav,
422
+ refetch: refreshRowsAfterWrite,
423
+ openDrawer,
424
+ host,
425
+ access: { appFeatures, userRoles },
426
+ }), [
427
+ screen.toolbarActions,
428
+ effectiveTranslate,
429
+ nav,
430
+ host,
431
+ dispatcher,
432
+ refreshRowsAfterWrite,
433
+ openDrawer,
434
+ appFeatures,
435
+ userRoles,
436
+ ]);
437
+ const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
438
+ nav,
439
+ host,
440
+ dispatcher,
441
+ openDrawer,
442
+ onWriteSuccess: refreshRowsAfterWrite,
443
+ });
444
+ // Expandable rows: open state is keyed by row id, so it survives refetch,
445
+ // sort and page changes. Several rows can be open at once.
446
+ const expandableRow = screen.expandableRow;
447
+ const expandableSection = useMemo(() => expandableRow !== undefined
448
+ ? computeRelatedListSectionViewModel(expandableRow, effectiveTranslate)
449
+ : undefined, [expandableRow, effectiveTranslate]);
450
+ const [expandedRowIds, setExpandedRowIds] = useState(() => new Set());
451
+ const toggleRowExpanded = useCallback((rowId) => {
452
+ setExpandedRowIds((prev) => {
453
+ const next = new Set(prev);
454
+ if (next.has(rowId))
455
+ next.delete(rowId);
456
+ else
457
+ next.add(rowId);
458
+ return next;
459
+ });
460
+ }, []);
461
+ // Bumped after a write that ran outside the sub-list (drawer submit,
462
+ // title-row action) so open sub-lists remount and reload.
463
+ const [expansionNonce, setExpansionNonce] = useState(0);
464
+ const invalidateExpansions = useCallback(() => setExpansionNonce((n) => n + 1), []);
465
+ const renderExpandedRow = useCallback((row) => expandableSection === undefined ? null : (_jsx(EntityListExpandedRow, { section: expandableSection, row: row, featureName: featureName, ...(translate !== undefined && { translate }), expansionNonce: expansionNonce, openDrawer: openDrawer, onAfterWrite: refreshRowsAfterWrite, onSubListInvalidated: invalidateExpansions })), [
466
+ expandableSection,
467
+ featureName,
468
+ translate,
469
+ expansionNonce,
470
+ openDrawer,
471
+ refreshRowsAfterWrite,
472
+ invalidateExpansions,
473
+ ]);
474
+ if (rowsQuery.loading && rowsQuery.data === null) {
475
+ return _jsx(QueryLoadingBanner, {});
476
+ }
477
+ if (rowsQuery.error && rowsQuery.data === null) {
478
+ return (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch }));
479
+ }
480
+ // A failed refetch keeps the last good data on screen.
481
+ const refetchErrorBanner = rowsQuery.error ? (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch })) : null;
482
+ // RenderList's onRowClick is a 1-arg callback; KumikoScreen's
483
+ // 2-arg shape (row, entityName) is the public surface — thread the
484
+ // screen's entity through here so callers don't have to re-derive
485
+ // it.
486
+ // Row body click: a navigate rowAction explicitly marked as rowClick wins
487
+ // (declared author intent "clicking the row does X"). Without the flag the
488
+ // path is unchanged: create-app's entityEdit default or an onRowClick set
489
+ // by the app author.
490
+ const rowClickAction = findRowClickAction(screen.rowActions);
491
+ const wrappedOnRowClick = rowClickAction
492
+ ? (row) => rowClickAction.kind === "drawer"
493
+ ? runDrawerRowAction(rowClickAction, openDrawer, row)
494
+ : runNavigate(rowClickAction, row)
495
+ : onRowClick !== undefined
496
+ ? (row) => onRowClick(row, screen.entity)
497
+ : undefined;
498
+ // Searchable default: explicit author choice wins, otherwise auto-on when
499
+ // the entity has searchable fields (dead toolbar slot otherwise — the
500
+ // server search index has nothing to filter). Gated on
501
+ // schema.searchAdapterMissing !== true regardless of source (explicit or
502
+ // auto) — a search bar the server can't serve would 422 at request-time
503
+ // (#2032); matches the boot-time check in api/server.ts (#2051).
504
+ const searchable = schema.searchAdapterMissing !== true &&
505
+ (screen.searchable ??
506
+ Object.values(entity.fields).some((f) => "searchable" in f && f.searchable === true));
507
+ // Assemble pager props only for pagination="pages". The server total
508
+ // arrives async; until it is there RenderList renders the table without a
509
+ // pager (the pager has its own total>0 guard).
510
+ const total = rowsQuery.data?.total;
511
+ const pager = usePager && total !== undefined
512
+ ? {
513
+ page: urlState.page,
514
+ limit,
515
+ total,
516
+ onPageChange: urlState.setPage,
517
+ pageSizeOptions: pageSizeOptionsFor(limit),
518
+ onPageSizeChange: urlState.setPageSize,
519
+ }
520
+ : undefined;
521
+ // In infinite mode rows come from accumulated (collected across several
522
+ // useQuery calls), with pages/false from the current result.
523
+ const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
524
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
525
+ description: effectiveTranslate(screen.description),
526
+ }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
527
+ ? { screenPadding: false, chromeless: true }
528
+ : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
529
+ onReachEnd: loadMore,
530
+ loadingMore: rowsQuery.loading,
531
+ hasMore,
532
+ }), ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
533
+ filterFacets,
534
+ filterValues: urlState.filters,
535
+ onFilterChange: urlState.setFilter,
536
+ onFilterReset: urlState.clearFilters,
537
+ }), ...(expandableSection !== undefined && {
538
+ expandedRowIds,
539
+ onToggleRowExpanded: toggleRowExpanded,
540
+ renderExpandedRow,
541
+ }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
542
+ closeDrawer();
543
+ invalidateExpansions();
544
+ refreshFacetCounts();
545
+ void rowsQuery.refetch();
546
+ } })] }));
547
+ }
@@ -1,7 +1,6 @@
1
1
  import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { type ReactNode } from "react";
3
- import type { FormValues, ListRowViewModel, Translate } from "../headless/index.ts";
4
- import { type ListSort } from "../hooks/use-list-url-state.ts";
3
+ import type { ListRowViewModel, Translate } from "../headless/index.ts";
5
4
  import type { FeatureSchema } from "./feature-schema.ts";
6
5
  import { qualifyScreenId } from "./qualify-screen-id.ts";
7
6
  import { screenAccessAllows } from "./screen-access.ts";
@@ -19,38 +18,4 @@ export { qualifyScreenId };
19
18
  export declare function qualifyNavId(featureName: string, navId: string): string;
20
19
  export { screenAccessAllows };
21
20
  export declare function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCopyLink, }: KumikoScreenProps): ReactNode;
22
- /** Empty state for a screen QN that is not in the schema (feature disabled for
23
- * the tenant, stale link, miswired screen). The QN only goes to the console. */
24
- export declare function ScreenMissingState({ screenQn, homeScreenId, testId, }: {
25
- readonly screenQn: string;
26
- readonly homeScreenId?: string;
27
- readonly testId?: string;
28
- }): ReactNode;
29
- export declare function buildInitialValues(fields: Readonly<Record<string, unknown>>, defaultCurrency?: string, moneyCurrencyOverrides?: Readonly<Record<string, string>>): FormValues;
30
- export declare function tenantCurrencyMoneyFieldNames(fields: Readonly<Record<string, unknown>>): readonly string[];
31
- export declare function literalCurrencyOverrides(fields: Readonly<Record<string, unknown>>): Readonly<Record<string, string>>;
32
- export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string | undefined): {
33
- readonly overrides: Readonly<Record<string, string>> | undefined;
34
- readonly loading: boolean;
35
- readonly unresolved: boolean;
36
- };
37
- export type InitialValueSources = {
38
- readonly searchParams: Readonly<Record<string, string>>;
39
- readonly urlPrefillFields: readonly string[] | undefined;
40
- readonly renderableFields?: ReadonlySet<string>;
41
- readonly defaultCurrency?: string;
42
- readonly moneyCurrencyOverrides?: Readonly<Record<string, string>>;
43
- readonly drawerOverrides?: Readonly<Record<string, unknown>>;
44
- readonly handoffValues?: Readonly<Record<string, unknown>>;
45
- };
46
- export declare function mergeSearchParamsIntoInitial(fields: Readonly<Record<string, unknown>>, sources: InitialValueSources): Record<string, unknown>;
47
- export declare function buildListQueryPayload(state: {
48
- readonly limit: number;
49
- readonly search: string;
50
- readonly sort: ListSort | null;
51
- readonly usePager: boolean;
52
- readonly page: number;
53
- readonly useInfinite: boolean;
54
- readonly cursor: string | undefined;
55
- }): Record<string, unknown>;
56
21
  export type { ScreenDefinition };