@cratis/scene.react 4.16.0 → 4.18.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.
Files changed (86) hide show
  1. package/core/coreComponents.ts +2 -0
  2. package/core/coreDescriptors.ts +6 -0
  3. package/core/corePackage.ts +1 -0
  4. package/dist/cjs/core/coreComponents.js +3 -1
  5. package/dist/cjs/core/coreComponents.js.map +1 -1
  6. package/dist/cjs/core/coreDescriptors.js +16 -0
  7. package/dist/cjs/core/coreDescriptors.js.map +1 -1
  8. package/dist/cjs/core/corePackage.js +2 -1
  9. package/dist/cjs/core/corePackage.js.map +1 -1
  10. package/dist/cjs/index.js +11 -0
  11. package/dist/cjs/index.js.map +1 -1
  12. package/dist/cjs/navigation/CoreOutlet.js +32 -0
  13. package/dist/cjs/navigation/CoreOutlet.js.map +1 -0
  14. package/dist/cjs/navigation/SceneNavigationContext.js +2 -0
  15. package/dist/cjs/navigation/SceneNavigationContext.js.map +1 -1
  16. package/dist/cjs/navigation/SceneNavigationHost.js +46 -29
  17. package/dist/cjs/navigation/SceneNavigationHost.js.map +1 -1
  18. package/dist/cjs/navigation/SceneOutletContext.js +14 -0
  19. package/dist/cjs/navigation/SceneOutletContext.js.map +1 -0
  20. package/dist/cjs/navigation/collectOutletOwners.js +36 -0
  21. package/dist/cjs/navigation/collectOutletOwners.js.map +1 -0
  22. package/dist/cjs/navigation/placeInOutlet.js +65 -0
  23. package/dist/cjs/navigation/placeInOutlet.js.map +1 -0
  24. package/dist/cjs/navigation/useSceneHistoryNavigation.js +22 -6
  25. package/dist/cjs/navigation/useSceneHistoryNavigation.js.map +1 -1
  26. package/dist/cjs/packages/ScenePackageBundle.js +23 -13
  27. package/dist/cjs/packages/ScenePackageBundle.js.map +1 -1
  28. package/dist/esm/core/coreComponents.d.ts.map +1 -1
  29. package/dist/esm/core/coreComponents.js +3 -1
  30. package/dist/esm/core/coreComponents.js.map +1 -1
  31. package/dist/esm/core/coreDescriptors.d.ts.map +1 -1
  32. package/dist/esm/core/coreDescriptors.js +16 -0
  33. package/dist/esm/core/coreDescriptors.js.map +1 -1
  34. package/dist/esm/core/corePackage.d.ts.map +1 -1
  35. package/dist/esm/core/corePackage.js +2 -1
  36. package/dist/esm/core/corePackage.js.map +1 -1
  37. package/dist/esm/index.js +5 -1
  38. package/dist/esm/index.js.map +1 -1
  39. package/dist/esm/navigation/CoreOutlet.d.ts +3 -0
  40. package/dist/esm/navigation/CoreOutlet.d.ts.map +1 -0
  41. package/dist/esm/navigation/CoreOutlet.js +30 -0
  42. package/dist/esm/navigation/CoreOutlet.js.map +1 -0
  43. package/dist/esm/navigation/SceneNavigationContext.d.ts +3 -0
  44. package/dist/esm/navigation/SceneNavigationContext.d.ts.map +1 -1
  45. package/dist/esm/navigation/SceneNavigationContext.js +3 -2
  46. package/dist/esm/navigation/SceneNavigationContext.js.map +1 -1
  47. package/dist/esm/navigation/SceneNavigationHost.d.ts.map +1 -1
  48. package/dist/esm/navigation/SceneNavigationHost.js +47 -30
  49. package/dist/esm/navigation/SceneNavigationHost.js.map +1 -1
  50. package/dist/esm/navigation/SceneNavigationState.d.ts +2 -0
  51. package/dist/esm/navigation/SceneNavigationState.d.ts.map +1 -1
  52. package/dist/esm/navigation/SceneOutletContext.d.ts +9 -0
  53. package/dist/esm/navigation/SceneOutletContext.d.ts.map +1 -0
  54. package/dist/esm/navigation/SceneOutletContext.js +11 -0
  55. package/dist/esm/navigation/SceneOutletContext.js.map +1 -0
  56. package/dist/esm/navigation/collectOutletOwners.d.ts +4 -0
  57. package/dist/esm/navigation/collectOutletOwners.d.ts.map +1 -0
  58. package/dist/esm/navigation/collectOutletOwners.js +33 -0
  59. package/dist/esm/navigation/collectOutletOwners.js.map +1 -0
  60. package/dist/esm/navigation/index.d.ts +4 -0
  61. package/dist/esm/navigation/index.d.ts.map +1 -1
  62. package/dist/esm/navigation/index.js +4 -0
  63. package/dist/esm/navigation/index.js.map +1 -1
  64. package/dist/esm/navigation/placeInOutlet.d.ts +4 -0
  65. package/dist/esm/navigation/placeInOutlet.d.ts.map +1 -0
  66. package/dist/esm/navigation/placeInOutlet.js +63 -0
  67. package/dist/esm/navigation/placeInOutlet.js.map +1 -0
  68. package/dist/esm/navigation/useSceneHistoryNavigation.d.ts +2 -1
  69. package/dist/esm/navigation/useSceneHistoryNavigation.d.ts.map +1 -1
  70. package/dist/esm/navigation/useSceneHistoryNavigation.js +22 -6
  71. package/dist/esm/navigation/useSceneHistoryNavigation.js.map +1 -1
  72. package/dist/esm/packages/ScenePackageBundle.d.ts.map +1 -1
  73. package/dist/esm/packages/ScenePackageBundle.js +23 -13
  74. package/dist/esm/packages/ScenePackageBundle.js.map +1 -1
  75. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  76. package/navigation/CoreOutlet.tsx +26 -0
  77. package/navigation/SceneNavigationContext.tsx +10 -0
  78. package/navigation/SceneNavigationHost.tsx +12 -6
  79. package/navigation/SceneNavigationState.ts +13 -0
  80. package/navigation/SceneOutletContext.tsx +19 -0
  81. package/navigation/collectOutletOwners.ts +30 -0
  82. package/navigation/index.ts +4 -0
  83. package/navigation/placeInOutlet.ts +68 -0
  84. package/navigation/useSceneHistoryNavigation.ts +20 -5
  85. package/package.json +5 -5
  86. package/packages/ScenePackageBundle.ts +30 -17
@@ -0,0 +1,26 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { useContext } from 'react';
5
+ import { RegisteredComponentProps } from '../renderer/ComponentRegistry';
6
+ import { SceneElementView } from '../SceneElementView';
7
+ import { SceneNavigationContextInternal } from './SceneNavigationContext';
8
+ import { useSceneOutlets } from './SceneOutletContext';
9
+
10
+ /**
11
+ * A nested outlet a screen declares: `core:outlet` with a `name`. Inside a navigation host it renders the
12
+ * screen placed in that outlet - which may declare outlets of its own, so composition nests to any depth -
13
+ * and nothing while the outlet is empty. Outside a navigation host it renders the empty region.
14
+ */
15
+ export function CoreOutlet({ element }: RegisteredComponentProps) {
16
+ const name = typeof element.properties.name === 'string' ? element.properties.name : '';
17
+ const navigation = useContext(SceneNavigationContextInternal);
18
+ const outlets = useSceneOutlets();
19
+ const placed = navigation?.outlets[name];
20
+ const screen = placed && outlets ? outlets.screens[placed] : undefined;
21
+
22
+ return <div data-scene-outlet={name} data-scene-outlet-screen={placed}>
23
+ {screen && outlets && <SceneElementView element={screen} registry={outlets.registry}
24
+ dataContext={navigation?.bindingScope.dataContext} queryResults={navigation?.bindingScope.queryResults} />}
25
+ </div>;
26
+ }
@@ -8,7 +8,14 @@ import { DestinationResolution } from './DestinationResolution';
8
8
  import { DialogResult } from './DialogResult';
9
9
 
10
10
  export interface SceneNavigationContextValue {
11
+ /** The screen the last navigation opened. */
11
12
  currentScreen: string;
13
+
14
+ /** The screen in the primary region. */
15
+ primaryScreen: string;
16
+
17
+ /** Which screen each nested outlet shows, by outlet name. */
18
+ outlets: Record<string, string>;
12
19
  currentUrl?: string;
13
20
  currentOutlet?: string;
14
21
  currentDialog?: string;
@@ -33,6 +40,9 @@ export interface SceneNavigationContextValue {
33
40
 
34
41
  const Context = createContext<SceneNavigationContextValue | undefined>(undefined);
35
42
 
43
+ /** The navigation context itself, for components that render outside a host as well as inside one. */
44
+ export const SceneNavigationContextInternal = Context;
45
+
36
46
  export interface SceneNavigationProviderProps {
37
47
  value: SceneNavigationContextValue;
38
48
  children: ReactNode;
@@ -11,6 +11,8 @@ import { SceneHistory } from './SceneHistory';
11
11
  import { SceneNavigationProvider } from './SceneNavigationContext';
12
12
  import { SceneRoute } from './SceneRoute';
13
13
  import { sceneRoutesFrom } from './sceneRoutesFrom';
14
+ import { collectOutletOwners } from './collectOutletOwners';
15
+ import { SceneOutletContext } from './SceneOutletContext';
14
16
  import { useSceneHistoryNavigation } from './useSceneHistoryNavigation';
15
17
 
16
18
  export interface SceneNavigationHostProps {
@@ -34,24 +36,28 @@ export interface SceneNavigationHostProps {
34
36
  }
35
37
 
36
38
  /**
37
- * Executes Scene destinations against an in-memory rendered hierarchy: screen routes replace outlets,
39
+ * Executes Scene destinations against an in-memory rendered hierarchy: a destination into a nested outlet -
40
+ * one a screen declares with `core:outlet` - places its screen there and keeps the screens above it, to any
41
+ * depth; any other screen route replaces the primary region,
38
42
  * dialog destinations render dialog content over the screen that opened them, and external destinations
39
43
  * only report their resolved action. Every navigation is a history entry.
40
44
  */
41
45
  export function SceneNavigationHost({ screens, dialogs = {}, initialScreen, registry, bindingScope = {}, history, destinations, routes }: SceneNavigationHostProps) {
42
46
  const [memoryHistory] = useState(() => createMemorySceneHistory());
43
47
  const routeTable = useMemo(() => routes ?? sceneRoutesFrom(Object.keys(screens), destinations), [destinations, routes, screens]);
44
- const value = useSceneHistoryNavigation(history ?? memoryHistory, routeTable, initialScreen, bindingScope);
48
+ const outletOwners = useMemo(() => collectOutletOwners(screens), [screens]);
49
+ const value = useSceneHistoryNavigation(history ?? memoryHistory, routeTable, initialScreen, bindingScope, outletOwners, destinations);
50
+ const outletContext = useMemo(() => ({ screens, registry }), [registry, screens]);
45
51
 
46
- const screen = screens[value.currentScreen];
52
+ const screen = screens[value.primaryScreen];
47
53
  const dialog = value.currentDialog ? dialogs[value.currentDialog] : undefined;
48
54
 
49
- return <SceneNavigationProvider value={value}>
50
- <div data-scene-outlet={value.currentOutlet ?? 'primary'} data-scene-url={value.currentUrl}>
55
+ return <SceneNavigationProvider value={value}><SceneOutletContext.Provider value={outletContext}>
56
+ <div data-scene-outlet={value.primaryScreen === value.currentScreen ? value.currentOutlet ?? 'primary' : 'primary'} data-scene-url={value.currentUrl}>
51
57
  {screen && <SceneElementView element={screen} registry={registry} dataContext={bindingScope.dataContext} queryResults={bindingScope.queryResults} componentOutputs={bindingScope.componentOutputs} />}
52
58
  </div>
53
59
  {dialog && <div role='dialog' aria-modal='true' aria-label={value.currentDialog} data-scene-dialog={value.currentDialog}>
54
60
  <SceneElementView element={dialog} registry={registry} dataContext={bindingScope.dataContext} queryResults={bindingScope.queryResults} componentOutputs={bindingScope.componentOutputs} />
55
61
  </div>}
56
- </SceneNavigationProvider>;
62
+ </SceneOutletContext.Provider></SceneNavigationProvider>;
57
63
  }
@@ -7,8 +7,21 @@
7
7
  * and refresh restore exactly this.
8
8
  */
9
9
  export interface SceneNavigationState {
10
+ /** The screen the last navigation opened - in the primary region or in a nested outlet. */
10
11
  screen: string;
11
12
 
13
+ /**
14
+ * The screen in the primary region. Absent means `screen`: a navigation into an outlet no rendered screen
15
+ * declares replaces the primary region, as before nested outlets existed.
16
+ */
17
+ primary?: string;
18
+
19
+ /**
20
+ * Which screen each nested outlet shows, by outlet name. An outlet is nested when a screen declares it with
21
+ * a `core:outlet` element; placing a screen there keeps every screen above it on the page.
22
+ */
23
+ outlets?: Record<string, string>;
24
+
12
25
  /** The URL relative to the host's base path, as `resolveDestination` builds it. */
13
26
  url?: string;
14
27
  outlet?: string;
@@ -0,0 +1,19 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { createContext, useContext } from 'react';
5
+ import { SceneElement } from '@cratis/scene.model';
6
+ import { ComponentRegistry } from '../renderer';
7
+
8
+ /** What a nested outlet needs to render the screen placed in it. */
9
+ export interface SceneOutletContextValue {
10
+ screens: Record<string, SceneElement>;
11
+ registry: ComponentRegistry;
12
+ }
13
+
14
+ export const SceneOutletContext = createContext<SceneOutletContextValue | undefined>(undefined);
15
+
16
+ /** The screens and registry of the enclosing navigation host, or undefined outside one. */
17
+ export function useSceneOutlets(): SceneOutletContextValue | undefined {
18
+ return useContext(SceneOutletContext);
19
+ }
@@ -0,0 +1,30 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { SceneElement } from '@cratis/scene.model';
5
+
6
+ /** The registry key of the component that declares a nested outlet. */
7
+ export const outletComponentName = 'core:outlet';
8
+
9
+ /**
10
+ * Which screen declares each nested outlet: every `core:outlet` element, by its `name`, mapped to the screen
11
+ * whose tree contains it. A screen that declares an outlet another screen also declares keeps the first one,
12
+ * in screen order - the same rule navigation diagnostics report as a duplicate outlet.
13
+ */
14
+ export function collectOutletOwners(screens: Record<string, SceneElement>): Record<string, string> {
15
+ const owners: Record<string, string> = {};
16
+ for (const [screen, element] of Object.entries(screens)) {
17
+ for (const outlet of outletNames(element)) {
18
+ if (!(outlet in owners)) owners[outlet] = screen;
19
+ }
20
+ }
21
+
22
+ return owners;
23
+ }
24
+
25
+ function outletNames(element: SceneElement): string[] {
26
+ const node = element as { componentName?: string; properties?: Record<string, unknown>; slots?: Record<string, SceneElement[]>; children?: SceneElement[] };
27
+ const own = node.componentName === outletComponentName && typeof node.properties?.name === 'string' ? [node.properties.name] : [];
28
+ const children = [...Object.values(node.slots ?? {}).flat(), ...(Array.isArray(node.children) ? node.children : [])];
29
+ return [...own, ...children.flatMap(outletNames)];
30
+ }
@@ -18,3 +18,7 @@ export * from './navigationRouteKey';
18
18
  export * from './resolveDestination';
19
19
  export * from './sceneRoutesFrom';
20
20
  export * from './useSceneHistoryNavigation';
21
+ export * from './CoreOutlet';
22
+ export * from './SceneOutletContext';
23
+ export * from './collectOutletOwners';
24
+ export * from './placeInOutlet';
@@ -0,0 +1,68 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { DestinationReference } from '@cratis/scene.model';
5
+ import { SceneNavigationState } from './SceneNavigationState';
6
+
7
+ /** The most levels of nesting a placement follows before it stops, so a cyclic model cannot loop forever. */
8
+ const maximumDepth = 32;
9
+
10
+ /**
11
+ * Places a screen, recursively composing nested outlets.
12
+ *
13
+ * When `outlet` is a nested outlet - one a screen declares with `core:outlet` - the screen goes into it, and
14
+ * the screen that declares it is placed first: where it already is, or, when it is not on the page, where a
15
+ * known destination puts it, up to the primary region. Outlets declared by screens that are no longer on the
16
+ * page are dropped. Any other outlet replaces the primary region and clears every nested placement.
17
+ */
18
+ export function placeInOutlet(
19
+ current: SceneNavigationState,
20
+ screen: string,
21
+ outlet: string | undefined,
22
+ owners: Record<string, string>,
23
+ destinations: DestinationReference[] = [],
24
+ ): Pick<SceneNavigationState, 'primary' | 'outlets'> {
25
+ const primary = current.primary ?? current.screen;
26
+ const placements: Record<string, string> = { ...(current.outlets ?? {}) };
27
+ let root = primary;
28
+
29
+ let target: { screen: string; outlet: string | undefined } | undefined = { screen, outlet };
30
+ for (let depth = 0; target && depth < maximumDepth; depth++) {
31
+ const owner = target.outlet === undefined ? undefined : owners[target.outlet];
32
+ if (owner === undefined) {
33
+ root = target.screen;
34
+ break;
35
+ }
36
+
37
+ placements[target.outlet!] = target.screen;
38
+ if (isOnPage(owner, root, placements, owners)) break;
39
+ target = { screen: owner, outlet: destinations.find(destination => destination.screen === owner && destination.outlet)?.outlet };
40
+ }
41
+
42
+ return { primary: root, outlets: reachable(root, placements, owners) };
43
+ }
44
+
45
+ /** Whether a screen is the root or shown in an outlet reachable from it. */
46
+ function isOnPage(screen: string, root: string, placements: Record<string, string>, owners: Record<string, string>): boolean {
47
+ return screen === root || Object.values(reachable(root, placements, owners)).includes(screen);
48
+ }
49
+
50
+ /** Only the placements reachable from the root through the outlets each placed screen declares. */
51
+ function reachable(root: string, placements: Record<string, string>, owners: Record<string, string>): Record<string, string> {
52
+ const kept: Record<string, string> = {};
53
+ const pending = [root];
54
+ const seen = new Set<string>();
55
+ while (pending.length > 0) {
56
+ const screen = pending.shift()!;
57
+ if (seen.has(screen)) continue;
58
+ seen.add(screen);
59
+ for (const [outlet, placed] of Object.entries(placements)) {
60
+ if (owners[outlet] === screen) {
61
+ kept[outlet] = placed;
62
+ pending.push(placed);
63
+ }
64
+ }
65
+ }
66
+
67
+ return kept;
68
+ }
@@ -6,6 +6,7 @@ import { BindingScope } from '@cratis/scene.engine';
6
6
  import { DestinationKind, DestinationReference } from '@cratis/scene.model';
7
7
  import { DialogResult } from './DialogResult';
8
8
  import { matchSceneRoute } from './matchSceneRoute';
9
+ import { placeInOutlet } from './placeInOutlet';
9
10
  import { resolveDestination } from './resolveDestination';
10
11
  import { SceneHistory } from './SceneHistory';
11
12
  import { SceneNavigationContextValue } from './SceneNavigationContext';
@@ -17,11 +18,21 @@ import { SceneRoute } from './SceneRoute';
17
18
  * so back and forward step through them, a refresh restores the stored entry, and a deep link is matched
18
19
  * against the route table. Closing a dialog goes back to the entry that opened it.
19
20
  */
20
- export function useSceneHistoryNavigation(history: SceneHistory, routes: SceneRoute[], initialScreen: string, bindingScope: BindingScope): SceneNavigationContextValue {
21
+ export function useSceneHistoryNavigation(
22
+ history: SceneHistory,
23
+ routes: SceneRoute[],
24
+ initialScreen: string,
25
+ bindingScope: BindingScope,
26
+ outletOwners: Record<string, string> = {},
27
+ destinations: DestinationReference[] = [],
28
+ ): SceneNavigationContextValue {
21
29
  const fromLocation = useCallback((url: string | undefined): SceneNavigationState | undefined => {
22
30
  const match = url ? matchSceneRoute(url, routes) : undefined;
23
- return match ? { screen: match.route.screen, url, outlet: match.route.outlet, parameters: match.parameters } : undefined;
24
- }, [routes]);
31
+ if (!match) return undefined;
32
+ const start: SceneNavigationState = { screen: initialScreen, parameters: {} };
33
+ const placement = placeInOutlet(start, match.route.screen, match.route.outlet, outletOwners, destinations);
34
+ return { screen: match.route.screen, url, outlet: match.route.outlet, parameters: match.parameters, ...placement };
35
+ }, [destinations, initialScreen, outletOwners, routes]);
25
36
  const fallback = useMemo<SceneNavigationState>(() => ({ screen: initialScreen, url: initialScreen, parameters: {} }), [initialScreen]);
26
37
 
27
38
  const [unresolvedUrl] = useState(() => {
@@ -52,11 +63,13 @@ export function useSceneHistoryNavigation(history: SceneHistory, routes: SceneRo
52
63
  if (resolution.kind === DestinationKind.Dialog) {
53
64
  if (resolution.dialog) go({ ...state, dialog: resolution.dialog });
54
65
  } else if (resolution.kind !== DestinationKind.External) {
55
- go({ screen: destination.screen ?? destination.slice ?? state.screen, url: resolution.url, outlet: resolution.outlet, parameters: resolution.parameters });
66
+ const screen = destination.screen ?? destination.slice ?? state.screen;
67
+ const placement = placeInOutlet(state, screen, resolution.outlet, outletOwners, destinations);
68
+ go({ screen, url: resolution.url, outlet: resolution.outlet, parameters: resolution.parameters, ...placement });
56
69
  }
57
70
 
58
71
  return resolution;
59
- }, [bindingScope, go, state]);
72
+ }, [bindingScope, destinations, go, outletOwners, state]);
60
73
 
61
74
  const closeDialog = useCallback((result?: unknown) => {
62
75
  if (!state.dialog) return;
@@ -66,6 +79,8 @@ export function useSceneHistoryNavigation(history: SceneHistory, routes: SceneRo
66
79
 
67
80
  return useMemo(() => ({
68
81
  currentScreen: state.screen,
82
+ primaryScreen: state.primary ?? state.screen,
83
+ outlets: state.outlets ?? {},
69
84
  currentUrl: state.url,
70
85
  currentOutlet: state.outlet,
71
86
  currentDialog: state.dialog,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/scene.react",
3
- "version": "4.16.0",
3
+ "version": "4.18.0",
4
4
  "description": "The React renderer implementing Scene.Engine's Renderer contract against real React/DOM.",
5
5
  "keywords": [
6
6
  "cratis",
@@ -51,12 +51,12 @@
51
51
  "build-storybook": "storybook build"
52
52
  },
53
53
  "dependencies": {
54
- "@cratis/scene.engine": "4.16.0",
55
- "@cratis/scene.model": "4.16.0"
54
+ "@cratis/scene.engine": "4.18.0",
55
+ "@cratis/scene.model": "4.18.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@cratis/scene.engine": "4.16.0",
59
- "@cratis/scene.model": "4.16.0",
58
+ "@cratis/scene.engine": "4.18.0",
59
+ "@cratis/scene.model": "4.18.0",
60
60
  "@storybook/addon-links": "^10.6.1",
61
61
  "@storybook/react": "^10.6.1",
62
62
  "@storybook/react-vite": "^10.6.1",
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
- import { ComponentDescriptor, DialogTemplate, Layout, PackageKind, PropertyValueType, ScenePackage, Screen, ScreenTemplate, Theme } from '@cratis/scene.model';
4
+ import { ComponentDescriptor, DialogTemplate, Layout, PackageKind, PropertyDescriptor, PropertyValueType, ScenePackage, Screen, ScreenTemplate, Theme } from '@cratis/scene.model';
5
5
  import { IconAdapterRegistry, createIconAdapterRegistry } from '../icons/IconAdapterRegistry';
6
6
  import { IconLibraryBundle } from '../icons/IconLibraryBundle';
7
7
  import { ComponentRegistry } from '../renderer';
@@ -203,6 +203,34 @@ function validateIconLibrary(bundle: ScenePackageBundle): string[] {
203
203
  return problems;
204
204
  }
205
205
 
206
+ function validateProperties(component: string, properties: PropertyDescriptor[], parent: string, problems: string[]): void {
207
+ const paths = new Set<string>();
208
+ for (const property of properties) {
209
+ const path = `${parent}${property.path}`;
210
+ if (paths.has(property.path)) {
211
+ problems.push(`'${component}' describes the property '${path}' more than once`);
212
+ }
213
+
214
+ paths.add(property.path);
215
+
216
+ if (property.valueType === PropertyValueType.Collection && property.item === undefined) {
217
+ problems.push(`'${component}' describes the collection '${path}' without describing its items`);
218
+ }
219
+
220
+ if (property.valueType === PropertyValueType.Collection && property.item !== undefined) {
221
+ validateProperties(component, property.item.properties, `${path}[].`, problems);
222
+ }
223
+
224
+ if (property.valueType === PropertyValueType.Object && property.properties !== undefined) {
225
+ validateProperties(component, property.properties, `${path}.`, problems);
226
+ }
227
+
228
+ if (property.valueType === PropertyValueType.Enum && (property.choices ?? []).length === 0) {
229
+ problems.push(`'${component}' describes the enum '${path}' without choices`);
230
+ }
231
+ }
232
+ }
233
+
206
234
  function validateDescriptors(bundle: ScenePackageBundle): string[] {
207
235
  const problems: string[] = [];
208
236
  const declared = new Set(bundle.manifest.components.map((name) => componentRegistryKey(bundle.manifest.name, name)));
@@ -219,22 +247,7 @@ function validateDescriptors(bundle: ScenePackageBundle): string[] {
219
247
 
220
248
  described.add(descriptor.component);
221
249
 
222
- const paths = new Set<string>();
223
- for (const property of descriptor.properties) {
224
- if (paths.has(property.path)) {
225
- problems.push(`'${descriptor.component}' describes the property '${property.path}' more than once`);
226
- }
227
-
228
- paths.add(property.path);
229
-
230
- if (property.valueType === PropertyValueType.Collection && property.item === undefined) {
231
- problems.push(`'${descriptor.component}' describes the collection '${property.path}' without describing its items`);
232
- }
233
-
234
- if (property.valueType === PropertyValueType.Enum && (property.choices ?? []).length === 0) {
235
- problems.push(`'${descriptor.component}' describes the enum '${property.path}' without choices`);
236
- }
237
- }
250
+ validateProperties(descriptor.component, descriptor.properties, '', problems);
238
251
  }
239
252
 
240
253
  return problems;