@cosmicdrift/kumiko-renderer-web 0.259.0 → 0.261.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__/entity-edit-redirect.test.tsx +158 -0
- package/src/__tests__/kumiko-screen.test.tsx +34 -0
- package/src/__tests__/primitives.test.tsx +1 -1
- package/src/__tests__/projection-detail-record-layout.test.tsx +111 -0
- package/src/__tests__/render-edit.test.tsx +330 -0
- package/src/layout/workspace-shell.tsx +2 -1
- package/src/primitives/__tests__/textarea-submit-shortcut.test.tsx +71 -0
- package/src/primitives/index.tsx +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.261.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.261.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.261.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.261.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",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@types/react-dom": "^19.2.3",
|
|
67
67
|
"jsdom": "^29.1.1",
|
|
68
68
|
"tailwindcss": "^4.3.0",
|
|
69
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
69
|
+
"@cosmicdrift/kumiko-locale-de": "0.261.0"
|
|
70
70
|
},
|
|
71
71
|
"repository": {
|
|
72
72
|
"type": "git",
|
|
@@ -42,6 +42,44 @@ function buildSchema(redirect?: string): FeatureSchema {
|
|
|
42
42
|
} as FeatureSchema;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// The edited "product" stands in for a child record (a deposit movement's
|
|
46
|
+
// deposit, a protocol section's protocol) — a "parent-detail" entityEdit
|
|
47
|
+
// screen is the redirect target, and parentEntity's own field carries the
|
|
48
|
+
// parent id.
|
|
49
|
+
const parentEntity: EntityDefinition = {
|
|
50
|
+
fields: {
|
|
51
|
+
name: { type: "text", required: false, searchable: false, sortable: false },
|
|
52
|
+
parentId: { type: "text", required: false, searchable: false, sortable: false },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function buildParentRedirectSchema(): FeatureSchema {
|
|
57
|
+
const editScreen: ScreenDefinition = {
|
|
58
|
+
id: "product-edit",
|
|
59
|
+
type: "entityEdit",
|
|
60
|
+
entity: "product",
|
|
61
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
62
|
+
redirect: { screen: "parent-detail", idFrom: "parentId" },
|
|
63
|
+
};
|
|
64
|
+
const parentDetailScreen: ScreenDefinition = {
|
|
65
|
+
id: "parent-detail",
|
|
66
|
+
type: "entityEdit",
|
|
67
|
+
entity: "product",
|
|
68
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
69
|
+
};
|
|
70
|
+
const listScreen: ScreenDefinition = {
|
|
71
|
+
id: "product-list",
|
|
72
|
+
type: "entityList",
|
|
73
|
+
entity: "product",
|
|
74
|
+
columns: ["name"],
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
featureName: "shop",
|
|
78
|
+
entities: { product: parentEntity },
|
|
79
|
+
screens: [editScreen, parentDetailScreen, listScreen],
|
|
80
|
+
} as FeatureSchema;
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
function fillNameAndSubmit(): void {
|
|
46
84
|
const nameInput = screen.getByTestId("field-name").querySelector("input") as HTMLInputElement;
|
|
47
85
|
fireEvent.change(nameInput, { target: { value: "Widget" } });
|
|
@@ -306,4 +344,124 @@ describe("entityEdit redirect (#1942)", () => {
|
|
|
306
344
|
|
|
307
345
|
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
|
|
308
346
|
});
|
|
347
|
+
|
|
348
|
+
// entityEdit's redirect object form + idFrom — same resolution as
|
|
349
|
+
// actionForm's, reused by EntityEditUpdateForm's handleSubmitted.
|
|
350
|
+
test("update: redirect object form with idFrom falls back to the loaded record when the payload lacks the field", async () => {
|
|
351
|
+
const navigated: NavTarget[] = [];
|
|
352
|
+
const dispatcher = createMockDispatcher({
|
|
353
|
+
query: (async () => ({
|
|
354
|
+
isSuccess: true,
|
|
355
|
+
data: { id: "42", version: 1, name: "Existing", parentId: "parent-1" },
|
|
356
|
+
})) as unknown as Dispatcher["query"],
|
|
357
|
+
// The update handler's success payload reports only its own id
|
|
358
|
+
// (event-store-executor-write.ts) — no "parentId" field.
|
|
359
|
+
write: (async () => ({
|
|
360
|
+
isSuccess: true,
|
|
361
|
+
data: { id: "42" },
|
|
362
|
+
})) as unknown as Dispatcher["write"],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
render(
|
|
366
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
367
|
+
<NavProvider
|
|
368
|
+
value={{
|
|
369
|
+
route: { screenId: "shop:screen:product-edit", entityId: "42" },
|
|
370
|
+
navigate: (target) => navigated.push(target),
|
|
371
|
+
replace: () => {},
|
|
372
|
+
hrefFor: () => "",
|
|
373
|
+
searchParams: {},
|
|
374
|
+
setSearchParams: () => {},
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
<KumikoScreen
|
|
378
|
+
schema={buildParentRedirectSchema()}
|
|
379
|
+
qn="shop:screen:product-edit"
|
|
380
|
+
entityId="42"
|
|
381
|
+
/>
|
|
382
|
+
</NavProvider>
|
|
383
|
+
</DispatcherProvider>,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
|
|
387
|
+
fillNameAndSubmit();
|
|
388
|
+
|
|
389
|
+
await waitFor(() =>
|
|
390
|
+
expect(navigated).toEqual([{ screenId: "parent-detail", entityId: "parent-1" }]),
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("update: redirect object form with idFrom prefers the payload value over the loaded record", async () => {
|
|
395
|
+
const navigated: NavTarget[] = [];
|
|
396
|
+
const dispatcher = createMockDispatcher({
|
|
397
|
+
query: (async () => ({
|
|
398
|
+
isSuccess: true,
|
|
399
|
+
data: { id: "42", version: 1, name: "Existing", parentId: "record-parent" },
|
|
400
|
+
})) as unknown as Dispatcher["query"],
|
|
401
|
+
write: (async () => ({
|
|
402
|
+
isSuccess: true,
|
|
403
|
+
data: { id: "42", parentId: "payload-parent" },
|
|
404
|
+
})) as unknown as Dispatcher["write"],
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
render(
|
|
408
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
409
|
+
<NavProvider
|
|
410
|
+
value={{
|
|
411
|
+
route: { screenId: "shop:screen:product-edit", entityId: "42" },
|
|
412
|
+
navigate: (target) => navigated.push(target),
|
|
413
|
+
replace: () => {},
|
|
414
|
+
hrefFor: () => "",
|
|
415
|
+
searchParams: {},
|
|
416
|
+
setSearchParams: () => {},
|
|
417
|
+
}}
|
|
418
|
+
>
|
|
419
|
+
<KumikoScreen
|
|
420
|
+
schema={buildParentRedirectSchema()}
|
|
421
|
+
qn="shop:screen:product-edit"
|
|
422
|
+
entityId="42"
|
|
423
|
+
/>
|
|
424
|
+
</NavProvider>
|
|
425
|
+
</DispatcherProvider>,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
|
|
429
|
+
fillNameAndSubmit();
|
|
430
|
+
|
|
431
|
+
await waitFor(() =>
|
|
432
|
+
expect(navigated).toEqual([{ screenId: "parent-detail", entityId: "payload-parent" }]),
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("create: redirect object form with idFrom reads the id from the write handler's payload", async () => {
|
|
437
|
+
const navigated: NavTarget[] = [];
|
|
438
|
+
const dispatcher = createMockDispatcher({
|
|
439
|
+
write: (async () => ({
|
|
440
|
+
isSuccess: true,
|
|
441
|
+
data: { id: "new-1", parentId: "parent-1" },
|
|
442
|
+
})) as unknown as Dispatcher["write"],
|
|
443
|
+
});
|
|
444
|
+
render(
|
|
445
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
446
|
+
<NavProvider
|
|
447
|
+
value={{
|
|
448
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
449
|
+
navigate: (target) => navigated.push(target),
|
|
450
|
+
replace: () => {},
|
|
451
|
+
hrefFor: () => "",
|
|
452
|
+
searchParams: {},
|
|
453
|
+
setSearchParams: () => {},
|
|
454
|
+
}}
|
|
455
|
+
>
|
|
456
|
+
<KumikoScreen schema={buildParentRedirectSchema()} qn="shop:screen:product-edit" />
|
|
457
|
+
</NavProvider>
|
|
458
|
+
</DispatcherProvider>,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
fillNameAndSubmit();
|
|
462
|
+
|
|
463
|
+
await waitFor(() =>
|
|
464
|
+
expect(navigated).toEqual([{ screenId: "parent-detail", entityId: "parent-1" }]),
|
|
465
|
+
);
|
|
466
|
+
});
|
|
309
467
|
});
|
|
@@ -1911,6 +1911,40 @@ describe("KumikoScreen", () => {
|
|
|
1911
1911
|
expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
|
|
1912
1912
|
});
|
|
1913
1913
|
|
|
1914
|
+
// slots.header/footer on entityEdit are shared through action-form-shim's
|
|
1915
|
+
// synthesizeActionFormScreen — an actionForm mounts them the same way.
|
|
1916
|
+
test("actionForm: slots.header and slots.footer both render (fw#2853)", async () => {
|
|
1917
|
+
const TopBanner = () => <div data-testid="action-form-header-slot">top banner</div>;
|
|
1918
|
+
const BottomAction = () => <button type="button" data-testid="action-form-footer-slot" />;
|
|
1919
|
+
|
|
1920
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
1921
|
+
id: "quick-add-slots",
|
|
1922
|
+
type: "actionForm",
|
|
1923
|
+
handler: "tasks:write:task:quick-add",
|
|
1924
|
+
fields: { title: { type: "text", required: true } },
|
|
1925
|
+
layout: { sections: [{ title: "Basics", fields: ["title"] }] },
|
|
1926
|
+
slots: {
|
|
1927
|
+
header: { react: { __component: "TopBanner" } },
|
|
1928
|
+
footer: { react: { __component: "BottomAction" } },
|
|
1929
|
+
},
|
|
1930
|
+
};
|
|
1931
|
+
|
|
1932
|
+
render(
|
|
1933
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
1934
|
+
<ExtensionSectionsProvider value={{ TopBanner, BottomAction }}>
|
|
1935
|
+
<KumikoScreen
|
|
1936
|
+
schema={{ ...schema, screens: [actionScreen] }}
|
|
1937
|
+
qn="tasks:screen:quick-add-slots"
|
|
1938
|
+
/>
|
|
1939
|
+
</ExtensionSectionsProvider>
|
|
1940
|
+
</DispatcherProvider>,
|
|
1941
|
+
);
|
|
1942
|
+
|
|
1943
|
+
expect(screen.getByTestId("action-form-header-slot")).toBeTruthy();
|
|
1944
|
+
expect(screen.getByTestId("action-form-footer-slot")).toBeTruthy();
|
|
1945
|
+
expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1914
1948
|
// fw#2752: actionForm submitStyle reaches the submit button.
|
|
1915
1949
|
test("actionForm submitStyle='danger': submit button renders destructive variant", async () => {
|
|
1916
1950
|
const dispatcher = makeDispatcher({
|
|
@@ -404,7 +404,7 @@ describe("DataTable", () => {
|
|
|
404
404
|
/>
|
|
405
405
|
</LocaleProvider>,
|
|
406
406
|
);
|
|
407
|
-
expect(screen.getByTestId("t-empty").textContent).toBe("
|
|
407
|
+
expect(screen.getByTestId("t-empty").textContent).toBe("Noch keine Einträge.");
|
|
408
408
|
});
|
|
409
409
|
|
|
410
410
|
test("rows + cells get individual testIds for E2E hooks", () => {
|
|
@@ -79,6 +79,31 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
79
79
|
expect(screen.getByTestId("kumiko-screen-projection-detail-status").textContent).toBe("active");
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
test("status sits in the title row; the subtitle sits in its own row below", async () => {
|
|
83
|
+
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
84
|
+
...baseScreen,
|
|
85
|
+
header: { title: "tenantName", subtitle: "address", status: "state" },
|
|
86
|
+
};
|
|
87
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
88
|
+
|
|
89
|
+
render(
|
|
90
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
91
|
+
<KumikoScreen
|
|
92
|
+
schema={schemaFor(headerScreen)}
|
|
93
|
+
qn="rentals:screen:rent-detail"
|
|
94
|
+
entityId="rent-1"
|
|
95
|
+
/>
|
|
96
|
+
</DispatcherProvider>,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
100
|
+
const title = screen.getByTestId("kumiko-screen-projection-detail-title");
|
|
101
|
+
const status = screen.getByTestId("kumiko-screen-projection-detail-status");
|
|
102
|
+
const subtitle = screen.getByTestId("kumiko-screen-projection-detail-subtitle");
|
|
103
|
+
expect(status.parentElement).toBe(title.parentElement);
|
|
104
|
+
expect(subtitle.parentElement).not.toBe(title.parentElement);
|
|
105
|
+
});
|
|
106
|
+
|
|
82
107
|
test("renders the metrics band with fieldLabels-translated labels and query-row values", async () => {
|
|
83
108
|
const metricsScreen: ProjectionDetailScreenDefinition = {
|
|
84
109
|
...baseScreen,
|
|
@@ -158,6 +183,92 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
158
183
|
expect(setSearchParamsCalls).toContainEqual({ overdueDays: "3" });
|
|
159
184
|
});
|
|
160
185
|
|
|
186
|
+
test("metric.navigate with only tab — clicking stays on the record and just activates the tab", async () => {
|
|
187
|
+
const navigateCalls: unknown[] = [];
|
|
188
|
+
const setSearchParamsCalls: Readonly<Record<string, string | null>>[] = [];
|
|
189
|
+
const navApi: NavApi = {
|
|
190
|
+
route: undefined,
|
|
191
|
+
navigate: (target) => navigateCalls.push(target),
|
|
192
|
+
replace: () => {},
|
|
193
|
+
hrefFor: () => "",
|
|
194
|
+
searchParams: {},
|
|
195
|
+
setSearchParams: (updates) => setSearchParamsCalls.push(updates),
|
|
196
|
+
};
|
|
197
|
+
const metricsScreen: ProjectionDetailScreenDefinition = {
|
|
198
|
+
...baseScreen,
|
|
199
|
+
metrics: [
|
|
200
|
+
{
|
|
201
|
+
field: "balance",
|
|
202
|
+
label: "rentals.detail.metric.balance",
|
|
203
|
+
navigate: { tab: "history" },
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
208
|
+
const user = userEvent.setup();
|
|
209
|
+
|
|
210
|
+
render(
|
|
211
|
+
<NavProvider value={navApi}>
|
|
212
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
213
|
+
<KumikoScreen
|
|
214
|
+
schema={schemaFor(metricsScreen)}
|
|
215
|
+
qn="rentals:screen:rent-detail"
|
|
216
|
+
entityId="rent-1"
|
|
217
|
+
/>
|
|
218
|
+
</DispatcherProvider>
|
|
219
|
+
</NavProvider>,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
223
|
+
await user.click(screen.getByTestId("kumiko-screen-projection-detail-metric-balance"));
|
|
224
|
+
|
|
225
|
+
expect(navigateCalls).toEqual([]);
|
|
226
|
+
expect(setSearchParamsCalls).toContainEqual({ tab: "history" });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("metric.navigate with screen + tab — navigates to the screen and sets the tab search param", async () => {
|
|
230
|
+
const navigateCalls: unknown[] = [];
|
|
231
|
+
const setSearchParamsCalls: Readonly<Record<string, string | null>>[] = [];
|
|
232
|
+
const navApi: NavApi = {
|
|
233
|
+
route: undefined,
|
|
234
|
+
navigate: (target) => navigateCalls.push(target),
|
|
235
|
+
replace: () => {},
|
|
236
|
+
hrefFor: () => "",
|
|
237
|
+
searchParams: {},
|
|
238
|
+
setSearchParams: (updates) => setSearchParamsCalls.push(updates),
|
|
239
|
+
};
|
|
240
|
+
const metricsScreen: ProjectionDetailScreenDefinition = {
|
|
241
|
+
...baseScreen,
|
|
242
|
+
metrics: [
|
|
243
|
+
{
|
|
244
|
+
field: "balance",
|
|
245
|
+
label: "rentals.detail.metric.balance",
|
|
246
|
+
navigate: { screen: "tenant-detail", tab: "history" },
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
251
|
+
const user = userEvent.setup();
|
|
252
|
+
|
|
253
|
+
render(
|
|
254
|
+
<NavProvider value={navApi}>
|
|
255
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
256
|
+
<KumikoScreen
|
|
257
|
+
schema={schemaFor(metricsScreen)}
|
|
258
|
+
qn="rentals:screen:rent-detail"
|
|
259
|
+
entityId="rent-1"
|
|
260
|
+
/>
|
|
261
|
+
</DispatcherProvider>
|
|
262
|
+
</NavProvider>,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
266
|
+
await user.click(screen.getByTestId("kumiko-screen-projection-detail-metric-balance"));
|
|
267
|
+
|
|
268
|
+
expect(navigateCalls).toEqual([{ screenId: "tenant-detail" }]);
|
|
269
|
+
expect(setSearchParamsCalls).toContainEqual({ tab: "history" });
|
|
270
|
+
});
|
|
271
|
+
|
|
161
272
|
test("header.subtitleHref — an absolute http(s) URL renders the subtitle as an external link", async () => {
|
|
162
273
|
const headerScreen: ProjectionDetailScreenDefinition = {
|
|
163
274
|
...baseScreen,
|
|
@@ -3537,3 +3537,333 @@ describe("RenderEdit fields filter", () => {
|
|
|
3537
3537
|
expect(write).not.toHaveBeenCalled();
|
|
3538
3538
|
});
|
|
3539
3539
|
});
|
|
3540
|
+
|
|
3541
|
+
describe("RenderEdit — slots.header", () => {
|
|
3542
|
+
function HeaderExtra({ entityId }: { readonly entityId: string | null }): ReactNode {
|
|
3543
|
+
return (
|
|
3544
|
+
<div data-testid="header-extra" data-entity-id={entityId ?? "(create)"}>
|
|
3545
|
+
header extra
|
|
3546
|
+
</div>
|
|
3547
|
+
);
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
function makeHeaderScreen(): EntityEditScreenDefinition {
|
|
3551
|
+
return {
|
|
3552
|
+
id: "orders:screen:order-edit-header",
|
|
3553
|
+
type: "entityEdit",
|
|
3554
|
+
entity: "order",
|
|
3555
|
+
layout: {
|
|
3556
|
+
sections: [{ title: "Basics", columns: 1, fields: [{ field: "title" }] }],
|
|
3557
|
+
},
|
|
3558
|
+
slots: { header: { react: { __component: "HeaderExtra" } } },
|
|
3559
|
+
};
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
function makeWizardHeaderScreen(): EntityEditScreenDefinition {
|
|
3563
|
+
return {
|
|
3564
|
+
id: "orders:screen:order-wizard-header",
|
|
3565
|
+
type: "entityEdit",
|
|
3566
|
+
entity: "order",
|
|
3567
|
+
layout: {
|
|
3568
|
+
mode: "wizard",
|
|
3569
|
+
sections: [
|
|
3570
|
+
{ title: "Basics", columns: 1, fields: [{ field: "title" }] },
|
|
3571
|
+
{ title: "Details", columns: 1, fields: [{ field: "count" }] },
|
|
3572
|
+
],
|
|
3573
|
+
},
|
|
3574
|
+
slots: { header: { react: { __component: "HeaderExtra" } } },
|
|
3575
|
+
};
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
test("header slot renders above the section content, not in the actions container", () => {
|
|
3579
|
+
render(
|
|
3580
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3581
|
+
<ExtensionSectionsProvider value={{ HeaderExtra }}>
|
|
3582
|
+
<RenderEdit<TestValues>
|
|
3583
|
+
screen={makeHeaderScreen()}
|
|
3584
|
+
entity={orderEntity}
|
|
3585
|
+
featureName="orders"
|
|
3586
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
3587
|
+
writeCommand="order:update"
|
|
3588
|
+
entityId="order-1"
|
|
3589
|
+
/>
|
|
3590
|
+
</ExtensionSectionsProvider>
|
|
3591
|
+
</DispatcherProvider>,
|
|
3592
|
+
);
|
|
3593
|
+
|
|
3594
|
+
const header = screen.getByTestId("header-extra");
|
|
3595
|
+
const field = screen.getByTestId("field-title");
|
|
3596
|
+
expect(Boolean(header.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(
|
|
3597
|
+
true,
|
|
3598
|
+
);
|
|
3599
|
+
expect(header.getAttribute("data-entity-id")).toBe("order-1");
|
|
3600
|
+
expect(
|
|
3601
|
+
screen.getByTestId("render-edit-form-actions").querySelector('[data-testid="header-extra"]'),
|
|
3602
|
+
).toBeNull();
|
|
3603
|
+
});
|
|
3604
|
+
|
|
3605
|
+
test("header slot stays visible across wizard steps", async () => {
|
|
3606
|
+
render(
|
|
3607
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3608
|
+
<ExtensionSectionsProvider value={{ HeaderExtra }}>
|
|
3609
|
+
<RenderEdit<TestValues>
|
|
3610
|
+
screen={makeWizardHeaderScreen()}
|
|
3611
|
+
entity={orderEntity}
|
|
3612
|
+
featureName="orders"
|
|
3613
|
+
initial={{ title: "Acme", count: 0 }}
|
|
3614
|
+
writeCommand="order:create"
|
|
3615
|
+
/>
|
|
3616
|
+
</ExtensionSectionsProvider>
|
|
3617
|
+
</DispatcherProvider>,
|
|
3618
|
+
);
|
|
3619
|
+
|
|
3620
|
+
expect(screen.getByTestId("header-extra")).toBeTruthy();
|
|
3621
|
+
|
|
3622
|
+
const form = screen.getByTestId("render-edit-form");
|
|
3623
|
+
await act(async () => {
|
|
3624
|
+
fireEvent.submit(form);
|
|
3625
|
+
await Promise.resolve();
|
|
3626
|
+
});
|
|
3627
|
+
|
|
3628
|
+
expect(screen.getByTestId("header-extra")).toBeTruthy();
|
|
3629
|
+
});
|
|
3630
|
+
|
|
3631
|
+
test("header slot renders before a caller-supplied headerRegion", () => {
|
|
3632
|
+
render(
|
|
3633
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3634
|
+
<ExtensionSectionsProvider value={{ HeaderExtra }}>
|
|
3635
|
+
<RenderEdit<TestValues>
|
|
3636
|
+
screen={makeHeaderScreen()}
|
|
3637
|
+
entity={orderEntity}
|
|
3638
|
+
featureName="orders"
|
|
3639
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
3640
|
+
writeCommand="order:update"
|
|
3641
|
+
entityId="order-1"
|
|
3642
|
+
headerRegion={<div data-testid="host-header-region">host header</div>}
|
|
3643
|
+
/>
|
|
3644
|
+
</ExtensionSectionsProvider>
|
|
3645
|
+
</DispatcherProvider>,
|
|
3646
|
+
);
|
|
3647
|
+
|
|
3648
|
+
const header = screen.getByTestId("header-extra");
|
|
3649
|
+
const hostRegion = screen.getByTestId("host-header-region");
|
|
3650
|
+
expect(
|
|
3651
|
+
Boolean(header.compareDocumentPosition(hostRegion) & Node.DOCUMENT_POSITION_FOLLOWING),
|
|
3652
|
+
).toBe(true);
|
|
3653
|
+
});
|
|
3654
|
+
});
|
|
3655
|
+
|
|
3656
|
+
describe("RenderEdit — slots.footer", () => {
|
|
3657
|
+
function FooterExtra({
|
|
3658
|
+
entityId,
|
|
3659
|
+
hasUnsavedChanges,
|
|
3660
|
+
wizardStep,
|
|
3661
|
+
}: {
|
|
3662
|
+
readonly entityId: string | null;
|
|
3663
|
+
readonly hasUnsavedChanges?: boolean;
|
|
3664
|
+
readonly wizardStep?: { readonly index: number; readonly isLast: boolean };
|
|
3665
|
+
}): ReactNode {
|
|
3666
|
+
return (
|
|
3667
|
+
<button
|
|
3668
|
+
type="button"
|
|
3669
|
+
data-testid="footer-extra"
|
|
3670
|
+
data-entity-id={entityId ?? "(create)"}
|
|
3671
|
+
data-dirty={String(hasUnsavedChanges === true)}
|
|
3672
|
+
data-step={wizardStep !== undefined ? String(wizardStep.index) : "undefined"}
|
|
3673
|
+
data-last={wizardStep !== undefined ? String(wizardStep.isLast) : "undefined"}
|
|
3674
|
+
>
|
|
3675
|
+
extra
|
|
3676
|
+
</button>
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
function makeFooterScreen(): EntityEditScreenDefinition {
|
|
3681
|
+
return {
|
|
3682
|
+
id: "orders:screen:order-edit-footer",
|
|
3683
|
+
type: "entityEdit",
|
|
3684
|
+
entity: "order",
|
|
3685
|
+
layout: {
|
|
3686
|
+
sections: [{ title: "Basics", columns: 1, fields: [{ field: "title" }] }],
|
|
3687
|
+
},
|
|
3688
|
+
slots: { footer: { react: { __component: "FooterExtra" } } },
|
|
3689
|
+
};
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
function makeWizardFooterScreen(): EntityEditScreenDefinition {
|
|
3693
|
+
return {
|
|
3694
|
+
id: "orders:screen:order-wizard-footer",
|
|
3695
|
+
type: "entityEdit",
|
|
3696
|
+
entity: "order",
|
|
3697
|
+
layout: {
|
|
3698
|
+
mode: "wizard",
|
|
3699
|
+
sections: [
|
|
3700
|
+
{ title: "Basics", columns: 1, fields: [{ field: "title" }] },
|
|
3701
|
+
{ title: "Details", columns: 1, fields: [{ field: "count" }] },
|
|
3702
|
+
],
|
|
3703
|
+
},
|
|
3704
|
+
slots: { footer: { react: { __component: "FooterExtra" } } },
|
|
3705
|
+
};
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
test("renders inside the form actions container, before the submit button", () => {
|
|
3709
|
+
render(
|
|
3710
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3711
|
+
<ExtensionSectionsProvider value={{ FooterExtra }}>
|
|
3712
|
+
<RenderEdit<TestValues>
|
|
3713
|
+
screen={makeFooterScreen()}
|
|
3714
|
+
entity={orderEntity}
|
|
3715
|
+
featureName="orders"
|
|
3716
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
3717
|
+
writeCommand="order:update"
|
|
3718
|
+
entityId="order-1"
|
|
3719
|
+
/>
|
|
3720
|
+
</ExtensionSectionsProvider>
|
|
3721
|
+
</DispatcherProvider>,
|
|
3722
|
+
);
|
|
3723
|
+
|
|
3724
|
+
const actions = screen.getByTestId("render-edit-form-actions");
|
|
3725
|
+
const footer = within(actions).getByTestId("footer-extra");
|
|
3726
|
+
const submit = within(actions).getByTestId("render-edit-submit");
|
|
3727
|
+
expect(Boolean(footer.compareDocumentPosition(submit) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(
|
|
3728
|
+
true,
|
|
3729
|
+
);
|
|
3730
|
+
});
|
|
3731
|
+
|
|
3732
|
+
test("hasUnsavedChanges starts false and flips true after a field change", () => {
|
|
3733
|
+
render(
|
|
3734
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3735
|
+
<ExtensionSectionsProvider value={{ FooterExtra }}>
|
|
3736
|
+
<RenderEdit<TestValues>
|
|
3737
|
+
screen={makeFooterScreen()}
|
|
3738
|
+
entity={orderEntity}
|
|
3739
|
+
featureName="orders"
|
|
3740
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
3741
|
+
writeCommand="order:update"
|
|
3742
|
+
entityId="order-1"
|
|
3743
|
+
/>
|
|
3744
|
+
</ExtensionSectionsProvider>
|
|
3745
|
+
</DispatcherProvider>,
|
|
3746
|
+
);
|
|
3747
|
+
|
|
3748
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-dirty")).toBe("false");
|
|
3749
|
+
|
|
3750
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
3751
|
+
fireEvent.change(titleInput, { target: { value: "Acme Corp" } });
|
|
3752
|
+
|
|
3753
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-dirty")).toBe("true");
|
|
3754
|
+
});
|
|
3755
|
+
|
|
3756
|
+
test("entityId is passed through", () => {
|
|
3757
|
+
render(
|
|
3758
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3759
|
+
<ExtensionSectionsProvider value={{ FooterExtra }}>
|
|
3760
|
+
<RenderEdit<TestValues>
|
|
3761
|
+
screen={makeFooterScreen()}
|
|
3762
|
+
entity={orderEntity}
|
|
3763
|
+
featureName="orders"
|
|
3764
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
3765
|
+
writeCommand="order:update"
|
|
3766
|
+
entityId="order-77"
|
|
3767
|
+
/>
|
|
3768
|
+
</ExtensionSectionsProvider>
|
|
3769
|
+
</DispatcherProvider>,
|
|
3770
|
+
);
|
|
3771
|
+
|
|
3772
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-entity-id")).toBe("order-77");
|
|
3773
|
+
});
|
|
3774
|
+
|
|
3775
|
+
test("wizardStep reflects the current step and last-step flag, and advances on Next", async () => {
|
|
3776
|
+
render(
|
|
3777
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3778
|
+
<ExtensionSectionsProvider value={{ FooterExtra }}>
|
|
3779
|
+
<RenderEdit<TestValues>
|
|
3780
|
+
screen={makeWizardFooterScreen()}
|
|
3781
|
+
entity={orderEntity}
|
|
3782
|
+
featureName="orders"
|
|
3783
|
+
initial={{ title: "Acme", count: 0 }}
|
|
3784
|
+
writeCommand="order:create"
|
|
3785
|
+
/>
|
|
3786
|
+
</ExtensionSectionsProvider>
|
|
3787
|
+
</DispatcherProvider>,
|
|
3788
|
+
);
|
|
3789
|
+
|
|
3790
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-step")).toBe("0");
|
|
3791
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-last")).toBe("false");
|
|
3792
|
+
|
|
3793
|
+
const form = screen.getByTestId("render-edit-form");
|
|
3794
|
+
await act(async () => {
|
|
3795
|
+
fireEvent.submit(form);
|
|
3796
|
+
await Promise.resolve();
|
|
3797
|
+
});
|
|
3798
|
+
|
|
3799
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-step")).toBe("1");
|
|
3800
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-last")).toBe("true");
|
|
3801
|
+
});
|
|
3802
|
+
|
|
3803
|
+
test("wizardStep is undefined on a non-wizard form", () => {
|
|
3804
|
+
render(
|
|
3805
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3806
|
+
<ExtensionSectionsProvider value={{ FooterExtra }}>
|
|
3807
|
+
<RenderEdit<TestValues>
|
|
3808
|
+
screen={makeFooterScreen()}
|
|
3809
|
+
entity={orderEntity}
|
|
3810
|
+
featureName="orders"
|
|
3811
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
3812
|
+
writeCommand="order:update"
|
|
3813
|
+
entityId="order-1"
|
|
3814
|
+
/>
|
|
3815
|
+
</ExtensionSectionsProvider>
|
|
3816
|
+
</DispatcherProvider>,
|
|
3817
|
+
);
|
|
3818
|
+
|
|
3819
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-step")).toBe("undefined");
|
|
3820
|
+
expect(screen.getByTestId("footer-extra").getAttribute("data-last")).toBe("undefined");
|
|
3821
|
+
});
|
|
3822
|
+
|
|
3823
|
+
test("a declared but unregistered footer slot renders nothing and does not crash; submit stays", () => {
|
|
3824
|
+
render(
|
|
3825
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3826
|
+
<ExtensionSectionsProvider value={{}}>
|
|
3827
|
+
<RenderEdit<TestValues>
|
|
3828
|
+
screen={makeFooterScreen()}
|
|
3829
|
+
entity={orderEntity}
|
|
3830
|
+
featureName="orders"
|
|
3831
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
3832
|
+
writeCommand="order:update"
|
|
3833
|
+
entityId="order-1"
|
|
3834
|
+
/>
|
|
3835
|
+
</ExtensionSectionsProvider>
|
|
3836
|
+
</DispatcherProvider>,
|
|
3837
|
+
);
|
|
3838
|
+
|
|
3839
|
+
expect(screen.queryByTestId("footer-extra")).toBeNull();
|
|
3840
|
+
expect(screen.getByTestId("render-edit-submit")).toBeTruthy();
|
|
3841
|
+
});
|
|
3842
|
+
|
|
3843
|
+
test("a screen without slots renders only the pre-existing actions (no extra element)", () => {
|
|
3844
|
+
const screenDef: EntityEditScreenDefinition = {
|
|
3845
|
+
id: "orders:screen:order-edit-no-footer",
|
|
3846
|
+
type: "entityEdit",
|
|
3847
|
+
entity: "order",
|
|
3848
|
+
layout: {
|
|
3849
|
+
sections: [{ title: "Basics", columns: 1, fields: [{ field: "title" }] }],
|
|
3850
|
+
},
|
|
3851
|
+
};
|
|
3852
|
+
render(
|
|
3853
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3854
|
+
<RenderEdit<TestValues>
|
|
3855
|
+
screen={screenDef}
|
|
3856
|
+
entity={orderEntity}
|
|
3857
|
+
featureName="orders"
|
|
3858
|
+
initial={{ title: "Acme", count: 0, isUrgent: false }}
|
|
3859
|
+
writeCommand="order:update"
|
|
3860
|
+
entityId="order-1"
|
|
3861
|
+
/>
|
|
3862
|
+
</DispatcherProvider>,
|
|
3863
|
+
);
|
|
3864
|
+
|
|
3865
|
+
const actions = screen.getByTestId("render-edit-form-actions");
|
|
3866
|
+
expect(actions.children.length).toBe(1);
|
|
3867
|
+
expect(actions.children[0]?.getAttribute("data-testid")).toBe("render-edit-submit");
|
|
3868
|
+
});
|
|
3869
|
+
});
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// rule that NavDefinition.access follows)
|
|
31
31
|
|
|
32
32
|
import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
33
|
+
import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
33
34
|
import type { AppSchema, FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
34
35
|
import { qualifyNavId, toAppSchema, UserRolesProvider, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
35
36
|
import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react";
|
|
@@ -282,7 +283,7 @@ export function filterByAccess(
|
|
|
282
283
|
|
|
283
284
|
function userMatchesAccess(access: AccessRule | undefined, userRoles: readonly string[]): boolean {
|
|
284
285
|
if (access === undefined) return true;
|
|
285
|
-
if ("openToAll" in access) return access
|
|
286
|
+
if ("openToAll" in access) return isOpenToAllGranted(access);
|
|
286
287
|
return access.roles.some((r) => userRoles.includes(r));
|
|
287
288
|
}
|
|
288
289
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// kumiko-screen-akte-bedienkonzept: Ctrl/Cmd+Enter submits a textarea
|
|
2
|
+
// (InputProps.onSubmitShortcut) instead of inserting a newline. Plain Enter
|
|
3
|
+
// keeps inserting a newline — asserted here via the keydown event's
|
|
4
|
+
// defaultPrevented state, since fireEvent.keyDown does not itself simulate
|
|
5
|
+
// the browser's native newline insertion.
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
8
|
+
import { defaultPrimitives } from "../index";
|
|
9
|
+
|
|
10
|
+
const { Input } = defaultPrimitives;
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
|
|
13
|
+
function renderTextarea(onSubmitShortcut?: () => void): HTMLTextAreaElement {
|
|
14
|
+
const { container } = render(
|
|
15
|
+
<Input
|
|
16
|
+
kind="textarea"
|
|
17
|
+
id="notes"
|
|
18
|
+
name="notes"
|
|
19
|
+
value=""
|
|
20
|
+
onChange={noop}
|
|
21
|
+
{...(onSubmitShortcut !== undefined && { onSubmitShortcut })}
|
|
22
|
+
/>,
|
|
23
|
+
);
|
|
24
|
+
const textarea = container.querySelector("textarea");
|
|
25
|
+
if (textarea === null) throw new Error("no textarea rendered");
|
|
26
|
+
return textarea;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("textarea onSubmitShortcut (Ctrl/Cmd+Enter)", () => {
|
|
30
|
+
test("Ctrl+Enter calls onSubmitShortcut and prevents the default newline", () => {
|
|
31
|
+
let calls = 0;
|
|
32
|
+
const textarea = renderTextarea(() => {
|
|
33
|
+
calls += 1;
|
|
34
|
+
});
|
|
35
|
+
// fireEvent.keyDown returns the DOM dispatchEvent() result — false once
|
|
36
|
+
// preventDefault() was called on the (cancelable) event.
|
|
37
|
+
const notPrevented = fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
|
|
38
|
+
expect(calls).toBe(1);
|
|
39
|
+
expect(notPrevented).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("Cmd+Enter (metaKey) calls onSubmitShortcut and prevents the default newline", () => {
|
|
43
|
+
let calls = 0;
|
|
44
|
+
const textarea = renderTextarea(() => {
|
|
45
|
+
calls += 1;
|
|
46
|
+
});
|
|
47
|
+
const notPrevented = fireEvent.keyDown(textarea, { key: "Enter", metaKey: true });
|
|
48
|
+
expect(calls).toBe(1);
|
|
49
|
+
expect(notPrevented).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("plain Enter does not call onSubmitShortcut and leaves the newline insertion alone", () => {
|
|
53
|
+
let calls = 0;
|
|
54
|
+
const textarea = renderTextarea(() => {
|
|
55
|
+
calls += 1;
|
|
56
|
+
});
|
|
57
|
+
const notPrevented = fireEvent.keyDown(textarea, { key: "Enter" });
|
|
58
|
+
expect(calls).toBe(0);
|
|
59
|
+
expect(notPrevented).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("without onSubmitShortcut, no aria-keyshortcuts is advertised", () => {
|
|
63
|
+
const textarea = renderTextarea();
|
|
64
|
+
expect(textarea.getAttribute("aria-keyshortcuts")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("with onSubmitShortcut, aria-keyshortcuts advertises both chords", () => {
|
|
68
|
+
const textarea = renderTextarea(() => {});
|
|
69
|
+
expect(textarea.getAttribute("aria-keyshortcuts")).toBe("Control+Enter Meta+Enter");
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -814,6 +814,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
814
814
|
);
|
|
815
815
|
case "textarea": {
|
|
816
816
|
const rows = normalizedTextareaRows(props.rows);
|
|
817
|
+
const onSubmitShortcut = props.onSubmitShortcut;
|
|
817
818
|
return (
|
|
818
819
|
<Textarea
|
|
819
820
|
{...common}
|
|
@@ -824,6 +825,14 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
824
825
|
className="resize-y"
|
|
825
826
|
{...(props.placeholder !== undefined && { placeholder: props.placeholder })}
|
|
826
827
|
{...(rows !== undefined && { style: textareaMinHeight(rows) })}
|
|
828
|
+
{...(onSubmitShortcut !== undefined && {
|
|
829
|
+
"aria-keyshortcuts": "Control+Enter Meta+Enter",
|
|
830
|
+
onKeyDown: (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
831
|
+
if (e.key !== "Enter" || !(e.metaKey || e.ctrlKey)) return;
|
|
832
|
+
e.preventDefault();
|
|
833
|
+
onSubmitShortcut();
|
|
834
|
+
},
|
|
835
|
+
})}
|
|
827
836
|
/>
|
|
828
837
|
);
|
|
829
838
|
}
|
|
@@ -2359,7 +2368,7 @@ function FormFooter({
|
|
|
2359
2368
|
{actions !== undefined && (
|
|
2360
2369
|
<div
|
|
2361
2370
|
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
2362
|
-
className="flex items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
|
|
2371
|
+
className="flex flex-wrap items-center gap-2 max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto"
|
|
2363
2372
|
>
|
|
2364
2373
|
{actions}
|
|
2365
2374
|
</div>
|