@cosmicdrift/kumiko-renderer 0.359.0 → 0.361.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.
@@ -0,0 +1,16 @@
1
+ import type { EditFieldSpec } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { CurrentUser } from "../current-user.js";
3
+ type NormalizedEditField = Exclude<EditFieldSpec, string>;
4
+ /** Calendar date (`YYYY-MM-DD`) of `now` in the given IANA zone. The en-CA
5
+ * locale is used only because it formats dates as year-month-day. */
6
+ export declare function todayInTimeZone(now: Date, timeZone: string): string;
7
+ export type EditPrefillContext = {
8
+ readonly now: Date;
9
+ /** Fallback zone when the user carries none (resolver or browser zone). */
10
+ readonly fallbackTimeZone: string;
11
+ readonly user: CurrentUser | undefined;
12
+ };
13
+ /** Fills empty values of fields that declare `prefill`. Only meant for the
14
+ * create form: existing values (defaults, URL params, handoff) always win. */
15
+ export declare function applyEditPrefill(initial: Readonly<Record<string, unknown>>, specs: readonly NormalizedEditField[], context: EditPrefillContext): Record<string, unknown>;
16
+ export {};
@@ -0,0 +1,30 @@
1
+ /** Calendar date (`YYYY-MM-DD`) of `now` in the given IANA zone. The en-CA
2
+ * locale is used only because it formats dates as year-month-day. */
3
+ export function todayInTimeZone(now, timeZone) {
4
+ return new Intl.DateTimeFormat("en-CA", {
5
+ timeZone,
6
+ year: "numeric",
7
+ month: "2-digit",
8
+ day: "2-digit",
9
+ }).format(now);
10
+ }
11
+ function isEmptyValue(value) {
12
+ return value === undefined || value === null || value === "";
13
+ }
14
+ /** Fills empty values of fields that declare `prefill`. Only meant for the
15
+ * create form: existing values (defaults, URL params, handoff) always win. */
16
+ export function applyEditPrefill(initial, specs, context) {
17
+ const out = { ...initial };
18
+ for (const spec of specs) {
19
+ const { prefill } = spec;
20
+ if (prefill === undefined || !isEmptyValue(out[spec.field]))
21
+ continue;
22
+ if (prefill.kind === "today") {
23
+ out[spec.field] = todayInTimeZone(context.now, context.user?.timeZone ?? context.fallbackTimeZone);
24
+ }
25
+ else if (context.user !== undefined) {
26
+ out[spec.field] = context.user[prefill.property];
27
+ }
28
+ }
29
+ return out;
30
+ }
@@ -0,0 +1,8 @@
1
+ import type { RenderEditAction } from "../components/render-edit-types.js";
2
+ export declare function primaryHeaderAction(actions: readonly RenderEditAction[], preferredId?: string): RenderEditAction | undefined;
3
+ export type CompactHeaderPrimary = {
4
+ readonly action: RenderEditAction;
5
+ readonly iconOnly: boolean;
6
+ };
7
+ export declare function compactHeaderPrimary(actions: readonly RenderEditAction[], preferredId?: string): CompactHeaderPrimary | undefined;
8
+ export declare function orderPlainHeaderActions(actions: readonly RenderEditAction[], preferredId?: string): readonly RenderEditAction[];
@@ -0,0 +1,31 @@
1
+ import { needsActionConfirm } from "../components/render-edit-action-button.js";
2
+ // The primary header action always stays a visible text button: the screen's
3
+ // declared `primaryAction` if it is visible, else `edit`, else the first
4
+ // `style: "primary"` action, else the first action at all.
5
+ export function primaryHeaderAction(actions, preferredId) {
6
+ const preferred = preferredId !== undefined ? actions.find((a) => a.id === preferredId) : undefined;
7
+ return (preferred ??
8
+ actions.find((a) => a.id === "edit") ??
9
+ actions.find((a) => a.style === "primary") ??
10
+ actions[0]);
11
+ }
12
+ // A bare icon for a destructive or confirm-gated action (e.g. "x" for
13
+ // terminate) reads as "close", so only plain icon actions may stand alone.
14
+ function canStandAsIconButton(action) {
15
+ return action?.icon !== undefined && action.style !== "danger" && !needsActionConfirm(action);
16
+ }
17
+ // A declared, visible `primaryAction` is always the phone header's button
18
+ // (labelled when a bare icon would mislead), so it matches the desktop header
19
+ // and `edit` moves into the menu. Without one the pencil-style icon action
20
+ // stays visible, falling back to the precedence-only primary.
21
+ export function compactHeaderPrimary(actions, preferredId) {
22
+ const declared = preferredId !== undefined ? actions.find((a) => a.id === preferredId) : undefined;
23
+ if (declared !== undefined)
24
+ return { action: declared, iconOnly: canStandAsIconButton(declared) };
25
+ const iconAction = primaryHeaderAction(actions);
26
+ return canStandAsIconButton(iconAction) ? { action: iconAction, iconOnly: true } : undefined;
27
+ }
28
+ export function orderPlainHeaderActions(actions, preferredId) {
29
+ const preferred = preferredId !== undefined ? actions.find((a) => a.id === preferredId) : undefined;
30
+ return preferred === undefined ? actions : [preferred, ...actions.filter((a) => a !== preferred)];
31
+ }
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { evalFieldCondition, isRecordLocked, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { applyFormatSpec, buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
4
4
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
5
5
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
@@ -8,12 +8,13 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
8
8
  import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
9
9
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
10
10
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
11
- import { needsActionConfirm, RenderEditActionButton, } from "../components/render-edit-action-button.js";
11
+ import { RenderEditActionButton } from "../components/render-edit-action-button.js";
12
12
  import { RenderList } from "../components/render-list.js";
13
13
  import { useActionMenuItems } from "../components/use-action-menu-items.js";
14
14
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
15
15
  import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
16
16
  import { useUserRoles } from "../context/user-roles-context.js";
17
+ import { useOptionalCurrentUser } from "../current-user.js";
17
18
  import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
18
19
  import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
19
20
  import { useQuery } from "../hooks/use-query.js";
@@ -28,10 +29,13 @@ import { useAppFeatures } from "./app-features-context.js";
28
29
  import { buildConfigEditSchema, synthesizeConfigEditEntity, synthesizeConfigEditScreen, } from "./config-edit-shim.js";
29
30
  import { useCustomScreenComponent } from "./custom-screens.js";
30
31
  import { useDashboardBody } from "./dashboard-body.js";
32
+ import { applyEditPrefill } from "./edit-prefill.js";
31
33
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
32
34
  import { FacetCountBridges } from "./facet-count-bridge.js";
33
35
  import { buildFormSchema } from "./form-schema.js";
34
- import { hiddenRequiredFieldsFilled, layoutFieldNames } from "./layout-fields.js";
36
+ import { compactHeaderPrimary, orderPlainHeaderActions, primaryHeaderAction, } from "./header-actions.js";
37
+ import { hiddenRequiredFieldsFilled, layoutEditFields, layoutFieldNames } from "./layout-fields.js";
38
+ import { isListNarrowed, useListEmptyContent } from "./list-empty-content.js";
35
39
  import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
36
40
  import { useInitialValuesHandoff, useNav } from "./nav.js";
37
41
  import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
@@ -651,20 +655,24 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
651
655
  // without any such field.
652
656
  const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(entity.fields), [entity.fields]);
653
657
  const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
654
- const initial = useMemo(() => mergeSearchParamsIntoInitial(entity.fields, {
658
+ const currentUser = useOptionalCurrentUser();
659
+ const fallbackTimeZone = useOptionalTimeZone();
660
+ const initial = useMemo(() => applyEditPrefill(mergeSearchParamsIntoInitial(entity.fields, {
655
661
  searchParams: nav.searchParams,
656
662
  urlPrefillFields: screen.urlPrefillFields,
657
663
  renderableFields: layoutFieldNames(screen),
658
664
  defaultCurrency: entityDefaultCurrency,
659
665
  ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
660
666
  ...(handoffValues !== undefined && { handoffValues }),
661
- }), [
667
+ }), layoutEditFields(screen), { now: new Date(), fallbackTimeZone, user: currentUser }), [
662
668
  entity.fields,
663
669
  nav.searchParams,
664
670
  screen,
665
671
  entityDefaultCurrency,
666
672
  moneyCurrencyOverrides,
667
673
  handoffValues,
674
+ currentUser,
675
+ fallbackTimeZone,
668
676
  ]);
669
677
  const formSchema = useMemo(() => buildFormSchema(entity, screen, "create"), [entity, screen]);
670
678
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
@@ -697,8 +705,7 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
697
705
  });
698
706
  }
699
707
  else {
700
- const { screenId, entityId } = resolveRedirectTarget(redirect, result.data, schema, appFeatures, controlsRef.current?.getValues());
701
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
708
+ navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, result.data, schema, appFeatures, controlsRef.current?.getValues()));
702
709
  }
703
710
  }
704
711
  else {
@@ -929,14 +936,21 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
929
936
  if (currencyLoading) {
930
937
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
931
938
  }
932
- return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial,
939
+ // Evaluated against the stored record only, so typing in a form can never
940
+ // flip the screen between editable and view.
941
+ // Either condition shows the view: the server rejects saves of a locked record
942
+ // whatever the screen says, so a screen condition can only add read-only, never lift it.
943
+ // A record without the lock field counts as locked (isRecordLocked), matching the server.
944
+ const viewMode = (screen.readOnly !== undefined && evalFieldCondition(screen.readOnly, record)) ||
945
+ isRecordLocked(entity.lockedWhen, record);
946
+ return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial, ...(viewMode && { viewMode: true }),
933
947
  // Real route id for the extension section (set-value UI): the update
934
948
  // form deliberately keeps `id` out of the form values, so the section
935
949
  // needs it explicitly — otherwise it renders in create mode.
936
950
  entityId: entityId,
937
951
  // Stored customFields for the extension section, so editing shows the
938
952
  // persisted values instead of behaving write-only.
939
- extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, onDirtyChange: setFormDirty, ...(screen.allowDelete !== false && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), ...(headerMenuActions !== undefined && { menuActions: headerMenuActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
953
+ extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, onDirtyChange: setFormDirty, ...(screen.allowDelete !== false && !viewMode && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), ...(headerMenuActions !== undefined && { menuActions: headerMenuActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
940
954
  closeDrawer();
941
955
  void onReload();
942
956
  } })] }));
@@ -1478,6 +1492,13 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1478
1492
  refreshRowsAfterWrite,
1479
1493
  openDrawer,
1480
1494
  ]);
1495
+ const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1496
+ nav,
1497
+ host,
1498
+ dispatcher,
1499
+ openDrawer,
1500
+ onWriteSuccess: refreshRowsAfterWrite,
1501
+ });
1481
1502
  // Expandable rows: open state is keyed by row id, so it survives refetch,
1482
1503
  // sort and page changes. Several rows can be open at once.
1483
1504
  const expandableRow = screen.expandableRow;
@@ -1554,7 +1575,9 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1554
1575
  // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1555
1576
  // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1556
1577
  const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
1557
- return (_jsxs(_Fragment, { children: [_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, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1578
+ return (_jsxs(_Fragment, { children: [_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), ...(screen.description !== undefined && {
1579
+ description: effectiveTranslate(screen.description),
1580
+ }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1558
1581
  ? { screenPadding: false, chromeless: true }
1559
1582
  : { 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 && {
1560
1583
  onReachEnd: loadMore,
@@ -1730,6 +1753,13 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1730
1753
  rowsQuery.refetch,
1731
1754
  openDrawer,
1732
1755
  ]);
1756
+ const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1757
+ nav,
1758
+ host,
1759
+ dispatcher,
1760
+ openDrawer,
1761
+ onWriteSuccess: rowsQuery.refetch,
1762
+ });
1733
1763
  if (rowsQuery.loading && rowsQuery.data === null) {
1734
1764
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1735
1765
  }
@@ -1764,7 +1794,9 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1764
1794
  onPageSizeChange: urlState.setPageSize,
1765
1795
  }
1766
1796
  : undefined;
1767
- return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1797
+ return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, narrowed: isListNarrowed(activeSearch, urlState.filters), ...(screen.description !== undefined && {
1798
+ description: effectiveTranslate(screen.description),
1799
+ }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1768
1800
  ? { screenPadding: false, chromeless: true }
1769
1801
  : { screenPadding: true }), ...(screenFillsHeight(listScreen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1770
1802
  filterFacets,
@@ -1873,7 +1905,7 @@ function buildSubtitleParts(parts, record, nav, appFeatures, userRoles, host) {
1873
1905
  function headerStatusTone(header, record) {
1874
1906
  if (header.status === undefined)
1875
1907
  return undefined;
1876
- const value = String(record[header.status] ?? "");
1908
+ const value = String(record[header.statusToneField ?? header.status] ?? "");
1877
1909
  const declared = header.statusTones !== undefined && Object.hasOwn(header.statusTones, value)
1878
1910
  ? header.statusTones[value]
1879
1911
  : undefined;
@@ -1896,40 +1928,28 @@ function resolveSubtitleHref(header, record) {
1896
1928
  // natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
1897
1929
  // controller.submit() ohne submit-config throwen — der No-Op macht diesen
1898
1930
  // Pfad harmlos statt ihn dem Zufall zu überlassen.
1899
- // The primary header action always stays a visible text button — `edit` if
1900
- // declared, else the first `style: "primary"` action, else the first action
1901
- // at all. One step longer than row actions' `primaryRowAction` (renderer-web
1902
- // primitives/index.tsx): rows don't declare `style: "primary"`.
1903
- function primaryHeaderAction(actions) {
1904
- return (actions.find((a) => a.id === "edit") ?? actions.find((a) => a.style === "primary") ?? actions[0]);
1905
- }
1906
1931
  // Header actions bar (A7): <=2 actions render as plain buttons; >2 with an
1907
1932
  // `ActionOverflowMenu` primitive collapse to the primary button plus a menu
1908
1933
  // for the rest — same rule as RowActionsCell for table rows. Owns confirm
1909
1934
  // state for menu items: RenderEditActionButton already owns a per-button
1910
1935
  // confirm dialog for the always-visible primary action, but a menu item has
1911
1936
  // no button of its own to carry one.
1912
- function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
1937
+ function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, preferredPrimaryId, PageHeader, }) {
1913
1938
  const t = useTranslation();
1914
1939
  const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
1915
1940
  Dialog,
1916
1941
  onError,
1917
1942
  });
1918
1943
  if (compact && PageHeader !== undefined) {
1919
- const primary = primaryHeaderAction(actions);
1920
- // A bare icon for a destructive or confirm-gated action (e.g. "x" for terminate)
1921
- // reads as "close"; such actions go to the labelled menu instead.
1922
- const iconPrimary = primary?.icon !== undefined && primary.style !== "danger" && !needsActionConfirm(primary)
1923
- ? primary
1924
- : undefined;
1925
- const overflowActions = actions.filter((a) => a !== iconPrimary);
1926
- return (_jsxs(_Fragment, { children: [iconPrimary !== undefined && (_jsx(RenderEditActionButton, { action: iconPrimary, Button: Button, Dialog: Dialog, iconOnly: true, onError: onError }, iconPrimary.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
1944
+ const compactPrimary = compactHeaderPrimary(actions, preferredPrimaryId);
1945
+ const overflowActions = actions.filter((a) => a !== compactPrimary?.action);
1946
+ return (_jsxs(_Fragment, { children: [compactPrimary !== undefined && (_jsx(RenderEditActionButton, { action: compactPrimary.action, Button: Button, Dialog: Dialog, iconOnly: compactPrimary.iconOnly, compactLabel: true, onError: onError }, compactPrimary.action.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
1927
1947
  }
1928
1948
  const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
1929
1949
  if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
1930
- return (_jsx(_Fragment, { children: actions.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
1950
+ return (_jsx(_Fragment, { children: orderPlainHeaderActions(actions, preferredPrimaryId).map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
1931
1951
  }
1932
- const primary = primaryHeaderAction(actions);
1952
+ const primary = primaryHeaderAction(actions, preferredPrimaryId);
1933
1953
  const rest = actions.filter((a) => a.id !== primary?.id);
1934
1954
  return (_jsxs(_Fragment, { children: [primary !== undefined && (_jsx(RenderEditActionButton, { action: primary, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, primary.id)), _jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: "kumiko-screen-projection-detail-actions-overflow", items: rest.map(toMenuItem) }), pendingActionDialog] }));
1935
1955
  }
@@ -2172,7 +2192,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2172
2192
  // these are actions on the record the head shows, not on whichever tab is
2173
2193
  // open, so they must stay in place across tab switches instead of
2174
2194
  // trailing the active tab's content in the card footer.
2175
- const headerActionsContent = hasHeaderActions && (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError }) }));
2195
+ const headerActionsContent = hasHeaderActions && (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, ...(screen.primaryAction !== undefined && { preferredPrimaryId: screen.primaryAction }) }) }));
2176
2196
  // Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
2177
2197
  // this shares the same page padding/width as the record's edit card below
2178
2198
  // it, instead of sitting flush against the screen edge (fw record-screen
@@ -2207,15 +2227,19 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2207
2227
  const metricsBlock = (subtitle, lead) => MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, ...(lead !== undefined && { lead }), children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
2208
2228
  const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
2209
2229
  const pageHeaderCompact = pageHeaderCompactContext && usesPageHeaderSlot;
2230
+ // The phone shell header clips a long status mid-word, so on phones the badge
2231
+ // sits in the body instead of the shell header slot.
2232
+ const statusBadge = header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : undefined;
2233
+ // Lives in the band's subtitle row so it shares the metrics' horizontal inset.
2234
+ const bandSubtitle = pageHeaderCompact && statusBadge !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-status-row", children: statusBadge }), headerSubtitleSlot] })) : (headerSubtitleSlot);
2210
2235
  const actionErrorBanner = actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError }));
2211
- const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(header?.status !== undefined &&
2212
- StatusBadge !== undefined && {
2213
- status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
2214
- }), ...(hasHeaderActions && {
2215
- actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader }) })),
2216
- }) }), MetricBand === undefined && headerSubtitleSlot, MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
2236
+ const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(!pageHeaderCompact && statusBadge !== undefined && { status: statusBadge }), ...(hasHeaderActions && {
2237
+ actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader, ...(screen.primaryAction !== undefined && {
2238
+ preferredPrimaryId: screen.primaryAction,
2239
+ }) }) })),
2240
+ }) }), MetricBand === undefined && bandSubtitle, MetricBand === undefined && headerSlot, (hasMetrics || bandSubtitle !== undefined || headerSlot !== undefined) &&
2217
2241
  (MetricBand !== undefined || hasMetrics) &&
2218
- metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
2242
+ metricsBlock(bandSubtitle, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
2219
2243
  ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
2220
2244
  ...(headerTitleSlot === undefined &&
2221
2245
  recordTitle !== undefined && { title: recordTitle }),
@@ -2309,10 +2333,24 @@ function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fal
2309
2333
  const target = findRedirectTargetScreen(redirect, schema, appFeatures);
2310
2334
  const carriesId = target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
2311
2335
  if (!carriesId)
2312
- return { screenId: targetId, entityId: undefined };
2336
+ return { screenId: targetId, entityId: undefined, tab: undefined };
2313
2337
  const entityId = extractIdField(submittedData, idField) ??
2314
2338
  (fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
2315
- return { screenId: targetId, entityId };
2339
+ return {
2340
+ screenId: targetId,
2341
+ entityId,
2342
+ tab: typeof redirect === "string" ? undefined : redirect.tab,
2343
+ };
2344
+ }
2345
+ function navigateToRedirectTarget(nav, target) {
2346
+ const navTarget = {
2347
+ screenId: target.screenId,
2348
+ ...(target.entityId !== undefined && { entityId: target.entityId }),
2349
+ };
2350
+ if (target.tab === undefined)
2351
+ nav.navigate(navTarget);
2352
+ else
2353
+ nav.navigate(navTarget, { searchParams: { tab: target.tab } });
2316
2354
  }
2317
2355
  // Success navigation for a configured redirect — shared by entityEdit's
2318
2356
  // save and writeHandler record actions. The string form navigates without an
@@ -2328,8 +2366,7 @@ function navigateAfterRedirect(options) {
2328
2366
  nav.navigate({ screenId: lastSegment(redirect) });
2329
2367
  return;
2330
2368
  }
2331
- const { screenId, entityId } = resolveRedirectTarget(redirect, resultData, schema, appFeatures, record);
2332
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2369
+ navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, resultData, schema, appFeatures, record));
2333
2370
  }
2334
2371
  // The update payload nests the saved projection under `data`; a changed
2335
2372
  // parent FK lives there, not in the pre-edit `record`.
@@ -2423,8 +2460,7 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2423
2460
  navigateToReturn(nav, returnTarget);
2424
2461
  }
2425
2462
  else {
2426
- const { screenId, entityId } = resolveRedirectTarget(screen.redirect, result.data, schema, appFeatures);
2427
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2463
+ navigateToRedirectTarget(nav, resolveRedirectTarget(screen.redirect, result.data, schema, appFeatures));
2428
2464
  }
2429
2465
  }
2430
2466
  }, [
@@ -0,0 +1,8 @@
1
+ import type { ListEmptyStateSpec } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { Translate } from "@cosmicdrift/kumiko-headless";
3
+ import type { ListEmptyStateContent } from "../components/list-empty-state.js";
4
+ import { buildRecordActions } from "./row-actions.js";
5
+ type ActionBindings = Pick<Parameters<typeof buildRecordActions>[0], "nav" | "host" | "dispatcher" | "openDrawer" | "onWriteSuccess">;
6
+ export declare function useListEmptyContent(spec: ListEmptyStateSpec | undefined, translate: Translate, bindings: ActionBindings): ListEmptyStateContent | undefined;
7
+ export declare function isListNarrowed(search: string, filters: Readonly<Record<string, readonly string[]>>, defaultFilters?: Readonly<Record<string, readonly string[]>>): boolean;
8
+ export {};
@@ -0,0 +1,42 @@
1
+ import { useMemo } from "react";
2
+ import { buildRecordActions } from "./row-actions.js";
3
+ const NO_RECORD = {};
4
+ // An empty list has no row, so the action is resolved against an empty record.
5
+ export function useListEmptyContent(spec, translate, bindings) {
6
+ const { nav, host, dispatcher, openDrawer, onWriteSuccess } = bindings;
7
+ return useMemo(() => {
8
+ if (spec === undefined)
9
+ return undefined;
10
+ const action = spec.action === undefined
11
+ ? undefined
12
+ : buildRecordActions({
13
+ actions: [spec.action],
14
+ record: NO_RECORD,
15
+ translate,
16
+ nav,
17
+ host,
18
+ dispatcher,
19
+ openDrawer,
20
+ onWriteSuccess,
21
+ })?.[0];
22
+ return {
23
+ title: translate(spec.title),
24
+ ...(spec.description !== undefined && { description: translate(spec.description) }),
25
+ ...(action !== undefined && { action }),
26
+ };
27
+ }, [spec, translate, nav, host, dispatcher, openDrawer, onWriteSuccess]);
28
+ }
29
+ // Facets with a default are only "narrowed" once the user moved off that default.
30
+ export function isListNarrowed(search, filters, defaultFilters = {}) {
31
+ if (search.trim() !== "")
32
+ return true;
33
+ const fields = new Set([...Object.keys(filters), ...Object.keys(defaultFilters)]);
34
+ for (const field of fields) {
35
+ const current = filters[field] ?? [];
36
+ const baseline = defaultFilters[field] ?? [];
37
+ if (current.length !== baseline.length || current.some((v) => !baseline.includes(v))) {
38
+ return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
@@ -141,6 +141,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
141
141
  type: cell.type,
142
142
  required: cell.required,
143
143
  derived: isDerived,
144
+ ...(cell.readOnly === true && { readOnly: true }),
144
145
  referenceOptions,
145
146
  referenceLoading: query?.loading ?? false,
146
147
  };
@@ -151,8 +152,10 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
151
152
  type: cell.type,
152
153
  required: cell.required,
153
154
  derived: isDerived,
155
+ ...(cell.readOnly === true && { readOnly: true }),
154
156
  ...(cell.options !== undefined && { options: cell.options }),
155
157
  ...(cell.optionLabels !== undefined && { optionLabels: cell.optionLabels }),
158
+ ...(cell.display !== undefined && { display: cell.display }),
156
159
  };
157
160
  });
158
161
  const totals = (field.embeddedListTotals ?? []).map((subFieldName) => {
@@ -222,6 +225,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
222
225
  }
223
226
  function handlePasteCells(rowIndex, columnIndex, grid) {
224
227
  const maxItems = field.embeddedListMaxItems;
228
+ const fixedRows = field.embeddedListFixedRows === true;
225
229
  const nextRows = [...rows];
226
230
  const touchedIndices = new Set();
227
231
  let droppedRows = 0;
@@ -229,7 +233,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
229
233
  grid.forEach((gridRow, gridRowOffset) => {
230
234
  const targetRowIndex = rowIndex + gridRowOffset;
231
235
  if (targetRowIndex >= nextRows.length) {
232
- if (maxItems !== undefined && nextRows.length >= maxItems) {
236
+ if (fixedRows || (maxItems !== undefined && nextRows.length >= maxItems)) {
233
237
  droppedRows += 1;
234
238
  return;
235
239
  }
@@ -241,7 +245,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
241
245
  let updatedRow = { ...targetRow };
242
246
  gridRow.forEach((text, gridColOffset) => {
243
247
  const column = columns[columnIndex + gridColOffset];
244
- if (column === undefined)
248
+ if (column === undefined || column.readOnly === true)
245
249
  return;
246
250
  const coerced = coerceCellValue(column, text, field.embeddedListCurrency ?? "EUR");
247
251
  // Also covers an unparseable money paste (e.g. mangled thousands
@@ -263,5 +267,5 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
263
267
  const recomputed = nextRows.map((row, i) => touchedIndices.has(i) ? withRecomputedDerived(row, derived, cells) : row);
264
268
  onChange(recomputed);
265
269
  }
266
- return (_jsx(EmbeddedListInput, { id: id, columns: columns, rows: rows, totals: totals, currency: field.embeddedListCurrency, disabled: field.readOnly, minItems: field.embeddedListMinItems, maxItems: field.embeddedListMaxItems, listIssues: combinedListIssues, rowIssues: rowIssues, cellIssues: cellIssues, onCellChange: handleCellChange, onAddRow: handleAddRow, onRemoveRow: handleRemoveRow, onDuplicateRow: handleDuplicateRow, onMoveRow: handleMoveRow, onPasteCells: handlePasteCells, addLabel: t("kumiko.field.embedded-list.add-row"), removeLabel: t("kumiko.field.embedded-list.remove-row"), duplicateLabel: t("kumiko.field.embedded-list.duplicate-row"), moveUpLabel: t("kumiko.field.embedded-list.move-up"), moveDownLabel: t("kumiko.field.embedded-list.move-down"), emptyLabel: t("kumiko.field.embedded-list.empty"), emptyCtaLabel: t("kumiko.field.embedded-list.empty-cta"), testId: id }));
270
+ return (_jsx(EmbeddedListInput, { id: id, columns: columns, rows: rows, totals: totals, currency: field.embeddedListCurrency, disabled: field.readOnly, fixedRows: field.embeddedListFixedRows, minItems: field.embeddedListMinItems, maxItems: field.embeddedListMaxItems, listIssues: combinedListIssues, rowIssues: rowIssues, cellIssues: cellIssues, onCellChange: handleCellChange, onAddRow: handleAddRow, onRemoveRow: handleRemoveRow, onDuplicateRow: handleDuplicateRow, onMoveRow: handleMoveRow, onPasteCells: handlePasteCells, addLabel: t("kumiko.field.embedded-list.add-row"), actionsLabel: t("kumiko.field.embedded-list.actions"), removeLabel: t("kumiko.field.embedded-list.remove-row"), duplicateLabel: t("kumiko.field.embedded-list.duplicate-row"), moveUpLabel: t("kumiko.field.embedded-list.move-up"), moveDownLabel: t("kumiko.field.embedded-list.move-down"), emptyLabel: t("kumiko.field.embedded-list.empty"), emptyCtaLabel: t("kumiko.field.embedded-list.empty-cta"), testId: id }));
267
271
  }
@@ -0,0 +1,10 @@
1
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
2
+ import type { ReactNode } from "react";
3
+ export type FileListFieldProps = {
4
+ readonly field: EditFieldViewModel;
5
+ readonly id: string;
6
+ readonly kind: "file" | "image";
7
+ readonly onChange: (value: readonly string[]) => void;
8
+ readonly hasError: boolean;
9
+ };
10
+ export declare function FileListField({ field, id, kind, onChange, hasError, }: FileListFieldProps): ReactNode;
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useTranslation } from "../i18n.js";
3
+ import { usePrimitives } from "../primitives.js";
4
+ function isFileIdList(value) {
5
+ return Array.isArray(value);
6
+ }
7
+ function readFileIds(value) {
8
+ if (!isFileIdList(value))
9
+ return [];
10
+ return value.filter((entry) => typeof entry === "string" && entry !== "");
11
+ }
12
+ export function FileListField({ field, id, kind, onChange, hasError, }) {
13
+ const { Input, Button, Grid, GridCell, Text } = usePrimitives();
14
+ const t = useTranslation();
15
+ const fileIds = readFileIds(field.value);
16
+ const disabled = field.readOnly;
17
+ const canAdd = !disabled && (field.maxCount === undefined || fileIds.length < field.maxCount);
18
+ const inputProps = {
19
+ kind,
20
+ name: field.field,
21
+ disabled,
22
+ hasError,
23
+ ...(field.accept !== undefined && { accept: field.accept }),
24
+ ...(field.maxSize !== undefined && { maxSize: field.maxSize }),
25
+ ...(field.entityType !== undefined && { entityType: field.entityType }),
26
+ ...(field.entityId !== undefined && { entityId: field.entityId }),
27
+ ...(field.fieldName !== undefined && { fieldName: field.fieldName }),
28
+ ...(kind === "image" &&
29
+ field.imageVariant !== undefined && { imageVariant: field.imageVariant }),
30
+ ...(kind === "image" && field.capture !== undefined && { capture: field.capture }),
31
+ };
32
+ function move(fromIndex, toIndex) {
33
+ const next = [...fileIds];
34
+ const [moved] = next.splice(fromIndex, 1);
35
+ if (moved === undefined)
36
+ return;
37
+ next.splice(toIndex, 0, moved);
38
+ onChange(next);
39
+ }
40
+ return (_jsxs(Grid, { columns: 1, testId: `${id}-file-list`, children: [fileIds.length === 0 && _jsx(Text, { variant: "muted", children: t("kumiko.field.file-list.empty") }), fileIds.map((fileId, index) => (_jsxs(GridCell, { children: [_jsx(Input, { ...inputProps, id: `${id}-${index}`, value: fileId, onChange: (next) => {
41
+ if (next === null)
42
+ onChange(fileIds.filter((_, i) => i !== index));
43
+ else
44
+ onChange(fileIds.map((existing, i) => (i === index ? next : existing)));
45
+ } }), !disabled && (_jsxs(Grid, { columns: "auto", children: [index > 0 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-up", ariaLabel: t("kumiko.field.file-list.move-up"), testId: `${id}-${index}-move-up`, onClick: () => move(index, index - 1) })), index < fileIds.length - 1 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-down", ariaLabel: t("kumiko.field.file-list.move-down"), testId: `${id}-${index}-move-down`, onClick: () => move(index, index + 1) }))] }))] }, fileId))), canAdd && (_jsxs(GridCell, { children: [_jsx(Text, { variant: "muted", children: t("kumiko.field.file-list.add") }), _jsx(Input, { ...inputProps, id: `${id}-add`, value: null, onChange: (next) => {
46
+ if (next !== null)
47
+ onChange([...fileIds, next]);
48
+ } })] }, "add"))] }));
49
+ }
@@ -0,0 +1,14 @@
1
+ import { type ReactNode } from "react";
2
+ import type { RenderEditAction } from "./render-edit-types.js";
3
+ export type ListEmptyStateContent = {
4
+ readonly title: string;
5
+ readonly description?: string;
6
+ readonly action?: RenderEditAction;
7
+ };
8
+ export declare function ListEmptyStateBody({ content, narrowed, createButton, translate, }: {
9
+ readonly content: ListEmptyStateContent | undefined;
10
+ /** A search term or changed filter produced the empty result, so "create the first one" would mislead. */
11
+ readonly narrowed: boolean;
12
+ readonly createButton: ReactNode;
13
+ readonly translate: (key: string) => string;
14
+ }): ReactNode;
@@ -0,0 +1,12 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { usePrimitives } from "../primitives.js";
4
+ import { RenderEditActionButton } from "./render-edit-action-button.js";
5
+ export function ListEmptyStateBody({ content, narrowed, createButton, translate, }) {
6
+ const { Text, Button, Dialog, Banner } = usePrimitives();
7
+ const [actionError, setActionError] = useState(null);
8
+ if (narrowed)
9
+ return _jsx(Text, { children: translate("kumiko.list.empty.filtered") });
10
+ const description = content === undefined ? translate("kumiko.list.empty.hint") : content.description;
11
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-list-empty-state-action-error", children: actionError })), createButton] }));
12
+ }
@@ -9,13 +9,15 @@ export declare function RenderEditActionConfirmDialog({ action, open, onOpenChan
9
9
  readonly onConfirm: () => void | Promise<void>;
10
10
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
11
11
  }): ReactNode;
12
- export declare function RenderEditActionButton({ action, iconOnly, hideIcon, Button, Dialog, onError, }: {
12
+ export declare function RenderEditActionButton({ action, iconOnly, hideIcon, compactLabel, Button, Dialog, onError, }: {
13
13
  readonly action: RenderEditAction;
14
14
  /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
15
15
  * effect when this action actually resolved an icon. */
16
16
  readonly iconOnly?: boolean;
17
17
  /** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
18
18
  readonly hideIcon?: boolean;
19
+ /** Short labelled button for a phone header, where a bare icon would hide what the action does. */
20
+ readonly compactLabel?: boolean;
19
21
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
20
22
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
21
23
  readonly onError: (text: string | null) => void;
@@ -14,7 +14,7 @@ export function RenderEditActionConfirmDialog({ action, open, onOpenChange, onCo
14
14
  // render-list.tsx's ToolbarActionView (each RenderEditAction is
15
15
  // independently bound by the caller, there is no shared trigger pipeline
16
16
  // to hook into like the built-in onDelete/onSubmit paths have).
17
- export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, Button, Dialog, onError, }) {
17
+ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, compactLabel = false, Button, Dialog, onError, }) {
18
18
  const [busy, setBusy] = useState(false);
19
19
  const [confirmOpen, setConfirmOpen] = useState(false);
20
20
  const trigger = async () => {
@@ -32,7 +32,7 @@ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = fa
32
32
  };
33
33
  const variant = action.style ?? "secondary";
34
34
  const showIconOnly = iconOnly && action.icon !== undefined;
35
- return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && {
35
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(compactLabel && !showIconOnly && { size: "sm" }), ...(showIconOnly && {
36
36
  size: "icon",
37
37
  ariaLabel: action.label,
38
38
  title: action.label,
@@ -160,12 +160,14 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
160
160
  * no way to force-disable an arbitrary registered component. Omitting
161
161
  * this prop keeps unchanged behavior.
162
162
  *
163
- * ponytail: direct-consumer only — kumiko-screen.tsx's RenderEdit call
164
- * sites pass explicit prop lists without a spread and never forward
165
- * `disabled`, so a screen-driven app can't set the locked state today.
166
- * Upgrade path if that's needed: thread a screen-spec flag through to
167
- * `EntityEditCreateBody`/`EntityEditEditBody`. */
163
+ * Screen-driven read-only is `viewMode`, not this flag. */
168
164
  readonly disabled?: boolean;
165
+ /** Read-only view of an existing record (`EntityEditScreenDefinition.readOnly`):
166
+ * every field is disabled, no submit, discard or dirty footer, and the title
167
+ * resolves `screen:<id>.view.title` first. The host omits `onDelete`; record
168
+ * actions and cancel stay. UI only: server-side protection is the access
169
+ * rules and write handlers. */
170
+ readonly viewMode?: boolean;
169
171
  /** Renders the fields without RenderEdit's own action bar (save, cancel,
170
172
  * delete, copy-link). For hosts that put those controls into their own
171
173
  * chrome — a drawer footer, a wizard shell — and drive the write through
@@ -224,7 +224,9 @@ function resolveTitleTemplate(template, values, translate, fallback) {
224
224
  return translate(template, values);
225
225
  }
226
226
  export function RenderEdit(props) {
227
- const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, } = props;
227
+ const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, } = props;
228
+ // View mode is a locked form, so everything keyed on `disabled` follows.
229
+ const disabled = disabledProp || viewMode;
228
230
  const i18nScreenId = props.i18nScreenId ?? screen.id;
229
231
  const { customSubmit } = props;
230
232
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -631,7 +633,17 @@ export function RenderEdit(props) {
631
633
  translate,
632
634
  featureName,
633
635
  mode: writeVerb,
634
- }), [screen, entity, snapshot.values, translate, featureName, writeVerb]);
636
+ ...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
637
+ }), [
638
+ screen,
639
+ entity,
640
+ snapshot.values,
641
+ translate,
642
+ featureName,
643
+ writeVerb,
644
+ isCreateMode,
645
+ entityIdProp,
646
+ ]);
635
647
  const filteredSections = useMemo(
636
648
  // A fully-hidden "fields" section (every field in it currently
637
649
  // condition-hidden) must not occupy a wizard step; it would render
@@ -1121,10 +1133,11 @@ export function RenderEdit(props) {
1121
1133
  // draw a no-op Save. Extension registrations still get their Save, since
1122
1134
  // persistExtensions() runs on the customSubmit path too.
1123
1135
  const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
1124
- const showsSubmit = isFormEditable ||
1125
- hasExtensionRegistrations ||
1126
- isFieldlessSubmitForm ||
1127
- submitWithoutChanges !== undefined;
1136
+ const showsSubmit = !viewMode &&
1137
+ (isFormEditable ||
1138
+ hasExtensionRegistrations ||
1139
+ isFieldlessSubmitForm ||
1140
+ submitWithoutChanges !== undefined);
1128
1141
  const footerSlot = screen.slots?.footer;
1129
1142
  // An unregistered footer component renders null, so only a resolvable one may
1130
1143
  // count as an action (else read-only screens get an empty footer strip).
@@ -1150,6 +1163,7 @@ export function RenderEdit(props) {
1150
1163
  // Edit-mode screen forms always carry the dirty status (0 = quiet "No unsaved
1151
1164
  // changes"); wizards keep the status-only-when-dirty behaviour.
1152
1165
  const hasDirtyFooter = !isWizard &&
1166
+ !viewMode &&
1153
1167
  (dirtyFooter === true || (isScreenForm && !isCreate && headerRegion === undefined));
1154
1168
  const unsavedCount = !hasDirtyFooter
1155
1169
  ? undefined
@@ -1180,7 +1194,7 @@ export function RenderEdit(props) {
1180
1194
  ? "kumiko.form.saveChanges"
1181
1195
  : "kumiko.actions.save")) })), writeFormFooterAction] }));
1182
1196
  // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
1183
- // screen:<id>.<mode>.title / .<mode>.subtitle
1197
+ // screen:<id>.<mode>.title / .<mode>.subtitle (mode = "create"|"edit"|"view")
1184
1198
  // Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
1185
1199
  // title falls back to screenId; subtitle falls back to screen.description,
1186
1200
  // itself run through translate() so an i18n key (the established
@@ -1189,7 +1203,7 @@ export function RenderEdit(props) {
1189
1203
  // prose, same as it does for an unknown key (fw#2723 review). Same slot
1190
1204
  // section.description already fills for a section. Falls back to
1191
1205
  // undefined (no subtitle) when neither is set.
1192
- const formMode = isCreate ? "create" : "edit";
1206
+ const formMode = isCreate ? "create" : viewMode ? "view" : "edit";
1193
1207
  const recordTitle = !isCreate && screen.recordTitleField !== undefined
1194
1208
  ? resolveRecordTitle(initial[screen.recordTitleField])
1195
1209
  : undefined;
@@ -15,6 +15,7 @@ import { useLocale, useTranslation } from "../i18n.js";
15
15
  import { usePrimitives } from "../primitives.js";
16
16
  import { AvailabilityOptionsMultiSelect, AvailabilityOptionsSelect, staticSelectOptions, } from "./availability-options-select.js";
17
17
  import { EmbeddedListField } from "./embedded-list-field.js";
18
+ import { FileListField } from "./file-list-field.js";
18
19
  import { MultiSelectCheckboxes } from "./multi-select-checkboxes.js";
19
20
  import { QueryOptionsSelect } from "./query-options-select.js";
20
21
  import { ReferenceCreateDialog } from "./reference-create-dialog.js";
@@ -45,7 +46,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
45
46
  // Komponente gemountet werden (nicht als pure render-Call).
46
47
  const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : field.type === "select" &&
47
48
  field.selectOptionsAvailabilityQuery !== undefined &&
48
- !readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : (renderInput({
49
+ !readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) && !isFileFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : (renderInput({
49
50
  field,
50
51
  id,
51
52
  hasError,
@@ -276,11 +277,16 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
276
277
  return _jsx(Text, { testId: `field-value-${field.field}`, children: labels.join(", ") });
277
278
  }
278
279
  // Field types whose read display already isn't a boxed "disabled input" look
279
- // (embedded/jsonb render as an info Banner, files/images as an unsupported-
280
- // type Banner) — `readOnlyDisplayText` doesn't cover these, they keep going
281
- // through `renderInput`'s existing Banner branch in text-display mode too.
280
+ // (embedded/jsonb render as an info Banner) — `readOnlyDisplayText` doesn't
281
+ // cover these, they keep going through `renderInput`'s existing Banner branch
282
+ // in text-display mode too.
282
283
  function isComplexFieldType(type) {
283
- return type === "embedded" || type === "jsonb" || type === "files" || type === "images";
284
+ return type === "embedded" || type === "jsonb";
285
+ }
286
+ // Read-only file fields keep their (disabled) widget: it shows a thumbnail or
287
+ // the file name, where plain text could only show the FileRef UUID.
288
+ function isFileFieldType(type) {
289
+ return type === "file" || type === "image" || type === "files" || type === "images";
284
290
  }
285
291
  // Mirrors ACTION_ICON_BY_ID (kumiko-screen.tsx) for fields: only names with
286
292
  // an unambiguous matching FieldIconKey are listed here, everything else
@@ -383,22 +389,21 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
383
389
  case "image": {
384
390
  const kind = field.type === "image" ? "image" : "file";
385
391
  const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
386
- return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }), ...(field.imageVariant !== undefined && { imageVariant: field.imageVariant }), ...(field.capture !== undefined && { capture: field.capture }) }));
392
+ return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.entityId !== undefined && { entityId: field.entityId }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }), ...(field.imageVariant !== undefined && { imageVariant: field.imageVariant }), ...(field.capture !== undefined && { capture: field.capture }) }));
387
393
  }
394
+ case "files":
395
+ case "images":
396
+ return (_jsx(FileListField, { field: field, id: id, kind: field.type === "images" ? "image" : "file", hasError: hasError, onChange: (ids) => onChange(ids) }));
388
397
  // embedded (without embeddedListCells — that's embeddedList, which has
389
398
  // had its own EmbeddedListField widget since #1838) and jsonb carry
390
- // arbitrary objects; files/images carry a FileRef-UUID array and have
391
- // no multi-upload widget yet (deliberately deferred, #1925). Without a
392
- // dedicated widget these must NOT fall through to a text input:
393
- // stringValue() turns them into "[object Object]" / a comma-joined
394
- // string, and saving that overwrites the real data with the mangled
395
- // string (#1834). A `required: true` on any of these is caught loudly
399
+ // arbitrary objects. Without a dedicated widget these must NOT fall
400
+ // through to a text input: stringValue() turns them into
401
+ // "[object Object]" and saving that overwrites the real data with the
402
+ // mangled string (#1834). A `required: true` on either is caught loudly
396
403
  // at boot (validateNoWidgetRequiredField in the framework package)
397
404
  // instead of silently failing here.
398
405
  case "embedded":
399
- case "jsonb":
400
- case "files":
401
- case "images": {
406
+ case "jsonb": {
402
407
  const hasValue = field.value !== undefined && field.value !== null && field.value !== "";
403
408
  return (_jsxs(Banner, { id: id, variant: "info", children: [t("kumiko.field.unsupported"), hasValue &&
404
409
  (JsonView !== undefined ? (_jsx(JsonView, { value: field.value, testId: `field-value-${field.field}` })) : (_jsx(Text, { variant: "code", children: JSON.stringify(field.value) })))] }));
@@ -556,9 +561,6 @@ function readOnlyDisplayText(field, appLocale, t) {
556
561
  .map((v) => (typeof v === "string" ? (labels?.[v] ?? v) : stringValue(v)))
557
562
  .join(", ");
558
563
  }
559
- case "file":
560
- case "image":
561
- return typeof value === "string" ? value : "—";
562
564
  default:
563
565
  return stringValue(value);
564
566
  }
@@ -3,6 +3,7 @@ import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import { type ReactNode } from "react";
4
4
  import type { ListSort } from "../hooks/use-list-url-state.js";
5
5
  import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableProps, type DataTableRowAction, type DataTableRowActionMode, type DataTableRowGrouping } from "../primitives.js";
6
+ import { type ListEmptyStateContent } from "./list-empty-state.js";
6
7
  export type RenderListProps = {
7
8
  readonly screen: EntityListScreenDefinition;
8
9
  readonly entity: EntityDefinition;
@@ -27,8 +28,12 @@ export type RenderListProps = {
27
28
  /** Placeholder für das Search-Input. Default kommt aus dem i18n-
28
29
  * Bundle (`kumiko.list.search-placeholder`). */
29
30
  readonly searchPlaceholder?: string;
30
- /** Hint at the left of the toolbar when there is no search box (already translated). */
31
+ /** Hint at the left of the toolbar (already translated). */
31
32
  readonly description?: string;
33
+ /** Author-declared replacement for the default empty box (already translated). */
34
+ readonly emptyContent?: ListEmptyStateContent;
35
+ /** Search or filters changed the result; the empty box then says "no matches". */
36
+ readonly narrowed?: boolean;
32
37
  /** Plural-forms key for the footer count noun; default is the entity's `:noun` convention key. */
33
38
  readonly itemNounKey?: string;
34
39
  /** Aktueller Search-Term (vom URL-State / Parent). RenderList puffert
@@ -6,6 +6,7 @@ import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
6
6
  import { useTranslation } from "../i18n.js";
7
7
  import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
8
8
  import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
9
+ import { ListEmptyStateBody } from "./list-empty-state.js";
9
10
  import { needsActionConfirm } from "./render-edit-action-button.js";
10
11
  const SEARCH_DEBOUNCE_MS = 300;
11
12
  // Icon-less actions are promoted too (with a "plus" fallback at the call site).
@@ -13,12 +14,12 @@ function isHeaderPromotableToolbarAction(action) {
13
14
  return action.style === "primary" && !needsActionConfirm(action);
14
15
  }
15
16
  export function RenderList(props) {
16
- 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, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
17
+ const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, emptyContent, narrowed = false, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
17
18
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
18
19
  // wären Column-Header raw i18n-Keys.
19
20
  const t = useTranslation();
20
21
  const translate = translateProp ?? t;
21
- const { DataTable, Button, Dialog, Input, Text, Banner, PageHeader } = usePrimitives();
22
+ const { DataTable, Button, Dialog, Input, Banner, PageHeader } = usePrimitives();
22
23
  const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
23
24
  const pageHeaderCompact = usePageHeaderCompact();
24
25
  const createInPageHeader = onCreate !== undefined && PageHeader !== undefined && pageHeaderSlotAvailable;
@@ -168,7 +169,7 @@ export function RenderList(props) {
168
169
  // Wenn weder onCreate noch ein Override gegeben ist, fällt die
169
170
  // DataTable auf den DataTable-Default zurück (`kumiko.list.no-entries`).
170
171
  const composedEmptyState = emptyState ??
171
- (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);
172
+ (onCreate !== undefined || emptyContent !== undefined || narrowed ? (_jsx(ListEmptyStateBody, { content: emptyContent, narrowed: narrowed, translate: translate, createButton: onCreate !== undefined && !narrowed ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-empty-create", children: effectiveCreateLabel })) : undefined })) : undefined);
172
173
  // Title-Resolution: ein konventioneller i18n-Key `screen:<id>.title`.
173
174
  // Wenn das Bundle den Key kennt, schöner Titel; sonst kommt der
174
175
  // screenId selber raus (kein "Untitled"-Fallback — der App-Dev sieht
@@ -177,7 +178,7 @@ export function RenderList(props) {
177
178
  const resolvedTitle = translate(titleKey);
178
179
  const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
179
180
  // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
180
- return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), 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 }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(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 }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
181
+ return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), 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 && { 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 }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(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 }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
181
182
  }
182
183
  // Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
183
184
  // eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from "react";
2
+ /** The signed-in user as the renderer sees it. Provided by the auth layer
3
+ * (auth-email-password's SessionProvider); absent in apps without one. */
4
+ export type CurrentUser = {
5
+ readonly id: string;
6
+ readonly email: string;
7
+ readonly displayName: string;
8
+ /** Effective IANA zone of the user (override, else tenant). */
9
+ readonly timeZone?: string;
10
+ };
11
+ export declare function CurrentUserProvider({ user, children, }: {
12
+ readonly user: CurrentUser | undefined;
13
+ readonly children: ReactNode;
14
+ }): ReactNode;
15
+ /** Non-throwing: undefined outside a provider or while signed out. */
16
+ export declare function useOptionalCurrentUser(): CurrentUser | undefined;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from "react";
3
+ const CurrentUserContext = createContext(undefined);
4
+ export function CurrentUserProvider({ user, children, }) {
5
+ return _jsx(CurrentUserContext.Provider, { value: user, children: children });
6
+ }
7
+ /** Non-throwing: undefined outside a provider or while signed out. */
8
+ export function useOptionalCurrentUser() {
9
+ return useContext(CurrentUserContext);
10
+ }
@@ -45,6 +45,17 @@ export const kumikoDefaultTranslations = {
45
45
  "kumiko.field.writeOnly.setPlaceholder": "Set — leave empty to keep it",
46
46
  "kumiko.field.writeOnly.undo": "Undo",
47
47
  "kumiko.field.writeOnly.willRemove": "Removed on save",
48
+ "kumiko.field.embedded-list.actions": "Actions",
49
+ "kumiko.field.embedded-list.cell-label": "{column}, row {row}",
50
+ "kumiko.field.file-list.add": "Add file",
51
+ "kumiko.field.file-list.move-up": "Move file up",
52
+ "kumiko.field.file-list.move-down": "Move file down",
53
+ "kumiko.field.file-list.empty": "No files yet.",
54
+ "kumiko.field.file-upload.attached": "File attached",
55
+ "kumiko.field.file-upload.change": "Change",
56
+ "kumiko.field.file-upload.failed": "Upload failed",
57
+ "kumiko.field.file-upload.remove": "Remove",
58
+ "kumiko.field.file-upload.upload": "Upload",
48
59
  "kumiko.field.embedded-list.add-row": "Add row",
49
60
  "kumiko.field.embedded-list.remove-row": "Remove row",
50
61
  "kumiko.field.embedded-list.duplicate-row": "Duplicate row",
@@ -58,6 +69,7 @@ export const kumikoDefaultTranslations = {
58
69
  "kumiko.field.multiSelect.deselect-all": "Deselect all",
59
70
  "kumiko.list.search-placeholder": "Search…",
60
71
  "kumiko.list.empty.title": "No entries yet.",
72
+ "kumiko.list.empty.filtered": "No entries match the current search or filters.",
61
73
  "kumiko.list.empty.hint": "Create the first one to get started.",
62
74
  "kumiko.list.no-entries": "No entries.",
63
75
  "kumiko.record.not-found.title": "This record was not found.",
@@ -242,6 +254,7 @@ export const kumikoDefaultTranslations = {
242
254
  "errors.conflict": "Conflict — the operation could not be completed.",
243
255
  "errors.versionConflict": "The record was modified in the meantime. Reload and try again.",
244
256
  "errors.preconditionFailed": "The record changed in the meantime. Reload and try again.",
257
+ "errors.recordLocked": "This record is locked and can no longer be changed.",
245
258
  "errors.uniqueViolation": "This entry already exists.",
246
259
  "errors.unprocessable": "The request could not be processed.",
247
260
  "errors.unconfigured": "This feature isn't configured yet.",
package/dist/index.d.ts CHANGED
@@ -48,6 +48,8 @@ export { DraftStorageProvider, useDraftStorage } from "./context/draft-storage-c
48
48
  export { EmbeddedScreenProvider, useIsEmbeddedScreen } from "./context/embedded-screen-context.js";
49
49
  export type { UserRolesProviderProps } from "./context/user-roles-context.js";
50
50
  export { UserRolesProvider, useUserRoles } from "./context/user-roles-context.js";
51
+ export type { CurrentUser } from "./current-user.js";
52
+ export { CurrentUserProvider, useOptionalCurrentUser } from "./current-user.js";
51
53
  export { formatWhen } from "./format-when.js";
52
54
  export { REFERENCE_COMBOBOX_LIMIT, REFERENCE_LIST_LOOKUP_LIMIT, REFERENCE_SEARCH_DEBOUNCE_MS, } from "./hooks/reference-limits.js";
53
55
  export type { AiTextActionState, AiTextMode, AiTextRewriteStyle, AiTextRunPayload, AiTextRunResult, AiTextUsage, UseAiTextActionResult, UseCompletionResult, } from "./hooks/use-ai-text.js";
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ export { DispatcherProvider, useDispatcher, useDispatcherStatus, useOptionalDisp
37
37
  export { DraftStorageProvider, useDraftStorage } from "./context/draft-storage-context.js";
38
38
  export { EmbeddedScreenProvider, useIsEmbeddedScreen } from "./context/embedded-screen-context.js";
39
39
  export { UserRolesProvider, useUserRoles } from "./context/user-roles-context.js";
40
+ export { CurrentUserProvider, useOptionalCurrentUser } from "./current-user.js";
40
41
  export { formatWhen } from "./format-when.js";
41
42
  export { REFERENCE_COMBOBOX_LIMIT, REFERENCE_LIST_LOOKUP_LIMIT, REFERENCE_SEARCH_DEBOUNCE_MS, } from "./hooks/reference-limits.js";
42
43
  export { AI_TEXT_RUN_QN, useAiTextAction, useCompletion } from "./hooks/use-ai-text.js";
@@ -295,6 +295,9 @@ export type InputProps = {
295
295
  readonly name: string;
296
296
  /** FileRef-UUID (gespeicherte Datei) oder null (kein Upload). */
297
297
  readonly value: string | null;
298
+ /** Implementations must offer their own remove control that calls
299
+ * `onChange(null)`: FileListField relies on it to drop a file from an
300
+ * `images`/`files` field. */
298
301
  readonly onChange: (fileId: string | null) => void;
299
302
  readonly disabled?: boolean;
300
303
  readonly required?: boolean;
@@ -305,6 +308,9 @@ export type InputProps = {
305
308
  /** Für den Upload-POST `/api/files` — der Endpoint prüft maxSize/accept
306
309
  * gegen die Field-Def von (entityType, fieldName). */
307
310
  readonly entityType?: string;
311
+ /** Must be sent as the `entityId` part of the upload so the FileRef is
312
+ * attached to the record (qr overlays and access guards need it). */
313
+ readonly entityId?: string;
308
314
  readonly fieldName?: string;
309
315
  /** Only for kind:"image" — variant name for the preview
310
316
  * (`/api/files/:id/variant/:name`). Absent → the original. */
@@ -755,9 +761,14 @@ export type EmbeddedListColumn = {
755
761
  /** Read-only, value supplied by the caller (already computed from
756
762
  * sibling cells) — not directly user-editable. */
757
763
  readonly derived: boolean;
764
+ /** Shown but not editable by the user; unlike `derived` it is not computed. */
765
+ readonly readOnly?: boolean;
758
766
  /** Only for `type: "select"`. */
759
767
  readonly options?: readonly string[];
760
768
  readonly optionLabels?: Readonly<Record<string, string>>;
769
+ /** Only for `type: "select"`; `"radio"` renders segmented buttons, the
770
+ * default is a dropdown. */
771
+ readonly display?: "radio" | "dropdown";
761
772
  /** Only for `type: "reference"` — pre-fetched by the caller, ONE query
762
773
  * per column shared across every row (not one query per cell). */
763
774
  readonly referenceOptions?: readonly {
@@ -791,6 +802,8 @@ export type EmbeddedListInputProps = {
791
802
  /** Whole-list read-only (e.g. a released invoice) — hides row-mutation
792
803
  * affordances and disables every cell. */
793
804
  readonly disabled?: boolean;
805
+ /** Rows are fixed: no add, remove, duplicate or move; cells stay editable. */
806
+ readonly fixedRows?: boolean;
794
807
  readonly minItems?: number;
795
808
  readonly maxItems?: number;
796
809
  /** Shown under the whole list (e.g. under the totals row). */
@@ -810,6 +823,8 @@ export type EmbeddedListInputProps = {
810
823
  * handling wired up. */
811
824
  readonly onPasteCells?: (rowIndex: number, columnIndex: number, grid: readonly (readonly string[])[]) => void;
812
825
  readonly addLabel: string;
826
+ /** Accessible name of the (otherwise headerless) row-actions column. */
827
+ readonly actionsLabel: string;
813
828
  readonly removeLabel: string;
814
829
  readonly duplicateLabel: string;
815
830
  readonly moveUpLabel: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.359.0",
3
+ "version": "0.361.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.359.0",
19
- "@cosmicdrift/kumiko-headless": "0.359.0",
20
- "@cosmicdrift/kumiko-types": "0.359.0",
18
+ "@cosmicdrift/kumiko-framework": "0.361.0",
19
+ "@cosmicdrift/kumiko-headless": "0.361.0",
20
+ "@cosmicdrift/kumiko-types": "0.361.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.359.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.361.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,47 @@
1
1
  [
2
+ {
3
+ "version": "0.361.0",
4
+ "type": "improvement",
5
+ "title": "Create form prefill and CurrentUserProvider",
6
+ "detail": "EntityEditCreateBody applies prefill to empty initial values. New exports CurrentUser, CurrentUserProvider and useOptionalCurrentUser."
7
+ },
8
+ {
9
+ "version": "0.361.0",
10
+ "type": "improvement",
11
+ "title": "Read-only view mode for entityEdit",
12
+ "detail": "RenderEdit takes viewMode, which disables all fields and removes submit, discard, delete and the dirty footer while keeping record actions and cancel. The update body sets it when screen.readOnly holds for the record and resolves screen:<id>.view.title before screen:<id>.title."
13
+ },
14
+ {
15
+ "version": "0.361.0",
16
+ "type": "improvement",
17
+ "title": "EmbeddedListColumn.readOnly and EmbeddedListInput fixedRows",
18
+ "detail": "EmbeddedListField forwards both, skips readOnly columns when pasting and never appends rows from a paste when rows are fixed."
19
+ },
20
+ {
21
+ "version": "0.361.0",
22
+ "type": "improvement",
23
+ "title": "EmbeddedListColumn.display",
24
+ "detail": "EmbeddedListField forwards the select display to the EmbeddedListInput primitive."
25
+ },
26
+ {
27
+ "version": "0.361.0",
28
+ "type": "improvement",
29
+ "title": "entityEdit shows locked records as a view",
30
+ "detail": "EntityEditUpdateForm shows the view when screen.readOnly matches or entity.lockedWhen matches or its field is missing from the record. Adds the default string errors.recordLocked."
31
+ },
32
+ {
33
+ "version": "0.361.0",
34
+ "type": "breaking",
35
+ "title": "Searchable lists show their description as its own row",
36
+ "detail": "An entityList or projectionList with search used to hide its description. It now renders as a muted row below the toolbar, the same as on lists without search.",
37
+ "migration": "Look at lists that set description together with search. Drop descriptions that were only kept as a hint for developers, or move the text into emptyState if it explains an empty list."
38
+ },
39
+ {
40
+ "version": "0.360.0",
41
+ "type": "improvement",
42
+ "title": "FileListField edits images and files fields",
43
+ "detail": "RenderField renders type images and files as FileListField instead of the unsupported banner. One tile per stored FileRef id with move up, move down and remove buttons, one trailing empty tile to add a file (hidden at maxCount or when read-only). It is built from Input kind file or image, Button and the layout primitives, passes accept, maxSize, entityType, entityId, fieldName, imageVariant and capture to every tile, and reads only string ids from the stored value. The read-only text display of these fields lists the ids. New i18n keys kumiko.field.file-list.add, move-up, move-down and empty; kumiko.field.file-upload.upload, change, remove, attached and failed; kumiko.field.embedded-list.actions and cell-label. The file and image Input kinds accept entityId. EmbeddedListInput has a new required actionsLabel prop."
44
+ },
2
45
  {
3
46
  "version": "0.357.0",
4
47
  "type": "improvement",