@cosmicdrift/kumiko-renderer-web 0.146.4 → 0.147.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/__tests__/dashboard-screen.test.tsx +4 -0
- package/src/__tests__/form-action-bar.test.tsx +9 -0
- package/src/__tests__/projection-list-actions.test.tsx +42 -0
- package/src/__tests__/use-mutation.test.tsx +86 -0
- package/src/app/dashboard-body.tsx +22 -26
- package/src/index.ts +4 -0
- package/src/layout/nav-tree.tsx +10 -10
- package/src/primitives/index.tsx +24 -4
- package/src/widgets/__tests__/ai-text-field.test.tsx +267 -0
- package/src/widgets/__tests__/form-kit.test.tsx +77 -0
- package/src/widgets/__tests__/result-panel.test.tsx +72 -0
- package/src/widgets/__tests__/widgets.test.tsx +14 -0
- package/src/widgets/ai-text-field.tsx +370 -0
- package/src/widgets/detail-list.tsx +4 -1
- package/src/widgets/form-fields.tsx +15 -12
- package/src/widgets/index.ts +6 -0
- package/src/widgets/mode-switch.tsx +2 -1
- package/src/widgets/use-draft.ts +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.147.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.147.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.147.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -326,6 +326,10 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
|
|
|
326
326
|
);
|
|
327
327
|
await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
|
|
328
328
|
expect(screen.queryByTestId("kpi-icon")).toBeNull();
|
|
329
|
+
// #931: unregistriertes Icon darf auch keinen leeren, akzentgefärbten
|
|
330
|
+
// Chip-Wrapper rendern (StatCard gated den Span auf icon !== undefined).
|
|
331
|
+
const card = screen.getByTestId("dashboard-panel-kpi");
|
|
332
|
+
expect(card.querySelector('[style*="123456"]')).toBeNull();
|
|
329
333
|
});
|
|
330
334
|
|
|
331
335
|
test("stat-Panel: fehlende deltaDirection unterdrückt den Chip (kein Crash)", async () => {
|
|
@@ -120,6 +120,15 @@ describe("DefaultSection Card-Standard (subtitle + actions-Footer)", () => {
|
|
|
120
120
|
expect(actions.textContent).toContain("Übernehmen");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
test('standalone: variant="destructive" adds a destructive border, default variant does not', () => {
|
|
124
|
+
render(
|
|
125
|
+
<Section testId="danger" title="Danger zone" variant="destructive">
|
|
126
|
+
<div>x</div>
|
|
127
|
+
</Section>,
|
|
128
|
+
);
|
|
129
|
+
expect(screen.getByTestId("danger").className).toContain("border-destructive/40");
|
|
130
|
+
});
|
|
131
|
+
|
|
123
132
|
test("title-only standalone (Bestands-Consumer) hat keinen Divider mehr", () => {
|
|
124
133
|
render(
|
|
125
134
|
<Section testId="legacy" title="Stammdaten">
|
|
@@ -86,4 +86,46 @@ describe("projectionList writeHandler-Actions", () => {
|
|
|
86
86
|
expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:sync");
|
|
87
87
|
expect(write.mock.calls[0]?.[1]).toEqual({ source: "manual" });
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
// Prod-Bug 2026-06-07 (siehe useRowActionTrigger): ein verschluckter
|
|
91
|
+
// Write-Fehler sah für den User wie "nichts passiert" aus. Row- UND
|
|
92
|
+
// Toolbar-Action auf projectionList müssen denselben Surfacing-Pfad wie
|
|
93
|
+
// entityList nehmen (Toast statt stiller no-op).
|
|
94
|
+
test("Row-Action-Fehler wird als Toast surfaced, nicht verschluckt", async () => {
|
|
95
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
96
|
+
isSuccess: false,
|
|
97
|
+
error: { code: "internal_error", httpStatus: 500, message: "maintenance start failed" },
|
|
98
|
+
}));
|
|
99
|
+
const { ToastProvider } = await import("../primitives/toast");
|
|
100
|
+
render(
|
|
101
|
+
<ToastProvider>
|
|
102
|
+
<DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
|
|
103
|
+
<KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
|
|
104
|
+
</DispatcherProvider>
|
|
105
|
+
</ToastProvider>,
|
|
106
|
+
);
|
|
107
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
108
|
+
|
|
109
|
+
fireEvent.click(screen.getByRole("button", { name: "status:action:start" }));
|
|
110
|
+
expect(await screen.findByText("maintenance start failed")).toBeTruthy();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("Toolbar-Action-Fehler wird als Toast surfaced, nicht verschluckt", async () => {
|
|
114
|
+
const write = mock(async (_type: string, _payload: unknown) => ({
|
|
115
|
+
isSuccess: false,
|
|
116
|
+
error: { code: "internal_error", httpStatus: 500, message: "maintenance sync failed" },
|
|
117
|
+
}));
|
|
118
|
+
const { ToastProvider } = await import("../primitives/toast");
|
|
119
|
+
render(
|
|
120
|
+
<ToastProvider>
|
|
121
|
+
<DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
|
|
122
|
+
<KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
|
|
123
|
+
</DispatcherProvider>
|
|
124
|
+
</ToastProvider>,
|
|
125
|
+
);
|
|
126
|
+
await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
|
|
127
|
+
|
|
128
|
+
fireEvent.click(screen.getByRole("button", { name: "status:action:sync" }));
|
|
129
|
+
expect(await screen.findByText("maintenance sync failed")).toBeTruthy();
|
|
130
|
+
});
|
|
89
131
|
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { DispatcherProvider, useMutation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { act, createMockDispatcher, renderHook, waitFor } from "./test-utils";
|
|
6
|
+
|
|
7
|
+
function wrap(dispatcher: Dispatcher) {
|
|
8
|
+
return ({ children }: { children: ReactNode }) => (
|
|
9
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("useMutation", () => {
|
|
14
|
+
// #902/2: two overlapping calls on one instance (list-row actions sharing
|
|
15
|
+
// it) must not let the earlier-STARTED, later-RESOLVING call clobber the
|
|
16
|
+
// state set by the later-started, earlier-resolving one.
|
|
17
|
+
test("out-of-order resolution: state reflects the most recently started call, not the most recently resolved one", async () => {
|
|
18
|
+
let resolveFirst: (() => void) | undefined;
|
|
19
|
+
const write = mock(async (_type: string, payload: unknown) => {
|
|
20
|
+
if ((payload as { id: string }).id === "row-1") {
|
|
21
|
+
await new Promise<void>((resolve) => {
|
|
22
|
+
resolveFirst = resolve;
|
|
23
|
+
});
|
|
24
|
+
return { isSuccess: true, data: { id: "row-1" } };
|
|
25
|
+
}
|
|
26
|
+
return { isSuccess: true, data: { id: "row-2" } };
|
|
27
|
+
});
|
|
28
|
+
const dispatcher = createMockDispatcher({ write: write as unknown as Dispatcher["write"] });
|
|
29
|
+
const { result } = renderHook(() => useMutation("row:update"), {
|
|
30
|
+
wrapper: wrap(dispatcher),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
let firstCallDone: Promise<unknown>;
|
|
34
|
+
let secondCallDone: Promise<unknown>;
|
|
35
|
+
act(() => {
|
|
36
|
+
firstCallDone = result.current.mutate({ id: "row-1" });
|
|
37
|
+
});
|
|
38
|
+
await act(async () => {
|
|
39
|
+
secondCallDone = result.current.mutate({ id: "row-2" });
|
|
40
|
+
// let the second (no-delay) call resolve before the first
|
|
41
|
+
await Promise.resolve();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await waitFor(() => expect(result.current.data).toEqual({ id: "row-2" }));
|
|
45
|
+
expect(result.current.pending).toBe(false); // row-2 (the latest call) already settled
|
|
46
|
+
|
|
47
|
+
await act(async () => {
|
|
48
|
+
resolveFirst?.();
|
|
49
|
+
await firstCallDone;
|
|
50
|
+
await secondCallDone;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The first call resolving late must not overwrite row-2's state.
|
|
54
|
+
expect(result.current.data).toEqual({ id: "row-2" });
|
|
55
|
+
expect(result.current.pending).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("reset() invalidates a still-pending call's late state update", async () => {
|
|
59
|
+
let resolveWrite: (() => void) | undefined;
|
|
60
|
+
const write = mock(async () => {
|
|
61
|
+
await new Promise<void>((resolve) => {
|
|
62
|
+
resolveWrite = resolve;
|
|
63
|
+
});
|
|
64
|
+
return { isSuccess: true, data: { id: "late" } };
|
|
65
|
+
});
|
|
66
|
+
const dispatcher = createMockDispatcher({ write: write as unknown as Dispatcher["write"] });
|
|
67
|
+
const { result } = renderHook(() => useMutation("row:update"), {
|
|
68
|
+
wrapper: wrap(dispatcher),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
let mutateDone: Promise<unknown>;
|
|
72
|
+
act(() => {
|
|
73
|
+
mutateDone = result.current.mutate({ id: "x" });
|
|
74
|
+
});
|
|
75
|
+
act(() => {
|
|
76
|
+
result.current.reset();
|
|
77
|
+
});
|
|
78
|
+
await act(async () => {
|
|
79
|
+
resolveWrite?.();
|
|
80
|
+
await mutateDone;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.current.data).toBeNull();
|
|
84
|
+
expect(result.current.pending).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// anzeigefertige Werte (der Query-Handler formatiert).
|
|
10
10
|
// deltaField/deltaDirectionField(+deltaToneField) sind
|
|
11
11
|
// optional — nur wenn BEIDE Felder gesetzt sind UND geliefert
|
|
12
|
-
// werden, zeigt die Kachel einen Delta-Chip ("↓
|
|
12
|
+
// werden, zeigt die Kachel einen Delta-Chip ("↓23 %").
|
|
13
13
|
// icon/accentColor sind statisch am Panel (keine Query-
|
|
14
14
|
// Felder) — icon über extensionSectionComponents wie bei
|
|
15
15
|
// custom-Panels, accentColor ein roher CSS-Farbwert.
|
|
@@ -61,43 +61,34 @@ const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"])
|
|
|
61
61
|
const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
|
|
62
62
|
const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
|
|
63
63
|
|
|
64
|
-
function
|
|
64
|
+
function StatPanelBody({
|
|
65
65
|
panel,
|
|
66
|
+
label,
|
|
66
67
|
screenId,
|
|
67
68
|
filterParams,
|
|
68
69
|
}: {
|
|
69
70
|
readonly panel: DashboardStatPanel;
|
|
71
|
+
readonly label: string;
|
|
70
72
|
readonly screenId: string;
|
|
71
73
|
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
72
74
|
}): ReactNode {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
// Resolved HERE (not in a separate always-rendered child) so `icon` on
|
|
76
|
+
// <StatCard> is `undefined` — not a React element that renders empty —
|
|
77
|
+
// when the icon name isn't registered. StatCard gates its accent chip on
|
|
78
|
+
// `icon !== undefined`, so a resolved-but-hidden element used to leave a
|
|
79
|
+
// stray accent-colored chip next to the label.
|
|
80
|
+
const iconName = panel.icon !== undefined ? extensionSectionName(panel.icon) : undefined;
|
|
81
|
+
const Icon = useExtensionSectionComponent(iconName);
|
|
75
82
|
useEffect(() => {
|
|
76
|
-
if (panel.icon !== undefined &&
|
|
83
|
+
if (panel.icon !== undefined && iconName !== undefined && Icon === undefined) {
|
|
77
84
|
// biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
|
|
78
85
|
console.warn(
|
|
79
86
|
`[kumiko] Dashboard stat-panel "${panel.id}" on screen "${screenId}" references icon ` +
|
|
80
|
-
`"${
|
|
87
|
+
`"${iconName}", which is not registered in clientFeatures.extensionSectionComponents.`,
|
|
81
88
|
);
|
|
82
89
|
}
|
|
83
|
-
}, [panel.icon, panel.id,
|
|
84
|
-
if (Icon === undefined) return null;
|
|
85
|
-
return (
|
|
86
|
-
<Icon entityName={screenId} entityId={null} screenId={screenId} filterParams={filterParams} />
|
|
87
|
-
);
|
|
88
|
-
}
|
|
90
|
+
}, [panel.icon, panel.id, iconName, Icon, screenId]);
|
|
89
91
|
|
|
90
|
-
function StatPanelBody({
|
|
91
|
-
panel,
|
|
92
|
-
label,
|
|
93
|
-
screenId,
|
|
94
|
-
filterParams,
|
|
95
|
-
}: {
|
|
96
|
-
readonly panel: DashboardStatPanel;
|
|
97
|
-
readonly label: string;
|
|
98
|
-
readonly screenId: string;
|
|
99
|
-
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
100
|
-
}): ReactNode {
|
|
101
92
|
const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
|
|
102
93
|
panel.query,
|
|
103
94
|
filterParams,
|
|
@@ -114,14 +105,19 @@ function StatPanelBody({
|
|
|
114
105
|
return (
|
|
115
106
|
<StatCard
|
|
116
107
|
icon={
|
|
117
|
-
|
|
118
|
-
<
|
|
108
|
+
Icon !== undefined ? (
|
|
109
|
+
<Icon
|
|
110
|
+
entityName={screenId}
|
|
111
|
+
entityId={null}
|
|
112
|
+
screenId={screenId}
|
|
113
|
+
filterParams={filterParams}
|
|
114
|
+
/>
|
|
119
115
|
) : undefined
|
|
120
116
|
}
|
|
121
117
|
label={label}
|
|
122
118
|
value={String(record[panel.valueField] ?? "—")}
|
|
123
119
|
tone={tone}
|
|
124
|
-
accentColor
|
|
120
|
+
{...(Icon !== undefined && { accentColor: panel.accentColor })}
|
|
125
121
|
{...(sub !== undefined && sub !== null && { sub: String(sub) })}
|
|
126
122
|
{...(delta !== undefined && { delta })}
|
|
127
123
|
testId={`dashboard-panel-${panel.id}`}
|
package/src/index.ts
CHANGED
|
@@ -155,6 +155,8 @@ export {
|
|
|
155
155
|
} from "./tokens";
|
|
156
156
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
|
|
157
157
|
export type {
|
|
158
|
+
AiTextAreaProps,
|
|
159
|
+
AiTextFieldProps,
|
|
158
160
|
BooleanFieldProps,
|
|
159
161
|
ComparisonMetric,
|
|
160
162
|
DateFieldProps,
|
|
@@ -176,6 +178,8 @@ export type {
|
|
|
176
178
|
TimeseriesPoint,
|
|
177
179
|
} from "./widgets";
|
|
178
180
|
export {
|
|
181
|
+
AiTextArea,
|
|
182
|
+
AiTextField,
|
|
179
183
|
BooleanField,
|
|
180
184
|
CollapsibleSection,
|
|
181
185
|
ComparisonTable,
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -619,11 +619,11 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
619
619
|
|
|
620
620
|
const chevron = s.expandable ? (
|
|
621
621
|
<SidebarMenuAction
|
|
622
|
-
aria-label={t(s.
|
|
623
|
-
aria-expanded={
|
|
622
|
+
aria-label={t(s.isExpanded ? "kumiko.nav.collapse" : "kumiko.nav.expand")}
|
|
623
|
+
aria-expanded={s.isExpanded}
|
|
624
624
|
onClick={() => onToggle(node.qualifiedName)}
|
|
625
625
|
>
|
|
626
|
-
{s.
|
|
626
|
+
{s.isExpanded ? <ChevronDown /> : <ChevronRight />}
|
|
627
627
|
</SidebarMenuAction>
|
|
628
628
|
) : null;
|
|
629
629
|
|
|
@@ -675,15 +675,15 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
675
675
|
onClick={() => {
|
|
676
676
|
if (s.expandable) onToggle(node.qualifiedName);
|
|
677
677
|
}}
|
|
678
|
-
{...(s.expandable && { "aria-expanded":
|
|
678
|
+
{...(s.expandable && { "aria-expanded": s.isExpanded })}
|
|
679
679
|
>
|
|
680
680
|
<NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
|
|
681
681
|
<span className="truncate">{s.displayLabel}</span>
|
|
682
682
|
{s.expandable &&
|
|
683
|
-
(s.
|
|
684
|
-
<ChevronRight className="ml-auto" />
|
|
685
|
-
) : (
|
|
683
|
+
(s.isExpanded ? (
|
|
686
684
|
<ChevronDown className="ml-auto" />
|
|
685
|
+
) : (
|
|
686
|
+
<ChevronRight className="ml-auto" />
|
|
687
687
|
))}
|
|
688
688
|
</SidebarMenuButton>
|
|
689
689
|
<NodeActions node={node} />
|
|
@@ -724,15 +724,15 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
|
|
|
724
724
|
const chevron = s.expandable ? (
|
|
725
725
|
<button
|
|
726
726
|
type="button"
|
|
727
|
-
aria-label={t(s.
|
|
728
|
-
aria-expanded={
|
|
727
|
+
aria-label={t(s.isExpanded ? "kumiko.nav.collapse" : "kumiko.nav.expand")}
|
|
728
|
+
aria-expanded={s.isExpanded}
|
|
729
729
|
onClick={(e) => {
|
|
730
730
|
e.stopPropagation();
|
|
731
731
|
onToggle(node.qualifiedName);
|
|
732
732
|
}}
|
|
733
733
|
className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
734
734
|
>
|
|
735
|
-
{s.
|
|
735
|
+
{s.isExpanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
|
|
736
736
|
</button>
|
|
737
737
|
) : null;
|
|
738
738
|
|
package/src/primitives/index.tsx
CHANGED
|
@@ -144,7 +144,7 @@ function DefaultButton({
|
|
|
144
144
|
data-loading={loading === true ? "true" : undefined}
|
|
145
145
|
variant={BUTTON_VARIANT[variant]}
|
|
146
146
|
size={BUTTON_SIZE[size]}
|
|
147
|
-
|
|
147
|
+
aria-label={ariaLabel}
|
|
148
148
|
className={className}
|
|
149
149
|
>
|
|
150
150
|
{loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
|
|
@@ -1509,7 +1509,14 @@ export function FormScreenShell({
|
|
|
1509
1509
|
);
|
|
1510
1510
|
}
|
|
1511
1511
|
|
|
1512
|
-
function DefaultSection({
|
|
1512
|
+
function DefaultSection({
|
|
1513
|
+
title,
|
|
1514
|
+
subtitle,
|
|
1515
|
+
children,
|
|
1516
|
+
actions,
|
|
1517
|
+
variant = "default",
|
|
1518
|
+
testId,
|
|
1519
|
+
}: SectionProps): ReactNode {
|
|
1513
1520
|
const insideForm = useContext(InsideFormContext);
|
|
1514
1521
|
|
|
1515
1522
|
// h3 statt CardTitle (= div): erhält die Heading-Semantik für
|
|
@@ -1542,7 +1549,13 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
|
|
|
1542
1549
|
// actions hier = rechtsbündige Reihe (das Form trägt den eigenen Footer).
|
|
1543
1550
|
if (insideForm) {
|
|
1544
1551
|
return (
|
|
1545
|
-
<section
|
|
1552
|
+
<section
|
|
1553
|
+
data-testid={testId}
|
|
1554
|
+
className={cn(
|
|
1555
|
+
"flex flex-col gap-4 px-6 py-6",
|
|
1556
|
+
variant === "destructive" && "border-l-2 border-destructive/40",
|
|
1557
|
+
)}
|
|
1558
|
+
>
|
|
1546
1559
|
{header}
|
|
1547
1560
|
{children}
|
|
1548
1561
|
{actions !== undefined && (
|
|
@@ -1565,7 +1578,14 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
|
|
|
1565
1578
|
// `children`) WOULD get silently clipped — verify this against any new
|
|
1566
1579
|
// standalone-section content that renders its own non-portaled overlay.
|
|
1567
1580
|
return (
|
|
1568
|
-
<div
|
|
1581
|
+
<div
|
|
1582
|
+
data-testid={testId}
|
|
1583
|
+
className={cn(
|
|
1584
|
+
cardSurface(),
|
|
1585
|
+
"overflow-hidden",
|
|
1586
|
+
variant === "destructive" && "border-destructive/40",
|
|
1587
|
+
)}
|
|
1588
|
+
>
|
|
1569
1589
|
<div className="flex flex-col gap-4 px-6 py-6">
|
|
1570
1590
|
{header}
|
|
1571
1591
|
{children}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { type ReactElement, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
createMockDispatcher,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
} from "../../__tests__/test-utils";
|
|
11
|
+
import { AiTextArea, AiTextField, type AiTextFieldProps } from "../ai-text-field";
|
|
12
|
+
|
|
13
|
+
function renderWithDispatcher(
|
|
14
|
+
ui: ReactElement,
|
|
15
|
+
query: NonNullable<Parameters<typeof createMockDispatcher>[0]>["query"],
|
|
16
|
+
) {
|
|
17
|
+
const dispatcher = createMockDispatcher({ query });
|
|
18
|
+
return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Real controlled-component loop: onChange feeds back into `value` so
|
|
22
|
+
// Tab-accept sees the actually-typed text, not a stale prop. Plain
|
|
23
|
+
// `mock()`-only setups (no state) can't exercise this — that's a test
|
|
24
|
+
// bug we hit and fixed once already (see git history), keep it this way.
|
|
25
|
+
function ControlledAiTextField(
|
|
26
|
+
props: Omit<AiTextFieldProps, "value" | "onChange"> & {
|
|
27
|
+
readonly initialValue: string;
|
|
28
|
+
readonly onChangeSpy?: (v: string) => void;
|
|
29
|
+
},
|
|
30
|
+
) {
|
|
31
|
+
const { initialValue, onChangeSpy, ...rest } = props;
|
|
32
|
+
const [value, setValue] = useState(initialValue);
|
|
33
|
+
return (
|
|
34
|
+
<AiTextField
|
|
35
|
+
{...rest}
|
|
36
|
+
value={value}
|
|
37
|
+
onChange={(v) => {
|
|
38
|
+
setValue(v);
|
|
39
|
+
onChangeSpy?.(v);
|
|
40
|
+
}}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("AiTextField", () => {
|
|
46
|
+
test("plain typing calls onChange like a normal text field", () => {
|
|
47
|
+
const onChangeSpy = mock((_v: string) => {});
|
|
48
|
+
renderWithDispatcher(
|
|
49
|
+
<ControlledAiTextField
|
|
50
|
+
label="Title"
|
|
51
|
+
id="title"
|
|
52
|
+
name="title"
|
|
53
|
+
initialValue=""
|
|
54
|
+
onChangeSpy={onChangeSpy}
|
|
55
|
+
actions={[]}
|
|
56
|
+
completion={false}
|
|
57
|
+
testId="title"
|
|
58
|
+
/>,
|
|
59
|
+
(async () => ({
|
|
60
|
+
isSuccess: true,
|
|
61
|
+
data: { type: "text", text: "", usage: { inputTokens: 0, outputTokens: 0 } },
|
|
62
|
+
})) as never,
|
|
63
|
+
);
|
|
64
|
+
fireEvent.change(screen.getByTestId("title-input"), { target: { value: "hi" } });
|
|
65
|
+
expect(onChangeSpy).toHaveBeenCalledWith("hi");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("ghost-text: shows suggestion, Tab accepts it appended to the typed text", async () => {
|
|
69
|
+
const onChangeSpy = mock((_v: string) => {});
|
|
70
|
+
renderWithDispatcher(
|
|
71
|
+
<ControlledAiTextField
|
|
72
|
+
label="Title"
|
|
73
|
+
id="title"
|
|
74
|
+
name="title"
|
|
75
|
+
initialValue=""
|
|
76
|
+
onChangeSpy={onChangeSpy}
|
|
77
|
+
actions={[]}
|
|
78
|
+
completionDebounceMs={5}
|
|
79
|
+
testId="title"
|
|
80
|
+
/>,
|
|
81
|
+
(async () => ({
|
|
82
|
+
isSuccess: true,
|
|
83
|
+
data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
84
|
+
})) as never,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const input = screen.getByTestId("title-input");
|
|
88
|
+
fireEvent.change(input, { target: { value: "the quick brown " } });
|
|
89
|
+
|
|
90
|
+
await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
|
|
91
|
+
|
|
92
|
+
fireEvent.keyDown(input, { key: "Tab" });
|
|
93
|
+
expect(onChangeSpy).toHaveBeenLastCalledWith("the quick brown fox");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("ghost-text: Esc discards the suggestion without calling onChange", async () => {
|
|
97
|
+
const onChangeSpy = mock((_v: string) => {});
|
|
98
|
+
renderWithDispatcher(
|
|
99
|
+
<ControlledAiTextField
|
|
100
|
+
label="Title"
|
|
101
|
+
id="title"
|
|
102
|
+
name="title"
|
|
103
|
+
initialValue=""
|
|
104
|
+
onChangeSpy={onChangeSpy}
|
|
105
|
+
actions={[]}
|
|
106
|
+
completionDebounceMs={5}
|
|
107
|
+
testId="title"
|
|
108
|
+
/>,
|
|
109
|
+
(async () => ({
|
|
110
|
+
isSuccess: true,
|
|
111
|
+
data: { type: "text", text: "there", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
112
|
+
})) as never,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const input = screen.getByTestId("title-input");
|
|
116
|
+
fireEvent.change(input, { target: { value: "hi " } });
|
|
117
|
+
await waitFor(() => expect(screen.queryByText("there")).toBeTruthy(), { timeout: 1000 });
|
|
118
|
+
|
|
119
|
+
onChangeSpy.mockClear();
|
|
120
|
+
fireEvent.keyDown(input, { key: "Escape" });
|
|
121
|
+
expect(screen.queryByText("there")).toBeNull();
|
|
122
|
+
expect(onChangeSpy).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("feature_disabled → toolbar hides, field stays usable (graceful degradation)", async () => {
|
|
126
|
+
const onChangeSpy = mock((_v: string) => {});
|
|
127
|
+
renderWithDispatcher(
|
|
128
|
+
<ControlledAiTextField
|
|
129
|
+
label="Title"
|
|
130
|
+
id="title"
|
|
131
|
+
name="title"
|
|
132
|
+
initialValue=""
|
|
133
|
+
onChangeSpy={onChangeSpy}
|
|
134
|
+
completionDebounceMs={5}
|
|
135
|
+
testId="title"
|
|
136
|
+
/>,
|
|
137
|
+
(async () => ({
|
|
138
|
+
isSuccess: false,
|
|
139
|
+
error: { code: "feature_disabled", message: "off", i18nKey: "errors.feature.disabled" },
|
|
140
|
+
})) as never,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
fireEvent.change(screen.getByTestId("title-input"), { target: { value: "hi " } });
|
|
144
|
+
await waitFor(() => expect(screen.queryByRole("button")).toBeNull(), { timeout: 1000 });
|
|
145
|
+
expect(screen.getByTestId("title-input")).toBeTruthy();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("correct action: opens dialog, runs immediately, apply calls onChange with the result", async () => {
|
|
149
|
+
const onChangeSpy = mock((_v: string) => {});
|
|
150
|
+
renderWithDispatcher(
|
|
151
|
+
<ControlledAiTextField
|
|
152
|
+
label="Title"
|
|
153
|
+
id="title"
|
|
154
|
+
name="title"
|
|
155
|
+
initialValue="teh cat sat"
|
|
156
|
+
onChangeSpy={onChangeSpy}
|
|
157
|
+
completion={false}
|
|
158
|
+
actions={["correct"]}
|
|
159
|
+
testId="title"
|
|
160
|
+
/>,
|
|
161
|
+
(async (_type: string, payload: unknown) => {
|
|
162
|
+
const mode = (payload as { mode: string }).mode;
|
|
163
|
+
if (mode !== "correct") throw new Error(`unexpected mode ${mode}`);
|
|
164
|
+
return {
|
|
165
|
+
isSuccess: true,
|
|
166
|
+
data: { type: "text", text: "the cat sat", usage: { inputTokens: 3, outputTokens: 3 } },
|
|
167
|
+
};
|
|
168
|
+
}) as never,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
fireEvent.click(screen.getByRole("button", { name: "Correct" }));
|
|
172
|
+
await waitFor(() => expect(screen.queryByText("the cat sat")).toBeTruthy());
|
|
173
|
+
|
|
174
|
+
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
|
175
|
+
expect(onChangeSpy).toHaveBeenCalledWith("the cat sat");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("AiTextField ghost-text overlay — scroll sync", () => {
|
|
180
|
+
test("Tab/Escape aside: overlay mirrors the input's scrollLeft on an interactive scroll", async () => {
|
|
181
|
+
renderWithDispatcher(
|
|
182
|
+
<ControlledAiTextField
|
|
183
|
+
label="Title"
|
|
184
|
+
id="title"
|
|
185
|
+
name="title"
|
|
186
|
+
initialValue=""
|
|
187
|
+
actions={[]}
|
|
188
|
+
completionDebounceMs={5}
|
|
189
|
+
testId="title"
|
|
190
|
+
/>,
|
|
191
|
+
(async () => ({
|
|
192
|
+
isSuccess: true,
|
|
193
|
+
data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
194
|
+
})) as never,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const input = screen.getByTestId("title-input") as HTMLInputElement;
|
|
198
|
+
fireEvent.change(input, { target: { value: "the quick brown " } });
|
|
199
|
+
await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
|
|
200
|
+
|
|
201
|
+
const overlay = input.parentElement?.querySelector('[aria-hidden="true"]') as HTMLDivElement;
|
|
202
|
+
expect(overlay).toBeTruthy();
|
|
203
|
+
|
|
204
|
+
// Interactive scroll (e.g. arrow-key caret move) — caught by the
|
|
205
|
+
// onScroll handler.
|
|
206
|
+
Object.defineProperty(input, "scrollLeft", { value: 50, writable: true });
|
|
207
|
+
fireEvent.scroll(input);
|
|
208
|
+
expect(overlay.scrollLeft).toBe(50);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("overlay resyncs on typing even without a `scroll` event (Chrome doesn't fire one for caret-follow auto-scroll)", async () => {
|
|
212
|
+
renderWithDispatcher(
|
|
213
|
+
<ControlledAiTextField
|
|
214
|
+
label="Title"
|
|
215
|
+
id="title"
|
|
216
|
+
name="title"
|
|
217
|
+
initialValue=""
|
|
218
|
+
actions={[]}
|
|
219
|
+
completionDebounceMs={5}
|
|
220
|
+
testId="title"
|
|
221
|
+
/>,
|
|
222
|
+
(async () => ({
|
|
223
|
+
isSuccess: true,
|
|
224
|
+
data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
225
|
+
})) as never,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const input = screen.getByTestId("title-input") as HTMLInputElement;
|
|
229
|
+
fireEvent.change(input, { target: { value: "the quick brown " } });
|
|
230
|
+
await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
|
|
231
|
+
|
|
232
|
+
const overlay = input.parentElement?.querySelector('[aria-hidden="true"]') as HTMLDivElement;
|
|
233
|
+
|
|
234
|
+
// Simulate the browser having auto-scrolled the input to reveal the
|
|
235
|
+
// caret WITHOUT firing a `scroll` event — the real Chrome behavior this
|
|
236
|
+
// bug hinged on. Only the value/suggestion-keyed layout effect (not
|
|
237
|
+
// onScroll) can catch this.
|
|
238
|
+
Object.defineProperty(input, "scrollLeft", { value: 120, writable: true });
|
|
239
|
+
fireEvent.change(input, { target: { value: "the quick brown f" } });
|
|
240
|
+
await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
|
|
241
|
+
|
|
242
|
+
expect(overlay.scrollLeft).toBe(120);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("AiTextArea", () => {
|
|
247
|
+
test("renders a textarea with the given rows", () => {
|
|
248
|
+
const onChange = mock((_v: string) => {});
|
|
249
|
+
renderWithDispatcher(
|
|
250
|
+
<AiTextArea
|
|
251
|
+
label="Body"
|
|
252
|
+
id="body"
|
|
253
|
+
name="body"
|
|
254
|
+
value=""
|
|
255
|
+
onChange={onChange}
|
|
256
|
+
rows={6}
|
|
257
|
+
actions={[]}
|
|
258
|
+
completion={false}
|
|
259
|
+
testId="body"
|
|
260
|
+
/>,
|
|
261
|
+
(async () => ({ isSuccess: true, data: {} })) as never,
|
|
262
|
+
);
|
|
263
|
+
const el = screen.getByTestId("body-input") as HTMLTextAreaElement;
|
|
264
|
+
expect(el.tagName).toBe("TEXTAREA");
|
|
265
|
+
expect(el.getAttribute("rows")).toBe("6");
|
|
266
|
+
});
|
|
267
|
+
});
|