@cosmicdrift/kumiko-framework 0.239.0 → 0.240.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.240.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.240.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.240.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -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,
@@ -732,6 +737,93 @@ export function validateScreens(
732
737
  );
733
738
  }
734
739
  }
740
+ if (section.rowActions !== undefined) {
741
+ for (const action of section.rowActions) {
742
+ if (action.kind === "navigate") {
743
+ const target = resolveRowActionNavigateTarget(
744
+ feature.name,
745
+ screenId,
746
+ "projectionDetail",
747
+ "rowAction",
748
+ action,
749
+ allScreenQns,
750
+ navTargetShortIds,
751
+ screensByShortId,
752
+ detailForScreens,
753
+ );
754
+ validateRowActionNavigateParams(
755
+ feature.name,
756
+ screenId,
757
+ "projectionDetail",
758
+ undefined,
759
+ action,
760
+ target,
761
+ );
762
+ } else if (!allWriteHandlerQns.has(action.handler)) {
763
+ throw new Error(
764
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section ` +
765
+ `"${section.title}" (relatedList) rowAction "${action.id}" handler "${action.handler}" ` +
766
+ `is not a registered write-handler. Check the QN spelling (expected ` +
767
+ `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
768
+ );
769
+ }
770
+ }
771
+ // section.rowClick (legacy, navigate-only) and a rowActions entry
772
+ // marked rowClick:true both claim the row-body click — same
773
+ // at-most-one constraint as entityList/projectionList's
774
+ // validateAtMostOneRowClick, just spanning two fields instead of one.
775
+ const rowClickActionCount = section.rowActions.filter(
776
+ (a) => a.kind === "navigate" && a.rowClick === true,
777
+ ).length;
778
+ const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
779
+ if (rowClickActionCount + legacyRowClickCount > 1) {
780
+ throw new Error(
781
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
782
+ `(relatedList) has both a rowClick and ${rowClickActionCount} rowActions marked ` +
783
+ `rowClick:true — at most one may fire on a row-body click.`,
784
+ );
785
+ }
786
+ }
787
+ continue;
788
+ }
789
+ if (isWriteFormEditSection(section)) {
790
+ if (Object.keys(section.fieldDefs).length === 0) {
791
+ throw new Error(
792
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
793
+ `(writeForm) has an empty fieldDefs map — declare at least one field type.`,
794
+ );
795
+ }
796
+ if (section.fields.length === 0) {
797
+ throw new Error(
798
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
799
+ `(writeForm) has zero fields — drop the section or add fields to it.`,
800
+ );
801
+ }
802
+ for (const f of section.fields) {
803
+ const fieldName = normalizeEditField(f).field;
804
+ if (section.fieldDefs[fieldName] === undefined) {
805
+ throw new Error(
806
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
807
+ `(writeForm) field "${fieldName}" has no entry in fieldDefs — every rendered field needs ` +
808
+ `a type declared there.`,
809
+ );
810
+ }
811
+ }
812
+ if (screen.layout.mode === "wizard") {
813
+ throw new Error(
814
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
815
+ `is kind "writeForm" in a wizard layout — a self-persisting form is not supported in a ` +
816
+ `stepped form. Remove mode: "wizard" or drop the writeForm section.`,
817
+ );
818
+ }
819
+ if (!allWriteHandlerQns.has(section.handler)) {
820
+ throw new Error(
821
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
822
+ `(writeForm) handler "${section.handler}" is not a registered write-handler. Check the QN ` +
823
+ `spelling (expected "<feature>:write:<short>") and that the handler is declared via ` +
824
+ `r.writeHandler(...).`,
825
+ );
826
+ }
735
827
  continue;
736
828
  }
737
829
  if (section.fields.length === 0) {
@@ -837,6 +929,12 @@ export function validateScreens(
837
929
  `primitive (fw#2166).`,
838
930
  );
839
931
  }
932
+ if (isWriteFormEditSection(section)) {
933
+ throw new Error(
934
+ `[Feature ${feature.name}] Screen "${screenId}" (configEdit) writeForm section ` +
935
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
936
+ );
937
+ }
840
938
  if (section.fields.length === 0) {
841
939
  throw new Error(
842
940
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
@@ -952,6 +1050,12 @@ export function validateScreens(
952
1050
  `primitive (fw#2166).`,
953
1051
  );
954
1052
  }
1053
+ if (isWriteFormEditSection(section)) {
1054
+ throw new Error(
1055
+ `[Feature ${feature.name}] Screen "${screenId}" (actionForm) writeForm section ` +
1056
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1057
+ );
1058
+ }
955
1059
  if (section.fields.length === 0) {
956
1060
  throw new Error(
957
1061
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
@@ -1308,6 +1412,12 @@ export function validateScreens(
1308
1412
  `primitive (fw#2166).`,
1309
1413
  );
1310
1414
  }
1415
+ if (isWriteFormEditSection(section)) {
1416
+ throw new Error(
1417
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) writeForm section ` +
1418
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1419
+ );
1420
+ }
1311
1421
  if (section.fields.length === 0) {
1312
1422
  throw new Error(
1313
1423
  `[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,