@cosmicdrift/kumiko-renderer 0.359.0 → 0.361.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/file-list-field.d.ts +10 -0
- package/dist/components/file-list-field.js +49 -0
- 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 +22 -8
- package/dist/components/render-field.js +20 -18
- 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 +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/primitives.d.ts +15 -0
- package/package.json +5 -5
- package/src/changes.json +43 -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"), removeLabel: t("kumiko.field.embedded-list.remove-row"), duplicateLabel: t("kumiko.field.embedded-list.duplicate-row"), moveUpLabel: t("kumiko.field.embedded-list.move-up"), moveDownLabel: t("kumiko.field.embedded-list.move-down"), emptyLabel: t("kumiko.field.embedded-list.empty"), emptyCtaLabel: t("kumiko.field.embedded-list.empty-cta"), testId: id }));
|
|
270
|
+
return (_jsx(EmbeddedListInput, { id: id, columns: columns, rows: rows, totals: totals, currency: field.embeddedListCurrency, disabled: field.readOnly, fixedRows: field.embeddedListFixedRows, minItems: field.embeddedListMinItems, maxItems: field.embeddedListMaxItems, listIssues: combinedListIssues, rowIssues: rowIssues, cellIssues: cellIssues, onCellChange: handleCellChange, onAddRow: handleAddRow, onRemoveRow: handleRemoveRow, onDuplicateRow: handleDuplicateRow, onMoveRow: handleMoveRow, onPasteCells: handlePasteCells, addLabel: t("kumiko.field.embedded-list.add-row"), actionsLabel: t("kumiko.field.embedded-list.actions"), removeLabel: t("kumiko.field.embedded-list.remove-row"), duplicateLabel: t("kumiko.field.embedded-list.duplicate-row"), moveUpLabel: t("kumiko.field.embedded-list.move-up"), moveDownLabel: t("kumiko.field.embedded-list.move-down"), emptyLabel: t("kumiko.field.embedded-list.empty"), emptyCtaLabel: t("kumiko.field.embedded-list.empty-cta"), testId: id }));
|
|
267
271
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
export type FileListFieldProps = {
|
|
4
|
+
readonly field: EditFieldViewModel;
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly kind: "file" | "image";
|
|
7
|
+
readonly onChange: (value: readonly string[]) => void;
|
|
8
|
+
readonly hasError: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function FileListField({ field, id, kind, onChange, hasError, }: FileListFieldProps): ReactNode;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useTranslation } from "../i18n.js";
|
|
3
|
+
import { usePrimitives } from "../primitives.js";
|
|
4
|
+
function isFileIdList(value) {
|
|
5
|
+
return Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function readFileIds(value) {
|
|
8
|
+
if (!isFileIdList(value))
|
|
9
|
+
return [];
|
|
10
|
+
return value.filter((entry) => typeof entry === "string" && entry !== "");
|
|
11
|
+
}
|
|
12
|
+
export function FileListField({ field, id, kind, onChange, hasError, }) {
|
|
13
|
+
const { Input, Button, Grid, GridCell, Text } = usePrimitives();
|
|
14
|
+
const t = useTranslation();
|
|
15
|
+
const fileIds = readFileIds(field.value);
|
|
16
|
+
const disabled = field.readOnly;
|
|
17
|
+
const canAdd = !disabled && (field.maxCount === undefined || fileIds.length < field.maxCount);
|
|
18
|
+
const inputProps = {
|
|
19
|
+
kind,
|
|
20
|
+
name: field.field,
|
|
21
|
+
disabled,
|
|
22
|
+
hasError,
|
|
23
|
+
...(field.accept !== undefined && { accept: field.accept }),
|
|
24
|
+
...(field.maxSize !== undefined && { maxSize: field.maxSize }),
|
|
25
|
+
...(field.entityType !== undefined && { entityType: field.entityType }),
|
|
26
|
+
...(field.entityId !== undefined && { entityId: field.entityId }),
|
|
27
|
+
...(field.fieldName !== undefined && { fieldName: field.fieldName }),
|
|
28
|
+
...(kind === "image" &&
|
|
29
|
+
field.imageVariant !== undefined && { imageVariant: field.imageVariant }),
|
|
30
|
+
...(kind === "image" && field.capture !== undefined && { capture: field.capture }),
|
|
31
|
+
};
|
|
32
|
+
function move(fromIndex, toIndex) {
|
|
33
|
+
const next = [...fileIds];
|
|
34
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
35
|
+
if (moved === undefined)
|
|
36
|
+
return;
|
|
37
|
+
next.splice(toIndex, 0, moved);
|
|
38
|
+
onChange(next);
|
|
39
|
+
}
|
|
40
|
+
return (_jsxs(Grid, { columns: 1, testId: `${id}-file-list`, children: [fileIds.length === 0 && _jsx(Text, { variant: "muted", children: t("kumiko.field.file-list.empty") }), fileIds.map((fileId, index) => (_jsxs(GridCell, { children: [_jsx(Input, { ...inputProps, id: `${id}-${index}`, value: fileId, onChange: (next) => {
|
|
41
|
+
if (next === null)
|
|
42
|
+
onChange(fileIds.filter((_, i) => i !== index));
|
|
43
|
+
else
|
|
44
|
+
onChange(fileIds.map((existing, i) => (i === index ? next : existing)));
|
|
45
|
+
} }), !disabled && (_jsxs(Grid, { columns: "auto", children: [index > 0 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-up", ariaLabel: t("kumiko.field.file-list.move-up"), testId: `${id}-${index}-move-up`, onClick: () => move(index, index - 1) })), index < fileIds.length - 1 && (_jsx(Button, { variant: "ghost", size: "icon", icon: "chevron-down", ariaLabel: t("kumiko.field.file-list.move-down"), testId: `${id}-${index}-move-down`, onClick: () => move(index, index + 1) }))] }))] }, fileId))), canAdd && (_jsxs(GridCell, { children: [_jsx(Text, { variant: "muted", children: t("kumiko.field.file-list.add") }), _jsx(Input, { ...inputProps, id: `${id}-add`, value: null, onChange: (next) => {
|
|
46
|
+
if (next !== null)
|
|
47
|
+
onChange([...fileIds, next]);
|
|
48
|
+
} })] }, "add"))] }));
|
|
49
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { RenderEditAction } from "./render-edit-types.js";
|
|
3
|
+
export type ListEmptyStateContent = {
|
|
4
|
+
readonly title: string;
|
|
5
|
+
readonly description?: string;
|
|
6
|
+
readonly action?: RenderEditAction;
|
|
7
|
+
};
|
|
8
|
+
export declare function ListEmptyStateBody({ content, narrowed, createButton, translate, }: {
|
|
9
|
+
readonly content: ListEmptyStateContent | undefined;
|
|
10
|
+
/** A search term or changed filter produced the empty result, so "create the first one" would mislead. */
|
|
11
|
+
readonly narrowed: boolean;
|
|
12
|
+
readonly createButton: ReactNode;
|
|
13
|
+
readonly translate: (key: string) => string;
|
|
14
|
+
}): ReactNode;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { usePrimitives } from "../primitives.js";
|
|
4
|
+
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
5
|
+
export function ListEmptyStateBody({ content, narrowed, createButton, translate, }) {
|
|
6
|
+
const { Text, Button, Dialog, Banner } = usePrimitives();
|
|
7
|
+
const [actionError, setActionError] = useState(null);
|
|
8
|
+
if (narrowed)
|
|
9
|
+
return _jsx(Text, { children: translate("kumiko.list.empty.filtered") });
|
|
10
|
+
const description = content === undefined ? translate("kumiko.list.empty.hint") : content.description;
|
|
11
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-list-empty-state-action-error", children: actionError })), createButton] }));
|
|
12
|
+
}
|
|
@@ -9,13 +9,15 @@ export declare function RenderEditActionConfirmDialog({ action, open, onOpenChan
|
|
|
9
9
|
readonly onConfirm: () => void | Promise<void>;
|
|
10
10
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
11
11
|
}): ReactNode;
|
|
12
|
-
export declare function RenderEditActionButton({ action, iconOnly, hideIcon, Button, Dialog, onError, }: {
|
|
12
|
+
export declare function RenderEditActionButton({ action, iconOnly, hideIcon, compactLabel, Button, Dialog, onError, }: {
|
|
13
13
|
readonly action: RenderEditAction;
|
|
14
14
|
/** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
|
|
15
15
|
* effect when this action actually resolved an icon. */
|
|
16
16
|
readonly iconOnly?: boolean;
|
|
17
17
|
/** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
|
|
18
18
|
readonly hideIcon?: boolean;
|
|
19
|
+
/** Short labelled button for a phone header, where a bare icon would hide what the action does. */
|
|
20
|
+
readonly compactLabel?: boolean;
|
|
19
21
|
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
20
22
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
21
23
|
readonly onError: (text: string | null) => void;
|
|
@@ -14,7 +14,7 @@ export function RenderEditActionConfirmDialog({ action, open, onOpenChange, onCo
|
|
|
14
14
|
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
15
15
|
// independently bound by the caller, there is no shared trigger pipeline
|
|
16
16
|
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
17
|
-
export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, Button, Dialog, onError, }) {
|
|
17
|
+
export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, compactLabel = false, Button, Dialog, onError, }) {
|
|
18
18
|
const [busy, setBusy] = useState(false);
|
|
19
19
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
20
20
|
const trigger = async () => {
|
|
@@ -32,7 +32,7 @@ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = fa
|
|
|
32
32
|
};
|
|
33
33
|
const variant = action.style ?? "secondary";
|
|
34
34
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
35
|
-
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && {
|
|
35
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(compactLabel && !showIconOnly && { size: "sm" }), ...(showIconOnly && {
|
|
36
36
|
size: "icon",
|
|
37
37
|
ariaLabel: action.label,
|
|
38
38
|
title: action.label,
|
|
@@ -160,12 +160,14 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
160
160
|
* no way to force-disable an arbitrary registered component. Omitting
|
|
161
161
|
* this prop keeps unchanged behavior.
|
|
162
162
|
*
|
|
163
|
-
*
|
|
164
|
-
* sites pass explicit prop lists without a spread and never forward
|
|
165
|
-
* `disabled`, so a screen-driven app can't set the locked state today.
|
|
166
|
-
* Upgrade path if that's needed: thread a screen-spec flag through to
|
|
167
|
-
* `EntityEditCreateBody`/`EntityEditEditBody`. */
|
|
163
|
+
* Screen-driven read-only is `viewMode`, not this flag. */
|
|
168
164
|
readonly disabled?: boolean;
|
|
165
|
+
/** Read-only view of an existing record (`EntityEditScreenDefinition.readOnly`):
|
|
166
|
+
* every field is disabled, no submit, discard or dirty footer, and the title
|
|
167
|
+
* resolves `screen:<id>.view.title` first. The host omits `onDelete`; record
|
|
168
|
+
* actions and cancel stay. UI only: server-side protection is the access
|
|
169
|
+
* rules and write handlers. */
|
|
170
|
+
readonly viewMode?: boolean;
|
|
169
171
|
/** Renders the fields without RenderEdit's own action bar (save, cancel,
|
|
170
172
|
* delete, copy-link). For hosts that put those controls into their own
|
|
171
173
|
* chrome — a drawer footer, a wizard shell — and drive the write through
|
|
@@ -224,7 +224,9 @@ function resolveTitleTemplate(template, values, translate, fallback) {
|
|
|
224
224
|
return translate(template, values);
|
|
225
225
|
}
|
|
226
226
|
export function RenderEdit(props) {
|
|
227
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, } = props;
|
|
227
|
+
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, refreshToken, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled: disabledProp = false, viewMode = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, submitBlocked = false, } = props;
|
|
228
|
+
// View mode is a locked form, so everything keyed on `disabled` follows.
|
|
229
|
+
const disabled = disabledProp || viewMode;
|
|
228
230
|
const i18nScreenId = props.i18nScreenId ?? screen.id;
|
|
229
231
|
const { customSubmit } = props;
|
|
230
232
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
@@ -631,7 +633,17 @@ export function RenderEdit(props) {
|
|
|
631
633
|
translate,
|
|
632
634
|
featureName,
|
|
633
635
|
mode: writeVerb,
|
|
634
|
-
|
|
636
|
+
...(!isCreateMode && entityIdProp != null && { entityId: entityIdProp }),
|
|
637
|
+
}), [
|
|
638
|
+
screen,
|
|
639
|
+
entity,
|
|
640
|
+
snapshot.values,
|
|
641
|
+
translate,
|
|
642
|
+
featureName,
|
|
643
|
+
writeVerb,
|
|
644
|
+
isCreateMode,
|
|
645
|
+
entityIdProp,
|
|
646
|
+
]);
|
|
635
647
|
const filteredSections = useMemo(
|
|
636
648
|
// A fully-hidden "fields" section (every field in it currently
|
|
637
649
|
// condition-hidden) must not occupy a wizard step; it would render
|
|
@@ -1121,10 +1133,11 @@ export function RenderEdit(props) {
|
|
|
1121
1133
|
// draw a no-op Save. Extension registrations still get their Save, since
|
|
1122
1134
|
// persistExtensions() runs on the customSubmit path too.
|
|
1123
1135
|
const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
|
|
1124
|
-
const showsSubmit =
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1136
|
+
const showsSubmit = !viewMode &&
|
|
1137
|
+
(isFormEditable ||
|
|
1138
|
+
hasExtensionRegistrations ||
|
|
1139
|
+
isFieldlessSubmitForm ||
|
|
1140
|
+
submitWithoutChanges !== undefined);
|
|
1128
1141
|
const footerSlot = screen.slots?.footer;
|
|
1129
1142
|
// An unregistered footer component renders null, so only a resolvable one may
|
|
1130
1143
|
// count as an action (else read-only screens get an empty footer strip).
|
|
@@ -1150,6 +1163,7 @@ export function RenderEdit(props) {
|
|
|
1150
1163
|
// Edit-mode screen forms always carry the dirty status (0 = quiet "No unsaved
|
|
1151
1164
|
// changes"); wizards keep the status-only-when-dirty behaviour.
|
|
1152
1165
|
const hasDirtyFooter = !isWizard &&
|
|
1166
|
+
!viewMode &&
|
|
1153
1167
|
(dirtyFooter === true || (isScreenForm && !isCreate && headerRegion === undefined));
|
|
1154
1168
|
const unsavedCount = !hasDirtyFooter
|
|
1155
1169
|
? undefined
|
|
@@ -1180,7 +1194,7 @@ export function RenderEdit(props) {
|
|
|
1180
1194
|
? "kumiko.form.saveChanges"
|
|
1181
1195
|
: "kumiko.actions.save")) })), writeFormFooterAction] }));
|
|
1182
1196
|
// Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
|
|
1183
|
-
// screen:<id>.<mode>.title / .<mode>.subtitle
|
|
1197
|
+
// screen:<id>.<mode>.title / .<mode>.subtitle (mode = "create"|"edit"|"view")
|
|
1184
1198
|
// Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
|
|
1185
1199
|
// title falls back to screenId; subtitle falls back to screen.description,
|
|
1186
1200
|
// itself run through translate() so an i18n key (the established
|
|
@@ -1189,7 +1203,7 @@ export function RenderEdit(props) {
|
|
|
1189
1203
|
// prose, same as it does for an unknown key (fw#2723 review). Same slot
|
|
1190
1204
|
// section.description already fills for a section. Falls back to
|
|
1191
1205
|
// undefined (no subtitle) when neither is set.
|
|
1192
|
-
const formMode = isCreate ? "create" : "edit";
|
|
1206
|
+
const formMode = isCreate ? "create" : viewMode ? "view" : "edit";
|
|
1193
1207
|
const recordTitle = !isCreate && screen.recordTitleField !== undefined
|
|
1194
1208
|
? resolveRecordTitle(initial[screen.recordTitleField])
|
|
1195
1209
|
: undefined;
|
|
@@ -15,6 +15,7 @@ import { useLocale, useTranslation } from "../i18n.js";
|
|
|
15
15
|
import { usePrimitives } from "../primitives.js";
|
|
16
16
|
import { AvailabilityOptionsMultiSelect, AvailabilityOptionsSelect, staticSelectOptions, } from "./availability-options-select.js";
|
|
17
17
|
import { EmbeddedListField } from "./embedded-list-field.js";
|
|
18
|
+
import { FileListField } from "./file-list-field.js";
|
|
18
19
|
import { MultiSelectCheckboxes } from "./multi-select-checkboxes.js";
|
|
19
20
|
import { QueryOptionsSelect } from "./query-options-select.js";
|
|
20
21
|
import { ReferenceCreateDialog } from "./reference-create-dialog.js";
|
|
@@ -45,7 +46,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
45
46
|
// Komponente gemountet werden (nicht als pure render-Call).
|
|
46
47
|
const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : field.type === "select" &&
|
|
47
48
|
field.selectOptionsAvailabilityQuery !== undefined &&
|
|
48
|
-
!readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : (renderInput({
|
|
49
|
+
!readOnlyText ? (_jsx(AvailabilityOptionsSelect, { field: field, query: field.selectOptionsAvailabilityQuery.query, payload: field.selectOptionsAvailabilityQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) && !isFileFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : (renderInput({
|
|
49
50
|
field,
|
|
50
51
|
id,
|
|
51
52
|
hasError,
|
|
@@ -276,11 +277,16 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
|
|
|
276
277
|
return _jsx(Text, { testId: `field-value-${field.field}`, children: labels.join(", ") });
|
|
277
278
|
}
|
|
278
279
|
// Field types whose read display already isn't a boxed "disabled input" look
|
|
279
|
-
// (embedded/jsonb render as an info Banner
|
|
280
|
-
//
|
|
281
|
-
//
|
|
280
|
+
// (embedded/jsonb render as an info Banner) — `readOnlyDisplayText` doesn't
|
|
281
|
+
// cover these, they keep going through `renderInput`'s existing Banner branch
|
|
282
|
+
// in text-display mode too.
|
|
282
283
|
function isComplexFieldType(type) {
|
|
283
|
-
return type === "embedded" || type === "jsonb"
|
|
284
|
+
return type === "embedded" || type === "jsonb";
|
|
285
|
+
}
|
|
286
|
+
// Read-only file fields keep their (disabled) widget: it shows a thumbnail or
|
|
287
|
+
// the file name, where plain text could only show the FileRef UUID.
|
|
288
|
+
function isFileFieldType(type) {
|
|
289
|
+
return type === "file" || type === "image" || type === "files" || type === "images";
|
|
284
290
|
}
|
|
285
291
|
// Mirrors ACTION_ICON_BY_ID (kumiko-screen.tsx) for fields: only names with
|
|
286
292
|
// an unambiguous matching FieldIconKey are listed here, everything else
|
|
@@ -383,22 +389,21 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
|
|
|
383
389
|
case "image": {
|
|
384
390
|
const kind = field.type === "image" ? "image" : "file";
|
|
385
391
|
const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
|
|
386
|
-
return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }), ...(field.imageVariant !== undefined && { imageVariant: field.imageVariant }), ...(field.capture !== undefined && { capture: field.capture }) }));
|
|
392
|
+
return (_jsx(Input, { kind: kind, ...common, value: fileId, onChange: (v) => onChange(v), ...(field.accept !== undefined && { accept: field.accept }), ...(field.maxSize !== undefined && { maxSize: field.maxSize }), ...(field.entityType !== undefined && { entityType: field.entityType }), ...(field.entityId !== undefined && { entityId: field.entityId }), ...(field.fieldName !== undefined && { fieldName: field.fieldName }), ...(field.imageVariant !== undefined && { imageVariant: field.imageVariant }), ...(field.capture !== undefined && { capture: field.capture }) }));
|
|
387
393
|
}
|
|
394
|
+
case "files":
|
|
395
|
+
case "images":
|
|
396
|
+
return (_jsx(FileListField, { field: field, id: id, kind: field.type === "images" ? "image" : "file", hasError: hasError, onChange: (ids) => onChange(ids) }));
|
|
388
397
|
// embedded (without embeddedListCells — that's embeddedList, which has
|
|
389
398
|
// had its own EmbeddedListField widget since #1838) and jsonb carry
|
|
390
|
-
// arbitrary objects
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// string, and saving that overwrites the real data with the mangled
|
|
395
|
-
// string (#1834). A `required: true` on any of these is caught loudly
|
|
399
|
+
// arbitrary objects. Without a dedicated widget these must NOT fall
|
|
400
|
+
// through to a text input: stringValue() turns them into
|
|
401
|
+
// "[object Object]" and saving that overwrites the real data with the
|
|
402
|
+
// mangled string (#1834). A `required: true` on either is caught loudly
|
|
396
403
|
// at boot (validateNoWidgetRequiredField in the framework package)
|
|
397
404
|
// instead of silently failing here.
|
|
398
405
|
case "embedded":
|
|
399
|
-
case "jsonb":
|
|
400
|
-
case "files":
|
|
401
|
-
case "images": {
|
|
406
|
+
case "jsonb": {
|
|
402
407
|
const hasValue = field.value !== undefined && field.value !== null && field.value !== "";
|
|
403
408
|
return (_jsxs(Banner, { id: id, variant: "info", children: [t("kumiko.field.unsupported"), hasValue &&
|
|
404
409
|
(JsonView !== undefined ? (_jsx(JsonView, { value: field.value, testId: `field-value-${field.field}` })) : (_jsx(Text, { variant: "code", children: JSON.stringify(field.value) })))] }));
|
|
@@ -556,9 +561,6 @@ function readOnlyDisplayText(field, appLocale, t) {
|
|
|
556
561
|
.map((v) => (typeof v === "string" ? (labels?.[v] ?? v) : stringValue(v)))
|
|
557
562
|
.join(", ");
|
|
558
563
|
}
|
|
559
|
-
case "file":
|
|
560
|
-
case "image":
|
|
561
|
-
return typeof value === "string" ? value : "—";
|
|
562
564
|
default:
|
|
563
565
|
return stringValue(value);
|
|
564
566
|
}
|
|
@@ -3,6 +3,7 @@ import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
|
3
3
|
import { type ReactNode } from "react";
|
|
4
4
|
import type { ListSort } from "../hooks/use-list-url-state.js";
|
|
5
5
|
import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableProps, type DataTableRowAction, type DataTableRowActionMode, type DataTableRowGrouping } from "../primitives.js";
|
|
6
|
+
import { type ListEmptyStateContent } from "./list-empty-state.js";
|
|
6
7
|
export type RenderListProps = {
|
|
7
8
|
readonly screen: EntityListScreenDefinition;
|
|
8
9
|
readonly entity: EntityDefinition;
|
|
@@ -27,8 +28,12 @@ export type RenderListProps = {
|
|
|
27
28
|
/** Placeholder für das Search-Input. Default kommt aus dem i18n-
|
|
28
29
|
* Bundle (`kumiko.list.search-placeholder`). */
|
|
29
30
|
readonly searchPlaceholder?: string;
|
|
30
|
-
/** Hint at the left of the toolbar
|
|
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
|
@@ -45,6 +45,17 @@ export const kumikoDefaultTranslations = {
|
|
|
45
45
|
"kumiko.field.writeOnly.setPlaceholder": "Set — leave empty to keep it",
|
|
46
46
|
"kumiko.field.writeOnly.undo": "Undo",
|
|
47
47
|
"kumiko.field.writeOnly.willRemove": "Removed on save",
|
|
48
|
+
"kumiko.field.embedded-list.actions": "Actions",
|
|
49
|
+
"kumiko.field.embedded-list.cell-label": "{column}, row {row}",
|
|
50
|
+
"kumiko.field.file-list.add": "Add file",
|
|
51
|
+
"kumiko.field.file-list.move-up": "Move file up",
|
|
52
|
+
"kumiko.field.file-list.move-down": "Move file down",
|
|
53
|
+
"kumiko.field.file-list.empty": "No files yet.",
|
|
54
|
+
"kumiko.field.file-upload.attached": "File attached",
|
|
55
|
+
"kumiko.field.file-upload.change": "Change",
|
|
56
|
+
"kumiko.field.file-upload.failed": "Upload failed",
|
|
57
|
+
"kumiko.field.file-upload.remove": "Remove",
|
|
58
|
+
"kumiko.field.file-upload.upload": "Upload",
|
|
48
59
|
"kumiko.field.embedded-list.add-row": "Add row",
|
|
49
60
|
"kumiko.field.embedded-list.remove-row": "Remove row",
|
|
50
61
|
"kumiko.field.embedded-list.duplicate-row": "Duplicate row",
|
|
@@ -58,6 +69,7 @@ export const kumikoDefaultTranslations = {
|
|
|
58
69
|
"kumiko.field.multiSelect.deselect-all": "Deselect all",
|
|
59
70
|
"kumiko.list.search-placeholder": "Search…",
|
|
60
71
|
"kumiko.list.empty.title": "No entries yet.",
|
|
72
|
+
"kumiko.list.empty.filtered": "No entries match the current search or filters.",
|
|
61
73
|
"kumiko.list.empty.hint": "Create the first one to get started.",
|
|
62
74
|
"kumiko.list.no-entries": "No entries.",
|
|
63
75
|
"kumiko.record.not-found.title": "This record was not found.",
|
|
@@ -242,6 +254,7 @@ export const kumikoDefaultTranslations = {
|
|
|
242
254
|
"errors.conflict": "Conflict — the operation could not be completed.",
|
|
243
255
|
"errors.versionConflict": "The record was modified in the meantime. Reload and try again.",
|
|
244
256
|
"errors.preconditionFailed": "The record changed in the meantime. Reload and try again.",
|
|
257
|
+
"errors.recordLocked": "This record is locked and can no longer be changed.",
|
|
245
258
|
"errors.uniqueViolation": "This entry already exists.",
|
|
246
259
|
"errors.unprocessable": "The request could not be processed.",
|
|
247
260
|
"errors.unconfigured": "This feature isn't configured yet.",
|
package/dist/index.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export { DraftStorageProvider, useDraftStorage } from "./context/draft-storage-c
|
|
|
48
48
|
export { EmbeddedScreenProvider, useIsEmbeddedScreen } from "./context/embedded-screen-context.js";
|
|
49
49
|
export type { UserRolesProviderProps } from "./context/user-roles-context.js";
|
|
50
50
|
export { UserRolesProvider, useUserRoles } from "./context/user-roles-context.js";
|
|
51
|
+
export type { CurrentUser } from "./current-user.js";
|
|
52
|
+
export { CurrentUserProvider, useOptionalCurrentUser } from "./current-user.js";
|
|
51
53
|
export { formatWhen } from "./format-when.js";
|
|
52
54
|
export { REFERENCE_COMBOBOX_LIMIT, REFERENCE_LIST_LOOKUP_LIMIT, REFERENCE_SEARCH_DEBOUNCE_MS, } from "./hooks/reference-limits.js";
|
|
53
55
|
export type { AiTextActionState, AiTextMode, AiTextRewriteStyle, AiTextRunPayload, AiTextRunResult, AiTextUsage, UseAiTextActionResult, UseCompletionResult, } from "./hooks/use-ai-text.js";
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ export { DispatcherProvider, useDispatcher, useDispatcherStatus, useOptionalDisp
|
|
|
37
37
|
export { DraftStorageProvider, useDraftStorage } from "./context/draft-storage-context.js";
|
|
38
38
|
export { EmbeddedScreenProvider, useIsEmbeddedScreen } from "./context/embedded-screen-context.js";
|
|
39
39
|
export { UserRolesProvider, useUserRoles } from "./context/user-roles-context.js";
|
|
40
|
+
export { CurrentUserProvider, useOptionalCurrentUser } from "./current-user.js";
|
|
40
41
|
export { formatWhen } from "./format-when.js";
|
|
41
42
|
export { REFERENCE_COMBOBOX_LIMIT, REFERENCE_LIST_LOOKUP_LIMIT, REFERENCE_SEARCH_DEBOUNCE_MS, } from "./hooks/reference-limits.js";
|
|
42
43
|
export { AI_TEXT_RUN_QN, useAiTextAction, useCompletion } from "./hooks/use-ai-text.js";
|
package/dist/primitives.d.ts
CHANGED
|
@@ -295,6 +295,9 @@ export type InputProps = {
|
|
|
295
295
|
readonly name: string;
|
|
296
296
|
/** FileRef-UUID (gespeicherte Datei) oder null (kein Upload). */
|
|
297
297
|
readonly value: string | null;
|
|
298
|
+
/** Implementations must offer their own remove control that calls
|
|
299
|
+
* `onChange(null)`: FileListField relies on it to drop a file from an
|
|
300
|
+
* `images`/`files` field. */
|
|
298
301
|
readonly onChange: (fileId: string | null) => void;
|
|
299
302
|
readonly disabled?: boolean;
|
|
300
303
|
readonly required?: boolean;
|
|
@@ -305,6 +308,9 @@ export type InputProps = {
|
|
|
305
308
|
/** Für den Upload-POST `/api/files` — der Endpoint prüft maxSize/accept
|
|
306
309
|
* gegen die Field-Def von (entityType, fieldName). */
|
|
307
310
|
readonly entityType?: string;
|
|
311
|
+
/** Must be sent as the `entityId` part of the upload so the FileRef is
|
|
312
|
+
* attached to the record (qr overlays and access guards need it). */
|
|
313
|
+
readonly entityId?: string;
|
|
308
314
|
readonly fieldName?: string;
|
|
309
315
|
/** Only for kind:"image" — variant name for the preview
|
|
310
316
|
* (`/api/files/:id/variant/:name`). Absent → the original. */
|
|
@@ -755,9 +761,14 @@ export type EmbeddedListColumn = {
|
|
|
755
761
|
/** Read-only, value supplied by the caller (already computed from
|
|
756
762
|
* sibling cells) — not directly user-editable. */
|
|
757
763
|
readonly derived: boolean;
|
|
764
|
+
/** Shown but not editable by the user; unlike `derived` it is not computed. */
|
|
765
|
+
readonly readOnly?: boolean;
|
|
758
766
|
/** Only for `type: "select"`. */
|
|
759
767
|
readonly options?: readonly string[];
|
|
760
768
|
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
769
|
+
/** Only for `type: "select"`; `"radio"` renders segmented buttons, the
|
|
770
|
+
* default is a dropdown. */
|
|
771
|
+
readonly display?: "radio" | "dropdown";
|
|
761
772
|
/** Only for `type: "reference"` — pre-fetched by the caller, ONE query
|
|
762
773
|
* per column shared across every row (not one query per cell). */
|
|
763
774
|
readonly referenceOptions?: readonly {
|
|
@@ -791,6 +802,8 @@ export type EmbeddedListInputProps = {
|
|
|
791
802
|
/** Whole-list read-only (e.g. a released invoice) — hides row-mutation
|
|
792
803
|
* affordances and disables every cell. */
|
|
793
804
|
readonly disabled?: boolean;
|
|
805
|
+
/** Rows are fixed: no add, remove, duplicate or move; cells stay editable. */
|
|
806
|
+
readonly fixedRows?: boolean;
|
|
794
807
|
readonly minItems?: number;
|
|
795
808
|
readonly maxItems?: number;
|
|
796
809
|
/** Shown under the whole list (e.g. under the totals row). */
|
|
@@ -810,6 +823,8 @@ export type EmbeddedListInputProps = {
|
|
|
810
823
|
* handling wired up. */
|
|
811
824
|
readonly onPasteCells?: (rowIndex: number, columnIndex: number, grid: readonly (readonly string[])[]) => void;
|
|
812
825
|
readonly addLabel: string;
|
|
826
|
+
/** Accessible name of the (otherwise headerless) row-actions column. */
|
|
827
|
+
readonly actionsLabel: string;
|
|
813
828
|
readonly removeLabel: string;
|
|
814
829
|
readonly duplicateLabel: string;
|
|
815
830
|
readonly moveUpLabel: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
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,47 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.361.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Create form prefill and CurrentUserProvider",
|
|
6
|
+
"detail": "EntityEditCreateBody applies prefill to empty initial values. New exports CurrentUser, CurrentUserProvider and useOptionalCurrentUser."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.361.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Read-only view mode for entityEdit",
|
|
12
|
+
"detail": "RenderEdit takes viewMode, which disables all fields and removes submit, discard, delete and the dirty footer while keeping record actions and cancel. The update body sets it when screen.readOnly holds for the record and resolves screen:<id>.view.title before screen:<id>.title."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.361.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "EmbeddedListColumn.readOnly and EmbeddedListInput fixedRows",
|
|
18
|
+
"detail": "EmbeddedListField forwards both, skips readOnly columns when pasting and never appends rows from a paste when rows are fixed."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.361.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "EmbeddedListColumn.display",
|
|
24
|
+
"detail": "EmbeddedListField forwards the select display to the EmbeddedListInput primitive."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.361.0",
|
|
28
|
+
"type": "improvement",
|
|
29
|
+
"title": "entityEdit shows locked records as a view",
|
|
30
|
+
"detail": "EntityEditUpdateForm shows the view when screen.readOnly matches or entity.lockedWhen matches or its field is missing from the record. Adds the default string errors.recordLocked."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.361.0",
|
|
34
|
+
"type": "breaking",
|
|
35
|
+
"title": "Searchable lists show their description as its own row",
|
|
36
|
+
"detail": "An entityList or projectionList with search used to hide its description. It now renders as a muted row below the toolbar, the same as on lists without search.",
|
|
37
|
+
"migration": "Look at lists that set description together with search. Drop descriptions that were only kept as a hint for developers, or move the text into emptyState if it explains an empty list."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"version": "0.360.0",
|
|
41
|
+
"type": "improvement",
|
|
42
|
+
"title": "FileListField edits images and files fields",
|
|
43
|
+
"detail": "RenderField renders type images and files as FileListField instead of the unsupported banner. One tile per stored FileRef id with move up, move down and remove buttons, one trailing empty tile to add a file (hidden at maxCount or when read-only). It is built from Input kind file or image, Button and the layout primitives, passes accept, maxSize, entityType, entityId, fieldName, imageVariant and capture to every tile, and reads only string ids from the stored value. The read-only text display of these fields lists the ids. New i18n keys kumiko.field.file-list.add, move-up, move-down and empty; kumiko.field.file-upload.upload, change, remove, attached and failed; kumiko.field.embedded-list.actions and cell-label. The file and image Input kinds accept entityId. EmbeddedListInput has a new required actionsLabel prop."
|
|
44
|
+
},
|
|
2
45
|
{
|
|
3
46
|
"version": "0.357.0",
|
|
4
47
|
"type": "improvement",
|