@cosmicdrift/kumiko-framework 0.238.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 +3 -3
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +381 -0
- package/src/db/event-store-executor-read.ts +186 -34
- package/src/engine/__tests__/engine.test.ts +77 -28
- package/src/engine/__tests__/screen.test.ts +455 -0
- package/src/engine/boot-validator/screens.ts +111 -1
- package/src/engine/entity-handlers.ts +7 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/registry-facade.ts +3 -3
- package/src/engine/registry-state.ts +13 -2
- package/src/engine/registry-validate.ts +47 -14
- package/src/engine/registry.ts +2 -2
- package/src/engine/screen-helpers.ts +5 -0
- package/src/engine/types/index.ts +1 -0
- package/src/i18n/required-surface-keys.ts +20 -1
- package/src/ui-types/index.ts +6 -1
|
@@ -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 {
|
|
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}" ` +
|
|
@@ -339,6 +339,13 @@ export function defineEntityQueryHandler(
|
|
|
339
339
|
const result = await executor.list(listPayload, query.user, db, {
|
|
340
340
|
...(ctx.searchAdapter !== undefined && { searchAdapter: ctx.searchAdapter }),
|
|
341
341
|
...(ctx.includeDeleted === true && { includeDeleted: true }),
|
|
342
|
+
// fw#2660 — registry access only exists at request time, so the
|
|
343
|
+
// searchable-reference cache + entity resolver travel through the
|
|
344
|
+
// same runtime-override extension point as searchAdapter above.
|
|
345
|
+
referenceSearch: {
|
|
346
|
+
fields: ctx.registry.getSearchableReferences(entityName),
|
|
347
|
+
resolveEntity: (name) => ctx.registry.getEntity(name),
|
|
348
|
+
},
|
|
342
349
|
});
|
|
343
350
|
const enrichedRows = hasRefFields
|
|
344
351
|
? await enrichWithReferences(
|
package/src/engine/index.ts
CHANGED
|
@@ -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";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IncomingRelation, RegistryState } from "./registry-state";
|
|
1
|
+
import type { IncomingRelation, RegistryState, SearchableReferenceField } from "./registry-state";
|
|
2
2
|
import { filterByPhase, filterOwned } from "./registry-state";
|
|
3
3
|
import type {
|
|
4
4
|
AuthClaimsHookDef,
|
|
@@ -97,8 +97,8 @@ export function buildRegistryFacade(state: RegistryState): Registry {
|
|
|
97
97
|
return (state.relationMap.get(entityName) ?? {}) as EntityRelations; // @cast-boundary schema-walk
|
|
98
98
|
},
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
return state.
|
|
100
|
+
getSearchableReferences(entityName: string): readonly SearchableReferenceField[] {
|
|
101
|
+
return state.searchableReferencesCache.get(entityName) ?? [];
|
|
102
102
|
},
|
|
103
103
|
|
|
104
104
|
getIncomingRelations(entityName: string): readonly IncomingRelation[] {
|
|
@@ -49,6 +49,17 @@ export type IncomingRelation = {
|
|
|
49
49
|
relation: RelationDefinition;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
// One entry per `ReferenceFieldDef` with `searchable: true` (fw#2660) —
|
|
53
|
+
// resolved once at boot so the read-path never re-parses `field.entity`
|
|
54
|
+
// per request. `labelField` is guaranteed defined here: buildSearchableSortableCaches
|
|
55
|
+
// throws at boot for a searchable reference missing an explicit labelField
|
|
56
|
+
// (the "id" default is a UUID column, ILIKE against it would 500 at request time).
|
|
57
|
+
export type SearchableReferenceField = {
|
|
58
|
+
readonly fieldName: string;
|
|
59
|
+
readonly targetEntityName: string;
|
|
60
|
+
readonly labelField: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
52
63
|
const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
|
|
53
64
|
|
|
54
65
|
// Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
|
|
@@ -223,7 +234,7 @@ export type RegistryState = {
|
|
|
223
234
|
treeActionsMap: Map<string, Readonly<Record<string, TreeActionDef>>>;
|
|
224
235
|
searchableFieldsCache: Map<string, readonly string[]>;
|
|
225
236
|
sortableFieldsCache: Map<string, readonly string[]>;
|
|
226
|
-
|
|
237
|
+
searchableReferencesCache: Map<string, readonly SearchableReferenceField[]>;
|
|
227
238
|
incomingRelationsCache: Map<string, IncomingRelation[]>;
|
|
228
239
|
hasRateLimitedHandlerCached: boolean;
|
|
229
240
|
};
|
|
@@ -286,7 +297,7 @@ export function createInitialState(): RegistryState {
|
|
|
286
297
|
treeActionsMap: new Map(),
|
|
287
298
|
searchableFieldsCache: new Map(),
|
|
288
299
|
sortableFieldsCache: new Map(),
|
|
289
|
-
|
|
300
|
+
searchableReferencesCache: new Map(),
|
|
290
301
|
incomingRelationsCache: new Map(),
|
|
291
302
|
hasRateLimitedHandlerCached: false,
|
|
292
303
|
};
|