@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.
- package/dist/app/action-form-body.d.ts +50 -0
- package/dist/app/action-form-body.js +275 -0
- package/dist/app/client-actions.js +2 -2
- package/dist/app/config-edit-body.d.ts +9 -0
- package/dist/app/config-edit-body.js +340 -0
- package/dist/app/entity-edit-prefill.d.ts +27 -0
- package/dist/app/entity-edit-prefill.js +377 -0
- package/dist/app/entity-edit-screen.d.ts +11 -0
- package/dist/app/entity-edit-screen.js +395 -0
- package/dist/app/entity-list-screen.d.ts +10 -0
- package/dist/app/entity-list-screen.js +547 -0
- package/dist/app/kumiko-screen.d.ts +1 -36
- package/dist/app/kumiko-screen.js +16 -2855
- package/dist/app/projection-detail-body.d.ts +12 -0
- package/dist/app/projection-detail-body.js +464 -0
- package/dist/app/projection-detail-shim.d.ts +1 -1
- package/dist/app/projection-detail-shim.js +5 -1
- package/dist/app/projection-list-body.d.ts +10 -0
- package/dist/app/projection-list-body.js +245 -0
- package/dist/app/projection-list-shim.d.ts +2 -1
- package/dist/app/projection-list-shim.js +4 -0
- package/dist/app/reference-facet-bridge.js +2 -2
- package/dist/app/row-actions.js +3 -4
- package/dist/app/screen-shared.d.ts +72 -0
- package/dist/app/screen-shared.js +325 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/components/edit-mounts.d.ts +33 -0
- package/dist/components/edit-mounts.js +53 -0
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +2 -2
- package/dist/components/render-edit-helpers.d.ts +29 -0
- package/dist/components/render-edit-helpers.js +121 -0
- package/dist/components/render-edit.d.ts +1 -4
- package/dist/components/render-edit.js +41 -443
- package/dist/components/render-field.js +3 -4
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-wizard-form-draft.d.ts +51 -0
- package/dist/components/use-wizard-form-draft.js +291 -0
- package/dist/dev-console.d.ts +3 -0
- package/dist/dev-console.js +15 -0
- package/dist/headless/contracts/primitives.js +14 -17
- package/dist/headless/format/index.js +3 -3
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +9 -16
- package/dist/headless/view-model/list.d.ts +1 -1
- package/dist/headless/view-model/list.js +3 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -5
- package/dist/live/error-mapping.js +3 -4
- package/dist/web/app/create-app.js +5 -8
- package/dist/web/app/dashboard-body.js +4 -599
- package/dist/web/app/dashboard-panels.d.ts +53 -0
- package/dist/web/app/dashboard-panels.js +373 -0
- package/dist/web/app/dashboard-shared.d.ts +90 -0
- package/dist/web/app/dashboard-shared.js +171 -0
- package/dist/web/app/dashboard-text.d.ts +6 -0
- package/dist/web/app/dashboard-text.js +65 -0
- package/dist/web/layout/nav-tree.js +3 -4
- package/dist/web/layout/target-resolver-stub.js +2 -2
- package/dist/web/primitives/banner.d.ts +4 -0
- package/dist/web/primitives/banner.js +28 -0
- package/dist/web/primitives/button.d.ts +7 -0
- package/dist/web/primitives/button.js +48 -0
- package/dist/web/primitives/card-styles.d.ts +8 -0
- package/dist/web/primitives/card-styles.js +18 -0
- package/dist/web/primitives/card.d.ts +4 -0
- package/dist/web/primitives/card.js +41 -0
- package/dist/web/primitives/contexts.d.ts +22 -0
- package/dist/web/primitives/contexts.js +50 -0
- package/dist/web/primitives/data-table-cell.d.ts +76 -0
- package/dist/web/primitives/data-table-cell.js +403 -0
- package/dist/web/primitives/data-table.d.ts +4 -0
- package/dist/web/primitives/data-table.js +279 -0
- package/dist/web/primitives/default-form.d.ts +4 -0
- package/dist/web/primitives/default-form.js +79 -0
- package/dist/web/primitives/facet-filter.d.ts +14 -0
- package/dist/web/primitives/facet-filter.js +39 -0
- package/dist/web/primitives/field.d.ts +63 -0
- package/dist/web/primitives/field.js +181 -0
- package/dist/web/primitives/form.d.ts +53 -0
- package/dist/web/primitives/form.js +252 -0
- package/dist/web/primitives/index.d.ts +7 -30
- package/dist/web/primitives/index.js +27 -2006
- package/dist/web/primitives/input.d.ts +4 -0
- package/dist/web/primitives/input.js +166 -0
- package/dist/web/primitives/layout-elements.d.ts +14 -0
- package/dist/web/primitives/layout-elements.js +194 -0
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/pager.d.ts +22 -0
- package/dist/web/primitives/pager.js +89 -0
- package/dist/web/primitives/row-actions.d.ts +13 -0
- package/dist/web/primitives/row-actions.js +178 -0
- package/dist/web/primitives/secret-reveal.d.ts +4 -0
- package/dist/web/primitives/secret-reveal.js +67 -0
- package/package.json +5 -5
- package/src/changes.json +6 -0
|
@@ -0,0 +1,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 {
|
|
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 };
|