@cosmicdrift/kumiko-bundled-features 0.98.0 → 0.102.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 (45) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/auth-mailer.ts +28 -12
  3. package/src/auth-email-password/index.ts +3 -0
  4. package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
  5. package/src/channel-email/email-channel.ts +32 -20
  6. package/src/channel-email/feature.ts +2 -0
  7. package/src/channel-email/index.ts +6 -1
  8. package/src/channel-email/smtp-transport.ts +37 -0
  9. package/src/channel-in-app/feature.ts +1 -0
  10. package/src/channel-in-app/in-app-channel.ts +1 -0
  11. package/src/channel-push/feature.ts +1 -0
  12. package/src/channel-push/push-channel.ts +1 -0
  13. package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
  14. package/src/delivery/attempt-log.ts +54 -0
  15. package/src/delivery/channel-context.ts +17 -0
  16. package/src/delivery/constants.ts +35 -1
  17. package/src/delivery/delivery-service.ts +118 -47
  18. package/src/delivery/events.ts +7 -1
  19. package/src/delivery/feature.ts +38 -14
  20. package/src/delivery/index.ts +3 -0
  21. package/src/delivery/jobs.ts +153 -0
  22. package/src/delivery/tables.ts +5 -1
  23. package/src/delivery/types.ts +25 -2
  24. package/src/file-foundation/feature.ts +28 -141
  25. package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
  26. package/src/presets/dsgvo-self-service.ts +30 -0
  27. package/src/presets/index.ts +1 -0
  28. package/src/secrets/feature.ts +3 -1
  29. package/src/secrets/index.ts +1 -0
  30. package/src/tags/constants.ts +6 -0
  31. package/src/tags/index.ts +1 -0
  32. package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
  33. package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
  34. package/src/tags/web/client-plugin.tsx +6 -0
  35. package/src/tags/web/i18n.ts +2 -2
  36. package/src/tags/web/index.ts +2 -0
  37. package/src/tags/web/tag-filter.tsx +24 -12
  38. package/src/tags/web/tags-cell.tsx +46 -0
  39. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
  40. package/src/text-content/seeding.ts +35 -1
  41. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
  42. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
  43. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
  44. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
  45. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +7 -13
@@ -2,6 +2,7 @@ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type {
4
4
  NotifyOptions,
5
+ NotifyPriority,
5
6
  Registry,
6
7
  SessionUser,
7
8
  TenantId,
@@ -29,10 +30,31 @@ export type ChannelResult = {
29
30
  readonly address?: string;
30
31
  };
31
32
 
33
+ // Output of a channel's render step, passed into send(). Only channels that
34
+ // declare render() (email) produce one; inline channels (inApp) and channels
35
+ // without an expensive render step (push) receive `undefined`.
36
+ export type RenderedMessage = {
37
+ readonly html: string;
38
+ readonly subject: string;
39
+ };
40
+
41
+ // `mode` decides how the delivery-service dispatches a channel:
42
+ // inline — sent synchronously inside notify() (inApp: DB insert + SSE).
43
+ // queued — sent asynchronously via the delivery.send job; channels with a
44
+ // render() additionally run through delivery.render first.
45
+ export type DeliveryChannelMode = "inline" | "queued";
46
+
32
47
  export type DeliveryChannel = {
33
48
  readonly name: string;
49
+ readonly mode: DeliveryChannelMode;
34
50
  resolve(userId: string, ctx: ChannelContext): Promise<string | null>;
35
- send(address: string, message: ChannelMessage, ctx: ChannelContext): Promise<ChannelResult>;
51
+ render?(message: ChannelMessage, ctx: ChannelContext): Promise<RenderedMessage>;
52
+ send(
53
+ address: string,
54
+ message: ChannelMessage,
55
+ ctx: ChannelContext,
56
+ rendered?: RenderedMessage,
57
+ ): Promise<ChannelResult>;
36
58
  };
37
59
 
38
60
  // --- Notification Renderer ---
@@ -55,8 +77,9 @@ export type DeliveryLogEntry = {
55
77
  readonly channel: string;
56
78
  readonly recipientId: string | null;
57
79
  readonly recipientAddress: string | null;
58
- readonly status: "sent" | "failed" | "skipped";
80
+ readonly status: "queued" | "sent" | "failed" | "skipped";
59
81
  readonly error: string | null;
82
+ readonly priority: NotifyPriority;
60
83
  };
61
84
 
62
85
  // --- Delivery Service ---
@@ -1,103 +1,44 @@
1
1
  // kumiko-feature-version: 1
2
2
  //
3
- // file-foundation as a Kumiko bundled feature — plugin-API shape.
3
+ // file-foundation as a Kumiko bundled feature — declares the `fileProvider`
4
+ // extension point + the per-tenant `provider` config key. Provider RESOLUTION
5
+ // (createFileProviderForTenant) + the plugin types now live in the framework
6
+ // (packages/framework/src/files/provider-resolver.ts) so the upload routes,
7
+ // `ctx.files` and the GDPR jobs resolve through ONE path — uploads, export and
8
+ // erasure hit the same store by construction. This feature re-exports the moved
9
+ // symbols unchanged so existing imports keep working.
4
10
  //
5
- // **Pattern-Vorbild:** identisch zu `mail-foundation`. Foundation
6
- // deklariert extension-point `fileProvider`, Provider-Features (file-
7
- // provider-s3, später file-provider-azure-blob, file-provider-gcs)
8
- // registrieren sich namentlich. Tenant wählt zur Runtime via config-
9
- // key `provider`.
11
+ // **Pattern-Vorbild:** identisch zu `mail-foundation`. Foundation deklariert
12
+ // extension-point `fileProvider`, Provider-Features (file-provider-s3, -inmemory,
13
+ // -s3-env, später -gcs/-azure-blob) registrieren sich namentlich. Tenant wählt
14
+ // zur Runtime via config-key `provider`.
10
15
  //
11
- // **Was diese Foundation NICHT mehr macht:**
12
- // - Keine S3-spezifischen Config-Keys mehr (bucket/region/endpoint/
13
- // forcePathStyle/accessKeyId) — die leben im Provider-Plugin.
14
- // - Kein direkter Import von `createS3Provider`. Foundation kennt
15
- // nur das `FileStorageProvider`-Interface (Type-Import, kein
16
- // runtime-coupling).
17
- //
18
- // **Standalone:** Foundation ist ohne tier-engine nutzbar. Existing
19
- // `files-provider-s3` (App-wide-Library) bleibt unangetastet.
20
- //
21
- // **Boot-Dependencies:** config (für provider-selector). Kein secrets,
22
- // weil Foundation selbst keine Secrets hält.
16
+ // **Boot-Dependencies:** config (für provider-selector). Kein secrets, weil
17
+ // Foundation selbst keine Secrets hält.
23
18
 
24
- import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
25
19
  import {
26
20
  access,
27
- type ConfigAccessor,
28
21
  createTenantConfig,
29
22
  defineFeature,
30
- type Registry,
23
+ EXT_FILE_PROVIDER,
31
24
  } from "@cosmicdrift/kumiko-framework/engine";
32
- import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
33
-
34
- const FEATURE_NAME = "file-foundation";
35
-
36
- // =============================================================================
37
- // Plugin-Interface — what a Provider-Plugin must implement
38
- // =============================================================================
39
-
40
- /**
41
- * Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu
42
- * fett (haelt tx, actor, signal etc.) — Provider sollen sich auf die
43
- * read-Felder beschraenken die fuer Tenant-Config + Secret-Lookup
44
- * gebraucht werden.
45
- *
46
- * **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt
47
- * es keinen request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider
48
- * `ctx.tx` lesen wuerde, wuerde der ganze Worker-Pfad zur Runtime
49
- * brechen — und das wuerde NUR mit S3 und nur in production auffallen.
50
- * Die schmale Surface zwingt Provider zur expliziten Erweiterung
51
- * (extra-arg) statt silent ctx-feld-ausnutzen.
52
- *
53
- * **Felder:**
54
- * config — fuer tenant-config-reads (bucket/region/endpoint/...)
55
- * registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
56
- * secrets — fuer tenant-secret-reads (s3.secretAccessKey)
57
- * _userId — Audit-Identity fuer secret-reads. Im Handler-Pfad setzt
58
- * der dispatcher das auf die Caller-User-ID; im Worker-Pfad
59
- * muss der r.job-Wrap das explizit auf eine System-Identity
60
- * setzen (z.B. "system:user-data-rights:run-export-jobs").
61
- */
62
- export type FileProviderContext = {
63
- readonly config?: ConfigAccessor;
64
- readonly registry?: Registry;
65
- readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
66
- readonly _userId?: string | undefined;
67
- };
68
-
69
- /**
70
- * File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
71
- * file-provider-azure-blob, ...) registers an implementation via
72
- * `r.useExtension("fileProvider", "<name>", { build })`.
73
- *
74
- * **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein FileProviderContext,
75
- * nicht ein voller HandlerContext. Felder ausserhalb der schmalen
76
- * Surface (z.B. `ctx.tx`, `ctx.actor`, `ctx.signal`, `ctx.notify`) sind
77
- * im Worker-Pfad (r.job-getriggerte Provider-Builds) NICHT vorhanden.
78
- * Cast `ctx as unknown as HandlerContext` macht den Compiler happy aber
79
- * fliegt zur Runtime im Worker — und der Crash kommt erst in production
80
- * mit dem ersten S3-Tenant. Wenn ein Plugin Felder braucht die nicht in
81
- * FileProviderContext sind: lieber FileProviderContext explizit erweitern
82
- * (sichtbarer breaking change) als ctx-cast.
83
- */
84
- export type FileProviderPlugin = {
85
- readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
86
- };
87
25
 
88
- // extension-usage `options` is engine-payload (unknown) — structurally validate
89
- // instead of casting blind.
90
- export function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
91
- return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
92
- }
26
+ export type {
27
+ FileProviderContext,
28
+ FileProviderPlugin,
29
+ } from "@cosmicdrift/kumiko-framework/files";
30
+ // Moved into the framework — re-exported here so `@cosmicdrift/kumiko-bundled-
31
+ // features/file-foundation` consumers (user-data-rights, app code) keep working.
32
+ export {
33
+ createFileProviderForTenant,
34
+ isFileProviderPlugin,
35
+ } from "@cosmicdrift/kumiko-framework/files";
93
36
 
94
- // =============================================================================
95
- // Feature-definition
96
- // =============================================================================
37
+ const FEATURE_NAME = "file-foundation";
97
38
 
98
39
  export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
99
40
  r.describe(
100
- "Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider` \u2014 use this feature together with at least one `file-provider-*` feature; the `files` feature builds on top of it for tracked `FileRef` entities with GDPR hooks.",
41
+ "Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider` use this feature together with at least one `file-provider-*` feature; the `files` feature builds on top of it for tracked `FileRef` entities with GDPR hooks.",
101
42
  );
102
43
  r.uiHints({
103
44
  displayLabel: "File Provider Foundation",
@@ -106,7 +47,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
106
47
  });
107
48
  r.requires("config");
108
49
 
109
- r.extendsRegistrar("fileProvider", {
50
+ r.extendsRegistrar(EXT_FILE_PROVIDER, {
110
51
  onRegister: () => {
111
52
  // No side-effects at register-time — registry stores the usage,
112
53
  // factory looks it up at request-time.
@@ -124,61 +65,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
124
65
  });
125
66
  // Readiness gating: provider-plugins' required keys/secrets count only
126
67
  // while their plugin is the one this key selects.
127
- r.extensionSelector("fileProvider", configKeys.provider);
68
+ r.extensionSelector(EXT_FILE_PROVIDER, configKeys.provider);
128
69
 
129
70
  return { configKeys };
130
71
  });
131
-
132
- // =============================================================================
133
- // Provider-factory — looks up the registered plugin + delegates
134
- // =============================================================================
135
-
136
- export async function createFileProviderForTenant(
137
- ctx: FileProviderContext,
138
- tenantId: string,
139
- handlerName = "file-foundation:provider-factory",
140
- ): Promise<FileStorageProvider> {
141
- const ctxConfig = ctx.config;
142
- if (!ctxConfig) {
143
- throw new Error(
144
- `${handlerName}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
145
- );
146
- }
147
- if (!ctx.registry) {
148
- throw new Error(
149
- `${handlerName}: ctx.registry is missing — required to look up registered file-provider plugins`,
150
- );
151
- }
152
-
153
- const provider = requireDefined(
154
- await ctxConfig(fileFoundationFeature.exports.configKeys.provider),
155
- FEATURE_NAME,
156
- "provider",
157
- ) as string; // @cast-boundary engine-payload
158
- if (provider.length === 0) {
159
- const usages = ctx.registry.getExtensionUsages("fileProvider");
160
- const known = usages.map((u) => u.entityName).join(", ") || "<none>";
161
- throw new Error(
162
- `${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
163
- `Mount a file-provider-* feature first if no plugins are registered.`,
164
- );
165
- }
166
-
167
- const usages = ctx.registry.getExtensionUsages("fileProvider");
168
- const usage = usages.find((u) => u.entityName === provider);
169
- if (!usage) {
170
- const known = usages.map((u) => u.entityName).join(", ") || "<none>";
171
- throw new Error(
172
- `${FEATURE_NAME}: provider "${provider}" not registered. Known: ${known}. ` +
173
- `Mount the matching file-provider-${provider} feature.`,
174
- );
175
- }
176
-
177
- if (!isFileProviderPlugin(usage.options)) {
178
- throw new Error(
179
- `${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
180
- `extension options must be a FileProviderPlugin.`,
181
- );
182
- }
183
- return usage.options.build(ctx, tenantId);
184
- }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { dsgvoSelfServiceFeatures } from "../dsgvo-self-service";
3
+
4
+ // Pins the DSGVO/self-service bundle: the five features in dependency order
5
+ // (user-data-rights requires data-retention + compliance-profiles + sessions;
6
+ // user-profile requires user-data-rights). Order is load-bearing, so it is
7
+ // asserted explicitly.
8
+
9
+ describe("dsgvoSelfServiceFeatures", () => {
10
+ test("returns the five features in require-order", () => {
11
+ const names = dsgvoSelfServiceFeatures().map((f) => f.name);
12
+ expect(names).toEqual([
13
+ "data-retention",
14
+ "compliance-profiles",
15
+ "sessions",
16
+ "user-data-rights",
17
+ "user-profile",
18
+ ]);
19
+ });
20
+
21
+ test("data-retention/compliance-profiles precede user-data-rights (dependency order holds)", () => {
22
+ const names = dsgvoSelfServiceFeatures().map((f) => f.name);
23
+ const udr = names.indexOf("user-data-rights");
24
+ expect(names.indexOf("data-retention")).toBeLessThan(udr);
25
+ expect(names.indexOf("compliance-profiles")).toBeLessThan(udr);
26
+ expect(names.indexOf("sessions")).toBeLessThan(udr);
27
+ expect(names.indexOf("user-profile")).toBeGreaterThan(udr);
28
+ });
29
+
30
+ test("each call yields fresh feature instances (no shared mutable state)", () => {
31
+ const a = dsgvoSelfServiceFeatures();
32
+ const b = dsgvoSelfServiceFeatures();
33
+ expect(a[0]).not.toBe(b[0]);
34
+ });
35
+ });
@@ -0,0 +1,30 @@
1
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createComplianceProfilesFeature } from "../compliance-profiles";
3
+ import { createDataRetentionFeature } from "../data-retention";
4
+ import { createSessionsFeature } from "../sessions";
5
+ import { createUserDataRightsFeature, type UserDataRightsOptions } from "../user-data-rights";
6
+ import { createUserProfileFeature } from "../user-profile";
7
+
8
+ export type DsgvoSelfServiceOptions = {
9
+ /** Durchgereicht an createUserDataRightsFeature — Export-/Deletion-Mail-
10
+ * Callbacks + Apex-Deletion-HMAC. Default {} (no-op Mail-Side). */
11
+ readonly userDataRights?: UserDataRightsOptions;
12
+ };
13
+
14
+ // DSGVO- + Account-Self-Service-Kette, die jede Kumiko-SaaS-App mountet
15
+ // (Privacy-Center, Account-Löschung Art. 17, Export Art. 20, Sessions).
16
+ // Die Reihenfolge IST load-bearing (Require-Order): user-data-rights braucht
17
+ // data-retention + compliance-profiles + sessions, user-profile braucht
18
+ // user-data-rights. Genau diese Order stand bisher in jeder App handkopiert
19
+ // mit Erklär-Kommentar. text-content + legal-pages bleiben bewusst draußen —
20
+ // legal-pages hat ein app-spezifisches wrapLayout, text-content ist
21
+ // standalone Foundation; beide spreaded die App selbst dazu.
22
+ export function dsgvoSelfServiceFeatures(opts: DsgvoSelfServiceOptions = {}): FeatureDefinition[] {
23
+ return [
24
+ createDataRetentionFeature(),
25
+ createComplianceProfilesFeature(),
26
+ createSessionsFeature(),
27
+ createUserDataRightsFeature(opts.userDataRights ?? {}),
28
+ createUserProfileFeature(),
29
+ ];
30
+ }
@@ -0,0 +1 @@
1
+ export { type DsgvoSelfServiceOptions, dsgvoSelfServiceFeatures } from "./dsgvo-self-service";
@@ -20,6 +20,8 @@ import { tenantSecretEntity, tenantSecretsTable } from "./table";
20
20
  * and picks the highest as the active KEK unless
21
21
  * `KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION` pins one explicitly.
22
22
  */
23
+ export const SECRETS_FEATURE_NAME = "secrets";
24
+
23
25
  export const secretsEnvSchema = z.object({
24
26
  KUMIKO_SECRETS_MASTER_KEY_V1: z
25
27
  .string()
@@ -83,7 +85,7 @@ export function requireSecretsContext(
83
85
  }
84
86
 
85
87
  export function createSecretsFeature(): FeatureDefinition {
86
- return defineFeature("secrets", (r) => {
88
+ return defineFeature(SECRETS_FEATURE_NAME, (r) => {
87
89
  r.describe(
88
90
  "Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump.",
89
91
  );
@@ -2,6 +2,7 @@ export {
2
2
  createSecretsContext,
3
3
  createSecretsFeature,
4
4
  requireSecretsContext,
5
+ SECRETS_FEATURE_NAME,
5
6
  type SecretsContext,
6
7
  type SecretsContextOptions,
7
8
  type StoredEnvelope,
@@ -19,6 +19,12 @@ export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
19
19
  // after mounting tagsClient(); the renderer passes it the list's screenId.
20
20
  export const TAGS_FILTER_EXTENSION_NAME = "TagFilter";
21
21
 
22
+ // Registry name for the <TagsCell> column renderer. A host entityList shows an
23
+ // entity's tags inline by declaring a labeled virtual column:
24
+ // `{ field: "tags", label: "Tags", renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } }`
25
+ // after mounting tagsClient() (which registers it under clientFeatures.columnRenderers).
26
+ export const TAGS_COLUMN_RENDERER_NAME = "TagsCell";
27
+
22
28
  // Screen-id of the standalone Tags management screen (custom screen rendering
23
29
  // TagManager). Qualified = "tags:screen:tag-list"; the app places it via r.nav.
24
30
  export const TAGS_SCREEN_ID = "tag-list";
package/src/tags/index.ts CHANGED
@@ -2,6 +2,7 @@ export { tagAssignmentAggregateId } from "./aggregate-id";
2
2
  export {
3
3
  DEFAULT_TAG_ACCESS,
4
4
  DEFAULT_TAG_ROLES,
5
+ TAGS_COLUMN_RENDERER_NAME,
5
6
  TAGS_FEATURE_NAME,
6
7
  TAGS_FILTER_EXTENSION_NAME,
7
8
  TAGS_SCREEN_ID,
@@ -0,0 +1,124 @@
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 } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { TagsQueries } from "../../constants";
11
+ import { defaultTranslations } from "../i18n";
12
+ import { TagFilter } from "../tag-filter";
13
+
14
+ type TagRow = { id: string; name: string; color?: string };
15
+ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
16
+
17
+ let catalogRows: readonly TagRow[] = [];
18
+ let assignmentRows: readonly AssignmentRow[] = [];
19
+ const setFilterSpy = mock((_field: string, _values: readonly string[]) => {});
20
+
21
+ beforeEach(() => {
22
+ catalogRows = [
23
+ { id: "t1", name: "urgent", color: "#ef4444" },
24
+ { id: "t2", name: "backend", color: "#3b82f6" },
25
+ ];
26
+ assignmentRows = [
27
+ { tagId: "t1", entityType: "note", entityId: "n1" },
28
+ { tagId: "t2", entityType: "note", entityId: "n2" },
29
+ ];
30
+ setFilterSpy.mockClear();
31
+ });
32
+
33
+ const useQuerySpy = mock((type: string) => ({
34
+ data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
35
+ loading: false,
36
+ error: null,
37
+ refetch: mock(async () => {}),
38
+ }));
39
+
40
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
41
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
42
+ ...actual_renderer,
43
+ useQuery: useQuerySpy,
44
+ useListUrlState: () => ({
45
+ sort: null,
46
+ q: "",
47
+ page: 1,
48
+ filters: {},
49
+ setSort: mock(() => {}),
50
+ setQ: mock(() => {}),
51
+ setPage: mock(() => {}),
52
+ setFilter: setFilterSpy,
53
+ clearFilters: mock(() => {}),
54
+ }),
55
+ }));
56
+
57
+ // Headless picker stub: a button that reports a tag selection back through the
58
+ // real onChange contract — no Dialog/cmdk popover to drive.
59
+ const StubPicker = ({
60
+ onChange,
61
+ }: {
62
+ readonly value: readonly string[];
63
+ readonly onChange: (next: readonly string[]) => void;
64
+ readonly entityType: string;
65
+ readonly open: boolean;
66
+ readonly onOpenChange: (open: boolean) => void;
67
+ }): ReactNode => (
68
+ <button type="button" data-testid="picker-pick-t1" onClick={() => onChange(["t1"])}>
69
+ pick urgent
70
+ </button>
71
+ );
72
+ mock.module("../tag-picker", () => ({ TagPicker: StubPicker }));
73
+
74
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
75
+ return (
76
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
77
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
78
+ </LocaleProvider>
79
+ );
80
+ }
81
+
82
+ describe("TagFilter", () => {
83
+ test("idle → just the filter button, no chips, no clear", () => {
84
+ render(
85
+ <Wrapper>
86
+ <TagFilter entityName="note" screenId="note-list" />
87
+ </Wrapper>,
88
+ );
89
+ expect(screen.getByTestId("tag-filter-open")).toBeTruthy();
90
+ expect(screen.queryByTestId("tag-chip")).toBeNull();
91
+ expect(screen.queryByTestId("tag-filter-clear")).toBeNull();
92
+ });
93
+
94
+ test("picking a tag → narrows the list to its entity ids + shows the selected chip + clear", () => {
95
+ render(
96
+ <Wrapper>
97
+ <TagFilter entityName="note" screenId="note-list" />
98
+ </Wrapper>,
99
+ );
100
+
101
+ fireEvent.click(screen.getByTestId("picker-pick-t1"));
102
+
103
+ // urgent (t1) is assigned to n1 → list narrows to that id set
104
+ expect(setFilterSpy).toHaveBeenCalledWith("id", ["n1"]);
105
+ // the active selection is visible as a chip + a clear affordance
106
+ expect(screen.getByText("urgent")).toBeTruthy();
107
+ expect(screen.getByTestId("tag-filter-clear")).toBeTruthy();
108
+ });
109
+
110
+ test("clear → drops the filter and the chip", () => {
111
+ render(
112
+ <Wrapper>
113
+ <TagFilter entityName="note" screenId="note-list" />
114
+ </Wrapper>,
115
+ );
116
+ fireEvent.click(screen.getByTestId("picker-pick-t1"));
117
+ setFilterSpy.mockClear();
118
+
119
+ fireEvent.click(screen.getByTestId("tag-filter-clear"));
120
+
121
+ expect(setFilterSpy).toHaveBeenCalledWith("id", []);
122
+ expect(screen.queryByText("urgent")).toBeNull();
123
+ });
124
+ });
@@ -0,0 +1,94 @@
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 { render, screen } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { TagsQueries } from "../../constants";
11
+ import { defaultTranslations } from "../i18n";
12
+ import { TagsCell } from "../tags-cell";
13
+
14
+ type TagRow = { id: string; name: string; color?: string };
15
+ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
16
+
17
+ let catalogRows: readonly TagRow[] = [];
18
+ let assignmentRows: readonly AssignmentRow[] = [];
19
+
20
+ beforeEach(() => {
21
+ catalogRows = [];
22
+ assignmentRows = [];
23
+ });
24
+
25
+ const useQuerySpy = mock((type: string) => ({
26
+ data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
27
+ loading: false,
28
+ error: null,
29
+ refetch: mock(async () => {}),
30
+ }));
31
+
32
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
33
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
34
+ ...actual_renderer,
35
+ useQuery: useQuerySpy,
36
+ }));
37
+
38
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
39
+ return (
40
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
41
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
42
+ </LocaleProvider>
43
+ );
44
+ }
45
+
46
+ function renderCell(rowId: string): void {
47
+ render(
48
+ <Wrapper>
49
+ <TagsCell value={rowId} row={{ id: rowId }} column={{ field: "tags" }} />
50
+ </Wrapper>,
51
+ );
52
+ }
53
+
54
+ describe("TagsCell", () => {
55
+ test("renders a chip per tag assigned to the row's id, joined to the catalog", () => {
56
+ catalogRows = [
57
+ { id: "t1", name: "important", color: "#ef4444" },
58
+ { id: "t2", name: "project-x", color: "#3b82f6" },
59
+ ];
60
+ assignmentRows = [
61
+ { tagId: "t1", entityType: "note", entityId: "n1" },
62
+ { tagId: "t2", entityType: "note", entityId: "n1" },
63
+ { tagId: "t1", entityType: "note", entityId: "n2" }, // a different row
64
+ ];
65
+
66
+ renderCell("n1");
67
+
68
+ expect(screen.getByTestId("tags-cell")).toBeTruthy();
69
+ expect(screen.getByText("important")).toBeTruthy();
70
+ expect(screen.getByText("project-x")).toBeTruthy();
71
+ });
72
+
73
+ test("renders nothing when the row has no assigned tags", () => {
74
+ catalogRows = [{ id: "t1", name: "important" }];
75
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "other-row" }];
76
+
77
+ renderCell("n1");
78
+
79
+ expect(screen.queryByTestId("tags-cell")).toBeNull();
80
+ });
81
+
82
+ test("renders nothing for an empty row id", () => {
83
+ catalogRows = [{ id: "t1", name: "important" }];
84
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "n1" }];
85
+
86
+ render(
87
+ <Wrapper>
88
+ <TagsCell value="" row={{}} column={{ field: "tags" }} />
89
+ </Wrapper>,
90
+ );
91
+
92
+ expect(screen.queryByTestId("tags-cell")).toBeNull();
93
+ });
94
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
4
  import {
5
+ TAGS_COLUMN_RENDERER_NAME,
5
6
  TAGS_FEATURE_NAME,
6
7
  TAGS_FILTER_EXTENSION_NAME,
7
8
  TAGS_SCREEN_ID,
@@ -11,6 +12,7 @@ import { defaultTranslations } from "./i18n";
11
12
  import { TagFilter } from "./tag-filter";
12
13
  import { TagManager } from "./tag-manager";
13
14
  import { TagSection } from "./tag-section";
15
+ import { TagsCell } from "./tags-cell";
14
16
 
15
17
  export function tagsClient(): ClientFeatureDefinition {
16
18
  return {
@@ -20,6 +22,10 @@ export function tagsClient(): ClientFeatureDefinition {
20
22
  // Header-slot tag filter for any entityList toolbar.
21
23
  [TAGS_FILTER_EXTENSION_NAME]: TagFilter,
22
24
  },
25
+ // Inline tag chips on any entityList row, via a labeled virtual column.
26
+ columnRenderers: {
27
+ [TAGS_COLUMN_RENDERER_NAME]: TagsCell,
28
+ },
23
29
  // Standalone Tags management screen (custom screen → TagManager).
24
30
  components: {
25
31
  [TAGS_SCREEN_ID]: TagManager,
@@ -30,7 +30,7 @@ export const defaultTranslations: TranslationsByLocale = {
30
30
  "tags.picker.title": "Tags",
31
31
  "tags.picker.done": "Fertig",
32
32
  "tags.filter.label": "Nach Tag filtern",
33
- "tags.filter.active": "Tags: {count}",
33
+ "tags.filter.clear": "Filter zurücksetzen",
34
34
  },
35
35
  en: {
36
36
  "tags.section.createMode": "Save the entity first to add tags.",
@@ -55,6 +55,6 @@ export const defaultTranslations: TranslationsByLocale = {
55
55
  "tags.picker.title": "Tags",
56
56
  "tags.picker.done": "Done",
57
57
  "tags.filter.label": "Filter by tag",
58
- "tags.filter.active": "Tags: {count}",
58
+ "tags.filter.clear": "Clear",
59
59
  },
60
60
  };
@@ -1,5 +1,6 @@
1
1
  // @runtime client
2
2
  export {
3
+ TAGS_COLUMN_RENDERER_NAME,
3
4
  TAGS_FILTER_EXTENSION_NAME,
4
5
  TAGS_SCREEN_ID,
5
6
  TAGS_SECTION_EXTENSION_NAME,
@@ -13,3 +14,4 @@ export { TagFilter } from "./tag-filter";
13
14
  export { TagManager } from "./tag-manager";
14
15
  export { TagPicker } from "./tag-picker";
15
16
  export { TagSection } from "./tag-section";
17
+ export { TagsCell } from "./tags-cell";