@cosmicdrift/kumiko-renderer-web 0.233.0 → 0.235.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/__tests__/app-layout.test.tsx +13 -12
- package/src/__tests__/default-app-shell.test.tsx +33 -0
- package/src/__tests__/form-action-bar.test.tsx +1 -1
- package/src/__tests__/kumiko-screen.test.tsx +293 -3
- package/src/__tests__/primitives.test.tsx +182 -3
- package/src/__tests__/projection-detail-record-layout.test.tsx +119 -0
- package/src/__tests__/render-edit.test.tsx +9 -10
- package/src/__tests__/secrets-edit.test.tsx +287 -0
- package/src/__tests__/workspace-shell.test.tsx +5 -4
- package/src/icons.tsx +84 -1
- package/src/layout/app-layout.tsx +7 -7
- package/src/layout/default-app-shell.tsx +3 -2
- package/src/layout/workspace-shell.tsx +3 -2
- package/src/primitives/__tests__/button.test.tsx +37 -0
- package/src/primitives/__tests__/select-segmented.test.tsx +84 -0
- package/src/primitives/index.tsx +210 -17
- package/src/primitives/metric.tsx +18 -10
- package/src/styles.css +21 -17
- package/src/ui/switch.tsx +35 -0
|
@@ -129,6 +129,69 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
129
129
|
expect(screen.queryByTestId("kumiko-screen-projection-detail-title")).toBeNull();
|
|
130
130
|
expect(screen.queryByTestId("kumiko-screen-projection-detail-metrics")).toBeNull();
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
test("colors the status badge via the StatusTone heuristic for a recognized value", async () => {
|
|
134
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
135
|
+
...baseScreen,
|
|
136
|
+
header: { title: "tenantName", status: "state" },
|
|
137
|
+
};
|
|
138
|
+
const dispatcher = dispatcherReturning({ ...rowData, state: "overdue" });
|
|
139
|
+
|
|
140
|
+
render(
|
|
141
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
142
|
+
<KumikoScreen
|
|
143
|
+
schema={schemaFor(headerScreen)}
|
|
144
|
+
qn="rentals:screen:rent-detail"
|
|
145
|
+
entityId="rent-1"
|
|
146
|
+
/>
|
|
147
|
+
</DispatcherProvider>,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
151
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
|
|
152
|
+
"text-status-bad",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("an unmapped status value falls back to the muted tone", async () => {
|
|
157
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
158
|
+
...baseScreen,
|
|
159
|
+
header: { title: "tenantName", status: "state" },
|
|
160
|
+
};
|
|
161
|
+
const dispatcher = dispatcherReturning({ ...rowData, state: "archived" });
|
|
162
|
+
|
|
163
|
+
render(
|
|
164
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
165
|
+
<KumikoScreen
|
|
166
|
+
schema={schemaFor(headerScreen)}
|
|
167
|
+
qn="rentals:screen:rent-detail"
|
|
168
|
+
entityId="rent-1"
|
|
169
|
+
/>
|
|
170
|
+
</DispatcherProvider>,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
174
|
+
expect(screen.getByTestId("kumiko-screen-projection-detail-status").className).toContain(
|
|
175
|
+
"text-muted-foreground",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("read-only detail screens render no footer action bar", async () => {
|
|
180
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
181
|
+
|
|
182
|
+
render(
|
|
183
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
184
|
+
<KumikoScreen
|
|
185
|
+
schema={schemaFor(baseScreen)}
|
|
186
|
+
qn="rentals:screen:rent-detail"
|
|
187
|
+
entityId="rent-1"
|
|
188
|
+
/>
|
|
189
|
+
</DispatcherProvider>,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
193
|
+
expect(screen.queryByTestId("render-edit-form-actions")).toBeNull();
|
|
194
|
+
});
|
|
132
195
|
});
|
|
133
196
|
|
|
134
197
|
describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
|
|
@@ -399,6 +462,58 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
|
399
462
|
|
|
400
463
|
expect(setSearchParamsCalls).toContainEqual({ tab: "payments" });
|
|
401
464
|
});
|
|
465
|
+
|
|
466
|
+
test("relatedList tab content renders without its own Section card wrapper", async () => {
|
|
467
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
468
|
+
const { navApi } = navWithTab("payments");
|
|
469
|
+
|
|
470
|
+
render(
|
|
471
|
+
<NavProvider value={navApi}>
|
|
472
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
473
|
+
<KumikoScreen
|
|
474
|
+
schema={schemaFor(tabsScreen)}
|
|
475
|
+
qn="rentals:screen:rent-detail"
|
|
476
|
+
entityId="rent-1"
|
|
477
|
+
/>
|
|
478
|
+
</DispatcherProvider>
|
|
479
|
+
</NavProvider>,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
await waitFor(() =>
|
|
483
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
484
|
+
);
|
|
485
|
+
// hideTitle (tabs mode) drops the Section wrapper — DataTable already
|
|
486
|
+
// draws its own card frame, so a second nested Card would double it up.
|
|
487
|
+
expect(screen.queryByTestId("related-list-Payments")).toBeNull();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("without a Tabs primitive, falls back to the stacked all-sections layout instead of truncating to the first section (fw#2501)", async () => {
|
|
491
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
492
|
+
const { navApi } = navWithTab(undefined);
|
|
493
|
+
|
|
494
|
+
renderWithPrimitivesOverride(
|
|
495
|
+
<NavProvider value={navApi}>
|
|
496
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
497
|
+
<KumikoScreen
|
|
498
|
+
schema={schemaFor(tabsScreen)}
|
|
499
|
+
qn="rentals:screen:rent-detail"
|
|
500
|
+
entityId="rent-1"
|
|
501
|
+
/>
|
|
502
|
+
</DispatcherProvider>
|
|
503
|
+
</NavProvider>,
|
|
504
|
+
{ Tabs: undefined },
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
await waitFor(() => screen.getByTestId("field-description"));
|
|
508
|
+
// Pre-fix: activeSection stayed truncated to sections[0] even without a
|
|
509
|
+
// Tabs primitive to switch away from it — payments/invoices content was
|
|
510
|
+
// permanently unreachable, with no tab strip to reach it either.
|
|
511
|
+
await waitFor(() =>
|
|
512
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
513
|
+
);
|
|
514
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(true);
|
|
515
|
+
expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs")).toBeNull();
|
|
516
|
+
});
|
|
402
517
|
});
|
|
403
518
|
|
|
404
519
|
// Only guard against a solon-shaped screen (relatedList-heavy) silently regressing.
|
|
@@ -448,6 +563,10 @@ describe("KumikoScreen / projectionDetail — unchanged for a solon-shaped scree
|
|
|
448
563
|
// Contrast for the tabs-mode "form title suppressed" test above — outside
|
|
449
564
|
// tabs mode (hideSectionTitles unset) the form's own title still renders.
|
|
450
565
|
expect(screen.getByTestId("render-edit-form-title")).toBeTruthy();
|
|
566
|
+
// Contrast for the tabs-mode "no Section wrapper" test above — a stacked
|
|
567
|
+
// (non-tabs) relatedList section has a visible title, so it keeps its
|
|
568
|
+
// own Section card.
|
|
569
|
+
expect(screen.getByTestId("related-list-Payments")).toBeTruthy();
|
|
451
570
|
});
|
|
452
571
|
});
|
|
453
572
|
|
|
@@ -119,8 +119,8 @@ describe("RenderEdit", () => {
|
|
|
119
119
|
// notes is hidden (isUrgent=false): title, count, isUrgent → 3 cells.
|
|
120
120
|
expect(grid?.children.length).toBe(3);
|
|
121
121
|
|
|
122
|
-
const
|
|
123
|
-
fireEvent.click(
|
|
122
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
123
|
+
fireEvent.click(urgentSwitch as HTMLElement);
|
|
124
124
|
|
|
125
125
|
// notes becomes visible → 4 cells, no leftover empty cell from before.
|
|
126
126
|
expect(grid?.children.length).toBe(4);
|
|
@@ -240,10 +240,9 @@ describe("RenderEdit", () => {
|
|
|
240
240
|
);
|
|
241
241
|
|
|
242
242
|
expect(screen.queryByTestId("field-notes")).toBeNull();
|
|
243
|
-
// boolean
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
fireEvent.click(urgentCheckbox as HTMLElement);
|
|
243
|
+
// boolean field in a standard form = vendored Radix Switch → button[role=switch].
|
|
244
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
245
|
+
fireEvent.click(urgentSwitch as HTMLElement);
|
|
247
246
|
expect(screen.queryByTestId("field-notes")).toBeTruthy();
|
|
248
247
|
});
|
|
249
248
|
|
|
@@ -284,7 +283,7 @@ describe("RenderEdit", () => {
|
|
|
284
283
|
expect(seenResults[0]?.isSuccess).toBe(true);
|
|
285
284
|
});
|
|
286
285
|
|
|
287
|
-
test("layout.width defaults the form shell to max-w-
|
|
286
|
+
test("layout.width defaults the form shell to max-w-4xl when unset", () => {
|
|
288
287
|
render(
|
|
289
288
|
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
290
289
|
<RenderEdit<TestValues>
|
|
@@ -298,7 +297,7 @@ describe("RenderEdit", () => {
|
|
|
298
297
|
);
|
|
299
298
|
|
|
300
299
|
const shell = screen.getByTestId("render-edit-form").firstElementChild;
|
|
301
|
-
expect(shell?.className).toContain("max-w-
|
|
300
|
+
expect(shell?.className).toContain("max-w-4xl");
|
|
302
301
|
expect(shell?.className).not.toContain("max-w-3xl");
|
|
303
302
|
});
|
|
304
303
|
|
|
@@ -2858,8 +2857,8 @@ describe("RenderEdit locked state (#1896)", () => {
|
|
|
2858
2857
|
expect((titleInput as HTMLInputElement).disabled).toBe(true);
|
|
2859
2858
|
const countInput = screen.getByTestId("field-count").querySelector("input");
|
|
2860
2859
|
expect((countInput as HTMLInputElement).disabled).toBe(true);
|
|
2861
|
-
const
|
|
2862
|
-
expect((
|
|
2860
|
+
const urgentSwitch = screen.getByTestId("field-isUrgent").querySelector('[role="switch"]');
|
|
2861
|
+
expect((urgentSwitch as HTMLButtonElement).disabled).toBe(true);
|
|
2863
2862
|
expect((screen.getByTestId("render-edit-submit") as HTMLButtonElement).disabled).toBe(true);
|
|
2864
2863
|
});
|
|
2865
2864
|
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Unit tests for the secretsEdit screen type. Secrets are write-only — the
|
|
3
|
+
// security invariant this file exists to pin: an input NEVER starts pre-filled
|
|
4
|
+
// with the redacted preview, and no dispatched payload ever carries that
|
|
5
|
+
// preview string next to the plaintext the user typed.
|
|
6
|
+
//
|
|
7
|
+
// `as unknown as Dispatcher["query"/"batch"/"write"]` throughout: each inline
|
|
8
|
+
// mock lambda only implements the one overload a given test exercises, never
|
|
9
|
+
// the full overloaded Dispatcher signature — the missing overloads are never
|
|
10
|
+
// called at runtime.
|
|
11
|
+
|
|
12
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
13
|
+
import type { SecretsEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
14
|
+
import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
15
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import userEvent from "@testing-library/user-event";
|
|
18
|
+
import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
|
|
19
|
+
|
|
20
|
+
const secretsScreen: SecretsEditScreenDefinition = {
|
|
21
|
+
id: "secrets",
|
|
22
|
+
type: "secretsEdit",
|
|
23
|
+
secretKeys: { "stripe-api-key": "stripe:secret:api-key" },
|
|
24
|
+
fieldLabels: { "stripe-api-key": "config.secret.stripe.api-key.label" },
|
|
25
|
+
sections: [{ fields: ["stripe-api-key"] }],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const schema: FeatureSchema = {
|
|
29
|
+
featureName: "config",
|
|
30
|
+
entities: {},
|
|
31
|
+
screens: [secretsScreen],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("KumikoScreen / secretsEdit", () => {
|
|
35
|
+
test("a set secret shows its redacted preview while the input stays empty", async () => {
|
|
36
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
37
|
+
query: (async () => ({
|
|
38
|
+
isSuccess: true,
|
|
39
|
+
data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
|
|
40
|
+
})) as unknown as Dispatcher["query"],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
render(
|
|
44
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
45
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
46
|
+
</DispatcherProvider>,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
50
|
+
await waitFor(() => screen.getByText("sk_***abc"));
|
|
51
|
+
const input = screen.getByTestId("secret-input-stripe-api-key") as HTMLInputElement;
|
|
52
|
+
expect(input.value).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("submitting with no input dispatches nothing", async () => {
|
|
56
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
57
|
+
isSuccess: true as const,
|
|
58
|
+
results: [],
|
|
59
|
+
}));
|
|
60
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
61
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
62
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
render(
|
|
67
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
68
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
69
|
+
</DispatcherProvider>,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
73
|
+
await user.click(screen.getByTestId("secrets-edit-submit"));
|
|
74
|
+
expect(batchSpy).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("typing a value and saving dispatches exactly one secrets:write:set with the plaintext, never the redacted preview", async () => {
|
|
78
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
79
|
+
isSuccess: true as const,
|
|
80
|
+
results: [],
|
|
81
|
+
}));
|
|
82
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
83
|
+
query: (async () => ({
|
|
84
|
+
isSuccess: true,
|
|
85
|
+
data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
|
|
86
|
+
})) as unknown as Dispatcher["query"],
|
|
87
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const user = userEvent.setup();
|
|
91
|
+
render(
|
|
92
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
93
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
94
|
+
</DispatcherProvider>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
98
|
+
const input = screen.getByTestId("secret-input-stripe-api-key");
|
|
99
|
+
await user.type(input, "sk_live_newvalue");
|
|
100
|
+
await user.click(screen.getByTestId("secrets-edit-submit"));
|
|
101
|
+
|
|
102
|
+
await waitFor(() => expect(batchSpy).toHaveBeenCalledTimes(1));
|
|
103
|
+
const commands = batchSpy.mock.calls[0]?.[0];
|
|
104
|
+
if (!commands) throw new Error("batchSpy not called");
|
|
105
|
+
expect(commands).toEqual([
|
|
106
|
+
{
|
|
107
|
+
type: "secrets:write:set",
|
|
108
|
+
payload: { key: "stripe:secret:api-key", value: "sk_live_newvalue" },
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
// The security invariant this test exists for: the preview never rides
|
|
112
|
+
// along in a write payload next to the plaintext.
|
|
113
|
+
expect(JSON.stringify(commands)).not.toContain("sk_***abc");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("an unset required secret shows the required marker; a set one does not", async () => {
|
|
117
|
+
const requiredScreen: SecretsEditScreenDefinition = {
|
|
118
|
+
...secretsScreen,
|
|
119
|
+
requiredFields: ["stripe-api-key"],
|
|
120
|
+
};
|
|
121
|
+
const requiredSchema: FeatureSchema = {
|
|
122
|
+
featureName: "config",
|
|
123
|
+
entities: {},
|
|
124
|
+
screens: [requiredScreen],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
128
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
133
|
+
<KumikoScreen schema={requiredSchema} qn="config:screen:secrets" />
|
|
134
|
+
</DispatcherProvider>,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
138
|
+
await waitFor(() => screen.getByTestId("required-marker-stripe-api-key"));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a set required secret does not show the required marker", async () => {
|
|
142
|
+
const requiredScreen: SecretsEditScreenDefinition = {
|
|
143
|
+
...secretsScreen,
|
|
144
|
+
requiredFields: ["stripe-api-key"],
|
|
145
|
+
};
|
|
146
|
+
const requiredSchema: FeatureSchema = {
|
|
147
|
+
featureName: "config",
|
|
148
|
+
entities: {},
|
|
149
|
+
screens: [requiredScreen],
|
|
150
|
+
};
|
|
151
|
+
const qualifiedKey = requiredScreen.secretKeys["stripe-api-key"];
|
|
152
|
+
|
|
153
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
154
|
+
query: (async () => ({
|
|
155
|
+
isSuccess: true,
|
|
156
|
+
data: [{ key: qualifiedKey, redactedPreview: "sk_***abc", hint: null }],
|
|
157
|
+
})) as unknown as Dispatcher["query"],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
render(
|
|
161
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
162
|
+
<KumikoScreen schema={requiredSchema} qn="config:screen:secrets" />
|
|
163
|
+
</DispatcherProvider>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
167
|
+
await waitFor(() => screen.getByText("sk_***abc"));
|
|
168
|
+
expect(screen.queryByTestId("required-marker-stripe-api-key")).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("an unset required secret does not block saving a different secret", async () => {
|
|
172
|
+
const twoFieldScreen: SecretsEditScreenDefinition = {
|
|
173
|
+
id: "secrets",
|
|
174
|
+
type: "secretsEdit",
|
|
175
|
+
secretKeys: {
|
|
176
|
+
"stripe-api-key": "stripe:secret:[REDACTED:API key param]",
|
|
177
|
+
"webhook-secret": "stripe:secret:[REDACTED:webhook param]",
|
|
178
|
+
},
|
|
179
|
+
fieldLabels: {
|
|
180
|
+
"stripe-api-key": "config.secret.stripe.api-key.label",
|
|
181
|
+
"webhook-secret": "config.secret.stripe.webhook.label",
|
|
182
|
+
},
|
|
183
|
+
sections: [{ fields: ["stripe-api-key", "webhook-secret"] }],
|
|
184
|
+
requiredFields: ["stripe-api-key"],
|
|
185
|
+
};
|
|
186
|
+
const twoFieldSchema: FeatureSchema = {
|
|
187
|
+
featureName: "config",
|
|
188
|
+
entities: {},
|
|
189
|
+
screens: [twoFieldScreen],
|
|
190
|
+
};
|
|
191
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
192
|
+
isSuccess: true as const,
|
|
193
|
+
results: [],
|
|
194
|
+
}));
|
|
195
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
196
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
197
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const user = userEvent.setup();
|
|
201
|
+
render(
|
|
202
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
203
|
+
<KumikoScreen schema={twoFieldSchema} qn="config:screen:secrets" />
|
|
204
|
+
</DispatcherProvider>,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
208
|
+
const requiredInput = screen.getByTestId("secret-input-stripe-api-key") as HTMLInputElement;
|
|
209
|
+
expect(requiredInput.required).toBe(false);
|
|
210
|
+
|
|
211
|
+
await user.type(screen.getByTestId("secret-input-webhook-secret"), "whsec_newvalue");
|
|
212
|
+
await user.click(screen.getByTestId("secrets-edit-submit"));
|
|
213
|
+
|
|
214
|
+
await waitFor(() => expect(batchSpy).toHaveBeenCalledTimes(1));
|
|
215
|
+
const commands = batchSpy.mock.calls[0]?.[0];
|
|
216
|
+
if (!commands) throw new Error("batchSpy not called");
|
|
217
|
+
expect(commands).toEqual([
|
|
218
|
+
{
|
|
219
|
+
type: "secrets:write:set",
|
|
220
|
+
payload: { key: "stripe:secret:[REDACTED:webhook param]", value: "whsec_newvalue" },
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("delete dispatches secrets:write:delete with the qualified key", async () => {
|
|
226
|
+
const writeSpy = mock(async (_type: string, _payload: unknown) => ({
|
|
227
|
+
isSuccess: true as const,
|
|
228
|
+
data: {},
|
|
229
|
+
}));
|
|
230
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
231
|
+
query: (async () => ({
|
|
232
|
+
isSuccess: true,
|
|
233
|
+
data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
|
|
234
|
+
})) as unknown as Dispatcher["query"],
|
|
235
|
+
write: writeSpy as unknown as Dispatcher["write"],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const user = userEvent.setup();
|
|
239
|
+
render(
|
|
240
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
241
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
242
|
+
</DispatcherProvider>,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
246
|
+
await user.click(screen.getByTestId("secret-delete-stripe-api-key"));
|
|
247
|
+
|
|
248
|
+
await waitFor(() => expect(writeSpy).toHaveBeenCalledTimes(1));
|
|
249
|
+
expect(writeSpy).toHaveBeenCalledWith("secrets:write:delete", { key: "stripe:secret:api-key" });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("a failed save shows the error and never renders the typed plaintext", async () => {
|
|
253
|
+
const plaintext = "sk_live_wouldbeleakedifechoed";
|
|
254
|
+
const serverError: DispatcherError = {
|
|
255
|
+
code: "TENANT_SECRET_WRITE_CONFLICT",
|
|
256
|
+
httpStatus: 409,
|
|
257
|
+
i18nKey: "secrets:errors.writeConflict",
|
|
258
|
+
message: "Could not save secret: a concurrent update conflicted, please retry.",
|
|
259
|
+
};
|
|
260
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
261
|
+
isSuccess: false as const,
|
|
262
|
+
error: serverError,
|
|
263
|
+
}));
|
|
264
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
265
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
266
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const user = userEvent.setup();
|
|
270
|
+
render(
|
|
271
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
272
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
273
|
+
</DispatcherProvider>,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
277
|
+
const input = screen.getByTestId("secret-input-stripe-api-key");
|
|
278
|
+
await user.type(input, plaintext);
|
|
279
|
+
await user.click(screen.getByTestId("secrets-edit-submit"));
|
|
280
|
+
|
|
281
|
+
await waitFor(() => screen.getByTestId("secrets-edit-error"));
|
|
282
|
+
expect(screen.getByTestId("secrets-edit-error").textContent).toContain(serverError.message);
|
|
283
|
+
// The drafted plaintext must never leak into the error surface (or
|
|
284
|
+
// anywhere else in the DOM besides the input's own `value`).
|
|
285
|
+
expect(document.body.textContent).not.toContain(plaintext);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -440,13 +440,12 @@ describe("WorkspaceShell", () => {
|
|
|
440
440
|
expect(screen.queryByText("Tours")).toBeNull();
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
-
test("fill
|
|
443
|
+
test("fill defaults to true: applies h-svh on root and min-h-0 on inset/main", () => {
|
|
444
444
|
renderShell(
|
|
445
445
|
<WorkspaceShell
|
|
446
446
|
brand={<div>Brand</div>}
|
|
447
447
|
schema={schema}
|
|
448
448
|
user={{ id: "u1", roles: ["admin"] }}
|
|
449
|
-
fill
|
|
450
449
|
>
|
|
451
450
|
<div>content</div>
|
|
452
451
|
</WorkspaceShell>,
|
|
@@ -462,13 +461,15 @@ describe("WorkspaceShell", () => {
|
|
|
462
461
|
expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
|
|
463
462
|
});
|
|
464
463
|
|
|
465
|
-
//
|
|
466
|
-
|
|
464
|
+
// fill={false} is the escape hatch back to page-scroll: must not apply
|
|
465
|
+
// viewport-lock classes.
|
|
466
|
+
test("fill={false} does not apply h-svh or min-h-0", () => {
|
|
467
467
|
renderShell(
|
|
468
468
|
<WorkspaceShell
|
|
469
469
|
brand={<div>Brand</div>}
|
|
470
470
|
schema={schema}
|
|
471
471
|
user={{ id: "u1", roles: ["admin"] }}
|
|
472
|
+
fill={false}
|
|
472
473
|
>
|
|
473
474
|
<div>content</div>
|
|
474
475
|
</WorkspaceShell>,
|
package/src/icons.tsx
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
// Icon registry: symbolic `NavIconKey` → lucide-react component. `NavIconKey`
|
|
2
|
+
// is the closed vocabulary a feature author can write (packages/types/src/
|
|
3
|
+
// nav-icon.ts); `satisfies` below makes this map a compile-time drift
|
|
4
|
+
// guard — a key added to one without the other fails the build.
|
|
5
|
+
//
|
|
6
|
+
// Runtime data (provider-supplied nav/action icons) is only typed as plain
|
|
7
|
+
// `string` at the resolved-tree layer, so an unknown key can still show up
|
|
8
|
+
// at runtime on occasion — `Icon` falls back to rendering the raw key as
|
|
9
|
+
// text rather than crashing, mirroring the old `ActionGlyph` behavior in
|
|
10
|
+
// nav-tree.tsx.
|
|
11
|
+
//
|
|
12
|
+
// Buttons resolve icons through the same map — importing it from the nav
|
|
13
|
+
// layout module would couple them to the sidebar.
|
|
1
14
|
import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
15
|
import {
|
|
16
|
+
AlertTriangle,
|
|
17
|
+
Archive,
|
|
3
18
|
ArrowLeft,
|
|
4
19
|
ArrowRight,
|
|
5
20
|
BarChart3,
|
|
@@ -9,17 +24,27 @@ import {
|
|
|
9
24
|
Calculator,
|
|
10
25
|
CalendarDays,
|
|
11
26
|
Check,
|
|
27
|
+
CheckCircle2,
|
|
28
|
+
ChevronDown,
|
|
29
|
+
ChevronRight,
|
|
12
30
|
ClipboardList,
|
|
31
|
+
Clock,
|
|
13
32
|
Coins,
|
|
14
33
|
Copy,
|
|
15
34
|
CreditCard,
|
|
16
35
|
Download,
|
|
36
|
+
ExternalLink,
|
|
37
|
+
Eye,
|
|
38
|
+
EyeOff,
|
|
17
39
|
FileText,
|
|
40
|
+
Filter,
|
|
41
|
+
Flag,
|
|
18
42
|
Folder,
|
|
19
43
|
FolderOpen,
|
|
20
44
|
Gauge,
|
|
21
45
|
Hash,
|
|
22
46
|
Home,
|
|
47
|
+
Info,
|
|
23
48
|
KeyRound,
|
|
24
49
|
Languages,
|
|
25
50
|
Layers,
|
|
@@ -28,14 +53,23 @@ import {
|
|
|
28
53
|
LineChart,
|
|
29
54
|
Link,
|
|
30
55
|
List,
|
|
56
|
+
Loader2,
|
|
31
57
|
Lock,
|
|
32
58
|
Mail,
|
|
59
|
+
MapPin,
|
|
60
|
+
MoreHorizontal,
|
|
61
|
+
MoreVertical,
|
|
33
62
|
Package,
|
|
34
63
|
Palette,
|
|
64
|
+
Pencil,
|
|
65
|
+
Phone,
|
|
35
66
|
PiggyBank,
|
|
36
67
|
Plus,
|
|
68
|
+
Printer,
|
|
37
69
|
Receipt,
|
|
70
|
+
RefreshCw,
|
|
38
71
|
Rocket,
|
|
72
|
+
Save,
|
|
39
73
|
Search,
|
|
40
74
|
Send,
|
|
41
75
|
Server,
|
|
@@ -44,19 +78,23 @@ import {
|
|
|
44
78
|
Shield,
|
|
45
79
|
ShieldCheck,
|
|
46
80
|
Sparkles,
|
|
81
|
+
Star,
|
|
47
82
|
Table,
|
|
48
83
|
Tag,
|
|
49
84
|
Trash2,
|
|
50
85
|
TrendingUp,
|
|
86
|
+
Undo2,
|
|
51
87
|
Upload,
|
|
52
88
|
User,
|
|
53
89
|
Users,
|
|
54
90
|
Wallet,
|
|
55
91
|
Wand2,
|
|
56
92
|
X,
|
|
93
|
+
XCircle,
|
|
57
94
|
} from "lucide-react";
|
|
95
|
+
import type { ReactNode } from "react";
|
|
96
|
+
import { cn } from "./lib/cn";
|
|
58
97
|
|
|
59
|
-
// Buttons resolve icons through the same map, importing it from the nav layout module would couple them to the sidebar.
|
|
60
98
|
export const NAV_ICONS = {
|
|
61
99
|
dashboard: LayoutDashboard,
|
|
62
100
|
"layout-grid": LayoutGrid,
|
|
@@ -113,4 +151,49 @@ export const NAV_ICONS = {
|
|
|
113
151
|
"arrow-left": ArrowLeft,
|
|
114
152
|
"arrow-right": ArrowRight,
|
|
115
153
|
copy: Copy,
|
|
154
|
+
pencil: Pencil,
|
|
155
|
+
eye: Eye,
|
|
156
|
+
"eye-off": EyeOff,
|
|
157
|
+
filter: Filter,
|
|
158
|
+
refresh: RefreshCw,
|
|
159
|
+
"more-horizontal": MoreHorizontal,
|
|
160
|
+
"more-vertical": MoreVertical,
|
|
161
|
+
"external-link": ExternalLink,
|
|
162
|
+
"chevron-down": ChevronDown,
|
|
163
|
+
"chevron-right": ChevronRight,
|
|
164
|
+
save: Save,
|
|
165
|
+
undo: Undo2,
|
|
166
|
+
archive: Archive,
|
|
167
|
+
star: Star,
|
|
168
|
+
flag: Flag,
|
|
169
|
+
clock: Clock,
|
|
170
|
+
"map-pin": MapPin,
|
|
171
|
+
phone: Phone,
|
|
172
|
+
printer: Printer,
|
|
173
|
+
"alert-triangle": AlertTriangle,
|
|
174
|
+
info: Info,
|
|
175
|
+
"check-circle": CheckCircle2,
|
|
176
|
+
"x-circle": XCircle,
|
|
177
|
+
loader: Loader2,
|
|
116
178
|
} as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
|
|
179
|
+
|
|
180
|
+
// Widened alias for runtime lookups against the plain `string` icon keys
|
|
181
|
+
// resolved-tree nodes carry — not the closed NavIconKey union NAV_ICONS
|
|
182
|
+
// itself is typed against.
|
|
183
|
+
const ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
|
|
184
|
+
|
|
185
|
+
export function Icon({
|
|
186
|
+
name,
|
|
187
|
+
className,
|
|
188
|
+
}: {
|
|
189
|
+
readonly name: NavIconKey;
|
|
190
|
+
readonly className?: string;
|
|
191
|
+
}): ReactNode {
|
|
192
|
+
const LucideIcon = Object.hasOwn(NAV_ICONS, name) ? ICON_LOOKUP[name] : undefined;
|
|
193
|
+
if (LucideIcon !== undefined) return <LucideIcon aria-hidden="true" className={className} />;
|
|
194
|
+
return (
|
|
195
|
+
<span aria-hidden="true" className={cn("text-xs", className)}>
|
|
196
|
+
{name}
|
|
197
|
+
</span>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -13,12 +13,12 @@ export type AppLayoutProps = {
|
|
|
13
13
|
readonly sidebar?: ReactNode;
|
|
14
14
|
readonly children: ReactNode;
|
|
15
15
|
readonly testId?: string;
|
|
16
|
-
/** Viewport-fit Shell. true → Wurzel = `h-screen` (fixe
|
|
17
|
-
* Sidebar/Topbar bleiben stehen, der Main-Bereich
|
|
18
|
-
* (`min-h-0` + `overflow-auto`). false
|
|
19
|
-
* `min-h-screen`-Flow, die ganze Seite scrollt
|
|
20
|
-
*
|
|
21
|
-
* Clippt nie — der Content scrollt in `main` statt im Body. */
|
|
16
|
+
/** Viewport-fit Shell. Default `true` → Wurzel = `h-screen` (fixe
|
|
17
|
+
* Viewport-Höhe), Sidebar/Topbar bleiben stehen, der Main-Bereich
|
|
18
|
+
* scrollt INNEN (`min-h-0` + `overflow-auto`). `false` → klassischer
|
|
19
|
+
* `min-h-screen`-Flow, die ganze Seite scrollt — Escape-Hatch für
|
|
20
|
+
* eine öffentliche, lange Content-Seite die echten Body-Scroll
|
|
21
|
+
* braucht. Clippt nie — der Content scrollt in `main` statt im Body. */
|
|
22
22
|
readonly fill?: boolean;
|
|
23
23
|
/** Optionaler Klassen-Append an die Wurzel (eigener Hintergrund etc.).
|
|
24
24
|
* Erweitert die Defaults, ersetzt sie nicht (cn-merge). */
|
|
@@ -32,7 +32,7 @@ export function AppLayout({
|
|
|
32
32
|
sidebar,
|
|
33
33
|
children,
|
|
34
34
|
testId,
|
|
35
|
-
fill,
|
|
35
|
+
fill = true,
|
|
36
36
|
className,
|
|
37
37
|
mainClassName,
|
|
38
38
|
}: AppLayoutProps): ReactNode {
|