@cosmicdrift/kumiko-bundled-features 0.21.0 → 0.22.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -26,6 +26,7 @@
26
26
  "./tier-engine": "./src/tier-engine/index.ts",
27
27
  "./cap-counter": "./src/cap-counter/index.ts",
28
28
  "./custom-fields": "./src/custom-fields/index.ts",
29
+ "./custom-fields/web": "./src/custom-fields/web/index.ts",
29
30
  "./billing-foundation": "./src/billing-foundation/index.ts",
30
31
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
31
32
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
@@ -72,13 +73,10 @@
72
73
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
73
74
  },
74
75
  "dependencies": {
75
- "@aws-sdk/client-s3": "^3.1045.0",
76
- "@aws-sdk/lib-storage": "^3.1045.0",
77
- "@aws-sdk/s3-request-presigner": "^3.1045.0",
78
- "@cosmicdrift/kumiko-dispatcher-live": "0.14.0",
79
- "@cosmicdrift/kumiko-framework": "0.14.0",
80
- "@cosmicdrift/kumiko-renderer": "0.14.0",
81
- "@cosmicdrift/kumiko-renderer-web": "0.14.0",
76
+ "@cosmicdrift/kumiko-dispatcher-live": "0.21.0",
77
+ "@cosmicdrift/kumiko-framework": "0.21.0",
78
+ "@cosmicdrift/kumiko-renderer": "0.21.0",
79
+ "@cosmicdrift/kumiko-renderer-web": "0.21.0",
82
80
  "@mollie/api-client": "^4.5.0",
83
81
  "@node-rs/argon2": "^2.0.2",
84
82
  "@types/nodemailer": "^8.0.0",
@@ -335,6 +335,64 @@ describe("custom-fields integration — value validation (Builder-Reuse)", () =>
335
335
  expect(await countSetEvents(id)).toBe(0);
336
336
  });
337
337
 
338
+ test("required-text accepts empty + over-maxLength strings (constraint-keys stripped)", async () => {
339
+ const id = "bbbbbbbb-bbbb-4000-8000-00000000000b";
340
+ // serializedField carries required + maxLength + format — value-schema
341
+ // strips these before fieldToZod, so the runtime schema collapses to a
342
+ // bare z.string(). Required-on-set + length/format-enforcement remain
343
+ // out-of-scope (Plan-Doc "Stammfeld-Identität").
344
+ await stack.http.writeOk(
345
+ "custom-fields:write:define-tenant-field",
346
+ {
347
+ entityName: "property",
348
+ fieldKey: "note",
349
+ serializedField: { type: "text", required: true, maxLength: 5, format: "email" },
350
+ required: false,
351
+ searchable: false,
352
+ displayOrder: 0,
353
+ },
354
+ admin,
355
+ );
356
+ await createProperty(id, "TypeOnly");
357
+
358
+ await setCustomField("property", id, "note", "");
359
+ await setCustomField("property", id, "note", "not-an-email-and-way-too-long");
360
+ await stack.eventDispatcher?.runOnce();
361
+
362
+ expect(await rawCustomFields(id)).toMatchObject({ note: "not-an-email-and-way-too-long" });
363
+ });
364
+
365
+ test("default-having field validates as plain type (default-key stripped)", async () => {
366
+ const id = "cccccccc-cccc-4000-8000-00000000000c";
367
+ // Pre-fix: fieldToZod folded `default` into `.default(...)`. Combined with
368
+ // emitting `payload.value` (not `parsed.data`) the in-code path would skip
369
+ // the type-check for a missing value. value-schema now strips `default`
370
+ // before fieldToZod so the runtime schema is bare `z.number()` — matching
371
+ // values still pass, type-mismatches still 422.
372
+ await stack.http.writeOk(
373
+ "custom-fields:write:define-tenant-field",
374
+ {
375
+ entityName: "property",
376
+ fieldKey: "score",
377
+ serializedField: { type: "number", default: 0 },
378
+ required: false,
379
+ searchable: false,
380
+ displayOrder: 0,
381
+ },
382
+ admin,
383
+ );
384
+ await createProperty(id, "DefaultStripped");
385
+
386
+ const err = await setErr(id, "score", "not-a-number");
387
+ expect(err.httpStatus).toBe(422);
388
+ expect(err.details).toMatchObject({ reason: "custom_field_value_invalid" });
389
+ expect(await countSetEvents(id)).toBe(0);
390
+
391
+ await setCustomField("property", id, "score", 7);
392
+ await stack.eventDispatcher?.runOnce();
393
+ expect(await rawCustomFields(id)).toMatchObject({ score: 7 });
394
+ });
395
+
338
396
  test("embedded field rejects a non-object value → 422, no event", async () => {
339
397
  const id = "aaaaaaaa-aaaa-4000-8000-00000000000a";
340
398
  // embedded carries a sub-field schema in serializedField — exercises
@@ -1,3 +1,4 @@
1
+ // @runtime client
1
2
  // custom-fields bundle constants — feature-name + event-names.
2
3
  //
3
4
  // Spec: kumiko-platform/docs/plans/features/custom-fields.md
@@ -5,6 +6,28 @@
5
6
 
6
7
  export const CUSTOM_FIELDS_FEATURE_NAME = "custom-fields";
7
8
 
9
+ // Qualified handler names (QN format: scope:type:name). Mirror text-
10
+ // content's Handler/Queries object pattern — Clients (z.B. die
11
+ // CustomFieldsFormSection web-component) referenzieren über das Object
12
+ // statt magic-strings.
13
+ export const CustomFieldsHandlers = {
14
+ defineTenantField: "custom-fields:write:define-tenant-field",
15
+ defineSystemField: "custom-fields:write:define-system-field",
16
+ deleteTenantField: "custom-fields:write:delete-tenant-field",
17
+ deleteSystemField: "custom-fields:write:delete-system-field",
18
+ setCustomField: "custom-fields:write:set-custom-field",
19
+ clearCustomField: "custom-fields:write:clear-custom-field",
20
+ } as const;
21
+
22
+ export const CustomFieldsQueries = {
23
+ fieldDefinitionList: "custom-fields:query:field-definition:list",
24
+ } as const;
25
+
26
+ // Name unter dem die web-component im ExtensionSectionsProvider
27
+ // registriert wird — Apps referenzieren ihn im Screen-Schema via
28
+ // `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`.
29
+ export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
30
+
8
31
  // Event-Type-Names (qualified at registration via r.defineEvent — final
9
32
  // names are `custom-fields:event:field-definition-created` etc.).
10
33
  // Short-names MUST be in kebab-case (no dots): qualifyEntityName runs toKebab
@@ -32,11 +32,15 @@ export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
32
32
  // 3. Value-Validation (Builder-Reuse): der Wert wird gegen das aus
33
33
  // serializedField rehydrierte fieldToZod-Schema geparst. Type-Mismatch →
34
34
  // 422, KEIN Event entsteht (Projection bleibt typed — Plan-Doc
35
- // Stammfeld-Identität). `value: null` auf einem typisierten Feld ist ein
36
- // Type-Mismatch → 422; zum Entfernen eines Werts dient clear-custom-field.
35
+ // Stammfeld-Identität). `value: null` ODER `value: undefined` auf einem
36
+ // typisierten Feld sind Type-Mismatches → 422; zum Entfernen eines Werts
37
+ // dient clear-custom-field.
37
38
  //
38
- // Scope: NUR Type-Validation. Required-on-set + Default-Application sind
39
- // out-of-scope (Plan-Doc "Stammfeld-Identität" listet sie als eigene Zeilen).
39
+ // Scope: ECHTE type-only Validation. value-schema.ts strippt `required`,
40
+ // `maxLength`, `format` und `default` aus dem serializedField bevor fieldToZod
41
+ // daraus ein Schema baut — wir prüfen nur die Type-Shape. Required-on-set,
42
+ // Default-Application und Length-/Format-Enforcement bleiben out-of-scope
43
+ // (Plan-Doc "Stammfeld-Identität" listet sie als eigene Zeilen).
40
44
  export const setCustomFieldHandler: WriteHandlerDef = {
41
45
  name: "set-custom-field",
42
46
  schema: setCustomFieldPayloadSchema,
@@ -4,29 +4,67 @@ import {
4
4
  fieldToZod,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import type { z } from "zod";
7
+ import { SUPPORTED_FIELD_TYPES } from "../constants";
7
8
 
8
9
  // Builds a Zod schema that validates a custom-field VALUE against its
9
10
  // fieldDefinition. Reuses the framework's `fieldToZod` (Builder-Reuse /
10
11
  // Stammfeld-Identität, Plan-Doc) — one field-type-schema source, no drift
11
12
  // between Stammfeld- and Custom-Field-validation.
12
13
  //
14
+ // Scope: type-shape only. fieldToZod also folds `required`, `maxLength`,
15
+ // `format`, and `default` into Zod refinements; we strip those before the
16
+ // call so set-custom-field rejects type-mismatches and only type-mismatches.
17
+ // Required-on-set, default-application, and length/format-enforcement remain
18
+ // out-of-scope per the Plan-Doc ("Stammfeld-Identität" lists them as
19
+ // separate concerns).
20
+ //
13
21
  // Vocabulary bridge: custom-fields expose `enum` (Plan + `r.field.enum([...])`)
14
22
  // where the framework's FieldDefinition calls the equivalent type `select`
15
- // with an `options` array. This single boundary translates it; everything
16
- // else is already FieldDefinition-shaped (the serialized dehydrated builder).
23
+ // with an `options` array.
17
24
  //
18
25
  // Returns `null` when the serialized field is unparseable or names a type
19
- // `fieldToZod` cannot interpret — callers then skip value-validation rather
20
- // than hard-rejecting a field they cannot understand.
26
+ // outside the custom-fields-supported set — callers then skip value-validation.
27
+
28
+ const SUPPORTED_TYPES_SET: ReadonlySet<string> = new Set(SUPPORTED_FIELD_TYPES);
29
+ const SUPPORTED_EMBEDDED_SUB_TYPES: ReadonlySet<string> = new Set([
30
+ "text",
31
+ "number",
32
+ "boolean",
33
+ "date",
34
+ ]);
35
+
36
+ // Constraint-keys fieldToZod converts into Zod refinements. Stripped so the
37
+ // resulting schema validates the type-shape only, not the constraint.
38
+ const CONSTRAINT_KEYS = ["required", "maxLength", "format", "default"] as const;
39
+
21
40
  export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny | null {
22
41
  if (!parsedField || typeof parsedField !== "object") return null;
23
42
  const obj = parsedField as Record<string, unknown>;
24
- if (typeof obj["type"] !== "string") return null;
43
+ const rawType = obj["type"];
44
+ if (typeof rawType !== "string") return null;
45
+ if (!SUPPORTED_TYPES_SET.has(rawType)) return null;
25
46
 
26
- const fieldDef =
27
- obj["type"] === "enum"
28
- ? { ...obj, type: "select", options: obj["values"] ?? obj["options"] ?? [] }
29
- : obj;
47
+ // Embedded sub-fields: pre-check the sub-type set so we surface unknown
48
+ // sub-types as "skip validation" (return null) rather than letting
49
+ // fieldToZod's assertUnreachable throw and the catch swallow real bugs.
50
+ if (rawType === "embedded") {
51
+ const schema = obj["schema"];
52
+ if (!schema || typeof schema !== "object") return null;
53
+ for (const sub of Object.values(schema)) {
54
+ if (!sub || typeof sub !== "object") return null;
55
+ const subType = (sub as Record<string, unknown>)["type"];
56
+ if (typeof subType !== "string" || !SUPPORTED_EMBEDDED_SUB_TYPES.has(subType)) {
57
+ return null;
58
+ }
59
+ }
60
+ }
61
+
62
+ const fieldDef: Record<string, unknown> = { ...obj };
63
+ for (const k of CONSTRAINT_KEYS) delete fieldDef[k];
64
+ if (rawType === "enum") {
65
+ fieldDef["type"] = "select";
66
+ fieldDef["options"] = obj["values"] ?? obj["options"] ?? [];
67
+ }
30
68
 
31
69
  // fieldToZod's money case validates `currency` against the passed list, not
32
70
  // a field-level key — so surface the field's own currency when it declares
@@ -34,12 +72,8 @@ export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny
34
72
  const currencies =
35
73
  typeof obj["currency"] === "string" ? [obj["currency"] as string] : DEFAULT_CURRENCIES;
36
74
 
37
- try {
38
- // @cast-boundary serialized-field is the dehydrated r.field.X() output =
39
- // a FieldDefinition; fieldToZod reads only its type-specific keys (the
40
- // extra fieldAccess/sensitive/retention/label keys are ignored).
41
- return fieldToZod(fieldDef as unknown as FieldDefinition, currencies);
42
- } catch {
43
- return null;
44
- }
75
+ // @cast-boundary serialized-field is the dehydrated r.field.X() output =
76
+ // a FieldDefinition; fieldToZod reads only its type-specific keys (the
77
+ // extra fieldAccess/sensitive/retention/label keys are ignored).
78
+ return fieldToZod(fieldDef as unknown as FieldDefinition, currencies);
45
79
  }
@@ -0,0 +1,144 @@
1
+ import { 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 { CustomFieldsFormSection } from "../custom-fields-form-section";
11
+
12
+ type FieldRow = {
13
+ id: string;
14
+ entityName: string;
15
+ fieldKey: string;
16
+ type: string;
17
+ required: boolean;
18
+ displayOrder: number;
19
+ };
20
+
21
+ const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
22
+ let mockedQueryRows: readonly FieldRow[] = [];
23
+
24
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
25
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
26
+ ...actual_renderer,
27
+ useDispatcher: mock(() => ({
28
+ write: dispatchSpy,
29
+ query: mock(),
30
+ batch: mock(),
31
+ })),
32
+ useQuery: mock(() => ({
33
+ data: { rows: mockedQueryRows },
34
+ loading: false,
35
+ error: null,
36
+ refetch: mock(),
37
+ })),
38
+ }));
39
+
40
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
41
+ return (
42
+ <LocaleProvider resolver={createStaticLocaleResolver()}>
43
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
44
+ </LocaleProvider>
45
+ );
46
+ }
47
+
48
+ describe("CustomFieldsFormSection", () => {
49
+ test("renders an input per matching fieldDefinition and dispatches set-custom-field on save", async () => {
50
+ mockedQueryRows = [
51
+ {
52
+ id: "f1",
53
+ entityName: "component",
54
+ fieldKey: "vendor",
55
+ type: "text",
56
+ required: false,
57
+ displayOrder: 1,
58
+ },
59
+ {
60
+ id: "f2",
61
+ entityName: "component",
62
+ fieldKey: "tier",
63
+ type: "number",
64
+ required: false,
65
+ displayOrder: 2,
66
+ },
67
+ {
68
+ id: "f3",
69
+ entityName: "incident",
70
+ fieldKey: "rootCause",
71
+ type: "text",
72
+ required: false,
73
+ displayOrder: 1,
74
+ },
75
+ ];
76
+ dispatchSpy.mockClear();
77
+
78
+ render(
79
+ <Wrapper>
80
+ <CustomFieldsFormSection entityName="component" entityId="row-42" />
81
+ </Wrapper>,
82
+ );
83
+
84
+ // Only `component`-entity fields are rendered (incident's rootCause filtered out).
85
+ expect(screen.getByTestId("custom-fields-form-section")).toBeTruthy();
86
+ const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
87
+ const tierInput = document.getElementById("custom-field-tier") as HTMLInputElement;
88
+ expect(vendorInput).toBeTruthy();
89
+ expect(tierInput).toBeTruthy();
90
+ expect(document.getElementById("custom-field-rootCause")).toBeNull();
91
+
92
+ // Type in vendor; tier left empty (should be skipped on save).
93
+ fireEvent.change(vendorInput, { target: { value: "Hetzner" } });
94
+
95
+ const saveBtn = screen.getByTestId("custom-fields-form-save");
96
+ fireEvent.click(saveBtn);
97
+ // Wait one microtask for the async handleSave loop.
98
+ await Promise.resolve();
99
+ await Promise.resolve();
100
+
101
+ expect(dispatchSpy).toHaveBeenCalledTimes(1);
102
+ expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
103
+ entityName: "component",
104
+ entityId: "row-42",
105
+ fieldKey: "vendor",
106
+ value: "Hetzner",
107
+ });
108
+ });
109
+
110
+ test("shows create-mode banner when entityId is null", () => {
111
+ mockedQueryRows = [];
112
+
113
+ render(
114
+ <Wrapper>
115
+ <CustomFieldsFormSection entityName="component" entityId={null} />
116
+ </Wrapper>,
117
+ );
118
+
119
+ expect(screen.getByTestId("custom-fields-form-create-mode")).toBeTruthy();
120
+ expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
121
+ });
122
+
123
+ test("shows empty banner when no fieldDefinitions match entityName", () => {
124
+ mockedQueryRows = [
125
+ {
126
+ id: "f3",
127
+ entityName: "incident",
128
+ fieldKey: "rootCause",
129
+ type: "text",
130
+ required: false,
131
+ displayOrder: 1,
132
+ },
133
+ ];
134
+
135
+ render(
136
+ <Wrapper>
137
+ <CustomFieldsFormSection entityName="component" entityId="row-42" />
138
+ </Wrapper>,
139
+ );
140
+
141
+ expect(screen.getByTestId("custom-fields-form-empty")).toBeTruthy();
142
+ expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
143
+ });
144
+ });
@@ -0,0 +1,25 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für custom-fields. Wird vom App-Code in
3
+ // createKumikoApp({ clientFeatures: [customFieldsClient()] }) eingehängt
4
+ // und registriert die CustomFieldsFormSection unter dem Namen
5
+ // CUSTOM_FIELDS_FORM_EXTENSION_NAME im ExtensionSectionsProvider. Apps
6
+ // referenzieren den Namen im Screen-Schema:
7
+ //
8
+ // layout: { sections: [
9
+ // { kind: "extension", title: "...", component: {
10
+ // react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME }
11
+ // } },
12
+ // ]}
13
+
14
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
15
+ import { CUSTOM_FIELDS_FEATURE_NAME, CUSTOM_FIELDS_FORM_EXTENSION_NAME } from "../constants";
16
+ import { CustomFieldsFormSection } from "./custom-fields-form-section";
17
+
18
+ export function customFieldsClient(): ClientFeatureDefinition {
19
+ return {
20
+ name: CUSTOM_FIELDS_FEATURE_NAME,
21
+ extensionSectionComponents: {
22
+ [CUSTOM_FIELDS_FORM_EXTENSION_NAME]: CustomFieldsFormSection,
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,181 @@
1
+ // @runtime client
2
+ // CustomFieldsFormSection — extension-section component für entityEdit-
3
+ // Screens. Lädt die fieldDefinition-Liste des Tenants, filtert auf die
4
+ // host-Entity, rendert pro Definition einen typed Input, dispatched
5
+ // `custom-fields:write:set-custom-field` pro non-empty Value beim Save.
6
+ //
7
+ // Mount via createKumikoApp({ clientFeatures: [customFieldsClient()] })
8
+ // — der clientFeature-Factory registriert diese Component unter dem
9
+ // Namen `CustomFieldsFormSection`, den die App im Screen-Schema via
10
+ // `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`
11
+ // referenziert.
12
+
13
+ import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
14
+ import { type ReactNode, useState } from "react";
15
+ import { CustomFieldsHandlers, CustomFieldsQueries } from "../constants";
16
+
17
+ type FieldDefinitionRow = {
18
+ readonly id: string;
19
+ readonly entityName: string;
20
+ readonly fieldKey: string;
21
+ readonly type: string;
22
+ readonly required: boolean;
23
+ readonly displayOrder: number;
24
+ };
25
+
26
+ type FieldDefinitionListResponse = {
27
+ readonly rows: readonly FieldDefinitionRow[];
28
+ };
29
+
30
+ export function CustomFieldsFormSection({
31
+ entityName,
32
+ entityId,
33
+ }: {
34
+ readonly entityName: string;
35
+ readonly entityId: string | null;
36
+ }): ReactNode {
37
+ const { Banner, Button, Field, Input, Text } = usePrimitives();
38
+ const dispatcher = useDispatcher();
39
+ const query = useQuery<FieldDefinitionListResponse>(CustomFieldsQueries.fieldDefinitionList, {});
40
+ const [pending, setPending] = useState<Readonly<Record<string, string>>>({});
41
+ const [saving, setSaving] = useState(false);
42
+ const [errorKey, setErrorKey] = useState<string | null>(null);
43
+
44
+ if (entityId === null) {
45
+ return (
46
+ <Banner variant="info" testId="custom-fields-form-create-mode">
47
+ <Text>Save the entity first to add custom field values.</Text>
48
+ </Banner>
49
+ );
50
+ }
51
+ if (query.loading && query.data === null) {
52
+ return (
53
+ <Banner variant="loading" testId="custom-fields-form-loading">
54
+ <Text>Loading…</Text>
55
+ </Banner>
56
+ );
57
+ }
58
+ if (query.error) {
59
+ return (
60
+ <Banner variant="error" testId="custom-fields-form-error">
61
+ <Text>{query.error.i18nKey}</Text>
62
+ </Banner>
63
+ );
64
+ }
65
+
66
+ const matchingFields = (query.data?.rows ?? [])
67
+ .filter((f) => f.entityName === entityName)
68
+ .slice()
69
+ .sort((a, b) => a.displayOrder - b.displayOrder);
70
+
71
+ if (matchingFields.length === 0) {
72
+ return (
73
+ <Banner variant="info" testId="custom-fields-form-empty">
74
+ <Text>No custom fields defined for "{entityName}".</Text>
75
+ </Banner>
76
+ );
77
+ }
78
+
79
+ const handleSave = async (): Promise<void> => {
80
+ setSaving(true);
81
+ setErrorKey(null);
82
+ try {
83
+ for (const field of matchingFields) {
84
+ const raw = pending[field.fieldKey];
85
+ if (raw === undefined || raw === "") continue;
86
+ const value = coerceValue(field.type, raw);
87
+ const result = await dispatcher.write(CustomFieldsHandlers.setCustomField, {
88
+ entityName,
89
+ entityId,
90
+ fieldKey: field.fieldKey,
91
+ value,
92
+ });
93
+ if (!result.isSuccess) {
94
+ setErrorKey(result.error?.i18nKey ?? "custom-fields:save-failed");
95
+ return;
96
+ }
97
+ }
98
+ setPending({});
99
+ } finally {
100
+ setSaving(false);
101
+ }
102
+ };
103
+
104
+ const dirty = Object.values(pending).some((v) => v !== "");
105
+
106
+ return (
107
+ <div data-testid="custom-fields-form-section">
108
+ {matchingFields.map((field) => (
109
+ <Field
110
+ key={field.id}
111
+ id={`custom-field-${field.fieldKey}`}
112
+ label={field.fieldKey}
113
+ required={field.required}
114
+ >
115
+ {renderInputFor(field, pending[field.fieldKey] ?? "", (v) =>
116
+ setPending((p) => ({ ...p, [field.fieldKey]: v })),
117
+ )}
118
+ </Field>
119
+ ))}
120
+ <Button
121
+ variant="primary"
122
+ onClick={() => void handleSave()}
123
+ disabled={saving || !dirty}
124
+ testId="custom-fields-form-save"
125
+ >
126
+ {saving ? "Saving…" : "Save custom fields"}
127
+ </Button>
128
+ {errorKey !== null && (
129
+ <Banner variant="error" testId="custom-fields-form-save-error">
130
+ <Text>{errorKey}</Text>
131
+ </Banner>
132
+ )}
133
+ </div>
134
+ );
135
+
136
+ function renderInputFor(
137
+ field: FieldDefinitionRow,
138
+ raw: string,
139
+ onChange: (v: string) => void,
140
+ ): ReactNode {
141
+ const id = `custom-field-${field.fieldKey}`;
142
+ const name = field.fieldKey;
143
+ if (field.type === "number") {
144
+ return (
145
+ <Input
146
+ kind="number"
147
+ id={id}
148
+ name={name}
149
+ value={raw === "" ? "" : Number(raw)}
150
+ onChange={(v) => onChange(v === undefined ? "" : String(v))}
151
+ />
152
+ );
153
+ }
154
+ if (field.type === "boolean") {
155
+ return (
156
+ <Input
157
+ kind="boolean"
158
+ id={id}
159
+ name={name}
160
+ value={raw === "true"}
161
+ onChange={(v) => onChange(v ? "true" : "false")}
162
+ />
163
+ );
164
+ }
165
+ if (field.type === "date") {
166
+ return (
167
+ <Input kind="date" id={id} name={name} value={raw} onChange={(v) => onChange(v ?? "")} />
168
+ );
169
+ }
170
+ return <Input kind="text" id={id} name={name} value={raw} onChange={onChange} />;
171
+ }
172
+ }
173
+
174
+ function coerceValue(type: string, raw: string): unknown {
175
+ if (type === "number") {
176
+ const n = Number(raw);
177
+ return Number.isNaN(n) ? raw : n;
178
+ }
179
+ if (type === "boolean") return raw === "true";
180
+ return raw;
181
+ }
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ export {
3
+ CUSTOM_FIELDS_FORM_EXTENSION_NAME,
4
+ CustomFieldsHandlers,
5
+ CustomFieldsQueries,
6
+ } from "../constants";
7
+ export { customFieldsClient } from "./client-plugin";
8
+ export { CustomFieldsFormSection } from "./custom-fields-form-section";
@@ -4,13 +4,11 @@
4
4
  // 1. Feature-Definition Smoke (Boot-Validation passes)
5
5
  // 2. Cross-Feature-Behavior: fileRef-Entity ist als Hook-Anker für
6
6
  // Sprint-2-userData-Extension nutzbar
7
- // 3. DDL-Konsistenz: Framework-pgTable + Feature-Entity zeigen auf
8
- // dieselbe Postgres-Struktur (Drift-Guard)
9
- // 4. Event-QN-Match: r.defineEvent + framework's fileUploadedEvent
10
- // resolven zum selben QN
7
+ // 3. DDL-Konsistenz: fileRefsTable (buildEntityTable) + fileRefEntity
8
+ // zeigen auf dieselbe Postgres-Struktur (Drift-Guard)
11
9
 
12
10
  import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
13
- import { FILE_UPLOADED_EVENT_TYPE, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
11
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
14
12
  import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
15
13
 
16
14
  // Native dialect exposes column metadata on the `columns` array (EntityTableMeta)
@@ -148,21 +146,3 @@ describe("files :: DDL-Konsistenz (M3, S1.7)", () => {
148
146
  expect(pgColumns.has("size")).toBe(true);
149
147
  });
150
148
  });
151
-
152
- describe("files :: event-QN-match (M4, S1.7)", () => {
153
- test("framework's fileUploadedEvent.name === 'files:event:uploaded'", () => {
154
- // Wenn das Framework den Event-Namen aendert, fliegt dieser Test
155
- // sofort an — und der QN aus r.defineEvent("uploaded") im feature
156
- // wuerde nicht mehr matchen. Drift-Guard.
157
- expect(FILE_UPLOADED_EVENT_TYPE).toBe("files:event:uploaded");
158
- });
159
-
160
- test("Feature-Name 'files' + Event-Short 'uploaded' = QN 'files:event:uploaded'", () => {
161
- // r.defineEvent("uploaded") in defineFeature("files", ...) resolved
162
- // zu QN "files:event:uploaded" via Framework-Convention. Match
163
- // garantiert dass framework's appendEvent + EventDef-Schema-
164
- // Validation auf demselben QN landen.
165
- const expected = `${feature.name}:event:uploaded`;
166
- expect(expected).toBe(FILE_UPLOADED_EVENT_TYPE);
167
- });
168
- });