@cosmicdrift/kumiko-framework 0.330.2 → 0.332.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/dist/db/blind-index-cleanup.d.ts +1 -0
- package/dist/db/blind-index-cleanup.js +12 -0
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/queries/event-consumer.d.ts +2 -1
- package/dist/db/queries/event-consumer.js +12 -2
- package/dist/engine/boot-validator/action-wiring.js +3 -0
- package/dist/engine/boot-validator/config-deps.d.ts +1 -0
- package/dist/engine/boot-validator/config-deps.js +24 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +20 -0
- package/dist/engine/boot-validator/index.js +6 -2
- package/dist/engine/boot-validator/projection-list-screens.js +11 -6
- package/dist/engine/boot-validator/query-output-columns.js +23 -2
- package/dist/engine/boot-validator/query-refs.d.ts +2 -1
- package/dist/engine/boot-validator/query-refs.js +24 -1
- package/dist/engine/boot-validator/screens.d.ts +3 -1
- package/dist/engine/boot-validator/screens.js +180 -78
- package/dist/engine/boot-validator/selector-owner-panels.d.ts +2 -0
- package/dist/engine/boot-validator/selector-owner-panels.js +25 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +18 -1
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/build-config-feature-schema.js +40 -6
- package/dist/engine/changeset-changes.js +6 -0
- package/dist/engine/config-helpers.d.ts +2 -0
- package/dist/engine/config-helpers.js +9 -0
- package/dist/engine/entity-handlers.js +1 -1
- package/dist/engine/feature-ui-extensions.d.ts +4 -2
- package/dist/engine/feature-ui-extensions.js +40 -3
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/project-app-schema-for-roles.js +30 -2
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/registry-ingest.js +2 -0
- package/dist/engine/registry-state.d.ts +2 -1
- package/dist/engine/registry-state.js +1 -0
- package/dist/engine/schema-builder.d.ts +3 -1
- package/dist/engine/schema-builder.js +5 -6
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/errors/reasons.js +7 -6
- package/dist/i18n/required-surface-keys.js +27 -10
- package/dist/pipeline/dispatch-write.js +26 -2
- package/dist/pipeline/event-consumer-state.js +6 -0
- package/dist/pipeline/event-dispatcher-admin.js +36 -16
- package/dist/pipeline/event-dispatcher-delivery.d.ts +2 -0
- package/dist/pipeline/event-dispatcher-delivery.js +22 -2
- package/dist/pipeline/event-dispatcher.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +4 -4
- package/src/changes.json +54 -0
|
@@ -471,9 +471,28 @@ function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpt
|
|
|
471
471
|
if (ftype === "money") {
|
|
472
472
|
validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
|
|
473
473
|
}
|
|
474
|
+
validateFormSelectOptions(featureName, screenId, context, fname, fdef);
|
|
474
475
|
}
|
|
475
476
|
return fieldNames;
|
|
476
477
|
}
|
|
478
|
+
// Static options XOR optionsQuery on inline-form select fields. The QN
|
|
479
|
+
// existence check lives in query-refs.ts with the other query refs.
|
|
480
|
+
function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef) {
|
|
481
|
+
if (fdef.type === "select") {
|
|
482
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
|
|
483
|
+
if (fdef.optionsQuery !== undefined) {
|
|
484
|
+
if (fdef.optionsQuery.length === 0) {
|
|
485
|
+
throw new Error(`${where} has an empty optionsQuery`);
|
|
486
|
+
}
|
|
487
|
+
if (fdef.options.length > 0) {
|
|
488
|
+
throw new Error(`${where} declares both options and optionsQuery — pick one`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
else if (fdef.optionsQueryPayload !== undefined) {
|
|
492
|
+
throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
477
496
|
// Fail-closed currency-source gate (fw#2839), the runtime half of the
|
|
478
497
|
// narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
|
|
479
498
|
// stop it. An inline form screen has no entity, so a money field there can't
|
|
@@ -681,6 +700,98 @@ function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
|
|
|
681
700
|
`a standalone r.nav() pointing at it, or set dormant: true if it's intentionally reachable ` +
|
|
682
701
|
`only via a direct link/redirect or a consuming app's own r.nav().`);
|
|
683
702
|
}
|
|
703
|
+
// Shared by projectionDetail relatedList sections and entityList.expandableRow:
|
|
704
|
+
// both render a relatedList whose rows come from an arbitrary query.
|
|
705
|
+
function validateRelatedListSection(args) {
|
|
706
|
+
const { featureName, featureMap, where, section } = args;
|
|
707
|
+
if (!section.query || typeof section.query !== "string") {
|
|
708
|
+
throw new Error(`[Feature ${featureName}] ${where} has empty or non-string query.`);
|
|
709
|
+
}
|
|
710
|
+
if (section.entity !== undefined) {
|
|
711
|
+
assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, "entity", section.entity, featureName, featureMap);
|
|
712
|
+
}
|
|
713
|
+
for (const col of section.columns) {
|
|
714
|
+
const normalizedCol = normalizeListColumn(col);
|
|
715
|
+
if (normalizedCol.refEntity !== undefined) {
|
|
716
|
+
assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, featureName, featureMap);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (section.rowClick !== undefined) {
|
|
720
|
+
const targetEntity = section.rowClick.entity;
|
|
721
|
+
const hasDetailScreen = [...featureMap.values()].some((f) => Object.values(f.screens).some((s) => s.detailFor === targetEntity));
|
|
722
|
+
if (!hasDetailScreen) {
|
|
723
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
724
|
+
`rowClick targets entity "${targetEntity}", but no screen declares ` +
|
|
725
|
+
`detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (section.rowActions !== undefined)
|
|
729
|
+
validateRelatedListRowActions(args, section.rowActions);
|
|
730
|
+
if (section.defaultSort !== undefined)
|
|
731
|
+
validateRelatedListDefaultSort(args, section.defaultSort);
|
|
732
|
+
if (section.facets !== undefined) {
|
|
733
|
+
validateListFacets(`[Feature ${featureName}] ${where}`, section.facets, section.columns, featureName, featureMap);
|
|
734
|
+
}
|
|
735
|
+
if (section.toolbarActions !== undefined) {
|
|
736
|
+
validateRelatedListToolbarActions(args, section.toolbarActions);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function validateRelatedListRowActions(args, rowActions) {
|
|
740
|
+
const { featureName, screenId, screenType, where, section, screens, allWriteHandlerQns, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
|
|
741
|
+
for (const action of rowActions) {
|
|
742
|
+
if (action.kind === "navigate") {
|
|
743
|
+
const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
744
|
+
validateRowActionNavigateParams(featureName, screenId, screenType, undefined, action, target);
|
|
745
|
+
}
|
|
746
|
+
else if (action.kind === "drawer") {
|
|
747
|
+
validateDrawerTargetAction(featureName, screenId, screenType, "rowAction", action, screens);
|
|
748
|
+
}
|
|
749
|
+
else if (!allWriteHandlerQns.has(action.handler)) {
|
|
750
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
751
|
+
`rowAction "${action.id}" handler "${action.handler}" ` +
|
|
752
|
+
`is not a registered write-handler. Check the QN spelling (expected ` +
|
|
753
|
+
`"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// section.rowClick (legacy, navigate-only) and a rowActions entry
|
|
757
|
+
// marked rowClick:true both claim the row-body click — same
|
|
758
|
+
// at-most-one constraint as entityList/projectionList's
|
|
759
|
+
// validateAtMostOneRowClick, just spanning two fields instead of one.
|
|
760
|
+
const rowClickActionCount = rowActions.filter((a) => a.kind === "navigate" && a.rowClick === true).length;
|
|
761
|
+
const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
|
|
762
|
+
if (rowClickActionCount + legacyRowClickCount > 1) {
|
|
763
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
764
|
+
`has both a rowClick and ${rowClickActionCount} rowActions marked ` +
|
|
765
|
+
`rowClick:true — at most one may fire on a row-body click.`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function validateRelatedListDefaultSort(args, defaultSort) {
|
|
769
|
+
const { featureName, where, section } = args;
|
|
770
|
+
const sortField = defaultSort.field;
|
|
771
|
+
const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
|
|
772
|
+
if (col === undefined) {
|
|
773
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
774
|
+
`defaultSort.field "${sortField}" is not a listed column.`);
|
|
775
|
+
}
|
|
776
|
+
if (normalizeListColumn(col).sortable !== true) {
|
|
777
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
778
|
+
`defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
|
|
779
|
+
`the column or pick another field.`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// Only drawer-kind and navigate actions that set tab are validated here;
|
|
783
|
+
// plain navigate (no tab) and writeHandler toolbarActions have no boot check yet.
|
|
784
|
+
function validateRelatedListToolbarActions(args, toolbarActions) {
|
|
785
|
+
const { featureName, screenId, screenType, screens, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
|
|
786
|
+
for (const action of toolbarActions) {
|
|
787
|
+
if (action.kind === "drawer") {
|
|
788
|
+
validateDrawerTargetAction(featureName, screenId, screenType, "toolbarAction", action, screens);
|
|
789
|
+
}
|
|
790
|
+
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
791
|
+
resolveRowActionNavigateTarget(featureName, screenId, screenType, "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
684
795
|
export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns, screensByShortId, detailForScreens) {
|
|
685
796
|
// navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
|
|
686
797
|
// der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
|
|
@@ -831,6 +942,37 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
831
942
|
}
|
|
832
943
|
}
|
|
833
944
|
}
|
|
945
|
+
if (Array.isArray(screen.header?.subtitle)) {
|
|
946
|
+
if (screen.header.subtitleHref !== undefined) {
|
|
947
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
|
|
948
|
+
`together with a multi-part header.subtitle — subtitleHref only works with the string form.`);
|
|
949
|
+
}
|
|
950
|
+
const seenPartFields = new Set();
|
|
951
|
+
for (const part of screen.header.subtitle) {
|
|
952
|
+
const partField = typeof part === "string" ? part : part.field;
|
|
953
|
+
if (seenPartFields.has(partField)) {
|
|
954
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.subtitle ` +
|
|
955
|
+
`lists field "${partField}" twice.`);
|
|
956
|
+
}
|
|
957
|
+
seenPartFields.add(partField);
|
|
958
|
+
const navigate = typeof part === "string" ? undefined : part.navigate;
|
|
959
|
+
if (navigate === undefined)
|
|
960
|
+
continue;
|
|
961
|
+
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
962
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.subtitle part ` +
|
|
963
|
+
`"${partField}" navigates with neither "screen" nor "entity" — a tab-only navigate has no link target.`);
|
|
964
|
+
}
|
|
965
|
+
resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "subtitle part", {
|
|
966
|
+
kind: "navigate",
|
|
967
|
+
id: partField,
|
|
968
|
+
label: partField,
|
|
969
|
+
...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
|
|
970
|
+
...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
|
|
971
|
+
...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
|
|
972
|
+
...(navigate.tab !== undefined ? { tab: navigate.tab } : {}),
|
|
973
|
+
}, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
834
976
|
for (const section of screen.layout.sections) {
|
|
835
977
|
const sectionLabel = section.title ?? section.id ?? "(untitled)";
|
|
836
978
|
if (section.actions !== undefined) {
|
|
@@ -876,87 +1018,25 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
876
1018
|
continue;
|
|
877
1019
|
}
|
|
878
1020
|
if (section.kind === "relatedList") {
|
|
879
|
-
if (!section.query || typeof section.query !== "string") {
|
|
880
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
881
|
-
`(relatedList) has empty or non-string query.`);
|
|
882
|
-
}
|
|
883
|
-
for (const col of section.columns) {
|
|
884
|
-
const normalizedCol = normalizeListColumn(col);
|
|
885
|
-
if (normalizedCol.refEntity !== undefined) {
|
|
886
|
-
assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" (relatedList)`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, feature.name, featureMap);
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
1021
|
if (screen.layout.mode === "wizard") {
|
|
890
1022
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
891
1023
|
`is kind "relatedList" in a wizard layout — a read-only list is not supported in a ` +
|
|
892
1024
|
`stepped form. Remove mode: "wizard" or drop the relatedList section.`);
|
|
893
1025
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}
|
|
909
|
-
else if (action.kind === "drawer") {
|
|
910
|
-
validateDrawerTargetAction(feature.name, screenId, "projectionDetail", "rowAction", action, feature.screens);
|
|
911
|
-
}
|
|
912
|
-
else if (!allWriteHandlerQns.has(action.handler)) {
|
|
913
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section ` +
|
|
914
|
-
`"${section.title}" (relatedList) rowAction "${action.id}" handler "${action.handler}" ` +
|
|
915
|
-
`is not a registered write-handler. Check the QN spelling (expected ` +
|
|
916
|
-
`"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
// section.rowClick (legacy, navigate-only) and a rowActions entry
|
|
920
|
-
// marked rowClick:true both claim the row-body click — same
|
|
921
|
-
// at-most-one constraint as entityList/projectionList's
|
|
922
|
-
// validateAtMostOneRowClick, just spanning two fields instead of one.
|
|
923
|
-
const rowClickActionCount = section.rowActions.filter((a) => a.kind === "navigate" && a.rowClick === true).length;
|
|
924
|
-
const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
|
|
925
|
-
if (rowClickActionCount + legacyRowClickCount > 1) {
|
|
926
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
927
|
-
`(relatedList) has both a rowClick and ${rowClickActionCount} rowActions marked ` +
|
|
928
|
-
`rowClick:true — at most one may fire on a row-body click.`);
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
if (section.defaultSort !== undefined) {
|
|
932
|
-
const sortField = section.defaultSort.field;
|
|
933
|
-
const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
|
|
934
|
-
if (col === undefined) {
|
|
935
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
936
|
-
`(relatedList) defaultSort.field "${sortField}" is not a listed column.`);
|
|
937
|
-
}
|
|
938
|
-
if (normalizeListColumn(col).sortable !== true) {
|
|
939
|
-
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
940
|
-
`(relatedList) defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
|
|
941
|
-
`the column or pick another field.`);
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
if (section.facets !== undefined) {
|
|
945
|
-
validateListFacets(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`, section.facets, section.columns, feature.name, featureMap);
|
|
946
|
-
}
|
|
947
|
-
// Only drawer-kind and navigate actions that set tab are validated
|
|
948
|
-
// here — plain navigate (no tab) and writeHandler toolbarActions
|
|
949
|
-
// have no boot check yet, same gap as above.
|
|
950
|
-
if (section.toolbarActions !== undefined) {
|
|
951
|
-
for (const action of section.toolbarActions) {
|
|
952
|
-
if (action.kind === "drawer") {
|
|
953
|
-
validateDrawerTargetAction(feature.name, screenId, "projectionDetail", "toolbarAction", action, feature.screens);
|
|
954
|
-
}
|
|
955
|
-
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
956
|
-
resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
1026
|
+
validateRelatedListSection({
|
|
1027
|
+
featureName: feature.name,
|
|
1028
|
+
featureMap,
|
|
1029
|
+
screenId,
|
|
1030
|
+
screenType: "projectionDetail",
|
|
1031
|
+
where: `Screen "${screenId}" (projectionDetail) section "${section.title}" (relatedList)`,
|
|
1032
|
+
section,
|
|
1033
|
+
screens: feature.screens,
|
|
1034
|
+
allWriteHandlerQns,
|
|
1035
|
+
allScreenQns,
|
|
1036
|
+
navTargetShortIds,
|
|
1037
|
+
screensByShortId,
|
|
1038
|
+
detailForScreens,
|
|
1039
|
+
});
|
|
960
1040
|
continue;
|
|
961
1041
|
}
|
|
962
1042
|
if (isWriteFormEditSection(section)) {
|
|
@@ -1048,6 +1128,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1048
1128
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
|
|
1049
1129
|
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
|
|
1050
1130
|
}
|
|
1131
|
+
validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef);
|
|
1051
1132
|
}
|
|
1052
1133
|
if (screen.layout.sections.length === 0) {
|
|
1053
1134
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
|
|
@@ -1340,6 +1421,27 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1340
1421
|
validateActionFieldRefs(feature.name, screenId, "toolbarAction", action.id, action, fieldNames, rowMeta);
|
|
1341
1422
|
}
|
|
1342
1423
|
}
|
|
1424
|
+
if (screen.expandableRow !== undefined) {
|
|
1425
|
+
const expandableRow = screen.expandableRow;
|
|
1426
|
+
validateRelatedListSection({
|
|
1427
|
+
featureName: feature.name,
|
|
1428
|
+
featureMap,
|
|
1429
|
+
screenId,
|
|
1430
|
+
screenType: "entityList expandableRow",
|
|
1431
|
+
where: `Screen "${screenId}" (entityList) expandableRow "${expandableRow.title}"`,
|
|
1432
|
+
section: expandableRow,
|
|
1433
|
+
screens: feature.screens,
|
|
1434
|
+
allWriteHandlerQns,
|
|
1435
|
+
allScreenQns,
|
|
1436
|
+
navTargetShortIds,
|
|
1437
|
+
screensByShortId,
|
|
1438
|
+
detailForScreens,
|
|
1439
|
+
});
|
|
1440
|
+
// Toolbar actions of the expansion evaluate against the host row.
|
|
1441
|
+
for (const action of expandableRow.toolbarActions ?? []) {
|
|
1442
|
+
validateActionFieldRefs(feature.name, screenId, "toolbarAction", action.id, action, fieldNames, rowMeta);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1343
1445
|
}
|
|
1344
1446
|
else {
|
|
1345
1447
|
// Same rationale as the columns check: an entityEdit layout with zero
|
|
@@ -1491,7 +1593,7 @@ const EMBEDDABLE_SCREEN_TYPES = new Set([
|
|
|
1491
1593
|
"configEdit",
|
|
1492
1594
|
"secretsEdit",
|
|
1493
1595
|
]);
|
|
1494
|
-
function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
|
|
1596
|
+
export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
|
|
1495
1597
|
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
|
|
1496
1598
|
const targetQn = resolveScreenTargetQn(featureName, panel.screen);
|
|
1497
1599
|
const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
|
|
@@ -1523,7 +1625,7 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
|
|
|
1523
1625
|
}
|
|
1524
1626
|
}
|
|
1525
1627
|
}
|
|
1526
|
-
function validateDashboardCustomPanel(featureName, screenId, panel) {
|
|
1628
|
+
export function validateDashboardCustomPanel(featureName, screenId, panel) {
|
|
1527
1629
|
if (panel.component.react === undefined && panel.component.native === undefined) {
|
|
1528
1630
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
|
|
1529
1631
|
`declare a react/native component marker.`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { buildQueryHandlerMap } from "./projection-list-screens.js";
|
|
2
|
+
import { checkQueryRef } from "./query-refs.js";
|
|
3
|
+
import { validateDashboardCustomPanel, validateDashboardScreenPanel } from "./screens.js";
|
|
4
|
+
// Owner panels are merged into a dashboard that is built after boot, so the screen and
|
|
5
|
+
// query refs are checked here against the registered features instead.
|
|
6
|
+
export function validateExtensionSelectorPanels(features) {
|
|
7
|
+
const featureMap = new Map(features.map((feature) => [feature.name, feature]));
|
|
8
|
+
const queryHandlers = buildQueryHandlerMap(features);
|
|
9
|
+
for (const feature of features) {
|
|
10
|
+
for (const selector of feature.extensionSelectors ?? []) {
|
|
11
|
+
const where = `extensionSelector("${selector.extensionName}")`;
|
|
12
|
+
for (const panel of selector.panels ?? []) {
|
|
13
|
+
const context = `[Feature ${feature.name}] ${where} panel "${panel.id}"`;
|
|
14
|
+
if (panel.kind === "custom") {
|
|
15
|
+
validateDashboardCustomPanel(feature.name, where, panel);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
validateDashboardScreenPanel(feature.name, where, panel, featureMap);
|
|
19
|
+
if (panel.visibleWhen !== undefined) {
|
|
20
|
+
checkQueryRef(queryHandlers, panel.visibleWhen.query, () => `${context} visibleWhen`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -11,6 +11,12 @@ function scopeActions(actions, sourceScreenEntity, implicitParams) {
|
|
|
11
11
|
function relatedListParentParamField(section) {
|
|
12
12
|
return section.parentFilter?.field ?? section.parentParam ?? "id";
|
|
13
13
|
}
|
|
14
|
+
function subtitlePartNavigates(screen) {
|
|
15
|
+
const subtitle = screen.header?.subtitle;
|
|
16
|
+
if (typeof subtitle === "string" || subtitle === undefined)
|
|
17
|
+
return [];
|
|
18
|
+
return subtitle.flatMap((part) => typeof part === "string" || part.navigate === undefined ? [] : [part.navigate]);
|
|
19
|
+
}
|
|
14
20
|
function projectionDetailNavigateActions(screen) {
|
|
15
21
|
return [
|
|
16
22
|
...scopeActions(screen.actions, screen.detailFor),
|
|
@@ -23,12 +29,23 @@ function projectionDetailNavigateActions(screen) {
|
|
|
23
29
|
...(screen.metrics ?? []).flatMap((metric) => typeof metric === "string" || metric.navigate === undefined
|
|
24
30
|
? []
|
|
25
31
|
: scopeActions([metric.navigate], undefined)),
|
|
32
|
+
...subtitlePartNavigates(screen).flatMap((navigate) => scopeActions([navigate], undefined)),
|
|
26
33
|
];
|
|
27
34
|
}
|
|
28
35
|
function navigateActionsOf(screen) {
|
|
29
36
|
switch (screen.type) {
|
|
30
37
|
case "entityList":
|
|
31
|
-
return
|
|
38
|
+
return [
|
|
39
|
+
...scopeActions(screen.rowActions, screen.entity),
|
|
40
|
+
...(screen.expandableRow === undefined
|
|
41
|
+
? []
|
|
42
|
+
: [
|
|
43
|
+
...scopeActions(screen.expandableRow.rowActions, undefined),
|
|
44
|
+
...scopeActions(screen.expandableRow.toolbarActions, undefined, {
|
|
45
|
+
pick: [relatedListParentParamField(screen.expandableRow)],
|
|
46
|
+
}),
|
|
47
|
+
]),
|
|
48
|
+
];
|
|
32
49
|
case "projectionList":
|
|
33
50
|
return scopeActions(screen.rowActions, undefined);
|
|
34
51
|
case "entityEdit":
|
|
@@ -394,6 +394,9 @@ function projectField(fieldDef) {
|
|
|
394
394
|
out["entity"] = def["entity"];
|
|
395
395
|
if (typeof def["labelField"] === "string")
|
|
396
396
|
out["labelField"] = def["labelField"];
|
|
397
|
+
// fw#2780: author-supplied picker source — without it the client falls back to the list handler.
|
|
398
|
+
if (typeof def["optionsQuery"] === "string")
|
|
399
|
+
out["optionsQuery"] = def["optionsQuery"];
|
|
397
400
|
if (typeof def["multiple"] === "boolean")
|
|
398
401
|
out["multiple"] = def["multiple"];
|
|
399
402
|
// MultiSelect: display picks checkboxes vs. combobox in the renderer,
|
|
@@ -56,6 +56,7 @@ const ELEVATED_ROLES = {
|
|
|
56
56
|
const MACHINE_WRITE_ROLE = "system";
|
|
57
57
|
const NO_SELECTOR_GATING = {
|
|
58
58
|
pluginsByOwnerGroup: new Map(),
|
|
59
|
+
ownerPanelsByGroup: new Map(),
|
|
59
60
|
pluginFeatures: new Set(),
|
|
60
61
|
secretQns: new Set(),
|
|
61
62
|
};
|
|
@@ -93,6 +94,16 @@ function collectVisibleSelectorOwners(registry, visible) {
|
|
|
93
94
|
}
|
|
94
95
|
return { ownerGroupByExtension, selectorFeatures };
|
|
95
96
|
}
|
|
97
|
+
function collectOwnerPanelsByGroup(registry, ownerGroupByExtension) {
|
|
98
|
+
const panelsByGroup = new Map();
|
|
99
|
+
for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
|
|
100
|
+
const panels = registry.getExtensionSelectorPanels(extension);
|
|
101
|
+
if (panels.length === 0)
|
|
102
|
+
continue;
|
|
103
|
+
panelsByGroup.set(ownerGroup, [...(panelsByGroup.get(ownerGroup) ?? []), ...panels]);
|
|
104
|
+
}
|
|
105
|
+
return panelsByGroup;
|
|
106
|
+
}
|
|
96
107
|
function countSelectorUsagesByFeature(registry) {
|
|
97
108
|
const usageCountByFeature = new Map();
|
|
98
109
|
for (const [extensionName] of registry.getAllExtensionSelectors()) {
|
|
@@ -112,7 +123,9 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
|
112
123
|
if (ownerGroupByExtension.size === 0)
|
|
113
124
|
return NO_SELECTOR_GATING;
|
|
114
125
|
const usageCountByFeature = countSelectorUsagesByFeature(registry);
|
|
115
|
-
const
|
|
126
|
+
const ownerPanelsByGroup = collectOwnerPanelsByGroup(registry, ownerGroupByExtension);
|
|
127
|
+
// A selector with panels but no gated plugin still becomes a dashboard.
|
|
128
|
+
const pluginsByOwnerGroup = new Map([...ownerPanelsByGroup.keys()].map((ownerGroup) => [ownerGroup, []]));
|
|
116
129
|
const pluginFeatures = new Set();
|
|
117
130
|
const secretQns = new Set();
|
|
118
131
|
for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
|
|
@@ -135,7 +148,7 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
|
135
148
|
secretQns.add(s.qn);
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
|
-
return { pluginsByOwnerGroup, pluginFeatures, secretQns };
|
|
151
|
+
return { pluginsByOwnerGroup, ownerPanelsByGroup, pluginFeatures, secretQns };
|
|
139
152
|
}
|
|
140
153
|
// A tenant/user-home key with an elevated write role (SystemAdmin on a
|
|
141
154
|
// tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
|
|
@@ -173,7 +186,7 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
|
|
|
173
186
|
out.navs.push(buildFeatureNav(scope, feature, ordered, access, hub.declaredTranslationKeys));
|
|
174
187
|
continue;
|
|
175
188
|
}
|
|
176
|
-
const ownerHub = buildSelectorOwnerDashboard(feature, ordered, access, plugins, hub, secretsAccess);
|
|
189
|
+
const ownerHub = buildSelectorOwnerDashboard(feature, ordered, access, plugins, gating.ownerPanelsByGroup.get(feature) ?? [], hub, secretsAccess);
|
|
177
190
|
out.screens.push(...ownerHub.screens);
|
|
178
191
|
out.navs.push(buildFeatureNav(scope, feature, ordered, ownerHub.access, hub.declaredTranslationKeys));
|
|
179
192
|
Object.assign(out.translations, ownerHub.translations);
|
|
@@ -183,9 +196,20 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
|
|
|
183
196
|
function pluginTenantScreenId(plugin) {
|
|
184
197
|
return `${plugin.feature}-tenant`;
|
|
185
198
|
}
|
|
199
|
+
function assertUniquePanelIds(ownerGroup, panels) {
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
for (const panel of panels) {
|
|
202
|
+
if (seen.has(panel.id)) {
|
|
203
|
+
throw new Error(`Settings dashboard "${ownerGroup}-tenant" has duplicate panel id "${panel.id}" — an ` +
|
|
204
|
+
`extensionSelector panel id collides with another panel of the same dashboard.`);
|
|
205
|
+
}
|
|
206
|
+
seen.add(panel.id);
|
|
207
|
+
}
|
|
208
|
+
return panels;
|
|
209
|
+
}
|
|
186
210
|
// Plugin screens keep their `${F}-tenant` ids so consumer title keys stay valid;
|
|
187
211
|
// they are dormant because only this dashboard embeds them.
|
|
188
|
-
function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, hub, secretsAccess) {
|
|
212
|
+
function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
|
|
189
213
|
const selectionScreenId = `${ownerGroup}-tenant-selection`;
|
|
190
214
|
const selectionScreen = {
|
|
191
215
|
...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub),
|
|
@@ -240,11 +264,12 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
240
264
|
id: `${ownerGroup}-tenant`,
|
|
241
265
|
type: "dashboard",
|
|
242
266
|
description: EXTENSION_SELECTOR_HINT_KEY,
|
|
243
|
-
panels: [
|
|
267
|
+
panels: assertUniquePanelIds(ownerGroup, [
|
|
244
268
|
{ kind: "screen", id: "selection", screen: selectionScreenId },
|
|
269
|
+
...ownerPanels,
|
|
245
270
|
...configPanels,
|
|
246
271
|
...secretsPanels,
|
|
247
|
-
],
|
|
272
|
+
]),
|
|
248
273
|
access,
|
|
249
274
|
};
|
|
250
275
|
return { screens: [dashboard, ...screens], access, translations };
|
|
@@ -504,6 +529,15 @@ function deriveField(def, selectorPluginIds) {
|
|
|
504
529
|
case "boolean":
|
|
505
530
|
return createBooleanField();
|
|
506
531
|
case "select":
|
|
532
|
+
if (def.optionsQuery !== undefined) {
|
|
533
|
+
return createSelectField({
|
|
534
|
+
options: [],
|
|
535
|
+
optionsQuery: def.optionsQuery,
|
|
536
|
+
...(def.optionsQueryPayload !== undefined && {
|
|
537
|
+
optionsQueryPayload: def.optionsQueryPayload,
|
|
538
|
+
}),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
507
541
|
return def.options !== undefined && def.options.length > 0
|
|
508
542
|
? createSelectField({ options: def.options })
|
|
509
543
|
: createTextField({ personal: false, reason: "system_metadata" });
|
|
@@ -67,6 +67,12 @@ export function parseChangesetChanges(markdown, source) {
|
|
|
67
67
|
let match = BLOCK_RE.exec(markdown);
|
|
68
68
|
while (match !== null) {
|
|
69
69
|
const block = match[1];
|
|
70
|
+
// BLOCK_RE closes at the first line starting with "-->"; text after it on
|
|
71
|
+
// that line means a field value contained one and the block ended early.
|
|
72
|
+
const restOfLine = markdown.slice(match.index + match[0].length).split("\n", 1)[0] ?? "";
|
|
73
|
+
if (restOfLine.trim() !== "") {
|
|
74
|
+
throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
|
|
75
|
+
}
|
|
70
76
|
if (!block)
|
|
71
77
|
throw new Error(`${source}: empty kumiko-changes block`);
|
|
72
78
|
const fields = parseBlock(block, source);
|
|
@@ -18,6 +18,8 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
|
|
|
18
18
|
encrypted?: boolean;
|
|
19
19
|
piiEncrypted?: T extends "text" ? boolean : never;
|
|
20
20
|
options?: readonly string[];
|
|
21
|
+
optionsQuery?: T extends "select" ? string : never;
|
|
22
|
+
optionsQueryPayload?: T extends "select" ? Readonly<Record<string, string | number | boolean>> : never;
|
|
21
23
|
bounds?: T extends "number" ? ConfigBounds : never;
|
|
22
24
|
pattern?: T extends "text" ? {
|
|
23
25
|
regex: string;
|
|
@@ -35,6 +35,14 @@ const SCOPE_DEFAULTS = {
|
|
|
35
35
|
user: { write: access.all, read: access.all },
|
|
36
36
|
};
|
|
37
37
|
// --- Factory ---
|
|
38
|
+
function optionsQueryProps(opts) {
|
|
39
|
+
return {
|
|
40
|
+
...(opts.optionsQuery !== undefined && { optionsQuery: opts.optionsQuery }),
|
|
41
|
+
...(opts.optionsQueryPayload !== undefined && {
|
|
42
|
+
optionsQueryPayload: opts.optionsQueryPayload,
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
38
46
|
function createConfigKey(scope, type, opts = {}) {
|
|
39
47
|
const defaults = SCOPE_DEFAULTS[scope];
|
|
40
48
|
return {
|
|
@@ -48,6 +56,7 @@ function createConfigKey(scope, type, opts = {}) {
|
|
|
48
56
|
...(opts.encrypted ? { encrypted: true } : {}),
|
|
49
57
|
...(opts.piiEncrypted ? { piiEncrypted: true } : {}),
|
|
50
58
|
...(opts.options ? { options: opts.options } : {}),
|
|
59
|
+
...optionsQueryProps(opts),
|
|
51
60
|
bounds: opts.bounds, // @cast-boundary schema-walk
|
|
52
61
|
...(opts.pattern ? { pattern: opts.pattern } : {}),
|
|
53
62
|
computed: opts.computed,
|
|
@@ -224,7 +224,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
224
224
|
let handler;
|
|
225
225
|
switch (verb) {
|
|
226
226
|
case "create":
|
|
227
|
-
schema = buildInsertSchema(entity, undefined, excludedFields);
|
|
227
|
+
schema = buildInsertSchema(entity, undefined, excludedFields, { allowCallerId: true });
|
|
228
228
|
handler = async (event, ctx) => {
|
|
229
229
|
const { runPreSave } = ctx;
|
|
230
230
|
// A caller-chosen id (deterministic-id idempotent creates) is only
|
|
@@ -2,7 +2,7 @@ import { type WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websock
|
|
|
2
2
|
import type { EntityTableMeta } from "../db/entity-table-meta.js";
|
|
3
3
|
import type { FeatureBuilderState } from "./feature-builder-state.js";
|
|
4
4
|
import type { HttpRouteDefinition } from "./types/http-route.js";
|
|
5
|
-
import type { BootCheckFn, EntityProjectionExtension, EscapeHatchDeclaration, HookPhase, LifecycleHookFn, LifecycleHookType, MultiStreamProjectionDefinition, NameOrRef, ProjectionDefinition, RegistrarExtensionDef, SearchPayloadContributorFn, StoreTableOptions, TreeActionDef, TreeActionsHandle, ValidationHookFn } from "./types/index.js";
|
|
5
|
+
import type { BootCheckFn, EntityProjectionExtension, EscapeHatchDeclaration, ExtensionSelectorPanel, HookPhase, LifecycleHookFn, LifecycleHookType, MultiStreamProjectionDefinition, NameOrRef, ProjectionDefinition, RegistrarExtensionDef, SearchPayloadContributorFn, StoreTableOptions, TreeActionDef, TreeActionsHandle, ValidationHookFn } from "./types/index.js";
|
|
6
6
|
import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
|
|
7
7
|
import type { ScreenDefinition } from "./types/screen.js";
|
|
8
8
|
import type { WorkspaceDefinition } from "./types/workspace.js";
|
|
@@ -21,7 +21,9 @@ export declare function buildUiExtensionsMethods<TName extends string>(state: Fe
|
|
|
21
21
|
} & object), entityRef?: NameOrRef, options?: object): void;
|
|
22
22
|
extensionSelector(extensionName: string, key: {
|
|
23
23
|
readonly name: string;
|
|
24
|
-
} | string
|
|
24
|
+
} | string, options?: {
|
|
25
|
+
readonly panels?: readonly ExtensionSelectorPanel[];
|
|
26
|
+
}): void;
|
|
25
27
|
/**
|
|
26
28
|
* Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
|
|
27
29
|
* unter dem genannten Namen bereit. Die eigentliche Implementation
|
|
@@ -2,8 +2,39 @@ import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES,
|
|
|
2
2
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
|
|
3
3
|
import { LifecycleHookTypes } from "./constants.js";
|
|
4
4
|
import { resolveName } from "./handler-helpers.js";
|
|
5
|
-
import { isKebabSegment, toKebab } from "./qualified-name.js";
|
|
5
|
+
import { isKebabSegment, isValidQn, qualifyEntityName, toKebab } from "./qualified-name.js";
|
|
6
6
|
import { HookPhases } from "./types/index.js";
|
|
7
|
+
// Builds hooks/extensions/projections/screens/nav/workspace/tables/tree-actions
|
|
8
|
+
// registrar methods.
|
|
9
|
+
// Id the generated owner dashboard reserves for its selector panel.
|
|
10
|
+
const RESERVED_SELECTION_PANEL_ID = "selection";
|
|
11
|
+
function assertSelectorPanelIds(featureName, extensionName, panels) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
for (const { id } of panels) {
|
|
14
|
+
if (id === "" || id === RESERVED_SELECTION_PANEL_ID || seen.has(id)) {
|
|
15
|
+
throw new Error(`[Feature ${featureName}] extensionSelector("${extensionName}") panel id "${id}" must be unique, ` +
|
|
16
|
+
`non-empty and not "${RESERVED_SELECTION_PANEL_ID}" (reserved for the selector panel).`);
|
|
17
|
+
}
|
|
18
|
+
seen.add(id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// The owner dashboard lives in the "config" namespace, so a short screen ref must be
|
|
22
|
+
// qualified against the declaring feature here. Untyped authors can pass other kinds.
|
|
23
|
+
function qualifySelectorPanel(featureName, panel) {
|
|
24
|
+
if (panel.kind === "custom")
|
|
25
|
+
return panel;
|
|
26
|
+
if (panel.kind === "screen") {
|
|
27
|
+
if ("label" in panel) {
|
|
28
|
+
throw new Error(`[Feature ${featureName}] extensionSelector screen panel "${panel.id}" must not set a label — ` +
|
|
29
|
+
`the settings dashboard has no i18n namespace for it; the embedded screen carries its own title.`);
|
|
30
|
+
}
|
|
31
|
+
if (panel.screen === "" || isValidQn(panel.screen))
|
|
32
|
+
return panel;
|
|
33
|
+
return { ...panel, screen: qualifyEntityName(featureName, "screen", panel.screen) };
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`[Feature ${featureName}] extensionSelector panel ${JSON.stringify(panel)} has an unsupported kind — ` +
|
|
36
|
+
`only "custom" and "screen" panels are allowed.`);
|
|
37
|
+
}
|
|
7
38
|
function isEntityWideHookType(type) {
|
|
8
39
|
return (type === LifecycleHookTypes.postSave ||
|
|
9
40
|
type === LifecycleHookTypes.preDelete ||
|
|
@@ -184,13 +215,19 @@ export function buildUiExtensionsMethods(state, name) {
|
|
|
184
215
|
options: optionsBag,
|
|
185
216
|
});
|
|
186
217
|
},
|
|
187
|
-
extensionSelector(extensionName, key) {
|
|
218
|
+
extensionSelector(extensionName, key, options) {
|
|
188
219
|
if (state.extensionSelectors.some((s) => s.extensionName === extensionName)) {
|
|
189
220
|
throw new Error(`[Feature ${name}] extensionSelector("${extensionName}") declared twice — ` +
|
|
190
221
|
`one selector key per extension point.`);
|
|
191
222
|
}
|
|
192
223
|
const qualifiedKey = typeof key === "string" ? key : key.name;
|
|
193
|
-
|
|
224
|
+
const panels = options?.panels?.map((panel) => qualifySelectorPanel(name, panel));
|
|
225
|
+
assertSelectorPanelIds(name, extensionName, panels ?? []);
|
|
226
|
+
state.extensionSelectors.push({
|
|
227
|
+
extensionName,
|
|
228
|
+
qualifiedKey,
|
|
229
|
+
...(panels !== undefined && { panels }),
|
|
230
|
+
});
|
|
194
231
|
},
|
|
195
232
|
/**
|
|
196
233
|
* Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
|