@loj-lang/rdsl-compiler 0.5.0 → 0.6.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/codegen.d.ts.map +1 -1
- package/dist/codegen.js +3952 -1171
- package/dist/codegen.js.map +1 -1
- package/dist/dependency-graph.js +2 -2
- package/dist/dependency-graph.js.map +1 -1
- package/dist/expr.d.ts +24 -3
- package/dist/expr.d.ts.map +1 -1
- package/dist/expr.js +211 -8
- package/dist/expr.js.map +1 -1
- package/dist/flow-proof.d.ts +23 -3
- package/dist/flow-proof.d.ts.map +1 -1
- package/dist/flow-proof.js +179 -26
- package/dist/flow-proof.js.map +1 -1
- package/dist/formula-proof.d.ts +30 -0
- package/dist/formula-proof.d.ts.map +1 -0
- package/dist/formula-proof.js +596 -0
- package/dist/formula-proof.js.map +1 -0
- package/dist/host-files.d.ts +34 -0
- package/dist/host-files.d.ts.map +1 -1
- package/dist/host-files.js +418 -20
- package/dist/host-files.js.map +1 -1
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +147 -9
- package/dist/index.js.map +1 -1
- package/dist/ir.d.ts +142 -8
- package/dist/ir.d.ts.map +1 -1
- package/dist/manifest.d.ts +51 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +107 -5
- package/dist/manifest.js.map +1 -1
- package/dist/model-display-field.d.ts +3 -0
- package/dist/model-display-field.d.ts.map +1 -0
- package/dist/model-display-field.js +5 -0
- package/dist/model-display-field.js.map +1 -0
- package/dist/model-field-type.d.ts +10 -0
- package/dist/model-field-type.d.ts.map +1 -0
- package/dist/model-field-type.js +25 -0
- package/dist/model-field-type.js.map +1 -0
- package/dist/node-inspect.js +12 -5
- package/dist/node-inspect.js.map +1 -1
- package/dist/normalize.d.ts +4 -0
- package/dist/normalize.d.ts.map +1 -1
- package/dist/normalize.js +492 -59
- package/dist/normalize.js.map +1 -1
- package/dist/page-table-block.d.ts +11 -2
- package/dist/page-table-block.d.ts.map +1 -1
- package/dist/page-table-block.js +33 -1
- package/dist/page-table-block.js.map +1 -1
- package/dist/parser.d.ts +151 -4
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +798 -40
- package/dist/parser.js.map +1 -1
- package/dist/relation-projection.d.ts +1 -1
- package/dist/relation-projection.d.ts.map +1 -1
- package/dist/rules-proof.d.ts +16 -1
- package/dist/rules-proof.d.ts.map +1 -1
- package/dist/rules-proof.js +522 -28
- package/dist/rules-proof.js.map +1 -1
- package/dist/source-files.d.ts +7 -0
- package/dist/source-files.d.ts.map +1 -1
- package/dist/source-files.js +15 -2
- package/dist/source-files.js.map +1 -1
- package/dist/style-proof.d.ts +16 -2
- package/dist/style-proof.d.ts.map +1 -1
- package/dist/style-proof.js +156 -11
- package/dist/style-proof.js.map +1 -1
- package/dist/validator.d.ts +10 -0
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +939 -86
- package/dist/validator.js.map +1 -1
- package/package.json +31 -10
package/dist/validator.js
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
* - Effect targets reference known resources
|
|
11
11
|
* - Required fields are present
|
|
12
12
|
*/
|
|
13
|
+
import { lintSharedSnapshotCandidates, validateDuplicateSharedModelFieldNames, validateGeneratedPersistenceFieldNames, validateSharedCascadeFieldDecorator, validateSharedDisplayFieldTypes, validateSharedEnumFieldDecorator, validateSharedModelDisplayFieldUsage, validateSharedModelRelations, validateSharedSnapshotFieldDecorator, validateSharedSnapshotReferences, validateSharedSoftDeleteFieldNames } from '@loj-lang/shared-contracts';
|
|
13
14
|
import { analyzePageBlockData } from './page-table-block.js';
|
|
14
15
|
import { analyzeRelationProjection } from './relation-projection.js';
|
|
15
16
|
const ROUTE_PATH_SEGMENT_PATTERN = /^(?:[A-Za-z0-9_-]+|:[A-Za-z_][A-Za-z0-9_]*)$/;
|
|
16
|
-
const VALIDATION_CACHE_VERSION = '0.1.
|
|
17
|
+
const VALIDATION_CACHE_VERSION = '0.1.16';
|
|
17
18
|
export function validate(ir, options) {
|
|
18
19
|
const errors = [];
|
|
19
20
|
const previousCache = options?.cache?.version === VALIDATION_CACHE_VERSION
|
|
@@ -52,8 +53,10 @@ export function validate(ir, options) {
|
|
|
52
53
|
resourceNames,
|
|
53
54
|
readModelNames,
|
|
54
55
|
pageNames: pageNameList,
|
|
56
|
+
shell: ir.shell ?? null,
|
|
57
|
+
routes: ir.routes ?? null,
|
|
55
58
|
escapeStats: ir.escapeStats,
|
|
56
|
-
}), previousCache?.global?.core, () => validateGlobal(ir));
|
|
59
|
+
}), previousCache?.global?.core, () => validateGlobal(ir, resourceMap, pageMap));
|
|
57
60
|
errors.push(...globalEntry.errors);
|
|
58
61
|
const navigationEntries = ir.navigation.map((group) => resolveValidationSegment(createValidationSignature({
|
|
59
62
|
targets: group.items.map((item) => item.target),
|
|
@@ -189,12 +192,13 @@ function createValidationSignature(value) {
|
|
|
189
192
|
return normalized;
|
|
190
193
|
});
|
|
191
194
|
}
|
|
192
|
-
function validateGlobal(ir) {
|
|
195
|
+
function validateGlobal(ir, resourceMap, pageMap) {
|
|
193
196
|
const errors = [];
|
|
194
197
|
errors.push(...collectDuplicateErrors(ir.models, 'model'));
|
|
195
198
|
errors.push(...collectDuplicateErrors(ir.resources, 'resource'));
|
|
196
199
|
errors.push(...collectDuplicateErrors(ir.readModels, 'readModel'));
|
|
197
200
|
errors.push(...collectDuplicateErrors(ir.pages, 'page'));
|
|
201
|
+
errors.push(...collectModelDecoratorErrors(ir.models));
|
|
198
202
|
errors.push(...collectRelationErrors(ir.models));
|
|
199
203
|
if (ir.escapeStats) {
|
|
200
204
|
if (ir.escapeStats.overBudget) {
|
|
@@ -207,8 +211,38 @@ function validateGlobal(ir) {
|
|
|
207
211
|
});
|
|
208
212
|
}
|
|
209
213
|
}
|
|
214
|
+
validateAppRoutes(ir, resourceMap, pageMap, errors);
|
|
215
|
+
validateAppShell(ir, errors);
|
|
210
216
|
return errors;
|
|
211
217
|
}
|
|
218
|
+
function validateAppShell(ir, errors) {
|
|
219
|
+
const primaryNav = ir.shell?.navigation?.primary;
|
|
220
|
+
const secondaryNav = ir.shell?.navigation?.secondary;
|
|
221
|
+
const primaryGroups = ir.navigation.filter((group) => group.placement === 'primary');
|
|
222
|
+
const secondaryGroups = ir.navigation.filter((group) => group.placement === 'secondary');
|
|
223
|
+
if (primaryGroups.length > 0 && !primaryNav?.desktop && !primaryNav?.mobile) {
|
|
224
|
+
errors.push({
|
|
225
|
+
message: 'primary app.navigation groups are defined but app.shell.navigation.primary has no desktop/mobile placement; primary navigation will not render',
|
|
226
|
+
nodeId: ir.id,
|
|
227
|
+
severity: 'warning',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (secondaryGroups.length > 0 && !secondaryNav?.desktop && !secondaryNav?.mobile) {
|
|
231
|
+
errors.push({
|
|
232
|
+
message: 'secondary app.navigation groups are defined but app.shell.navigation.secondary has no desktop/mobile placement; secondary navigation will not render',
|
|
233
|
+
nodeId: ir.id,
|
|
234
|
+
severity: 'warning',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function validateAppRoutes(ir, resourceMap, pageMap, errors) {
|
|
239
|
+
if (ir.routes?.default) {
|
|
240
|
+
validateStaticAppRouteTarget(ir.routes.default, 'app.routes.default', resourceMap, pageMap, errors, 'default');
|
|
241
|
+
}
|
|
242
|
+
if (ir.routes?.notFound) {
|
|
243
|
+
validateStaticAppRouteTarget(ir.routes.notFound, 'app.routes.notFound', resourceMap, pageMap, errors, 'notFound');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
212
246
|
function validateReadModel(readModel) {
|
|
213
247
|
const errors = [];
|
|
214
248
|
if (!readModel.api) {
|
|
@@ -227,13 +261,6 @@ function validateReadModel(readModel) {
|
|
|
227
261
|
validateReadModelField(readModel, field, resultNames, errors);
|
|
228
262
|
}
|
|
229
263
|
if (readModel.list) {
|
|
230
|
-
if (readModel.list.columns.length === 0) {
|
|
231
|
-
errors.push({
|
|
232
|
-
message: `Read-model "${readModel.name}" list must define at least one column`,
|
|
233
|
-
nodeId: readModel.list.id,
|
|
234
|
-
severity: 'error',
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
264
|
for (const column of readModel.list.columns) {
|
|
238
265
|
const resultField = readModel.result.find((field) => field.name === column.field);
|
|
239
266
|
if (!resultField) {
|
|
@@ -482,9 +509,9 @@ function validateLinkedFormRules(resource, model, view, errors, mode) {
|
|
|
482
509
|
});
|
|
483
510
|
}
|
|
484
511
|
seenEligibility.add(entry.name);
|
|
485
|
-
validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode,
|
|
512
|
+
validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
|
|
486
513
|
for (const expr of entry.or) {
|
|
487
|
-
validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode,
|
|
514
|
+
validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
|
|
488
515
|
}
|
|
489
516
|
}
|
|
490
517
|
const seenValidation = new Set();
|
|
@@ -497,9 +524,9 @@ function validateLinkedFormRules(resource, model, view, errors, mode) {
|
|
|
497
524
|
});
|
|
498
525
|
}
|
|
499
526
|
seenValidation.add(entry.name);
|
|
500
|
-
validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode,
|
|
527
|
+
validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
|
|
501
528
|
for (const expr of entry.or) {
|
|
502
|
-
validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode,
|
|
529
|
+
validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
|
|
503
530
|
}
|
|
504
531
|
}
|
|
505
532
|
const seenDerivations = new Set();
|
|
@@ -547,9 +574,9 @@ function validateLinkedFormRules(resource, model, view, errors, mode) {
|
|
|
547
574
|
continue;
|
|
548
575
|
}
|
|
549
576
|
if (entry.when) {
|
|
550
|
-
validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode,
|
|
577
|
+
validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
|
|
551
578
|
}
|
|
552
|
-
validateWorkflowExpr(entry.value, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode,
|
|
579
|
+
validateWorkflowExpr(entry.value, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
|
|
553
580
|
}
|
|
554
581
|
}
|
|
555
582
|
function validateLinkedFormIncludeRules(resource, parentModel, include, targetModel, errors, mode) {
|
|
@@ -666,48 +693,104 @@ function validateReadModelField(readModel, field, seen, errors) {
|
|
|
666
693
|
}
|
|
667
694
|
}
|
|
668
695
|
function collectRelationErrors(models) {
|
|
696
|
+
return validateSharedModelRelations(models).map((issue) => ({
|
|
697
|
+
message: issue.message.replace(/^model ([^. ]+) field ([^. ]+) /, 'Field "$2" in model "$1" '),
|
|
698
|
+
nodeId: issue.nodeId,
|
|
699
|
+
severity: 'error',
|
|
700
|
+
}));
|
|
701
|
+
}
|
|
702
|
+
function collectModelDecoratorErrors(models) {
|
|
669
703
|
const errors = [];
|
|
670
|
-
const
|
|
704
|
+
for (const issue of validateSharedModelDisplayFieldUsage(models)) {
|
|
705
|
+
errors.push({
|
|
706
|
+
message: issue.message.replace(/^model ([^. ]+) /, 'Model "$1" '),
|
|
707
|
+
nodeId: issue.nodeId,
|
|
708
|
+
severity: 'error',
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
for (const issue of validateSharedSoftDeleteFieldNames(models)) {
|
|
712
|
+
errors.push({
|
|
713
|
+
message: issue.message,
|
|
714
|
+
nodeId: issue.nodeId,
|
|
715
|
+
severity: 'error',
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
for (const issue of validateSharedDisplayFieldTypes(models)) {
|
|
719
|
+
errors.push({
|
|
720
|
+
message: issue.message,
|
|
721
|
+
nodeId: issue.nodeId,
|
|
722
|
+
severity: 'error',
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
for (const issue of validateDuplicateSharedModelFieldNames(models)) {
|
|
726
|
+
errors.push({
|
|
727
|
+
message: issue.message,
|
|
728
|
+
nodeId: issue.nodeId,
|
|
729
|
+
severity: 'error',
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
for (const issue of validateGeneratedPersistenceFieldNames(models)) {
|
|
733
|
+
errors.push({
|
|
734
|
+
message: issue.message,
|
|
735
|
+
nodeId: issue.nodeId,
|
|
736
|
+
severity: 'error',
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
for (const issue of validateSharedSnapshotReferences(models)) {
|
|
740
|
+
errors.push({
|
|
741
|
+
message: issue.message,
|
|
742
|
+
nodeId: issue.nodeId,
|
|
743
|
+
severity: issue.severity,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
for (const issue of lintSharedSnapshotCandidates(models)) {
|
|
747
|
+
errors.push({
|
|
748
|
+
message: issue.message,
|
|
749
|
+
nodeId: issue.nodeId,
|
|
750
|
+
severity: issue.severity,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
671
753
|
for (const model of models) {
|
|
672
754
|
for (const field of model.fields) {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
});
|
|
683
|
-
continue;
|
|
755
|
+
const enumKeyPrefixDecorator = field.decorators.find((decorator) => decorator.name === 'enumKeyPrefix');
|
|
756
|
+
if (enumKeyPrefixDecorator) {
|
|
757
|
+
for (const issue of validateSharedEnumFieldDecorator(field, enumKeyPrefixDecorator)) {
|
|
758
|
+
errors.push({
|
|
759
|
+
message: issue.message,
|
|
760
|
+
nodeId: issue.nodeId,
|
|
761
|
+
severity: 'error',
|
|
762
|
+
});
|
|
763
|
+
}
|
|
684
764
|
}
|
|
685
|
-
const
|
|
686
|
-
if (
|
|
765
|
+
const enumLabelsDecorator = field.decorators.find((decorator) => decorator.name === 'enumLabels');
|
|
766
|
+
if (!enumLabelsDecorator) {
|
|
687
767
|
continue;
|
|
688
768
|
}
|
|
689
|
-
|
|
769
|
+
for (const issue of validateSharedEnumFieldDecorator(field, enumLabelsDecorator)) {
|
|
690
770
|
errors.push({
|
|
691
|
-
message:
|
|
692
|
-
nodeId:
|
|
771
|
+
message: issue.message,
|
|
772
|
+
nodeId: issue.nodeId,
|
|
693
773
|
severity: 'error',
|
|
694
774
|
});
|
|
695
775
|
}
|
|
696
|
-
const
|
|
697
|
-
if (
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
776
|
+
const snapshotDecorator = field.decorators.find((decorator) => decorator.name === 'snapshot');
|
|
777
|
+
if (snapshotDecorator) {
|
|
778
|
+
for (const issue of validateSharedSnapshotFieldDecorator(field, snapshotDecorator)) {
|
|
779
|
+
errors.push({
|
|
780
|
+
message: issue.message,
|
|
781
|
+
nodeId: issue.nodeId,
|
|
782
|
+
severity: 'error',
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const cascadeDecorator = field.decorators.find((decorator) => decorator.name === 'cascade');
|
|
787
|
+
if (!cascadeDecorator) {
|
|
703
788
|
continue;
|
|
704
789
|
}
|
|
705
|
-
|
|
706
|
-
|| inverseField.fieldType.kind !== 'belongsTo'
|
|
707
|
-
|| inverseField.fieldType.target !== model.name) {
|
|
790
|
+
for (const issue of validateSharedCascadeFieldDecorator(field, cascadeDecorator)) {
|
|
708
791
|
errors.push({
|
|
709
|
-
message:
|
|
710
|
-
nodeId:
|
|
792
|
+
message: issue.message,
|
|
793
|
+
nodeId: issue.nodeId,
|
|
711
794
|
severity: 'error',
|
|
712
795
|
});
|
|
713
796
|
}
|
|
@@ -715,6 +798,9 @@ function collectRelationErrors(models) {
|
|
|
715
798
|
}
|
|
716
799
|
return errors;
|
|
717
800
|
}
|
|
801
|
+
function hasDecorator(field, decoratorName) {
|
|
802
|
+
return field.decorators.some((decorator) => decorator.name === decoratorName);
|
|
803
|
+
}
|
|
718
804
|
function resolveResourceValidation(resource, modelMap, modelNames, resourceMap, resourceNames, pageNames, pageMap, pageNameList, modelFieldContext, resourceContext, previous) {
|
|
719
805
|
const resourceEntry = resolveValidationSegment(createValidationSignature({
|
|
720
806
|
name: resource.name,
|
|
@@ -743,6 +829,9 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
|
|
|
743
829
|
field: column.field,
|
|
744
830
|
customRenderer: Boolean(column.customRenderer),
|
|
745
831
|
})),
|
|
832
|
+
actions: resource.views.list.actions.map((action) => action.name),
|
|
833
|
+
batchActions: resource.views.list.batchActions.map((action) => action.name),
|
|
834
|
+
messages: resource.views.list.messages ?? null,
|
|
746
835
|
rules: resource.views.list.rules ?? null,
|
|
747
836
|
}), previous?.list, () => validateListView(resource, modelMap.get(resource.model), resource.views.list, Array.from(modelMap.values()), Array.from(resourceMap.values())))
|
|
748
837
|
: undefined;
|
|
@@ -752,8 +841,8 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
|
|
|
752
841
|
fields: resource.views.edit.fields.map((field) => ({
|
|
753
842
|
field: field.field,
|
|
754
843
|
customField: Boolean(field.customField),
|
|
755
|
-
|
|
756
|
-
|
|
844
|
+
visibleIf: field.visibleIf ?? null,
|
|
845
|
+
enabledIf: field.enabledIf ?? null,
|
|
757
846
|
})),
|
|
758
847
|
includes: resource.views.edit.includes.map((include) => ({
|
|
759
848
|
field: include.field,
|
|
@@ -767,11 +856,12 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
|
|
|
767
856
|
fields: include.fields.map((field) => ({
|
|
768
857
|
field: field.field,
|
|
769
858
|
customField: Boolean(field.customField),
|
|
770
|
-
|
|
771
|
-
|
|
859
|
+
visibleIf: field.visibleIf ?? null,
|
|
860
|
+
enabledIf: field.enabledIf ?? null,
|
|
772
861
|
})),
|
|
773
862
|
})),
|
|
774
863
|
rules: resource.views.edit.rules ?? null,
|
|
864
|
+
messages: resource.views.edit.messages ?? null,
|
|
775
865
|
rulesLink: resource.views.edit.rulesLink
|
|
776
866
|
? {
|
|
777
867
|
resolvedPath: resource.views.edit.rulesLink.resolvedPath,
|
|
@@ -789,8 +879,8 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
|
|
|
789
879
|
fields: resource.views.create.fields.map((field) => ({
|
|
790
880
|
field: field.field,
|
|
791
881
|
customField: Boolean(field.customField),
|
|
792
|
-
|
|
793
|
-
|
|
882
|
+
visibleIf: field.visibleIf ?? null,
|
|
883
|
+
enabledIf: field.enabledIf ?? null,
|
|
794
884
|
})),
|
|
795
885
|
includes: resource.views.create.includes.map((include) => ({
|
|
796
886
|
field: include.field,
|
|
@@ -804,11 +894,12 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
|
|
|
804
894
|
fields: include.fields.map((field) => ({
|
|
805
895
|
field: field.field,
|
|
806
896
|
customField: Boolean(field.customField),
|
|
807
|
-
|
|
808
|
-
|
|
897
|
+
visibleIf: field.visibleIf ?? null,
|
|
898
|
+
enabledIf: field.enabledIf ?? null,
|
|
809
899
|
})),
|
|
810
900
|
})),
|
|
811
901
|
rules: resource.views.create.rules ?? null,
|
|
902
|
+
messages: resource.views.create.messages ?? null,
|
|
812
903
|
rulesLink: resource.views.create.rulesLink
|
|
813
904
|
? {
|
|
814
905
|
resolvedPath: resource.views.create.rulesLink.resolvedPath,
|
|
@@ -833,6 +924,8 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
|
|
|
833
924
|
customRenderer: Boolean(field.customRenderer),
|
|
834
925
|
})),
|
|
835
926
|
related: resource.views.read.related.map((panel) => panel.field),
|
|
927
|
+
messages: resource.views.read.messages ?? null,
|
|
928
|
+
workflowMessages: resource.workflow ? resource.workflowMessages ?? null : null,
|
|
836
929
|
}), previous?.read, () => validateReadView(resource, modelMap.get(resource.model), Array.from(modelMap.values()), Array.from(resourceMap.values())))
|
|
837
930
|
: undefined;
|
|
838
931
|
return {
|
|
@@ -934,6 +1027,7 @@ function validateResourceBase(resource, modelMap) {
|
|
|
934
1027
|
}
|
|
935
1028
|
validateWorkflowExpr(transition.allow, resource.workflow.id, errors, (path) => validateWorkflowTransitionIdentifier(path, resource.name, modelFieldNames));
|
|
936
1029
|
}
|
|
1030
|
+
pushWorkflowMessageWarnings(resource, Boolean(resource.views.read?.related.length), errors);
|
|
937
1031
|
}
|
|
938
1032
|
return errors;
|
|
939
1033
|
}
|
|
@@ -1009,7 +1103,34 @@ function validateListView(resource, model, view, models, resources) {
|
|
|
1009
1103
|
});
|
|
1010
1104
|
}
|
|
1011
1105
|
}
|
|
1106
|
+
for (const action of view.batchActions) {
|
|
1107
|
+
if (action.name !== 'delete') {
|
|
1108
|
+
errors.push({
|
|
1109
|
+
message: `List batch action "${action.name}" in resource "${resource.name}" is not supported in the current slice; only "delete" is supported`,
|
|
1110
|
+
nodeId: action.id,
|
|
1111
|
+
severity: 'error',
|
|
1112
|
+
});
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!view.actions.some((entry) => entry.name === 'delete')) {
|
|
1116
|
+
errors.push({
|
|
1117
|
+
message: `List batch action "delete" in resource "${resource.name}" requires list.actions to include delete`,
|
|
1118
|
+
nodeId: action.id,
|
|
1119
|
+
severity: 'error',
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
for (const format of view.exportFormats) {
|
|
1124
|
+
if (format !== 'csv' && format !== 'xlsx') {
|
|
1125
|
+
errors.push({
|
|
1126
|
+
message: `List export "${format}" in resource "${resource.name}" is not supported in the current slice; only "csv" and "xlsx" are supported`,
|
|
1127
|
+
nodeId: view.id,
|
|
1128
|
+
severity: 'error',
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1012
1132
|
validateViewRulePaths(view.id, view.rules, errors);
|
|
1133
|
+
pushListMessageWarnings(resource, view, errors);
|
|
1013
1134
|
return errors;
|
|
1014
1135
|
}
|
|
1015
1136
|
function validateEditView(resource, model, modelMap, resourceMap, pageMap) {
|
|
@@ -1148,6 +1269,7 @@ function validateEditView(resource, model, modelMap, resourceMap, pageMap) {
|
|
|
1148
1269
|
}
|
|
1149
1270
|
validateViewRulePaths(view.id, view.rules, errors);
|
|
1150
1271
|
validateLinkedFormRules(resource, model, view, errors, 'edit');
|
|
1272
|
+
pushEditMessageWarnings(resource, model, view, errors);
|
|
1151
1273
|
return errors;
|
|
1152
1274
|
}
|
|
1153
1275
|
function validateCreateView(resource, model, modelMap, resourceMap, pageMap) {
|
|
@@ -1271,6 +1393,7 @@ function validateCreateView(resource, model, modelMap, resourceMap, pageMap) {
|
|
|
1271
1393
|
}
|
|
1272
1394
|
validateViewRulePaths(view.id, view.rules, errors);
|
|
1273
1395
|
validateLinkedFormRules(resource, model, view, errors, 'create');
|
|
1396
|
+
pushCreateMessageWarnings(resource, model, view, errors);
|
|
1274
1397
|
return errors;
|
|
1275
1398
|
}
|
|
1276
1399
|
function validateReadView(resource, model, models, resources) {
|
|
@@ -1343,6 +1466,7 @@ function validateReadView(resource, model, models, resources) {
|
|
|
1343
1466
|
});
|
|
1344
1467
|
}
|
|
1345
1468
|
}
|
|
1469
|
+
pushReadMessageWarnings(resource, view, errors);
|
|
1346
1470
|
return errors;
|
|
1347
1471
|
}
|
|
1348
1472
|
function validateToastEffect(effect, context, errors) {
|
|
@@ -1354,6 +1478,337 @@ function validateToastEffect(effect, context, errors) {
|
|
|
1354
1478
|
function isInverseRelationField(field) {
|
|
1355
1479
|
return field.fieldType.type === 'relation' && field.fieldType.kind === 'hasMany';
|
|
1356
1480
|
}
|
|
1481
|
+
function hasVersionFieldInFormView(model, view) {
|
|
1482
|
+
if (!model) {
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
const fieldMap = new Map(model.fields.map((field) => [field.name, field]));
|
|
1486
|
+
return view.fields
|
|
1487
|
+
.map((field) => fieldMap.get(field.field))
|
|
1488
|
+
.some((field) => Boolean(field) && field.decorators.some((decorator) => decorator.name === 'version'));
|
|
1489
|
+
}
|
|
1490
|
+
function pushMessageFallbackWarning(errors, nodeId, messageKey, fallback) {
|
|
1491
|
+
errors.push({
|
|
1492
|
+
message: `message "${messageKey}" not declared — generated code will use English fallback "${fallback}"`,
|
|
1493
|
+
nodeId,
|
|
1494
|
+
severity: 'warning',
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
function reportMissingMessage(errors, reporter, nodeId, ownerKind, ownerName, surface, key, fallback) {
|
|
1498
|
+
const messageKey = surface === 'workflowMessages'
|
|
1499
|
+
? `workflowMessages.${key}`
|
|
1500
|
+
: surface === 'page'
|
|
1501
|
+
? `page.messages.${key}`
|
|
1502
|
+
: `messages.${key}`;
|
|
1503
|
+
if (errors) {
|
|
1504
|
+
pushMessageFallbackWarning(errors, nodeId, messageKey, fallback);
|
|
1505
|
+
}
|
|
1506
|
+
reporter?.({
|
|
1507
|
+
nodeId,
|
|
1508
|
+
ownerKind,
|
|
1509
|
+
ownerName,
|
|
1510
|
+
surface,
|
|
1511
|
+
key,
|
|
1512
|
+
fallback,
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
function pushListMessageWarnings(resource, view, errors, reporter) {
|
|
1516
|
+
const messages = view.messages ?? {};
|
|
1517
|
+
if (!messages.loadFailed) {
|
|
1518
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'loadFailed', 'Failed to load data');
|
|
1519
|
+
}
|
|
1520
|
+
if (resource.views.create && !messages.createLabel) {
|
|
1521
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'createLabel', 'Create');
|
|
1522
|
+
}
|
|
1523
|
+
if (view.actions.some((action) => action.name === 'view') && !messages.viewLabel) {
|
|
1524
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'viewLabel', 'View');
|
|
1525
|
+
}
|
|
1526
|
+
if (view.actions.some((action) => action.name === 'edit') && !messages.editLabel) {
|
|
1527
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'editLabel', 'Edit');
|
|
1528
|
+
}
|
|
1529
|
+
if (view.actions.some((action) => action.name === 'workflow') && !messages.workflowLabel) {
|
|
1530
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'workflowLabel', 'Workflow');
|
|
1531
|
+
}
|
|
1532
|
+
if (view.actions.some((action) => action.name === 'delete') && !messages.deleteLabel) {
|
|
1533
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'deleteLabel', 'Delete');
|
|
1534
|
+
}
|
|
1535
|
+
if (view.batchActions.some((action) => action.name === 'delete') && !messages.deleteSelectedLabel) {
|
|
1536
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'deleteSelectedLabel', 'Delete selected');
|
|
1537
|
+
}
|
|
1538
|
+
if (view.exportFormats.length > 1 && !messages.exportLabel) {
|
|
1539
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'exportLabel', 'Export');
|
|
1540
|
+
}
|
|
1541
|
+
if (view.exportFormats.includes('csv') && !messages.exportCsvLabel) {
|
|
1542
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'exportCsvLabel', 'Export CSV');
|
|
1543
|
+
}
|
|
1544
|
+
if (view.exportFormats.includes('xlsx') && !messages.exportXlsxLabel) {
|
|
1545
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'exportXlsxLabel', 'Export XLSX');
|
|
1546
|
+
}
|
|
1547
|
+
if ((view.actions.some((action) => action.name === 'delete') || view.batchActions.some((action) => action.name === 'delete')) && !messages.deleted) {
|
|
1548
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'deleted', 'Archived/Deleted successfully');
|
|
1549
|
+
}
|
|
1550
|
+
if (view.exportFormats.length > 0 && !messages.exportReady) {
|
|
1551
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'exportReady', 'Export ready');
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
function pushEditMessageWarnings(resource, model, view, errors, reporter) {
|
|
1555
|
+
const messages = view.messages ?? {};
|
|
1556
|
+
if (!messages.saveFailed) {
|
|
1557
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'saveFailed', 'Failed to save');
|
|
1558
|
+
}
|
|
1559
|
+
if (!messages.permissionDenied) {
|
|
1560
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'permissionDenied', 'You do not have permission to perform this action');
|
|
1561
|
+
}
|
|
1562
|
+
if (!messages.cancelLabel) {
|
|
1563
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'cancelLabel', 'Cancel');
|
|
1564
|
+
}
|
|
1565
|
+
if (!messages.loadingLabel) {
|
|
1566
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'loadingLabel', 'Loading...');
|
|
1567
|
+
}
|
|
1568
|
+
if (!messages.notFound) {
|
|
1569
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'notFound', 'Record not found');
|
|
1570
|
+
}
|
|
1571
|
+
if (!resource.workflow && !messages.saveLabel) {
|
|
1572
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'saveLabel', 'Save');
|
|
1573
|
+
}
|
|
1574
|
+
if (resource.workflow && !messages.workflowLabel) {
|
|
1575
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'workflowLabel', 'Workflow');
|
|
1576
|
+
}
|
|
1577
|
+
if (resource.workflow && !messages.saveAndContinueLabel) {
|
|
1578
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'saveAndContinueLabel', 'Save and continue to {step}');
|
|
1579
|
+
}
|
|
1580
|
+
if (hasVersionFieldInFormView(model, view) && !messages.versionConflict) {
|
|
1581
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'versionConflict', 'This record changed since you loaded it. Reload and try again.');
|
|
1582
|
+
}
|
|
1583
|
+
if (hasVersionFieldInFormView(model, view) && !messages.reloadLatest) {
|
|
1584
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'reloadLatest', 'Reload latest');
|
|
1585
|
+
}
|
|
1586
|
+
if (view.includes.some((include) => include.minItems > 0) && !messages.minItemsRequired) {
|
|
1587
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'minItemsRequired', 'At least {count} {field} item(s) required in the current slice.');
|
|
1588
|
+
}
|
|
1589
|
+
if (view.includes.length > 0 && !messages.existingItemLabel) {
|
|
1590
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'existingItemLabel', 'Existing');
|
|
1591
|
+
}
|
|
1592
|
+
if (view.includes.length > 0 && !messages.newItemLabel) {
|
|
1593
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'newItemLabel', 'New');
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
function pushCreateMessageWarnings(resource, model, view, errors, reporter) {
|
|
1597
|
+
const messages = view.messages ?? {};
|
|
1598
|
+
if (!messages.createFailed) {
|
|
1599
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'createFailed', 'Failed to create');
|
|
1600
|
+
}
|
|
1601
|
+
if (!messages.permissionDenied) {
|
|
1602
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'permissionDenied', 'You do not have permission to perform this action');
|
|
1603
|
+
}
|
|
1604
|
+
if (!messages.cancelLabel) {
|
|
1605
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'cancelLabel', 'Cancel');
|
|
1606
|
+
}
|
|
1607
|
+
if (!messages.loadingLabel) {
|
|
1608
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'loadingLabel', 'Loading...');
|
|
1609
|
+
}
|
|
1610
|
+
if (!messages.notFound) {
|
|
1611
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'notFound', 'Record not found');
|
|
1612
|
+
}
|
|
1613
|
+
if (!resource.workflow && !messages.createLabel) {
|
|
1614
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'createLabel', 'Create');
|
|
1615
|
+
}
|
|
1616
|
+
if (resource.workflow && !messages.createAndContinueLabel) {
|
|
1617
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'createAndContinueLabel', 'Create and continue to {step}');
|
|
1618
|
+
}
|
|
1619
|
+
if (hasVersionFieldInFormView(model, view) && !messages.versionConflict) {
|
|
1620
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'versionConflict', 'This record changed since you loaded it. Reload and try again.');
|
|
1621
|
+
}
|
|
1622
|
+
if (view.includes.some((include) => include.minItems > 0) && !messages.minItemsRequired) {
|
|
1623
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'minItemsRequired', 'At least {count} {field} item(s) required in the current slice.');
|
|
1624
|
+
}
|
|
1625
|
+
if (view.includes.length > 0 && !messages.existingItemLabel) {
|
|
1626
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'existingItemLabel', 'Existing');
|
|
1627
|
+
}
|
|
1628
|
+
if (view.includes.length > 0 && !messages.newItemLabel) {
|
|
1629
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'newItemLabel', 'New');
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
function pushReadMessageWarnings(resource, view, errors, reporter) {
|
|
1633
|
+
const messages = view.messages ?? {};
|
|
1634
|
+
if (!messages.loadFailed) {
|
|
1635
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'loadFailed', 'Failed to load record');
|
|
1636
|
+
}
|
|
1637
|
+
if (!messages.loadingLabel) {
|
|
1638
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'loadingLabel', 'Loading...');
|
|
1639
|
+
}
|
|
1640
|
+
if (!messages.notFound) {
|
|
1641
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'notFound', 'Record not found');
|
|
1642
|
+
}
|
|
1643
|
+
if (!messages.backLabel) {
|
|
1644
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'backLabel', 'Back');
|
|
1645
|
+
}
|
|
1646
|
+
if (resource.views.edit && !messages.editLabel) {
|
|
1647
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'editLabel', 'Edit');
|
|
1648
|
+
}
|
|
1649
|
+
if (resource.workflow && !messages.workflowLabel) {
|
|
1650
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'workflowLabel', 'Workflow');
|
|
1651
|
+
}
|
|
1652
|
+
if (view.related.length > 0 && !messages.relatedTitle) {
|
|
1653
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'relatedTitle', 'Related');
|
|
1654
|
+
}
|
|
1655
|
+
if (view.related.length > 0 && !messages.emptyRelated) {
|
|
1656
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'emptyRelated', 'No related records');
|
|
1657
|
+
}
|
|
1658
|
+
if (view.related.length > 0 && !messages.createRelatedLabel) {
|
|
1659
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'createRelatedLabel', 'Create');
|
|
1660
|
+
}
|
|
1661
|
+
if (view.related.length > 0 && !messages.viewAllRelatedLabel) {
|
|
1662
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'viewAllRelatedLabel', 'View all');
|
|
1663
|
+
}
|
|
1664
|
+
if (resource.workflow) {
|
|
1665
|
+
if (!messages.currentState) {
|
|
1666
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'currentState', 'Current state');
|
|
1667
|
+
}
|
|
1668
|
+
if (!messages.currentStep) {
|
|
1669
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'currentStep', 'Current step');
|
|
1670
|
+
}
|
|
1671
|
+
if (!messages.nextStep) {
|
|
1672
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'nextStep', 'Next step');
|
|
1673
|
+
}
|
|
1674
|
+
if (!messages.stepDone) {
|
|
1675
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'stepDone', 'done');
|
|
1676
|
+
}
|
|
1677
|
+
if (!messages.stepCurrent) {
|
|
1678
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'stepCurrent', 'current');
|
|
1679
|
+
}
|
|
1680
|
+
if (!messages.stepUpcoming) {
|
|
1681
|
+
reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'stepUpcoming', 'upcoming');
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function pushWorkflowMessageWarnings(resource, hasRelatedPanels, errors, reporter) {
|
|
1686
|
+
const messages = resource.workflowMessages ?? {};
|
|
1687
|
+
const nodeId = resource.workflow?.id ?? resource.id;
|
|
1688
|
+
if (!messages.preconditionBlocked) {
|
|
1689
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'preconditionBlocked', 'Action blocked by workflow preconditions');
|
|
1690
|
+
}
|
|
1691
|
+
if (!messages.invalidTransition) {
|
|
1692
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'invalidTransition', 'Invalid workflow transition request');
|
|
1693
|
+
}
|
|
1694
|
+
if (!messages.updated) {
|
|
1695
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'updated', 'Workflow updated');
|
|
1696
|
+
}
|
|
1697
|
+
if (!messages.updateFailed) {
|
|
1698
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'updateFailed', 'Failed to update workflow');
|
|
1699
|
+
}
|
|
1700
|
+
if (!messages.title) {
|
|
1701
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'title', 'Workflow');
|
|
1702
|
+
}
|
|
1703
|
+
if (!messages.loadFailed) {
|
|
1704
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'loadFailed', 'Failed to load record');
|
|
1705
|
+
}
|
|
1706
|
+
if (!messages.loadingLabel) {
|
|
1707
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'loadingLabel', 'Loading...');
|
|
1708
|
+
}
|
|
1709
|
+
if (!messages.notFound) {
|
|
1710
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'notFound', 'Record not found');
|
|
1711
|
+
}
|
|
1712
|
+
if (!messages.backLabel) {
|
|
1713
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'backLabel', 'Back');
|
|
1714
|
+
}
|
|
1715
|
+
if (resource.views.edit && !messages.editLabel) {
|
|
1716
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'editLabel', 'Edit');
|
|
1717
|
+
}
|
|
1718
|
+
if (resource.views.read && !messages.viewLabel) {
|
|
1719
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'viewLabel', 'View');
|
|
1720
|
+
}
|
|
1721
|
+
if (hasRelatedPanels && !messages.relatedTitle) {
|
|
1722
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'relatedTitle', 'Related');
|
|
1723
|
+
}
|
|
1724
|
+
if (hasRelatedPanels && !messages.emptyRelated) {
|
|
1725
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'emptyRelated', 'No related records');
|
|
1726
|
+
}
|
|
1727
|
+
if (hasRelatedPanels && !messages.createRelatedLabel) {
|
|
1728
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'createRelatedLabel', 'Create');
|
|
1729
|
+
}
|
|
1730
|
+
if (hasRelatedPanels && !messages.viewAllRelatedLabel) {
|
|
1731
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'viewAllRelatedLabel', 'View all');
|
|
1732
|
+
}
|
|
1733
|
+
if (!messages.currentState) {
|
|
1734
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'currentState', 'Current state');
|
|
1735
|
+
}
|
|
1736
|
+
if (!messages.currentStep) {
|
|
1737
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'currentStep', 'Current step');
|
|
1738
|
+
}
|
|
1739
|
+
if (!messages.nextStep) {
|
|
1740
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'nextStep', 'Next step');
|
|
1741
|
+
}
|
|
1742
|
+
if (!messages.stepDone) {
|
|
1743
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'stepDone', 'done');
|
|
1744
|
+
}
|
|
1745
|
+
if (!messages.stepCurrent) {
|
|
1746
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'stepCurrent', 'current');
|
|
1747
|
+
}
|
|
1748
|
+
if (!messages.stepUpcoming) {
|
|
1749
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'stepUpcoming', 'upcoming');
|
|
1750
|
+
}
|
|
1751
|
+
if (!messages.redoStep) {
|
|
1752
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'redoStep', 'Redo {step}');
|
|
1753
|
+
}
|
|
1754
|
+
if (!messages.completeStep) {
|
|
1755
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'completeStep', 'Complete {step}');
|
|
1756
|
+
}
|
|
1757
|
+
if (!messages.advanceToStep) {
|
|
1758
|
+
reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'advanceToStep', 'Advance to {step}');
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
function pushPageMessageWarnings(page, errors, reporter) {
|
|
1762
|
+
const messages = page.messages ?? {};
|
|
1763
|
+
const hasDataBlocks = page.blocks.some((block) => block.blockType === 'table'
|
|
1764
|
+
|| block.blockType === 'timeline'
|
|
1765
|
+
|| block.blockType === 'notes'
|
|
1766
|
+
|| block.blockType === 'attachments'
|
|
1767
|
+
|| block.blockType === 'metric');
|
|
1768
|
+
if (!hasDataBlocks) {
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
if (!messages.loadFailed) {
|
|
1772
|
+
reportMissingMessage(errors, reporter, page.id, 'page', page.name, 'page', 'loadFailed', 'Failed to load data');
|
|
1773
|
+
}
|
|
1774
|
+
if (!messages.loadingLabel) {
|
|
1775
|
+
reportMissingMessage(errors, reporter, page.id, 'page', page.name, 'page', 'loadingLabel', 'Loading...');
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
export function collectMissingMessageTemplateEntries(ir) {
|
|
1779
|
+
const entries = [];
|
|
1780
|
+
const seen = new Set();
|
|
1781
|
+
const reporter = (entry) => {
|
|
1782
|
+
const signature = `${entry.ownerKind}:${entry.ownerName}:${entry.surface}:${entry.key}`;
|
|
1783
|
+
if (seen.has(signature)) {
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
seen.add(signature);
|
|
1787
|
+
entries.push(entry);
|
|
1788
|
+
};
|
|
1789
|
+
const modelMap = new Map(ir.models.map((model) => [model.name, model]));
|
|
1790
|
+
for (const resource of ir.resources) {
|
|
1791
|
+
if (resource.workflow) {
|
|
1792
|
+
pushWorkflowMessageWarnings(resource, Boolean(resource.views.read?.related.length), [], reporter);
|
|
1793
|
+
}
|
|
1794
|
+
if (resource.views.list) {
|
|
1795
|
+
pushListMessageWarnings(resource, resource.views.list, [], reporter);
|
|
1796
|
+
}
|
|
1797
|
+
if (resource.views.edit) {
|
|
1798
|
+
pushEditMessageWarnings(resource, modelMap.get(resource.model), resource.views.edit, [], reporter);
|
|
1799
|
+
}
|
|
1800
|
+
if (resource.views.create) {
|
|
1801
|
+
pushCreateMessageWarnings(resource, modelMap.get(resource.model), resource.views.create, [], reporter);
|
|
1802
|
+
}
|
|
1803
|
+
if (resource.views.read) {
|
|
1804
|
+
pushReadMessageWarnings(resource, resource.views.read, [], reporter);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
for (const page of ir.pages) {
|
|
1808
|
+
pushPageMessageWarnings(page, [], reporter);
|
|
1809
|
+
}
|
|
1810
|
+
return entries;
|
|
1811
|
+
}
|
|
1357
1812
|
function formatRelationProjectionError(subject, resource, analysis) {
|
|
1358
1813
|
switch (analysis.reason) {
|
|
1359
1814
|
case 'unsupportedPathShape':
|
|
@@ -1574,18 +2029,25 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1574
2029
|
|| analysis.kind === 'resourceList'
|
|
1575
2030
|
|| analysis.kind === 'readModelList'
|
|
1576
2031
|
|| analysis.kind === 'readModelCount'
|
|
2032
|
+
|| analysis.kind === 'readModelValue'
|
|
1577
2033
|
|| analysis.kind === 'recordRelationList'
|
|
1578
2034
|
|| analysis.kind === 'recordRelationCount') {
|
|
1579
2035
|
continue;
|
|
1580
2036
|
}
|
|
1581
2037
|
const blockLabel = block.title || block.id;
|
|
1582
|
-
const blockKindLabel = block
|
|
2038
|
+
const blockKindLabel = pageBlockKindLabel(block);
|
|
1583
2039
|
switch (analysis.reason) {
|
|
1584
2040
|
case 'missingData':
|
|
1585
2041
|
errors.push({
|
|
1586
2042
|
message: block.blockType === 'metric'
|
|
1587
|
-
? `Metric block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.count or data: <resource>.<hasManyField>.count`
|
|
1588
|
-
:
|
|
2043
|
+
? `Metric block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.count, data: readModel.<name>.<field>, or data: <resource>.<hasManyField>.count`
|
|
2044
|
+
: block.blockType === 'timeline'
|
|
2045
|
+
? `Timeline block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.list or data: <resource>.<hasManyField>`
|
|
2046
|
+
: block.blockType === 'notes'
|
|
2047
|
+
? `Notes block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.list or data: <resource>.<hasManyField>`
|
|
2048
|
+
: block.blockType === 'attachments'
|
|
2049
|
+
? `Attachments block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.list or data: <resource>.<hasManyField>`
|
|
2050
|
+
: `Table block "${blockLabel}" in page "${page.name}" must set data: <resource>.list, data: readModel.<name>.list, or data: <resource>.<hasManyField>`,
|
|
1589
2051
|
nodeId: block.id,
|
|
1590
2052
|
severity: 'error',
|
|
1591
2053
|
});
|
|
@@ -1593,8 +2055,14 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1593
2055
|
case 'unsupportedDataRef':
|
|
1594
2056
|
errors.push({
|
|
1595
2057
|
message: block.blockType === 'metric'
|
|
1596
|
-
? `Metric block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.count or data: <resource>.<hasManyField>.count; got "${analysis.data}"`
|
|
1597
|
-
:
|
|
2058
|
+
? `Metric block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.count, data: readModel.<name>.<field>, or data: <resource>.<hasManyField>.count; got "${analysis.data}"`
|
|
2059
|
+
: block.blockType === 'timeline'
|
|
2060
|
+
? `Timeline block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField>; got "${analysis.data}"`
|
|
2061
|
+
: block.blockType === 'notes'
|
|
2062
|
+
? `Notes block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField>; got "${analysis.data}"`
|
|
2063
|
+
: block.blockType === 'attachments'
|
|
2064
|
+
? `Attachments block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField>; got "${analysis.data}"`
|
|
2065
|
+
: `Table block "${blockLabel}" in page "${page.name}" must use data: <resource>.list, data: readModel.<name>.list, or data: <resource>.<hasManyField>; got "${analysis.data}"`,
|
|
1598
2066
|
nodeId: block.id,
|
|
1599
2067
|
severity: 'error',
|
|
1600
2068
|
});
|
|
@@ -1613,6 +2081,13 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1613
2081
|
severity: 'error',
|
|
1614
2082
|
});
|
|
1615
2083
|
break;
|
|
2084
|
+
case 'readModelResultFieldMissing':
|
|
2085
|
+
errors.push({
|
|
2086
|
+
message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references missing read-model result field "${analysis.readModelFieldName}" on "${analysis.readModelName}"`,
|
|
2087
|
+
nodeId: block.id,
|
|
2088
|
+
severity: 'error',
|
|
2089
|
+
});
|
|
2090
|
+
break;
|
|
1616
2091
|
case 'resourceMissing':
|
|
1617
2092
|
errors.push({
|
|
1618
2093
|
message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references unknown resource "${analysis.resourceName}"`,
|
|
@@ -1622,7 +2097,7 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1622
2097
|
break;
|
|
1623
2098
|
case 'resourceListMissing':
|
|
1624
2099
|
errors.push({
|
|
1625
|
-
message:
|
|
2100
|
+
message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" requires resource "${analysis.resourceName}" to define list:`,
|
|
1626
2101
|
nodeId: block.id,
|
|
1627
2102
|
severity: 'error',
|
|
1628
2103
|
});
|
|
@@ -1664,7 +2139,7 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1664
2139
|
break;
|
|
1665
2140
|
case 'targetResourceListMissing':
|
|
1666
2141
|
errors.push({
|
|
1667
|
-
message:
|
|
2142
|
+
message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" requires related resource "${analysis.targetResource?.name ?? analysis.targetModel?.name}" to define list:`,
|
|
1668
2143
|
nodeId: block.id,
|
|
1669
2144
|
severity: 'error',
|
|
1670
2145
|
});
|
|
@@ -1676,6 +2151,58 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1676
2151
|
for (const block of page.blocks) {
|
|
1677
2152
|
const analysis = analyzePageBlockData(block, resources, models, readModels);
|
|
1678
2153
|
const blockLabel = block.title || block.id;
|
|
2154
|
+
if (block.blockType === 'timeline') {
|
|
2155
|
+
if (analysis.kind !== 'readModelList' && analysis.kind !== 'recordRelationList') {
|
|
2156
|
+
errors.push({
|
|
2157
|
+
message: `Timeline block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField> in the current slice`,
|
|
2158
|
+
nodeId: block.id,
|
|
2159
|
+
severity: 'error',
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
else if (analysis.kind === 'recordRelationList') {
|
|
2163
|
+
errors.push(...validateTimelineBlock(page, block, analysis.targetModel, `related model "${analysis.targetModel.name}"`));
|
|
2164
|
+
}
|
|
2165
|
+
else {
|
|
2166
|
+
errors.push(...validateTimelineBlock(page, block, analysis.readModel, `read-model "${analysis.readModel.name}" result`));
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
if (block.blockType === 'notes') {
|
|
2170
|
+
if (analysis.kind !== 'readModelList' && analysis.kind !== 'recordRelationList') {
|
|
2171
|
+
errors.push({
|
|
2172
|
+
message: `Notes block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField> in the current slice`,
|
|
2173
|
+
nodeId: block.id,
|
|
2174
|
+
severity: 'error',
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
else if (analysis.kind === 'recordRelationList') {
|
|
2178
|
+
errors.push(...validateNotesBlock(page, block, analysis.targetModel, `related model "${analysis.targetModel.name}"`));
|
|
2179
|
+
}
|
|
2180
|
+
else {
|
|
2181
|
+
errors.push(...validateNotesBlock(page, block, analysis.readModel, `read-model "${analysis.readModel.name}" result`));
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
if (block.blockType === 'attachments') {
|
|
2185
|
+
if (analysis.kind !== 'readModelList' && analysis.kind !== 'recordRelationList') {
|
|
2186
|
+
errors.push({
|
|
2187
|
+
message: `Attachments block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField> in the current slice`,
|
|
2188
|
+
nodeId: block.id,
|
|
2189
|
+
severity: 'error',
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
else if (analysis.kind === 'recordRelationList') {
|
|
2193
|
+
errors.push(...validateAttachmentsBlock(page, block, analysis.targetModel, `related model "${analysis.targetModel.name}"`));
|
|
2194
|
+
}
|
|
2195
|
+
else {
|
|
2196
|
+
errors.push(...validateAttachmentsBlock(page, block, analysis.readModel, `read-model "${analysis.readModel.name}" result`));
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
if (block.blockType === 'table' && analysis.kind === 'readModelList' && analysis.listView.columns.length === 0) {
|
|
2200
|
+
errors.push({
|
|
2201
|
+
message: `Table block "${blockLabel}" in page "${page.name}" requires read-model "${analysis.readModel.name}" list to define at least one column`,
|
|
2202
|
+
nodeId: block.id,
|
|
2203
|
+
severity: 'error',
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
1679
2206
|
if (block.queryState) {
|
|
1680
2207
|
if (block.queryState.trim() === '') {
|
|
1681
2208
|
errors.push({
|
|
@@ -1684,9 +2211,9 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1684
2211
|
severity: 'error',
|
|
1685
2212
|
});
|
|
1686
2213
|
}
|
|
1687
|
-
else if (analysis.kind !== 'readModelList' && analysis.kind !== 'readModelCount') {
|
|
2214
|
+
else if (analysis.kind !== 'readModelList' && analysis.kind !== 'readModelCount' && analysis.kind !== 'readModelValue') {
|
|
1688
2215
|
errors.push({
|
|
1689
|
-
message: `Page block "${blockLabel}" in page "${page.name}" may only use queryState with data: readModel.<name>.list or data: readModel.<name
|
|
2216
|
+
message: `Page block "${blockLabel}" in page "${page.name}" may only use queryState with data: readModel.<name>.list, data: readModel.<name>.count, or data: readModel.<name>.<field>`,
|
|
1690
2217
|
nodeId: block.id,
|
|
1691
2218
|
severity: 'error',
|
|
1692
2219
|
});
|
|
@@ -1705,6 +2232,27 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1705
2232
|
severity: 'error',
|
|
1706
2233
|
});
|
|
1707
2234
|
}
|
|
2235
|
+
else if (block.blockType === 'timeline') {
|
|
2236
|
+
errors.push({
|
|
2237
|
+
message: `Timeline block "${blockLabel}" in page "${page.name}" may not use selectionState`,
|
|
2238
|
+
nodeId: block.id,
|
|
2239
|
+
severity: 'error',
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
else if (block.blockType === 'notes') {
|
|
2243
|
+
errors.push({
|
|
2244
|
+
message: `Notes block "${blockLabel}" in page "${page.name}" may not use selectionState`,
|
|
2245
|
+
nodeId: block.id,
|
|
2246
|
+
severity: 'error',
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
else if (block.blockType === 'attachments') {
|
|
2250
|
+
errors.push({
|
|
2251
|
+
message: `Attachments block "${blockLabel}" in page "${page.name}" may not use selectionState`,
|
|
2252
|
+
nodeId: block.id,
|
|
2253
|
+
severity: 'error',
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
1708
2256
|
else if (analysis.kind !== 'readModelList' || block.blockType !== 'table') {
|
|
1709
2257
|
errors.push({
|
|
1710
2258
|
message: `Page block "${blockLabel}" in page "${page.name}" may only use selectionState with data: readModel.<name>.list table consumers in the current slice`,
|
|
@@ -1744,7 +2292,7 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1744
2292
|
severity: 'error',
|
|
1745
2293
|
});
|
|
1746
2294
|
}
|
|
1747
|
-
else if (inputField.fieldType.type !== 'scalar' ||
|
|
2295
|
+
else if (inputField.fieldType.type !== 'scalar' || !['string', 'date', 'datetime'].includes(inputField.fieldType.name)) {
|
|
1748
2296
|
errors.push({
|
|
1749
2297
|
message: `Page block "${blockLabel}" in page "${page.name}" dateNavigation field "${dateNavigation.field}" must be a string/date-like read-model input in the current slice`,
|
|
1750
2298
|
nodeId: block.id,
|
|
@@ -1773,6 +2321,184 @@ function validatePage(page, resources, models, readModels) {
|
|
|
1773
2321
|
errors.push(...validatePageBlockRowActions(page, block, resources, models, readModels));
|
|
1774
2322
|
}
|
|
1775
2323
|
errors.push(...validatePageActions(page, resources, models, queryStateGroups, selectionStateGroups));
|
|
2324
|
+
pushPageMessageWarnings(page, errors);
|
|
2325
|
+
return errors;
|
|
2326
|
+
}
|
|
2327
|
+
function pageBlockKindLabel(block) {
|
|
2328
|
+
if (block.blockType === 'metric') {
|
|
2329
|
+
return 'Metric block';
|
|
2330
|
+
}
|
|
2331
|
+
if (block.blockType === 'timeline') {
|
|
2332
|
+
return 'Timeline block';
|
|
2333
|
+
}
|
|
2334
|
+
if (block.blockType === 'notes') {
|
|
2335
|
+
return 'Notes block';
|
|
2336
|
+
}
|
|
2337
|
+
if (block.blockType === 'attachments') {
|
|
2338
|
+
return 'Attachments block';
|
|
2339
|
+
}
|
|
2340
|
+
return 'Table block';
|
|
2341
|
+
}
|
|
2342
|
+
function validateFieldContract(fields, fieldName, typeName) {
|
|
2343
|
+
const field = fields.find((candidate) => candidate.name === fieldName);
|
|
2344
|
+
return Boolean(field && field.fieldType.type === 'scalar' && field.fieldType.name === typeName);
|
|
2345
|
+
}
|
|
2346
|
+
function validateTimelineBlock(page, block, source, sourceLabel) {
|
|
2347
|
+
const errors = [];
|
|
2348
|
+
const blockLabel = block.title || block.id;
|
|
2349
|
+
const fields = 'result' in source ? source.result : source.fields;
|
|
2350
|
+
if (!validateFieldContract(fields, 'action', 'string')) {
|
|
2351
|
+
errors.push({
|
|
2352
|
+
message: `Timeline block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.action: string`,
|
|
2353
|
+
nodeId: block.id,
|
|
2354
|
+
severity: 'error',
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
if (!validateFieldContract(fields, 'performedAt', 'datetime')) {
|
|
2358
|
+
errors.push({
|
|
2359
|
+
message: `Timeline block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.performedAt: datetime`,
|
|
2360
|
+
nodeId: block.id,
|
|
2361
|
+
severity: 'error',
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
for (const fieldName of ['performedBy', 'message', 'beforeState', 'afterState']) {
|
|
2365
|
+
const field = fields.find((candidate) => candidate.name === fieldName);
|
|
2366
|
+
if (!field) {
|
|
2367
|
+
continue;
|
|
2368
|
+
}
|
|
2369
|
+
if (field.fieldType.type !== 'scalar' || field.fieldType.name !== 'string') {
|
|
2370
|
+
errors.push({
|
|
2371
|
+
message: `Timeline block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.${fieldName}: string when present`,
|
|
2372
|
+
nodeId: block.id,
|
|
2373
|
+
severity: 'error',
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
if (block.rowActions.length > 0) {
|
|
2378
|
+
errors.push({
|
|
2379
|
+
message: `Timeline block "${blockLabel}" in page "${page.name}" may not use rowActions`,
|
|
2380
|
+
nodeId: block.id,
|
|
2381
|
+
severity: 'error',
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
if (block.selectionState) {
|
|
2385
|
+
errors.push({
|
|
2386
|
+
message: `Timeline block "${blockLabel}" in page "${page.name}" may not use selectionState`,
|
|
2387
|
+
nodeId: block.id,
|
|
2388
|
+
severity: 'error',
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
return errors;
|
|
2392
|
+
}
|
|
2393
|
+
function validateNotesBlock(page, block, source, sourceLabel) {
|
|
2394
|
+
const errors = [];
|
|
2395
|
+
const blockLabel = block.title || block.id;
|
|
2396
|
+
const fields = 'result' in source ? source.result : source.fields;
|
|
2397
|
+
if (!validateFieldContract(fields, 'body', 'string')) {
|
|
2398
|
+
errors.push({
|
|
2399
|
+
message: `Notes block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.body: string`,
|
|
2400
|
+
nodeId: block.id,
|
|
2401
|
+
severity: 'error',
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
if (!validateFieldContract(fields, 'notedAt', 'datetime')) {
|
|
2405
|
+
errors.push({
|
|
2406
|
+
message: `Notes block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.notedAt: datetime`,
|
|
2407
|
+
nodeId: block.id,
|
|
2408
|
+
severity: 'error',
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
for (const fieldName of ['notedBy', 'title']) {
|
|
2412
|
+
const field = fields.find((candidate) => candidate.name === fieldName);
|
|
2413
|
+
if (!field) {
|
|
2414
|
+
continue;
|
|
2415
|
+
}
|
|
2416
|
+
if (field.fieldType.type !== 'scalar' || field.fieldType.name !== 'string') {
|
|
2417
|
+
errors.push({
|
|
2418
|
+
message: `Notes block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.${fieldName}: string when present`,
|
|
2419
|
+
nodeId: block.id,
|
|
2420
|
+
severity: 'error',
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
const visibilityField = fields.find((candidate) => candidate.name === 'visibility');
|
|
2425
|
+
if (visibilityField
|
|
2426
|
+
&& ((visibilityField.fieldType.type !== 'scalar' || visibilityField.fieldType.name !== 'string')
|
|
2427
|
+
&& visibilityField.fieldType.type !== 'enum')) {
|
|
2428
|
+
errors.push({
|
|
2429
|
+
message: `Notes block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.visibility: string or enum(...) when present`,
|
|
2430
|
+
nodeId: block.id,
|
|
2431
|
+
severity: 'error',
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
if (block.rowActions.length > 0) {
|
|
2435
|
+
errors.push({
|
|
2436
|
+
message: `Notes block "${blockLabel}" in page "${page.name}" may not use rowActions`,
|
|
2437
|
+
nodeId: block.id,
|
|
2438
|
+
severity: 'error',
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
if (block.selectionState) {
|
|
2442
|
+
errors.push({
|
|
2443
|
+
message: `Notes block "${blockLabel}" in page "${page.name}" may not use selectionState`,
|
|
2444
|
+
nodeId: block.id,
|
|
2445
|
+
severity: 'error',
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
return errors;
|
|
2449
|
+
}
|
|
2450
|
+
function validateAttachmentsBlock(page, block, source, sourceLabel) {
|
|
2451
|
+
const errors = [];
|
|
2452
|
+
const blockLabel = block.title || block.id;
|
|
2453
|
+
const fields = 'result' in source ? source.result : source.fields;
|
|
2454
|
+
if (!validateFieldContract(fields, 'fileName', 'string')) {
|
|
2455
|
+
errors.push({
|
|
2456
|
+
message: `Attachments block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.fileName: string`,
|
|
2457
|
+
nodeId: block.id,
|
|
2458
|
+
severity: 'error',
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
if (!validateFieldContract(fields, 'fileUrl', 'string')) {
|
|
2462
|
+
errors.push({
|
|
2463
|
+
message: `Attachments block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.fileUrl: string`,
|
|
2464
|
+
nodeId: block.id,
|
|
2465
|
+
severity: 'error',
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
if (!validateFieldContract(fields, 'attachedAt', 'datetime')) {
|
|
2469
|
+
errors.push({
|
|
2470
|
+
message: `Attachments block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.attachedAt: datetime`,
|
|
2471
|
+
nodeId: block.id,
|
|
2472
|
+
severity: 'error',
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
for (const fieldName of ['attachedBy', 'mediaType', 'sizeLabel', 'title']) {
|
|
2476
|
+
const field = fields.find((candidate) => candidate.name === fieldName);
|
|
2477
|
+
if (!field) {
|
|
2478
|
+
continue;
|
|
2479
|
+
}
|
|
2480
|
+
if (field.fieldType.type !== 'scalar' || field.fieldType.name !== 'string') {
|
|
2481
|
+
errors.push({
|
|
2482
|
+
message: `Attachments block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.${fieldName}: string when present`,
|
|
2483
|
+
nodeId: block.id,
|
|
2484
|
+
severity: 'error',
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
if (block.rowActions.length > 0) {
|
|
2489
|
+
errors.push({
|
|
2490
|
+
message: `Attachments block "${blockLabel}" in page "${page.name}" may not use rowActions`,
|
|
2491
|
+
nodeId: block.id,
|
|
2492
|
+
severity: 'error',
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
if (block.selectionState) {
|
|
2496
|
+
errors.push({
|
|
2497
|
+
message: `Attachments block "${blockLabel}" in page "${page.name}" may not use selectionState`,
|
|
2498
|
+
nodeId: block.id,
|
|
2499
|
+
severity: 'error',
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
1776
2502
|
return errors;
|
|
1777
2503
|
}
|
|
1778
2504
|
function serializeReadModelInputSignature(readModel) {
|
|
@@ -1789,7 +2515,13 @@ function validatePageBlockRowActions(page, block, resources, models, readModels)
|
|
|
1789
2515
|
}
|
|
1790
2516
|
if (block.blockType !== 'table') {
|
|
1791
2517
|
errors.push({
|
|
1792
|
-
message:
|
|
2518
|
+
message: block.blockType === 'timeline'
|
|
2519
|
+
? `Timeline block "${block.title || block.id}" in page "${page.name}" may not use rowActions`
|
|
2520
|
+
: block.blockType === 'notes'
|
|
2521
|
+
? `Notes block "${block.title || block.id}" in page "${page.name}" may not use rowActions`
|
|
2522
|
+
: block.blockType === 'attachments'
|
|
2523
|
+
? `Attachments block "${block.title || block.id}" in page "${page.name}" may not use rowActions`
|
|
2524
|
+
: `Page block "${block.title || block.id}" in page "${page.name}" may only use rowActions on type: table`,
|
|
1793
2525
|
nodeId: block.id,
|
|
1794
2526
|
severity: 'error',
|
|
1795
2527
|
});
|
|
@@ -2043,30 +2775,75 @@ function validateRuleValuePath(rule, key, nodeId, errors) {
|
|
|
2043
2775
|
}
|
|
2044
2776
|
}
|
|
2045
2777
|
function validateFormFieldRulePaths(field, errors) {
|
|
2046
|
-
validateRuleValuePath(field.
|
|
2047
|
-
validateRuleValuePath(field.
|
|
2778
|
+
validateRuleValuePath(field.visibleIf, 'field.visibleIf', field.id, errors);
|
|
2779
|
+
validateRuleValuePath(field.enabledIf, 'field.enabledIf', field.id, errors);
|
|
2048
2780
|
}
|
|
2049
2781
|
function validateFormFieldRules(field, errors, validateIdentifier) {
|
|
2050
|
-
if (field.
|
|
2051
|
-
validateWorkflowExpr(field.
|
|
2782
|
+
if (field.visibleIf?.source === 'builtin') {
|
|
2783
|
+
validateWorkflowExpr(field.visibleIf.expr, field.id, errors, validateIdentifier);
|
|
2052
2784
|
}
|
|
2053
|
-
if (field.
|
|
2054
|
-
validateWorkflowExpr(field.
|
|
2785
|
+
if (field.enabledIf?.source === 'builtin') {
|
|
2786
|
+
validateWorkflowExpr(field.enabledIf.expr, field.id, errors, validateIdentifier);
|
|
2055
2787
|
}
|
|
2056
2788
|
}
|
|
2057
2789
|
function validateWorkflowExpr(expr, nodeId, errors, validateIdentifier) {
|
|
2058
|
-
|
|
2790
|
+
const visitScoped = (node, bound = new Set()) => {
|
|
2059
2791
|
if (node.type === 'identifier') {
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2792
|
+
if (!(node.path.length > 0 && bound.has(node.path[0]))) {
|
|
2793
|
+
const error = validateIdentifier(node.path);
|
|
2794
|
+
if (error) {
|
|
2795
|
+
errors.push({
|
|
2796
|
+
message: error,
|
|
2797
|
+
nodeId,
|
|
2798
|
+
severity: 'error',
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2067
2801
|
}
|
|
2802
|
+
return;
|
|
2068
2803
|
}
|
|
2069
|
-
|
|
2804
|
+
if (node.type === 'binary') {
|
|
2805
|
+
visitScoped(node.left, bound);
|
|
2806
|
+
visitScoped(node.right, bound);
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
if (node.type === 'unary') {
|
|
2810
|
+
visitScoped(node.operand, bound);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
if (node.type === 'call') {
|
|
2814
|
+
for (const arg of node.args) {
|
|
2815
|
+
visitScoped(arg, bound);
|
|
2816
|
+
}
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
if (node.type === 'quantifier') {
|
|
2820
|
+
visitScoped(node.collection, bound);
|
|
2821
|
+
const inner = new Set(bound);
|
|
2822
|
+
inner.add(node.binding);
|
|
2823
|
+
visitScoped(node.predicate, inner);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (node.type === 'member') {
|
|
2827
|
+
visitScoped(node.object, bound);
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
if (node.type === 'in') {
|
|
2831
|
+
visitScoped(node.value, bound);
|
|
2832
|
+
for (const item of node.list) {
|
|
2833
|
+
visitScoped(item, bound);
|
|
2834
|
+
}
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
if (node.type === 'nullCheck') {
|
|
2838
|
+
visitScoped(node.value, bound);
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
if (node.type === 'like') {
|
|
2842
|
+
visitScoped(node.value, bound);
|
|
2843
|
+
visitScoped(node.pattern, bound);
|
|
2844
|
+
}
|
|
2845
|
+
};
|
|
2846
|
+
visitScoped(expr);
|
|
2070
2847
|
}
|
|
2071
2848
|
function validateWorkflowTransitionIdentifier(path, resourceName, modelFieldNames) {
|
|
2072
2849
|
if (path.length === 0) {
|
|
@@ -2130,7 +2907,7 @@ function validateReadModelRulesEligibilityIdentifier(path, readModelName, inputF
|
|
|
2130
2907
|
}
|
|
2131
2908
|
return `Read-model "${readModelName}" rules eligibility uses unsupported identifier root "${root}"; use currentUser, input, or bare enum-like literals`;
|
|
2132
2909
|
}
|
|
2133
|
-
function validateLinkedFormRulesIdentifier(path, resourceName, mode,
|
|
2910
|
+
function validateLinkedFormRulesIdentifier(path, resourceName, mode, modelFieldMap) {
|
|
2134
2911
|
if (path.length === 0) {
|
|
2135
2912
|
return undefined;
|
|
2136
2913
|
}
|
|
@@ -2138,9 +2915,6 @@ function validateLinkedFormRulesIdentifier(path, resourceName, mode, modelFieldN
|
|
|
2138
2915
|
if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
|
|
2139
2916
|
return undefined;
|
|
2140
2917
|
}
|
|
2141
|
-
if (rest.length > 0) {
|
|
2142
|
-
return `Resource "${resourceName}" ${mode}.rules support only one-level property access; got "${path.join('.')}"`;
|
|
2143
|
-
}
|
|
2144
2918
|
if (root === 'currentUser') {
|
|
2145
2919
|
if (!property) {
|
|
2146
2920
|
return undefined;
|
|
@@ -2154,16 +2928,26 @@ function validateLinkedFormRulesIdentifier(path, resourceName, mode, modelFieldN
|
|
|
2154
2928
|
if (!property) {
|
|
2155
2929
|
return undefined;
|
|
2156
2930
|
}
|
|
2157
|
-
|
|
2931
|
+
const field = modelFieldMap.get(property);
|
|
2932
|
+
if (!field) {
|
|
2158
2933
|
return `Resource "${resourceName}" ${mode}.rules reference unknown formData field "${property}"`;
|
|
2159
2934
|
}
|
|
2160
|
-
|
|
2935
|
+
if (rest.length === 0) {
|
|
2936
|
+
return undefined;
|
|
2937
|
+
}
|
|
2938
|
+
if (rest.length === 1 && field.fieldType.type === 'relation' && field.fieldType.kind === 'hasMany') {
|
|
2939
|
+
return undefined;
|
|
2940
|
+
}
|
|
2941
|
+
return `Resource "${resourceName}" ${mode}.rules support only one-level property access except direct hasMany(...) projection; got "${path.join('.')}"`;
|
|
2942
|
+
}
|
|
2943
|
+
if (rest.length > 0) {
|
|
2944
|
+
return `Resource "${resourceName}" ${mode}.rules support only one-level property access; got "${path.join('.')}"`;
|
|
2161
2945
|
}
|
|
2162
2946
|
if (root === 'record' && mode === 'edit') {
|
|
2163
2947
|
if (!property) {
|
|
2164
2948
|
return undefined;
|
|
2165
2949
|
}
|
|
2166
|
-
if (property !== 'id' && !
|
|
2950
|
+
if (property !== 'id' && !modelFieldMap.has(property)) {
|
|
2167
2951
|
return `Resource "${resourceName}" edit.rules reference unknown record field "${property}"`;
|
|
2168
2952
|
}
|
|
2169
2953
|
return undefined;
|
|
@@ -2369,6 +3153,11 @@ function visitExpr(expr, visit) {
|
|
|
2369
3153
|
}
|
|
2370
3154
|
return;
|
|
2371
3155
|
}
|
|
3156
|
+
if (expr.type === 'quantifier') {
|
|
3157
|
+
visitExpr(expr.collection, visit);
|
|
3158
|
+
visitExpr(expr.predicate, visit);
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
2372
3161
|
if (expr.type === 'member') {
|
|
2373
3162
|
visitExpr(expr.object, visit);
|
|
2374
3163
|
return;
|
|
@@ -2454,6 +3243,70 @@ function validateRouteTarget(target, nodeId, resourceMap, pageMap, errors) {
|
|
|
2454
3243
|
}
|
|
2455
3244
|
}
|
|
2456
3245
|
}
|
|
3246
|
+
function validateStaticAppRouteTarget(target, nodeId, resourceMap, pageMap, errors, routeName) {
|
|
3247
|
+
const parts = target.split('.');
|
|
3248
|
+
if (parts[0] === 'page' && parts.length === 2) {
|
|
3249
|
+
const page = pageMap.get(parts[1]);
|
|
3250
|
+
if (!page) {
|
|
3251
|
+
errors.push({
|
|
3252
|
+
message: `app.routes.${routeName} references unknown page "${parts[1]}"`,
|
|
3253
|
+
nodeId,
|
|
3254
|
+
severity: 'error',
|
|
3255
|
+
});
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
if (page.path && page.path.split('/').some((segment) => segment.startsWith(':'))) {
|
|
3259
|
+
errors.push({
|
|
3260
|
+
message: `app.routes.${routeName} references page "${parts[1]}" with record-scoped path "${page.path}"; only static page routes are supported`,
|
|
3261
|
+
nodeId,
|
|
3262
|
+
severity: 'error',
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
return;
|
|
3266
|
+
}
|
|
3267
|
+
if (parts[0] === 'resource' && parts.length === 3) {
|
|
3268
|
+
const resource = resourceMap.get(parts[1]);
|
|
3269
|
+
if (!resource) {
|
|
3270
|
+
errors.push({
|
|
3271
|
+
message: `app.routes.${routeName} references unknown resource "${parts[1]}"`,
|
|
3272
|
+
nodeId,
|
|
3273
|
+
severity: 'error',
|
|
3274
|
+
});
|
|
3275
|
+
return;
|
|
3276
|
+
}
|
|
3277
|
+
if (parts[2] === 'list') {
|
|
3278
|
+
if (!resource.views.list) {
|
|
3279
|
+
errors.push({
|
|
3280
|
+
message: `app.routes.${routeName} requires resource "${resource.name}" to define list:`,
|
|
3281
|
+
nodeId,
|
|
3282
|
+
severity: 'error',
|
|
3283
|
+
});
|
|
3284
|
+
}
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
if (parts[2] === 'create') {
|
|
3288
|
+
if (!resource.views.create) {
|
|
3289
|
+
errors.push({
|
|
3290
|
+
message: `app.routes.${routeName} requires resource "${resource.name}" to define create:`,
|
|
3291
|
+
nodeId,
|
|
3292
|
+
severity: 'error',
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
errors.push({
|
|
3298
|
+
message: `app.routes.${routeName} only supports static resource views; use resource.<name>.list or resource.<name>.create`,
|
|
3299
|
+
nodeId,
|
|
3300
|
+
severity: 'error',
|
|
3301
|
+
});
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
errors.push({
|
|
3305
|
+
message: `app.routes.${routeName} must target page.<name> or resource.<name>.list/create`,
|
|
3306
|
+
nodeId,
|
|
3307
|
+
severity: 'error',
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
2457
3310
|
function validateNavTarget(target, nodeId, resourceMap, pageMap, errors) {
|
|
2458
3311
|
// Navigation target formats: "resource.users.list", "page.dashboard"
|
|
2459
3312
|
const parts = target.split('.');
|