@cosmicdrift/kumiko-bundled-features 0.21.1 → 0.23.1
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 +2 -1
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +4 -4
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
- package/src/auth-email-password/seeding.ts +9 -6
- package/src/compliance-profiles/seeding.ts +4 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +58 -0
- package/src/custom-fields/constants.ts +23 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -4
- package/src/custom-fields/lib/value-schema.ts +51 -17
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +144 -0
- package/src/custom-fields/web/client-plugin.tsx +25 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +181 -0
- package/src/custom-fields/web/index.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +3 -23
- package/src/files/feature.ts +7 -34
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +27 -0
- package/src/files-provider-s3/s3-provider.ts +8 -13
- package/src/tenant/__tests__/seed-testing.integration.test.ts +1 -1
- package/src/tenant/seeding.ts +35 -15
- package/src/text-content/seeding.ts +4 -1
- package/src/text-content/table.ts +1 -1
- package/src/user/__tests__/seed-testing.integration.test.ts +5 -5
- package/src/user/seeding.ts +13 -8
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -15
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +231 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -15
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -15
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +6 -18
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +3 -0
- package/src/files/schema/file-ref.ts +0 -58
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
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",
|
|
@@ -138,12 +138,12 @@ beforeEach(async () => {
|
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
// Alice = Admin von Tenant-A
|
|
141
|
-
aliceId = await seedUser(stack.db, {
|
|
141
|
+
({ id: aliceId } = await seedUser(stack.db, {
|
|
142
142
|
email: ALICE_EMAIL,
|
|
143
143
|
displayName: "Alice",
|
|
144
144
|
passwordHash: await hashPassword("alice-pw-1234"),
|
|
145
145
|
emailVerified: true,
|
|
146
|
-
});
|
|
146
|
+
}));
|
|
147
147
|
await seedTenantMembership(stack.db, {
|
|
148
148
|
userId: aliceId,
|
|
149
149
|
tenantId: TENANT_A_ID,
|
|
@@ -151,12 +151,12 @@ beforeEach(async () => {
|
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
// Bob = Member von Tenant-B (für Branch 1 + 2 tests)
|
|
154
|
-
bobId = await seedUser(stack.db, {
|
|
154
|
+
({ id: bobId } = await seedUser(stack.db, {
|
|
155
155
|
email: BOB_EMAIL,
|
|
156
156
|
displayName: "Bob",
|
|
157
157
|
passwordHash: await hashPassword(BOB_PASSWORD),
|
|
158
158
|
emailVerified: true,
|
|
159
|
-
});
|
|
159
|
+
}));
|
|
160
160
|
await seedTenantMembership(stack.db, {
|
|
161
161
|
userId: bobId,
|
|
162
162
|
tenantId: TENANT_B_ID,
|
|
@@ -61,7 +61,7 @@ beforeEach(async () => {
|
|
|
61
61
|
|
|
62
62
|
describe("seedAdmin", () => {
|
|
63
63
|
test("legt Tenants, User mit gehashtem Password und Memberships an — Login-Roundtrip funktioniert", async () => {
|
|
64
|
-
const userId = await seedAdmin(stack.db, {
|
|
64
|
+
const { id: userId } = await seedAdmin(stack.db, {
|
|
65
65
|
email: "admin@example.com",
|
|
66
66
|
password: "secret-pw",
|
|
67
67
|
displayName: "Admin",
|
|
@@ -113,7 +113,7 @@ describe("seedAdmin", () => {
|
|
|
113
113
|
|
|
114
114
|
test("idempotent: zweiter Aufruf no-op (kein Crash, Stand bleibt)", async () => {
|
|
115
115
|
// Erstaufruf
|
|
116
|
-
const userId1 = await seedAdmin(stack.db, {
|
|
116
|
+
const { id: userId1 } = await seedAdmin(stack.db, {
|
|
117
117
|
email: "admin@example.com",
|
|
118
118
|
password: "pw1",
|
|
119
119
|
displayName: "Admin",
|
|
@@ -124,7 +124,7 @@ describe("seedAdmin", () => {
|
|
|
124
124
|
// Zweiter Aufruf — gleicher Email, anderes Password (würde theoretisch
|
|
125
125
|
// einen neuen Hash erzeugen und neu schreiben, der idempotent-Check
|
|
126
126
|
// greift VOR dem Insert).
|
|
127
|
-
const userId2 = await seedAdmin(stack.db, {
|
|
127
|
+
const { id: userId2 } = await seedAdmin(stack.db, {
|
|
128
128
|
email: "admin@example.com",
|
|
129
129
|
password: "pw2",
|
|
130
130
|
displayName: "Admin",
|
|
@@ -138,7 +138,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
138
138
|
// @cast-boundary db-runner — TenantDb.raw is DbRunner; seed-helpers
|
|
139
139
|
// operate on plain drizzle-API which both shapes expose identically.
|
|
140
140
|
const dbConn = ctx.db.raw as DbConnection;
|
|
141
|
-
const userId = await seedUserWithPassword(dbConn, {
|
|
141
|
+
const { id: userId } = await seedUserWithPassword(dbConn, {
|
|
142
142
|
email: invitationEmail,
|
|
143
143
|
password: event.payload.password,
|
|
144
144
|
displayName: invitationEmail.split("@")[0] ?? invitationEmail,
|
|
@@ -46,12 +46,12 @@ export type SeedUserWithPasswordOptions = {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Seed a user mit Plain-Password (wird vor dem Insert mit argon2
|
|
49
|
-
* gehasht). Liefert userId, idempotent über email.
|
|
49
|
+
* gehasht). Liefert die userId, idempotent über email.
|
|
50
50
|
*/
|
|
51
51
|
export async function seedUserWithPassword(
|
|
52
52
|
db: DbConnection,
|
|
53
53
|
options: SeedUserWithPasswordOptions,
|
|
54
|
-
): Promise<string> {
|
|
54
|
+
): Promise<{ id: string }> {
|
|
55
55
|
const passwordHash = await hashPassword(options.password);
|
|
56
56
|
return seedUser(db, {
|
|
57
57
|
email: options.email,
|
|
@@ -114,7 +114,7 @@ export async function provisionSignupAccount(
|
|
|
114
114
|
key: options.tenantKey,
|
|
115
115
|
name: options.tenantName,
|
|
116
116
|
});
|
|
117
|
-
const userId = await seedUserWithPassword(db, {
|
|
117
|
+
const { id: userId } = await seedUserWithPassword(db, {
|
|
118
118
|
email: options.email,
|
|
119
119
|
password: options.password,
|
|
120
120
|
displayName: options.displayName,
|
|
@@ -155,14 +155,17 @@ export type SeedAdminOptions = {
|
|
|
155
155
|
* Password + N Tenants + N Memberships. Alles idempotent (Re-Run im
|
|
156
156
|
* persistent-DB-Modus läuft durch). Liefert die userId zurück.
|
|
157
157
|
*/
|
|
158
|
-
export async function seedAdmin(
|
|
158
|
+
export async function seedAdmin(
|
|
159
|
+
db: DbConnection,
|
|
160
|
+
options: SeedAdminOptions,
|
|
161
|
+
): Promise<{ id: string }> {
|
|
159
162
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
160
163
|
|
|
161
164
|
for (const m of options.memberships) {
|
|
162
165
|
await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
const userId = await seedUserWithPassword(db, {
|
|
168
|
+
const { id: userId } = await seedUserWithPassword(db, {
|
|
166
169
|
email: options.email,
|
|
167
170
|
password: options.password,
|
|
168
171
|
displayName: options.displayName,
|
|
@@ -179,5 +182,5 @@ export async function seedAdmin(db: DbConnection, options: SeedAdminOptions): Pr
|
|
|
179
182
|
});
|
|
180
183
|
}
|
|
181
184
|
|
|
182
|
-
return userId;
|
|
185
|
+
return { id: userId };
|
|
183
186
|
}
|
|
@@ -45,7 +45,7 @@ export type SeedComplianceProfileOptions = {
|
|
|
45
45
|
export async function seedComplianceProfile(
|
|
46
46
|
db: DbConnection,
|
|
47
47
|
opts: SeedComplianceProfileOptions,
|
|
48
|
-
): Promise<{ id: string
|
|
48
|
+
): Promise<{ id: string }> {
|
|
49
49
|
// user.tenantId muss === opts.tenantId sein damit Event-Store-Stream
|
|
50
50
|
// + Projection im selben Tenant-Bucket landen (Memory:
|
|
51
51
|
// feedback_event_store_tenant_consistency).
|
|
@@ -73,6 +73,9 @@ export async function seedComplianceProfile(
|
|
|
73
73
|
if (!result.isSuccess) {
|
|
74
74
|
throw new Error(`seedComplianceProfile create failed: ${JSON.stringify(result)}`);
|
|
75
75
|
}
|
|
76
|
+
// @cast-boundary db-row: executor.create result.data ist die
|
|
77
|
+
// inserted Projection-Row (Record<string, unknown>); id ist nach
|
|
78
|
+
// INSERT garantiert (Runtime-Check direkt darunter).
|
|
76
79
|
const data = result.data as { id?: string };
|
|
77
80
|
if (data.id === undefined) {
|
|
78
81
|
throw new Error("seedComplianceProfile: executor.create did not return an id");
|
|
@@ -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`
|
|
36
|
-
// Type-
|
|
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:
|
|
39
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
20
|
-
|
|
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
|
-
|
|
43
|
+
const rawType = obj["type"];
|
|
44
|
+
if (typeof rawType !== "string") return null;
|
|
45
|
+
if (!SUPPORTED_TYPES_SET.has(rawType)) return null;
|
|
25
46
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
}
|