@cosmicdrift/kumiko-renderer-web 0.182.1 → 0.183.1
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 +9 -5
- package/src/__tests__/workspace-shell.test.tsx +43 -24
- package/src/app/__tests__/rich-content-editor.test.tsx +21 -0
- package/src/app/__tests__/tiptap-editor.test.tsx +94 -0
- package/src/app/rich-content-editor.tsx +21 -0
- package/src/app/tiptap-editor.tsx +178 -0
- package/src/index.ts +1 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +40 -19
- package/src/layout/nav-tree.tsx +7 -1
- package/src/layout/sidebar-brand.tsx +4 -2
- package/src/layout/workspace-switcher.tsx +48 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.183.1",
|
|
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.183.1",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.183.1",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.183.1",
|
|
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",
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
"react-dom": "^19.2.6",
|
|
39
39
|
"react-resizable-panels": "^4.12.2",
|
|
40
40
|
"tailwind-merge": "^3.6.0",
|
|
41
|
-
"temporal-polyfill": "^0.3.2"
|
|
41
|
+
"temporal-polyfill": "^0.3.2",
|
|
42
|
+
"@tiptap/core": "^3.29.2",
|
|
43
|
+
"@tiptap/pm": "^3.29.2",
|
|
44
|
+
"@tiptap/react": "^3.29.2",
|
|
45
|
+
"@tiptap/starter-kit": "^3.29.2"
|
|
42
46
|
},
|
|
43
47
|
"peerDependencies": {
|
|
44
48
|
"@tailwindcss/cli": "^4.3.0",
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
PrimitivesProvider,
|
|
11
11
|
} from "@cosmicdrift/kumiko-renderer";
|
|
12
12
|
import { render as _render, act } from "@testing-library/react";
|
|
13
|
+
import userEvent from "@testing-library/user-event";
|
|
13
14
|
import type { ReactNode } from "react";
|
|
14
15
|
import { useBrowserNavApi } from "../app/nav";
|
|
15
16
|
import {
|
|
@@ -20,7 +21,17 @@ import {
|
|
|
20
21
|
} from "../layout/workspace-shell";
|
|
21
22
|
import { WorkspaceSwitcher } from "../layout/workspace-switcher";
|
|
22
23
|
import { defaultPrimitives } from "../primitives";
|
|
23
|
-
import { createMockDispatcher,
|
|
24
|
+
import { createMockDispatcher, renderWithSidebar, screen } from "./test-utils";
|
|
25
|
+
|
|
26
|
+
// Dropdown instead of a tab row (see workspace-switcher.tsx) — the
|
|
27
|
+
// trigger always shows only the active label, Radix renders the list
|
|
28
|
+
// only once open. Assertions that just check "which workspace is
|
|
29
|
+
// active" read the trigger text instead of a per-tab aria-selected;
|
|
30
|
+
// only tests that need the full list or click an entry open the
|
|
31
|
+
// dropdown (userEvent, Radix reacts to pointerdown).
|
|
32
|
+
function activeWorkspaceLabel(): string | null {
|
|
33
|
+
return screen.getByTestId("workspace-switcher-trigger").textContent;
|
|
34
|
+
}
|
|
24
35
|
|
|
25
36
|
// jsdom shares window.history across tests in the same file. Reset to /
|
|
26
37
|
// before each render so URL-driven workspace state from one test doesn't
|
|
@@ -208,18 +219,19 @@ describe("firstNavScreenId", () => {
|
|
|
208
219
|
|
|
209
220
|
describe("WorkspaceSwitcher", () => {
|
|
210
221
|
test("renders nothing for a single workspace (no choice = no UI)", () => {
|
|
211
|
-
const { container } =
|
|
222
|
+
const { container } = renderWithSidebar(
|
|
212
223
|
<WorkspaceSwitcher
|
|
213
224
|
workspaces={[ws("only", { openToAll: true })]}
|
|
214
225
|
activeId="only"
|
|
215
226
|
onSelect={() => {}}
|
|
216
227
|
/>,
|
|
217
228
|
);
|
|
218
|
-
expect(container.
|
|
229
|
+
expect(container.querySelector('[data-testid="workspace-switcher-trigger"]')).toBeNull();
|
|
219
230
|
});
|
|
220
231
|
|
|
221
|
-
test("
|
|
222
|
-
|
|
232
|
+
test("trigger shows the active workspace, dropdown lists all with aria-checked", async () => {
|
|
233
|
+
const user = userEvent.setup();
|
|
234
|
+
renderWithSidebar(
|
|
223
235
|
<WorkspaceSwitcher
|
|
224
236
|
workspaces={[
|
|
225
237
|
ws("admin", { label: "Admin", openToAll: true }),
|
|
@@ -229,15 +241,16 @@ describe("WorkspaceSwitcher", () => {
|
|
|
229
241
|
onSelect={() => {}}
|
|
230
242
|
/>,
|
|
231
243
|
);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
expect(
|
|
235
|
-
expect(
|
|
244
|
+
expect(activeWorkspaceLabel()).toBe("Admin");
|
|
245
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
246
|
+
expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-checked")).toBe("true");
|
|
247
|
+
expect(screen.getByTestId("workspace-tab-driver").getAttribute("aria-checked")).toBe("false");
|
|
236
248
|
});
|
|
237
249
|
|
|
238
|
-
test("clicking a
|
|
250
|
+
test("clicking a dropdown entry calls onSelect with that workspace id", async () => {
|
|
251
|
+
const user = userEvent.setup();
|
|
239
252
|
const onSelect = mock();
|
|
240
|
-
|
|
253
|
+
renderWithSidebar(
|
|
241
254
|
<WorkspaceSwitcher
|
|
242
255
|
workspaces={[
|
|
243
256
|
ws("admin", { label: "Admin", openToAll: true }),
|
|
@@ -247,7 +260,8 @@ describe("WorkspaceSwitcher", () => {
|
|
|
247
260
|
onSelect={onSelect}
|
|
248
261
|
/>,
|
|
249
262
|
);
|
|
250
|
-
|
|
263
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
264
|
+
await user.click(screen.getByTestId("workspace-tab-driver"));
|
|
251
265
|
expect(onSelect).toHaveBeenCalledWith("driver");
|
|
252
266
|
});
|
|
253
267
|
});
|
|
@@ -292,7 +306,8 @@ describe("WorkspaceShell", () => {
|
|
|
292
306
|
],
|
|
293
307
|
} as const;
|
|
294
308
|
|
|
295
|
-
test("an admin sees admin + dispatch in the switcher (driver hidden)", () => {
|
|
309
|
+
test("an admin sees admin + dispatch in the switcher (driver hidden)", async () => {
|
|
310
|
+
const user = userEvent.setup();
|
|
296
311
|
renderShell(
|
|
297
312
|
<WorkspaceShell
|
|
298
313
|
brand={<div>Brand</div>}
|
|
@@ -302,6 +317,7 @@ describe("WorkspaceShell", () => {
|
|
|
302
317
|
<div>content</div>
|
|
303
318
|
</WorkspaceShell>,
|
|
304
319
|
);
|
|
320
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
305
321
|
expect(screen.getByTestId("workspace-tab-admin")).toBeTruthy();
|
|
306
322
|
expect(screen.getByTestId("workspace-tab-dispatch")).toBeTruthy();
|
|
307
323
|
expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
|
|
@@ -317,7 +333,7 @@ describe("WorkspaceShell", () => {
|
|
|
317
333
|
<div>content</div>
|
|
318
334
|
</WorkspaceShell>,
|
|
319
335
|
);
|
|
320
|
-
expect(
|
|
336
|
+
expect(activeWorkspaceLabel()).toBe("Admin");
|
|
321
337
|
});
|
|
322
338
|
|
|
323
339
|
test("only the active workspace's nav members appear in the sidebar", () => {
|
|
@@ -337,7 +353,8 @@ describe("WorkspaceShell", () => {
|
|
|
337
353
|
expect(screen.queryAllByText("Tours").length).toBe(0);
|
|
338
354
|
});
|
|
339
355
|
|
|
340
|
-
test("clicking the dispatch tab swaps the visible nav set", () => {
|
|
356
|
+
test("clicking the dispatch tab swaps the visible nav set", async () => {
|
|
357
|
+
const user = userEvent.setup();
|
|
341
358
|
renderShell(
|
|
342
359
|
<WorkspaceShell
|
|
343
360
|
brand={<div>Brand</div>}
|
|
@@ -347,7 +364,8 @@ describe("WorkspaceShell", () => {
|
|
|
347
364
|
<div>content</div>
|
|
348
365
|
</WorkspaceShell>,
|
|
349
366
|
);
|
|
350
|
-
|
|
367
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
368
|
+
await user.click(screen.getByTestId("workspace-tab-dispatch"));
|
|
351
369
|
// dispatch → orders + tours, NOT system (getAllByText: Desktop + Mobile-Sheet)
|
|
352
370
|
expect(screen.getAllByText("Orders").length).toBeGreaterThan(0);
|
|
353
371
|
expect(screen.getAllByText("Tours").length).toBeGreaterThan(0);
|
|
@@ -401,7 +419,7 @@ describe("WorkspaceShell", () => {
|
|
|
401
419
|
<div>content</div>
|
|
402
420
|
</WorkspaceShell>,
|
|
403
421
|
);
|
|
404
|
-
expect(
|
|
422
|
+
expect(activeWorkspaceLabel()).toBe("Cockpit");
|
|
405
423
|
});
|
|
406
424
|
|
|
407
425
|
// Security regression — without the empty-allow-set branch, the NavTree
|
|
@@ -535,17 +553,18 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
|
535
553
|
<div>content</div>
|
|
536
554
|
</WorkspaceShell>,
|
|
537
555
|
);
|
|
538
|
-
expect(
|
|
539
|
-
expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("false");
|
|
556
|
+
expect(activeWorkspaceLabel()).toBe("dispatch");
|
|
540
557
|
});
|
|
541
558
|
|
|
542
|
-
test("clicking a tab pushes /<workspace>/<screen> to the URL", () => {
|
|
559
|
+
test("clicking a tab pushes /<workspace>/<screen> to the URL", async () => {
|
|
560
|
+
const user = userEvent.setup();
|
|
543
561
|
renderShell(
|
|
544
562
|
<WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
|
|
545
563
|
<div>content</div>
|
|
546
564
|
</WorkspaceShell>,
|
|
547
565
|
);
|
|
548
|
-
|
|
566
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
567
|
+
await user.click(screen.getByTestId("workspace-tab-dispatch"));
|
|
549
568
|
// dispatch's first nav-member is bmc:nav:orders → screenId "orders"
|
|
550
569
|
expect(window.location.pathname).toBe("/dispatch/orders");
|
|
551
570
|
});
|
|
@@ -604,7 +623,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
|
604
623
|
</WorkspaceShell>,
|
|
605
624
|
);
|
|
606
625
|
// Default workspace = admin. Ghost id ignored, no error thrown.
|
|
607
|
-
expect(
|
|
626
|
+
expect(activeWorkspaceLabel()).toBe("admin");
|
|
608
627
|
});
|
|
609
628
|
|
|
610
629
|
test("popstate (back/forward) updates the active tab", () => {
|
|
@@ -614,7 +633,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
|
614
633
|
<div>content</div>
|
|
615
634
|
</WorkspaceShell>,
|
|
616
635
|
);
|
|
617
|
-
expect(
|
|
636
|
+
expect(activeWorkspaceLabel()).toBe("admin");
|
|
618
637
|
// Simulate the user hitting "forward" to /dispatch/orders. pushState
|
|
619
638
|
// alone doesn't fire popstate — we synthesize the event so the
|
|
620
639
|
// hook's listener-set notifies subscribers. act() flushes the
|
|
@@ -623,7 +642,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
|
|
|
623
642
|
window.history.pushState(null, "", "/dispatch/orders");
|
|
624
643
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
625
644
|
});
|
|
626
|
-
expect(
|
|
645
|
+
expect(activeWorkspaceLabel()).toBe("dispatch");
|
|
627
646
|
});
|
|
628
647
|
});
|
|
629
648
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { render, screen } from "../../__tests__/test-utils";
|
|
4
|
+
import { RichContentEditor } from "../rich-content-editor";
|
|
5
|
+
|
|
6
|
+
describe("RichContentEditor", () => {
|
|
7
|
+
test("falls back to the plain textarea while the tiptap chunk loads, then swaps in the editor", async () => {
|
|
8
|
+
render(
|
|
9
|
+
<RichContentEditor
|
|
10
|
+
value="<p>hello</p>"
|
|
11
|
+
onChange={() => {}}
|
|
12
|
+
variables={[]}
|
|
13
|
+
readOnly={false}
|
|
14
|
+
/>,
|
|
15
|
+
);
|
|
16
|
+
// The textarea id proves #1794's "never an empty panel" fallback is the
|
|
17
|
+
// Suspense boundary — a null fallback would leave nothing to find here.
|
|
18
|
+
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
|
|
19
|
+
expect(await screen.findByText("hello")).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
5
|
+
import TiptapEditor from "../tiptap-editor";
|
|
6
|
+
|
|
7
|
+
function Controlled({ initial }: { readonly initial: string }): ReactNode {
|
|
8
|
+
const [value, setValue] = useState(initial);
|
|
9
|
+
return <TiptapEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Mirrors the real caller: TextBlockEditor mounts with value="" and only
|
|
13
|
+
// gets the loaded content after its by-slug query resolves — value arrives
|
|
14
|
+
// as a prop update, not at mount. A test that renders the final HTML
|
|
15
|
+
// straight away would stay green even if the sync effect were missing.
|
|
16
|
+
function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
|
|
17
|
+
const [value, setValue] = useState("");
|
|
18
|
+
return (
|
|
19
|
+
<div>
|
|
20
|
+
<button type="button" onClick={() => setValue(loaded)}>
|
|
21
|
+
load
|
|
22
|
+
</button>
|
|
23
|
+
<TiptapEditor value={value} onChange={setValue} variables={[]} readOnly={false} />
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("TiptapEditor — jsdom smoke", () => {
|
|
29
|
+
test("mounts a contenteditable surface for the given HTML", async () => {
|
|
30
|
+
render(
|
|
31
|
+
<TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
|
|
32
|
+
);
|
|
33
|
+
const editable = await screen.findByText("hello");
|
|
34
|
+
expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the contenteditable surface carries CONTENT_EDITOR_ELEMENT_ID so the wrapping Field's label stays associated", async () => {
|
|
38
|
+
render(
|
|
39
|
+
<TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
|
|
40
|
+
);
|
|
41
|
+
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no variables → no chip bar", async () => {
|
|
45
|
+
render(
|
|
46
|
+
<TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
|
|
47
|
+
);
|
|
48
|
+
await screen.findByText("hello");
|
|
49
|
+
expect(screen.queryAllByRole("button", { name: "Bold" })).toHaveLength(1);
|
|
50
|
+
expect(screen.queryByText(/^\{\{.*\}\}$/)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("every toolbar action resolves by its accessible name", async () => {
|
|
54
|
+
render(
|
|
55
|
+
<TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
|
|
56
|
+
);
|
|
57
|
+
await screen.findByText("hello");
|
|
58
|
+
for (const name of [
|
|
59
|
+
"Bold",
|
|
60
|
+
"Italic",
|
|
61
|
+
"Heading 1",
|
|
62
|
+
"Heading 2",
|
|
63
|
+
"Bullet list",
|
|
64
|
+
"Numbered list",
|
|
65
|
+
]) {
|
|
66
|
+
expect(screen.getByRole("button", { name })).toBeTruthy();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a value arriving after mount (async load) still reaches the editor", async () => {
|
|
71
|
+
render(<LateValue loaded="<p>loaded content</p>" />);
|
|
72
|
+
expect(screen.queryByText("loaded content")).toBeNull();
|
|
73
|
+
fireEvent.click(screen.getByText("load"));
|
|
74
|
+
expect(await screen.findByText("loaded content")).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("toolbar H1 toggles the block type and reports it via onChange", async () => {
|
|
78
|
+
// Block-type commands (headings) apply at the cursor's block, unlike
|
|
79
|
+
// inline marks (bold/italic) which need an explicit text selection —
|
|
80
|
+
// simulating a real range selection in happy-dom is unreliable, so this
|
|
81
|
+
// is the toolbar-wiring smoke test; the extension itself is tiptap's.
|
|
82
|
+
render(<Controlled initial="<p>hello</p>" />);
|
|
83
|
+
await screen.findByText("hello");
|
|
84
|
+
fireEvent.click(screen.getByRole("button", { name: "Heading 1" }));
|
|
85
|
+
const editable = (await screen.findByText("hello")).closest('[contenteditable="true"]');
|
|
86
|
+
expect(editable?.innerHTML).toContain("<h1");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("variable chip inserts {{name}} as plain text at the cursor", async () => {
|
|
90
|
+
render(<Controlled initial="<p></p>" />);
|
|
91
|
+
fireEvent.click(screen.getByText("{{name}}"));
|
|
92
|
+
expect(await screen.findByText("{{name}}", { selector: "p" })).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
//
|
|
3
|
+
// "rich" contentFormat editor: WYSIWYG on tiptap. Public contract is the
|
|
4
|
+
// same four props every ContentEditorComponent gets — no tiptap type
|
|
5
|
+
// crosses this boundary. tiptap itself is dynamic-imported (TiptapEditor
|
|
6
|
+
// lives in ./tiptap-editor) so an app that never mounts a "rich" collection
|
|
7
|
+
// never pays for it; the fallback while the chunk loads is the same plain
|
|
8
|
+
// textarea #1794 uses for a missing editor, never a blank panel.
|
|
9
|
+
|
|
10
|
+
import { type ContentEditorProps, TextareaContentEditor } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import { lazy, type ReactNode, Suspense } from "react";
|
|
12
|
+
|
|
13
|
+
const TiptapEditor = lazy(() => import("./tiptap-editor"));
|
|
14
|
+
|
|
15
|
+
export function RichContentEditor(props: ContentEditorProps): ReactNode {
|
|
16
|
+
return (
|
|
17
|
+
<Suspense fallback={<TextareaContentEditor {...props} />}>
|
|
18
|
+
<TiptapEditor {...props} />
|
|
19
|
+
</Suspense>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
//
|
|
3
|
+
// tiptap-backed implementation behind RichContentEditor's lazy() boundary —
|
|
4
|
+
// never import this file directly, tiptap must only load when a "rich"
|
|
5
|
+
// collection is actually rendered. StarterKit covers bold/italic/lists/
|
|
6
|
+
// headings, and its bundled Link extension autolinks on type/paste — no
|
|
7
|
+
// link button, so no URL-prompt UI to build or test.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CONTENT_EDITOR_ELEMENT_ID,
|
|
11
|
+
type ContentEditorProps,
|
|
12
|
+
TextareaContentEditor,
|
|
13
|
+
usePrimitives,
|
|
14
|
+
useTranslation,
|
|
15
|
+
VariableChips,
|
|
16
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
18
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
19
|
+
import {
|
|
20
|
+
Bold as BoldIcon,
|
|
21
|
+
Heading1,
|
|
22
|
+
Heading2,
|
|
23
|
+
Italic as ItalicIcon,
|
|
24
|
+
List,
|
|
25
|
+
ListOrdered,
|
|
26
|
+
} from "lucide-react";
|
|
27
|
+
import { type ReactNode, useEffect } from "react";
|
|
28
|
+
import { cn } from "../lib/cn";
|
|
29
|
+
|
|
30
|
+
type ToolbarAction = {
|
|
31
|
+
readonly label: string;
|
|
32
|
+
readonly icon: typeof BoldIcon;
|
|
33
|
+
readonly isActive: boolean;
|
|
34
|
+
readonly onClick: () => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function Toolbar({
|
|
38
|
+
actions,
|
|
39
|
+
disabled,
|
|
40
|
+
}: {
|
|
41
|
+
readonly actions: readonly ToolbarAction[];
|
|
42
|
+
readonly disabled: boolean;
|
|
43
|
+
}): ReactNode {
|
|
44
|
+
const { Button } = usePrimitives();
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex flex-wrap gap-1 border-b border-input p-1">
|
|
47
|
+
{actions.map((action) => (
|
|
48
|
+
<Button
|
|
49
|
+
key={action.label}
|
|
50
|
+
type="button"
|
|
51
|
+
variant={action.isActive ? "primary" : "secondary"}
|
|
52
|
+
size="icon"
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
onClick={action.onClick}
|
|
55
|
+
ariaLabel={action.label}
|
|
56
|
+
>
|
|
57
|
+
<action.icon size={16} />
|
|
58
|
+
</Button>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function TiptapEditor({
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
variables,
|
|
68
|
+
readOnly,
|
|
69
|
+
}: ContentEditorProps): ReactNode {
|
|
70
|
+
const t = useTranslation();
|
|
71
|
+
const editor = useEditor({
|
|
72
|
+
extensions: [StarterKit],
|
|
73
|
+
content: value,
|
|
74
|
+
editable: !readOnly,
|
|
75
|
+
// Suspense (RichContentEditor's lazy boundary) speculatively mounts
|
|
76
|
+
// this component before committing it; immediate render would create
|
|
77
|
+
// an editor instance during that throwaway pass.
|
|
78
|
+
immediatelyRender: false,
|
|
79
|
+
// Same id the textarea fallback uses — the Field wrapping the editor
|
|
80
|
+
// (TextBlockEditor) associates its label via this id; the editor
|
|
81
|
+
// contract has no `id` prop of its own, see content-editors.tsx.
|
|
82
|
+
editorProps: { attributes: { id: CONTENT_EDITOR_ELEMENT_ID } },
|
|
83
|
+
onUpdate: ({ editor: e }) => onChange(e.getHTML()),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// TextBlockEditor loads the entry's content asynchronously (by-slug query
|
|
87
|
+
// resolves after mount), so `value` arrives after useEditor already read
|
|
88
|
+
// its initial (empty) content once. Sync it in — guarded against
|
|
89
|
+
// clobbering in-flight typing by comparing against the editor's own HTML.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (editor && value !== editor.getHTML()) {
|
|
92
|
+
editor.commands.setContent(value, { emitUpdate: false });
|
|
93
|
+
}
|
|
94
|
+
}, [value, editor]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
editor?.setEditable(!readOnly);
|
|
98
|
+
}, [readOnly, editor]);
|
|
99
|
+
|
|
100
|
+
if (!editor)
|
|
101
|
+
return (
|
|
102
|
+
<TextareaContentEditor
|
|
103
|
+
value={value}
|
|
104
|
+
onChange={onChange}
|
|
105
|
+
variables={variables}
|
|
106
|
+
readOnly={readOnly}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const actions: readonly ToolbarAction[] = [
|
|
111
|
+
{
|
|
112
|
+
label: t("kumiko.contentEditor.bold"),
|
|
113
|
+
icon: BoldIcon,
|
|
114
|
+
isActive: editor.isActive("bold"),
|
|
115
|
+
onClick: () => editor.chain().focus().toggleBold().run(),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
label: t("kumiko.contentEditor.italic"),
|
|
119
|
+
icon: ItalicIcon,
|
|
120
|
+
isActive: editor.isActive("italic"),
|
|
121
|
+
onClick: () => editor.chain().focus().toggleItalic().run(),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
label: t("kumiko.contentEditor.heading1"),
|
|
125
|
+
icon: Heading1,
|
|
126
|
+
isActive: editor.isActive("heading", { level: 1 }),
|
|
127
|
+
onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
label: t("kumiko.contentEditor.heading2"),
|
|
131
|
+
icon: Heading2,
|
|
132
|
+
isActive: editor.isActive("heading", { level: 2 }),
|
|
133
|
+
onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
label: t("kumiko.contentEditor.bulletList"),
|
|
137
|
+
icon: List,
|
|
138
|
+
isActive: editor.isActive("bulletList"),
|
|
139
|
+
onClick: () => editor.chain().focus().toggleBulletList().run(),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: t("kumiko.contentEditor.orderedList"),
|
|
143
|
+
icon: ListOrdered,
|
|
144
|
+
isActive: editor.isActive("orderedList"),
|
|
145
|
+
onClick: () => editor.chain().focus().toggleOrderedList().run(),
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const insertVariable = (name: string): void => {
|
|
150
|
+
editor
|
|
151
|
+
.chain()
|
|
152
|
+
.focus()
|
|
153
|
+
.insertContent({ type: "text", text: `{{${name}}}` })
|
|
154
|
+
.run();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="rounded-md border border-input bg-transparent">
|
|
159
|
+
<Toolbar actions={actions} disabled={readOnly} />
|
|
160
|
+
<EditorContent
|
|
161
|
+
editor={editor}
|
|
162
|
+
className={cn(
|
|
163
|
+
"min-h-40 px-3 py-2 text-base outline-none md:text-sm",
|
|
164
|
+
"[&_.ProseMirror]:min-h-40 [&_.ProseMirror]:outline-none",
|
|
165
|
+
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-6 [&_ol]:pl-6",
|
|
166
|
+
"[&_h1]:text-xl [&_h1]:font-bold [&_h2]:text-lg [&_h2]:font-bold",
|
|
167
|
+
"[&_a]:underline [&_a]:text-primary",
|
|
168
|
+
"[&_strong]:font-bold [&_em]:italic",
|
|
169
|
+
)}
|
|
170
|
+
/>
|
|
171
|
+
{variables.length > 0 && (
|
|
172
|
+
<div className="border-t border-input p-1">
|
|
173
|
+
<VariableChips variables={variables} onInsert={insertVariable} disabled={readOnly} />
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -97,6 +97,7 @@ export type { KumikoLinkProps } from "./app/nav";
|
|
|
97
97
|
export { KumikoLink, useBrowserNavApi } from "./app/nav";
|
|
98
98
|
export { PlainContentEditor } from "./app/plain-content-editor";
|
|
99
99
|
export { useResolvers } from "./app/resolvers-context";
|
|
100
|
+
export { RichContentEditor } from "./app/rich-content-editor";
|
|
100
101
|
export type { AppLayoutProps } from "./layout/app-layout";
|
|
101
102
|
export { AppLayout } from "./layout/app-layout";
|
|
102
103
|
export type { AvatarProps, AvatarSize } from "./layout/avatar";
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// WorkspaceSwitcher Render-Tests (Phase 1, test-luecken-integration, Tier 2).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// <= 1
|
|
5
|
-
//
|
|
3
|
+
// Dropdown built on Radix (like LanguageSwitcher/TenantSwitcher). Pins:
|
|
4
|
+
// no switcher at <= 1 workspace, trigger shows the active label, dropdown
|
|
5
|
+
// lists all workspaces with aria-checked on the active one, onSelect
|
|
6
|
+
// callback. Radix opens on pointerdown → userEvent instead of
|
|
7
|
+
// fireEvent.click, same as language-switcher.test.tsx.
|
|
6
8
|
|
|
7
9
|
import { describe, expect, mock, test } from "bun:test";
|
|
8
10
|
import {
|
|
@@ -10,7 +12,8 @@ import {
|
|
|
10
12
|
LocaleProvider,
|
|
11
13
|
type WorkspaceSchema,
|
|
12
14
|
} from "@cosmicdrift/kumiko-renderer";
|
|
13
|
-
import
|
|
15
|
+
import userEvent from "@testing-library/user-event";
|
|
16
|
+
import { renderWithSidebar, screen } from "../../__tests__/test-utils";
|
|
14
17
|
import { WorkspaceSwitcher } from "../workspace-switcher";
|
|
15
18
|
|
|
16
19
|
function ws(id: string, label = id): WorkspaceSchema {
|
|
@@ -19,14 +22,14 @@ function ws(id: string, label = id): WorkspaceSchema {
|
|
|
19
22
|
|
|
20
23
|
describe("WorkspaceSwitcher — Render", () => {
|
|
21
24
|
test("ein einziger Workspace → rendert nichts (kein nutzloser Switcher)", () => {
|
|
22
|
-
const { container } =
|
|
25
|
+
const { container } = renderWithSidebar(
|
|
23
26
|
<WorkspaceSwitcher workspaces={[ws("a")]} activeId="a" onSelect={() => {}} />,
|
|
24
27
|
);
|
|
25
|
-
expect(container.querySelector('[
|
|
28
|
+
expect(container.querySelector('[data-testid="workspace-switcher-trigger"]')).toBeNull();
|
|
26
29
|
});
|
|
27
30
|
|
|
28
|
-
test("mehrere Workspaces →
|
|
29
|
-
|
|
31
|
+
test("mehrere Workspaces → Trigger zeigt aktives Label", () => {
|
|
32
|
+
renderWithSidebar(
|
|
30
33
|
<WorkspaceSwitcher
|
|
31
34
|
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
32
35
|
activeId="b"
|
|
@@ -34,29 +37,45 @@ describe("WorkspaceSwitcher — Render", () => {
|
|
|
34
37
|
testId="sw"
|
|
35
38
|
/>,
|
|
36
39
|
);
|
|
37
|
-
expect(screen.getByTestId("sw")
|
|
38
|
-
expect(screen.
|
|
39
|
-
expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-selected")).toBe("true");
|
|
40
|
+
expect(screen.getByTestId("sw")).toBeTruthy();
|
|
41
|
+
expect(screen.getByText("Beta")).toBeTruthy();
|
|
40
42
|
});
|
|
41
43
|
|
|
42
|
-
test("
|
|
44
|
+
test("Dropdown öffnen listet alle Workspaces, aria-checked am aktiven", async () => {
|
|
45
|
+
const user = userEvent.setup();
|
|
46
|
+
renderWithSidebar(
|
|
47
|
+
<WorkspaceSwitcher
|
|
48
|
+
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
49
|
+
activeId="b"
|
|
50
|
+
onSelect={() => {}}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
54
|
+
expect(screen.getByTestId("workspace-tab-a").getAttribute("aria-checked")).toBe("false");
|
|
55
|
+
expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-checked")).toBe("true");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("Click auf einen Eintrag ruft onSelect mit der Workspace-id", async () => {
|
|
59
|
+
const user = userEvent.setup();
|
|
43
60
|
const onSelect = mock((_id: string) => {});
|
|
44
|
-
|
|
61
|
+
renderWithSidebar(
|
|
45
62
|
<WorkspaceSwitcher
|
|
46
63
|
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
47
64
|
activeId="a"
|
|
48
65
|
onSelect={onSelect}
|
|
49
66
|
/>,
|
|
50
67
|
);
|
|
51
|
-
|
|
68
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
69
|
+
await user.click(screen.getByTestId("workspace-tab-b"));
|
|
52
70
|
expect(onSelect).toHaveBeenCalledWith("b");
|
|
53
71
|
});
|
|
54
72
|
});
|
|
55
73
|
|
|
56
74
|
describe("WorkspaceSwitcher — i18n-Labels (Punkt-Konvention)", () => {
|
|
57
|
-
test("Label mit Punkt geht durch t() und rendert die Übersetzung", () => {
|
|
75
|
+
test("Label mit Punkt geht durch t() und rendert die Übersetzung", async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
58
77
|
const bundle = { en: { "nav.adminArea": "Admin Area" } };
|
|
59
|
-
|
|
78
|
+
renderWithSidebar(
|
|
60
79
|
<LocaleProvider
|
|
61
80
|
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
62
81
|
fallbackBundles={[bundle]}
|
|
@@ -68,8 +87,10 @@ describe("WorkspaceSwitcher — i18n-Labels (Punkt-Konvention)", () => {
|
|
|
68
87
|
/>
|
|
69
88
|
</LocaleProvider>,
|
|
70
89
|
);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
// Trigger shows the translated active label.
|
|
91
|
+
expect(screen.getByText("Admin Area")).toBeTruthy();
|
|
92
|
+
await user.click(screen.getByTestId("workspace-switcher-trigger"));
|
|
93
|
+
// No dot: verbatim, no t() roundtrip.
|
|
94
|
+
expect(screen.getByTestId("workspace-tab-b").textContent).toBe("Plain Label");
|
|
74
95
|
});
|
|
75
96
|
});
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -31,11 +31,13 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
BarChart3,
|
|
33
33
|
Bell,
|
|
34
|
+
BookOpen,
|
|
34
35
|
Building,
|
|
35
36
|
Calculator,
|
|
36
37
|
CalendarDays,
|
|
37
38
|
ChevronDown,
|
|
38
39
|
ChevronRight,
|
|
40
|
+
ClipboardList,
|
|
39
41
|
Coins,
|
|
40
42
|
CreditCard,
|
|
41
43
|
Download,
|
|
@@ -54,6 +56,7 @@ import {
|
|
|
54
56
|
List,
|
|
55
57
|
Lock,
|
|
56
58
|
Mail,
|
|
59
|
+
Package,
|
|
57
60
|
Palette,
|
|
58
61
|
PiggyBank,
|
|
59
62
|
Plus,
|
|
@@ -109,6 +112,9 @@ import { parseTargetFromSearchParams } from "./target-url";
|
|
|
109
112
|
const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
110
113
|
dashboard: LayoutDashboard,
|
|
111
114
|
"layout-grid": LayoutGrid,
|
|
115
|
+
"book-open": BookOpen,
|
|
116
|
+
"clipboard-list": ClipboardList,
|
|
117
|
+
package: Package,
|
|
112
118
|
gauge: Gauge,
|
|
113
119
|
list: List,
|
|
114
120
|
table: Table,
|
|
@@ -231,7 +237,7 @@ export function NavTree({
|
|
|
231
237
|
<NavFilterContext.Provider value={navFilter}>
|
|
232
238
|
<NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
|
|
233
239
|
<div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
|
|
234
|
-
<div className="px-2 pt-2 pb-1">
|
|
240
|
+
<div className="px-2 pt-2 pb-1 group-data-[collapsible=icon]:hidden">
|
|
235
241
|
<SidebarInput
|
|
236
242
|
value={filter}
|
|
237
243
|
onChange={(e) => setFilter(e.target.value)}
|
|
@@ -37,11 +37,13 @@ export function SidebarBrand({
|
|
|
37
37
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
38
38
|
{logo ?? <span className="text-sm font-semibold">{name.charAt(0)}</span>}
|
|
39
39
|
</div>
|
|
40
|
-
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
40
|
+
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
|
41
41
|
<span className="truncate font-semibold">{name}</span>
|
|
42
42
|
{plan !== undefined && <span className="truncate text-xs">{plan}</span>}
|
|
43
43
|
</div>
|
|
44
|
-
{collapsible &&
|
|
44
|
+
{collapsible && (
|
|
45
|
+
<ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" />
|
|
46
|
+
)}
|
|
45
47
|
</SidebarMenuButton>
|
|
46
48
|
</SidebarMenuItem>
|
|
47
49
|
</SidebarMenu>
|
|
@@ -3,11 +3,28 @@
|
|
|
3
3
|
// id, and a callback. Stays presentational so WorkspaceShell can own the
|
|
4
4
|
// state (URL ?w=, defaults, role filtering) and tests can hand any list
|
|
5
5
|
// in directly.
|
|
6
|
+
//
|
|
7
|
+
// Dropdown instead of a tab row: a row of fixed buttons overflowed the
|
|
8
|
+
// sidebar width with 3+ workspaces (or even 2 longer names) —
|
|
9
|
+
// truncate+scroll then hid the last entry entirely, unclickable. A
|
|
10
|
+
// dropdown has constant trigger width regardless of count/length.
|
|
11
|
+
//
|
|
12
|
+
// Hidden entirely when collapsed instead of an icon/initial: a label at
|
|
13
|
+
// icon width isn't readable anyway, and the user switches workspaces via
|
|
14
|
+
// the full sidebar — no attempt to keep this operable in the icon rail
|
|
15
|
+
// too (same as the search box in nav-tree.tsx).
|
|
6
16
|
|
|
7
17
|
import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
8
18
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import { ChevronsUpDown } from "lucide-react";
|
|
9
20
|
import type { ReactNode } from "react";
|
|
10
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
DropdownMenu,
|
|
23
|
+
DropdownMenuCheckboxItem,
|
|
24
|
+
DropdownMenuContent,
|
|
25
|
+
DropdownMenuTrigger,
|
|
26
|
+
} from "../primitives/dropdown-menu";
|
|
27
|
+
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
|
|
11
28
|
|
|
12
29
|
export type WorkspaceSwitcherProps = {
|
|
13
30
|
readonly workspaces: readonly WorkspaceSchema[];
|
|
@@ -26,37 +43,39 @@ export function WorkspaceSwitcher({
|
|
|
26
43
|
// Single workspace doesn't need a switcher — the user has no choice
|
|
27
44
|
// anyway. Render nothing instead of a useless one-button row.
|
|
28
45
|
if (workspaces.length <= 1) return null;
|
|
46
|
+
|
|
47
|
+
const labelOf = (ws: WorkspaceSchema): string =>
|
|
48
|
+
ws.definition.label.includes(".") ? t(ws.definition.label) : ws.definition.label;
|
|
49
|
+
const active = workspaces.find((ws) => ws.definition.id === activeId);
|
|
50
|
+
|
|
29
51
|
return (
|
|
30
|
-
<
|
|
52
|
+
<SidebarMenu
|
|
31
53
|
data-testid={testId}
|
|
32
54
|
data-kumiko-layout="workspace-switcher"
|
|
33
|
-
|
|
34
|
-
className="flex items-center gap-1"
|
|
55
|
+
className="group-data-[collapsible=icon]:hidden"
|
|
35
56
|
>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)}
|
|
55
|
-
>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
})}
|
|
60
|
-
</div>
|
|
57
|
+
<SidebarMenuItem>
|
|
58
|
+
<DropdownMenu>
|
|
59
|
+
<DropdownMenuTrigger asChild>
|
|
60
|
+
<SidebarMenuButton data-testid="workspace-switcher-trigger">
|
|
61
|
+
<span className="truncate">{active !== undefined ? labelOf(active) : ""}</span>
|
|
62
|
+
<ChevronsUpDown className="ml-auto" />
|
|
63
|
+
</SidebarMenuButton>
|
|
64
|
+
</DropdownMenuTrigger>
|
|
65
|
+
<DropdownMenuContent align="start" className="min-w-[10rem]">
|
|
66
|
+
{workspaces.map((ws) => (
|
|
67
|
+
<DropdownMenuCheckboxItem
|
|
68
|
+
key={ws.definition.id}
|
|
69
|
+
checked={ws.definition.id === activeId}
|
|
70
|
+
data-testid={`workspace-tab-${ws.definition.id}`}
|
|
71
|
+
onSelect={() => onSelect(ws.definition.id)}
|
|
72
|
+
>
|
|
73
|
+
<span className="truncate">{labelOf(ws)}</span>
|
|
74
|
+
</DropdownMenuCheckboxItem>
|
|
75
|
+
))}
|
|
76
|
+
</DropdownMenuContent>
|
|
77
|
+
</DropdownMenu>
|
|
78
|
+
</SidebarMenuItem>
|
|
79
|
+
</SidebarMenu>
|
|
61
80
|
);
|
|
62
81
|
}
|