@cosmicdrift/kumiko-framework 0.239.0 → 0.241.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.239.0",
3
+ "version": "0.241.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.239.0",
201
+ "@cosmicdrift/kumiko-types": "0.241.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.239.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.241.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
package/src/changes.json CHANGED
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "0.241.0",
4
+ "type": "improvement",
5
+ "title": "ProjectionDetailScreenDefinition gains singleton: boolean for self-service screens bound to a caller-scoped query (fw#2312).",
6
+ "detail": "A `projectionDetail` screen previously always resolved its row from a path id — the only case `ProjectionDetailBody` handled — so binding one to a `me`-style query (e.g. `user:query:user:me`) rendered nothing but a \"missing id\" error banner. With `singleton: true`, the query is called without the `idParam` key (there is no id to send) and any path id is ignored rather than forwarded into the query or into extension sections' entity-id resolution — a singleton row is server-picked, so no client-supplied id can reach it. The boot validator rejects `idParam` or `detailFor` declared together with `singleton` (both are meaningless once the server owns row selection)."
7
+ },
8
+ {
9
+ "version": "0.241.0",
10
+ "type": "improvement",
11
+ "title": "DashboardStatPanel gains an optional params field for static query parameters (fw#2312).",
12
+ "detail": "`DashboardStatPanel.params?: Readonly<Record<string, unknown>>` (`@cosmicdrift/kumiko-types`) is merged under the panel's dynamic `filterParams` by `@cosmicdrift/kumiko-renderer-web`'s dashboard body — lets a panel pin a value (e.g. a status facet) the screen-wide filter doesn't cover, without needing its own query. Additive; existing `dashboard` screens are unaffected."
13
+ },
14
+ {
15
+ "version": "0.241.0",
16
+ "type": "improvement",
17
+ "title": "EditFieldSpec.renderer gains a json format for pretty-printed structured fields (fw#2312).",
18
+ "detail": "`FieldFormatRegistry.json: { indent?: number }` (`@cosmicdrift/kumiko-types`, resolved by `@cosmicdrift/kumiko-headless`) — set `renderer: { format: \"json\", indent: 2 }` on an `EditFieldSpec` to pretty-print a JSON-string field instead of showing the raw escaped string. Pairs with the new `JsonView` primitive below when `@cosmicdrift/kumiko-renderer-web` is in use."
19
+ },
20
+ {
21
+ "version": "0.241.0",
22
+ "type": "improvement",
23
+ "title": "Optional JsonView primitive for structured, syntax-highlighted JSON display (fw#2312).",
24
+ "detail": "`CorePrimitives.JsonView` (`@cosmicdrift/kumiko-renderer`, web impl `DefaultJsonView` in `@cosmicdrift/kumiko-renderer-web`) receives the raw value (not a pre-stringified string) and stringifies + tokenizes itself; never throws on circular references, `BigInt`, or other non-serializable input. Optional on `CorePrimitives`, so an existing partial `CorePrimitives` test mock/provider keeps compiling unchanged — every call site (renderer's `format: \"json\"` fields, the jsonb/embedded/files/images fallback banner, `EditorPanel`'s unresolved-target args display) falls back to the prior `<Text>`/`<pre>` rendering when no `JsonView` is registered. A custom `PrimitivesProvider` that wants the improved rendering needs to add a `JsonView` implementation; nothing breaks if it doesn't."
25
+ },
2
26
  {
3
27
  "version": "0.235.0",
4
28
  "type": "breaking",
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ // fw#2312: `singleton: true` marks a projectionDetail screen whose query
13
+ // determines the shown row from the caller's context (session), not a row
14
+ // id in the path — a self-service screen like "my profile". idParam names
15
+ // the query-payload key for a path row id, which a singleton screen never
16
+ // sends; detailFor drives the auto-generated "Edit" action's navigation via
17
+ // that same path id (fw#2166). Both combinations are rejected at boot
18
+ // instead of silently winning (idParam ignored / edit button opening a
19
+ // blank create form).
20
+ describe("validateBoot — projectionDetail singleton (fw#2312)", () => {
21
+ test("singleton + idParam throws", () => {
22
+ const feature = defineFeature("app", (r) => {
23
+ r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
24
+ access: { openToAll: true },
25
+ });
26
+ r.screen({
27
+ id: "profile",
28
+ type: "projectionDetail",
29
+ query: "app:query:me",
30
+ singleton: true,
31
+ idParam: "userId",
32
+ layout: { sections: [{ title: "s", fields: ["id"] }] },
33
+ });
34
+ });
35
+ expect(() => validateBoot([feature])).toThrow(
36
+ /Screen "profile" \(projectionDetail\) sets both singleton: true and idParam/,
37
+ );
38
+ });
39
+
40
+ test("singleton + detailFor throws", () => {
41
+ const feature = defineFeature("app", (r) => {
42
+ r.entity("user", createEntity({ fields: { name: createTextField() } }));
43
+ r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
44
+ access: { openToAll: true },
45
+ });
46
+ r.screen({
47
+ id: "profile",
48
+ type: "projectionDetail",
49
+ query: "app:query:me",
50
+ singleton: true,
51
+ detailFor: "user",
52
+ layout: { sections: [{ title: "s", fields: ["id"] }] },
53
+ });
54
+ r.screen({
55
+ id: "user-edit",
56
+ type: "entityEdit",
57
+ entity: "user",
58
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
59
+ });
60
+ });
61
+ expect(() => validateBoot([feature])).toThrow(
62
+ /Screen "profile" \(projectionDetail\) sets both singleton: true and detailFor/,
63
+ );
64
+ });
65
+
66
+ test("singleton without idParam/detailFor boots cleanly", () => {
67
+ const feature = defineFeature("app", (r) => {
68
+ r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
69
+ access: { openToAll: true },
70
+ });
71
+ r.screen({
72
+ id: "profile",
73
+ type: "projectionDetail",
74
+ query: "app:query:me",
75
+ singleton: true,
76
+ layout: { sections: [{ title: "s", fields: ["id"] }] },
77
+ });
78
+ });
79
+ expect(() => validateBoot([feature])).not.toThrow();
80
+ });
81
+ });
@@ -435,6 +435,461 @@ describe("r.screen() — registration", () => {
435
435
  );
436
436
  });
437
437
 
438
+ test("validateBoot rejects a relatedList rowAction with an unregistered write-handler (fw editable-detail-screens)", () => {
439
+ const features = [
440
+ defineFeature("app", (r) => {
441
+ r.screen({
442
+ id: "x",
443
+ type: "projectionDetail",
444
+ query: "app:query:foo:detail",
445
+ layout: {
446
+ sections: [
447
+ {
448
+ kind: "relatedList",
449
+ title: "s",
450
+ query: "app:query:foo:list",
451
+ columns: ["name"],
452
+ rowActions: [{ id: "sync", label: "actions.sync", handler: "app:write:missing" }],
453
+ },
454
+ ],
455
+ },
456
+ });
457
+ }),
458
+ ];
459
+ expect(() => validateBoot(features)).toThrow(
460
+ /relatedList\).*rowAction "sync" handler "app:write:missing".*not a registered write-handler/,
461
+ );
462
+ });
463
+
464
+ test("validateBoot accepts a relatedList rowAction bound to a registered write-handler (fw editable-detail-screens)", () => {
465
+ const features = [
466
+ defineFeature("app", (r) => {
467
+ r.writeHandler(
468
+ "sync",
469
+ z.object({}),
470
+ async () => ({ isSuccess: true as const, data: null }),
471
+ {
472
+ access: { roles: ["Admin"] },
473
+ },
474
+ );
475
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
476
+ access: { openToAll: true },
477
+ });
478
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
479
+ access: { openToAll: true },
480
+ });
481
+ r.screen({
482
+ id: "x",
483
+ type: "projectionDetail",
484
+ query: "app:query:foo:detail",
485
+ layout: {
486
+ sections: [
487
+ {
488
+ kind: "relatedList",
489
+ title: "s",
490
+ query: "app:query:foo:list",
491
+ columns: ["name"],
492
+ rowActions: [
493
+ {
494
+ id: "sync",
495
+ label: "actions.sync",
496
+ handler: "app:write:sync",
497
+ payload: { pick: ["name"] },
498
+ },
499
+ ],
500
+ },
501
+ ],
502
+ },
503
+ });
504
+ }),
505
+ ];
506
+ expect(() => validateBoot(features)).not.toThrow();
507
+ });
508
+
509
+ test("validateBoot rejects a relatedList section with both rowClick and a rowActions rowClick:true entry (fw editable-detail-screens)", () => {
510
+ const features = [
511
+ defineFeature("billing", (r) => {
512
+ r.entity(
513
+ "invoice",
514
+ createEntity({ table: "invoices", fields: { name: createTextField() } }),
515
+ );
516
+ r.screen({
517
+ id: "invoice-detail",
518
+ type: "custom",
519
+ renderer: { react: { __component: "stub" } },
520
+ detailFor: "invoice",
521
+ });
522
+ }),
523
+ defineFeature("app", (r) => {
524
+ r.screen({
525
+ id: "x",
526
+ type: "projectionDetail",
527
+ query: "app:query:foo:detail",
528
+ layout: {
529
+ sections: [
530
+ {
531
+ kind: "relatedList",
532
+ title: "s",
533
+ query: "app:query:foo:list",
534
+ columns: ["name"],
535
+ rowClick: { entity: "invoice" },
536
+ rowActions: [
537
+ {
538
+ kind: "navigate",
539
+ id: "open",
540
+ label: "actions.open",
541
+ entity: "invoice",
542
+ entityId: "id",
543
+ rowClick: true,
544
+ },
545
+ ],
546
+ },
547
+ ],
548
+ },
549
+ });
550
+ }),
551
+ ];
552
+ expect(() => validateBoot(features)).toThrow(
553
+ /relatedList\).*has both a rowClick and 1 rowActions marked rowClick:true/,
554
+ );
555
+ });
556
+
557
+ test("validateBoot rejects a projectionDetail writeForm section with an empty fieldDefs map (fw editable-detail-screens)", () => {
558
+ const features = [
559
+ defineFeature("app", (r) => {
560
+ r.screen({
561
+ id: "x",
562
+ type: "projectionDetail",
563
+ query: "app:query:foo:detail",
564
+ layout: {
565
+ sections: [
566
+ {
567
+ kind: "writeForm",
568
+ title: "s",
569
+ fieldDefs: {},
570
+ fields: ["name"],
571
+ handler: "app:write:save",
572
+ },
573
+ ],
574
+ },
575
+ });
576
+ }),
577
+ ];
578
+ expect(() => validateBoot(features)).toThrow(/\(writeForm\) has an empty fieldDefs map/);
579
+ });
580
+
581
+ test("validateBoot rejects a projectionDetail writeForm section with zero fields (fw editable-detail-screens)", () => {
582
+ const features = [
583
+ defineFeature("app", (r) => {
584
+ r.screen({
585
+ id: "x",
586
+ type: "projectionDetail",
587
+ query: "app:query:foo:detail",
588
+ layout: {
589
+ sections: [
590
+ {
591
+ kind: "writeForm",
592
+ title: "s",
593
+ fieldDefs: { name: createTextField() },
594
+ fields: [],
595
+ handler: "app:write:save",
596
+ },
597
+ ],
598
+ },
599
+ });
600
+ }),
601
+ ];
602
+ expect(() => validateBoot(features)).toThrow(/\(writeForm\) has zero fields/);
603
+ });
604
+
605
+ test("validateBoot rejects a projectionDetail writeForm field with no entry in fieldDefs (fw editable-detail-screens)", () => {
606
+ const features = [
607
+ defineFeature("app", (r) => {
608
+ r.screen({
609
+ id: "x",
610
+ type: "projectionDetail",
611
+ query: "app:query:foo:detail",
612
+ layout: {
613
+ sections: [
614
+ {
615
+ kind: "writeForm",
616
+ title: "s",
617
+ fieldDefs: { name: createTextField() },
618
+ fields: ["sku"],
619
+ handler: "app:write:save",
620
+ },
621
+ ],
622
+ },
623
+ });
624
+ }),
625
+ ];
626
+ expect(() => validateBoot(features)).toThrow(
627
+ /\(writeForm\) field "sku" has no entry in fieldDefs/,
628
+ );
629
+ });
630
+
631
+ test("validateBoot rejects a projectionDetail writeForm section in a wizard layout (fw editable-detail-screens)", () => {
632
+ const features = [
633
+ defineFeature("app", (r) => {
634
+ r.screen({
635
+ id: "x",
636
+ type: "projectionDetail",
637
+ query: "app:query:foo:detail",
638
+ layout: {
639
+ mode: "wizard",
640
+ sections: [
641
+ {
642
+ kind: "writeForm",
643
+ title: "s",
644
+ fieldDefs: { name: createTextField() },
645
+ fields: ["name"],
646
+ handler: "app:write:save",
647
+ },
648
+ ],
649
+ },
650
+ });
651
+ }),
652
+ ];
653
+ expect(() => validateBoot(features)).toThrow(/writeForm.*wizard layout/);
654
+ });
655
+
656
+ test("validateBoot rejects a projectionDetail writeForm section with an unregistered write-handler (fw editable-detail-screens)", () => {
657
+ const features = [
658
+ defineFeature("app", (r) => {
659
+ r.screen({
660
+ id: "x",
661
+ type: "projectionDetail",
662
+ query: "app:query:foo:detail",
663
+ layout: {
664
+ sections: [
665
+ {
666
+ kind: "writeForm",
667
+ title: "s",
668
+ fieldDefs: { name: createTextField() },
669
+ fields: ["name"],
670
+ handler: "app:write:missing",
671
+ },
672
+ ],
673
+ },
674
+ });
675
+ }),
676
+ ];
677
+ expect(() => validateBoot(features)).toThrow(
678
+ /\(writeForm\) handler "app:write:missing".*not a registered write-handler/,
679
+ );
680
+ });
681
+
682
+ test("validateBoot accepts a valid projectionDetail writeForm section (fw editable-detail-screens)", () => {
683
+ const features = [
684
+ defineFeature("app", (r) => {
685
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
686
+ access: { openToAll: true },
687
+ });
688
+ r.writeHandler(
689
+ "save",
690
+ z.object({}),
691
+ async () => ({ isSuccess: true as const, data: null }),
692
+ {
693
+ access: { roles: ["Admin"] },
694
+ },
695
+ );
696
+ r.screen({
697
+ id: "x",
698
+ type: "projectionDetail",
699
+ query: "app:query:foo:detail",
700
+ layout: {
701
+ sections: [
702
+ {
703
+ kind: "writeForm",
704
+ title: "s",
705
+ fieldDefs: { name: createTextField() },
706
+ fields: ["name"],
707
+ handler: "app:write:save",
708
+ submitLabel: "actions.save",
709
+ },
710
+ ],
711
+ },
712
+ });
713
+ }),
714
+ ];
715
+ expect(() => validateBoot(features)).not.toThrow();
716
+ });
717
+
718
+ // withBootValidatorFixture auto-fills every key requiredKeysFromFeature
719
+ // reports, so every other boot test in this file passes regardless of
720
+ // whether required-surface-keys.ts's writeForm branch names the right key
721
+ // — it would pass even for a wrong namespace. Bypass the fixture (raw
722
+ // validateBootRaw, no stubTranslations) to pin the actual key contract.
723
+ test("validateBoot requires a translation for a writeForm field's own label, namespaced under the write-form pseudo-entity (fw editable-detail-screens)", () => {
724
+ const features = [
725
+ defineFeature("app", (r) => {
726
+ // Screens always require their own "screen:<id>.title" key — supplied
727
+ // here so the throw below isolates the one key this test is about.
728
+ r.translations({ keys: { "screen:x.title": { en: "X" } } });
729
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
730
+ access: { openToAll: true },
731
+ });
732
+ r.writeHandler(
733
+ "save",
734
+ z.object({}),
735
+ async () => ({ isSuccess: true as const, data: null }),
736
+ { access: { roles: ["Admin"] } },
737
+ );
738
+ r.screen({
739
+ id: "x",
740
+ type: "projectionDetail",
741
+ query: "app:query:foo:detail",
742
+ layout: {
743
+ sections: [
744
+ {
745
+ kind: "writeForm",
746
+ title: "s",
747
+ fieldDefs: { name: createTextField() },
748
+ fields: ["name"],
749
+ handler: "app:write:save",
750
+ },
751
+ ],
752
+ },
753
+ });
754
+ }),
755
+ ];
756
+
757
+ expect(() => validateBootRaw(features)).toThrow(
758
+ '[i18n] Feature "app": required translation key missing: "app:entity:__write-form-section__:field:name"',
759
+ );
760
+ });
761
+
762
+ test("translating the writeForm field's pseudo-entity key lets boot pass", () => {
763
+ const features = [
764
+ defineFeature("app", (r) => {
765
+ r.translations({
766
+ keys: {
767
+ "screen:x.title": { en: "X" },
768
+ "app:entity:__write-form-section__:field:name": { en: "Name" },
769
+ },
770
+ });
771
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
772
+ access: { openToAll: true },
773
+ });
774
+ r.writeHandler(
775
+ "save",
776
+ z.object({}),
777
+ async () => ({ isSuccess: true as const, data: null }),
778
+ { access: { roles: ["Admin"] } },
779
+ );
780
+ r.screen({
781
+ id: "x",
782
+ type: "projectionDetail",
783
+ query: "app:query:foo:detail",
784
+ layout: {
785
+ sections: [
786
+ {
787
+ kind: "writeForm",
788
+ title: "s",
789
+ fieldDefs: { name: createTextField() },
790
+ fields: ["name"],
791
+ handler: "app:write:save",
792
+ },
793
+ ],
794
+ },
795
+ });
796
+ }),
797
+ ];
798
+
799
+ expect(() => validateBootRaw(features)).not.toThrow();
800
+ });
801
+
802
+ test("validateBoot rejects a writeForm section on entityEdit (fw editable-detail-screens)", () => {
803
+ const features = [
804
+ defineFeature("shop", (r) => {
805
+ r.entity("product", productEntity());
806
+ r.screen({
807
+ id: "product-edit",
808
+ type: "entityEdit",
809
+ entity: "product",
810
+ layout: {
811
+ sections: [
812
+ {
813
+ kind: "writeForm",
814
+ title: "s",
815
+ fieldDefs: { name: createTextField() },
816
+ fields: ["name"],
817
+ handler: "shop:write:save",
818
+ },
819
+ ],
820
+ },
821
+ });
822
+ }),
823
+ ];
824
+ expect(() => validateBoot(features)).toThrow(
825
+ /\(entityEdit\) writeForm section "s".*projectionDetail-only/,
826
+ );
827
+ });
828
+
829
+ test("validateBoot rejects a writeForm section on configEdit (fw editable-detail-screens)", () => {
830
+ const features = [
831
+ defineFeature("shop", (r) => {
832
+ r.config({
833
+ keys: { "site-name": createTenantConfig("text", { default: "" }) },
834
+ });
835
+ r.screen({
836
+ id: "settings",
837
+ type: "configEdit",
838
+ scope: "tenant",
839
+ configKeys: { siteName: "shop:config:site-name" },
840
+ fields: { siteName: { type: "text" } } as never,
841
+ layout: {
842
+ sections: [
843
+ {
844
+ kind: "writeForm",
845
+ title: "s",
846
+ fieldDefs: { name: createTextField() },
847
+ fields: ["name"],
848
+ handler: "shop:write:save",
849
+ },
850
+ ],
851
+ },
852
+ });
853
+ }),
854
+ ];
855
+ expect(() => validateBoot(features)).toThrow(
856
+ /\(configEdit\) writeForm section "s".*projectionDetail-only/,
857
+ );
858
+ });
859
+
860
+ test("validateBoot rejects a writeForm section on actionForm (fw editable-detail-screens)", () => {
861
+ const features = [
862
+ defineFeature("shop", (r) => {
863
+ r.writeHandler(
864
+ "restock",
865
+ z.object({}),
866
+ async () => ({ isSuccess: true as const, data: null }),
867
+ { access: { roles: ["Admin"] } },
868
+ );
869
+ r.screen({
870
+ id: "restock",
871
+ type: "actionForm",
872
+ handler: "shop:write:restock",
873
+ fields: { qty: { type: "number" } } as never,
874
+ layout: {
875
+ sections: [
876
+ {
877
+ kind: "writeForm",
878
+ title: "s",
879
+ fieldDefs: { name: createTextField() },
880
+ fields: ["name"],
881
+ handler: "shop:write:save",
882
+ },
883
+ ],
884
+ },
885
+ });
886
+ }),
887
+ ];
888
+ expect(() => validateBoot(features)).toThrow(
889
+ /\(actionForm\) writeForm section "s".*projectionDetail-only/,
890
+ );
891
+ });
892
+
438
893
  test("stores an entityEdit screen with sections + conditional fields", () => {
439
894
  const feature = defineFeature("shop", (r) => {
440
895
  r.entity("product", productEntity());
@@ -9,7 +9,12 @@ import { rowMetaFieldNames } from "../../db/table-builder";
9
9
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
10
10
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
11
11
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
12
- import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
12
+ import {
13
+ isExtensionEditSection,
14
+ isWriteFormEditSection,
15
+ normalizeEditField,
16
+ normalizeListColumn,
17
+ } from "../screen-helpers";
13
18
  import type { EntityDefinition, FeatureDefinition } from "../types";
14
19
  import type {
15
20
  DashboardCustomPanel,
@@ -617,6 +622,26 @@ export function validateScreens(
617
622
  `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has empty or non-string query.`,
618
623
  );
619
624
  }
625
+ if (screen.singleton === true && screen.idParam !== undefined) {
626
+ throw new Error(
627
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets both singleton: true ` +
628
+ `and idParam — singleton means the server picks the row from the caller's context, so there ` +
629
+ `is no row id to send under idParam. Remove idParam.`,
630
+ );
631
+ }
632
+ // singleton has no row id in the path, but the auto-generated "Edit"
633
+ // action (fw#2166, defaultEditAction in kumiko-screen.tsx) navigates
634
+ // to the detailFor entity's entityEdit screen using that path id —
635
+ // under singleton it would silently open that screen in CREATE mode
636
+ // instead of editing the singleton row. Reject rather than let that
637
+ // win silently, same as the idParam check above.
638
+ if (screen.singleton === true && screen.detailFor !== undefined) {
639
+ throw new Error(
640
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets both singleton: true ` +
641
+ `and detailFor — the auto-generated "Edit" action navigates using the path's row id, which a ` +
642
+ `singleton screen never has. Remove detailFor, or declare an explicit "edit" action instead.`,
643
+ );
644
+ }
620
645
  if (screen.layout.sections.length === 0) {
621
646
  throw new Error(
622
647
  `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has an empty sections list — ` +
@@ -732,6 +757,93 @@ export function validateScreens(
732
757
  );
733
758
  }
734
759
  }
760
+ if (section.rowActions !== undefined) {
761
+ for (const action of section.rowActions) {
762
+ if (action.kind === "navigate") {
763
+ const target = resolveRowActionNavigateTarget(
764
+ feature.name,
765
+ screenId,
766
+ "projectionDetail",
767
+ "rowAction",
768
+ action,
769
+ allScreenQns,
770
+ navTargetShortIds,
771
+ screensByShortId,
772
+ detailForScreens,
773
+ );
774
+ validateRowActionNavigateParams(
775
+ feature.name,
776
+ screenId,
777
+ "projectionDetail",
778
+ undefined,
779
+ action,
780
+ target,
781
+ );
782
+ } else if (!allWriteHandlerQns.has(action.handler)) {
783
+ throw new Error(
784
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section ` +
785
+ `"${section.title}" (relatedList) rowAction "${action.id}" handler "${action.handler}" ` +
786
+ `is not a registered write-handler. Check the QN spelling (expected ` +
787
+ `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
788
+ );
789
+ }
790
+ }
791
+ // section.rowClick (legacy, navigate-only) and a rowActions entry
792
+ // marked rowClick:true both claim the row-body click — same
793
+ // at-most-one constraint as entityList/projectionList's
794
+ // validateAtMostOneRowClick, just spanning two fields instead of one.
795
+ const rowClickActionCount = section.rowActions.filter(
796
+ (a) => a.kind === "navigate" && a.rowClick === true,
797
+ ).length;
798
+ const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
799
+ if (rowClickActionCount + legacyRowClickCount > 1) {
800
+ throw new Error(
801
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
802
+ `(relatedList) has both a rowClick and ${rowClickActionCount} rowActions marked ` +
803
+ `rowClick:true — at most one may fire on a row-body click.`,
804
+ );
805
+ }
806
+ }
807
+ continue;
808
+ }
809
+ if (isWriteFormEditSection(section)) {
810
+ if (Object.keys(section.fieldDefs).length === 0) {
811
+ throw new Error(
812
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
813
+ `(writeForm) has an empty fieldDefs map — declare at least one field type.`,
814
+ );
815
+ }
816
+ if (section.fields.length === 0) {
817
+ throw new Error(
818
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
819
+ `(writeForm) has zero fields — drop the section or add fields to it.`,
820
+ );
821
+ }
822
+ for (const f of section.fields) {
823
+ const fieldName = normalizeEditField(f).field;
824
+ if (section.fieldDefs[fieldName] === undefined) {
825
+ throw new Error(
826
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
827
+ `(writeForm) field "${fieldName}" has no entry in fieldDefs — every rendered field needs ` +
828
+ `a type declared there.`,
829
+ );
830
+ }
831
+ }
832
+ if (screen.layout.mode === "wizard") {
833
+ throw new Error(
834
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
835
+ `is kind "writeForm" in a wizard layout — a self-persisting form is not supported in a ` +
836
+ `stepped form. Remove mode: "wizard" or drop the writeForm section.`,
837
+ );
838
+ }
839
+ if (!allWriteHandlerQns.has(section.handler)) {
840
+ throw new Error(
841
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
842
+ `(writeForm) handler "${section.handler}" is not a registered write-handler. Check the QN ` +
843
+ `spelling (expected "<feature>:write:<short>") and that the handler is declared via ` +
844
+ `r.writeHandler(...).`,
845
+ );
846
+ }
735
847
  continue;
736
848
  }
737
849
  if (section.fields.length === 0) {
@@ -837,6 +949,12 @@ export function validateScreens(
837
949
  `primitive (fw#2166).`,
838
950
  );
839
951
  }
952
+ if (isWriteFormEditSection(section)) {
953
+ throw new Error(
954
+ `[Feature ${feature.name}] Screen "${screenId}" (configEdit) writeForm section ` +
955
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
956
+ );
957
+ }
840
958
  if (section.fields.length === 0) {
841
959
  throw new Error(
842
960
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
@@ -952,6 +1070,12 @@ export function validateScreens(
952
1070
  `primitive (fw#2166).`,
953
1071
  );
954
1072
  }
1073
+ if (isWriteFormEditSection(section)) {
1074
+ throw new Error(
1075
+ `[Feature ${feature.name}] Screen "${screenId}" (actionForm) writeForm section ` +
1076
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1077
+ );
1078
+ }
955
1079
  if (section.fields.length === 0) {
956
1080
  throw new Error(
957
1081
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
@@ -1308,6 +1432,12 @@ export function validateScreens(
1308
1432
  `primitive (fw#2166).`,
1309
1433
  );
1310
1434
  }
1435
+ if (isWriteFormEditSection(section)) {
1436
+ throw new Error(
1437
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) writeForm section ` +
1438
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1439
+ );
1440
+ }
1311
1441
  if (section.fields.length === 0) {
1312
1442
  throw new Error(
1313
1443
  `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
@@ -242,6 +242,7 @@ export { buildInsertSchema, buildUpdateSchema, fieldToZod } from "./schema-build
242
242
  export {
243
243
  isExtensionEditSection,
244
244
  isFieldsEditSection,
245
+ isWriteFormEditSection,
245
246
  normalizeEditField,
246
247
  normalizeListColumn,
247
248
  } from "./screen-helpers";
@@ -3,6 +3,7 @@ import type {
3
3
  EditFieldSpec,
4
4
  EditFieldsSection,
5
5
  EditSectionSpec,
6
+ EditWriteFormSection,
6
7
  FieldCondition,
7
8
  FormatSpec,
8
9
  ListColumnSpec,
@@ -16,6 +17,10 @@ export function isFieldsEditSection(section: EditSectionSpec): section is EditFi
16
17
  return section.kind === undefined || section.kind === "fields";
17
18
  }
18
19
 
20
+ export function isWriteFormEditSection(section: EditSectionSpec): section is EditWriteFormSection {
21
+ return section.kind === "writeForm";
22
+ }
23
+
19
24
  // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
20
25
  // authors who branch on the three FieldRenderer variants without manual
21
26
  // "format" in renderer checks.
@@ -275,6 +275,7 @@ export type {
275
275
  EditLayout,
276
276
  EditRelatedListSection,
277
277
  EditSectionSpec,
278
+ EditWriteFormSection,
278
279
  EntityEditScreenDefinition,
279
280
  EntityListScreenDefinition,
280
281
  FieldCondition,
@@ -1,4 +1,8 @@
1
- import { isExtensionEditSection, normalizeListColumn } from "../engine/screen-helpers";
1
+ import {
2
+ isExtensionEditSection,
3
+ isWriteFormEditSection,
4
+ normalizeListColumn,
5
+ } from "../engine/screen-helpers";
2
6
  import type {
3
7
  ActionFormScreenDefinition,
4
8
  ConfigEditScreenDefinition,
@@ -27,6 +31,11 @@ export const CONFIG_EDIT_ENTITY = "__config-edit__";
27
31
  /** Pseudo-entity for projectionDetail field labels (renderer projection-detail-shim). */
28
32
  export const PROJECTION_DETAIL_ENTITY = "__projection-detail__";
29
33
 
34
+ /** Pseudo-entity for a projectionDetail writeForm section's own fieldDefs
35
+ * (distinct from PROJECTION_DETAIL_ENTITY — the section's fields aren't
36
+ * drawn from the host record's display entity). */
37
+ export const WRITE_FORM_SECTION_ENTITY = "__write-form-section__";
38
+
30
39
  export function fieldLabelKey(featureName: string, entityName: string, fieldName: string): string {
31
40
  return `${featureName}:entity:${entityName}:field:${fieldName}`;
32
41
  }
@@ -247,6 +256,16 @@ export function requiredKeysFromScreen(
247
256
  const normalized = normalizeListColumn(col);
248
257
  if (normalized.label !== undefined) pushKey(out, normalized.label);
249
258
  }
259
+ for (const action of section.rowActions ?? []) pushRowActionKeys(out, action);
260
+ continue;
261
+ }
262
+ if (isWriteFormEditSection(section)) {
263
+ pushKey(out, section.title);
264
+ pushKey(out, section.submitLabel);
265
+ for (const f of section.fields) {
266
+ const fieldName = editFieldName(f);
267
+ out.add(fieldLabelKey(featureName, WRITE_FORM_SECTION_ENTITY, fieldName));
268
+ }
250
269
  continue;
251
270
  }
252
271
  pushKey(out, section.title);
@@ -30,6 +30,7 @@ export {
30
30
  isExtensionEditSection,
31
31
  isFieldsEditSection,
32
32
  isFormatSpec,
33
+ isWriteFormEditSection,
33
34
  normalizeEditField,
34
35
  normalizeListColumn,
35
36
  } from "../engine/screen-helpers";
@@ -74,6 +75,7 @@ export type {
74
75
  EditLayout,
75
76
  EditRelatedListSection,
76
77
  EditSectionSpec,
78
+ EditWriteFormSection,
77
79
  EntityEditScreenDefinition,
78
80
  EntityListScreenDefinition,
79
81
  FieldCondition,
@@ -100,7 +102,10 @@ export type {
100
102
  export type { TargetRef } from "../engine/types/target-ref";
101
103
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
102
104
  export type { WorkspaceDefinition } from "../engine/types/workspace";
103
- export { PROJECTION_DETAIL_ENTITY } from "../i18n/required-surface-keys";
105
+ export {
106
+ PROJECTION_DETAIL_ENTITY,
107
+ WRITE_FORM_SECTION_ENTITY,
108
+ } from "../i18n/required-surface-keys";
104
109
  export type {
105
110
  AppSchema,
106
111
  FeatureSchema,