@svadmin/core 0.61.0 → 0.61.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/product-pages.ts +9 -1
- package/src/product-review.ts +63 -2
package/package.json
CHANGED
package/src/product-pages.ts
CHANGED
|
@@ -27,8 +27,16 @@ export function buildProductPagePlan(surface: ProductSurface, plan: ProductPlan)
|
|
|
27
27
|
.filter(id => id !== surface.primaryActionId)
|
|
28
28
|
.map(id => actionById.get(id))
|
|
29
29
|
.filter((action): action is ProductAction => action !== undefined);
|
|
30
|
+
// ProductSurface currently declares section titles separately from its
|
|
31
|
+
// resource list. Keep resource declarations in one section until the plan
|
|
32
|
+
// schema can express per-section ownership; repeating every resource in
|
|
33
|
+
// every section makes generated pages look like duplicate data.
|
|
30
34
|
const sections = surface.sections.length
|
|
31
|
-
? surface.sections.map((title, index) => ({
|
|
35
|
+
? surface.sections.map((title, index) => ({
|
|
36
|
+
id: `${surface.id}.section.${index + 1}`,
|
|
37
|
+
title,
|
|
38
|
+
resourceIds: index === 0 ? surface.resourceIds : [],
|
|
39
|
+
}))
|
|
32
40
|
: [{ id: `${surface.id}.content`, title: surface.name, resourceIds: surface.resourceIds }];
|
|
33
41
|
return {
|
|
34
42
|
id: surface.id,
|
package/src/product-review.ts
CHANGED
|
@@ -37,6 +37,40 @@ export function reviewProductPlan(plan: ProductPlan): ProductReviewReport {
|
|
|
37
37
|
const surfaceIds = new Set(plan.surfaces.map(surface => surface.id));
|
|
38
38
|
const actionIds = new Set(plan.actions.map(action => action.id));
|
|
39
39
|
|
|
40
|
+
const runtimeNames = new Map<string, string>();
|
|
41
|
+
for (const resource of plan.resources) {
|
|
42
|
+
const runtimeName = resource.runtimeName ?? resource.name;
|
|
43
|
+
const previous = runtimeNames.get(runtimeName);
|
|
44
|
+
if (previous !== undefined) {
|
|
45
|
+
addIssue(issues, {
|
|
46
|
+
code: 'resource.duplicate-runtime-name',
|
|
47
|
+
severity: 'error',
|
|
48
|
+
message: `Resources "${resource.label}" and "${previous}" resolve to the same runtime name "${runtimeName}".`,
|
|
49
|
+
path: `resources.${resource.id}.runtimeName`,
|
|
50
|
+
suggestion: 'Give every generated resource route a unique runtime name.',
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
runtimeNames.set(runtimeName, resource.label);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const surfaceRoutes = new Map<string, string>();
|
|
58
|
+
for (const surface of plan.surfaces) {
|
|
59
|
+
const route = surface.route ?? `/workflows/${surface.id}`;
|
|
60
|
+
const previous = surfaceRoutes.get(route);
|
|
61
|
+
if (previous !== undefined) {
|
|
62
|
+
addIssue(issues, {
|
|
63
|
+
code: 'surface.duplicate-route',
|
|
64
|
+
severity: 'error',
|
|
65
|
+
message: `Surfaces "${surface.name}" and "${previous}" resolve to the same route "${route}".`,
|
|
66
|
+
path: `surfaces.${surface.id}.route`,
|
|
67
|
+
suggestion: 'Give every generated surface a unique route.',
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
surfaceRoutes.set(route, surface.name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
for (const workflow of plan.workflows) {
|
|
41
75
|
if (!workflow.actorRoleIds.length) addIssue(issues, { code: 'workflow.missing-actor', severity: 'error', message: `Workflow "${workflow.name}" has no actor role.`, path: `workflows.${workflow.id}.actorRoleIds` });
|
|
42
76
|
for (const roleId of workflow.actorRoleIds) if (!has(roleIds, roleId)) addIssue(issues, { code: 'workflow.unknown-role', severity: 'error', message: `Workflow "${workflow.name}" references unknown role "${roleId}".`, path: `workflows.${workflow.id}.actorRoleIds` });
|
|
@@ -46,12 +80,34 @@ export function reviewProductPlan(plan: ProductPlan): ProductReviewReport {
|
|
|
46
80
|
for (const resource of plan.resources) {
|
|
47
81
|
if (!resource.workflowIds.length && !resource.internal) addIssue(issues, { code: 'resource.no-workflow', severity: 'warning', message: `Resource "${resource.label}" is not attached to a workflow.`, path: `resources.${resource.id}.workflowIds`, suggestion: 'Make it contextual/internal or attach it to a real task.' });
|
|
48
82
|
for (const workflowId of resource.workflowIds) if (!has(workflowIds, workflowId)) addIssue(issues, { code: 'resource.unknown-workflow', severity: 'error', message: `Resource "${resource.label}" references unknown workflow "${workflowId}".`, path: `resources.${resource.id}.workflowIds` });
|
|
83
|
+
for (const roleId of resource.roleIds) if (!has(roleIds, roleId)) addIssue(issues, { code: 'resource.unknown-role', severity: 'error', message: `Resource "${resource.label}" references unknown role "${roleId}".`, path: `resources.${resource.id}.roleIds` });
|
|
84
|
+
resource.fields.forEach(field => {
|
|
85
|
+
for (const roleId of field.readableBy) if (!has(roleIds, roleId)) addIssue(issues, { code: 'field.unknown-role', severity: 'error', message: `Field "${field.label}" references unknown readable role "${roleId}".`, path: `resources.${resource.id}.fields.${field.id}.readableBy` });
|
|
86
|
+
for (const roleId of field.writableBy) if (!has(roleIds, roleId)) addIssue(issues, { code: 'field.unknown-role', severity: 'error', message: `Field "${field.label}" references unknown writable role "${roleId}".`, path: `resources.${resource.id}.fields.${field.id}.writableBy` });
|
|
87
|
+
for (const workflowId of field.usedByWorkflowIds) if (!has(workflowIds, workflowId)) addIssue(issues, { code: 'field.unknown-workflow', severity: 'error', message: `Field "${field.label}" references unknown workflow "${workflowId}".`, path: `resources.${resource.id}.fields.${field.id}.usedByWorkflowIds` });
|
|
88
|
+
});
|
|
49
89
|
}
|
|
50
90
|
|
|
51
91
|
for (const surface of plan.surfaces) {
|
|
52
|
-
reviewSurface(surface, { workflowIds, actionIds, resourceIds }, issues);
|
|
92
|
+
reviewSurface(surface, { workflowIds, actionIds, resourceIds, roleIds, surfaceIds }, issues);
|
|
53
93
|
}
|
|
54
94
|
|
|
95
|
+
const visitingSurfaces = new Set<string>();
|
|
96
|
+
const visitedSurfaces = new Set<string>();
|
|
97
|
+
const visitSurface = (id: string): void => {
|
|
98
|
+
if (visitingSurfaces.has(id)) {
|
|
99
|
+
addIssue(issues, { code: 'surface.cycle', severity: 'error', message: `Surface hierarchy contains a cycle at "${id}".`, path: `surfaces.${id}.parentSurfaceId` });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (visitedSurfaces.has(id)) return;
|
|
103
|
+
visitingSurfaces.add(id);
|
|
104
|
+
const parentId = plan.surfaces.find(surface => surface.id === id)?.parentSurfaceId;
|
|
105
|
+
if (parentId !== undefined && surfaceIds.has(parentId)) visitSurface(parentId);
|
|
106
|
+
visitingSurfaces.delete(id);
|
|
107
|
+
visitedSurfaces.add(id);
|
|
108
|
+
};
|
|
109
|
+
for (const surface of plan.surfaces) visitSurface(surface.id);
|
|
110
|
+
|
|
55
111
|
const menuById = new Map(plan.menu.map(item => [item.id, item] as const));
|
|
56
112
|
for (const item of plan.menu) {
|
|
57
113
|
if (item.parentId !== undefined && !menuById.has(item.parentId)) {
|
|
@@ -78,11 +134,14 @@ export function reviewProductPlan(plan: ProductPlan): ProductReviewReport {
|
|
|
78
134
|
if (!item.workflowIds.length && !item.resourceId && !item.surfaceId) addIssue(issues, { code: 'menu.no-business-target', severity: 'warning', message: `Menu item "${item.label}" has no workflow, resource or surface target.`, path: `menu.${item.id}`, suggestion: 'Keep it only for an intentional external/system entry.' });
|
|
79
135
|
if (item.surfaceId && !has(surfaceIds, item.surfaceId)) addIssue(issues, { code: 'menu.unknown-surface', severity: 'error', message: `Menu item "${item.label}" references unknown surface.`, path: `menu.${item.id}.surfaceId` });
|
|
80
136
|
if (item.resourceId && !has(resourceIds, item.resourceId)) addIssue(issues, { code: 'menu.unknown-resource', severity: 'error', message: `Menu item "${item.label}" references unknown resource.`, path: `menu.${item.id}.resourceId` });
|
|
137
|
+
for (const workflowId of item.workflowIds) if (!has(workflowIds, workflowId)) addIssue(issues, { code: 'menu.unknown-workflow', severity: 'error', message: `Menu item "${item.label}" references unknown workflow "${workflowId}".`, path: `menu.${item.id}.workflowIds` });
|
|
138
|
+
for (const roleId of item.roleIds) if (!has(roleIds, roleId)) addIssue(issues, { code: 'menu.unknown-role', severity: 'error', message: `Menu item "${item.label}" references unknown role "${roleId}".`, path: `menu.${item.id}.roleIds` });
|
|
81
139
|
if (!item.roleIds.length && item.placement !== 'hidden') addIssue(issues, { code: 'menu.public-entry', severity: 'info', message: `Menu item "${item.label}" is public at the UI hint layer.`, path: `menu.${item.id}.roleIds`, suggestion: 'Keep server authorization independent from menu visibility.' });
|
|
82
140
|
}
|
|
83
141
|
|
|
84
142
|
for (const action of plan.actions) {
|
|
85
143
|
if (!has(workflowIds, action.workflowId)) addIssue(issues, { code: 'action.unknown-workflow', severity: 'error', message: `Action "${action.label}" references unknown workflow.`, path: `actions.${action.id}.workflowId` });
|
|
144
|
+
for (const roleId of action.roleIds) if (!has(roleIds, roleId)) addIssue(issues, { code: 'action.unknown-role', severity: 'error', message: `Action "${action.label}" references unknown role "${roleId}".`, path: `actions.${action.id}.roleIds` });
|
|
86
145
|
if (!action.roleIds.length) addIssue(issues, { code: 'action.missing-roles', severity: 'warning', message: `Action "${action.label}" has no role visibility contract.`, path: `actions.${action.id}.roleIds` });
|
|
87
146
|
}
|
|
88
147
|
|
|
@@ -110,8 +169,10 @@ export function reviewProductPlan(plan: ProductPlan): ProductReviewReport {
|
|
|
110
169
|
};
|
|
111
170
|
}
|
|
112
171
|
|
|
113
|
-
function reviewSurface(surface: ProductSurface, refs: { workflowIds: ReadonlySet<string>; actionIds: ReadonlySet<string>; resourceIds: ReadonlySet<string> }, issues: ProductReviewIssue[]): void {
|
|
172
|
+
function reviewSurface(surface: ProductSurface, refs: { workflowIds: ReadonlySet<string>; actionIds: ReadonlySet<string>; resourceIds: ReadonlySet<string>; roleIds: ReadonlySet<string>; surfaceIds: ReadonlySet<string> }, issues: ProductReviewIssue[]): void {
|
|
114
173
|
if (!surface.purpose.trim()) addIssue(issues, { code: 'surface.missing-purpose', severity: 'error', message: `Surface "${surface.name}" has no purpose.`, path: `surfaces.${surface.id}.purpose` });
|
|
174
|
+
for (const roleId of surface.roleIds) if (!refs.roleIds.has(roleId)) addIssue(issues, { code: 'surface.unknown-role', severity: 'error', message: `Surface "${surface.name}" references unknown role "${roleId}".`, path: `surfaces.${surface.id}.roleIds` });
|
|
175
|
+
if (surface.parentSurfaceId !== undefined && !refs.surfaceIds.has(surface.parentSurfaceId)) addIssue(issues, { code: 'surface.unknown-parent', severity: 'error', message: `Surface "${surface.name}" references unknown parent surface "${surface.parentSurfaceId}".`, path: `surfaces.${surface.id}.parentSurfaceId` });
|
|
115
176
|
if (!surface.workflowIds.length) addIssue(issues, { code: 'surface.no-workflow', severity: 'warning', message: `Surface "${surface.name}" is not attached to a workflow.`, path: `surfaces.${surface.id}.workflowIds` });
|
|
116
177
|
surface.workflowIds.forEach(id => { if (!refs.workflowIds.has(id)) addIssue(issues, { code: 'surface.unknown-workflow', severity: 'error', message: `Surface "${surface.name}" references unknown workflow "${id}".`, path: `surfaces.${surface.id}.workflowIds` }); });
|
|
117
178
|
surface.resourceIds.forEach(id => { if (!refs.resourceIds.has(id)) addIssue(issues, { code: 'surface.unknown-resource', severity: 'error', message: `Surface "${surface.name}" references unknown resource "${id}".`, path: `surfaces.${surface.id}.resourceIds` }); });
|