@cosmicdrift/kumiko-bundled-features 0.88.0 → 0.90.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.
Files changed (52) hide show
  1. package/package.json +9 -6
  2. package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
  3. package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
  4. package/src/data-retention/__tests__/resolver.test.ts +3 -3
  5. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
  6. package/src/data-retention/feature.ts +58 -7
  7. package/src/data-retention/presets.ts +5 -5
  8. package/src/data-retention/resolve-for-tenant.ts +9 -4
  9. package/src/data-retention/resolve-tenant-preset.ts +51 -0
  10. package/src/data-retention/run-retention-cleanup.ts +151 -0
  11. package/src/folders/__tests__/drift.test.ts +43 -0
  12. package/src/folders/__tests__/feature.test.ts +168 -0
  13. package/src/folders/__tests__/folders.integration.test.ts +290 -0
  14. package/src/folders/aggregate-id.ts +23 -0
  15. package/src/folders/constants.ts +40 -0
  16. package/src/folders/entity.ts +42 -0
  17. package/src/folders/executor.ts +11 -0
  18. package/src/folders/feature.ts +106 -0
  19. package/src/folders/handlers/clear-folder.write.ts +35 -0
  20. package/src/folders/handlers/set-folder.write.ts +82 -0
  21. package/src/folders/index.ts +23 -0
  22. package/src/folders/schemas.ts +18 -0
  23. package/src/folders/web/__tests__/folder-section.test.tsx +181 -0
  24. package/src/folders/web/__tests__/tree.test.ts +58 -0
  25. package/src/folders/web/client-plugin.tsx +16 -0
  26. package/src/folders/web/folder-manager.tsx +323 -0
  27. package/src/folders/web/folder-section.tsx +198 -0
  28. package/src/folders/web/i18n.ts +55 -0
  29. package/src/folders/web/index.ts +6 -0
  30. package/src/folders/web/tree.ts +54 -0
  31. package/src/folders-user-data/hooks.ts +58 -0
  32. package/src/folders-user-data/index.ts +33 -0
  33. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
  34. package/src/legal-pages/feature.ts +22 -10
  35. package/src/mail-foundation/feature.ts +51 -6
  36. package/src/mail-foundation/index.ts +2 -0
  37. package/src/mail-transport-inmemory/feature.ts +6 -3
  38. package/src/mail-transport-smtp/feature.ts +11 -10
  39. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
  40. package/src/managed-pages/feature.ts +17 -9
  41. package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
  42. package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
  43. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
  44. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
  45. package/src/user-data-rights/email-templates.ts +211 -0
  46. package/src/user-data-rights/feature.ts +96 -21
  47. package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
  48. package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
  49. package/src/user-data-rights/lib/default-mailers.ts +116 -0
  50. package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
  51. package/src/user-data-rights/run-export-jobs.ts +19 -8
  52. package/src/user-data-rights/run-forget-cleanup.ts +11 -1
@@ -0,0 +1,40 @@
1
+ // @runtime client
2
+ // folders bundle constants — feature-name + qualified handler/query names.
3
+ //
4
+ // Spec: kumiko-platform/docs/plans/folders-feature.md
5
+
6
+ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ export const FOLDERS_FEATURE_NAME = "folders";
9
+
10
+ // Registry name for the drop-in <FolderSection> component. Apps reference it in a
11
+ // screen schema via `component: { react: { __component: FOLDER_SECTION_EXTENSION_NAME } }`
12
+ // after mounting foldersClient(); the component is also importable directly for
13
+ // standalone use from `@cosmicdrift/kumiko-bundled-features/folders/web`.
14
+ export const FOLDER_SECTION_EXTENSION_NAME = "FolderSection";
15
+
16
+ // Qualified handler names (QN format: scope:type:name). The folder catalog uses
17
+ // generic defineEntity*Handler (create/update/delete) → "folder:<verb>" qualified
18
+ // to "folders:write:folder:<verb>". update covers rename (and, later, reparent).
19
+ // set-folder/clear-folder are the hand-written single-membership handlers.
20
+ export const FoldersHandlers = {
21
+ createFolder: "folders:write:folder:create",
22
+ updateFolder: "folders:write:folder:update",
23
+ deleteFolder: "folders:write:folder:delete",
24
+ setFolder: "folders:write:set-folder",
25
+ clearFolder: "folders:write:clear-folder",
26
+ } as const;
27
+
28
+ export const FoldersQueries = {
29
+ folderList: "folders:query:folder:list",
30
+ folderDetail: "folders:query:folder:detail",
31
+ assignmentList: "folders:query:folder-assignment:list",
32
+ } as const;
33
+
34
+ // Default RBAC for every folder write/read path. Like tags, folders are a
35
+ // low-sensitivity organisation tool, so both tenant roles may use them. Apps
36
+ // with their own role vocabulary override via createFoldersFeature({ roles }),
37
+ // or adopt the host's access model with createFoldersFeature({ access }).
38
+ export const DEFAULT_FOLDER_ROLES = ["TenantAdmin", "TenantMember"] as const;
39
+
40
+ export const DEFAULT_FOLDER_ACCESS: AccessRule = { roles: DEFAULT_FOLDER_ROLES };
@@ -0,0 +1,42 @@
1
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // folder — per-tenant hierarchical catalog. Event-sourced (create/update/delete
4
+ // via the standard executor); the framework projects `read_folders` from its own
5
+ // CRUD events. `parentId` null → root folder; otherwise it points at another
6
+ // folder's id, forming a tree. tenantId is a base column set by the framework →
7
+ // tenant-scoped.
8
+ export const folderEntity = createEntity({
9
+ table: "read_folders",
10
+ fields: {
11
+ name: createTextField({ required: true, maxLength: 64 }),
12
+ // Parent folder id, or absent for a root folder. No FK (event-sourced); a
13
+ // dangling parentId renders the folder at root — folders-view guards cycles.
14
+ parentId: createTextField({ maxLength: 64 }),
15
+ },
16
+ });
17
+
18
+ // folder-assignment — host-agnostic membership row keyed by (entityType, entityId).
19
+ // Unlike tag-assignment this is SINGLE-membership: the aggregate-id is derived from
20
+ // (tenantId, entityType, entityId) WITHOUT folderId (see aggregate-id.ts), so an
21
+ // entity has exactly one assignment row and "put into folder X" updates folderId
22
+ // (move) instead of creating a second row.
23
+ //
24
+ // softDelete is required, NOT cosmetic: the aggregate-id is deterministic, so
25
+ // clearing an assignment leaves a (created+deleted) event stream under that id.
26
+ // A hard delete would force the next set to create() at version 0 onto that
27
+ // existing stream → version_conflict. With softDelete the set handler resurrects
28
+ // the stream via restore(); the list query filters isDeleted.
29
+ //
30
+ // Cross-entity views compose in the read-layer (no JOIN):
31
+ // - folder of an entity → list assignments filter { field: "entityId", op: "eq" }
32
+ // - entities in a folder → list assignments filter { field: "folderId", op: "eq" }
33
+ export const folderAssignmentEntity = createEntity({
34
+ table: "read_folder_assignments",
35
+ softDelete: true,
36
+ fields: {
37
+ folderId: createTextField({ required: true, maxLength: 64 }),
38
+ entityType: createTextField({ required: true, maxLength: 64 }),
39
+ // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
40
+ entityId: createTextField({ required: true, maxLength: 128 }),
41
+ },
42
+ });
@@ -0,0 +1,11 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { folderAssignmentEntity, folderEntity } from "./entity";
3
+
4
+ // Shared executors for the folder + folder-assignment write-handlers.
5
+ // createEntityExecutor is side-effect-free; instantiating once keeps the
6
+ // table+executor pair in one place instead of rebuilding it per handler module.
7
+ export const { executor: folderExecutor } = createEntityExecutor("folder", folderEntity);
8
+ export const { executor: folderAssignmentExecutor } = createEntityExecutor(
9
+ "folder-assignment",
10
+ folderAssignmentEntity,
11
+ );
@@ -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
+ }