@cosmicdrift/kumiko-framework 0.222.0 → 0.224.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-framework",
3
- "version": "0.222.0",
3
+ "version": "0.224.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.222.0",
201
+ "@cosmicdrift/kumiko-types": "0.224.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.222.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.224.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -1355,6 +1355,119 @@ describe("boot-validator", () => {
1355
1355
  ];
1356
1356
  expect(() => validateBoot(features)).not.toThrow();
1357
1357
  });
1358
+
1359
+ test("accepts display: checkboxes with columns and maxRows", () => {
1360
+ const features = [
1361
+ defineFeature("driver", (r) => {
1362
+ r.entity(
1363
+ "profile",
1364
+ createEntity({
1365
+ fields: {
1366
+ tags: createMultiSelectField({
1367
+ options: ["a", "b", "c"] as const,
1368
+ display: "checkboxes",
1369
+ columns: 2,
1370
+ maxRows: 3,
1371
+ }),
1372
+ },
1373
+ }),
1374
+ );
1375
+ }),
1376
+ ];
1377
+ expect(() => validateBoot(features)).not.toThrow();
1378
+ });
1379
+
1380
+ test("rejects columns without display: checkboxes", () => {
1381
+ const features = [
1382
+ defineFeature("driver", (r) => {
1383
+ r.entity(
1384
+ "profile",
1385
+ createEntity({
1386
+ fields: {
1387
+ tags: createMultiSelectField({ options: ["a", "b"] as const, columns: 2 }),
1388
+ },
1389
+ }),
1390
+ );
1391
+ }),
1392
+ ];
1393
+ expect(() => validateBoot(features)).toThrow(/columns.*display.*checkboxes/);
1394
+ });
1395
+
1396
+ test("rejects columns when display is explicitly dropdown", () => {
1397
+ const features = [
1398
+ defineFeature("driver", (r) => {
1399
+ r.entity(
1400
+ "profile",
1401
+ createEntity({
1402
+ fields: {
1403
+ tags: createMultiSelectField({
1404
+ options: ["a", "b"] as const,
1405
+ display: "dropdown",
1406
+ columns: 2,
1407
+ }),
1408
+ },
1409
+ }),
1410
+ );
1411
+ }),
1412
+ ];
1413
+ expect(() => validateBoot(features)).toThrow(/columns.*display.*checkboxes/);
1414
+ });
1415
+
1416
+ test("rejects maxRows without display: checkboxes", () => {
1417
+ const features = [
1418
+ defineFeature("driver", (r) => {
1419
+ r.entity(
1420
+ "profile",
1421
+ createEntity({
1422
+ fields: {
1423
+ tags: createMultiSelectField({ options: ["a", "b"] as const, maxRows: 3 }),
1424
+ },
1425
+ }),
1426
+ );
1427
+ }),
1428
+ ];
1429
+ expect(() => validateBoot(features)).toThrow(/maxRows.*display.*checkboxes/);
1430
+ });
1431
+
1432
+ test("rejects maxRows below 1", () => {
1433
+ const features = [
1434
+ defineFeature("driver", (r) => {
1435
+ r.entity(
1436
+ "profile",
1437
+ createEntity({
1438
+ fields: {
1439
+ tags: createMultiSelectField({
1440
+ options: ["a", "b"] as const,
1441
+ display: "checkboxes",
1442
+ maxRows: 0,
1443
+ }),
1444
+ },
1445
+ }),
1446
+ );
1447
+ }),
1448
+ ];
1449
+ expect(() => validateBoot(features)).toThrow(/invalid maxRows/);
1450
+ });
1451
+
1452
+ test("rejects non-integer maxRows", () => {
1453
+ const features = [
1454
+ defineFeature("driver", (r) => {
1455
+ r.entity(
1456
+ "profile",
1457
+ createEntity({
1458
+ fields: {
1459
+ tags: createMultiSelectField({
1460
+ options: ["a", "b"] as const,
1461
+ display: "checkboxes",
1462
+ maxRows: 1.5,
1463
+ }),
1464
+ },
1465
+ }),
1466
+ );
1467
+ }),
1468
+ ];
1469
+ expect(() => validateBoot(features)).toThrow(/invalid maxRows/);
1470
+ });
1358
1471
  });
1359
1472
 
1360
1473
  describe("image variants", () => {
@@ -1,7 +1,12 @@
1
1
  import type { BlurRegion, VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
2
2
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key";
3
3
  import { parseRefTarget } from "../parse-ref-target";
4
- import type { EmbeddedFieldDef, EntityDefinition, FeatureDefinition } from "../types";
4
+ import type {
5
+ EmbeddedFieldDef,
6
+ EntityDefinition,
7
+ FeatureDefinition,
8
+ MultiSelectFieldDef,
9
+ } from "../types";
5
10
 
6
11
  export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
7
12
 
@@ -676,6 +681,32 @@ function validateEmbeddedListMetadata(
676
681
  // default — wenn gesetzt — ist eine Teilmenge der options. Beides würde
677
682
  // auch im Zod-Schema bei runtime fehlschlagen, der Boot-Catch ist nur
678
683
  // die früheste Stelle für klare Fehlermeldungen.
684
+ function validateCheckboxDisplayOptions(
685
+ field: MultiSelectFieldDef,
686
+ fieldName: string,
687
+ entityName: string,
688
+ ): void {
689
+ if (field.display === "checkboxes") {
690
+ if (field.maxRows !== undefined && (!Number.isInteger(field.maxRows) || field.maxRows < 1)) {
691
+ throw new Error(
692
+ `MultiSelect field "${fieldName}" on entity "${entityName}" has invalid maxRows "${field.maxRows}" — must be a positive integer.`,
693
+ );
694
+ }
695
+ // skip: checkboxes is the valid display for these options — the checks below only apply to other displays
696
+ return;
697
+ }
698
+ if (field.columns !== undefined) {
699
+ throw new Error(
700
+ `MultiSelect field "${fieldName}" on entity "${entityName}" sets columns, which is only valid with display: "checkboxes".`,
701
+ );
702
+ }
703
+ if (field.maxRows !== undefined) {
704
+ throw new Error(
705
+ `MultiSelect field "${fieldName}" on entity "${entityName}" sets maxRows, which is only valid with display: "checkboxes".`,
706
+ );
707
+ }
708
+ }
709
+
679
710
  export function validateMultiSelectFields(feature: FeatureDefinition): void {
680
711
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
681
712
  for (const [fieldName, field] of Object.entries(entity.fields)) {
@@ -697,6 +728,8 @@ export function validateMultiSelectFields(feature: FeatureDefinition): void {
697
728
  }
698
729
  }
699
730
  }
731
+
732
+ validateCheckboxDisplayOptions(field, fieldName, entityName);
700
733
  }
701
734
  }
702
735
  }
@@ -162,25 +162,32 @@ export function createSelectField<
162
162
  }
163
163
 
164
164
  /**
165
- * Multi-Select-Field — N Werte aus einer festen Options-Liste.
165
+ * Multi-select field — N values from a fixed option list.
166
166
  *
167
- * Storage: jsonb-Array<string>. Jeder Eintrag muss in `options` enthalten
168
- * sein (Boot-Validator). UI-Renderer rendert das als Checkbox-Group oder
169
- * Multi-Select-Dropdown.
167
+ * Storage: jsonb-Array<string>. Every entry must be in `options`
168
+ * (boot-validator). Renders as a combobox dropdown by default; set
169
+ * `display: "checkboxes"` to render every option as a visible checkbox with
170
+ * a select-all toggle instead.
170
171
  *
171
172
  * ```ts
172
173
  * licenceClasses: createMultiSelectField({
173
174
  * options: ["B", "BE", "C", "C1", "CE", "C1E", "D", "D1"] as const,
174
175
  * default: ["B"],
175
176
  * }),
177
+ *
178
+ * languages: createMultiSelectField({
179
+ * options: ["en", "de", "es", "fr", "it"] as const,
180
+ * display: "checkboxes",
181
+ * columns: 2,
182
+ * }),
176
183
  * ```
177
184
  *
178
185
  * Caller-API:
179
186
  * Write: `{ licenceClasses: ["B", "BE", "C1"] }`
180
187
  * Read: `{ licenceClasses: ["B", "BE", "C1"] }`
181
188
  *
182
- * Wann statt `select`: wenn mehrere Werte gleichzeitig erlaubt sind.
183
- * Wann statt `embedded` mit Booleans: bei mehr als ~5 Optionen.
189
+ * Use instead of `select` when more than one value can be chosen at once.
190
+ * Use instead of `embedded` with booleans once there are more than ~5 options.
184
191
  */
185
192
  export function createMultiSelectField<const TOptions extends readonly string[]>(
186
193
  opts: { options: TOptions } & Partial<