@appsemble/lang-sdk 0.38.0 → 0.38.2-test.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/gridUtils.d.ts +63 -0
- package/gridUtils.js +114 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/pageUtils.d.ts +9 -0
- package/pageUtils.js +41 -0
- package/schemas/AppLayoutDefinition.js +9 -0
- package/schemas/BasePageDefinition.js +19 -0
- package/schemas/BlockDefinition.js +4 -1
- package/schemas/PageLayoutDefinition.js +6 -1
- package/schemas/PageParentDefinition.d.ts +2 -0
- package/schemas/PageParentDefinition.js +35 -0
- package/schemas/index.d.ts +1 -0
- package/schemas/index.js +1 -0
- package/types/core.d.ts +19 -0
- package/validation.js +143 -45
package/gridUtils.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type GridLayoutDefinition, type PageDefinition, type PageLayoutDefinition, type ResponsiveGridLayoutDefinition } from './types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The template area a page grid layout uses to place the breadcrumb trail.
|
|
4
|
+
*/
|
|
5
|
+
export declare const breadcrumbsGridArea = "breadcrumbs";
|
|
6
|
+
/**
|
|
7
|
+
* The devices a responsive grid layout is defined for, from the smallest breakpoint up.
|
|
8
|
+
*/
|
|
9
|
+
export declare const gridDeviceOrder: readonly ["mobile", "tablet", "desktop"];
|
|
10
|
+
/**
|
|
11
|
+
* One of the devices a responsive grid layout is defined for.
|
|
12
|
+
*/
|
|
13
|
+
export type GridDeviceName = (typeof gridDeviceOrder)[number];
|
|
14
|
+
/**
|
|
15
|
+
* The grid a device falls back to when neither it nor a smaller device defines one.
|
|
16
|
+
*/
|
|
17
|
+
export declare const defaultGridLayout: Readonly<GridLayoutDefinition>;
|
|
18
|
+
/**
|
|
19
|
+
* Collect the template areas a responsive grid layout defines.
|
|
20
|
+
*
|
|
21
|
+
* @param layoutDefinition The responsive grid layout to read.
|
|
22
|
+
* @returns Every area named by the template of any device layout, without the `.` placeholder.
|
|
23
|
+
*/
|
|
24
|
+
export declare function getGridTemplateAreas(layoutDefinition: ResponsiveGridLayoutDefinition | undefined): Set<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Collect the grid layouts of a page, in definition order.
|
|
27
|
+
*
|
|
28
|
+
* A page type that splits its content over sub pages defines a grid per sub page, so a tab, a step
|
|
29
|
+
* or a loop sub page each carry their own. A basic page has a single grid, and a container page has
|
|
30
|
+
* none of its own.
|
|
31
|
+
*
|
|
32
|
+
* @param page The page to read the grid layouts of.
|
|
33
|
+
* @returns The layout of every sub page the page renders, which is undefined where none is defined.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getPageGridLayouts(page: PageDefinition): (PageLayoutDefinition | undefined)[];
|
|
36
|
+
/**
|
|
37
|
+
* Collect the template areas each device renders.
|
|
38
|
+
*
|
|
39
|
+
* A device that defines no template of its own inherits the one of the next smaller device, the way
|
|
40
|
+
* the app applies the grid of a page, down to a fallback of the single `main` area. So the areas a
|
|
41
|
+
* device renders are not the areas its own template names.
|
|
42
|
+
*
|
|
43
|
+
* @param layout The responsive grid layout to read.
|
|
44
|
+
* @returns The areas rendered by each device, keyed by device name.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getCascadedGridTemplateAreas(layout: ResponsiveGridLayoutDefinition | undefined): Record<GridDeviceName, Set<string>>;
|
|
47
|
+
/**
|
|
48
|
+
* Check whether a grid layout places the breadcrumb trail.
|
|
49
|
+
*
|
|
50
|
+
* Every device has to render the area, since a device that does not would place the trail against
|
|
51
|
+
* implicit grid lines rather than inside the layout.
|
|
52
|
+
*
|
|
53
|
+
* @param layout The responsive grid layout to check.
|
|
54
|
+
* @returns Whether every device renders the breadcrumbs template area.
|
|
55
|
+
*/
|
|
56
|
+
export declare function hasBreadcrumbsGridArea(layout: PageLayoutDefinition | undefined): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Check whether a page renders the breadcrumb trail from a grid layout.
|
|
59
|
+
*
|
|
60
|
+
* @param page The page to check the grid layouts of.
|
|
61
|
+
* @returns Whether any grid layout of the page places the breadcrumb trail.
|
|
62
|
+
*/
|
|
63
|
+
export declare function pageHasBreadcrumbsGridArea(page: PageDefinition): boolean;
|
package/gridUtils.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template area a page grid layout uses to place the breadcrumb trail.
|
|
3
|
+
*/
|
|
4
|
+
export const breadcrumbsGridArea = 'breadcrumbs';
|
|
5
|
+
/**
|
|
6
|
+
* The devices a responsive grid layout is defined for, from the smallest breakpoint up.
|
|
7
|
+
*/
|
|
8
|
+
export const gridDeviceOrder = ['mobile', 'tablet', 'desktop'];
|
|
9
|
+
/**
|
|
10
|
+
* The grid a device falls back to when neither it nor a smaller device defines one.
|
|
11
|
+
*/
|
|
12
|
+
export const defaultGridLayout = {
|
|
13
|
+
columns: 1,
|
|
14
|
+
template: ['main'],
|
|
15
|
+
};
|
|
16
|
+
function getTemplateAreas(template) {
|
|
17
|
+
const areas = new Set();
|
|
18
|
+
for (const row of template) {
|
|
19
|
+
for (const area of row.split(' ')) {
|
|
20
|
+
if (area && area !== '.') {
|
|
21
|
+
areas.add(area);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return areas;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Collect the template areas a responsive grid layout defines.
|
|
29
|
+
*
|
|
30
|
+
* @param layoutDefinition The responsive grid layout to read.
|
|
31
|
+
* @returns Every area named by the template of any device layout, without the `.` placeholder.
|
|
32
|
+
*/
|
|
33
|
+
export function getGridTemplateAreas(layoutDefinition) {
|
|
34
|
+
const areas = new Set();
|
|
35
|
+
if (!layoutDefinition) {
|
|
36
|
+
return areas;
|
|
37
|
+
}
|
|
38
|
+
for (const deviceDefinition of Object.values(layoutDefinition)) {
|
|
39
|
+
for (const area of getTemplateAreas(deviceDefinition?.layout?.template ?? [])) {
|
|
40
|
+
areas.add(area);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return areas;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Collect the grid layouts of a page, in definition order.
|
|
47
|
+
*
|
|
48
|
+
* A page type that splits its content over sub pages defines a grid per sub page, so a tab, a step
|
|
49
|
+
* or a loop sub page each carry their own. A basic page has a single grid, and a container page has
|
|
50
|
+
* none of its own.
|
|
51
|
+
*
|
|
52
|
+
* @param page The page to read the grid layouts of.
|
|
53
|
+
* @returns The layout of every sub page the page renders, which is undefined where none is defined.
|
|
54
|
+
*/
|
|
55
|
+
export function getPageGridLayouts(page) {
|
|
56
|
+
switch (page.type) {
|
|
57
|
+
case 'container':
|
|
58
|
+
return [];
|
|
59
|
+
case 'flow':
|
|
60
|
+
return page.steps.map((step) => step.layout);
|
|
61
|
+
case 'loop':
|
|
62
|
+
return [page.start, page.foreach, page.end].flatMap((subPage) => subPage ? [subPage.layout] : []);
|
|
63
|
+
case 'tabs':
|
|
64
|
+
return page.tabs
|
|
65
|
+
? page.tabs.map((tab) => tab.layout)
|
|
66
|
+
: page.definition
|
|
67
|
+
? [page.definition.foreach.layout]
|
|
68
|
+
: [];
|
|
69
|
+
default:
|
|
70
|
+
return [page.layout];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Collect the template areas each device renders.
|
|
75
|
+
*
|
|
76
|
+
* A device that defines no template of its own inherits the one of the next smaller device, the way
|
|
77
|
+
* the app applies the grid of a page, down to a fallback of the single `main` area. So the areas a
|
|
78
|
+
* device renders are not the areas its own template names.
|
|
79
|
+
*
|
|
80
|
+
* @param layout The responsive grid layout to read.
|
|
81
|
+
* @returns The areas rendered by each device, keyed by device name.
|
|
82
|
+
*/
|
|
83
|
+
export function getCascadedGridTemplateAreas(layout) {
|
|
84
|
+
let { template } = defaultGridLayout;
|
|
85
|
+
return Object.fromEntries(gridDeviceOrder.map((device) => {
|
|
86
|
+
template = layout?.[device]?.layout?.template ?? template;
|
|
87
|
+
return [device, getTemplateAreas(template)];
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Check whether a grid layout places the breadcrumb trail.
|
|
92
|
+
*
|
|
93
|
+
* Every device has to render the area, since a device that does not would place the trail against
|
|
94
|
+
* implicit grid lines rather than inside the layout.
|
|
95
|
+
*
|
|
96
|
+
* @param layout The responsive grid layout to check.
|
|
97
|
+
* @returns Whether every device renders the breadcrumbs template area.
|
|
98
|
+
*/
|
|
99
|
+
export function hasBreadcrumbsGridArea(layout) {
|
|
100
|
+
if (!layout) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return Object.values(getCascadedGridTemplateAreas(layout)).every((areas) => areas.has(breadcrumbsGridArea));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Check whether a page renders the breadcrumb trail from a grid layout.
|
|
107
|
+
*
|
|
108
|
+
* @param page The page to check the grid layouts of.
|
|
109
|
+
* @returns Whether any grid layout of the page places the breadcrumb trail.
|
|
110
|
+
*/
|
|
111
|
+
export function pageHasBreadcrumbsGridArea(page) {
|
|
112
|
+
return getPageGridLayouts(page).some(hasBreadcrumbsGridArea);
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=gridUtils.js.map
|
package/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './authorization.js';
|
|
|
6
6
|
export * from './blockUtils.js';
|
|
7
7
|
export * from './validation.js';
|
|
8
8
|
export * from './findPageByName.js';
|
|
9
|
+
export * from './gridUtils.js';
|
|
9
10
|
export * from './pageUtils.js';
|
|
10
11
|
export * from './iterApp.js';
|
|
11
12
|
export * from './serverActions.js';
|
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export * from './authorization.js';
|
|
|
6
6
|
export * from './blockUtils.js';
|
|
7
7
|
export * from './validation.js';
|
|
8
8
|
export * from './findPageByName.js';
|
|
9
|
+
export * from './gridUtils.js';
|
|
9
10
|
export * from './pageUtils.js';
|
|
10
11
|
export * from './iterApp.js';
|
|
11
12
|
export * from './serverActions.js';
|
package/package.json
CHANGED
package/pageUtils.d.ts
CHANGED
|
@@ -5,4 +5,13 @@ export declare function getPageMessageId(pageName: string): string;
|
|
|
5
5
|
export declare function getPagePathSegment(page: string | NamedPage): string;
|
|
6
6
|
export declare function getPageDisplayName(page: NamedPage, getAppMessage: MessageGetter): string;
|
|
7
7
|
export declare function findPageById(pages: PageDefinition[], normalizedPageId: string, appMessageIds: string[], getAppMessage: MessageGetter): PageDefinition | null;
|
|
8
|
+
/**
|
|
9
|
+
* Collect the pages a page sits under, by following `parent` upwards.
|
|
10
|
+
*
|
|
11
|
+
* @param pages The pages of the app.
|
|
12
|
+
* @param page The page to collect the ancestors of.
|
|
13
|
+
* @param appMemberRoles The roles held by the app member, used to resolve role-scoped parents.
|
|
14
|
+
* @returns The ancestors, root first, excluding the page itself.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getPageAncestors(pages: PageDefinition[], page: PageDefinition, appMemberRoles?: string[]): PageDefinition[];
|
|
8
17
|
export {};
|
package/pageUtils.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findPageByName } from './findPageByName.js';
|
|
1
2
|
import { normalize } from './normalize.js';
|
|
2
3
|
export function getPageMessageId(pageName) {
|
|
3
4
|
return `pages.${normalize(pageName)}`;
|
|
@@ -35,4 +36,44 @@ export function findPageById(pages, normalizedPageId, appMessageIds, getAppMessa
|
|
|
35
36
|
}
|
|
36
37
|
return null;
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve which parent page applies to a member.
|
|
41
|
+
*
|
|
42
|
+
* @param parent The `parent` of a page, in any of its accepted forms.
|
|
43
|
+
* @param appMemberRoles The roles held by the app member.
|
|
44
|
+
* @returns The name of the applicable parent page, if any.
|
|
45
|
+
*/
|
|
46
|
+
function resolveParentName(parent, appMemberRoles) {
|
|
47
|
+
for (const entry of [].concat(parent ?? [])) {
|
|
48
|
+
if (typeof entry === 'string') {
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
if (!entry.roles || entry.roles.some((role) => appMemberRoles.includes(role))) {
|
|
52
|
+
return entry.page;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Collect the pages a page sits under, by following `parent` upwards.
|
|
58
|
+
*
|
|
59
|
+
* @param pages The pages of the app.
|
|
60
|
+
* @param page The page to collect the ancestors of.
|
|
61
|
+
* @param appMemberRoles The roles held by the app member, used to resolve role-scoped parents.
|
|
62
|
+
* @returns The ancestors, root first, excluding the page itself.
|
|
63
|
+
*/
|
|
64
|
+
export function getPageAncestors(pages, page, appMemberRoles = []) {
|
|
65
|
+
const ancestors = [];
|
|
66
|
+
const visited = new Set([page.name]);
|
|
67
|
+
let current = page;
|
|
68
|
+
for (let parentName = resolveParentName(current.parent, appMemberRoles); parentName; parentName = resolveParentName(current.parent, appMemberRoles)) {
|
|
69
|
+
const parent = findPageByName(pages, parentName);
|
|
70
|
+
if (!parent || visited.has(parent.name)) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
visited.add(parent.name);
|
|
74
|
+
ancestors.unshift(parent);
|
|
75
|
+
current = parent;
|
|
76
|
+
}
|
|
77
|
+
return ancestors;
|
|
78
|
+
}
|
|
38
79
|
//# sourceMappingURL=pageUtils.js.map
|
|
@@ -91,6 +91,15 @@ rendered as side navigation.
|
|
|
91
91
|
type: 'string',
|
|
92
92
|
enum: ['appName', 'pageName'],
|
|
93
93
|
},
|
|
94
|
+
breadcrumbs: {
|
|
95
|
+
type: 'boolean',
|
|
96
|
+
default: false,
|
|
97
|
+
description: `Whether to display a breadcrumb trail below the title bar.
|
|
98
|
+
|
|
99
|
+
The trail is built from the \`parent\` of each page. A grid layout that names a \`breadcrumbs\`
|
|
100
|
+
template area renders the trail in that area instead.
|
|
101
|
+
`,
|
|
102
|
+
},
|
|
94
103
|
hideTitleBar: {
|
|
95
104
|
description: 'Whether to hide the title bar',
|
|
96
105
|
type: 'boolean',
|
|
@@ -53,6 +53,25 @@ navigation. Set to \`hidden\` to display no navigational menus at all.
|
|
|
53
53
|
This will be displayed in the navigation menu.
|
|
54
54
|
`,
|
|
55
55
|
},
|
|
56
|
+
parent: {
|
|
57
|
+
description: `The page this page sits under in the breadcrumb hierarchy.
|
|
58
|
+
|
|
59
|
+
A page name applies to every app member. An entry carrying \`roles\` applies only to members holding
|
|
60
|
+
one of those roles. A list is resolved in order, so the first matching entry wins and an entry
|
|
61
|
+
without \`roles\` acts as the fallback.
|
|
62
|
+
|
|
63
|
+
A page that declares \`parameters\` cannot be used as a parent, because its URL cannot be resolved
|
|
64
|
+
from another page.
|
|
65
|
+
`,
|
|
66
|
+
anyOf: [
|
|
67
|
+
{ $ref: '#/components/schemas/PageParentDefinition' },
|
|
68
|
+
{
|
|
69
|
+
type: 'array',
|
|
70
|
+
minItems: 1,
|
|
71
|
+
items: { $ref: '#/components/schemas/PageParentDefinition' },
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
56
75
|
parameters: {
|
|
57
76
|
type: 'array',
|
|
58
77
|
description: `Page parameters can be used for linking to a page that should display a single resource.
|
|
@@ -91,7 +91,10 @@ The exact meaning of the parameters depends on the block type.
|
|
|
91
91
|
events: { $ref: '#/components/schemas/EventsDefinition' },
|
|
92
92
|
gridArea: {
|
|
93
93
|
type: 'string',
|
|
94
|
-
description:
|
|
94
|
+
description: `Grid area defined in the page layout definition.
|
|
95
|
+
|
|
96
|
+
The \`breadcrumbs\` area is reserved for the breadcrumb trail of the app.
|
|
97
|
+
`,
|
|
95
98
|
},
|
|
96
99
|
},
|
|
97
100
|
};
|
|
@@ -4,6 +4,11 @@ export const PageLayoutDefinition = extendJSONSchema(ResponsiveGridLayoutDefinit
|
|
|
4
4
|
type: 'object',
|
|
5
5
|
additionalProperties: false,
|
|
6
6
|
minProperties: 1,
|
|
7
|
-
description:
|
|
7
|
+
description: `Responsive grid layout for a page.
|
|
8
|
+
|
|
9
|
+
The \`breadcrumbs\` template area is reserved for the breadcrumb trail of the app. It requires
|
|
10
|
+
\`layout.breadcrumbs\`, has to be part of the grid every breakpoint renders, and has to be defined by
|
|
11
|
+
every sub page of a page or by none of them.
|
|
12
|
+
`,
|
|
8
13
|
});
|
|
9
14
|
//# sourceMappingURL=PageLayoutDefinition.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const PageParentDefinition = {
|
|
2
|
+
description: `A candidate parent page, optionally scoped to a set of roles.
|
|
3
|
+
|
|
4
|
+
A plain string names the parent page. An object scopes that parent to the roles that may use it.
|
|
5
|
+
`,
|
|
6
|
+
anyOf: [
|
|
7
|
+
{
|
|
8
|
+
type: 'string',
|
|
9
|
+
maxLength: 50,
|
|
10
|
+
description: 'The name of the parent page.',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
type: 'object',
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
required: ['page'],
|
|
16
|
+
properties: {
|
|
17
|
+
page: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
maxLength: 50,
|
|
20
|
+
description: 'The name of the parent page.',
|
|
21
|
+
},
|
|
22
|
+
roles: {
|
|
23
|
+
type: 'array',
|
|
24
|
+
minItems: 1,
|
|
25
|
+
items: { type: 'string' },
|
|
26
|
+
description: `The roles this parent applies to.
|
|
27
|
+
|
|
28
|
+
When omitted, the parent applies to every app member.
|
|
29
|
+
`,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
//# sourceMappingURL=PageParentDefinition.js.map
|
package/schemas/index.d.ts
CHANGED
package/schemas/index.js
CHANGED
|
@@ -131,4 +131,5 @@ export * from './ResponsiveGridLayoutDefinition.js';
|
|
|
131
131
|
export * from './NavbarDeviceGridLayoutDefinition.js';
|
|
132
132
|
export * from './NavbarLayoutDefinition.js';
|
|
133
133
|
export * from './PageLayoutDefinition.js';
|
|
134
|
+
export * from './PageParentDefinition.js';
|
|
134
135
|
//# sourceMappingURL=index.js.map
|
package/types/core.d.ts
CHANGED
|
@@ -108,6 +108,10 @@ export interface AppDefinition {
|
|
|
108
108
|
*
|
|
109
109
|
*/
|
|
110
110
|
titleBarText?: 'appName' | 'pageName';
|
|
111
|
+
/**
|
|
112
|
+
* Whether to display a breadcrumb trail below the title bar.
|
|
113
|
+
*/
|
|
114
|
+
breadcrumbs?: boolean;
|
|
111
115
|
/**
|
|
112
116
|
* Whether to hide the title bar from all pages in the app.
|
|
113
117
|
*/
|
|
@@ -199,6 +203,13 @@ export interface AppDefinition {
|
|
|
199
203
|
*/
|
|
200
204
|
registry?: string;
|
|
201
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* A candidate parent page, optionally scoped to a set of roles.
|
|
208
|
+
*/
|
|
209
|
+
export type PageParentDefinition = string | {
|
|
210
|
+
page: string;
|
|
211
|
+
roles?: string[];
|
|
212
|
+
};
|
|
202
213
|
/**
|
|
203
214
|
* This describes what a page will look like in the app.
|
|
204
215
|
*/
|
|
@@ -233,6 +244,14 @@ export interface BasePageDefinition {
|
|
|
233
244
|
* top navigation is rendered as side navigation.
|
|
234
245
|
*/
|
|
235
246
|
navigation?: Navigation | 'profileDropdown';
|
|
247
|
+
/**
|
|
248
|
+
* The page this page sits under in the breadcrumb hierarchy.
|
|
249
|
+
*
|
|
250
|
+
* A page name applies to every member. An entry carrying `roles` applies only to members holding
|
|
251
|
+
* one of them. A list is resolved in order, so the first matching entry wins and an entry without
|
|
252
|
+
* `roles` acts as the fallback.
|
|
253
|
+
*/
|
|
254
|
+
parent?: PageParentDefinition | PageParentDefinition[];
|
|
236
255
|
/**
|
|
237
256
|
* A list of roles that may view the page.
|
|
238
257
|
*/
|
package/validation.js
CHANGED
|
@@ -6,6 +6,7 @@ import { BlockParamInstanceValidator } from './BasicValidator.js';
|
|
|
6
6
|
import { getAppBlocks, normalizeBlockName } from './blockUtils.js';
|
|
7
7
|
import { partialNormalized } from './constants/index.js';
|
|
8
8
|
import { findPageByName } from './findPageByName.js';
|
|
9
|
+
import { breadcrumbsGridArea, getCascadedGridTemplateAreas, getGridTemplateAreas, gridDeviceOrder, } from './gridUtils.js';
|
|
9
10
|
import { iterAction, iterApp } from './iterApp.js';
|
|
10
11
|
import { has } from './miscellaneous.js';
|
|
11
12
|
import { normalize } from './normalize.js';
|
|
@@ -109,6 +110,70 @@ function validateUniquePageNames(definition, report) {
|
|
|
109
110
|
}
|
|
110
111
|
checkPages(definition.pages);
|
|
111
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Iterate over the parent entries of every page, including the pages nested in a container.
|
|
115
|
+
*
|
|
116
|
+
* `iterApp` gives a nested page the path of the container it sits in, which is too coarse to point
|
|
117
|
+
* at the `parent` of one page, so the pages are walked here instead.
|
|
118
|
+
*
|
|
119
|
+
* @param pages The pages to walk.
|
|
120
|
+
* @param onParent Called with each parent entry, the path it lives at, and the page declaring it.
|
|
121
|
+
* @param prefix The path the pages live at.
|
|
122
|
+
*/
|
|
123
|
+
function iterPageParents(pages, onParent, prefix = ['pages']) {
|
|
124
|
+
for (const [index, page] of pages.entries()) {
|
|
125
|
+
const pagePath = [...prefix, index];
|
|
126
|
+
for (const [entryIndex, entry] of []
|
|
127
|
+
.concat(page.parent ?? [])
|
|
128
|
+
.entries()) {
|
|
129
|
+
onParent(entry, Array.isArray(page.parent) ? [...pagePath, 'parent', entryIndex] : [...pagePath, 'parent'], page);
|
|
130
|
+
}
|
|
131
|
+
if (page.type === 'container') {
|
|
132
|
+
iterPageParents(page.pages, onParent, [...pagePath, 'pages']);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function validatePageParents(definition, report) {
|
|
137
|
+
if (!definition.pages) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Every declared edge counts, whatever roles it is scoped to: roles overlap, so a cycle reachable
|
|
141
|
+
// under any role assignment is an authoring mistake.
|
|
142
|
+
function referencesItself(pageName, parentName) {
|
|
143
|
+
const visited = new Set();
|
|
144
|
+
const queue = [parentName];
|
|
145
|
+
while (queue.length) {
|
|
146
|
+
const name = queue.shift();
|
|
147
|
+
if (name === pageName) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (visited.has(name)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
visited.add(name);
|
|
154
|
+
const parent = findPageByName(definition.pages, name);
|
|
155
|
+
for (const entry of [].concat(parent?.parent ?? [])) {
|
|
156
|
+
queue.push(typeof entry === 'string' ? entry : entry.page);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
iterPageParents(definition.pages, (entry, path, page) => {
|
|
162
|
+
const parentName = typeof entry === 'string' ? entry : entry.page;
|
|
163
|
+
const parent = findPageByName(definition.pages, parentName);
|
|
164
|
+
if (!parent) {
|
|
165
|
+
report(parentName, 'refers to a page that doesn’t exist', path);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (referencesItself(page.name, parentName)) {
|
|
169
|
+
report(parentName, 'cyclically references itself', path);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (parent.parameters?.length) {
|
|
173
|
+
report(parentName, 'refers to a page with parameters, which cannot be used as a parent', path);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
112
177
|
function validateMembersSchema(definition, report) {
|
|
113
178
|
if (!definition.members?.properties) {
|
|
114
179
|
return;
|
|
@@ -368,24 +433,6 @@ function validateController(definition, controllerImplementations, report) {
|
|
|
368
433
|
},
|
|
369
434
|
});
|
|
370
435
|
}
|
|
371
|
-
function getTemplateAreas(layoutDefinition) {
|
|
372
|
-
const areas = new Set();
|
|
373
|
-
if (!layoutDefinition) {
|
|
374
|
-
return areas;
|
|
375
|
-
}
|
|
376
|
-
for (const deviceDefinition of Object.values(layoutDefinition)) {
|
|
377
|
-
if (deviceDefinition?.layout?.template) {
|
|
378
|
-
for (const row of deviceDefinition.layout.template) {
|
|
379
|
-
for (const area of row.split(' ')) {
|
|
380
|
-
if (area && area !== '.') {
|
|
381
|
-
areas.add(area);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return areas;
|
|
388
|
-
}
|
|
389
436
|
function validateTemplateAreasAreRectangular(template, report, path) {
|
|
390
437
|
const grid = template.map((row) => row.split(' '));
|
|
391
438
|
function isAreaRectangular(areaName) {
|
|
@@ -463,16 +510,56 @@ function validateResponsiveGridLayoutDefinition(layoutDefinition, report, path)
|
|
|
463
510
|
}
|
|
464
511
|
}
|
|
465
512
|
function validateBlockGridAreas(blocks, layoutDefinition, report, path) {
|
|
466
|
-
const templateAreas =
|
|
513
|
+
const templateAreas = getGridTemplateAreas(layoutDefinition);
|
|
467
514
|
for (const [idx, block] of blocks.entries()) {
|
|
468
|
-
if (block.gridArea
|
|
515
|
+
if (block.gridArea === breadcrumbsGridArea) {
|
|
516
|
+
report(block.gridArea, 'is reserved for the breadcrumb trail', [...path, idx, 'gridArea']);
|
|
517
|
+
}
|
|
518
|
+
else if (layoutDefinition && block.gridArea && !templateAreas.has(block.gridArea)) {
|
|
469
519
|
report(block.gridArea, `does not match any area defined in the layout template. Available areas: ${[...templateAreas].join(', ') || 'none'}`, [...path, idx, 'gridArea']);
|
|
470
520
|
}
|
|
471
521
|
}
|
|
472
522
|
}
|
|
473
|
-
function
|
|
523
|
+
function validateBreadcrumbsGridArea(layout, report, path, breadcrumbsEnabled) {
|
|
524
|
+
if (!layout || !getGridTemplateAreas(layout).has(breadcrumbsGridArea)) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!breadcrumbsEnabled) {
|
|
528
|
+
for (const [deviceName, deviceDefinition] of Object.entries(layout)) {
|
|
529
|
+
const template = deviceDefinition?.layout?.template;
|
|
530
|
+
if (template?.some((row) => row.split(' ').includes(breadcrumbsGridArea))) {
|
|
531
|
+
report(template, `the '${breadcrumbsGridArea}' grid area requires layout.breadcrumbs to be enabled`, [...path, deviceName, 'layout', 'template']);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// A device inherits the template of the next smaller one, so an area defined only for a larger
|
|
537
|
+
// device leaves the trail without a slot to sit in below that breakpoint.
|
|
538
|
+
const cascaded = getCascadedGridTemplateAreas(layout);
|
|
539
|
+
const missing = gridDeviceOrder.filter((device) => !cascaded[device].has(breadcrumbsGridArea));
|
|
540
|
+
if (missing.length) {
|
|
541
|
+
report(layout, `the '${breadcrumbsGridArea}' grid area is missing from the grid rendered on ${missing.join(', ')}`, path);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function validateConsistentBreadcrumbsGridArea(subPages, report) {
|
|
545
|
+
// The trail belongs to the page, so it may not appear on one sub page and be missing from the
|
|
546
|
+
// next. Defining the area consistently also lets the app runtime tell from the page definition
|
|
547
|
+
// alone whether a page renders the trail from its grid or below the title bar.
|
|
548
|
+
const declaresArea = ([subPage]) => getGridTemplateAreas(subPage.layout).has(breadcrumbsGridArea);
|
|
549
|
+
const declaring = subPages.filter(declaresArea);
|
|
550
|
+
if (!declaring.length || declaring.length === subPages.length) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
for (const entry of subPages) {
|
|
554
|
+
if (!declaresArea(entry)) {
|
|
555
|
+
report(entry[0], `the '${breadcrumbsGridArea}' grid area must be defined by every sub page of this page or by none`, entry[1]);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function validateSubPageLayout(layout, blocks, report, path, breadcrumbsEnabled) {
|
|
474
560
|
validateResponsiveGridLayoutDefinition(layout, report, path);
|
|
475
|
-
|
|
561
|
+
validateBreadcrumbsGridArea(layout, report, path, breadcrumbsEnabled);
|
|
562
|
+
if (blocks) {
|
|
476
563
|
validateBlockGridAreas(blocks, layout, report, [...path, 'blocks']);
|
|
477
564
|
}
|
|
478
565
|
}
|
|
@@ -532,51 +619,54 @@ function validateGridLayout(definition, report) {
|
|
|
532
619
|
}
|
|
533
620
|
}
|
|
534
621
|
}
|
|
622
|
+
const breadcrumbsEnabled = definition.layout?.breadcrumbs === true;
|
|
535
623
|
iterApp(definition, {
|
|
536
624
|
onPage(page, path) {
|
|
537
625
|
// Basic page
|
|
538
626
|
if (page.type === 'page' || page.type === undefined) {
|
|
539
627
|
const basicPage = page;
|
|
540
|
-
validateSubPageLayout(basicPage.layout, basicPage.blocks, report, path);
|
|
628
|
+
validateSubPageLayout(basicPage.layout, basicPage.blocks, report, path, breadcrumbsEnabled);
|
|
541
629
|
return;
|
|
542
630
|
}
|
|
543
631
|
// Tabs page
|
|
544
632
|
if (page.type === 'tabs') {
|
|
545
633
|
const tabsPage = page;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
validateSubPageLayout(
|
|
553
|
-
}
|
|
634
|
+
const tabs = tabsPage.tabs
|
|
635
|
+
? tabsPage.tabs.map((tab, tabId) => [tab, [...path, 'tabs', tabId]])
|
|
636
|
+
: tabsPage.definition?.foreach
|
|
637
|
+
? [[tabsPage.definition.foreach, [...path, 'definition', 'foreach']]]
|
|
638
|
+
: [];
|
|
639
|
+
for (const [tab, tabPath] of tabs) {
|
|
640
|
+
validateSubPageLayout(tab.layout, tab.blocks, report, tabPath, breadcrumbsEnabled);
|
|
641
|
+
}
|
|
642
|
+
validateConsistentBreadcrumbsGridArea(tabs, report);
|
|
554
643
|
return;
|
|
555
644
|
}
|
|
556
645
|
// Flow page
|
|
557
646
|
if (page.type === 'flow') {
|
|
558
647
|
const flowPage = page;
|
|
559
|
-
|
|
560
|
-
|
|
648
|
+
const steps = flowPage.steps.map((step, stepId) => [
|
|
649
|
+
step,
|
|
650
|
+
[...path, 'steps', stepId],
|
|
651
|
+
]);
|
|
652
|
+
for (const [step, stepPath] of steps) {
|
|
653
|
+
validateSubPageLayout(step.layout, step.blocks, report, stepPath, breadcrumbsEnabled);
|
|
561
654
|
}
|
|
655
|
+
validateConsistentBreadcrumbsGridArea(steps, report);
|
|
562
656
|
return;
|
|
563
657
|
}
|
|
564
658
|
// Loop page
|
|
565
659
|
if (page.type === 'loop') {
|
|
566
660
|
const loopPage = page;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
...path,
|
|
575
|
-
'foreach',
|
|
576
|
-
]);
|
|
577
|
-
if (loopPage.end) {
|
|
578
|
-
validateSubPageLayout(loopPage.end.layout, loopPage.end.blocks, report, [...path, 'end']);
|
|
661
|
+
const subPages = [
|
|
662
|
+
[loopPage.start, 'start'],
|
|
663
|
+
[loopPage.foreach, 'foreach'],
|
|
664
|
+
[loopPage.end, 'end'],
|
|
665
|
+
].flatMap(([subPage, key]) => subPage ? [[subPage, [...path, key]]] : []);
|
|
666
|
+
for (const [subPage, subPagePath] of subPages) {
|
|
667
|
+
validateSubPageLayout(subPage.layout, subPage.blocks, report, subPagePath, breadcrumbsEnabled);
|
|
579
668
|
}
|
|
669
|
+
validateConsistentBreadcrumbsGridArea(subPages, report);
|
|
580
670
|
}
|
|
581
671
|
},
|
|
582
672
|
});
|
|
@@ -1069,6 +1159,13 @@ function validateSecurity(definition, report) {
|
|
|
1069
1159
|
}
|
|
1070
1160
|
}
|
|
1071
1161
|
iterApp(definition, { onBlock: checkRoles, onPage: checkRoles });
|
|
1162
|
+
if (definition.pages) {
|
|
1163
|
+
iterPageParents(definition.pages, (entry, path) => {
|
|
1164
|
+
if (typeof entry !== 'string') {
|
|
1165
|
+
checkRoles(entry, path);
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1072
1169
|
const { hideGroupDropdown } = definition.layout ?? {};
|
|
1073
1170
|
if (Array.isArray(hideGroupDropdown)) {
|
|
1074
1171
|
for (const [index, role] of hideGroupDropdown.entries()) {
|
|
@@ -1573,6 +1670,7 @@ export async function validateAppDefinition(definition, getBlockVersions, contro
|
|
|
1573
1670
|
validateLanguage(clonedDefinition, report);
|
|
1574
1671
|
validateResourceReferences(clonedDefinition, report);
|
|
1575
1672
|
validateMembersSchema(clonedDefinition, report);
|
|
1673
|
+
validatePageParents(clonedDefinition, report);
|
|
1576
1674
|
validatePhoneNumberDefinition(clonedDefinition, report);
|
|
1577
1675
|
validateResourceSchemas(clonedDefinition, report);
|
|
1578
1676
|
validateSecurity(clonedDefinition, report);
|