@cosmicdrift/kumiko-renderer 0.360.0 → 0.362.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
+ // The undeclared fallback picks an action nobody chose for the header, so a
13
+ // destructive or confirm-gated one (a bare "x" reads as "close") may not 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, icon
18
+ // only when it has an icon (the author chose the icon, the label is its aria-label),
19
+ // so it matches the desktop header and `edit` moves into the menu. Without one the
20
+ // pencil-style icon action 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: declared.icon !== undefined };
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";
@@ -250,6 +254,11 @@ export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrid
250
254
  out[name] = shape.default;
251
255
  continue;
252
256
  }
257
+ // An untouched optional number must not persist as a deliberate 0.
258
+ if (shape.type === "number" && shape.required !== true) {
259
+ out[name] = undefined;
260
+ continue;
261
+ }
253
262
  if (shape.type === "embedded" && shape.multiple === true) {
254
263
  out[name] = [];
255
264
  continue;
@@ -651,20 +660,24 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
651
660
  // without any such field.
652
661
  const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(entity.fields), [entity.fields]);
653
662
  const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
654
- const initial = useMemo(() => mergeSearchParamsIntoInitial(entity.fields, {
663
+ const currentUser = useOptionalCurrentUser();
664
+ const fallbackTimeZone = useOptionalTimeZone();
665
+ const initial = useMemo(() => applyEditPrefill(mergeSearchParamsIntoInitial(entity.fields, {
655
666
  searchParams: nav.searchParams,
656
667
  urlPrefillFields: screen.urlPrefillFields,
657
668
  renderableFields: layoutFieldNames(screen),
658
669
  defaultCurrency: entityDefaultCurrency,
659
670
  ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
660
671
  ...(handoffValues !== undefined && { handoffValues }),
661
- }), [
672
+ }), layoutEditFields(screen), { now: new Date(), fallbackTimeZone, user: currentUser }), [
662
673
  entity.fields,
663
674
  nav.searchParams,
664
675
  screen,
665
676
  entityDefaultCurrency,
666
677
  moneyCurrencyOverrides,
667
678
  handoffValues,
679
+ currentUser,
680
+ fallbackTimeZone,
668
681
  ]);
669
682
  const formSchema = useMemo(() => buildFormSchema(entity, screen, "create"), [entity, screen]);
670
683
  const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
@@ -697,8 +710,7 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
697
710
  });
698
711
  }
699
712
  else {
700
- const { screenId, entityId } = resolveRedirectTarget(redirect, result.data, schema, appFeatures, controlsRef.current?.getValues());
701
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
713
+ navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, result.data, schema, appFeatures, controlsRef.current?.getValues()));
702
714
  }
703
715
  }
704
716
  else {
@@ -929,14 +941,21 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
929
941
  if (currencyLoading) {
930
942
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
931
943
  }
932
- return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial,
944
+ // Evaluated against the stored record only, so typing in a form can never
945
+ // flip the screen between editable and view.
946
+ // Either condition shows the view: the server rejects saves of a locked record
947
+ // whatever the screen says, so a screen condition can only add read-only, never lift it.
948
+ // A record without the lock field counts as locked (isRecordLocked), matching the server.
949
+ const viewMode = (screen.readOnly !== undefined && evalFieldCondition(screen.readOnly, record)) ||
950
+ isRecordLocked(entity.lockedWhen, record);
951
+ return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial, ...(viewMode && { viewMode: true }),
933
952
  // Real route id for the extension section (set-value UI): the update
934
953
  // form deliberately keeps `id` out of the form values, so the section
935
954
  // needs it explicitly — otherwise it renders in create mode.
936
955
  entityId: entityId,
937
956
  // Stored customFields for the extension section, so editing shows the
938
957
  // 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: () => {
958
+ 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
959
  closeDrawer();
941
960
  void onReload();
942
961
  } })] }));
@@ -1147,7 +1166,8 @@ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
1147
1166
  function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1148
1167
  const isEmbeddedScreen = useIsEmbeddedScreen();
1149
1168
  const featureName = schema.featureName;
1150
- const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen, screen.createUnavailable);
1169
+ const resolvedCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen, screen.createUnavailable);
1170
+ const onCreate = screen.allowCreate === false ? undefined : resolvedCreate;
1151
1171
  const { Banner } = usePrimitives();
1152
1172
  const queryType = entityQueryCommand(featureName, screen.entity, "list");
1153
1173
  const nav = useNav();
@@ -1478,6 +1498,13 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1478
1498
  refreshRowsAfterWrite,
1479
1499
  openDrawer,
1480
1500
  ]);
1501
+ const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1502
+ nav,
1503
+ host,
1504
+ dispatcher,
1505
+ openDrawer,
1506
+ onWriteSuccess: refreshRowsAfterWrite,
1507
+ });
1481
1508
  // Expandable rows: open state is keyed by row id, so it survives refetch,
1482
1509
  // sort and page changes. Several rows can be open at once.
1483
1510
  const expandableRow = screen.expandableRow;
@@ -1554,7 +1581,9 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1554
1581
  // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1555
1582
  // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1556
1583
  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
1584
+ 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 && {
1585
+ description: effectiveTranslate(screen.description),
1586
+ }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1558
1587
  ? { screenPadding: false, chromeless: true }
1559
1588
  : { 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
1589
  onReachEnd: loadMore,
@@ -1730,6 +1759,13 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1730
1759
  rowsQuery.refetch,
1731
1760
  openDrawer,
1732
1761
  ]);
1762
+ const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1763
+ nav,
1764
+ host,
1765
+ dispatcher,
1766
+ openDrawer,
1767
+ onWriteSuccess: rowsQuery.refetch,
1768
+ });
1733
1769
  if (rowsQuery.loading && rowsQuery.data === null) {
1734
1770
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1735
1771
  }
@@ -1764,7 +1800,9 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1764
1800
  onPageSizeChange: urlState.setPageSize,
1765
1801
  }
1766
1802
  : 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
1803
+ 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 && {
1804
+ description: effectiveTranslate(screen.description),
1805
+ }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1768
1806
  ? { screenPadding: false, chromeless: true }
1769
1807
  : { 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
1808
  filterFacets,
@@ -1873,7 +1911,7 @@ function buildSubtitleParts(parts, record, nav, appFeatures, userRoles, host) {
1873
1911
  function headerStatusTone(header, record) {
1874
1912
  if (header.status === undefined)
1875
1913
  return undefined;
1876
- const value = String(record[header.status] ?? "");
1914
+ const value = String(record[header.statusToneField ?? header.status] ?? "");
1877
1915
  const declared = header.statusTones !== undefined && Object.hasOwn(header.statusTones, value)
1878
1916
  ? header.statusTones[value]
1879
1917
  : undefined;
@@ -1896,40 +1934,28 @@ function resolveSubtitleHref(header, record) {
1896
1934
  // natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
1897
1935
  // controller.submit() ohne submit-config throwen — der No-Op macht diesen
1898
1936
  // 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
1937
  // Header actions bar (A7): <=2 actions render as plain buttons; >2 with an
1907
1938
  // `ActionOverflowMenu` primitive collapse to the primary button plus a menu
1908
1939
  // for the rest — same rule as RowActionsCell for table rows. Owns confirm
1909
1940
  // state for menu items: RenderEditActionButton already owns a per-button
1910
1941
  // confirm dialog for the always-visible primary action, but a menu item has
1911
1942
  // no button of its own to carry one.
1912
- function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
1943
+ function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, preferredPrimaryId, PageHeader, }) {
1913
1944
  const t = useTranslation();
1914
1945
  const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
1915
1946
  Dialog,
1916
1947
  onError,
1917
1948
  });
1918
1949
  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] }));
1950
+ const compactPrimary = compactHeaderPrimary(actions, preferredPrimaryId);
1951
+ const overflowActions = actions.filter((a) => a !== compactPrimary?.action);
1952
+ 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
1953
  }
1928
1954
  const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
1929
1955
  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))) }));
1956
+ return (_jsx(_Fragment, { children: orderPlainHeaderActions(actions, preferredPrimaryId).map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
1931
1957
  }
1932
- const primary = primaryHeaderAction(actions);
1958
+ const primary = primaryHeaderAction(actions, preferredPrimaryId);
1933
1959
  const rest = actions.filter((a) => a.id !== primary?.id);
1934
1960
  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
1961
  }
@@ -2172,7 +2198,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2172
2198
  // these are actions on the record the head shows, not on whichever tab is
2173
2199
  // open, so they must stay in place across tab switches instead of
2174
2200
  // 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 }) }));
2201
+ 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
2202
  // Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
2177
2203
  // this shares the same page padding/width as the record's edit card below
2178
2204
  // it, instead of sitting flush against the screen edge (fw record-screen
@@ -2207,15 +2233,19 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2207
2233
  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
2234
  const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
2209
2235
  const pageHeaderCompact = pageHeaderCompactContext && usesPageHeaderSlot;
2236
+ // The phone shell header clips a long status mid-word, so on phones the badge
2237
+ // sits in the body instead of the shell header slot.
2238
+ 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;
2239
+ // Lives in the band's subtitle row so it shares the metrics' horizontal inset.
2240
+ const bandSubtitle = pageHeaderCompact && statusBadge !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-status-row", children: statusBadge }), headerSubtitleSlot] })) : (headerSubtitleSlot);
2210
2241
  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) &&
2242
+ 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 && {
2243
+ 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 && {
2244
+ preferredPrimaryId: screen.primaryAction,
2245
+ }) }) })),
2246
+ }) }), MetricBand === undefined && bandSubtitle, MetricBand === undefined && headerSlot, (hasMetrics || bandSubtitle !== undefined || headerSlot !== undefined) &&
2217
2247
  (MetricBand !== undefined || hasMetrics) &&
2218
- metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
2248
+ metricsBlock(bandSubtitle, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
2219
2249
  ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
2220
2250
  ...(headerTitleSlot === undefined &&
2221
2251
  recordTitle !== undefined && { title: recordTitle }),
@@ -2309,10 +2339,24 @@ function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fal
2309
2339
  const target = findRedirectTargetScreen(redirect, schema, appFeatures);
2310
2340
  const carriesId = target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
2311
2341
  if (!carriesId)
2312
- return { screenId: targetId, entityId: undefined };
2342
+ return { screenId: targetId, entityId: undefined, tab: undefined };
2313
2343
  const entityId = extractIdField(submittedData, idField) ??
2314
2344
  (fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
2315
- return { screenId: targetId, entityId };
2345
+ return {
2346
+ screenId: targetId,
2347
+ entityId,
2348
+ tab: typeof redirect === "string" ? undefined : redirect.tab,
2349
+ };
2350
+ }
2351
+ function navigateToRedirectTarget(nav, target) {
2352
+ const navTarget = {
2353
+ screenId: target.screenId,
2354
+ ...(target.entityId !== undefined && { entityId: target.entityId }),
2355
+ };
2356
+ if (target.tab === undefined)
2357
+ nav.navigate(navTarget);
2358
+ else
2359
+ nav.navigate(navTarget, { searchParams: { tab: target.tab } });
2316
2360
  }
2317
2361
  // Success navigation for a configured redirect — shared by entityEdit's
2318
2362
  // save and writeHandler record actions. The string form navigates without an
@@ -2328,8 +2372,7 @@ function navigateAfterRedirect(options) {
2328
2372
  nav.navigate({ screenId: lastSegment(redirect) });
2329
2373
  return;
2330
2374
  }
2331
- const { screenId, entityId } = resolveRedirectTarget(redirect, resultData, schema, appFeatures, record);
2332
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2375
+ navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, resultData, schema, appFeatures, record));
2333
2376
  }
2334
2377
  // The update payload nests the saved projection under `data`; a changed
2335
2378
  // parent FK lives there, not in the pre-edit `record`.
@@ -2423,8 +2466,7 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2423
2466
  navigateToReturn(nav, returnTarget);
2424
2467
  }
2425
2468
  else {
2426
- const { screenId, entityId } = resolveRedirectTarget(screen.redirect, result.data, schema, appFeatures);
2427
- nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2469
+ navigateToRedirectTarget(nav, resolveRedirectTarget(screen.redirect, result.data, schema, appFeatures));
2428
2470
  }
2429
2471
  }
2430
2472
  }, [
@@ -2523,8 +2565,9 @@ function mapFailedConfigCommandIssuesToField(error, failedIndex, commandFieldNam
2523
2565
  },
2524
2566
  };
2525
2567
  }
2526
- // A number/money field without a declared default starts empty: buildInitialValues
2527
- // maps it to 0, which a save would persist as a deliberate "0" override.
2568
+ // A money field without a declared default starts empty too (buildInitialValues
2569
+ // maps it to 0, which a save would persist as a deliberate "0" override); numbers
2570
+ // are handled there, except a required one, which config fields never are.
2528
2571
  function buildConfigEditDefaults(fields) {
2529
2572
  const defaults = buildInitialValues(fields); // @cast-boundary render-helper
2530
2573
  for (const [shortName, fieldDef] of Object.entries(fields)) {
@@ -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"), 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 }));
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,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,
@@ -1131,10 +1133,11 @@ export function RenderEdit(props) {
1131
1133
  // draw a no-op Save. Extension registrations still get their Save, since
1132
1134
  // persistExtensions() runs on the customSubmit path too.
1133
1135
  const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
1134
- const showsSubmit = isFormEditable ||
1135
- hasExtensionRegistrations ||
1136
- isFieldlessSubmitForm ||
1137
- submitWithoutChanges !== undefined;
1136
+ const showsSubmit = !viewMode &&
1137
+ (isFormEditable ||
1138
+ hasExtensionRegistrations ||
1139
+ isFieldlessSubmitForm ||
1140
+ submitWithoutChanges !== undefined);
1138
1141
  const footerSlot = screen.slots?.footer;
1139
1142
  // An unregistered footer component renders null, so only a resolvable one may
1140
1143
  // count as an action (else read-only screens get an empty footer strip).
@@ -1160,6 +1163,7 @@ export function RenderEdit(props) {
1160
1163
  // Edit-mode screen forms always carry the dirty status (0 = quiet "No unsaved
1161
1164
  // changes"); wizards keep the status-only-when-dirty behaviour.
1162
1165
  const hasDirtyFooter = !isWizard &&
1166
+ !viewMode &&
1163
1167
  (dirtyFooter === true || (isScreenForm && !isCreate && headerRegion === undefined));
1164
1168
  const unsavedCount = !hasDirtyFooter
1165
1169
  ? undefined
@@ -1190,7 +1194,7 @@ export function RenderEdit(props) {
1190
1194
  ? "kumiko.form.saveChanges"
1191
1195
  : "kumiko.actions.save")) })), writeFormFooterAction] }));
1192
1196
  // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
1193
- // screen:<id>.<mode>.title / .<mode>.subtitle
1197
+ // screen:<id>.<mode>.title / .<mode>.subtitle (mode = "create"|"edit"|"view")
1194
1198
  // Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
1195
1199
  // title falls back to screenId; subtitle falls back to screen.description,
1196
1200
  // itself run through translate() so an i18n key (the established
@@ -1199,7 +1203,7 @@ export function RenderEdit(props) {
1199
1203
  // prose, same as it does for an unknown key (fw#2723 review). Same slot
1200
1204
  // section.description already fills for a section. Falls back to
1201
1205
  // undefined (no subtitle) when neither is set.
1202
- const formMode = isCreate ? "create" : "edit";
1206
+ const formMode = isCreate ? "create" : viewMode ? "view" : "edit";
1203
1207
  const recordTitle = !isCreate && screen.recordTitleField !== undefined
1204
1208
  ? resolveRecordTitle(initial[screen.recordTitleField])
1205
1209
  : undefined;
@@ -46,7 +46,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
46
46
  // Komponente gemountet werden (nicht als pure render-Call).
47
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" &&
48
48
  field.selectOptionsAvailabilityQuery !== undefined &&
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) ? (_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({
50
50
  field,
51
51
  id,
52
52
  hasError,
@@ -283,6 +283,11 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
283
283
  function isComplexFieldType(type) {
284
284
  return type === "embedded" || type === "jsonb";
285
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";
290
+ }
286
291
  // Mirrors ACTION_ICON_BY_ID (kumiko-screen.tsx) for fields: only names with
287
292
  // an unambiguous matching FieldIconKey are listed here, everything else
288
293
  // stays iconless rather than guessing.
@@ -556,12 +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
- case "files":
563
- case "images":
564
- return Array.isArray(value) && value.length > 0 ? value.map(stringValue).join(", ") : "—";
565
564
  default:
566
565
  return stringValue(value);
567
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
+ }
@@ -69,6 +69,7 @@ export const kumikoDefaultTranslations = {
69
69
  "kumiko.field.multiSelect.deselect-all": "Deselect all",
70
70
  "kumiko.list.search-placeholder": "Search…",
71
71
  "kumiko.list.empty.title": "No entries yet.",
72
+ "kumiko.list.empty.filtered": "No entries match the current search or filters.",
72
73
  "kumiko.list.empty.hint": "Create the first one to get started.",
73
74
  "kumiko.list.no-entries": "No entries.",
74
75
  "kumiko.record.not-found.title": "This record was not found.",
@@ -253,6 +254,7 @@ export const kumikoDefaultTranslations = {
253
254
  "errors.conflict": "Conflict — the operation could not be completed.",
254
255
  "errors.versionConflict": "The record was modified in the meantime. Reload and try again.",
255
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.",
256
258
  "errors.uniqueViolation": "This entry already exists.",
257
259
  "errors.unprocessable": "The request could not be processed.",
258
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";
@@ -761,9 +761,14 @@ export type EmbeddedListColumn = {
761
761
  /** Read-only, value supplied by the caller (already computed from
762
762
  * sibling cells) — not directly user-editable. */
763
763
  readonly derived: boolean;
764
+ /** Shown but not editable by the user; unlike `derived` it is not computed. */
765
+ readonly readOnly?: boolean;
764
766
  /** Only for `type: "select"`. */
765
767
  readonly options?: readonly string[];
766
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";
767
772
  /** Only for `type: "reference"` — pre-fetched by the caller, ONE query
768
773
  * per column shared across every row (not one query per cell). */
769
774
  readonly referenceOptions?: readonly {
@@ -797,6 +802,8 @@ export type EmbeddedListInputProps = {
797
802
  /** Whole-list read-only (e.g. a released invoice) — hides row-mutation
798
803
  * affordances and disables every cell. */
799
804
  readonly disabled?: boolean;
805
+ /** Rows are fixed: no add, remove, duplicate or move; cells stay editable. */
806
+ readonly fixedRows?: boolean;
800
807
  readonly minItems?: number;
801
808
  readonly maxItems?: number;
802
809
  /** Shown under the whole list (e.g. under the totals row). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.360.0",
3
+ "version": "0.362.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.360.0",
19
- "@cosmicdrift/kumiko-headless": "0.360.0",
20
- "@cosmicdrift/kumiko-types": "0.360.0",
18
+ "@cosmicdrift/kumiko-framework": "0.362.0",
19
+ "@cosmicdrift/kumiko-headless": "0.362.0",
20
+ "@cosmicdrift/kumiko-types": "0.362.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.360.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.362.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,53 @@
1
1
  [
2
+ {
3
+ "version": "0.362.0",
4
+ "type": "improvement",
5
+ "title": "entityList honours allowCreate false",
6
+ "detail": "EntityListBody passes no onCreate when the list sets allowCreate false."
7
+ },
8
+ {
9
+ "version": "0.362.0",
10
+ "type": "fix",
11
+ "title": "A declared primaryAction with an icon is icon-only in the phone header",
12
+ "detail": "Regardless of confirm or style; the label is the button's aria-label and title. Without an icon it stays a text button. The ui-walkthrough terminate sample uses the circle-stop icon, since a bare x reads as close."
13
+ },
14
+ {
15
+ "version": "0.361.0",
16
+ "type": "improvement",
17
+ "title": "Create form prefill and CurrentUserProvider",
18
+ "detail": "EntityEditCreateBody applies prefill to empty initial values. New exports CurrentUser, CurrentUserProvider and useOptionalCurrentUser."
19
+ },
20
+ {
21
+ "version": "0.361.0",
22
+ "type": "improvement",
23
+ "title": "Read-only view mode for entityEdit",
24
+ "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."
25
+ },
26
+ {
27
+ "version": "0.361.0",
28
+ "type": "improvement",
29
+ "title": "EmbeddedListColumn.readOnly and EmbeddedListInput fixedRows",
30
+ "detail": "EmbeddedListField forwards both, skips readOnly columns when pasting and never appends rows from a paste when rows are fixed."
31
+ },
32
+ {
33
+ "version": "0.361.0",
34
+ "type": "improvement",
35
+ "title": "EmbeddedListColumn.display",
36
+ "detail": "EmbeddedListField forwards the select display to the EmbeddedListInput primitive."
37
+ },
38
+ {
39
+ "version": "0.361.0",
40
+ "type": "improvement",
41
+ "title": "entityEdit shows locked records as a view",
42
+ "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."
43
+ },
44
+ {
45
+ "version": "0.361.0",
46
+ "type": "breaking",
47
+ "title": "Searchable lists show their description as its own row",
48
+ "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.",
49
+ "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."
50
+ },
2
51
  {
3
52
  "version": "0.360.0",
4
53
  "type": "improvement",