@cosmicdrift/kumiko-renderer 0.390.1 → 0.391.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/projection-detail-header.js +7 -3
- package/dist/app/reference-link.d.ts +4 -0
- package/dist/app/reference-link.js +16 -0
- package/dist/app/use-projection-detail-data.js +19 -4
- package/dist/components/grid-cell-for-field.d.ts +3 -1
- package/dist/components/grid-cell-for-field.js +2 -2
- package/dist/components/query-options-select.d.ts +2 -1
- package/dist/components/query-options-select.js +15 -3
- package/dist/components/render-edit-field-grid.js +3 -1
- package/dist/components/render-edit-layout.js +1 -1
- package/dist/components/render-edit-titles.d.ts +1 -1
- package/dist/components/render-edit-titles.js +3 -6
- package/dist/components/render-field-control.d.ts +1 -0
- package/dist/components/render-field-control.js +2 -2
- package/dist/components/render-field.d.ts +4 -1
- package/dist/components/render-field.js +2 -1
- package/dist/components/use-render-list-columns.d.ts +2 -0
- package/dist/components/use-render-list-columns.js +38 -3
- package/dist/headless/form/form-controller.js +9 -0
- package/dist/headless/form/types.d.ts +1 -0
- package/dist/headless/format/index.d.ts +1 -1
- package/dist/headless/format/index.js +23 -0
- package/dist/metric-types.d.ts +29 -0
- package/dist/metric-types.js +1 -0
- package/dist/primitives.d.ts +2 -20
- package/dist/web/app/dashboard-panels.js +2 -2
- package/dist/web/layout/shell-header.js +2 -2
- package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
- package/dist/web/layout/uniform-bar-controls.js +3 -0
- package/dist/web/primitives/data-table-cell.js +25 -2
- package/dist/web/primitives/data-table.js +1 -1
- package/dist/web/primitives/metric.d.ts +2 -2
- package/dist/web/primitives/metric.js +13 -4
- package/dist/web/widgets/stat.d.ts +1 -0
- package/dist/web/widgets/stat.js +3 -0
- package/package.json +5 -5
- package/src/changes.json +18 -0
|
@@ -50,20 +50,24 @@ export function buildMetricItems(model) {
|
|
|
50
50
|
return screen.metrics?.map((metric) => {
|
|
51
51
|
const field = metric.field;
|
|
52
52
|
const label = effectiveTranslate(metric.label);
|
|
53
|
-
const
|
|
53
|
+
const rawValue = record[field];
|
|
54
|
+
const value = String(rawValue ?? "");
|
|
55
|
+
const tiles = screen.metricsStyle === "tiles";
|
|
56
|
+
// Same rule as the dashboard stat panels: a fixed tone must not paint an all-clear 0.
|
|
57
|
+
const tone = rawValue === 0 ? undefined : metric.tone;
|
|
54
58
|
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
55
59
|
const navigate = metric.navigate;
|
|
56
60
|
const onPress = navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
|
|
57
61
|
? () => runMetricNavigate(nav, navigate, record, host)
|
|
58
62
|
: undefined;
|
|
59
|
-
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));
|
|
63
|
+
return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }), ...(tiles && { tile: true }), ...(tone !== undefined && { tone }), ...(metric.icon !== undefined && { icon: metric.icon }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
|
|
60
64
|
});
|
|
61
65
|
}
|
|
62
66
|
// Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
|
|
63
67
|
export function renderMetricsBlock(model, metricItems, subtitle, lead) {
|
|
64
68
|
const { screen, primitives } = model;
|
|
65
69
|
const { MetricBand, Grid } = primitives;
|
|
66
|
-
return 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 }));
|
|
70
|
+
return MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, ...(lead !== undefined && { lead }), ...(screen.metricsStyle === "tiles" && { tiles: true }), children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
|
|
67
71
|
}
|
|
68
72
|
// The phone shell header clips a long status mid-word, so on phones the badge
|
|
69
73
|
// sits in the body instead of the shell header slot.
|
|
@@ -3,3 +3,7 @@ import type { CellLink } from "../headless/index.ts";
|
|
|
3
3
|
* navigation, the user may not open that screen, or no detail screen exists. */
|
|
4
4
|
export type ReferenceLinkResolver = (refEntity: string, id: string) => CellLink | undefined;
|
|
5
5
|
export declare function useReferenceLinkResolver(): ReferenceLinkResolver;
|
|
6
|
+
/** Link to a screen named by its QN, or undefined when there is no navigation
|
|
7
|
+
* or the user may not open that screen. */
|
|
8
|
+
export type ScreenLinkResolver = (screenQn: string) => CellLink | undefined;
|
|
9
|
+
export declare function useScreenLinkResolver(): ScreenLinkResolver;
|
|
@@ -2,6 +2,7 @@ import { useCallback } from "react";
|
|
|
2
2
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
3
3
|
import { useAppFeatures } from "./app-features-context.js";
|
|
4
4
|
import { useOptionalNav } from "./nav.js";
|
|
5
|
+
import { lastSegment } from "./qn.js";
|
|
5
6
|
import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
|
|
6
7
|
import { navigateTargetAllows } from "./screen-access.js";
|
|
7
8
|
export function useReferenceLinkResolver() {
|
|
@@ -30,3 +31,18 @@ export function useReferenceLinkResolver() {
|
|
|
30
31
|
return { href, onPress: () => navigateWithReturnTo(nav, target, host) };
|
|
31
32
|
}, [nav, appFeatures, userRoles, host]);
|
|
32
33
|
}
|
|
34
|
+
export function useScreenLinkResolver() {
|
|
35
|
+
const nav = useOptionalNav();
|
|
36
|
+
const appFeatures = useAppFeatures();
|
|
37
|
+
const userRoles = useUserRoles();
|
|
38
|
+
const host = useReturnHost();
|
|
39
|
+
return useCallback((screenQn) => {
|
|
40
|
+
const screenId = lastSegment(screenQn);
|
|
41
|
+
if (nav === undefined || screenId === "")
|
|
42
|
+
return undefined;
|
|
43
|
+
if (!navigateTargetAllows({ screen: screenId }, appFeatures, userRoles))
|
|
44
|
+
return undefined;
|
|
45
|
+
const target = { screenId };
|
|
46
|
+
return { href: nav.hrefFor(target), onPress: () => navigateWithReturnTo(nav, target, host) };
|
|
47
|
+
}, [nav, appFeatures, userRoles, host]);
|
|
48
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { rendererWarn } from "../dev-console.js";
|
|
3
3
|
import { useQuery } from "../hooks/use-query.js";
|
|
4
4
|
import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
|
|
@@ -29,7 +29,8 @@ export function useProjectionDetailData(input) {
|
|
|
29
29
|
: screen;
|
|
30
30
|
return synthesizeProjectionDetailScreen(source);
|
|
31
31
|
}, [screen, activeSection]);
|
|
32
|
-
const
|
|
32
|
+
const live = screen.live === true;
|
|
33
|
+
const detailQuery = useQuery(screen.query, effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {}, { live });
|
|
33
34
|
// A writeForm section's handler creates a new record (see EditWriteFormSection's
|
|
34
35
|
// doc) — both the outer record (header/metrics/other sections) and a sibling
|
|
35
36
|
// relatedList section's own independently-fetched rows must reflect it. A
|
|
@@ -39,9 +40,23 @@ export function useProjectionDetailData(input) {
|
|
|
39
40
|
// remount (a `key`) so unsaved input in the other writeForm sections and
|
|
40
41
|
// the related lists' local state survive.
|
|
41
42
|
const [reloadNonce, setReloadNonce] = useState(0);
|
|
43
|
+
// Live mode: every refetched record bumps the nonce below, a write included.
|
|
42
44
|
const reloadDetail = useCallback(async () => {
|
|
43
45
|
await refetchAfterWrite(detailQuery.refetch);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
if (!live)
|
|
47
|
+
setReloadNonce((n) => n + 1);
|
|
48
|
+
}, [detailQuery.refetch, live]);
|
|
49
|
+
// A live refetch only reaches the record; the related lists hold their own rows.
|
|
50
|
+
const liveRecord = live ? (detailQuery.data ?? undefined) : undefined;
|
|
51
|
+
const shownLiveRecord = useRef(liveRecord);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const previous = shownLiveRecord.current;
|
|
54
|
+
// skip: the shown record is unchanged, the related lists need no reload.
|
|
55
|
+
if (previous === liveRecord)
|
|
56
|
+
return;
|
|
57
|
+
shownLiveRecord.current = liveRecord;
|
|
58
|
+
if (previous !== undefined)
|
|
59
|
+
setReloadNonce((n) => n + 1);
|
|
60
|
+
}, [liveRecord]);
|
|
46
61
|
return { activeSection, hasTabs, entity, detailScreen, detailQuery, reloadNonce, reloadDetail };
|
|
47
62
|
}
|
|
@@ -6,6 +6,8 @@ export type GridCellForFieldProps = {
|
|
|
6
6
|
readonly columns: number;
|
|
7
7
|
readonly issues: readonly FieldIssue[] | undefined;
|
|
8
8
|
readonly onChange: (value: unknown) => void;
|
|
9
|
+
/** Passed through to RenderField — see RenderFieldProps.onSuggest. */
|
|
10
|
+
readonly onSuggest?: (value: string) => void;
|
|
9
11
|
readonly GridCell: ReturnType<typeof usePrimitives>["GridCell"];
|
|
10
12
|
/** Passed through so Reference fields can build the correct
|
|
11
13
|
* lookup query QN (`<feature>:query:<refEntity>:list`). */
|
|
@@ -33,4 +35,4 @@ export type GridCellForFieldProps = {
|
|
|
33
35
|
readonly sectionTitle?: string;
|
|
34
36
|
};
|
|
35
37
|
export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
|
|
36
|
-
export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }: GridCellForFieldProps): ReactNode;
|
|
38
|
+
export declare function GridCellForField({ field, columns, issues, onChange, onSuggest, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }: GridCellForFieldProps): ReactNode;
|
|
@@ -29,7 +29,7 @@ export function fieldCellWidth(field) {
|
|
|
29
29
|
return "full";
|
|
30
30
|
return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
|
|
31
31
|
}
|
|
32
|
-
export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }) {
|
|
32
|
+
export function GridCellForField({ field, columns, issues, onChange, onSuggest, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }) {
|
|
33
33
|
// RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
|
|
34
34
|
if (!field.visible)
|
|
35
35
|
return null;
|
|
@@ -38,5 +38,5 @@ export function GridCellForField({ field, columns, issues, onChange, GridCell, f
|
|
|
38
38
|
field.embeddedListCells !== undefined &&
|
|
39
39
|
field.label === sectionTitle;
|
|
40
40
|
const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
|
|
41
|
-
return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }), ...(hideLabel && { hideLabel }) }) }));
|
|
41
|
+
return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, ...(onSuggest !== undefined && { onSuggest }), featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }), ...(hideLabel && { hideLabel }) }) }));
|
|
42
42
|
}
|
|
@@ -11,8 +11,9 @@ type QueryOptionsSelectProps = {
|
|
|
11
11
|
readonly id: string;
|
|
12
12
|
readonly hasError: boolean;
|
|
13
13
|
readonly onChange: (value: unknown) => void;
|
|
14
|
+
readonly onSuggest?: (value: string) => void;
|
|
14
15
|
};
|
|
15
|
-
export declare function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
|
|
16
|
+
export declare function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, onSuggest, }: QueryOptionsSelectProps): ReactNode;
|
|
16
17
|
export declare function QueryOptionLabel({ query, payload, value, }: {
|
|
17
18
|
readonly query: string;
|
|
18
19
|
readonly payload: ResolvedOptionsQueryPayload;
|
|
@@ -22,7 +22,7 @@ export function resolveOptionsQueryPayload(payload, row) {
|
|
|
22
22
|
// Select whose options come from a query (SelectFieldDef.optionsQuery), so it
|
|
23
23
|
// has to be a mounted component for useQuery. useQuery re-fetches on the
|
|
24
24
|
// serialized payload, hence no payload memoization here.
|
|
25
|
-
export function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }) {
|
|
25
|
+
export function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, onSuggest, }) {
|
|
26
26
|
const { Input } = usePrimitives();
|
|
27
27
|
const resolvedPayload = resolveOptionsQueryPayload(payload, row);
|
|
28
28
|
const payloadKey = JSON.stringify(resolvedPayload);
|
|
@@ -41,8 +41,20 @@ export function QueryOptionsSelect({ field, query, payload, row, id, hasError, o
|
|
|
41
41
|
const previousKey = settledPayloadKey.current;
|
|
42
42
|
settledPayloadKey.current = payloadKey;
|
|
43
43
|
const payloadChanged = previousKey !== null && previousKey !== payloadKey;
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const discarded = payloadChanged && current !== "" && !data.rows.some((row) => row.value === current);
|
|
45
|
+
const suggestion = onSuggest === undefined ? undefined : data.rows.find((row) => row.default === true);
|
|
46
|
+
if (payloadChanged) {
|
|
47
|
+
// The user's sibling edit caused this load, so the form is dirty anyway: a
|
|
48
|
+
// discarded value is replaced by the suggestion in one change.
|
|
49
|
+
if (current !== "" && !discarded)
|
|
50
|
+
return;
|
|
51
|
+
if (suggestion)
|
|
52
|
+
onChange(suggestion.value);
|
|
53
|
+
else if (discarded)
|
|
54
|
+
onChange("");
|
|
55
|
+
}
|
|
56
|
+
else if (current === "" && suggestion) {
|
|
57
|
+
onSuggest?.(suggestion.value);
|
|
46
58
|
}
|
|
47
59
|
}, [data]);
|
|
48
60
|
// A stored value missing from the result (still loading, or dropped from the
|
|
@@ -28,7 +28,9 @@ function createFieldCellRenderer(model, section, columns) {
|
|
|
28
28
|
}), issues: snapshot.errors[field.field], onChange: (v) => {
|
|
29
29
|
model.setFieldUntyped(field.field, v);
|
|
30
30
|
clearErrorsAndValidate(field.field);
|
|
31
|
-
},
|
|
31
|
+
}, ...(props.actionFormScreenQn !== undefined && {
|
|
32
|
+
onSuggest: (value) => form.controller.seedField(field.field, value),
|
|
33
|
+
}), GridCell: GridCell, featureName: props.featureName, ...appendixCellProps(props, field.field), allIssues: snapshot.errors, valueDisplay: props.valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(props.hideSectionTitles !== true &&
|
|
32
34
|
section.title !== undefined && { sectionTitle: section.title }), ...(showsChangedMarker && field.field in snapshot.changes && { changed: true }) }, field.field));
|
|
33
35
|
}
|
|
34
36
|
export function renderFieldGrid(model, section, fields, columns, gridKey, columnsDeclared) {
|
|
@@ -41,7 +41,7 @@ function buildFormHeadingProps(model) {
|
|
|
41
41
|
...(props.hideSectionTitles !== true &&
|
|
42
42
|
!layout.titleInShell &&
|
|
43
43
|
!titles.titleInSettingsHeader &&
|
|
44
|
-
!titles.
|
|
44
|
+
!titles.titleInHeaderRegion && { title: titles.formTitle }),
|
|
45
45
|
...(titles.formSubtitle !== undefined &&
|
|
46
46
|
!titles.stepRail &&
|
|
47
47
|
!titles.titleInSettingsHeader && { subtitle: titles.formSubtitle }),
|
|
@@ -8,7 +8,7 @@ export type TitleModel = {
|
|
|
8
8
|
readonly formSubtitle: string | undefined;
|
|
9
9
|
readonly titleInSettingsHeader: boolean;
|
|
10
10
|
readonly usesFieldFlow: boolean;
|
|
11
|
-
readonly
|
|
11
|
+
readonly titleInHeaderRegion: boolean;
|
|
12
12
|
readonly stepRail: boolean;
|
|
13
13
|
readonly upNextSection: EditSectionViewModel | undefined;
|
|
14
14
|
readonly sectionNavItems: readonly FormSectionNavItem[];
|
|
@@ -83,12 +83,9 @@ export function deriveTitles(engine, layout, footer) {
|
|
|
83
83
|
// floating as a heading above the band.
|
|
84
84
|
titleInSettingsHeader: footer.isSettingsList && !layout.titleInShell,
|
|
85
85
|
usesFieldFlow: layout.isScreenForm || state.insideDrawer,
|
|
86
|
-
// The caller's headerRegion
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
recordTitle !== undefined &&
|
|
90
|
-
PageHeader !== undefined &&
|
|
91
|
-
state.pageHeaderSlotAvailable,
|
|
86
|
+
// The caller's headerRegion fills the shell page header with the record title
|
|
87
|
+
// or, without one, the screen title, so a form title above the sections repeats it.
|
|
88
|
+
titleInHeaderRegion: headerRegion !== undefined && PageHeader !== undefined && state.pageHeaderSlotAvailable,
|
|
92
89
|
stepRail: state.isWizard && layout.isScreenForm && StepBar !== undefined,
|
|
93
90
|
upNextSection: screen.layout.wizard?.aside?.upNext === true ? filteredSections[currentStep + 1] : undefined,
|
|
94
91
|
sectionNavItems: layout.isScreenForm && !state.isStepped
|
|
@@ -7,6 +7,7 @@ export type ControlContext = RenderInputArgs & {
|
|
|
7
7
|
readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>> | undefined;
|
|
8
8
|
readonly changed: boolean | undefined;
|
|
9
9
|
readonly fillDisabled: boolean | undefined;
|
|
10
|
+
readonly onSuggest?: (value: string) => void;
|
|
10
11
|
};
|
|
11
12
|
export declare function listPrintsOwnIssues(field: EditFieldViewModel): boolean;
|
|
12
13
|
export declare function renderControl(ctx: ControlContext): ReactNode;
|
|
@@ -42,11 +42,11 @@ const referenceRule = ({ field, id, hasError, onChange, Input, featureName, read
|
|
|
42
42
|
return (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }));
|
|
43
43
|
};
|
|
44
44
|
const selectQueryRule = (ctx) => {
|
|
45
|
-
const { field, id, hasError, onChange, readOnlyText } = ctx;
|
|
45
|
+
const { field, id, hasError, onChange, onSuggest, readOnlyText } = ctx;
|
|
46
46
|
const source = field.selectOptionsQuery;
|
|
47
47
|
if (field.type !== "select" || source === undefined || readOnlyText)
|
|
48
48
|
return undefined;
|
|
49
|
-
return (_jsx(QueryOptionsSelect, { field: field, query: source.query, payload: source.payload, row: rowOf(ctx), id: id, hasError: hasError, onChange: onChange }));
|
|
49
|
+
return (_jsx(QueryOptionsSelect, { field: field, query: source.query, payload: source.payload, row: rowOf(ctx), id: id, hasError: hasError, onChange: onChange, ...(onSuggest !== undefined && { onSuggest }) }));
|
|
50
50
|
};
|
|
51
51
|
const selectAvailabilityRule = (ctx) => {
|
|
52
52
|
const { field, id, hasError, onChange, readOnlyText } = ctx;
|
|
@@ -5,6 +5,9 @@ export type RenderFieldProps = {
|
|
|
5
5
|
readonly field: EditFieldViewModel;
|
|
6
6
|
readonly issues?: readonly FieldIssue[];
|
|
7
7
|
readonly onChange: (val: unknown) => void;
|
|
8
|
+
/** Seeds an empty optionsQuery select with the query's default row as a baseline
|
|
9
|
+
* value (not a change). Only set where that is wanted (actionForm). */
|
|
10
|
+
readonly onSuggest?: (value: string) => void;
|
|
8
11
|
/** Relevant only for type:"reference": the feature name for the lookup
|
|
9
12
|
* query QN (`<feature>:query:<refEntity>:list`). Other field types
|
|
10
13
|
* ignore this prop. */
|
|
@@ -46,4 +49,4 @@ export type RenderFieldProps = {
|
|
|
46
49
|
readonly hideLabel?: boolean;
|
|
47
50
|
};
|
|
48
51
|
export declare const AllFieldsRequiredProvider: import("react").Provider<boolean>;
|
|
49
|
-
export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, hideLabel, }: RenderFieldProps): ReactNode;
|
|
52
|
+
export declare function RenderField({ field, issues, onChange, onSuggest, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, hideLabel, }: RenderFieldProps): ReactNode;
|
|
@@ -20,7 +20,7 @@ function fieldOptionalProps({ issues, printsIssues, labelAppendix, fieldAppendix
|
|
|
20
20
|
...(hideLabel === true && { hideLabel }),
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, hideLabel, }) {
|
|
23
|
+
export function RenderField({ field, issues, onChange, onSuggest, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, hideLabel, }) {
|
|
24
24
|
const { Field, Input, Banner, Text, JsonView } = usePrimitives();
|
|
25
25
|
// The app locale is required for money/date inputs; otherwise they fall back
|
|
26
26
|
// to navigator.language instead of the selected app language. RenderField is
|
|
@@ -43,6 +43,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
43
43
|
id,
|
|
44
44
|
hasError: issues !== undefined && issues.length > 0,
|
|
45
45
|
onChange,
|
|
46
|
+
...(onSuggest !== undefined && { onSuggest }),
|
|
46
47
|
Input,
|
|
47
48
|
appLocale,
|
|
48
49
|
Banner,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { EntityDefinition, EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { type ScreenLinkResolver } from "../app/reference-link.ts";
|
|
2
3
|
import type { ListColumnViewModel, ListViewModel, Translate } from "../headless/index.ts";
|
|
3
4
|
import { type PublishedReferenceLookup } from "./render-list-reference-columns.ts";
|
|
5
|
+
export declare function withScreenLinks(columns: readonly ListColumnViewModel[], screen: EntityListScreenDefinition, resolveScreenLink: ScreenLinkResolver): readonly ListColumnViewModel[];
|
|
4
6
|
type RenderListColumnsInput = {
|
|
5
7
|
readonly screen: EntityListScreenDefinition;
|
|
6
8
|
readonly entity: Pick<EntityDefinition, "fields" | "derivedFields">;
|
|
@@ -1,7 +1,32 @@
|
|
|
1
|
+
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
2
|
import { useCallback, useMemo, useState } from "react";
|
|
2
|
-
import { useReferenceLinkResolver } from "../app/reference-link.js";
|
|
3
|
+
import { useReferenceLinkResolver, useScreenLinkResolver, } from "../app/reference-link.js";
|
|
3
4
|
import { computeListViewModel } from "../headless/index.js";
|
|
4
5
|
import { enrichReferenceColumns, selectReferenceColumns, } from "./render-list-reference-columns.js";
|
|
6
|
+
// A column whose rows name their own target screen links each cell there.
|
|
7
|
+
export function withScreenLinks(columns, screen, resolveScreenLink) {
|
|
8
|
+
const linkFields = new Map();
|
|
9
|
+
for (const spec of screen.columns) {
|
|
10
|
+
const normalized = normalizeListColumn(spec);
|
|
11
|
+
if (normalized.linkScreenField !== undefined) {
|
|
12
|
+
linkFields.set(normalized.field, normalized.linkScreenField);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (linkFields.size === 0)
|
|
16
|
+
return columns;
|
|
17
|
+
return columns.map((col) => {
|
|
18
|
+
const linkScreenField = linkFields.get(col.field);
|
|
19
|
+
if (linkScreenField === undefined)
|
|
20
|
+
return col;
|
|
21
|
+
return {
|
|
22
|
+
...col,
|
|
23
|
+
linkFor: (_value, row) => {
|
|
24
|
+
const screenQn = row?.[linkScreenField];
|
|
25
|
+
return typeof screenQn === "string" ? resolveScreenLink(screenQn) : undefined;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
}
|
|
5
30
|
export function useRenderListColumns(input) {
|
|
6
31
|
const { screen, entity, rows, featureName, translate } = input;
|
|
7
32
|
const vm = useMemo(() => computeListViewModel({ screen, entity, rows, translate, featureName }), [screen, entity, rows, translate, featureName]);
|
|
@@ -25,14 +50,24 @@ export function useRenderListColumns(input) {
|
|
|
25
50
|
});
|
|
26
51
|
}, []);
|
|
27
52
|
const resolveReferenceLink = useReferenceLinkResolver();
|
|
28
|
-
const
|
|
53
|
+
const resolveScreenLink = useScreenLinkResolver();
|
|
54
|
+
const enrichedColumns = useMemo(() => withScreenLinks(enrichReferenceColumns({
|
|
29
55
|
columns: vm.columns,
|
|
30
56
|
referenceColumns,
|
|
31
57
|
referenceLookups,
|
|
32
58
|
resolveReferenceLink,
|
|
33
59
|
featureName,
|
|
34
60
|
translate,
|
|
35
|
-
}),
|
|
61
|
+
}), screen, resolveScreenLink), [
|
|
62
|
+
vm.columns,
|
|
63
|
+
referenceColumns,
|
|
64
|
+
referenceLookups,
|
|
65
|
+
resolveReferenceLink,
|
|
66
|
+
resolveScreenLink,
|
|
67
|
+
screen,
|
|
68
|
+
featureName,
|
|
69
|
+
translate,
|
|
70
|
+
]);
|
|
36
71
|
const enrichedVm = useMemo(() => ({
|
|
37
72
|
...vm,
|
|
38
73
|
columns: enrichedColumns,
|
|
@@ -224,6 +224,14 @@ export function createFormController(options) {
|
|
|
224
224
|
};
|
|
225
225
|
invalidate();
|
|
226
226
|
}
|
|
227
|
+
function runSeedField(field, value) {
|
|
228
|
+
// skip: touched field keeps the user's input, a seed must not overwrite it
|
|
229
|
+
if (!Object.is(values[field], initial[field]))
|
|
230
|
+
return;
|
|
231
|
+
initial = { ...initial, [field]: value };
|
|
232
|
+
values = { ...values, [field]: value };
|
|
233
|
+
invalidate();
|
|
234
|
+
}
|
|
227
235
|
// Stale-submit-safe rebase. Difference to runRebase: the baseline is
|
|
228
236
|
// taken from the values that were actually SENT to the server, not
|
|
229
237
|
// from `values` at the moment the server replied. If the user typed
|
|
@@ -304,6 +312,7 @@ export function createFormController(options) {
|
|
|
304
312
|
},
|
|
305
313
|
rebase: runRebase,
|
|
306
314
|
rebaseFields: runRebaseFields,
|
|
315
|
+
seedField: runSeedField,
|
|
307
316
|
setCtx(nextCtx) {
|
|
308
317
|
// Cast back to TCtx: setCtx's public signature takes unknown so
|
|
309
318
|
// callers don't need to plumb generics. If conditions depend on ctx
|
|
@@ -34,6 +34,7 @@ export type FormController<TValues extends FormValues> = {
|
|
|
34
34
|
reset(): void;
|
|
35
35
|
rebase(): void;
|
|
36
36
|
rebaseFields(fields: readonly string[]): void;
|
|
37
|
+
seedField(field: string, value: unknown): void;
|
|
37
38
|
setCtx(ctx: unknown): void;
|
|
38
39
|
submit<TData = unknown>(): Promise<SubmitResult<TData>>;
|
|
39
40
|
};
|
|
@@ -9,4 +9,4 @@ export { type HtmlValue, html, RawHtml, raw } from "./html-template.ts";
|
|
|
9
9
|
export { currencyDecimals, formatMoney, resolveSafeMoneyLocale } from "./money.ts";
|
|
10
10
|
export declare function applyFormatSpec(spec: {
|
|
11
11
|
format: string;
|
|
12
|
-
} & Record<string, unknown>, value: unknown, translate?: (key: string) => string): string;
|
|
12
|
+
} & Record<string, unknown>, value: unknown, translate?: (key: string, params?: Readonly<Record<string, string | number>>) => string): string;
|
|
@@ -246,6 +246,28 @@ function formatEnumOptionCell(spec, value, translate) {
|
|
|
246
246
|
// multiSelect-style columns hold an array of option values
|
|
247
247
|
return Array.isArray(value) ? value.map(labelFor).join(", ") : labelFor(value);
|
|
248
248
|
}
|
|
249
|
+
function isRecordValue(value) {
|
|
250
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
251
|
+
}
|
|
252
|
+
// Only string and number params: money/date params need the dashboard formats.
|
|
253
|
+
function textParams(params) {
|
|
254
|
+
if (!isRecordValue(params))
|
|
255
|
+
return undefined;
|
|
256
|
+
const out = {};
|
|
257
|
+
for (const [name, param] of Object.entries(params)) {
|
|
258
|
+
if (typeof param === "string" || typeof param === "number")
|
|
259
|
+
out[name] = param;
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
function formatI18nTextCell(_spec, value, translate) {
|
|
264
|
+
if (typeof value === "string")
|
|
265
|
+
return translate === undefined ? value : translate(value);
|
|
266
|
+
if (!isRecordValue(value) || typeof value["i18nKey"] !== "string")
|
|
267
|
+
return String(value);
|
|
268
|
+
const key = value["i18nKey"];
|
|
269
|
+
return translate === undefined ? key : translate(key, textParams(value["i18nParams"]));
|
|
270
|
+
}
|
|
249
271
|
// Thumbnails and file links are drawn by the web list cell; this is the
|
|
250
272
|
// text fallback for every other surface.
|
|
251
273
|
function formatFileCell(_spec, value) {
|
|
@@ -265,6 +287,7 @@ const CELL_FORMATTERS = {
|
|
|
265
287
|
unit: formatUnitSpecCell,
|
|
266
288
|
priority: formatPriorityCell,
|
|
267
289
|
enumOption: formatEnumOptionCell,
|
|
290
|
+
i18nText: formatI18nTextCell,
|
|
268
291
|
image: formatFileCell,
|
|
269
292
|
file: formatFileCell,
|
|
270
293
|
json: formatJsonCell,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { DashboardStatTone } from "@cosmicdrift/kumiko-types/screen";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
/** Compact label/value tile (record-detail metrics band). `testId` is the
|
|
5
|
+
* tile's own id — the impl derives `${testId}-label`/`${testId}-value` for
|
|
6
|
+
* the two rendered nodes, so a caller only ever needs to know the base id. */
|
|
7
|
+
export type MetricProps = {
|
|
8
|
+
readonly label: string;
|
|
9
|
+
readonly value: string;
|
|
10
|
+
readonly testId?: string;
|
|
11
|
+
/** Click handler — set when the metric declares a `navigate` target
|
|
12
|
+
* (see `MetricSpec`). Omitted for a plain, non-interactive metric. */
|
|
13
|
+
readonly onPress?: () => void;
|
|
14
|
+
/** Draw as a KPI tile (the dashboard StatCard) instead of a band entry. */
|
|
15
|
+
readonly tile?: boolean;
|
|
16
|
+
readonly tone?: DashboardStatTone;
|
|
17
|
+
readonly icon?: NavIconKey;
|
|
18
|
+
};
|
|
19
|
+
/** Band above the tabs of a record detail: optional subtitle line plus the
|
|
20
|
+
* metrics as a definition list. `children` are `Metric` tiles. */
|
|
21
|
+
export type MetricBandProps = {
|
|
22
|
+
/** Free-form block rendered unstyled as the first full-width row of the band. */
|
|
23
|
+
readonly lead?: ReactNode;
|
|
24
|
+
readonly subtitle?: ReactNode;
|
|
25
|
+
readonly children: ReactNode;
|
|
26
|
+
/** The children are KPI tiles, laid out as a tile grid instead of a definition list. */
|
|
27
|
+
readonly tiles?: boolean;
|
|
28
|
+
readonly testId?: string;
|
|
29
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/primitives.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { DataTableBulk } from "./data-table-bulk-types.ts";
|
|
|
5
5
|
import type { DataTableDateRangeFacet, DataTableFacet } from "./data-table-facet-types.ts";
|
|
6
6
|
import type { DataTableTableBlock } from "./data-table-view-types.ts";
|
|
7
7
|
import type { FieldIssue, ListColumnViewModel, ListRowViewModel, RuntimeRenderer } from "./headless/index.ts";
|
|
8
|
+
import type { MetricBandProps, MetricProps } from "./metric-types.ts";
|
|
8
9
|
export type { RuntimeRenderer };
|
|
9
10
|
/** Escape hatch for `data-*` test hooks (e.g. an E2E selector) that don't
|
|
10
11
|
* warrant a dedicated typed prop. Web merges these onto the rendered
|
|
@@ -652,6 +653,7 @@ export declare function shouldRenderActionsIconOnly(actions: readonly {
|
|
|
652
653
|
}[]): boolean;
|
|
653
654
|
export type { BulkRowOutcome, DataTableBulk, DataTableBulkAction, } from "./data-table-bulk-types.ts";
|
|
654
655
|
export type { DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, } from "./data-table-facet-types.ts";
|
|
656
|
+
export type { MetricBandProps, MetricProps } from "./metric-types.ts";
|
|
655
657
|
export type DataTableProps = {
|
|
656
658
|
readonly columns: readonly ListColumnViewModel[];
|
|
657
659
|
readonly rows: readonly ListRowViewModel[];
|
|
@@ -1340,17 +1342,6 @@ export type StatusBadgeProps = {
|
|
|
1340
1342
|
};
|
|
1341
1343
|
export declare function statusToneForOptionTone(tone: SelectOptionTone): StatusTone | undefined;
|
|
1342
1344
|
export declare function statusToneForValue(value: string): StatusTone | undefined;
|
|
1343
|
-
/** Compact label/value tile (record-detail metrics band). `testId` is the
|
|
1344
|
-
* tile's own id — the impl derives `${testId}-label`/`${testId}-value` for
|
|
1345
|
-
* the two rendered nodes, so a caller only ever needs to know the base id. */
|
|
1346
|
-
export type MetricProps = {
|
|
1347
|
-
readonly label: string;
|
|
1348
|
-
readonly value: string;
|
|
1349
|
-
readonly testId?: string;
|
|
1350
|
-
/** Click handler — set when the metric declares a `navigate` target
|
|
1351
|
-
* (see `MetricSpec`). Omitted for a plain, non-interactive metric. */
|
|
1352
|
-
readonly onPress?: () => void;
|
|
1353
|
-
};
|
|
1354
1345
|
/** Structured JSON display (audit payload/metadata, job logs, unsupported
|
|
1355
1346
|
* jsonb/embedded field fallback). Takes the raw value, not a pre-stringified
|
|
1356
1347
|
* one — the implementation owns `JSON.stringify` (and must not throw on
|
|
@@ -1369,15 +1360,6 @@ export type FileValueListProps = {
|
|
|
1369
1360
|
readonly previewOpenByDefault?: boolean;
|
|
1370
1361
|
readonly testId?: string;
|
|
1371
1362
|
};
|
|
1372
|
-
/** Band above the tabs of a record detail: optional subtitle line plus the
|
|
1373
|
-
* metrics as a definition list. `children` are `Metric` tiles. */
|
|
1374
|
-
export type MetricBandProps = {
|
|
1375
|
-
/** Free-form block rendered unstyled as the first full-width row of the band. */
|
|
1376
|
-
readonly lead?: ReactNode;
|
|
1377
|
-
readonly subtitle?: ReactNode;
|
|
1378
|
-
readonly children: ReactNode;
|
|
1379
|
-
readonly testId?: string;
|
|
1380
|
-
};
|
|
1381
1363
|
/** Page title area rendered by the app shell's header instead of inside the
|
|
1382
1364
|
* screen: `title` replaces the last breadcrumb, `status` sits right after it,
|
|
1383
1365
|
* `actions` at the right edge. Renders nothing itself; without a shell slot
|
|
@@ -13,7 +13,7 @@ import { FeedList } from "../widgets/feed-list.js";
|
|
|
13
13
|
import { ModeSwitch } from "../widgets/mode-switch.js";
|
|
14
14
|
import { ProgressList } from "../widgets/progress-list.js";
|
|
15
15
|
import { SectionCard } from "../widgets/section-card.js";
|
|
16
|
-
import { StatCard, StatStripCell } from "../widgets/stat.js";
|
|
16
|
+
import { STAT_CARD_GRID_CLASSES, StatCard, StatStripCell, } from "../widgets/stat.js";
|
|
17
17
|
import { EmptyState, LoadingState } from "../widgets/states.js";
|
|
18
18
|
import { DAY_MS, HOUR_MS, PanelError, PanelShell, panelPayload, QueryPanel, useDashboardFormats, useDashboardQuery, useReportPanelLoaded, } from "./dashboard-shared.js";
|
|
19
19
|
import { isRecord, resolveDashboardText } from "./dashboard-text.js";
|
|
@@ -107,7 +107,7 @@ export function StatGroupPanelBody({ panel, label, screenId, screenParams, trans
|
|
|
107
107
|
if (label === undefined) {
|
|
108
108
|
return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-2 gap-y-2 rounded-lg border border-border bg-card py-1 lg:auto-cols-fr lg:grid-flow-col lg:divide-x lg:divide-border-row", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "strip" }, stat.id))) }));
|
|
109
109
|
}
|
|
110
|
-
return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className:
|
|
110
|
+
return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className: STAT_CARD_GRID_CLASSES, children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
|
|
111
111
|
}
|
|
112
112
|
function markerKindColor(kind) {
|
|
113
113
|
if (kind === undefined)
|
|
@@ -19,7 +19,7 @@ import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header
|
|
|
19
19
|
import { buildNavRegistrySliceForApp, lastSegment } from "./nav-tree.js";
|
|
20
20
|
import { usePageHeaderSlot } from "./page-header-slot.js";
|
|
21
21
|
import { resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
|
|
22
|
-
import {
|
|
22
|
+
import { SHELL_HEADER_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
|
|
23
23
|
export function ShellHeader({ schema, user, headerActions, }) {
|
|
24
24
|
const nav = useNav();
|
|
25
25
|
const t = useTranslation();
|
|
@@ -69,7 +69,7 @@ export function ShellHeader({ schema, user, headerActions, }) {
|
|
|
69
69
|
e.preventDefault();
|
|
70
70
|
nav.navigate({ screenId });
|
|
71
71
|
}, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
|
|
72
|
-
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(
|
|
72
|
+
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
73
73
|
}
|
|
74
74
|
// A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
|
|
75
75
|
// nodes with their own popovers, and the panel stays mounted while closed so their
|
|
@@ -2,3 +2,6 @@
|
|
|
2
2
|
// primitive asked for: the descendant selector outranks the button's own
|
|
3
3
|
// size utilities. Icon-only buttons keep their square size.
|
|
4
4
|
export const UNIFORM_BAR_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-9 [&_[data-slot=input]]:h-9";
|
|
5
|
+
// The shell header's own controls (workspace and tenant switcher, language, theme) are h-8,
|
|
6
|
+
// so screen actions portalled next to them get h-8 too instead of the toolbar's h-9.
|
|
7
|
+
export const SHELL_HEADER_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-8 [&_[data-slot=input]]:h-8";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
|
|
2
3
|
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
3
4
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
5
|
import { Check } from "lucide-react";
|
|
@@ -217,6 +218,22 @@ function selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBa
|
|
|
217
218
|
// dashboard-01 pattern: outline badge + muted instead of a filled secondary.
|
|
218
219
|
return (_jsx(Badge, { variant: "outline", className: "px-1.5 text-muted-foreground", children: label }));
|
|
219
220
|
}
|
|
221
|
+
function isSelectOptionTone(value) {
|
|
222
|
+
return SELECT_OPTION_TONES.some((tone) => tone === value);
|
|
223
|
+
}
|
|
224
|
+
// enumOption's `tones` arrive with the untyped spec, so each entry is checked here.
|
|
225
|
+
function enumOptionTones(spec) {
|
|
226
|
+
const tones = spec["tones"];
|
|
227
|
+
if (spec["format"] !== "enumOption" || typeof tones !== "object" || tones === null) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
const out = {};
|
|
231
|
+
for (const [option, tone] of Object.entries(tones)) {
|
|
232
|
+
if (isSelectOptionTone(tone))
|
|
233
|
+
out[option] = tone;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
220
237
|
// A multi reference may list the same id twice; the occurrence count keeps React keys unique.
|
|
221
238
|
function uniqueLinkItemKeys(items) {
|
|
222
239
|
const seen = new Map();
|
|
@@ -303,7 +320,12 @@ export function DataTableCell({ value, row, field, type, renderer, optionLabels,
|
|
|
303
320
|
}
|
|
304
321
|
if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
|
|
305
322
|
const spec = renderer;
|
|
306
|
-
|
|
323
|
+
const text = applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
|
|
324
|
+
const tones = enumOptionTones(spec);
|
|
325
|
+
if (tones !== undefined && typeof value === "string") {
|
|
326
|
+
return selectPill(value, { [value]: text }, tones, locale, RegisteredStatusBadge);
|
|
327
|
+
}
|
|
328
|
+
return link === undefined ? text : _jsx(CellTextLink, { link: link, children: text });
|
|
307
329
|
}
|
|
308
330
|
if (typeof renderer === "function") {
|
|
309
331
|
if (linkItems !== undefined) {
|
|
@@ -342,5 +364,6 @@ export function DataTableCell({ value, row, field, type, renderer, optionLabels,
|
|
|
342
364
|
if (type === "multiSelect" && Array.isArray(value) && value.length > 0) {
|
|
343
365
|
return (_jsx("span", { className: "flex flex-wrap gap-1", children: value.map((entry) => (_jsx(Fragment, { children: selectPill(entry, optionLabels, optionTones, locale, RegisteredStatusBadge) }, String(entry)))) }));
|
|
344
366
|
}
|
|
345
|
-
|
|
367
|
+
const text = defaultCellRender(value, type, optionLabels, locale, field, grouping);
|
|
368
|
+
return link === undefined ? text : _jsx(CellTextLink, { link: link, children: text });
|
|
346
369
|
}
|
|
@@ -33,5 +33,5 @@ export function DefaultDataTable(props) {
|
|
|
33
33
|
// (that lives in the shell's breadcrumb). The toolbar is NOT sticky: lists
|
|
34
34
|
// typically scroll with the page container, not internally. Sticky would
|
|
35
35
|
// compete with the topbar.
|
|
36
|
-
return (_jsxs("div", { className: wrapperClassName(model), children: [hasToolbar && renderToolbar(model, facetCluster), renderToolbarDescription(model, hasToolbar), renderTableContent(model)] }));
|
|
36
|
+
return (_jsxs("div", { className: wrapperClassName(model), children: [!model.fillsHeight && renderToolbarDescription(model, hasToolbar), hasToolbar && renderToolbar(model, facetCluster), model.fillsHeight && renderToolbarDescription(model, hasToolbar), renderTableContent(model)] }));
|
|
37
37
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import type { MetricBandProps, MetricProps } from "../../index.ts";
|
|
3
3
|
export declare const InsideScreenFormColumnContext: import("react").Context<boolean>;
|
|
4
|
-
export declare function DefaultMetricBand({ lead, subtitle, children, testId, }: MetricBandProps): ReactNode;
|
|
5
|
-
export declare function DefaultMetric(
|
|
4
|
+
export declare function DefaultMetricBand({ lead, subtitle, children, testId, tiles, }: MetricBandProps): ReactNode;
|
|
5
|
+
export declare function DefaultMetric(props: MetricProps): ReactNode;
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
|
+
import { Icon } from "../icons.js";
|
|
3
4
|
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { STAT_CARD_GRID_CLASSES, StatCard } from "../widgets/stat.js";
|
|
4
6
|
const InsideMetricBandContext = createContext(false);
|
|
5
7
|
// The screen-form column already insets and spaces its children; a band in its
|
|
6
8
|
// header region must not add its own padding on top. DefaultCard resets this
|
|
7
9
|
// to false because a card body is a different inset surface.
|
|
8
10
|
export const InsideScreenFormColumnContext = createContext(false);
|
|
9
|
-
//
|
|
10
|
-
export function DefaultMetricBand({ lead, subtitle, children, testId, }) {
|
|
11
|
+
// Band column counts follow the board: 2 below md, 3 below lg, 5 from lg.
|
|
12
|
+
export function DefaultMetricBand({ lead, subtitle, children, testId, tiles, }) {
|
|
11
13
|
const insideScreenFormColumn = useContext(InsideScreenFormColumnContext);
|
|
12
|
-
return (_jsxs("div", { className: cn("flex shrink-0 flex-col gap-3", !insideScreenFormColumn && "px-6 pb-4 pt-3.5"), "data-testid": testId, children: [lead !== undefined && _jsx("div", { "data-kumiko-layout": "metric-band-lead", children: lead }), subtitle !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-x-2 text-[13px] text-foreground-secondary [&_a]:text-primary", children: subtitle })), _jsx(InsideMetricBandContext.Provider, { value: true, children: _jsx("dl", { className: "m-0 grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-5", children: children }) })] }));
|
|
14
|
+
return (_jsxs("div", { className: cn("flex shrink-0 flex-col gap-3", !insideScreenFormColumn && "px-6 pb-4 pt-3.5"), "data-testid": testId, children: [lead !== undefined && _jsx("div", { "data-kumiko-layout": "metric-band-lead", children: lead }), subtitle !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-x-2 text-[13px] text-foreground-secondary [&_a]:text-primary", children: subtitle })), tiles === true ? (_jsx("div", { className: STAT_CARD_GRID_CLASSES, children: children })) : (_jsx(InsideMetricBandContext.Provider, { value: true, children: _jsx("dl", { className: "m-0 grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-5", children: children }) }))] }));
|
|
13
15
|
}
|
|
14
|
-
|
|
16
|
+
function MetricTile({ label, value, testId, onPress, tone, icon }) {
|
|
17
|
+
const card = (_jsx(StatCard, { label: label, value: value, ...(icon !== undefined && { icon: _jsx(Icon, { name: icon, className: "size-4" }) }), ...(tone !== undefined && { tone }), ...(testId !== undefined && { testId }) }));
|
|
18
|
+
return onPress !== undefined ? (_jsx("button", { type: "button", className: "text-left", onClick: onPress, children: card })) : (card);
|
|
19
|
+
}
|
|
20
|
+
export function DefaultMetric(props) {
|
|
15
21
|
const insideBand = useContext(InsideMetricBandContext);
|
|
22
|
+
if (props.tile === true)
|
|
23
|
+
return _jsx(MetricTile, { ...props });
|
|
24
|
+
const { label, value, testId, onPress } = props;
|
|
16
25
|
const item = (_jsxs("div", { "data-testid": testId, className: cn("flex min-w-0 flex-col gap-0.5", onPress !== undefined && "cursor-pointer"), ...(onPress !== undefined && { onClick: onPress }), children: [_jsx("dt", { className: "text-xs text-muted-foreground", "data-testid": testId !== undefined ? `${testId}-label` : undefined, children: label }), _jsx("dd", { className: "m-0 text-base font-medium tabular-nums text-foreground", "data-testid": testId !== undefined ? `${testId}-value` : undefined, children: onPress !== undefined ? (_jsx("button", { type: "button", className: "text-left hover:underline", children: value })) : (value) })] }));
|
|
17
26
|
return insideBand ? item : _jsx("dl", { className: "m-0", children: item });
|
|
18
27
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DashboardStatTone } from "@cosmicdrift/kumiko-types/screen";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
export type StatTone = DashboardStatTone;
|
|
4
|
+
export declare const STAT_CARD_GRID_CLASSES = "grid grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),1fr))] gap-3";
|
|
4
5
|
export type StatDelta = {
|
|
5
6
|
readonly value: string;
|
|
6
7
|
readonly direction: "up" | "down";
|
package/dist/web/widgets/stat.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { usePrimitives } from "../../index.js";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
// One grid for every row of StatCards (dashboard stat groups, detail metric tiles), so the
|
|
5
|
+
// cards fill the row at any count instead of leaving fixed empty columns.
|
|
6
|
+
export const STAT_CARD_GRID_CLASSES = "grid grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),1fr))] gap-3";
|
|
4
7
|
const TONE_CHIP = {
|
|
5
8
|
default: "bg-muted text-foreground",
|
|
6
9
|
positive: "bg-primary/10 text-primary",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.391.0",
|
|
4
4
|
"description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
52
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
51
|
+
"@cosmicdrift/kumiko-framework": "0.391.0",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.391.0",
|
|
53
53
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
54
54
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
55
55
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -92,8 +92,8 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
95
|
+
"@cosmicdrift/kumiko-locale-de": "0.391.0",
|
|
96
|
+
"@cosmicdrift/kumiko-testing": "0.391.0",
|
|
97
97
|
"@tailwindcss/cli": "^4.3.0",
|
|
98
98
|
"@testing-library/react": "^16.3.2",
|
|
99
99
|
"@testing-library/user-event": "^14.6.1",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.391.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Translated text cells, toned status pills, screen links in rows and live detail screens",
|
|
6
|
+
"detail": "An i18nText cell renders the text in the user's language. An enumOption column with tones draws the translated label as a status pill. A column with linkScreenField links the cell to the screen the row names, but only when the user may open that screen. A projectionDetail with live: true refetches the record and its relatedList sections on entity events. With metricsStyle \"tiles\" its metrics render as the dashboard KPI tiles (StatCard), the icon chip colored by the metric's tone; like on dashboards a value of 0 keeps the neutral tone."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.391.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "No repeated screen title on detail screens, header actions as high as the header controls, embedded table description first",
|
|
12
|
+
"detail": "A projectionDetail without a record title no longer repeats the screen title above its sections when the shell header already shows it. Screen actions in the shell header are now as high as the header's own controls (h-8) instead of the toolbar height. An embedded table (one that does not fill the screen height) shows its description above its toolbar, right under the section title."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.391.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "actionForm optionsQuery selects seed the row marked default",
|
|
18
|
+
"detail": "A row of an optionsQuery result can carry default: true. In an actionForm the first such row fills an empty field as a baseline value, like a prefill, so the form stays unchanged and leaving it asks nothing; set submitPrefilled to submit the suggestion unchanged. When a sibling field changes the payload and the old value drops out of the new rows, the new default replaces it as a normal change. configEdit never pre-selects, and a set value is never replaced."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.390.0",
|
|
4
22
|
"type": "fix",
|