@cosmicdrift/kumiko-bundled-features 0.89.0 → 0.90.2

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.
@@ -0,0 +1,106 @@
1
+ // folders — generic, host-agnostic hierarchical folders for ANY entity.
2
+ //
3
+ // **Event-sourced, not relational.** No pivot table with foreign keys and JOINs.
4
+ // The feature owns two event-sourced entities:
5
+ // 1. `folder` (read_folders) — per-tenant folder tree (parentId).
6
+ // 2. `folder-assignment` (read_folder_assignments) — single-membership rows keyed
7
+ // by (entityType, entityId) with a deterministic aggregate-id, so an entity
8
+ // belongs to at most one folder and re-setting moves it.
9
+ //
10
+ // Cross-entity views compose in the read-layer (no JOIN): list folder-assignments
11
+ // filtered on entityId (the folder of an entity) or folderId (entities in a folder).
12
+ //
13
+ // Scope: folder catalog CRUD (create/update[=rename]/delete/list/detail via the
14
+ // generic entity handlers), set-folder (put/move), clear-folder (unfile).
15
+ // Deferred: reparenting with cycle-check (folder:update CAN change parentId, but
16
+ // no UI exposes it in v1 — folders-view guards cycles defensively).
17
+
18
+ import {
19
+ type AccessRule,
20
+ defineEntityCreateHandler,
21
+ defineEntityDeleteHandler,
22
+ defineEntityDetailHandler,
23
+ defineEntityListHandler,
24
+ defineEntityUpdateHandler,
25
+ defineFeature,
26
+ type FeatureRegistrar,
27
+ } from "@cosmicdrift/kumiko-framework/engine";
28
+ import { DEFAULT_FOLDER_ACCESS, FOLDERS_FEATURE_NAME } from "./constants";
29
+ import { folderAssignmentEntity, folderEntity } from "./entity";
30
+ import { createClearFolderHandler } from "./handlers/clear-folder.write";
31
+ import { createSetFolderHandler } from "./handlers/set-folder.write";
32
+
33
+ // Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
34
+ // dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
35
+ // on/off per tenant — no host-side hook. For a tier-gated feature use
36
+ // { default: false } (fail-closed) and list the feature name in the entitling
37
+ // tiers' TierMap; tenants below it get every folder path disabled.
38
+ type FoldersToggleable = { readonly default: boolean };
39
+
40
+ function registerFolders(
41
+ r: FeatureRegistrar<typeof FOLDERS_FEATURE_NAME>,
42
+ access: AccessRule,
43
+ toggleable: FoldersToggleable | undefined,
44
+ ): void {
45
+ r.describe(
46
+ "Generic, host-agnostic hierarchical folders for any entity. Owns two event-sourced entities — the per-tenant `folder` tree (`read_folders`, self-referential via parentId) and SINGLE-membership `folder-assignment` rows keyed by (entityType, entityId) (`read_folder_assignments`) — so filing an entity adds NO column to the host and needs no relational pivot or JOIN. The folder catalog uses the generic entity handlers (create, update [= rename, optimistic-locked], delete, list, detail); set-folder puts/moves an entity into a folder (one folder per entity) and clear-folder unfiles it (both idempotent). Read which folder an entity is in, or which entities a folder holds, by listing `folder-assignment` filtered on `entityId` or `folderId`. Every path uses one access rule — adopt the host's model with createFoldersFeature({ access: { openToAll: true } }) or pin roles. Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
47
+ );
48
+ r.uiHints({
49
+ displayLabel: "Folders",
50
+ category: "data",
51
+ recommended: false,
52
+ });
53
+
54
+ if (toggleable !== undefined) r.toggleable(toggleable);
55
+
56
+ r.entity("folder", folderEntity);
57
+ r.entity("folder-assignment", folderAssignmentEntity);
58
+
59
+ // Folder catalog — plain CRUD, no custom logic. update is rename (and, in a
60
+ // later stage, reparent: it accepts changes.parentId, optimistic-locked).
61
+ r.writeHandler(defineEntityCreateHandler("folder", folderEntity, { access }));
62
+ r.writeHandler(defineEntityUpdateHandler("folder", folderEntity, { access }));
63
+ r.writeHandler(defineEntityDeleteHandler("folder", folderEntity, { access }));
64
+ r.queryHandler(defineEntityListHandler("folder", folderEntity, { access }));
65
+ r.queryHandler(defineEntityDetailHandler("folder", folderEntity, { access }));
66
+
67
+ // Single-membership assignment — hand-written (deterministic id + move/restore).
68
+ r.writeHandler(createSetFolderHandler(access));
69
+ r.writeHandler(createClearFolderHandler(access));
70
+ r.queryHandler(defineEntityListHandler("folder-assignment", folderAssignmentEntity, { access }));
71
+ }
72
+
73
+ export const foldersFeature = defineFeature(FOLDERS_FEATURE_NAME, (r) =>
74
+ registerFolders(r, DEFAULT_FOLDER_ACCESS, undefined),
75
+ );
76
+
77
+ export type FoldersFeatureOptions = {
78
+ /** Access rule for all folder write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
79
+ * Adopt the host's model — e.g. { openToAll: true } when any authenticated
80
+ * tenant user may file entities, or { roles: ["Admin"] } for a custom role
81
+ * vocabulary. Takes precedence over `roles`. */
82
+ readonly access?: AccessRule;
83
+ /** Shorthand for { access: { roles } }. Ignored when `access` is set. */
84
+ readonly roles?: readonly string[];
85
+ /** Make the whole feature tier-gatable: declares r.toggleable so the
86
+ * tier-engine/feature-toggles can enable/disable every folder path per tenant.
87
+ * `default` applies when no toggle/tier override exists — use { default: false }
88
+ * for fail-closed tier-gating. Omit to keep folders always-on (default). */
89
+ readonly toggleable?: FoldersToggleable;
90
+ };
91
+
92
+ function resolveAccess(opts: FoldersFeatureOptions): AccessRule {
93
+ if (opts.access !== undefined) return opts.access;
94
+ if (opts.roles !== undefined) return { roles: opts.roles };
95
+ return DEFAULT_FOLDER_ACCESS;
96
+ }
97
+
98
+ // Options wrapper. Without options returns the module-level singleton (no
99
+ // rebuild). access/roles/toggleable build a fresh feature-definition.
100
+ export function createFoldersFeature(opts: FoldersFeatureOptions = {}): typeof foldersFeature {
101
+ if (opts.access === undefined && opts.roles === undefined && opts.toggleable === undefined) {
102
+ return foldersFeature;
103
+ }
104
+ const access = resolveAccess(opts);
105
+ return defineFeature(FOLDERS_FEATURE_NAME, (r) => registerFolders(r, access, opts.toggleable));
106
+ }
@@ -0,0 +1,35 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { folderAssignmentAggregateId } from "../aggregate-id";
3
+ import { DEFAULT_FOLDER_ACCESS } from "../constants";
4
+ import { folderAssignmentExecutor } from "../executor";
5
+ import { type ClearFolderPayload, clearFolderPayloadSchema } from "../schemas";
6
+
7
+ // clear-folder — removes a host entity from its folder (back to "unfiled").
8
+ // Idempotent: clearing an entity that isn't in any folder is already the
9
+ // requested end state, so we pre-check and return success without a delete.
10
+ export function createClearFolderHandler(
11
+ access: AccessRule = DEFAULT_FOLDER_ACCESS,
12
+ ): WriteHandlerDef {
13
+ return {
14
+ name: "clear-folder",
15
+ schema: clearFolderPayloadSchema,
16
+ access,
17
+ handler: async (event, ctx) => {
18
+ const payload = event.payload as ClearFolderPayload; // @cast-boundary engine-payload
19
+ const id = folderAssignmentAggregateId(
20
+ event.user.tenantId,
21
+ payload.entityType,
22
+ payload.entityId,
23
+ );
24
+
25
+ const existing = await folderAssignmentExecutor.detail({ id }, event.user, ctx.db);
26
+ if (!existing) {
27
+ return { isSuccess: true as const, data: { id } };
28
+ }
29
+
30
+ return folderAssignmentExecutor.delete({ id }, event.user, ctx.db);
31
+ },
32
+ };
33
+ }
34
+
35
+ export const clearFolderHandler: WriteHandlerDef = createClearFolderHandler();
@@ -0,0 +1,82 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { folderAssignmentAggregateId } from "../aggregate-id";
4
+ import { DEFAULT_FOLDER_ACCESS } from "../constants";
5
+ import { folderAssignmentExecutor, folderExecutor } from "../executor";
6
+ import { type SetFolderPayload, setFolderPayloadSchema } from "../schemas";
7
+
8
+ // set-folder — puts a host entity into a folder. Single-membership: the
9
+ // assignment id is deterministic over (tenant, entity) WITHOUT folderId, so an
10
+ // entity has exactly one assignment and setting a different folder MOVES it.
11
+ //
12
+ // Lifecycle (set → clear → set):
13
+ // - already in the requested folder → success (requested end state).
14
+ // - in a different folder → update folderId (move).
15
+ // - cleared (soft-deleted) → restore() then update folderId to the
16
+ // requested one. create() would append at version 0 onto the
17
+ // created+deleted stream and version_conflict.
18
+ // - never assigned → create().
19
+ // The internal move/restore-update uses skipOptimisticLock: this handler is the
20
+ // authority for the assignment's folderId, there is no client read-modify-write
21
+ // race to guard, and clients never send a version for set-folder.
22
+ //
23
+ // Referential integrity: there is no FK (event-sourced, no JOIN), so we verify
24
+ // the target folder exists before writing — a malformed call with an unknown
25
+ // folderId would otherwise point an entity at a phantom folder.
26
+ export function createSetFolderHandler(
27
+ access: AccessRule = DEFAULT_FOLDER_ACCESS,
28
+ ): WriteHandlerDef {
29
+ return {
30
+ name: "set-folder",
31
+ schema: setFolderPayloadSchema,
32
+ access,
33
+ handler: async (event, ctx) => {
34
+ const payload = event.payload as SetFolderPayload; // @cast-boundary engine-payload
35
+ const id = folderAssignmentAggregateId(
36
+ event.user.tenantId,
37
+ payload.entityType,
38
+ payload.entityId,
39
+ );
40
+
41
+ const folder = await folderExecutor.detail({ id: payload.folderId }, event.user, ctx.db);
42
+ if (!folder) return writeFailure(new NotFoundError("folder", payload.folderId));
43
+
44
+ const existing = await folderAssignmentExecutor.detail({ id }, event.user, ctx.db);
45
+ if (existing) {
46
+ if (existing["folderId"] === payload.folderId) {
47
+ return { isSuccess: true as const, data: { id } };
48
+ }
49
+ return folderAssignmentExecutor.update(
50
+ { id, changes: { folderId: payload.folderId } },
51
+ event.user,
52
+ ctx.db,
53
+ { skipOptimisticLock: true },
54
+ );
55
+ }
56
+
57
+ const restored = await folderAssignmentExecutor.restore({ id }, event.user, ctx.db);
58
+ if (restored.isSuccess) {
59
+ return folderAssignmentExecutor.update(
60
+ { id, changes: { folderId: payload.folderId } },
61
+ event.user,
62
+ ctx.db,
63
+ { skipOptimisticLock: true },
64
+ );
65
+ }
66
+ if (restored.error.code !== "not_found") return restored;
67
+
68
+ return folderAssignmentExecutor.create(
69
+ {
70
+ id,
71
+ folderId: payload.folderId,
72
+ entityType: payload.entityType,
73
+ entityId: payload.entityId,
74
+ },
75
+ event.user,
76
+ ctx.db,
77
+ );
78
+ },
79
+ };
80
+ }
81
+
82
+ export const setFolderHandler: WriteHandlerDef = createSetFolderHandler();
@@ -0,0 +1,23 @@
1
+ export { folderAssignmentAggregateId } from "./aggregate-id";
2
+ export {
3
+ DEFAULT_FOLDER_ACCESS,
4
+ DEFAULT_FOLDER_ROLES,
5
+ FOLDER_SECTION_EXTENSION_NAME,
6
+ FOLDERS_FEATURE_NAME,
7
+ FoldersHandlers,
8
+ FoldersQueries,
9
+ } from "./constants";
10
+ export { folderAssignmentEntity, folderEntity } from "./entity";
11
+ export {
12
+ createFoldersFeature,
13
+ type FoldersFeatureOptions,
14
+ foldersFeature,
15
+ } from "./feature";
16
+ export { clearFolderHandler, createClearFolderHandler } from "./handlers/clear-folder.write";
17
+ export { createSetFolderHandler, setFolderHandler } from "./handlers/set-folder.write";
18
+ export {
19
+ type ClearFolderPayload,
20
+ clearFolderPayloadSchema,
21
+ type SetFolderPayload,
22
+ setFolderPayloadSchema,
23
+ } from "./schemas";
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+
3
+ // set-folder + clear-folder share the (entity) reference shape; set-folder adds
4
+ // the target folderId. Folder catalog CRUD (create/update/delete) uses the
5
+ // generic defineEntity*Handler schemas — no hand-written schema needed there.
6
+ const entityRef = {
7
+ entityType: z.string().min(1).max(64),
8
+ entityId: z.string().min(1).max(128),
9
+ } as const;
10
+
11
+ export const setFolderPayloadSchema = z.object({
12
+ folderId: z.string().min(1).max(64),
13
+ ...entityRef,
14
+ });
15
+ export type SetFolderPayload = z.infer<typeof setFolderPayloadSchema>;
16
+
17
+ export const clearFolderPayloadSchema = z.object(entityRef);
18
+ export type ClearFolderPayload = z.infer<typeof clearFolderPayloadSchema>;
@@ -0,0 +1,181 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { FoldersHandlers, FoldersQueries } from "../../constants";
11
+ import { FolderSection } from "../folder-section";
12
+ import { defaultTranslations } from "../i18n";
13
+
14
+ type FolderRow = { id: string; name: string; parentId: string | null; version: number };
15
+ type AssignmentRow = { folderId: string; entityType: string; entityId: string };
16
+
17
+ let folderRows: readonly FolderRow[] = [];
18
+ let assignmentRows: readonly AssignmentRow[] = [];
19
+
20
+ beforeEach(() => {
21
+ folderRows = [];
22
+ assignmentRows = [];
23
+ });
24
+
25
+ const dispatchSpy = mock(async (type: string) =>
26
+ type === FoldersHandlers.createFolder
27
+ ? { isSuccess: true, data: { id: "folder-new" } }
28
+ : { isSuccess: true, data: undefined },
29
+ );
30
+
31
+ const useQuerySpy = mock((type: string) => ({
32
+ data: type === FoldersQueries.folderList ? { rows: folderRows } : { rows: assignmentRows },
33
+ loading: false,
34
+ error: null,
35
+ refetch: mock(async () => {}),
36
+ }));
37
+
38
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
39
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
40
+ ...actual_renderer,
41
+ useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
42
+ useQuery: useQuerySpy,
43
+ }));
44
+
45
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
46
+ return (
47
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
48
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
49
+ </LocaleProvider>
50
+ );
51
+ }
52
+
53
+ // The real combobox is cmdk + Radix (popover = e2e/primitive-test territory).
54
+ // A headless single-select stub renders one button per option and fires
55
+ // onChange(value) — same contract, no popover — so the select/clear → set/clear
56
+ // wiring is pinnable in jsdom.
57
+ const StubInput: typeof defaultPrimitives.Input = (props) => {
58
+ if (props.kind === "combobox" && props.multiple !== true) {
59
+ return (
60
+ <div data-testid="stub-combobox">
61
+ {props.options.map((o) => (
62
+ <button
63
+ key={o.value}
64
+ type="button"
65
+ data-testid={`folder-opt-${o.value === "" ? "none" : o.value}`}
66
+ onClick={() => props.onChange(o.value)}
67
+ >
68
+ {o.label}
69
+ </button>
70
+ ))}
71
+ </div>
72
+ );
73
+ }
74
+ return <input data-testid={`stub-${props.id}`} />;
75
+ };
76
+
77
+ function StubComboboxWrapper({ children }: { readonly children: ReactNode }): ReactNode {
78
+ return (
79
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
80
+ <PrimitivesProvider value={{ ...defaultPrimitives, Input: StubInput }}>
81
+ {children}
82
+ </PrimitivesProvider>
83
+ </LocaleProvider>
84
+ );
85
+ }
86
+
87
+ describe("FolderSection", () => {
88
+ test("options carry the full folder path (not just the leaf name)", () => {
89
+ folderRows = [
90
+ { id: "f1", name: "Immobilie", parentId: null, version: 1 },
91
+ { id: "f2", name: "Müller", parentId: "f1", version: 1 },
92
+ ];
93
+ assignmentRows = [{ folderId: "f1", entityType: "credit", entityId: "c-1" }];
94
+
95
+ render(
96
+ <StubComboboxWrapper>
97
+ <FolderSection entityName="credit" entityId="c-1" />
98
+ </StubComboboxWrapper>,
99
+ );
100
+ expect(screen.getByText("Immobilie / Müller")).toBeTruthy();
101
+ });
102
+
103
+ test("selecting a different folder dispatches set-folder", async () => {
104
+ folderRows = [
105
+ { id: "f1", name: "A", parentId: null, version: 1 },
106
+ { id: "f2", name: "B", parentId: null, version: 1 },
107
+ ];
108
+ assignmentRows = [{ folderId: "f1", entityType: "credit", entityId: "c-1" }];
109
+ dispatchSpy.mockClear();
110
+
111
+ render(
112
+ <StubComboboxWrapper>
113
+ <FolderSection entityName="credit" entityId="c-1" />
114
+ </StubComboboxWrapper>,
115
+ );
116
+ fireEvent.click(screen.getByTestId("folder-opt-f2"));
117
+ await waitFor(() =>
118
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.setFolder, {
119
+ folderId: "f2",
120
+ entityType: "credit",
121
+ entityId: "c-1",
122
+ }),
123
+ );
124
+ });
125
+
126
+ test("selecting the no-folder option dispatches clear-folder", async () => {
127
+ folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
128
+ assignmentRows = [{ folderId: "f1", entityType: "credit", entityId: "c-1" }];
129
+ dispatchSpy.mockClear();
130
+
131
+ render(
132
+ <StubComboboxWrapper>
133
+ <FolderSection entityName="credit" entityId="c-1" />
134
+ </StubComboboxWrapper>,
135
+ );
136
+ fireEvent.click(screen.getByTestId("folder-opt-none"));
137
+ await waitFor(() =>
138
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.clearFolder, {
139
+ entityType: "credit",
140
+ entityId: "c-1",
141
+ }),
142
+ );
143
+ });
144
+
145
+ test("create-and-file dispatches create-folder, then set-folder with the new id", async () => {
146
+ folderRows = [];
147
+ assignmentRows = [];
148
+ dispatchSpy.mockClear();
149
+
150
+ render(
151
+ <Wrapper>
152
+ <FolderSection entityName="credit" entityId="c-9" />
153
+ </Wrapper>,
154
+ );
155
+ fireEvent.change(document.getElementById("folder-section-new") as HTMLInputElement, {
156
+ target: { value: "Neuer" },
157
+ });
158
+ fireEvent.click(screen.getByTestId("folder-section-create"));
159
+
160
+ await waitFor(() =>
161
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.createFolder, { name: "Neuer" }),
162
+ );
163
+ await waitFor(() =>
164
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.setFolder, {
165
+ folderId: "folder-new",
166
+ entityType: "credit",
167
+ entityId: "c-9",
168
+ }),
169
+ );
170
+ });
171
+
172
+ test("create-mode (no entityId yet) shows the save-first hint instead of the picker", () => {
173
+ render(
174
+ <Wrapper>
175
+ <FolderSection entityName="credit" entityId={null} />
176
+ </Wrapper>,
177
+ );
178
+ expect(screen.getByTestId("folder-section-create-mode")).toBeTruthy();
179
+ expect(screen.queryByTestId("folder-section")).toBeNull();
180
+ });
181
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildFolderTree, type FolderRow, folderPath } from "../tree";
3
+
4
+ const f = (id: string, name: string, parentId: string | null = null): FolderRow => ({
5
+ id,
6
+ name,
7
+ parentId,
8
+ version: 1,
9
+ });
10
+
11
+ describe("buildFolderTree", () => {
12
+ test("empty input → no roots", () => {
13
+ expect(buildFolderTree([])).toEqual([]);
14
+ });
15
+
16
+ test("flat roots are sorted by name with depth 0", () => {
17
+ const tree = buildFolderTree([f("2", "Beta"), f("1", "Alpha")]);
18
+ expect(tree.map((n) => n.name)).toEqual(["Alpha", "Beta"]);
19
+ expect(tree.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
20
+ });
21
+
22
+ test("children nest under their parent with incremented depth", () => {
23
+ const tree = buildFolderTree([
24
+ f("root", "Root"),
25
+ f("child", "Child", "root"),
26
+ f("grandchild", "Grandchild", "child"),
27
+ ]);
28
+ expect(tree).toHaveLength(1);
29
+ const root = tree[0]!;
30
+ expect(root.children).toHaveLength(1);
31
+ expect(root.children[0]!.depth).toBe(1);
32
+ expect(root.children[0]!.children[0]!.name).toBe("Grandchild");
33
+ expect(root.children[0]!.children[0]!.depth).toBe(2);
34
+ });
35
+
36
+ test("a row whose parentId points at a deleted parent surfaces as a root", () => {
37
+ const tree = buildFolderTree([f("orphan", "Orphan", "gone")]);
38
+ expect(tree).toHaveLength(1);
39
+ expect(tree[0]!.name).toBe("Orphan");
40
+ expect(tree[0]!.depth).toBe(0);
41
+ });
42
+ });
43
+
44
+ describe("folderPath", () => {
45
+ const rows = [f("a", "Immobilie"), f("b", "Müller", "a"), f("c", "Kredit", "b")];
46
+
47
+ test("root folder → just its name", () => {
48
+ expect(folderPath(rows, "a")).toBe("Immobilie");
49
+ });
50
+
51
+ test("nested folder → full path", () => {
52
+ expect(folderPath(rows, "c")).toBe("Immobilie / Müller / Kredit");
53
+ });
54
+
55
+ test("unknown id → empty string", () => {
56
+ expect(folderPath(rows, "nope")).toBe("");
57
+ });
58
+ });
@@ -0,0 +1,16 @@
1
+ // @runtime client
2
+
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
+ import { FOLDER_SECTION_EXTENSION_NAME, FOLDERS_FEATURE_NAME } from "../constants";
5
+ import { FolderSection } from "./folder-section";
6
+ import { defaultTranslations } from "./i18n";
7
+
8
+ export function foldersClient(): ClientFeatureDefinition {
9
+ return {
10
+ name: FOLDERS_FEATURE_NAME,
11
+ extensionSectionComponents: {
12
+ [FOLDER_SECTION_EXTENSION_NAME]: FolderSection,
13
+ },
14
+ translations: defaultTranslations,
15
+ };
16
+ }