@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.25.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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  4. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  5. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  6. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  7. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  8. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  9. package/src/custom-fields/constants.ts +8 -7
  10. package/src/custom-fields/db/queries/projection.ts +13 -5
  11. package/src/custom-fields/db/queries/retention.ts +20 -6
  12. package/src/custom-fields/executor.ts +10 -0
  13. package/src/custom-fields/feature.ts +32 -39
  14. package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
  15. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  16. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  17. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  18. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  19. package/src/custom-fields/lib/field-access.ts +4 -0
  20. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  21. package/src/custom-fields/run-retention.ts +6 -5
  22. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  23. package/src/custom-fields/web/client-plugin.tsx +2 -0
  24. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  25. package/src/custom-fields/web/i18n.ts +30 -0
  26. package/src/custom-fields/wire-for-entity.ts +1 -1
  27. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  28. package/src/feature-toggles/handlers/set.write.ts +13 -8
  29. package/src/secrets/feature.ts +4 -11
  30. package/src/subscription-stripe/feature.ts +2 -2
  31. package/src/template-resolver/handlers/list.query.ts +12 -10
  32. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  33. package/src/tenant/seeding.ts +3 -3
  34. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  35. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  36. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  37. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  38. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  39. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  40. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
@@ -25,15 +25,29 @@ export async function selectHostRowsWithCustomFields(
25
25
  return Array.isArray(rowsResult) ? rowsResult : [];
26
26
  }
27
27
 
28
- export async function updateHostRowCustomFields(
28
+ export async function applyRetentionRemovals(
29
29
  db: DbRunner,
30
30
  tableName: string,
31
- customFields: Record<string, unknown>,
31
+ deleteKeys: readonly string[],
32
+ anonymizeKeys: readonly string[],
32
33
  rowId: string,
33
34
  ): Promise<void> {
34
35
  const quoted = `"${tableName.replace(/"/g, '""')}"`;
35
- await asRawClient(db).unsafe(`UPDATE ${quoted} SET custom_fields = $1::jsonb WHERE id = $2`, [
36
- customFields,
37
- rowId,
38
- ]);
36
+ // Atomic per-row jsonb edit instead of read-modify-write of the whole
37
+ // object: delete-keys are dropped (`- $1::text[]`), anonymize-keys are set
38
+ // to JSON null via a merge patch. Operating on the live row value preserves
39
+ // a concurrent set-custom-field on any *other* key — no lost update.
40
+ await asRawClient(db).unsafe(
41
+ `UPDATE ${quoted} SET custom_fields = CASE
42
+ WHEN jsonb_typeof(custom_fields) = 'object' THEN
43
+ (custom_fields - $1::text[])
44
+ || COALESCE(
45
+ (SELECT jsonb_object_agg(k, 'null'::jsonb) FROM unnest($2::text[]) AS k),
46
+ '{}'::jsonb
47
+ )
48
+ ELSE custom_fields
49
+ END
50
+ WHERE id = $3`,
51
+ [deleteKeys, anonymizeKeys, rowId],
52
+ );
39
53
  }
@@ -0,0 +1,10 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { fieldDefinitionEntity } from "./entity";
3
+
4
+ // Single field-definition executor shared by the four define/delete handlers.
5
+ // createEntityExecutor is side-effect-free; instantiating it once keeps the
6
+ // table+executor pair in one place instead of rebuilding it per handler module.
7
+ export const { executor: fieldDefinitionExecutor } = createEntityExecutor(
8
+ "field-definition",
9
+ fieldDefinitionEntity,
10
+ );
@@ -37,7 +37,12 @@
37
37
  // Value-Validation gegen serializedField → set-custom-field via fieldToZod —
38
38
  // alle erledigt.)
39
39
 
40
- import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
40
+ import {
41
+ defineEntityListHandler,
42
+ defineFeature,
43
+ type FeatureRegistrar,
44
+ type WriteHandlerDef,
45
+ } from "@cosmicdrift/kumiko-framework/engine";
41
46
  import { z } from "zod";
42
47
  import {
43
48
  CUSTOM_FIELD_CLEARED_EVENT,
@@ -71,12 +76,14 @@ const fieldDefinitionDeletedSchema = z.object({
71
76
  tenantId: z.string().optional(),
72
77
  });
73
78
 
74
- // Singleton feature-definition mit typed exports. Handler + wire-for-entity
75
- // importieren diesen `customFieldsFeature` und greifen lazy in ihrer
76
- // runtime-arrow-fn auf `.exports.<event>.name` zu der module-cycle
77
- // (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
78
- // kein top-level-access stattfindet.
79
- export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
79
+ // Shared registration body for both the singleton and the quota-variant.
80
+ // Only the define-tenant-field handler differs (quota baked in or not); every
81
+ // other entity/event/handler is registered identically here so a new
82
+ // event/handler can never silently miss the quota variant.
83
+ function registerCustomFields(
84
+ r: FeatureRegistrar<typeof CUSTOM_FIELDS_FEATURE_NAME>,
85
+ defineTenantHandler: WriteHandlerDef,
86
+ ) {
80
87
  r.entity("field-definition", fieldDefinitionEntity);
81
88
 
82
89
  // Event-types — qualified als "custom-fields:event:<short-name>".
@@ -101,7 +108,7 @@ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r)
101
108
  r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
102
109
 
103
110
  // Definition-CRUD handlers (B1).
104
- r.writeHandler(defineTenantFieldHandler);
111
+ r.writeHandler(defineTenantHandler);
105
112
  r.writeHandler(defineSystemFieldHandler);
106
113
  r.writeHandler(deleteTenantFieldHandler);
107
114
  r.writeHandler(deleteSystemFieldHandler);
@@ -116,7 +123,16 @@ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r)
116
123
  );
117
124
 
118
125
  return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
119
- });
126
+ }
127
+
128
+ // Singleton feature-definition mit typed exports. Handler + wire-for-entity
129
+ // importieren diesen `customFieldsFeature` und greifen lazy in ihrer
130
+ // runtime-arrow-fn auf `.exports.<event>.name` zu — der module-cycle
131
+ // (feature.ts -> handlers/*.write.ts -> feature.ts) löst sich auf weil
132
+ // kein top-level-access stattfindet.
133
+ export const customFieldsFeature = defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
134
+ registerCustomFields(r, defineTenantFieldHandler),
135
+ );
120
136
 
121
137
  // Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
122
138
  // host-apps) nutzen weiterhin `createCustomFieldsFeature()`. Returnt den
@@ -133,34 +149,11 @@ export function createCustomFieldsFeature(
133
149
  if (opts.fieldDefinitionLimitPerTenant === undefined) {
134
150
  return customFieldsFeature;
135
151
  }
136
- return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) => {
137
- r.entity("field-definition", fieldDefinitionEntity);
138
-
139
- const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema);
140
- const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema);
141
- const fieldDefinitionDeletedEvent = r.defineEvent(
142
- FIELD_DEFINITION_DELETED_EVENT,
143
- fieldDefinitionDeletedSchema,
144
- );
145
-
146
- r.extendsRegistrar(CUSTOM_FIELDS_EXTENSION, {});
147
-
148
- r.writeHandler(
149
- createDefineTenantFieldHandler({
150
- fieldDefinitionLimitPerTenant: opts.fieldDefinitionLimitPerTenant,
151
- }),
152
- );
153
- r.writeHandler(defineSystemFieldHandler);
154
- r.writeHandler(deleteTenantFieldHandler);
155
- r.writeHandler(deleteSystemFieldHandler);
156
-
157
- r.writeHandler(setCustomFieldHandler);
158
- r.writeHandler(clearCustomFieldHandler);
159
-
160
- r.queryHandler(
161
- defineEntityListHandler("field-definition", fieldDefinitionEntity, tenantAdminAccess),
162
- );
163
-
164
- return { setEvent, clearedEvent, fieldDefinitionDeletedEvent };
165
- });
152
+ const limit = opts.fieldDefinitionLimitPerTenant;
153
+ return defineFeature(CUSTOM_FIELDS_FEATURE_NAME, (r) =>
154
+ registerCustomFields(
155
+ r,
156
+ createDefineTenantFieldHandler({ fieldDefinitionLimitPerTenant: limit }),
157
+ ),
158
+ );
166
159
  }
@@ -7,7 +7,11 @@ import { checkFieldAccessForWrite } from "../lib/field-access";
7
7
  export const clearCustomFieldPayloadSchema = z.object({
8
8
  entityName: z.string().min(1).max(64),
9
9
  entityId: z.string().min(1),
10
- fieldKey: z.string().min(1).max(64),
10
+ fieldKey: z
11
+ .string()
12
+ .min(1)
13
+ .max(64)
14
+ .regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
11
15
  });
12
16
  export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSchema>;
13
17
 
@@ -1,14 +1,9 @@
1
- import {
2
- createEntityExecutor,
3
- SYSTEM_TENANT_ID,
4
- type WriteHandlerDef,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
- import { fieldDefinitionEntity } from "../entity";
3
+ import { fieldDefinitionExecutor } from "../executor";
4
+ import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
8
5
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
9
6
 
10
- const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
11
-
12
7
  // define-system-field — SystemAdmin definiert eine system-weite Custom-Field-
13
8
  // Definition die für ALLE Tenants gilt. tenantId wird auf SYSTEM_TENANT_ID
14
9
  // gesetzt (NICHT vom Caller — SystemAdmin's event.user.tenantId würde sonst
@@ -38,20 +33,8 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
38
33
  // — the row lives in the system-scope-stream.
39
34
  const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
40
35
 
41
- return executor.create(
42
- {
43
- id: aggregateId,
44
- entityName: payload.entityName,
45
- fieldKey: payload.fieldKey,
46
- type: payload.serializedField.type,
47
- required: payload.required,
48
- searchable: payload.searchable,
49
- displayOrder: payload.displayOrder,
50
- serializedField: JSON.stringify({
51
- ...payload.serializedField,
52
- label: payload.label,
53
- }),
54
- },
36
+ return fieldDefinitionExecutor.create(
37
+ { id: aggregateId, ...buildFieldDefinitionColumns(payload) },
55
38
  systemUser,
56
39
  ctx.db,
57
40
  );
@@ -1,16 +1,11 @@
1
- import {
2
- createEntityExecutor,
3
- isSystemTenant,
4
- type WriteHandlerDef,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
7
3
  import { fieldDefinitionAggregateId } from "../aggregate-id";
8
- import { fieldDefinitionEntity } from "../entity";
4
+ import { fieldDefinitionExecutor } from "../executor";
5
+ import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
9
6
  import { countTenantFieldDefinitions } from "../lib/quota";
10
7
  import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
11
8
 
12
- const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
13
-
14
9
  // define-tenant-field — TenantAdmin definiert eine Custom-Field-Definition
15
10
  // für seinen eigenen Tenant. tenantId wird automatisch aus event.user.tenantId
16
11
  // abgeleitet (NICHT vom Caller setzbar — verhindert Cross-Tenant-Mutation).
@@ -32,8 +27,15 @@ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEnt
32
27
  // `unprocessable` + reason `cap_exceeded` BEFORE attempting the insert.
33
28
  // The factory below closes over the limit; the legacy const-export keeps
34
29
  // behavior unchanged for callers who didn't opt into a limit.
30
+ //
31
+ // Soft cap, not a hard guarantee: the count-then-insert is not serialized, so
32
+ // N concurrent defines with distinct fieldKeys can each read `current < limit`
33
+ // and overshoot by up to N. Acceptable here — defining fields is an admin-only,
34
+ // low-frequency action and the limit is not wired to billing/tier enforcement.
35
+ // If an exact cap is ever needed, serialize via advisory lock or a count
36
+ // constraint at the insert.
35
37
  export interface DefineTenantFieldOptions {
36
- /** Hard quota — `>= limit` definitions per tenant rejects further defines. */
38
+ /** Soft cap — `>= limit` definitions per tenant rejects further defines (see header: concurrent defines may overshoot). */
37
39
  readonly fieldDefinitionLimitPerTenant?: number;
38
40
  }
39
41
 
@@ -73,20 +75,8 @@ export function createDefineTenantFieldHandler(
73
75
  payload.fieldKey,
74
76
  );
75
77
 
76
- return executor.create(
77
- {
78
- id: aggregateId,
79
- entityName: payload.entityName,
80
- fieldKey: payload.fieldKey,
81
- type: payload.serializedField.type,
82
- required: payload.required,
83
- searchable: payload.searchable,
84
- displayOrder: payload.displayOrder,
85
- serializedField: JSON.stringify({
86
- ...payload.serializedField,
87
- label: payload.label,
88
- }),
89
- },
78
+ return fieldDefinitionExecutor.create(
79
+ { id: aggregateId, ...buildFieldDefinitionColumns(payload) },
90
80
  event.user,
91
81
  ctx.db,
92
82
  );
@@ -1,15 +1,9 @@
1
- import {
2
- createEntityExecutor,
3
- SYSTEM_TENANT_ID,
4
- type WriteHandlerDef,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
- import { fieldDefinitionEntity } from "../entity";
3
+ import { fieldDefinitionExecutor } from "../executor";
8
4
  import { customFieldsFeature } from "../feature";
9
5
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
10
6
 
11
- const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
12
-
13
7
  // delete-system-field — SystemAdmin entfernt eine system-weite Field-
14
8
  // Definition. Konsequenz: KEIN Tenant kann mehr neue Werte dafür setzen,
15
9
  // existing Werte in read_<entity>.customFields jsonb bleiben aber bestehen
@@ -29,7 +23,7 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
29
23
  );
30
24
 
31
25
  const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
32
- const result = await executor.delete({ id: aggregateId }, systemUser, ctx.db);
26
+ const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, systemUser, ctx.db);
33
27
 
34
28
  // Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
35
29
  // ihrer customFields jsonb. Im selben TX = atomic.
@@ -1,15 +1,9 @@
1
- import {
2
- createEntityExecutor,
3
- isSystemTenant,
4
- type WriteHandlerDef,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
7
- import { fieldDefinitionEntity } from "../entity";
3
+ import { fieldDefinitionExecutor } from "../executor";
8
4
  import { customFieldsFeature } from "../feature";
9
5
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
10
6
 
11
- const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
12
-
13
7
  // delete-tenant-field — TenantAdmin löscht eigene Field-Definition.
14
8
  // Spec-Promise (Plan-Doc v2 "wie Entity-Delete"): Events bleiben im event-
15
9
  // store für Audit-Trail. Read-Projection-Row wird entfernt. B2 wird die
@@ -36,7 +30,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
36
30
 
37
31
  const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
38
32
 
39
- const result = await executor.delete({ id: aggregateId }, event.user, ctx.db);
33
+ const result = await fieldDefinitionExecutor.delete({ id: aggregateId }, event.user, ctx.db);
40
34
 
41
35
  // Emit cascade-cleanup-Event NACH erfolgreichem Delete. host-entity-MSPs
42
36
  // (registriert via wireCustomFieldsFor) konsumieren das + entfernen orphan
@@ -34,6 +34,10 @@ export async function loadFieldDefinition(
34
34
 
35
35
  // Pure access-check on an already-loaded definition. Returns the required
36
36
  // roles when the caller is denied, or `null` when access is allowed.
37
+ //
38
+ // Contract: `fieldAccess.write` entries MUST be verbatim membership-system role
39
+ // names (e.g. "TenantAdmin", not "Admin") — match is exact like framework-wide
40
+ // `engine/access.ts`; no normalization, so a drifted name denies silently.
37
41
  export function fieldWriteAccessDeniedRoles(
38
42
  field: SerializedFieldShape | null,
39
43
  userRoles: ReadonlyArray<string>,
@@ -0,0 +1,33 @@
1
+ import type { DefineFieldPayload } from "../schemas";
2
+
3
+ export interface FieldDefinitionColumns {
4
+ readonly entityName: string;
5
+ readonly fieldKey: string;
6
+ readonly type: string;
7
+ readonly required: boolean;
8
+ readonly searchable: boolean;
9
+ readonly displayOrder: number;
10
+ readonly serializedField: string;
11
+ }
12
+
13
+ // The required/searchable/displayOrder columns are a denormalized projection of
14
+ // serializedField — derive, don't default. Zod gives top-level `required` a
15
+ // `.default(false)`, so "caller omitted it" is indistinguishable from `false`
16
+ // post-parse; serializedField-present therefore wins to preserve caller intent.
17
+ export function buildFieldDefinitionColumns(payload: DefineFieldPayload): FieldDefinitionColumns {
18
+ const sf = payload.serializedField;
19
+ const required = typeof sf["required"] === "boolean" ? sf["required"] : payload.required;
20
+ const searchable = typeof sf["searchable"] === "boolean" ? sf["searchable"] : payload.searchable;
21
+ const displayOrder =
22
+ typeof sf["displayOrder"] === "number" ? sf["displayOrder"] : payload.displayOrder;
23
+
24
+ return {
25
+ entityName: payload.entityName,
26
+ fieldKey: payload.fieldKey,
27
+ type: sf.type,
28
+ required,
29
+ searchable,
30
+ displayOrder,
31
+ serializedField: JSON.stringify({ ...sf, label: payload.label }),
32
+ };
33
+ }
@@ -18,9 +18,9 @@
18
18
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
19
19
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
20
  import {
21
+ applyRetentionRemovals,
21
22
  selectFieldDefinitionsWithSerialized,
22
23
  selectHostRowsWithCustomFields,
23
- updateHostRowCustomFields,
24
24
  } from "./db/queries/retention";
25
25
  import { parseSerializedField } from "./lib/parse-serialized-field";
26
26
 
@@ -122,17 +122,18 @@ export async function runCustomFieldsRetention(
122
122
  // skip: nothing on this row aged out — no UPDATE needed.
123
123
  if (removals.length === 0) continue;
124
124
 
125
- const mutated: Record<string, unknown> = { ...row.customFields };
125
+ const deleteKeys: string[] = [];
126
+ const anonymizeKeys: string[] = [];
126
127
  for (const { key, strategy } of removals) {
127
128
  if (strategy === "delete") {
128
- delete mutated[key];
129
+ deleteKeys.push(key);
129
130
  } else {
130
- mutated[key] = null;
131
+ anonymizeKeys.push(key);
131
132
  }
132
133
  removalsByFieldKey[key] = (removalsByFieldKey[key] ?? 0) + 1;
133
134
  }
134
135
 
135
- await updateHostRowCustomFields(opts.db, tableName, mutated, row.id);
136
+ await applyRetentionRemovals(opts.db, tableName, deleteKeys, anonymizeKeys, row.id);
136
137
  rowsUpdated++;
137
138
  }
138
139
 
@@ -8,6 +8,7 @@ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
8
  import { fireEvent, render, screen } from "@testing-library/react";
9
9
  import type { ReactNode } from "react";
10
10
  import { CustomFieldsFormSection } from "../custom-fields-form-section";
11
+ import { defaultTranslations } from "../i18n";
11
12
 
12
13
  type FieldRow = {
13
14
  id: string;
@@ -21,6 +22,13 @@ type FieldRow = {
21
22
  const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
22
23
  let mockedQueryRows: readonly FieldRow[] = [];
23
24
 
25
+ const useQuerySpy = mock((_type: string, _params: unknown, _options?: { enabled?: boolean }) => ({
26
+ data: { rows: mockedQueryRows },
27
+ loading: false,
28
+ error: null,
29
+ refetch: mock(),
30
+ }));
31
+
24
32
  const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
25
33
  mock.module("@cosmicdrift/kumiko-renderer", () => ({
26
34
  ...actual_renderer,
@@ -29,17 +37,12 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
29
37
  query: mock(),
30
38
  batch: mock(),
31
39
  })),
32
- useQuery: mock(() => ({
33
- data: { rows: mockedQueryRows },
34
- loading: false,
35
- error: null,
36
- refetch: mock(),
37
- })),
40
+ useQuery: useQuerySpy,
38
41
  }));
39
42
 
40
43
  function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
41
44
  return (
42
- <LocaleProvider resolver={createStaticLocaleResolver()}>
45
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
43
46
  <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
44
47
  </LocaleProvider>
45
48
  );
@@ -107,8 +110,9 @@ describe("CustomFieldsFormSection", () => {
107
110
  });
108
111
  });
109
112
 
110
- test("shows create-mode banner when entityId is null", () => {
113
+ test("shows create-mode banner when entityId is null and skips the fieldDefinition query", () => {
111
114
  mockedQueryRows = [];
115
+ useQuerySpy.mockClear();
112
116
 
113
117
  render(
114
118
  <Wrapper>
@@ -116,8 +120,14 @@ describe("CustomFieldsFormSection", () => {
116
120
  </Wrapper>,
117
121
  );
118
122
 
119
- expect(screen.getByTestId("custom-fields-form-create-mode")).toBeTruthy();
123
+ const banner = screen.getByTestId("custom-fields-form-create-mode");
124
+ expect(banner).toBeTruthy();
120
125
  expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
126
+ // The banner shows the translated string, not the raw i18n key.
127
+ expect(banner.textContent).toBe("Save the entity first to add custom field values.");
128
+ // create-mode discards the query result via the early return, so the
129
+ // fetch-on-mount must be disabled — no wasted server roundtrip.
130
+ expect(useQuerySpy.mock.calls[0]?.[2]).toEqual({ enabled: false });
121
131
  });
122
132
 
123
133
  test("shows empty banner when no fieldDefinitions match entityName", () => {
@@ -138,7 +148,32 @@ describe("CustomFieldsFormSection", () => {
138
148
  </Wrapper>,
139
149
  );
140
150
 
141
- expect(screen.getByTestId("custom-fields-form-empty")).toBeTruthy();
151
+ const banner = screen.getByTestId("custom-fields-form-empty");
152
+ expect(banner).toBeTruthy();
142
153
  expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
154
+ // Translated + interpolated with the host entity name, not the raw key.
155
+ expect(banner.textContent).toBe('No custom fields defined for "component".');
156
+ });
157
+
158
+ test("save button renders the translated label, not the raw i18n key", () => {
159
+ mockedQueryRows = [
160
+ {
161
+ id: "f1",
162
+ entityName: "component",
163
+ fieldKey: "vendor",
164
+ type: "text",
165
+ required: false,
166
+ displayOrder: 1,
167
+ },
168
+ ];
169
+
170
+ render(
171
+ <Wrapper>
172
+ <CustomFieldsFormSection entityName="component" entityId="row-42" />
173
+ </Wrapper>,
174
+ );
175
+
176
+ const saveBtn = screen.getByTestId("custom-fields-form-save");
177
+ expect(saveBtn.textContent).toBe("Save custom fields");
143
178
  });
144
179
  });
@@ -14,6 +14,7 @@
14
14
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
15
15
  import { CUSTOM_FIELDS_FEATURE_NAME, CUSTOM_FIELDS_FORM_EXTENSION_NAME } from "../constants";
16
16
  import { CustomFieldsFormSection } from "./custom-fields-form-section";
17
+ import { defaultTranslations } from "./i18n";
17
18
 
18
19
  export function customFieldsClient(): ClientFeatureDefinition {
19
20
  return {
@@ -21,5 +22,6 @@ export function customFieldsClient(): ClientFeatureDefinition {
21
22
  extensionSectionComponents: {
22
23
  [CUSTOM_FIELDS_FORM_EXTENSION_NAME]: CustomFieldsFormSection,
23
24
  },
25
+ translations: defaultTranslations,
24
26
  };
25
27
  }
@@ -10,7 +10,12 @@
10
10
  // `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`
11
11
  // referenziert.
12
12
 
13
- import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
13
+ import {
14
+ useDispatcher,
15
+ usePrimitives,
16
+ useQuery,
17
+ useTranslation,
18
+ } from "@cosmicdrift/kumiko-renderer";
14
19
  import { type ReactNode, useState } from "react";
15
20
  import { CustomFieldsHandlers, CustomFieldsQueries } from "../constants";
16
21
 
@@ -35,8 +40,13 @@ export function CustomFieldsFormSection({
35
40
  readonly entityId: string | null;
36
41
  }): ReactNode {
37
42
  const { Banner, Button, Field, Input, Text } = usePrimitives();
43
+ const t = useTranslation();
38
44
  const dispatcher = useDispatcher();
39
- const query = useQuery<FieldDefinitionListResponse>(CustomFieldsQueries.fieldDefinitionList, {});
45
+ const query = useQuery<FieldDefinitionListResponse>(
46
+ CustomFieldsQueries.fieldDefinitionList,
47
+ {},
48
+ { enabled: entityId !== null },
49
+ );
40
50
  const [pending, setPending] = useState<Readonly<Record<string, string>>>({});
41
51
  const [saving, setSaving] = useState(false);
42
52
  const [errorKey, setErrorKey] = useState<string | null>(null);
@@ -44,21 +54,21 @@ export function CustomFieldsFormSection({
44
54
  if (entityId === null) {
45
55
  return (
46
56
  <Banner variant="info" testId="custom-fields-form-create-mode">
47
- <Text>Save the entity first to add custom field values.</Text>
57
+ <Text>{t("custom-fields.form.createMode")}</Text>
48
58
  </Banner>
49
59
  );
50
60
  }
51
61
  if (query.loading && query.data === null) {
52
62
  return (
53
63
  <Banner variant="loading" testId="custom-fields-form-loading">
54
- <Text>Loading…</Text>
64
+ <Text>{t("custom-fields.form.loading")}</Text>
55
65
  </Banner>
56
66
  );
57
67
  }
58
68
  if (query.error) {
59
69
  return (
60
70
  <Banner variant="error" testId="custom-fields-form-error">
61
- <Text>{query.error.i18nKey}</Text>
71
+ <Text>{t(query.error.i18nKey, query.error.i18nParams)}</Text>
62
72
  </Banner>
63
73
  );
64
74
  }
@@ -71,7 +81,7 @@ export function CustomFieldsFormSection({
71
81
  if (matchingFields.length === 0) {
72
82
  return (
73
83
  <Banner variant="info" testId="custom-fields-form-empty">
74
- <Text>No custom fields defined for "{entityName}".</Text>
84
+ <Text>{t("custom-fields.form.empty", { entityName })}</Text>
75
85
  </Banner>
76
86
  );
77
87
  }
@@ -91,7 +101,7 @@ export function CustomFieldsFormSection({
91
101
  value,
92
102
  });
93
103
  if (!result.isSuccess) {
94
- setErrorKey(result.error?.i18nKey ?? "custom-fields:save-failed");
104
+ setErrorKey(result.error?.i18nKey ?? "custom-fields.errors.saveFailed");
95
105
  return;
96
106
  }
97
107
  }
@@ -123,11 +133,11 @@ export function CustomFieldsFormSection({
123
133
  disabled={saving || !dirty}
124
134
  testId="custom-fields-form-save"
125
135
  >
126
- {saving ? "Saving…" : "Save custom fields"}
136
+ {saving ? t("custom-fields.form.saving") : t("custom-fields.form.save")}
127
137
  </Button>
128
138
  {errorKey !== null && (
129
139
  <Banner variant="error" testId="custom-fields-form-save-error">
130
- <Text>{errorKey}</Text>
140
+ <Text>{t(errorKey)}</Text>
131
141
  </Banner>
132
142
  )}
133
143
  </div>
@@ -0,0 +1,30 @@
1
+ // @runtime client
2
+ // Default translation bundle for the custom-fields UI. customFieldsClient()
3
+ // hangs it into the LocaleProvider as a fallback bundle — apps override
4
+ // individual keys via `customFieldsClient({ translations: { de: { ... } } })`.
5
+ //
6
+ // Keys follow `custom-fields.<area>.<slug>`. `custom-fields.errors.*` mirror
7
+ // the i18nKeys the server-side handlers emit (e.g. `custom-fields:save-failed`).
8
+
9
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
10
+
11
+ export const defaultTranslations: TranslationsByLocale = {
12
+ de: {
13
+ "custom-fields.form.createMode": "Speichere zuerst den Eintrag, um Custom-Felder zu setzen.",
14
+ "custom-fields.form.loading": "Lädt…",
15
+ "custom-fields.form.empty": 'Keine Custom-Felder für "{entityName}" definiert.',
16
+ "custom-fields.form.save": "Custom-Felder speichern",
17
+ "custom-fields.form.saving": "Speichert…",
18
+ "custom-fields.errors.loadFailed": "Custom-Felder konnten nicht geladen werden.",
19
+ "custom-fields.errors.saveFailed": "Speichern fehlgeschlagen.",
20
+ },
21
+ en: {
22
+ "custom-fields.form.createMode": "Save the entity first to add custom field values.",
23
+ "custom-fields.form.loading": "Loading…",
24
+ "custom-fields.form.empty": 'No custom fields defined for "{entityName}".',
25
+ "custom-fields.form.save": "Save custom fields",
26
+ "custom-fields.form.saving": "Saving…",
27
+ "custom-fields.errors.loadFailed": "Could not load custom fields.",
28
+ "custom-fields.errors.saveFailed": "Save failed.",
29
+ },
30
+ };
@@ -171,8 +171,8 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
171
171
  const customFields = row["customFields"];
172
172
  if (customFields && typeof customFields === "object" && !Array.isArray(customFields)) {
173
173
  return {
174
- ...row,
175
174
  ...(customFields as Record<string, unknown>), // @cast-boundary db-row jsonb runtime-untyped
175
+ ...row, // base fields win: a custom fieldKey named `id`/`name` must not shadow the real column
176
176
  };
177
177
  }
178
178
  return row;