@cosmicdrift/kumiko-renderer 0.254.0 → 0.255.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 +4 -4
- package/src/app/__tests__/projection-detail-actions.test.tsx +245 -1
- package/src/app/kumiko-screen.tsx +245 -48
- package/src/app/row-actions.ts +1 -14
- package/src/components/related-list-section.tsx +1 -7
- package/src/components/render-edit-action-button.tsx +39 -13
- package/src/components/render-edit.tsx +1 -0
- package/src/index.ts +2 -0
- package/src/primitives.tsx +41 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.255.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.255.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.255.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.255.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -17,14 +17,17 @@ import type {
|
|
|
17
17
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
18
18
|
import { act, fireEvent, render, waitFor } from "@testing-library/react";
|
|
19
19
|
import type { ComponentType, ReactNode } from "react";
|
|
20
|
+
import { useState } from "react";
|
|
20
21
|
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
21
22
|
import { UserRolesProvider } from "../../context/user-roles-context";
|
|
22
23
|
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
23
24
|
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
24
25
|
import {
|
|
26
|
+
type ActionOverflowMenuProps,
|
|
25
27
|
type BannerProps,
|
|
26
28
|
type ButtonProps,
|
|
27
29
|
type CorePrimitives,
|
|
30
|
+
type DialogProps,
|
|
28
31
|
type FormProps,
|
|
29
32
|
PrimitivesProvider,
|
|
30
33
|
} from "../../primitives";
|
|
@@ -70,6 +73,55 @@ const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
|
|
|
70
73
|
<div data-testid={testId}>{children}</div>
|
|
71
74
|
);
|
|
72
75
|
|
|
76
|
+
// Minimal overflow-menu double (A7): a toggle button reveals the items as
|
|
77
|
+
// plain buttons, own testid per item so a test can assert an item is
|
|
78
|
+
// reachable only after the menu opens — no dropdown/portal machinery, this
|
|
79
|
+
// package doesn't test the real (renderer-web) widget.
|
|
80
|
+
const TestActionOverflowMenu: ComponentType<ActionOverflowMenuProps> = ({
|
|
81
|
+
items,
|
|
82
|
+
label,
|
|
83
|
+
testId,
|
|
84
|
+
}) => {
|
|
85
|
+
const [open, setOpen] = useState(false);
|
|
86
|
+
return (
|
|
87
|
+
<div>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
aria-label={label}
|
|
91
|
+
data-testid={testId ?? "action-overflow-trigger"}
|
|
92
|
+
onClick={() => setOpen((o) => !o)}
|
|
93
|
+
>
|
|
94
|
+
{"⋯"}
|
|
95
|
+
</button>
|
|
96
|
+
{open &&
|
|
97
|
+
items.map((item) => (
|
|
98
|
+
<button
|
|
99
|
+
key={item.id}
|
|
100
|
+
type="button"
|
|
101
|
+
data-testid={`${testId ?? "action-overflow"}-item-${item.id}`}
|
|
102
|
+
disabled={item.disabled}
|
|
103
|
+
onClick={item.onSelect}
|
|
104
|
+
>
|
|
105
|
+
{item.label}
|
|
106
|
+
</button>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Records the props of the last Dialog rendered *open* (a closed Dialog from
|
|
113
|
+
// an always-mounted RenderEditActionButton never overwrites it) — lets a
|
|
114
|
+
// test assert a confirm was requested and drive its onConfirm, without a
|
|
115
|
+
// real dialog/portal implementation.
|
|
116
|
+
type DialogSpy = ComponentType<DialogProps> & { lastProps: DialogProps | null };
|
|
117
|
+
const TestDialog: DialogSpy = Object.assign(
|
|
118
|
+
(props: DialogProps): ReactNode => {
|
|
119
|
+
if (props.open) TestDialog.lastProps = props;
|
|
120
|
+
return null;
|
|
121
|
+
},
|
|
122
|
+
{ lastProps: null as DialogProps | null },
|
|
123
|
+
);
|
|
124
|
+
|
|
73
125
|
const testPrimitives: CorePrimitives = {
|
|
74
126
|
Button: TestButton,
|
|
75
127
|
Banner: TestBanner,
|
|
@@ -83,12 +135,13 @@ const testPrimitives: CorePrimitives = {
|
|
|
83
135
|
GridCell: passChildren,
|
|
84
136
|
Text: passChildren,
|
|
85
137
|
Heading: noop,
|
|
86
|
-
Dialog:
|
|
138
|
+
Dialog: TestDialog,
|
|
87
139
|
Modal: noop,
|
|
88
140
|
Lightbox: noop,
|
|
89
141
|
ConfigSourceBadge: noop,
|
|
90
142
|
ConfigCascadeView: noop,
|
|
91
143
|
Link: noop,
|
|
144
|
+
ActionOverflowMenu: TestActionOverflowMenu,
|
|
92
145
|
};
|
|
93
146
|
|
|
94
147
|
function stubDispatcher(
|
|
@@ -416,6 +469,197 @@ describe("projectionDetail default edit action (fw#2166)", () => {
|
|
|
416
469
|
expect(navigated).toEqual({ screenId: "rent-edit", entityId: "rent-1" });
|
|
417
470
|
});
|
|
418
471
|
|
|
472
|
+
test("two header actions → both render as buttons, no overflow trigger (fw bedienkonzept A7)", async () => {
|
|
473
|
+
const schema: FeatureSchema = {
|
|
474
|
+
featureName: "app",
|
|
475
|
+
entities: {},
|
|
476
|
+
screens: [
|
|
477
|
+
detailScreen({
|
|
478
|
+
actions: [
|
|
479
|
+
{ kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
|
|
480
|
+
{ kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
|
|
481
|
+
],
|
|
482
|
+
}),
|
|
483
|
+
editScreen("rent"),
|
|
484
|
+
],
|
|
485
|
+
};
|
|
486
|
+
const { getByTestId, queryByTestId, queryByText } = renderDetail({
|
|
487
|
+
primarySchema: schema,
|
|
488
|
+
features: [schema],
|
|
489
|
+
userRoles: [],
|
|
490
|
+
});
|
|
491
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
492
|
+
|
|
493
|
+
expect(getByTestId("render-edit-action-edit").textContent).toBe("actions.edit");
|
|
494
|
+
expect(getByTestId("render-edit-action-duplicate").textContent).toBe("actions.duplicate");
|
|
495
|
+
expect(queryByTestId("kumiko-screen-projection-detail-actions-overflow")).toBeNull();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("three header actions → edit stays a text button, the rest collapse into the overflow menu (fw bedienkonzept A7)", async () => {
|
|
499
|
+
const schema: FeatureSchema = {
|
|
500
|
+
featureName: "app",
|
|
501
|
+
entities: {},
|
|
502
|
+
screens: [
|
|
503
|
+
detailScreen({
|
|
504
|
+
actions: [
|
|
505
|
+
{ kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
|
|
506
|
+
{ kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
|
|
507
|
+
{ kind: "navigate", id: "audit-log", label: "actions.auditLog", screen: "rent-edit" },
|
|
508
|
+
],
|
|
509
|
+
}),
|
|
510
|
+
editScreen("rent"),
|
|
511
|
+
],
|
|
512
|
+
};
|
|
513
|
+
const { getByTestId, queryByTestId, queryByText } = renderDetail({
|
|
514
|
+
primarySchema: schema,
|
|
515
|
+
features: [schema],
|
|
516
|
+
userRoles: [],
|
|
517
|
+
});
|
|
518
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
519
|
+
|
|
520
|
+
expect(getByTestId("render-edit-action-edit").textContent).toBe("actions.edit");
|
|
521
|
+
expect(queryByTestId("render-edit-action-duplicate")).toBeNull();
|
|
522
|
+
expect(queryByTestId("render-edit-action-audit-log")).toBeNull();
|
|
523
|
+
|
|
524
|
+
fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow"));
|
|
525
|
+
expect(
|
|
526
|
+
getByTestId("kumiko-screen-projection-detail-actions-overflow-item-duplicate").textContent,
|
|
527
|
+
).toBe("actions.duplicate");
|
|
528
|
+
expect(
|
|
529
|
+
getByTestId("kumiko-screen-projection-detail-actions-overflow-item-audit-log").textContent,
|
|
530
|
+
).toBe("actions.auditLog");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("four header actions incl. edit → edit stays a text button, the other three are only reachable via the overflow menu (fw bedienkonzept A7)", async () => {
|
|
534
|
+
const schema: FeatureSchema = {
|
|
535
|
+
featureName: "app",
|
|
536
|
+
entities: {},
|
|
537
|
+
screens: [
|
|
538
|
+
detailScreen({
|
|
539
|
+
actions: [
|
|
540
|
+
{ kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
|
|
541
|
+
{ kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
|
|
542
|
+
{ kind: "navigate", id: "audit-log", label: "actions.auditLog", screen: "rent-edit" },
|
|
543
|
+
{ kind: "navigate", id: "archive", label: "actions.archive", screen: "rent-edit" },
|
|
544
|
+
],
|
|
545
|
+
}),
|
|
546
|
+
editScreen("rent"),
|
|
547
|
+
],
|
|
548
|
+
};
|
|
549
|
+
const { getByTestId, queryByTestId, queryByText } = renderDetail({
|
|
550
|
+
primarySchema: schema,
|
|
551
|
+
features: [schema],
|
|
552
|
+
userRoles: [],
|
|
553
|
+
});
|
|
554
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
555
|
+
|
|
556
|
+
expect(getByTestId("render-edit-action-edit").textContent).toBe("actions.edit");
|
|
557
|
+
expect(queryByTestId("render-edit-action-duplicate")).toBeNull();
|
|
558
|
+
expect(queryByTestId("render-edit-action-audit-log")).toBeNull();
|
|
559
|
+
expect(queryByTestId("render-edit-action-archive")).toBeNull();
|
|
560
|
+
|
|
561
|
+
fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow"));
|
|
562
|
+
expect(
|
|
563
|
+
getByTestId("kumiko-screen-projection-detail-actions-overflow-item-duplicate").textContent,
|
|
564
|
+
).toBe("actions.duplicate");
|
|
565
|
+
expect(
|
|
566
|
+
getByTestId("kumiko-screen-projection-detail-actions-overflow-item-audit-log").textContent,
|
|
567
|
+
).toBe("actions.auditLog");
|
|
568
|
+
expect(
|
|
569
|
+
getByTestId("kumiko-screen-projection-detail-actions-overflow-item-archive").textContent,
|
|
570
|
+
).toBe("actions.archive");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test('header action without an id: "edit" entry → the style: "primary" action stays the visible button (fw bedienkonzept A7)', async () => {
|
|
574
|
+
const schema: FeatureSchema = {
|
|
575
|
+
featureName: "app",
|
|
576
|
+
entities: {},
|
|
577
|
+
screens: [
|
|
578
|
+
detailScreen({
|
|
579
|
+
actions: [
|
|
580
|
+
{ kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
|
|
581
|
+
{
|
|
582
|
+
kind: "navigate",
|
|
583
|
+
id: "approve",
|
|
584
|
+
label: "actions.approve",
|
|
585
|
+
screen: "rent-edit",
|
|
586
|
+
style: "primary",
|
|
587
|
+
},
|
|
588
|
+
{ kind: "navigate", id: "audit-log", label: "actions.auditLog", screen: "rent-edit" },
|
|
589
|
+
],
|
|
590
|
+
}),
|
|
591
|
+
],
|
|
592
|
+
};
|
|
593
|
+
const { getByTestId, queryByTestId, queryByText } = renderDetail({
|
|
594
|
+
primarySchema: schema,
|
|
595
|
+
features: [schema],
|
|
596
|
+
userRoles: [],
|
|
597
|
+
});
|
|
598
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
599
|
+
|
|
600
|
+
expect(getByTestId("render-edit-action-approve").textContent).toBe("actions.approve");
|
|
601
|
+
expect(queryByTestId("render-edit-action-duplicate")).toBeNull();
|
|
602
|
+
expect(queryByTestId("render-edit-action-audit-log")).toBeNull();
|
|
603
|
+
|
|
604
|
+
fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow"));
|
|
605
|
+
expect(
|
|
606
|
+
getByTestId("kumiko-screen-projection-detail-actions-overflow-item-duplicate").textContent,
|
|
607
|
+
).toBe("actions.duplicate");
|
|
608
|
+
expect(
|
|
609
|
+
getByTestId("kumiko-screen-projection-detail-actions-overflow-item-audit-log").textContent,
|
|
610
|
+
).toBe("actions.auditLog");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("a danger overflow-menu action still asks for confirmation before firing (fw bedienkonzept A7)", async () => {
|
|
614
|
+
const schema: FeatureSchema = {
|
|
615
|
+
featureName: "app",
|
|
616
|
+
entities: {},
|
|
617
|
+
screens: [
|
|
618
|
+
detailScreen({
|
|
619
|
+
actions: [
|
|
620
|
+
{ kind: "navigate", id: "edit", label: "actions.edit", screen: "rent-edit" },
|
|
621
|
+
{ kind: "navigate", id: "duplicate", label: "actions.duplicate", screen: "rent-edit" },
|
|
622
|
+
{
|
|
623
|
+
kind: "writeHandler",
|
|
624
|
+
id: "archive",
|
|
625
|
+
label: "actions.archive",
|
|
626
|
+
handler: "app:write:archive",
|
|
627
|
+
style: "danger",
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
}),
|
|
631
|
+
editScreen("rent"),
|
|
632
|
+
],
|
|
633
|
+
};
|
|
634
|
+
// `as DialogProps | null` widens the assignment so tsc doesn't narrow
|
|
635
|
+
// the property to the literal `null` type, which turns the later
|
|
636
|
+
// optional-chained reads below into `never` (compiler quirk).
|
|
637
|
+
TestDialog.lastProps = null as DialogProps | null;
|
|
638
|
+
const { getByTestId, queryByTestId, queryByText } = renderDetail({
|
|
639
|
+
primarySchema: schema,
|
|
640
|
+
features: [schema],
|
|
641
|
+
userRoles: [],
|
|
642
|
+
writeErrorMessage: "archive failed: rent is still active",
|
|
643
|
+
});
|
|
644
|
+
await waitFor(() => expect(queryByText("Loading…")).toBeNull());
|
|
645
|
+
|
|
646
|
+
fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow"));
|
|
647
|
+
fireEvent.click(getByTestId("kumiko-screen-projection-detail-actions-overflow-item-archive"));
|
|
648
|
+
|
|
649
|
+
// Danger action must not fire on the menu click alone — it needs the
|
|
650
|
+
// confirm dialog first, same as a danger row action.
|
|
651
|
+
expect(queryByTestId("render-edit-action-error")).toBeNull();
|
|
652
|
+
expect(TestDialog.lastProps?.open).toBe(true);
|
|
653
|
+
expect(TestDialog.lastProps?.title).toBe("actions.archive");
|
|
654
|
+
|
|
655
|
+
await act(async () => {
|
|
656
|
+
await TestDialog.lastProps?.onConfirm();
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const errorBanner = await waitFor(() => getByTestId("render-edit-action-error"));
|
|
660
|
+
expect(errorBanner.textContent).toBe("archive failed: rent is still active");
|
|
661
|
+
});
|
|
662
|
+
|
|
419
663
|
test("a failed writeHandler action shows its error in the head region, alongside the action button, NOT in Form's own actions/body regions (fw#2713)", async () => {
|
|
420
664
|
const schema: FeatureSchema = {
|
|
421
665
|
featureName: "app",
|
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
EntityDefinition,
|
|
7
7
|
EntityEditScreenDefinition,
|
|
8
8
|
EntityListScreenDefinition,
|
|
9
|
+
MetricNavigate,
|
|
10
|
+
MetricSpec,
|
|
9
11
|
ProjectionDetailScreenDefinition,
|
|
10
12
|
ProjectionListScreenDefinition,
|
|
11
13
|
RowAction,
|
|
@@ -14,7 +16,11 @@ import type {
|
|
|
14
16
|
ScreenDefinition,
|
|
15
17
|
ToolbarAction,
|
|
16
18
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
evalFieldCondition,
|
|
21
|
+
isWriteFormEditSection,
|
|
22
|
+
metricField,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
18
24
|
import type {
|
|
19
25
|
Command,
|
|
20
26
|
FormSnapshot,
|
|
@@ -23,11 +29,15 @@ import type {
|
|
|
23
29
|
SubmitResult,
|
|
24
30
|
Translate,
|
|
25
31
|
} from "@cosmicdrift/kumiko-headless";
|
|
26
|
-
import { fieldLabelKey, fieldOptionLabelKey } from "@cosmicdrift/kumiko-headless";
|
|
32
|
+
import { fieldLabelKey, fieldOptionLabelKey, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
27
33
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
28
34
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog";
|
|
29
35
|
import { RenderEdit, type RenderEditAction } from "../components/render-edit";
|
|
30
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
needsActionConfirm,
|
|
38
|
+
RenderEditActionButton,
|
|
39
|
+
RenderEditActionConfirmDialog,
|
|
40
|
+
} from "../components/render-edit-action-button";
|
|
31
41
|
import { RenderList, type ToolbarActionButton } from "../components/render-list";
|
|
32
42
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
33
43
|
import { useUserRoles } from "../context/user-roles-context";
|
|
@@ -37,7 +47,6 @@ import { useTranslation } from "../i18n";
|
|
|
37
47
|
import {
|
|
38
48
|
type DataTableFacet,
|
|
39
49
|
type DataTableRowAction,
|
|
40
|
-
shouldRenderActionsIconOnly,
|
|
41
50
|
statusToneForValue,
|
|
42
51
|
usePrimitives,
|
|
43
52
|
} from "../primitives";
|
|
@@ -55,7 +64,7 @@ import {
|
|
|
55
64
|
type ResolvedFacetSpec,
|
|
56
65
|
resolveProjectionFacetSpecs,
|
|
57
66
|
} from "./list-facets";
|
|
58
|
-
import { useNav } from "./nav";
|
|
67
|
+
import { type NavApi, useNav } from "./nav";
|
|
59
68
|
import {
|
|
60
69
|
synthesizeProjectionDetailEntity,
|
|
61
70
|
synthesizeProjectionDetailScreen,
|
|
@@ -69,7 +78,6 @@ import {
|
|
|
69
78
|
isWriteHandlerRowAction,
|
|
70
79
|
refetchAfterWrite,
|
|
71
80
|
resolveActionIcon,
|
|
72
|
-
rowActionModeFor,
|
|
73
81
|
runProjectionRowNavigate,
|
|
74
82
|
stringifyNavParams,
|
|
75
83
|
} from "./row-actions";
|
|
@@ -1614,12 +1622,6 @@ function EntityListBody({
|
|
|
1614
1622
|
openDrawer,
|
|
1615
1623
|
]);
|
|
1616
1624
|
|
|
1617
|
-
// Row actions that all resolve an icon render inline and collapse to
|
|
1618
|
-
// icon-only (fw#2580) — the adaptive default would bury more than two of
|
|
1619
|
-
// them in a kebab menu. A group with an icon-less member stays adaptive so
|
|
1620
|
-
// it never degrades into wall-to-wall text buttons.
|
|
1621
|
-
const rowActionMode = rowActionModeFor(rowActions);
|
|
1622
|
-
|
|
1623
1625
|
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
1624
1626
|
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
1625
1627
|
// → dispatcher.write(handler, payload?()). KumikoScreen kennt schon
|
|
@@ -1764,7 +1766,6 @@ function EntityListBody({
|
|
|
1764
1766
|
screenPadding
|
|
1765
1767
|
{...(pager !== undefined && { pager })}
|
|
1766
1768
|
{...(rowActions !== undefined && { rowActions })}
|
|
1767
|
-
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
1768
1769
|
{...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
|
|
1769
1770
|
{...(useInfinite && {
|
|
1770
1771
|
onReachEnd: loadMore,
|
|
@@ -1930,10 +1931,6 @@ function ProjectionListBody({
|
|
|
1930
1931
|
[screen.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, openDrawer],
|
|
1931
1932
|
);
|
|
1932
1933
|
|
|
1933
|
-
// Same icon-only collapse as entityList (fw#2580) — projectionList rows go
|
|
1934
|
-
// through the identical RenderList/DataTable path.
|
|
1935
|
-
const rowActionMode = rowActionModeFor(rowActions);
|
|
1936
|
-
|
|
1937
1934
|
const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
|
|
1938
1935
|
if (screen.toolbarActions === undefined) return undefined;
|
|
1939
1936
|
const out: ToolbarActionButton[] = [];
|
|
@@ -2055,7 +2052,6 @@ function ProjectionListBody({
|
|
|
2055
2052
|
screenPadding
|
|
2056
2053
|
{...(pager !== undefined && { pager })}
|
|
2057
2054
|
{...(rowActions !== undefined && { rowActions })}
|
|
2058
|
-
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
2059
2055
|
{...(toolbarActions !== undefined && { toolbarActions })}
|
|
2060
2056
|
{...(translate !== undefined && { translate })}
|
|
2061
2057
|
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
@@ -2083,6 +2079,65 @@ function ProjectionListBody({
|
|
|
2083
2079
|
);
|
|
2084
2080
|
}
|
|
2085
2081
|
|
|
2082
|
+
function metricLabelKey(
|
|
2083
|
+
metric: MetricSpec,
|
|
2084
|
+
fieldLabels: Readonly<Record<string, string>> | undefined,
|
|
2085
|
+
): string | undefined {
|
|
2086
|
+
if (typeof metric !== "string" && metric.label !== undefined) return metric.label;
|
|
2087
|
+
return fieldLabels?.[metricField(metric)];
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function metricNavigateSpec(metric: MetricSpec): MetricNavigate | undefined {
|
|
2091
|
+
return typeof metric === "string" ? undefined : metric.navigate;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// Reuses runProjectionRowNavigate (row-actions.ts) instead of re-deriving
|
|
2095
|
+
// navigate execution — a metric click has the same entity/screen/params
|
|
2096
|
+
// shape as a RowActionNavigate, minus the fields (id, label, style) that
|
|
2097
|
+
// function never reads.
|
|
2098
|
+
function runMetricNavigate(
|
|
2099
|
+
nav: NavApi,
|
|
2100
|
+
navigate: MetricNavigate,
|
|
2101
|
+
record: Readonly<Record<string, unknown>>,
|
|
2102
|
+
): void {
|
|
2103
|
+
const base = { kind: "navigate" as const, id: "metric-navigate", label: "" };
|
|
2104
|
+
const action: RowActionNavigate | undefined =
|
|
2105
|
+
navigate.entity !== undefined
|
|
2106
|
+
? {
|
|
2107
|
+
...base,
|
|
2108
|
+
entity: navigate.entity,
|
|
2109
|
+
...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
|
|
2110
|
+
...(navigate.params !== undefined && { params: navigate.params }),
|
|
2111
|
+
}
|
|
2112
|
+
: navigate.screen !== undefined
|
|
2113
|
+
? {
|
|
2114
|
+
...base,
|
|
2115
|
+
screen: navigate.screen,
|
|
2116
|
+
...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
|
|
2117
|
+
...(navigate.params !== undefined && { params: navigate.params }),
|
|
2118
|
+
}
|
|
2119
|
+
: undefined;
|
|
2120
|
+
if (action === undefined) return;
|
|
2121
|
+
runProjectionRowNavigate(nav, action, { id: "", values: record });
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
// Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
|
|
2125
|
+
// stricter than isSafeHref (which also allows relative paths and mailto:),
|
|
2126
|
+
// since a relative/mailto value here should render as plain text, not a
|
|
2127
|
+
// "_blank" external link.
|
|
2128
|
+
function isAbsoluteHttpUrl(value: string): boolean {
|
|
2129
|
+
return /^https?:\/\//i.test(value) && isSafeHref(value);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function resolveSubtitleHref(
|
|
2133
|
+
header: ProjectionDetailScreenDefinition["header"],
|
|
2134
|
+
record: Readonly<Record<string, unknown>>,
|
|
2135
|
+
): string | undefined {
|
|
2136
|
+
if (header?.subtitleHref === undefined) return undefined;
|
|
2137
|
+
const value = record[header.subtitleHref];
|
|
2138
|
+
return typeof value === "string" && isAbsoluteHttpUrl(value) ? value : undefined;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2086
2141
|
// Projection-Detail-Body — read-only single-row inspector über eine explizite
|
|
2087
2142
|
// Query statt einer Entity (siehe projection-detail-shim.ts für die Schulden-
|
|
2088
2143
|
// Doku). Fetcht selbst über `screen.query` + `idParam` (analog zu
|
|
@@ -2093,6 +2148,108 @@ function ProjectionListBody({
|
|
|
2093
2148
|
// natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
|
|
2094
2149
|
// controller.submit() ohne submit-config throwen — der No-Op macht diesen
|
|
2095
2150
|
// Pfad harmlos statt ihn dem Zufall zu überlassen.
|
|
2151
|
+
// The primary header action always stays a visible text button — `edit` if
|
|
2152
|
+
// declared, else the first `style: "primary"` action, else the first action
|
|
2153
|
+
// at all. One step longer than row actions' `primaryRowAction` (renderer-web
|
|
2154
|
+
// primitives/index.tsx): rows don't declare `style: "primary"`.
|
|
2155
|
+
function primaryHeaderAction(actions: readonly RenderEditAction[]): RenderEditAction | undefined {
|
|
2156
|
+
return (
|
|
2157
|
+
actions.find((a) => a.id === "edit") ?? actions.find((a) => a.style === "primary") ?? actions[0]
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// Header actions bar (A7): <=2 actions render as plain buttons; >2 with an
|
|
2162
|
+
// `ActionOverflowMenu` primitive collapse to the primary button plus a menu
|
|
2163
|
+
// for the rest — same rule as RowActionsCell for table rows. Owns confirm
|
|
2164
|
+
// state for menu items: RenderEditActionButton already owns a per-button
|
|
2165
|
+
// confirm dialog for the always-visible primary action, but a menu item has
|
|
2166
|
+
// no button of its own to carry one.
|
|
2167
|
+
function HeaderActionsBar({
|
|
2168
|
+
actions,
|
|
2169
|
+
Button,
|
|
2170
|
+
Dialog,
|
|
2171
|
+
ActionOverflowMenu,
|
|
2172
|
+
onError,
|
|
2173
|
+
}: {
|
|
2174
|
+
readonly actions: readonly RenderEditAction[];
|
|
2175
|
+
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
2176
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
2177
|
+
readonly ActionOverflowMenu: ReturnType<typeof usePrimitives>["ActionOverflowMenu"];
|
|
2178
|
+
readonly onError: (text: string | null) => void;
|
|
2179
|
+
}): ReactNode {
|
|
2180
|
+
const [pendingAction, setPendingAction] = useState<RenderEditAction | null>(null);
|
|
2181
|
+
const trigger = async (action: RenderEditAction): Promise<void> => {
|
|
2182
|
+
onError(null);
|
|
2183
|
+
try {
|
|
2184
|
+
await action.onPress();
|
|
2185
|
+
} catch (e) {
|
|
2186
|
+
onError(e instanceof Error ? e.message : String(e));
|
|
2187
|
+
}
|
|
2188
|
+
};
|
|
2189
|
+
if (actions.length <= 2 || ActionOverflowMenu === undefined) {
|
|
2190
|
+
return (
|
|
2191
|
+
<>
|
|
2192
|
+
{actions.map((action) => (
|
|
2193
|
+
<RenderEditActionButton
|
|
2194
|
+
key={action.id}
|
|
2195
|
+
action={action}
|
|
2196
|
+
Button={Button}
|
|
2197
|
+
Dialog={Dialog}
|
|
2198
|
+
onError={onError}
|
|
2199
|
+
/>
|
|
2200
|
+
))}
|
|
2201
|
+
</>
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
const primary = primaryHeaderAction(actions);
|
|
2205
|
+
const rest = actions.filter((a) => a.id !== primary?.id);
|
|
2206
|
+
return (
|
|
2207
|
+
<>
|
|
2208
|
+
{primary !== undefined && (
|
|
2209
|
+
<RenderEditActionButton
|
|
2210
|
+
key={primary.id}
|
|
2211
|
+
action={primary}
|
|
2212
|
+
Button={Button}
|
|
2213
|
+
Dialog={Dialog}
|
|
2214
|
+
onError={onError}
|
|
2215
|
+
/>
|
|
2216
|
+
)}
|
|
2217
|
+
<ActionOverflowMenu
|
|
2218
|
+
label="More actions"
|
|
2219
|
+
testId="kumiko-screen-projection-detail-actions-overflow"
|
|
2220
|
+
items={rest.map((action) => ({
|
|
2221
|
+
id: action.id,
|
|
2222
|
+
label: action.label,
|
|
2223
|
+
...(action.icon !== undefined && { icon: action.icon }),
|
|
2224
|
+
variant: action.style === "danger" ? ("danger" as const) : ("default" as const),
|
|
2225
|
+
onSelect: () => {
|
|
2226
|
+
if (needsActionConfirm(action)) {
|
|
2227
|
+
setPendingAction(action);
|
|
2228
|
+
} else {
|
|
2229
|
+
void trigger(action);
|
|
2230
|
+
}
|
|
2231
|
+
},
|
|
2232
|
+
}))}
|
|
2233
|
+
/>
|
|
2234
|
+
{pendingAction !== null && (
|
|
2235
|
+
<RenderEditActionConfirmDialog
|
|
2236
|
+
action={pendingAction}
|
|
2237
|
+
open={true}
|
|
2238
|
+
onOpenChange={(open) => {
|
|
2239
|
+
if (!open) setPendingAction(null);
|
|
2240
|
+
}}
|
|
2241
|
+
onConfirm={async () => {
|
|
2242
|
+
const action = pendingAction;
|
|
2243
|
+
setPendingAction(null);
|
|
2244
|
+
await trigger(action);
|
|
2245
|
+
}}
|
|
2246
|
+
Dialog={Dialog}
|
|
2247
|
+
/>
|
|
2248
|
+
)}
|
|
2249
|
+
</>
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2096
2253
|
function ProjectionDetailBody({
|
|
2097
2254
|
schema,
|
|
2098
2255
|
screen,
|
|
@@ -2107,8 +2264,21 @@ function ProjectionDetailBody({
|
|
|
2107
2264
|
readonly translate?: Translate;
|
|
2108
2265
|
readonly entityId?: string;
|
|
2109
2266
|
}): ReactNode {
|
|
2110
|
-
const {
|
|
2111
|
-
|
|
2267
|
+
const {
|
|
2268
|
+
Banner,
|
|
2269
|
+
Button,
|
|
2270
|
+
Dialog,
|
|
2271
|
+
Text,
|
|
2272
|
+
Heading,
|
|
2273
|
+
Grid,
|
|
2274
|
+
GridCell,
|
|
2275
|
+
Card,
|
|
2276
|
+
Tabs,
|
|
2277
|
+
StatusBadge,
|
|
2278
|
+
Metric,
|
|
2279
|
+
Link,
|
|
2280
|
+
ActionOverflowMenu,
|
|
2281
|
+
} = usePrimitives();
|
|
2112
2282
|
const t = useTranslation();
|
|
2113
2283
|
const effectiveTranslate = translate ?? t;
|
|
2114
2284
|
const nav = useNav();
|
|
@@ -2127,6 +2297,7 @@ function ProjectionDetailBody({
|
|
|
2127
2297
|
screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]
|
|
2128
2298
|
);
|
|
2129
2299
|
}, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
|
|
2300
|
+
const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
|
|
2130
2301
|
// Tabs is an optional Core-Primitive: without it the screen falls back to
|
|
2131
2302
|
// the stacked all-sections layout instead of silently truncating to section 1.
|
|
2132
2303
|
useEffect(() => {
|
|
@@ -2395,28 +2566,23 @@ function ProjectionDetailBody({
|
|
|
2395
2566
|
}
|
|
2396
2567
|
const hasHeader = screen.header !== undefined;
|
|
2397
2568
|
const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
|
|
2398
|
-
const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
|
|
2399
2569
|
// ?? [] rather than threading `headerActions !== undefined` through every
|
|
2400
|
-
// use below — an empty array is a safe no-op for .map/.length
|
|
2570
|
+
// use below — an empty array is a safe no-op for .map/.length.
|
|
2401
2571
|
const headerActionsList = headerActions ?? [];
|
|
2402
2572
|
const hasHeaderActions = headerActionsList.length > 0;
|
|
2403
|
-
const headerActionsIconOnly = shouldRenderActionsIconOnly(headerActionsList);
|
|
2404
2573
|
// Grouped into the head Card alongside title/status/metrics (fw#2713):
|
|
2405
2574
|
// these are actions on the record the head shows, not on whichever tab is
|
|
2406
2575
|
// open, so they must stay in place across tab switches instead of
|
|
2407
2576
|
// trailing the active tab's content in the card footer.
|
|
2408
2577
|
const headerActionsContent = hasHeaderActions && (
|
|
2409
2578
|
<Grid columns="auto" testId="kumiko-screen-projection-detail-actions">
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
onError={setActionError}
|
|
2418
|
-
/>
|
|
2419
|
-
))}
|
|
2579
|
+
<HeaderActionsBar
|
|
2580
|
+
actions={headerActionsList}
|
|
2581
|
+
Button={Button}
|
|
2582
|
+
Dialog={Dialog}
|
|
2583
|
+
ActionOverflowMenu={ActionOverflowMenu}
|
|
2584
|
+
onError={setActionError}
|
|
2585
|
+
/>
|
|
2420
2586
|
</Grid>
|
|
2421
2587
|
);
|
|
2422
2588
|
const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
|
|
@@ -2425,6 +2591,7 @@ function ProjectionDetailBody({
|
|
|
2425
2591
|
// it, instead of sitting flush against the screen edge (fw record-screen
|
|
2426
2592
|
// header polish).
|
|
2427
2593
|
const header = screen.header;
|
|
2594
|
+
const subtitleHref = resolveSubtitleHref(header, record);
|
|
2428
2595
|
const headerContent = (
|
|
2429
2596
|
<>
|
|
2430
2597
|
{hasHeaderCard && (
|
|
@@ -2436,11 +2603,20 @@ function ProjectionDetailBody({
|
|
|
2436
2603
|
</Heading>
|
|
2437
2604
|
{(header.subtitle !== undefined || header.status !== undefined) && (
|
|
2438
2605
|
<Grid columns="auto">
|
|
2439
|
-
{header.subtitle !== undefined &&
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2606
|
+
{header.subtitle !== undefined &&
|
|
2607
|
+
(subtitleHref !== undefined ? (
|
|
2608
|
+
<Link
|
|
2609
|
+
href={subtitleHref}
|
|
2610
|
+
target="_blank"
|
|
2611
|
+
testId="kumiko-screen-projection-detail-subtitle"
|
|
2612
|
+
>
|
|
2613
|
+
{String(record[header.subtitle] ?? "")}
|
|
2614
|
+
</Link>
|
|
2615
|
+
) : (
|
|
2616
|
+
<Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
|
|
2617
|
+
{String(record[header.subtitle] ?? "")}
|
|
2618
|
+
</Text>
|
|
2619
|
+
))}
|
|
2444
2620
|
{header.status !== undefined &&
|
|
2445
2621
|
(StatusBadge !== undefined ? (
|
|
2446
2622
|
<StatusBadge
|
|
@@ -2463,14 +2639,26 @@ function ProjectionDetailBody({
|
|
|
2463
2639
|
testId="kumiko-screen-projection-detail-metrics"
|
|
2464
2640
|
>
|
|
2465
2641
|
{screen.metrics?.map((metric) => {
|
|
2466
|
-
const
|
|
2467
|
-
const
|
|
2468
|
-
const
|
|
2469
|
-
const
|
|
2642
|
+
const field = metricField(metric);
|
|
2643
|
+
const labelKey = metricLabelKey(metric, screen.fieldLabels);
|
|
2644
|
+
const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
|
|
2645
|
+
const value = String(record[field] ?? "");
|
|
2646
|
+
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
2647
|
+
const navigate = metricNavigateSpec(metric);
|
|
2648
|
+
const onPress =
|
|
2649
|
+
navigate !== undefined
|
|
2650
|
+
? () => runMetricNavigate(nav, navigate, record)
|
|
2651
|
+
: undefined;
|
|
2470
2652
|
return Metric !== undefined ? (
|
|
2471
|
-
<Metric
|
|
2653
|
+
<Metric
|
|
2654
|
+
key={field}
|
|
2655
|
+
label={label}
|
|
2656
|
+
value={value}
|
|
2657
|
+
testId={testId}
|
|
2658
|
+
{...(onPress !== undefined && { onPress })}
|
|
2659
|
+
/>
|
|
2472
2660
|
) : (
|
|
2473
|
-
<GridCell key={
|
|
2661
|
+
<GridCell key={field}>
|
|
2474
2662
|
<Text variant="small" testId={`${testId}-label`}>
|
|
2475
2663
|
{label}
|
|
2476
2664
|
</Text>
|
|
@@ -2491,10 +2679,19 @@ function ProjectionDetailBody({
|
|
|
2491
2679
|
{hasTabs && activeSection !== undefined && (
|
|
2492
2680
|
<Tabs
|
|
2493
2681
|
testId="kumiko-screen-projection-detail-tabs"
|
|
2494
|
-
items={screen.layout.sections.map((section) =>
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2682
|
+
items={screen.layout.sections.map((section) => {
|
|
2683
|
+
const countField = isWriteFormEditSection(section) ? undefined : section.countField;
|
|
2684
|
+
const countValue = countField !== undefined ? record[countField] : undefined;
|
|
2685
|
+
const count =
|
|
2686
|
+
typeof countValue === "number" && Number.isFinite(countValue)
|
|
2687
|
+
? countValue
|
|
2688
|
+
: undefined;
|
|
2689
|
+
return {
|
|
2690
|
+
id: section.id ?? "",
|
|
2691
|
+
label: effectiveTranslate(section.title ?? section.id ?? ""),
|
|
2692
|
+
...(count !== undefined && { count }),
|
|
2693
|
+
};
|
|
2694
|
+
})}
|
|
2498
2695
|
activeId={activeSection.id ?? ""}
|
|
2499
2696
|
onSelect={(id) => nav.setSearchParams({ tab: id })}
|
|
2500
2697
|
/>
|
package/src/app/row-actions.ts
CHANGED
|
@@ -8,8 +8,7 @@ import type {
|
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
9
9
|
import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
10
|
import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
11
|
-
import type { DataTableRowAction
|
|
12
|
-
import { shouldRenderActionsIconOnly } from "../primitives";
|
|
11
|
+
import type { DataTableRowAction } from "../primitives";
|
|
13
12
|
import type { NavApi } from "./nav";
|
|
14
13
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
15
14
|
|
|
@@ -82,18 +81,6 @@ export function resolveActionIcon(id: string, declared?: IconKey): IconKey | und
|
|
|
82
81
|
);
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
// Row-action column mode for a resolved action set: a group where every
|
|
86
|
-
// member carries an icon renders inline so `shouldRenderActionsIconOnly`
|
|
87
|
-
// can collapse it to icon-only buttons (fw#2580). Anything else keeps the
|
|
88
|
-
// DataTable's adaptive default (kebab past two actions) — inline text
|
|
89
|
-
// buttons for an icon-less group are the very thing the collapse avoids.
|
|
90
|
-
export function rowActionModeFor(
|
|
91
|
-
actions: readonly DataTableRowAction[] | undefined,
|
|
92
|
-
): DataTableRowActionMode | undefined {
|
|
93
|
-
if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
|
|
94
|
-
return "inline";
|
|
95
|
-
}
|
|
96
|
-
|
|
97
84
|
export function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
|
|
98
85
|
const out: Record<string, string | null> = {};
|
|
99
86
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -17,11 +17,7 @@ import {
|
|
|
17
17
|
resolveProjectionFacetSpecs,
|
|
18
18
|
} from "../app/list-facets";
|
|
19
19
|
import { useNav } from "../app/nav";
|
|
20
|
-
import {
|
|
21
|
-
buildProjectionRowActions,
|
|
22
|
-
rowActionModeFor,
|
|
23
|
-
runProjectionRowNavigate,
|
|
24
|
-
} from "../app/row-actions";
|
|
20
|
+
import { buildProjectionRowActions, runProjectionRowNavigate } from "../app/row-actions";
|
|
25
21
|
import { dispatcherErrorText } from "../app/write-failed-error";
|
|
26
22
|
import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
27
23
|
import type { ListSort } from "../hooks/use-list-url-state";
|
|
@@ -202,7 +198,6 @@ export function RelatedListSection({
|
|
|
202
198
|
}),
|
|
203
199
|
[section.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch, onOpenDrawer],
|
|
204
200
|
);
|
|
205
|
-
const rowActionMode = rowActionModeFor(rowActions);
|
|
206
201
|
|
|
207
202
|
// A truncated fetch means `sortedRows` is a sort of a partial set, not of
|
|
208
203
|
// the full related-row set — the client-side sort above (or even plain
|
|
@@ -251,7 +246,6 @@ export function RelatedListSection({
|
|
|
251
246
|
})}
|
|
252
247
|
{...(onRowClick !== undefined && { onRowClick })}
|
|
253
248
|
{...(rowActions !== undefined && { rowActions })}
|
|
254
|
-
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
255
249
|
{...(hideTitle === true && { chromeless: true, scrollBody: true })}
|
|
256
250
|
/>
|
|
257
251
|
</>
|
|
@@ -3,6 +3,41 @@ import { useState } from "react";
|
|
|
3
3
|
import type { usePrimitives } from "../primitives";
|
|
4
4
|
import type { RenderEditAction } from "./render-edit-types";
|
|
5
5
|
|
|
6
|
+
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
7
|
+
// without an explicit confirm key — unless `confirmRequired` overrides it
|
|
8
|
+
// (schema-driven navigate/drawer actions, where the target form is itself
|
|
9
|
+
// the confirmation).
|
|
10
|
+
export function needsActionConfirm(action: RenderEditAction): boolean {
|
|
11
|
+
return action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function RenderEditActionConfirmDialog({
|
|
15
|
+
action,
|
|
16
|
+
open,
|
|
17
|
+
onOpenChange,
|
|
18
|
+
onConfirm,
|
|
19
|
+
Dialog,
|
|
20
|
+
}: {
|
|
21
|
+
readonly action: RenderEditAction;
|
|
22
|
+
readonly open: boolean;
|
|
23
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
24
|
+
readonly onConfirm: () => void | Promise<void>;
|
|
25
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
26
|
+
}): ReactNode {
|
|
27
|
+
return (
|
|
28
|
+
<Dialog
|
|
29
|
+
open={open}
|
|
30
|
+
onOpenChange={onOpenChange}
|
|
31
|
+
title={action.label}
|
|
32
|
+
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
33
|
+
confirmLabel={action.confirmLabel ?? action.label}
|
|
34
|
+
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
35
|
+
onConfirm={onConfirm}
|
|
36
|
+
testId={`render-edit-action-${action.id}-dialog`}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
6
41
|
// One header action + its own busy/confirm state — same pattern as
|
|
7
42
|
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
8
43
|
// independently bound by the caller, there is no shared trigger pipeline
|
|
@@ -38,12 +73,6 @@ export function RenderEditActionButton({
|
|
|
38
73
|
};
|
|
39
74
|
|
|
40
75
|
const variant = action.style ?? "secondary";
|
|
41
|
-
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
42
|
-
// without an explicit confirm key — unless `confirmRequired` overrides it
|
|
43
|
-
// (schema-driven navigate/drawer actions, where the target form is itself
|
|
44
|
-
// the confirmation).
|
|
45
|
-
const needsConfirm =
|
|
46
|
-
action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
|
|
47
76
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
48
77
|
|
|
49
78
|
return (
|
|
@@ -55,7 +84,7 @@ export function RenderEditActionButton({
|
|
|
55
84
|
{...(action.icon !== undefined && { icon: action.icon })}
|
|
56
85
|
{...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
|
|
57
86
|
onClick={() => {
|
|
58
|
-
if (
|
|
87
|
+
if (needsActionConfirm(action)) {
|
|
59
88
|
setConfirmOpen(true);
|
|
60
89
|
} else {
|
|
61
90
|
void trigger();
|
|
@@ -65,15 +94,12 @@ export function RenderEditActionButton({
|
|
|
65
94
|
>
|
|
66
95
|
{showIconOnly ? null : action.label}
|
|
67
96
|
</Button>
|
|
68
|
-
<
|
|
97
|
+
<RenderEditActionConfirmDialog
|
|
98
|
+
action={action}
|
|
69
99
|
open={confirmOpen}
|
|
70
100
|
onOpenChange={setConfirmOpen}
|
|
71
|
-
title={action.label}
|
|
72
|
-
{...(action.confirm !== undefined && { description: action.confirm })}
|
|
73
|
-
confirmLabel={action.confirmLabel ?? action.label}
|
|
74
|
-
{...(action.style === "danger" && { variant: "danger" as const })}
|
|
75
101
|
onConfirm={trigger}
|
|
76
|
-
|
|
102
|
+
Dialog={Dialog}
|
|
77
103
|
/>
|
|
78
104
|
</>
|
|
79
105
|
);
|
|
@@ -1088,6 +1088,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1088
1088
|
{...(screen.layout.width !== undefined && { width: screen.layout.width })}
|
|
1089
1089
|
{...(headerRegion !== undefined && { headerRegion })}
|
|
1090
1090
|
{...(fillHeight && { fillHeight })}
|
|
1091
|
+
{...(hideSectionTitles === true && { chromeless: true })}
|
|
1091
1092
|
>
|
|
1092
1093
|
{draftCandidates !== null && (
|
|
1093
1094
|
<Banner
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -785,6 +785,15 @@ export type FormProps = {
|
|
|
785
785
|
* document-flow height. Native impls may ignore this prop (already a
|
|
786
786
|
* bounded viewport there). */
|
|
787
787
|
readonly fillHeight?: boolean;
|
|
788
|
+
/** Drops the surrounding card (bg/border/rounded, title/subtitle block,
|
|
789
|
+
* footer border) so sections+footer render as siblings of `headerRegion`
|
|
790
|
+
* on the page background instead of inside a nested card — a tabbed
|
|
791
|
+
* projectionDetail's tab content is a sibling of the head card, not its
|
|
792
|
+
* child (bedienkonzept A1), so it must look like a standalone screen
|
|
793
|
+
* instead of a form embedded in another card. Default false: unchanged
|
|
794
|
+
* card chrome. Native impls may ignore this prop (no card chrome there
|
|
795
|
+
* to begin with). */
|
|
796
|
+
readonly chromeless?: boolean;
|
|
788
797
|
};
|
|
789
798
|
|
|
790
799
|
/** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
|
|
@@ -1054,7 +1063,13 @@ export type WizardStepGroupProps = {
|
|
|
1054
1063
|
* Keyboard/ARIA (role=tablist/tab, arrow-key navigation) is the
|
|
1055
1064
|
* implementation's job. */
|
|
1056
1065
|
export type TabsProps = {
|
|
1057
|
-
readonly items: readonly {
|
|
1066
|
+
readonly items: readonly {
|
|
1067
|
+
readonly id: string;
|
|
1068
|
+
readonly label: string;
|
|
1069
|
+
/** Shown dimmed after the label (e.g. an open-items count). Omit for
|
|
1070
|
+
* tabs without a counter. */
|
|
1071
|
+
readonly count?: number;
|
|
1072
|
+
}[];
|
|
1058
1073
|
readonly activeId: string;
|
|
1059
1074
|
readonly onSelect: (id: string) => void;
|
|
1060
1075
|
readonly testId?: string;
|
|
@@ -1115,6 +1130,9 @@ export type MetricProps = {
|
|
|
1115
1130
|
readonly label: string;
|
|
1116
1131
|
readonly value: string;
|
|
1117
1132
|
readonly testId?: string;
|
|
1133
|
+
/** Click handler — set when the metric declares a `navigate` target
|
|
1134
|
+
* (see `MetricSpec`). Omitted for a plain, non-interactive metric. */
|
|
1135
|
+
readonly onPress?: () => void;
|
|
1118
1136
|
};
|
|
1119
1137
|
|
|
1120
1138
|
/** Structured JSON display (audit payload/metadata, job logs, unsupported
|
|
@@ -1128,6 +1146,25 @@ export type JsonViewProps = {
|
|
|
1128
1146
|
readonly testId?: string;
|
|
1129
1147
|
};
|
|
1130
1148
|
|
|
1149
|
+
/** One item in an `ActionOverflowMenu` (A7: header/row actions beyond the
|
|
1150
|
+
* primary one collapse into this menu instead of a wall of buttons). */
|
|
1151
|
+
export type ActionMenuItemSpec = {
|
|
1152
|
+
readonly id: string;
|
|
1153
|
+
readonly label: string;
|
|
1154
|
+
/** Icon name, resolved by the implementation (same names as row actions). */
|
|
1155
|
+
readonly icon?: IconKey;
|
|
1156
|
+
readonly disabled?: boolean;
|
|
1157
|
+
readonly variant?: "default" | "danger";
|
|
1158
|
+
readonly onSelect: () => void;
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
export type ActionOverflowMenuProps = {
|
|
1162
|
+
readonly items: readonly ActionMenuItemSpec[];
|
|
1163
|
+
/** aria-label for the three-dots trigger. */
|
|
1164
|
+
readonly label: string;
|
|
1165
|
+
readonly testId?: string;
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1131
1168
|
// ---- Core-Registry (Kumiko-eigene Primitives) ----
|
|
1132
1169
|
|
|
1133
1170
|
export type CorePrimitives = {
|
|
@@ -1189,6 +1226,9 @@ export type CorePrimitives = {
|
|
|
1189
1226
|
* CorePrimitives mocks in tests keep compiling — additive rollout of
|
|
1190
1227
|
* a new primitive shouldn't force every test double to grow a stub. */
|
|
1191
1228
|
readonly FillContainer?: ComponentType<FillContainerProps>;
|
|
1229
|
+
/** Optional: without an implementation, callers with >2 header/row
|
|
1230
|
+
* actions fall back to today's all-buttons-inline rendering (A7). */
|
|
1231
|
+
readonly ActionOverflowMenu?: ComponentType<ActionOverflowMenuProps>;
|
|
1192
1232
|
};
|
|
1193
1233
|
|
|
1194
1234
|
/** Offene Extension-Zone für App-eigene Primitives. Devs erweitern
|