@cosmicdrift/kumiko-renderer 0.326.1 → 0.328.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/package.json +5 -5
- package/src/app/__tests__/entity-edit-fill-height.test.tsx +118 -0
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +64 -47
- package/src/app/action-form-shim.ts +2 -0
- package/src/app/kumiko-screen.tsx +274 -87
- package/src/app/projection-detail-shim.ts +1 -0
- package/src/app/projection-list-shim.ts +3 -0
- package/src/app/row-actions.ts +6 -0
- package/src/changes.json +52 -0
- package/src/components/__tests__/related-list-section.test.tsx +71 -2
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +2 -2
- package/src/components/__tests__/write-form-section.test.tsx +1 -1
- package/src/components/grid-cell-for-field.tsx +30 -2
- package/src/components/related-list-section.tsx +53 -29
- package/src/components/render-edit-action-button.tsx +4 -1
- package/src/components/render-edit-types.ts +10 -0
- package/src/components/render-edit.tsx +564 -393
- package/src/components/render-field.tsx +11 -2
- package/src/components/render-list.tsx +45 -13
- package/src/components/write-form-section.tsx +0 -1
- package/src/hooks/__tests__/use-query.test.tsx +149 -0
- package/src/hooks/use-list-url-state.ts +37 -1
- package/src/hooks/use-query.ts +57 -23
- package/src/i18n-defaults.ts +27 -1
- package/src/index.ts +7 -0
- package/src/inside-drawer.ts +12 -0
- package/src/page-header-slot.ts +12 -0
- package/src/primitives.tsx +137 -16
- package/src/screen-fills-height.ts +3 -0
|
@@ -49,16 +49,21 @@ import {
|
|
|
49
49
|
import { RenderList } from "../components/render-list";
|
|
50
50
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
51
51
|
import { useUserRoles } from "../context/user-roles-context";
|
|
52
|
-
import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
|
|
52
|
+
import { type ListSort, PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state";
|
|
53
53
|
import { type UseQueryResult, useQuery } from "../hooks/use-query";
|
|
54
|
-
import { useOptionalTimeZone, useTranslation } from "../i18n";
|
|
54
|
+
import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n";
|
|
55
|
+
import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer";
|
|
56
|
+
import { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable } from "../page-header-slot";
|
|
55
57
|
import {
|
|
56
58
|
type DataTableDateRangeFacet,
|
|
57
59
|
type DataTableFacet,
|
|
58
60
|
type DataTableRowAction,
|
|
61
|
+
type StatusTone,
|
|
62
|
+
statusToneForOptionTone,
|
|
59
63
|
statusToneForValue,
|
|
60
64
|
usePrimitives,
|
|
61
65
|
} from "../primitives";
|
|
66
|
+
import { screenFillsHeight } from "../screen-fills-height";
|
|
62
67
|
import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
|
|
63
68
|
import { useAppFeatures } from "./app-features-context";
|
|
64
69
|
import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
|
|
@@ -200,8 +205,11 @@ export function KumikoScreen({
|
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
const body = renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink });
|
|
203
|
-
// An embedded screen (e.g. a dashboard panel) keeps its parent's host
|
|
204
|
-
|
|
208
|
+
// An embedded screen (e.g. a dashboard panel) keeps its parent's host and
|
|
209
|
+
// must not portal its page header into the shell that hosts the outer screen.
|
|
210
|
+
if (outerHost !== undefined) {
|
|
211
|
+
return <PageHeaderSlotAvailableProvider value={false}>{body}</PageHeaderSlotAvailableProvider>;
|
|
212
|
+
}
|
|
205
213
|
return <ReturnHostProvider value={ownHost}>{body}</ReturnHostProvider>;
|
|
206
214
|
}
|
|
207
215
|
|
|
@@ -385,6 +393,12 @@ function useNavigateToListAfter(schema: FeatureSchema, entityName: string): () =
|
|
|
385
393
|
// Schema-Screens kommen mit qualifizierten ids ("publicstatus:screen:
|
|
386
394
|
// component-edit") aus der Registry; lastSegment strippt den Prefix
|
|
387
395
|
// für nav.navigate (siehe ./qn.ts für Doku).
|
|
396
|
+
function pageSizeOptionsFor(currentLimit: number): readonly number[] {
|
|
397
|
+
return PAGE_SIZE_OPTIONS.includes(currentLimit)
|
|
398
|
+
? PAGE_SIZE_OPTIONS
|
|
399
|
+
: [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
|
|
400
|
+
}
|
|
401
|
+
|
|
388
402
|
function useNavigateToCreateFor(
|
|
389
403
|
schema: FeatureSchema,
|
|
390
404
|
entityName: string,
|
|
@@ -580,6 +594,42 @@ function isMoneyValue(value: unknown): value is MoneyValue {
|
|
|
580
594
|
);
|
|
581
595
|
}
|
|
582
596
|
|
|
597
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
598
|
+
|
|
599
|
+
function formatSummaryValue(value: unknown, locale: string): string {
|
|
600
|
+
if (isMoneyValue(value)) {
|
|
601
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: value.currency }).format(
|
|
602
|
+
value.amount,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
if (typeof value === "number") return value.toLocaleString(locale);
|
|
606
|
+
if (typeof value === "string" && ISO_DATE_PATTERN.test(value)) {
|
|
607
|
+
return new Date(`${value}T00:00:00Z`).toLocaleDateString(locale, {
|
|
608
|
+
year: "numeric",
|
|
609
|
+
month: "2-digit",
|
|
610
|
+
day: "2-digit",
|
|
611
|
+
timeZone: "UTC",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
return value === null || value === undefined ? "" : String(value);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function resolveActionFormSummary(
|
|
618
|
+
summary: ActionFormScreenDefinition["summary"],
|
|
619
|
+
prefill: Readonly<Record<string, unknown>> | undefined,
|
|
620
|
+
translate: Translate,
|
|
621
|
+
locale: string,
|
|
622
|
+
): { readonly title: string; readonly subtitle?: string } | undefined {
|
|
623
|
+
if (summary === undefined) return undefined;
|
|
624
|
+
const params = Object.fromEntries(
|
|
625
|
+
Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]),
|
|
626
|
+
);
|
|
627
|
+
return {
|
|
628
|
+
title: translate(summary.title, params),
|
|
629
|
+
...(summary.subtitle !== undefined && { subtitle: translate(summary.subtitle, params) }),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
583
633
|
function parseJsonOrRaw(raw: string): unknown {
|
|
584
634
|
try {
|
|
585
635
|
return JSON.parse(raw);
|
|
@@ -1021,6 +1071,7 @@ function EntityEditCreateBody({
|
|
|
1021
1071
|
onSubmit={handleSubmitted}
|
|
1022
1072
|
onControlsReady={handleControlsReady}
|
|
1023
1073
|
onCancel={handleCancel}
|
|
1074
|
+
{...(screenFillsHeight(screen) && { fillScreenHeight: true })}
|
|
1024
1075
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
1025
1076
|
{...(translate !== undefined && { translate })}
|
|
1026
1077
|
/>
|
|
@@ -1328,6 +1379,7 @@ function EntityEditUpdateForm({
|
|
|
1328
1379
|
{...(screen.allowDelete !== false && { onDelete: handleDelete })}
|
|
1329
1380
|
onCancel={handleCancel}
|
|
1330
1381
|
onReload={() => void onReload()}
|
|
1382
|
+
{...(screenFillsHeight(screen) && { fillScreenHeight: true })}
|
|
1331
1383
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
1332
1384
|
{...(translate !== undefined && { translate })}
|
|
1333
1385
|
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
@@ -1639,9 +1691,20 @@ function DrawerHost({
|
|
|
1639
1691
|
readonly onClose: () => void;
|
|
1640
1692
|
readonly onSuccess: () => void;
|
|
1641
1693
|
}): ReactNode {
|
|
1642
|
-
const { Drawer, Banner, Text } = usePrimitives();
|
|
1694
|
+
const { Drawer, Banner, Text, Dialog } = usePrimitives();
|
|
1643
1695
|
const t = useTranslation();
|
|
1644
1696
|
const effectiveTranslate = translate ?? t;
|
|
1697
|
+
const [hasUnsavedInput, setHasUnsavedInput] = useState(false);
|
|
1698
|
+
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
|
|
1699
|
+
const closeAndReset = useCallback(() => {
|
|
1700
|
+
setHasUnsavedInput(false);
|
|
1701
|
+
setConfirmDiscardOpen(false);
|
|
1702
|
+
onClose();
|
|
1703
|
+
}, [onClose]);
|
|
1704
|
+
const requestClose = useCallback(() => {
|
|
1705
|
+
if (hasUnsavedInput) setConfirmDiscardOpen(true);
|
|
1706
|
+
else closeAndReset();
|
|
1707
|
+
}, [hasUnsavedInput, closeAndReset]);
|
|
1645
1708
|
// Drawer is an optional Core-Primitive (additive rollout) — same "skip +
|
|
1646
1709
|
// warn once" precedent as rowActions without a mounted DispatcherProvider
|
|
1647
1710
|
// above, instead of crashing when a web app hasn't upgraded its
|
|
@@ -1665,33 +1728,58 @@ function DrawerHost({
|
|
|
1665
1728
|
const allowed = screenAccessAllows(drawerScreen?.access, userRoles);
|
|
1666
1729
|
|
|
1667
1730
|
return (
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
<
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1731
|
+
<>
|
|
1732
|
+
<Drawer
|
|
1733
|
+
open={true}
|
|
1734
|
+
onOpenChange={(open) => {
|
|
1735
|
+
if (!open) requestClose();
|
|
1736
|
+
}}
|
|
1737
|
+
title={effectiveTranslate(drawerAction.label)}
|
|
1738
|
+
testId={`toolbar-drawer-${drawerAction.id}`}
|
|
1739
|
+
>
|
|
1740
|
+
<PageHeaderSlotAvailableProvider value={false}>
|
|
1741
|
+
<InsideDrawerProvider value={true}>
|
|
1742
|
+
{drawerScreen === undefined ? (
|
|
1743
|
+
<Banner padded variant="error" testId="kumiko-toolbar-drawer-not-found">
|
|
1744
|
+
Screen not found: <Text variant="code">{drawerAction.screen}</Text>
|
|
1745
|
+
</Banner>
|
|
1746
|
+
) : !allowed ? (
|
|
1747
|
+
<Banner padded variant="error" testId="kumiko-toolbar-drawer-access-denied">
|
|
1748
|
+
Access denied: <Text variant="code">{drawerAction.screen}</Text>
|
|
1749
|
+
</Banner>
|
|
1750
|
+
) : (
|
|
1751
|
+
<ActionFormBody
|
|
1752
|
+
schema={schema}
|
|
1753
|
+
screen={drawerScreen}
|
|
1754
|
+
{...(translate !== undefined && { translate })}
|
|
1755
|
+
{...(drawerInitialValues !== undefined && {
|
|
1756
|
+
initialOverrides: drawerInitialValues,
|
|
1757
|
+
})}
|
|
1758
|
+
onSuccess={() => {
|
|
1759
|
+
setHasUnsavedInput(false);
|
|
1760
|
+
onSuccess();
|
|
1761
|
+
}}
|
|
1762
|
+
onCancelOverride={requestClose}
|
|
1763
|
+
onDirtyChange={setHasUnsavedInput}
|
|
1764
|
+
submitLabelFallback={drawerAction.label}
|
|
1765
|
+
/>
|
|
1766
|
+
)}
|
|
1767
|
+
</InsideDrawerProvider>
|
|
1768
|
+
</PageHeaderSlotAvailableProvider>
|
|
1769
|
+
</Drawer>
|
|
1770
|
+
<Dialog
|
|
1771
|
+
open={confirmDiscardOpen}
|
|
1772
|
+
onOpenChange={setConfirmDiscardOpen}
|
|
1773
|
+
title={t("kumiko.drawer.discard.title")}
|
|
1774
|
+
description={t("kumiko.drawer.discard.body")}
|
|
1775
|
+
confirmLabel={t("kumiko.drawer.discard.confirm")}
|
|
1776
|
+
cancelLabel={t("kumiko.drawer.discard.cancel")}
|
|
1777
|
+
variant="danger"
|
|
1778
|
+
initialFocus="cancel"
|
|
1779
|
+
onConfirm={closeAndReset}
|
|
1780
|
+
testId="drawer-discard-dialog"
|
|
1781
|
+
/>
|
|
1782
|
+
</>
|
|
1695
1783
|
);
|
|
1696
1784
|
}
|
|
1697
1785
|
|
|
@@ -1765,7 +1853,7 @@ function EntityListBody({
|
|
|
1765
1853
|
// wenn URL keinen sort hat — Author-Default vs User-Choice.
|
|
1766
1854
|
const urlState = useListUrlState(screen.id);
|
|
1767
1855
|
const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
|
|
1768
|
-
const limit = screen.pageSize ?? 50;
|
|
1856
|
+
const limit = urlState.pageSize ?? screen.pageSize ?? 50;
|
|
1769
1857
|
const paginationMode = screen.pagination ?? "pages";
|
|
1770
1858
|
const usePager = paginationMode === "pages";
|
|
1771
1859
|
const useInfinite = paginationMode === "infinite";
|
|
@@ -2009,6 +2097,7 @@ function EntityListBody({
|
|
|
2009
2097
|
label: effectiveTranslate(action.label),
|
|
2010
2098
|
...(action.style !== undefined && { style: action.style }),
|
|
2011
2099
|
confirmRequired: false,
|
|
2100
|
+
...(navigateAction.rowClick === true && { rowClick: true }),
|
|
2012
2101
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2013
2102
|
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
2014
2103
|
...(actionVisible !== undefined && {
|
|
@@ -2166,6 +2255,8 @@ function EntityListBody({
|
|
|
2166
2255
|
limit,
|
|
2167
2256
|
total,
|
|
2168
2257
|
onPageChange: urlState.setPage,
|
|
2258
|
+
pageSizeOptions: pageSizeOptionsFor(limit),
|
|
2259
|
+
onPageSizeChange: urlState.setPageSize,
|
|
2169
2260
|
}
|
|
2170
2261
|
: undefined;
|
|
2171
2262
|
|
|
@@ -2187,6 +2278,7 @@ function EntityListBody({
|
|
|
2187
2278
|
sort={effectiveSort}
|
|
2188
2279
|
onSortChange={urlState.setSort}
|
|
2189
2280
|
screenPadding
|
|
2281
|
+
{...(screenFillsHeight(screen) && { scrollBody: true })}
|
|
2190
2282
|
{...(pager !== undefined && { pager })}
|
|
2191
2283
|
{...(rowActions !== undefined && { rowActions })}
|
|
2192
2284
|
{...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
|
|
@@ -2275,7 +2367,7 @@ function ProjectionListBody({
|
|
|
2275
2367
|
// screen) must not smuggle a param the query's Zod schema doesn't accept.
|
|
2276
2368
|
const activeSearch = searchable ? urlState.q : "";
|
|
2277
2369
|
const activeSort = sortable ? (urlState.sort ?? screen.defaultSort ?? null) : null;
|
|
2278
|
-
const limit = screen.pageSize ?? 50;
|
|
2370
|
+
const limit = urlState.pageSize ?? screen.pageSize ?? 50;
|
|
2279
2371
|
// Pages-mode only (see header comment) — an author-set pagination:
|
|
2280
2372
|
// "infinite" on a paginated projectionList is silently a no-op today.
|
|
2281
2373
|
const usePager = paginated && (screen.pagination ?? "pages") === "pages";
|
|
@@ -2494,7 +2586,14 @@ function ProjectionListBody({
|
|
|
2494
2586
|
const total = rowsQuery.data?.total;
|
|
2495
2587
|
const pager =
|
|
2496
2588
|
usePager && total !== undefined
|
|
2497
|
-
? {
|
|
2589
|
+
? {
|
|
2590
|
+
page: urlState.page,
|
|
2591
|
+
limit,
|
|
2592
|
+
total,
|
|
2593
|
+
onPageChange: urlState.setPage,
|
|
2594
|
+
pageSizeOptions: pageSizeOptionsFor(limit),
|
|
2595
|
+
onPageSizeChange: urlState.setPageSize,
|
|
2596
|
+
}
|
|
2498
2597
|
: undefined;
|
|
2499
2598
|
|
|
2500
2599
|
return (
|
|
@@ -2511,6 +2610,7 @@ function ProjectionListBody({
|
|
|
2511
2610
|
sort={activeSort}
|
|
2512
2611
|
onSortChange={urlState.setSort}
|
|
2513
2612
|
screenPadding
|
|
2613
|
+
{...(screenFillsHeight(listScreen) && { scrollBody: true })}
|
|
2514
2614
|
{...(pager !== undefined && { pager })}
|
|
2515
2615
|
{...(rowActions !== undefined && { rowActions })}
|
|
2516
2616
|
{...(toolbarActions !== undefined && { toolbarActions })}
|
|
@@ -2606,6 +2706,19 @@ function isAbsoluteHttpUrl(value: string): boolean {
|
|
|
2606
2706
|
return /^https?:\/\//i.test(value) && isSafeHref(value);
|
|
2607
2707
|
}
|
|
2608
2708
|
|
|
2709
|
+
function headerStatusTone(
|
|
2710
|
+
header: NonNullable<ProjectionDetailScreenDefinition["header"]>,
|
|
2711
|
+
record: Readonly<Record<string, unknown>>,
|
|
2712
|
+
): StatusTone | undefined {
|
|
2713
|
+
if (header.status === undefined) return undefined;
|
|
2714
|
+
const value = String(record[header.status] ?? "");
|
|
2715
|
+
const declared =
|
|
2716
|
+
header.statusTones !== undefined && Object.hasOwn(header.statusTones, value)
|
|
2717
|
+
? header.statusTones[value]
|
|
2718
|
+
: undefined;
|
|
2719
|
+
return declared !== undefined ? statusToneForOptionTone(declared) : statusToneForValue(value);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2609
2722
|
function resolveSubtitleHref(
|
|
2610
2723
|
header: ProjectionDetailScreenDefinition["header"],
|
|
2611
2724
|
record: Readonly<Record<string, unknown>>,
|
|
@@ -2647,13 +2760,17 @@ function HeaderActionsBar({
|
|
|
2647
2760
|
Dialog,
|
|
2648
2761
|
ActionOverflowMenu,
|
|
2649
2762
|
onError,
|
|
2763
|
+
collapseAfterPrimary = false,
|
|
2650
2764
|
}: {
|
|
2765
|
+
/** Header slot layout: only the primary action stays a button, the rest goes into the menu. */
|
|
2766
|
+
readonly collapseAfterPrimary?: boolean;
|
|
2651
2767
|
readonly actions: readonly RenderEditAction[];
|
|
2652
2768
|
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
2653
2769
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
2654
2770
|
readonly ActionOverflowMenu: ReturnType<typeof usePrimitives>["ActionOverflowMenu"];
|
|
2655
2771
|
readonly onError: (text: string | null) => void;
|
|
2656
2772
|
}): ReactNode {
|
|
2773
|
+
const t = useTranslation();
|
|
2657
2774
|
const [pendingAction, setPendingAction] = useState<RenderEditAction | null>(null);
|
|
2658
2775
|
const trigger = async (action: RenderEditAction): Promise<void> => {
|
|
2659
2776
|
onError(null);
|
|
@@ -2663,7 +2780,8 @@ function HeaderActionsBar({
|
|
|
2663
2780
|
onError(e instanceof Error ? e.message : String(e));
|
|
2664
2781
|
}
|
|
2665
2782
|
};
|
|
2666
|
-
|
|
2783
|
+
const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
|
|
2784
|
+
if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
|
|
2667
2785
|
return (
|
|
2668
2786
|
<>
|
|
2669
2787
|
{actions.map((action) => (
|
|
@@ -2672,6 +2790,7 @@ function HeaderActionsBar({
|
|
|
2672
2790
|
action={action}
|
|
2673
2791
|
Button={Button}
|
|
2674
2792
|
Dialog={Dialog}
|
|
2793
|
+
hideIcon
|
|
2675
2794
|
onError={onError}
|
|
2676
2795
|
/>
|
|
2677
2796
|
))}
|
|
@@ -2688,11 +2807,12 @@ function HeaderActionsBar({
|
|
|
2688
2807
|
action={primary}
|
|
2689
2808
|
Button={Button}
|
|
2690
2809
|
Dialog={Dialog}
|
|
2810
|
+
hideIcon
|
|
2691
2811
|
onError={onError}
|
|
2692
2812
|
/>
|
|
2693
2813
|
)}
|
|
2694
2814
|
<ActionOverflowMenu
|
|
2695
|
-
label="
|
|
2815
|
+
label={t("kumiko.list.row-actions.more")}
|
|
2696
2816
|
testId="kumiko-screen-projection-detail-actions-overflow"
|
|
2697
2817
|
items={rest.map((action) => ({
|
|
2698
2818
|
id: action.id,
|
|
@@ -2752,10 +2872,13 @@ function ProjectionDetailBody({
|
|
|
2752
2872
|
Tabs,
|
|
2753
2873
|
StatusBadge,
|
|
2754
2874
|
Metric,
|
|
2875
|
+
MetricBand,
|
|
2876
|
+
PageHeader,
|
|
2755
2877
|
Link,
|
|
2756
2878
|
ActionOverflowMenu,
|
|
2757
2879
|
} = usePrimitives();
|
|
2758
2880
|
const t = useTranslation();
|
|
2881
|
+
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
2759
2882
|
const effectiveTranslate = translate ?? t;
|
|
2760
2883
|
const nav = useNav();
|
|
2761
2884
|
const idParam = screen.idParam ?? "id";
|
|
@@ -3002,7 +3125,7 @@ function ProjectionDetailBody({
|
|
|
3002
3125
|
{StatusBadge !== undefined ? (
|
|
3003
3126
|
<StatusBadge
|
|
3004
3127
|
value={String(record[header.status] ?? "")}
|
|
3005
|
-
tone={
|
|
3128
|
+
tone={headerStatusTone(header, record)}
|
|
3006
3129
|
testId="kumiko-screen-projection-detail-status"
|
|
3007
3130
|
/>
|
|
3008
3131
|
) : (
|
|
@@ -3031,58 +3154,101 @@ function ProjectionDetailBody({
|
|
|
3031
3154
|
) : undefined;
|
|
3032
3155
|
// slots.header shares the header card with the actions (one card, actions
|
|
3033
3156
|
// top right) instead of rendering as an unframed block above it.
|
|
3157
|
+
const metricItems = screen.metrics?.map((metric) => {
|
|
3158
|
+
const field = metricField(metric);
|
|
3159
|
+
const labelKey = metricLabelKey(metric, screen.fieldLabels);
|
|
3160
|
+
const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
|
|
3161
|
+
const value = String(record[field] ?? "");
|
|
3162
|
+
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
3163
|
+
const navigate = metricNavigateSpec(metric);
|
|
3164
|
+
const onPress =
|
|
3165
|
+
navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
|
|
3166
|
+
? () => runMetricNavigate(nav, navigate, record, host)
|
|
3167
|
+
: undefined;
|
|
3168
|
+
return Metric !== undefined ? (
|
|
3169
|
+
<Metric
|
|
3170
|
+
key={field}
|
|
3171
|
+
label={label}
|
|
3172
|
+
value={value}
|
|
3173
|
+
testId={testId}
|
|
3174
|
+
{...(onPress !== undefined && { onPress })}
|
|
3175
|
+
/>
|
|
3176
|
+
) : (
|
|
3177
|
+
<GridCell key={field}>
|
|
3178
|
+
<Text variant="small" testId={`${testId}-label`}>
|
|
3179
|
+
{label}
|
|
3180
|
+
</Text>
|
|
3181
|
+
<Text testId={`${testId}-value`}>{value}</Text>
|
|
3182
|
+
</GridCell>
|
|
3183
|
+
);
|
|
3184
|
+
});
|
|
3185
|
+
// Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
|
|
3186
|
+
const metricsBlock = (subtitle?: ReactNode): ReactNode =>
|
|
3187
|
+
MetricBand !== undefined ? (
|
|
3188
|
+
<MetricBand testId="kumiko-screen-projection-detail-metrics" subtitle={subtitle}>
|
|
3189
|
+
{metricItems}
|
|
3190
|
+
</MetricBand>
|
|
3191
|
+
) : (
|
|
3192
|
+
<Grid columns={screen.metrics?.length ?? 1} testId="kumiko-screen-projection-detail-metrics">
|
|
3193
|
+
{metricItems}
|
|
3194
|
+
</Grid>
|
|
3195
|
+
);
|
|
3196
|
+
const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
|
|
3197
|
+
const actionErrorBanner = actionError !== null && (
|
|
3198
|
+
<Banner variant="error" testId="render-edit-action-error">
|
|
3199
|
+
{actionError}
|
|
3200
|
+
</Banner>
|
|
3201
|
+
);
|
|
3034
3202
|
const renderHeaderContent = (headerSlot: ReactNode | undefined): ReactNode => (
|
|
3035
3203
|
<>
|
|
3036
|
-
{
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
...(
|
|
3040
|
-
...(
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
columns={screen.metrics?.length ?? 1}
|
|
3048
|
-
testId="kumiko-screen-projection-detail-metrics"
|
|
3049
|
-
>
|
|
3050
|
-
{screen.metrics?.map((metric) => {
|
|
3051
|
-
const field = metricField(metric);
|
|
3052
|
-
const labelKey = metricLabelKey(metric, screen.fieldLabels);
|
|
3053
|
-
const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
|
|
3054
|
-
const value = String(record[field] ?? "");
|
|
3055
|
-
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
3056
|
-
const navigate = metricNavigateSpec(metric);
|
|
3057
|
-
const onPress =
|
|
3058
|
-
navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
|
|
3059
|
-
? () => runMetricNavigate(nav, navigate, record, host)
|
|
3060
|
-
: undefined;
|
|
3061
|
-
return Metric !== undefined ? (
|
|
3062
|
-
<Metric
|
|
3063
|
-
key={field}
|
|
3064
|
-
label={label}
|
|
3065
|
-
value={value}
|
|
3066
|
-
testId={testId}
|
|
3067
|
-
{...(onPress !== undefined && { onPress })}
|
|
3204
|
+
{usesPageHeaderSlot && headerSlot === undefined ? (
|
|
3205
|
+
<>
|
|
3206
|
+
<PageHeader
|
|
3207
|
+
{...(header !== undefined && { title: String(record[header.title] ?? "") })}
|
|
3208
|
+
{...(header?.status !== undefined &&
|
|
3209
|
+
StatusBadge !== undefined && {
|
|
3210
|
+
status: (
|
|
3211
|
+
<StatusBadge
|
|
3212
|
+
value={String(record[header.status] ?? "")}
|
|
3213
|
+
tone={headerStatusTone(header, record)}
|
|
3214
|
+
testId="kumiko-screen-projection-detail-status"
|
|
3068
3215
|
/>
|
|
3069
|
-
)
|
|
3070
|
-
<GridCell key={field}>
|
|
3071
|
-
<Text variant="small" testId={`${testId}-label`}>
|
|
3072
|
-
{label}
|
|
3073
|
-
</Text>
|
|
3074
|
-
<Text testId={`${testId}-value`}>{value}</Text>
|
|
3075
|
-
</GridCell>
|
|
3076
|
-
);
|
|
3216
|
+
),
|
|
3077
3217
|
})}
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3218
|
+
{...(hasHeaderActions && {
|
|
3219
|
+
actions: (
|
|
3220
|
+
<Grid columns="auto" testId="kumiko-screen-projection-detail-actions">
|
|
3221
|
+
<HeaderActionsBar
|
|
3222
|
+
actions={headerActionsList}
|
|
3223
|
+
Button={Button}
|
|
3224
|
+
Dialog={Dialog}
|
|
3225
|
+
ActionOverflowMenu={ActionOverflowMenu}
|
|
3226
|
+
onError={setActionError}
|
|
3227
|
+
collapseAfterPrimary
|
|
3228
|
+
/>
|
|
3229
|
+
</Grid>
|
|
3230
|
+
),
|
|
3231
|
+
})}
|
|
3232
|
+
/>
|
|
3233
|
+
{(hasMetrics || headerSubtitleSlot !== undefined) &&
|
|
3234
|
+
(MetricBand !== undefined || hasMetrics) &&
|
|
3235
|
+
metricsBlock(headerSubtitleSlot)}
|
|
3236
|
+
{actionErrorBanner}
|
|
3237
|
+
</>
|
|
3238
|
+
) : (
|
|
3239
|
+
(hasHeaderCard || headerSlot !== undefined) && (
|
|
3240
|
+
<Card
|
|
3241
|
+
slots={{
|
|
3242
|
+
...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
|
|
3243
|
+
...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
|
|
3244
|
+
...(headerSlot !== undefined && { headerContent: headerSlot }),
|
|
3245
|
+
...(hasHeaderActions && { headerActions: headerActionsContent }),
|
|
3246
|
+
}}
|
|
3247
|
+
>
|
|
3248
|
+
{hasMetrics && metricsBlock()}
|
|
3249
|
+
{actionErrorBanner}
|
|
3250
|
+
</Card>
|
|
3251
|
+
)
|
|
3086
3252
|
)}
|
|
3087
3253
|
{hasTabs && activeSection !== undefined && (
|
|
3088
3254
|
<Tabs
|
|
@@ -3120,6 +3286,7 @@ function ProjectionDetailBody({
|
|
|
3120
3286
|
onRelatedListDrawerAction={openDrawer}
|
|
3121
3287
|
{...(translate !== undefined && { translate })}
|
|
3122
3288
|
{...(hasTabs && { hideSectionTitles: true })}
|
|
3289
|
+
{...(screenFillsHeight(screen) && { fillScreenHeight: true })}
|
|
3123
3290
|
{...((hasHeaderCard || hasTabs || screen.slots?.header !== undefined) && {
|
|
3124
3291
|
headerRegion: renderHeaderContent,
|
|
3125
3292
|
})}
|
|
@@ -3238,6 +3405,8 @@ function ActionFormBody({
|
|
|
3238
3405
|
initialOverrides,
|
|
3239
3406
|
onSuccess,
|
|
3240
3407
|
onCancelOverride,
|
|
3408
|
+
onDirtyChange,
|
|
3409
|
+
submitLabelFallback,
|
|
3241
3410
|
}: {
|
|
3242
3411
|
readonly schema: FeatureSchema;
|
|
3243
3412
|
readonly screen: ActionFormScreenDefinition;
|
|
@@ -3255,9 +3424,16 @@ function ActionFormBody({
|
|
|
3255
3424
|
/** Drawer-hosted usage: replaces the cancelTarget/redirect-based Cancel
|
|
3256
3425
|
* handler so Cancel closes the drawer instead of navigating. */
|
|
3257
3426
|
readonly onCancelOverride?: () => void;
|
|
3427
|
+
/** Drawer-hosted usage: reports unsaved input so the host can confirm
|
|
3428
|
+
* before discarding it. */
|
|
3429
|
+
readonly onDirtyChange?: (dirty: boolean) => void;
|
|
3430
|
+
/** Drawer-hosted usage: submit label when the screen declares none (the opening action's label). */
|
|
3431
|
+
readonly submitLabelFallback?: string;
|
|
3258
3432
|
}): ReactNode {
|
|
3259
3433
|
const nav = useNav();
|
|
3260
3434
|
const appFeatures = useAppFeatures();
|
|
3435
|
+
const insideDrawer = useInsideDrawer();
|
|
3436
|
+
const locale = useLocale().locale();
|
|
3261
3437
|
const { Banner } = usePrimitives();
|
|
3262
3438
|
// Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
|
|
3263
3439
|
const returnTarget = useReturnTarget(screen.id);
|
|
@@ -3300,6 +3476,12 @@ function ActionFormBody({
|
|
|
3300
3476
|
handoffValues,
|
|
3301
3477
|
],
|
|
3302
3478
|
);
|
|
3479
|
+
const t = useTranslation();
|
|
3480
|
+
const effectiveTranslate = translate ?? t;
|
|
3481
|
+
const summary = useMemo(
|
|
3482
|
+
() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale),
|
|
3483
|
+
[screen.summary, initialOverrides, effectiveTranslate, locale],
|
|
3484
|
+
);
|
|
3303
3485
|
const handleSubmitted = useCallback(
|
|
3304
3486
|
(result: SubmitResult<unknown>) => {
|
|
3305
3487
|
if (!result.isSuccess) return;
|
|
@@ -3363,8 +3545,13 @@ function ActionFormBody({
|
|
|
3363
3545
|
payloadMode="values"
|
|
3364
3546
|
onSubmit={handleSubmitted}
|
|
3365
3547
|
{...(handleCancel !== undefined && { onCancel: handleCancel })}
|
|
3366
|
-
{...(
|
|
3548
|
+
{...(onDirtyChange !== undefined && { onDirtyChange })}
|
|
3549
|
+
{...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true })}
|
|
3550
|
+
{...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
|
|
3551
|
+
submitLabel: screen.submitLabel ?? submitLabelFallback,
|
|
3552
|
+
})}
|
|
3367
3553
|
{...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle })}
|
|
3554
|
+
{...(summary !== undefined && { summary })}
|
|
3368
3555
|
{...(translate !== undefined && { translate })}
|
|
3369
3556
|
/>
|
|
3370
3557
|
);
|
|
@@ -81,5 +81,6 @@ export function synthesizeProjectionDetailScreen(
|
|
|
81
81
|
...(screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
|
|
82
82
|
...(screen.slots !== undefined && { slots: screen.slots }),
|
|
83
83
|
...(screen.access !== undefined && { access: screen.access }),
|
|
84
|
+
...(screen.fillHeight !== undefined && { fillHeight: screen.fillHeight }),
|
|
84
85
|
};
|
|
85
86
|
}
|
|
@@ -61,7 +61,10 @@ export function synthesizeProjectionScreen(
|
|
|
61
61
|
...(screen.pageSize !== undefined && { pageSize: screen.pageSize }),
|
|
62
62
|
...(screen.defaultSort !== undefined && { defaultSort: screen.defaultSort }),
|
|
63
63
|
...(screen.searchable !== undefined && { searchable: screen.searchable }),
|
|
64
|
+
...(screen.createLabel !== undefined && { createLabel: screen.createLabel }),
|
|
65
|
+
...(screen.searchPlaceholder !== undefined && { searchPlaceholder: screen.searchPlaceholder }),
|
|
64
66
|
...(screen.slots !== undefined && { slots: screen.slots }),
|
|
65
67
|
...(screen.access !== undefined && { access: screen.access }),
|
|
68
|
+
...(screen.fillHeight !== undefined && { fillHeight: screen.fillHeight }),
|
|
66
69
|
};
|
|
67
70
|
}
|
package/src/app/row-actions.ts
CHANGED
|
@@ -135,6 +135,7 @@ function buildNavigateRowAction(
|
|
|
135
135
|
label: translate(action.label),
|
|
136
136
|
...(action.style !== undefined && { style: action.style }),
|
|
137
137
|
confirmRequired: false,
|
|
138
|
+
...(action.rowClick === true && { rowClick: true }),
|
|
138
139
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
139
140
|
onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row, host),
|
|
140
141
|
...(visible !== undefined && {
|
|
@@ -152,6 +153,11 @@ type OpenDrawer = (
|
|
|
152
153
|
// builders re-run inside a useMemo on every dep change.
|
|
153
154
|
const warnedDrawerActionIds = new Set<string>();
|
|
154
155
|
|
|
156
|
+
/** @internal test-only */
|
|
157
|
+
export function resetDrawerActionWarningsForTests(): void {
|
|
158
|
+
warnedDrawerActionIds.clear();
|
|
159
|
+
}
|
|
160
|
+
|
|
155
161
|
function warnDrawerActionDropped(
|
|
156
162
|
actionKind: "rowAction" | "toolbarAction",
|
|
157
163
|
actionId: string,
|