@cosmicdrift/kumiko-renderer 0.360.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.
- package/dist/app/edit-prefill.d.ts +16 -0
- package/dist/app/edit-prefill.js +30 -0
- package/dist/app/header-actions.d.ts +8 -0
- package/dist/app/header-actions.js +31 -0
- package/dist/app/kumiko-screen.js +80 -44
- package/dist/app/list-empty-content.d.ts +8 -0
- package/dist/app/list-empty-content.js +42 -0
- package/dist/components/embedded-list-field.js +7 -3
- package/dist/components/list-empty-state.d.ts +14 -0
- package/dist/components/list-empty-state.js +12 -0
- package/dist/components/render-edit-action-button.d.ts +3 -1
- package/dist/components/render-edit-action-button.js +2 -2
- package/dist/components/render-edit-types.d.ts +7 -5
- package/dist/components/render-edit.js +11 -7
- package/dist/components/render-field.js +6 -7
- package/dist/components/render-list.d.ts +6 -1
- package/dist/components/render-list.js +5 -4
- package/dist/current-user.d.ts +16 -0
- package/dist/current-user.js +10 -0
- package/dist/i18n-defaults.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/primitives.d.ts +7 -0
- package/package.json +5 -5
- package/src/changes.json +37 -0
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
1920
|
-
|
|
1921
|
-
|
|
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 }), ...(
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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"), 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
|
-
*
|
|
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 =
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
|
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,
|
|
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 ? (
|
|
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 &&
|
|
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
|
+
}
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -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";
|
package/dist/primitives.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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,41 @@
|
|
|
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
|
+
},
|
|
2
39
|
{
|
|
3
40
|
"version": "0.360.0",
|
|
4
41
|
"type": "improvement",
|