@cosmicdrift/kumiko-renderer 0.329.0 → 0.330.2
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/README.md +13 -0
- package/dist/app/kumiko-screen.js +27 -16
- package/dist/components/grid-cell-for-field.d.ts +2 -1
- package/dist/components/grid-cell-for-field.js +9 -2
- package/dist/components/render-edit-types.d.ts +4 -2
- package/dist/components/render-edit.d.ts +2 -0
- package/dist/components/render-edit.js +29 -17
- package/dist/components/render-field.js +3 -6
- package/dist/components/write-form-section.js +3 -4
- package/dist/i18n-defaults.js +8 -0
- package/dist/primitives.d.ts +19 -4
- package/package.json +5 -5
- package/src/changes.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @cosmicdrift/kumiko-renderer
|
|
2
|
+
|
|
3
|
+
Platform-agnostic React renderer for Kumiko screens: primitives contract, hooks, `KumikoScreen`, navigation and SSE abstractions. No DOM, no `EventSource`, no `react-dom`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @cosmicdrift/kumiko-renderer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Pair it with `@cosmicdrift/kumiko-renderer-web` for the browser.
|
|
12
|
+
|
|
13
|
+
Documentation: https://kumiko.rocks
|
|
@@ -5,7 +5,7 @@ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
|
5
5
|
import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
|
|
8
|
-
import { RenderEdit, } from "../components/render-edit.js";
|
|
8
|
+
import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
|
|
9
9
|
import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
|
|
10
10
|
import { RenderList } from "../components/render-list.js";
|
|
11
11
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
@@ -154,10 +154,12 @@ function pageSizeOptionsFor(currentLimit) {
|
|
|
154
154
|
? PAGE_SIZE_OPTIONS
|
|
155
155
|
: [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
|
|
156
156
|
}
|
|
157
|
-
function useNavigateToCreateFor(schema, entityName) {
|
|
157
|
+
function useNavigateToCreateFor(schema, entityName, createScreen) {
|
|
158
158
|
const nav = useNav();
|
|
159
159
|
const host = useReturnHost();
|
|
160
160
|
const editScreenId = useMemo(() => {
|
|
161
|
+
if (createScreen !== undefined)
|
|
162
|
+
return createScreen;
|
|
161
163
|
// allowCreate:false = update-only Edit-Screen (Create läuft über einen
|
|
162
164
|
// Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
|
|
163
165
|
// öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
|
|
@@ -167,7 +169,7 @@ function useNavigateToCreateFor(schema, entityName) {
|
|
|
167
169
|
s.allowCreate !== false &&
|
|
168
170
|
s.singleton !== true);
|
|
169
171
|
return edit !== undefined ? lastSegment(edit.id) : undefined;
|
|
170
|
-
}, [schema.screens, entityName]);
|
|
172
|
+
}, [schema.screens, entityName, createScreen]);
|
|
171
173
|
const navigate = useCallback(() => {
|
|
172
174
|
if (editScreenId !== undefined)
|
|
173
175
|
navigateWithReturnTo(nav, { screenId: editScreenId }, host);
|
|
@@ -730,12 +732,13 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
730
732
|
const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
|
|
731
733
|
const handleDelete = useCallback(async () => {
|
|
732
734
|
const res = await dispatcher.write(deleteCommand, { id: entityId });
|
|
733
|
-
if (res.isSuccess)
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
735
|
+
if (!res.isSuccess)
|
|
736
|
+
return res.error;
|
|
737
|
+
// Never return onto the just-deleted record.
|
|
738
|
+
const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
|
|
739
|
+
navigateToReturnOr(nav, target, navigateToList);
|
|
740
|
+
onDeleted?.();
|
|
741
|
+
return undefined;
|
|
739
742
|
}, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
|
|
740
743
|
// Never seed a tenant-declared money field with entityDefaultCurrency while
|
|
741
744
|
// its real tenant currency is still in flight (fw#2933) — wait instead. A
|
|
@@ -956,7 +959,7 @@ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
|
|
|
956
959
|
}
|
|
957
960
|
function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
958
961
|
const featureName = schema.featureName;
|
|
959
|
-
const onCreate = useNavigateToCreateFor(schema, screen.entity);
|
|
962
|
+
const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen);
|
|
960
963
|
const { Banner } = usePrimitives();
|
|
961
964
|
const queryType = entityQueryCommand(featureName, screen.entity, "list");
|
|
962
965
|
const nav = useNav();
|
|
@@ -1840,6 +1843,9 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1840
1843
|
// this shares the same page padding/width as the record's edit card below
|
|
1841
1844
|
// it, instead of sitting flush against the screen edge (fw record-screen
|
|
1842
1845
|
// header polish).
|
|
1846
|
+
const recordTitle = screen.recordTitleField !== undefined
|
|
1847
|
+
? resolveRecordTitle(record[screen.recordTitleField])
|
|
1848
|
+
: undefined;
|
|
1843
1849
|
const header = screen.header;
|
|
1844
1850
|
const subtitleHref = resolveSubtitleHref(header, record);
|
|
1845
1851
|
// Plain Text, not Heading — the Card title slot already renders as its own
|
|
@@ -1861,18 +1867,20 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1861
1867
|
return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
|
|
1862
1868
|
});
|
|
1863
1869
|
// Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
|
|
1864
|
-
const metricsBlock = (subtitle) => MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
|
|
1870
|
+
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 }));
|
|
1865
1871
|
const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
|
|
1866
1872
|
const actionErrorBanner = actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError }));
|
|
1867
|
-
const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot
|
|
1873
|
+
const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(header?.status !== undefined &&
|
|
1868
1874
|
StatusBadge !== undefined && {
|
|
1869
1875
|
status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
|
|
1870
1876
|
}), ...(hasHeaderActions && {
|
|
1871
1877
|
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 }) })),
|
|
1872
|
-
}) }), (hasMetrics || headerSubtitleSlot !== undefined) &&
|
|
1878
|
+
}) }), MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
|
|
1873
1879
|
(MetricBand !== undefined || hasMetrics) &&
|
|
1874
|
-
metricsBlock(headerSubtitleSlot), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined) && (_jsxs(Card, { slots: {
|
|
1880
|
+
metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
|
|
1875
1881
|
...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
|
|
1882
|
+
...(headerTitleSlot === undefined &&
|
|
1883
|
+
recordTitle !== undefined && { title: recordTitle }),
|
|
1876
1884
|
...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
|
|
1877
1885
|
...(headerSlot !== undefined && { headerContent: headerSlot }),
|
|
1878
1886
|
...(hasHeaderActions && { headerActions: headerActionsContent }),
|
|
@@ -1888,7 +1896,10 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1888
1896
|
...(count !== undefined && { count }),
|
|
1889
1897
|
};
|
|
1890
1898
|
}), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
|
|
1891
|
-
return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: detailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
|
|
1899
|
+
return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: detailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
|
|
1900
|
+
hasTabs ||
|
|
1901
|
+
screen.slots?.header !== undefined ||
|
|
1902
|
+
screen.recordTitleField !== undefined) && {
|
|
1892
1903
|
headerRegion: renderHeaderContent,
|
|
1893
1904
|
}), buildSectionActions: buildSectionActions, valueDisplay: screen.valueDisplay ?? "text" }, `${entityId}:${reloadNonce}`), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
|
|
1894
1905
|
closeDrawer();
|
|
@@ -2187,7 +2198,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2187
2198
|
if (initial === null) {
|
|
2188
2199
|
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
2189
2200
|
}
|
|
2190
|
-
return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, customSubmit: customSubmit, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), labelAppendix: (fieldName) => {
|
|
2201
|
+
return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, customSubmit: customSubmit, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), labelAppendix: (fieldName) => {
|
|
2191
2202
|
const cascade = cascades[fieldName];
|
|
2192
2203
|
if (cascade === undefined)
|
|
2193
2204
|
return undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
-
import type { usePrimitives } from "../primitives.js";
|
|
3
|
+
import type { FieldCellWidth, usePrimitives } from "../primitives.js";
|
|
4
4
|
export type GridCellForFieldProps = {
|
|
5
5
|
readonly field: EditFieldViewModel;
|
|
6
6
|
readonly columns: number;
|
|
@@ -22,4 +22,5 @@ export type GridCellForFieldProps = {
|
|
|
22
22
|
readonly changed?: boolean;
|
|
23
23
|
readonly flow?: boolean;
|
|
24
24
|
};
|
|
25
|
+
export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
|
|
25
26
|
export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }: GridCellForFieldProps): ReactNode;
|
|
@@ -6,9 +6,11 @@ const FIELD_CELL_WIDTH_BY_TYPE = {
|
|
|
6
6
|
bigInt: "number",
|
|
7
7
|
money: "money",
|
|
8
8
|
date: "date",
|
|
9
|
+
timestamp: "timestamp",
|
|
10
|
+
locatedTimestamp: "timestamp",
|
|
9
11
|
select: "select",
|
|
10
12
|
multiSelect: "select",
|
|
11
|
-
boolean: "
|
|
13
|
+
boolean: "toggle",
|
|
12
14
|
longText: "full",
|
|
13
15
|
embedded: "full",
|
|
14
16
|
jsonb: "full",
|
|
@@ -17,9 +19,14 @@ const FIELD_CELL_WIDTH_BY_TYPE = {
|
|
|
17
19
|
image: "full",
|
|
18
20
|
images: "full",
|
|
19
21
|
};
|
|
20
|
-
|
|
22
|
+
const READONLY_FULL_WIDTH_TYPES = new Set(["text", "uuid"]);
|
|
23
|
+
// Exported for unit tests.
|
|
24
|
+
export function fieldCellWidth(field) {
|
|
21
25
|
if (field.span !== undefined || (field.type === "text" && field.multiline))
|
|
22
26
|
return "full";
|
|
27
|
+
// A readonly value (UUID, long code) is plain text that must not be cut at the input width.
|
|
28
|
+
if (field.readOnly && READONLY_FULL_WIDTH_TYPES.has(field.type))
|
|
29
|
+
return "full";
|
|
23
30
|
return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
|
|
24
31
|
}
|
|
25
32
|
export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
-
import type { FormSnapshot, FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import type { DispatcherError, FormSnapshot, FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import type * as z from "zod";
|
|
5
5
|
export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
@@ -38,7 +38,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
38
38
|
* onto form fields — see SubmitConfig.serverFieldPathPrefix. Only needed
|
|
39
39
|
* when buildPayload nests the form values under a key. */
|
|
40
40
|
readonly serverFieldPathPrefix?: string;
|
|
41
|
-
|
|
41
|
+
/** Returning the rejected write's error shows it in the form-error banner;
|
|
42
|
+
* returning nothing means the delete went through. */
|
|
43
|
+
readonly onDelete?: () => Promise<DispatcherError | void> | DispatcherError | void;
|
|
42
44
|
readonly onCancel?: () => void;
|
|
43
45
|
readonly onReload?: () => void;
|
|
44
46
|
/** Fires when the form gains or loses unsaved input (field changes or a
|
|
@@ -3,4 +3,6 @@ import type { RenderEditProps } from "./render-edit-types.js";
|
|
|
3
3
|
export type { RenderEditAction, RenderEditChangeState, RenderEditControls, RenderEditProps, } from "./render-edit-types.js";
|
|
4
4
|
import { type ReactNode } from "react";
|
|
5
5
|
export declare const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
6
|
+
/** A record title is a non-blank string; anything else leaves the screen title alone. */
|
|
7
|
+
export declare function resolveRecordTitle(value: unknown): string | undefined;
|
|
6
8
|
export declare function RenderEdit<TValues extends FormValues, TCtx = unknown>(props: RenderEditProps<TValues, TCtx>): ReactNode;
|
|
@@ -141,6 +141,10 @@ function EditSlotMount({ slot, slotName, screenId, entityName, entityId, values,
|
|
|
141
141
|
function formSectionDomId(sectionIndex) {
|
|
142
142
|
return `form-section-${sectionIndex}`;
|
|
143
143
|
}
|
|
144
|
+
/** A record title is a non-blank string; anything else leaves the screen title alone. */
|
|
145
|
+
export function resolveRecordTitle(value) {
|
|
146
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
147
|
+
}
|
|
144
148
|
export function RenderEdit(props) {
|
|
145
149
|
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, summary, labelAppendix, fieldAppendix, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, headerRegion, buildSectionActions, } = props;
|
|
146
150
|
const { customSubmit } = props;
|
|
@@ -520,7 +524,7 @@ export function RenderEdit(props) {
|
|
|
520
524
|
section.visible), [vm.sections, fieldsFilter]);
|
|
521
525
|
const allFieldsRequired = useMemo(() => {
|
|
522
526
|
const fillable = filteredSections.flatMap((section) => section.kind === "fields"
|
|
523
|
-
? section.fields.filter((field) => field.visible && !field.readOnly)
|
|
527
|
+
? section.fields.filter((field) => field.visible && !field.readOnly && field.type !== "boolean")
|
|
524
528
|
: []);
|
|
525
529
|
return fillable.length > 0 && fillable.every((field) => field.required);
|
|
526
530
|
}, [filteredSections]);
|
|
@@ -957,6 +961,9 @@ export function RenderEdit(props) {
|
|
|
957
961
|
return id == null || id === "";
|
|
958
962
|
})();
|
|
959
963
|
const formMode = isCreate ? "create" : "edit";
|
|
964
|
+
const recordTitle = !isCreate && screen.recordTitleField !== undefined
|
|
965
|
+
? resolveRecordTitle(initial[screen.recordTitleField])
|
|
966
|
+
: undefined;
|
|
960
967
|
const resolveScreenText = (suffix) => {
|
|
961
968
|
for (const key of [
|
|
962
969
|
`screen:${screen.id}.${formMode}.${suffix}`,
|
|
@@ -1006,7 +1013,7 @@ export function RenderEdit(props) {
|
|
|
1006
1013
|
})
|
|
1007
1014
|
: translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
|
|
1008
1015
|
})();
|
|
1009
|
-
return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(headerMenuItems.length > 0 &&
|
|
1016
|
+
return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
|
|
1010
1017
|
ActionOverflowMenu !== undefined && {
|
|
1011
1018
|
actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
|
|
1012
1019
|
}) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true && !titleInShell && { title: formTitle }), ...(formSubtitle !== undefined && !stepRail && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount > 0 && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
|
|
@@ -1078,17 +1085,14 @@ export function RenderEdit(props) {
|
|
|
1078
1085
|
// over TValues; controller is mount-lifetime-stable, see onControlsReady above.
|
|
1079
1086
|
patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(hideSectionTitles !== true &&
|
|
1080
1087
|
sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title));
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
// panel (matches the non-tabs Section branch above via hideTitle).
|
|
1086
|
-
// testId stays only on the inner Section — this outer Card is
|
|
1087
|
-
// purely chrome, giving it the same testId would register two
|
|
1088
|
+
// Tabs mode: an unframed padded panel (the tab panel is the
|
|
1089
|
+
// surface), so extension content neither sits flush at the shell
|
|
1090
|
+
// edge nor touches the tab strip. testId stays only on the inner
|
|
1091
|
+
// Section — giving the wrapper the same one would register two
|
|
1088
1092
|
// elements under one id.
|
|
1089
|
-
const wrapped = hideSectionTitles
|
|
1093
|
+
const wrapped = hideSectionTitles !== true ? (mount) : (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
|
|
1090
1094
|
slots: { headerActions: sectionActionsEl },
|
|
1091
|
-
}), children: mount }, section.title))
|
|
1095
|
+
}), children: mount }, section.title));
|
|
1092
1096
|
return wrapWizardStep(section.title, wrapped);
|
|
1093
1097
|
}
|
|
1094
1098
|
if (section.kind === "relatedList") {
|
|
@@ -1125,8 +1129,8 @@ export function RenderEdit(props) {
|
|
|
1125
1129
|
}), ...(fieldAppendix !== undefined && {
|
|
1126
1130
|
fieldAppendix: fieldAppendix(field.field),
|
|
1127
1131
|
}), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...(!isCreate && field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
|
|
1128
|
-
// Tabs mode
|
|
1129
|
-
//
|
|
1132
|
+
// Tabs mode: the tab panel is the surface, so field sections render
|
|
1133
|
+
// as unframed padded panels; group cards inside stay framed.
|
|
1130
1134
|
// Stays `Card`, not `Section` — Section always flattens to a
|
|
1131
1135
|
// borderless divider when rendered inside this component's own
|
|
1132
1136
|
// `<Form>` (InsideFormContext), even with `chromeless` set, so it
|
|
@@ -1139,13 +1143,13 @@ export function RenderEdit(props) {
|
|
|
1139
1143
|
// group cards (Card-in-Card) — the boot-validator rejects that
|
|
1140
1144
|
// combination in tabs mode, so `sectionActionsEl` is always
|
|
1141
1145
|
// undefined here at runtime.
|
|
1142
|
-
return wrapWizardStep(sectionKey, groupsGrid);
|
|
1146
|
+
return wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
|
|
1143
1147
|
}
|
|
1144
1148
|
// No section title here (fw#3218) — the Tab strip right above
|
|
1145
1149
|
// already names this panel, so a repeated Card title would just
|
|
1146
1150
|
// duplicate it. The title row only exists to carry actions, and
|
|
1147
1151
|
// then it carries only the actions, never the title text.
|
|
1148
|
-
const cardEl = (_jsx(Card, { ...(sectionActionsEl !== undefined && {
|
|
1152
|
+
const cardEl = (_jsx(Card, { options: { framed: false }, ...(sectionActionsEl !== undefined && {
|
|
1149
1153
|
slots: { headerActions: sectionActionsEl },
|
|
1150
1154
|
}), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`) }, sectionKey));
|
|
1151
1155
|
return wrapWizardStep(sectionKey, cardEl);
|
|
@@ -1163,7 +1167,15 @@ export function RenderEdit(props) {
|
|
|
1163
1167
|
}), formError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
|
|
1164
1168
|
onReload();
|
|
1165
1169
|
setFormError(null);
|
|
1166
|
-
}, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: _jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey) }) })), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
|
|
1167
|
-
await onDelete();
|
|
1170
|
+
}, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: _jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }) })), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
|
|
1171
|
+
const rejection = await onDelete();
|
|
1172
|
+
// A rejected delete (e.g. a preDelete hook) carries its reason in
|
|
1173
|
+
// the field issues; the top-level key is only "validation failed".
|
|
1174
|
+
if (rejection !== undefined) {
|
|
1175
|
+
const issue = rejection.details?.fields?.[0];
|
|
1176
|
+
setFormError(issue === undefined
|
|
1177
|
+
? rejection
|
|
1178
|
+
: { ...rejection, i18nKey: issue.i18nKey, i18nParams: issue.params });
|
|
1179
|
+
}
|
|
1168
1180
|
}, testId: "render-edit-delete-dialog" }))] }) })] }));
|
|
1169
1181
|
}
|
|
@@ -54,7 +54,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
54
54
|
t,
|
|
55
55
|
row,
|
|
56
56
|
}));
|
|
57
|
-
return (_jsx(Field, { id: id, label: field.label, required: field.required && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(changed === true && { changed }), testId: `field-${field.field}`, children: control }));
|
|
57
|
+
return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(changed === true && { changed }), testId: `field-${field.field}`, children: control }));
|
|
58
58
|
}
|
|
59
59
|
// Where a reference field's option rows come from, and which of their keys
|
|
60
60
|
// holds the display text — the entity's own list handler plus `refLabelField`
|
|
@@ -319,14 +319,11 @@ function renderInput({ field, id, hasError, onChange, Input, appLocale, Banner,
|
|
|
319
319
|
case "bigInt": {
|
|
320
320
|
const icon = resolveFieldIcon(field);
|
|
321
321
|
const unit = resolveNumberUnit(field.unit, row);
|
|
322
|
-
return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), ...(icon !== undefined && { icon }), ...(unit !== undefined && { unit }) }));
|
|
322
|
+
return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), locale: appLocale, ...(field.grouping !== undefined && { grouping: field.grouping }), ...(field.type === "bigInt" && { integer: true }), ...(icon !== undefined && { icon }), ...(unit !== undefined && { unit }) }));
|
|
323
323
|
}
|
|
324
324
|
case "decimal": {
|
|
325
|
-
// step="any" disables the native stepMismatch constraint — without it
|
|
326
|
-
// <input type="number"> defaults to step=1 and blocks form submit on
|
|
327
|
-
// any fractional value via silent browser-native validation.
|
|
328
325
|
const icon = resolveFieldIcon(field);
|
|
329
|
-
return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v),
|
|
326
|
+
return (_jsx(Input, { kind: "number", ...common, value: numberValue(field.value), onChange: (v) => onChange(v), locale: appLocale, ...(field.grouping !== undefined && { grouping: field.grouping }), ...(icon !== undefined && { icon }) }));
|
|
330
327
|
}
|
|
331
328
|
case "tz":
|
|
332
329
|
return (_jsx(Input, { kind: "tz", ...common, value: stringValue(field.value), onChange: (v) => onChange(v) }));
|
|
@@ -88,11 +88,10 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
88
88
|
const submitButton = (_jsx(Button, { type: "button", variant: "primary", disabled: isSubmitting, loading: isSubmitting, onClick: () => void handleSubmit(), testId: "write-form-section-submit", children: section.submitLabel ?? effectiveTranslate("kumiko.actions.save") }));
|
|
89
89
|
const titleRowActions = actions !== undefined ? (_jsxs(_Fragment, { children: [actions, submitButton] })) : (submitButton);
|
|
90
90
|
// Section always flattens to a borderless divider when rendered inside
|
|
91
|
-
// RenderEdit's own <Form> in tabs mode (hideTitle) —
|
|
92
|
-
//
|
|
93
|
-
// branches stay on Card.
|
|
91
|
+
// RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
|
|
92
|
+
// stands in, like render-edit.tsx's tabs-mode fields/extension branches.
|
|
94
93
|
if (hideTitle) {
|
|
95
|
-
return (_jsx(Card, { slots: {
|
|
94
|
+
return (_jsx(Card, { options: { framed: false }, slots: {
|
|
96
95
|
...(section.description !== undefined && { subtitle: section.description }),
|
|
97
96
|
headerActions: titleRowActions,
|
|
98
97
|
}, testId: `write-form-${section.title ?? "section"}`, children: content }));
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -81,7 +81,15 @@ export const kumikoDefaultTranslations = {
|
|
|
81
81
|
"kumiko.combobox.empty": "No matches.",
|
|
82
82
|
"kumiko.combobox.loading": "Loading…",
|
|
83
83
|
"kumiko.combobox.placeholder": "—",
|
|
84
|
+
"kumiko.dashboard.chart.forecast": "Forecast",
|
|
85
|
+
"kumiko.dashboard.chart.markers": "Events",
|
|
84
86
|
"kumiko.dashboard.filter.all": "All",
|
|
87
|
+
"kumiko.dashboard.panel.error.isolated": "The other panels are not affected.",
|
|
88
|
+
"kumiko.dashboard.panel.error.retry": "Try again",
|
|
89
|
+
"kumiko.dashboard.panel.error.title": "{label} could not be loaded",
|
|
90
|
+
"kumiko.dashboard.time-range": "Time range",
|
|
91
|
+
"kumiko.dashboard.today": "Today",
|
|
92
|
+
"kumiko.dashboard.updated-at": "As of {time}",
|
|
85
93
|
"kumiko.app-boot.loading": "Loading…",
|
|
86
94
|
"kumiko.app-boot.unauthorized": "You need to sign in to see this.",
|
|
87
95
|
"kumiko.app-boot.failed": "Couldn't load the app. Check your connection and try again.",
|
package/dist/primitives.d.ts
CHANGED
|
@@ -211,6 +211,12 @@ export type InputProps = {
|
|
|
211
211
|
/** `<input step>`. "any" disables the native stepMismatch constraint
|
|
212
212
|
* (needed for decimal fields — integer fields leave this unset). */
|
|
213
213
|
readonly step?: number | "any";
|
|
214
|
+
/** BCP-47 tag for the grouped display and the decimal separator when typing. */
|
|
215
|
+
readonly locale?: string;
|
|
216
|
+
/** `false` shows the value without a thousands separator (e.g. a year). */
|
|
217
|
+
readonly grouping?: boolean;
|
|
218
|
+
/** Integer-only entry (bigInt): fractional input is rejected on blur. */
|
|
219
|
+
readonly integer?: boolean;
|
|
214
220
|
/** Resolved display suffix (static or from a sibling field) — never
|
|
215
221
|
* part of the numeric value. */
|
|
216
222
|
readonly unit?: string;
|
|
@@ -475,8 +481,9 @@ export type DataTableRowAction = {
|
|
|
475
481
|
readonly onTrigger: (row: ListRowViewModel) => Promise<void> | void;
|
|
476
482
|
/** Conditional Visibility pro Row (z.B. "Start" nur wenn status==="scheduled"). */
|
|
477
483
|
readonly isVisible?: (row: ListRowViewModel) => boolean;
|
|
478
|
-
/** This action is what a click on the row does.
|
|
479
|
-
*
|
|
484
|
+
/** This action is what a click on the row does. With `onRowClick` the table
|
|
485
|
+
* (except `"inline"` mode or editable cells) and the card layout drop it
|
|
486
|
+
* from the row menu, since the row itself is already the click target. */
|
|
480
487
|
readonly rowClick?: boolean;
|
|
481
488
|
/** Resolved icon (author `RowAction.icon` or the id-derived default) —
|
|
482
489
|
* drives both the icon-left-of-text render and the icon-only collapse
|
|
@@ -530,7 +537,8 @@ export type DataTableProps = {
|
|
|
530
537
|
* sich nur um Render + Confirm-Dialog. */
|
|
531
538
|
readonly rowActions?: readonly DataTableRowAction[];
|
|
532
539
|
/** How the row-action column renders:
|
|
533
|
-
* - `"adaptive"` (default): with `onRowClick` all actions
|
|
540
|
+
* - `"adaptive"` (default): with `onRowClick` all actions except the
|
|
541
|
+
* `rowClick` one go in the kebab (no kebab or column if none remain);
|
|
534
542
|
* without it the primary action is a link button and the rest go in the
|
|
535
543
|
* kebab (a single action gets no kebab).
|
|
536
544
|
* - `"inline"`: ALWAYS inline buttons, left-aligned, even with >2 (no
|
|
@@ -868,7 +876,7 @@ export type GridProps = {
|
|
|
868
876
|
readonly flow?: boolean;
|
|
869
877
|
};
|
|
870
878
|
/** Width class of a field cell inside a `flow` grid: sized by what the field holds. */
|
|
871
|
-
export type FieldCellWidth = "text" | "number" | "money" | "date" | "select" | "full" | "
|
|
879
|
+
export type FieldCellWidth = "text" | "number" | "money" | "date" | "timestamp" | "select" | "full" | "toggle";
|
|
872
880
|
/** Span-Wrapper für ein Kind innerhalb eines Grid. Web: `style={{gridColumn: span N}}`,
|
|
873
881
|
* Native: eigenes Width-Rechnen. */
|
|
874
882
|
export type GridCellProps = {
|
|
@@ -1021,6 +1029,9 @@ export type CardOptions = {
|
|
|
1021
1029
|
* relatedList table sizing to the panel instead of the page) instead of
|
|
1022
1030
|
* normal document-flow height. Default false. */
|
|
1023
1031
|
readonly fillHeight?: boolean;
|
|
1032
|
+
/** false drops border, background, shadow and radius but keeps padding and
|
|
1033
|
+
* header slots — a panel inside a host surface such as a tab panel. Default true. */
|
|
1034
|
+
readonly framed?: boolean;
|
|
1024
1035
|
};
|
|
1025
1036
|
/** Generische Card-Chrome (border/bg/radius/shadow + Header/Body/Footer) als
|
|
1026
1037
|
* Primitive — eine Quelle für alle Card-Flächen, statt pro App nachgebaut.
|
|
@@ -1135,6 +1146,8 @@ export type JsonViewProps = {
|
|
|
1135
1146
|
/** Band above the tabs of a record detail: optional subtitle line plus the
|
|
1136
1147
|
* metrics as a definition list. `children` are `Metric` tiles. */
|
|
1137
1148
|
export type MetricBandProps = {
|
|
1149
|
+
/** Free-form block rendered unstyled as the first full-width row of the band. */
|
|
1150
|
+
readonly lead?: ReactNode;
|
|
1138
1151
|
readonly subtitle?: ReactNode;
|
|
1139
1152
|
readonly children: ReactNode;
|
|
1140
1153
|
readonly testId?: string;
|
|
@@ -1145,6 +1158,8 @@ export type MetricBandProps = {
|
|
|
1145
1158
|
* (see `usePageHeaderSlotAvailable`) callers keep their previous placement. */
|
|
1146
1159
|
export type PageHeaderProps = {
|
|
1147
1160
|
readonly title?: string;
|
|
1161
|
+
/** Name of the loaded record; shown as an extra breadcrumb before the last one. */
|
|
1162
|
+
readonly recordTitle?: string;
|
|
1148
1163
|
readonly status?: ReactNode;
|
|
1149
1164
|
readonly actions?: ReactNode;
|
|
1150
1165
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.330.2",
|
|
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.330.2",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.330.2",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.330.2",
|
|
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.330.2"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.330.1",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Boolean fields in flow forms use FieldCellWidth \"toggle\" instead of \"auto\"",
|
|
6
|
+
"detail": "In flow forms (screen forms and drawers) a boolean field's label now shares the top line with its neighbours' labels and the switch sits on the input line. The `FieldCellWidth` union loses `\"auto\"` and gains `\"toggle\"`; the default web Grid maps it to a fixed minimum width.",
|
|
7
|
+
"migration": "Custom Grid/GridCell primitives keyed by FieldCellWidth: rename `auto` to `toggle`."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.330.1",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "projectionDetail tab panels get page padding, recordTitleField on projectionDetail",
|
|
13
|
+
"detail": "Tab panels on a projectionDetail with `layout.mode: \"tabs\"` now pad their content like the rest of the page: extension, field-section, groups and writeForm tabs render as an unframed padded panel with space below the tab strip. relatedList tabs stay flush. This fixes extension tabs sitting flush against the shell edge since 0.330.0. `CardOptions.framed` (default true) drops the card frame and keeps the padding. A projectionDetail without `header` can set `recordTitleField`: the query output field then titles the page header (breadcrumb \"list > record\"). The boot check requires the field to be in the query's outputSchema and rejects it next to `header`.",
|
|
14
|
+
"migration": "Extension tab panels get their padding back, so extension components that added their own padding to make up for the flush panel in 0.330.0 should drop it. Set `recordTitleField: \"<field>\"` on a projectionDetail without `header` to show the record name in the breadcrumb."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.330.0",
|
|
18
|
+
"type": "fix",
|
|
19
|
+
"title": "entityEdit screens show the server's reason when a delete is rejected",
|
|
20
|
+
"detail": "The reason (for example from a preDelete hook) now appears in the form-error banner instead of the confirm dialog closing silently. `RenderEdit`'s `onDelete` may return the rejected write's `DispatcherError` for this."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.330.0",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "Screen schema gains createScreen, recordTitleField and configEdit fillHeight; extension tabs render unframed",
|
|
26
|
+
"detail": "`createScreen` on an entityList names the screen opened by the create button. `recordTitleField` on an entityEdit shows a field value as the record title in the header breadcrumb (list > record > screen title). `fillHeight` on a configEdit (default true) switches it to the screen-form layout with a pinned footer; `false` restores the card layout. All three are optional and validated at boot. Extension tabs on a projectionDetail no longer wrap their content in a Card, like relatedList tabs. A `headerSlot` now renders inside the page-header slot instead of replacing the header.",
|
|
27
|
+
"migration": "Additive. Set `createScreen: \"<screen-id>\"` on an entityList and `recordTitleField: \"<field>\"` on an entityEdit to use them. configEdit screens now fill the shell height; set `fillHeight: false` to keep the card layout. Apps with screenshot tests of extension tabs lose the surrounding card frame."
|
|
28
|
+
},
|
|
2
29
|
{
|
|
3
30
|
"version": "0.328.0",
|
|
4
31
|
"type": "breaking",
|