@cratis/scene.react 4.29.0 → 4.31.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 (133) hide show
  1. package/core/CoreToolbar.tsx +37 -6
  2. package/core/coreComponentMetadata.ts +1 -0
  3. package/core/coreComponents.ts +2 -0
  4. package/core/coreDescriptors.ts +13 -0
  5. package/core/corePackage.ts +1 -0
  6. package/dist/cjs/core/CoreToolbar.js +44 -4
  7. package/dist/cjs/core/CoreToolbar.js.map +1 -1
  8. package/dist/cjs/core/coreComponentMetadata.js +6 -0
  9. package/dist/cjs/core/coreComponentMetadata.js.map +1 -1
  10. package/dist/cjs/core/coreComponents.js +3 -1
  11. package/dist/cjs/core/coreComponents.js.map +1 -1
  12. package/dist/cjs/core/coreDescriptors.js +27 -0
  13. package/dist/cjs/core/coreDescriptors.js.map +1 -1
  14. package/dist/cjs/core/corePackage.js +2 -1
  15. package/dist/cjs/core/corePackage.js.map +1 -1
  16. package/dist/cjs/index.js +20 -0
  17. package/dist/cjs/index.js.map +1 -1
  18. package/dist/cjs/navigation/SceneNavigationContext.js.map +1 -1
  19. package/dist/cjs/navigation/SceneNavigationHost.js +10 -5
  20. package/dist/cjs/navigation/SceneNavigationHost.js.map +1 -1
  21. package/dist/cjs/navigation/useSceneHistoryNavigation.js +3 -1
  22. package/dist/cjs/navigation/useSceneHistoryNavigation.js.map +1 -1
  23. package/dist/cjs/slices/CoreSliceDialog.js +21 -0
  24. package/dist/cjs/slices/CoreSliceDialog.js.map +1 -0
  25. package/dist/cjs/slices/SliceCommandFieldsContext.js +22 -0
  26. package/dist/cjs/slices/SliceCommandFieldsContext.js.map +1 -0
  27. package/dist/cjs/slices/SliceDialogView.js +262 -0
  28. package/dist/cjs/slices/SliceDialogView.js.map +1 -0
  29. package/dist/cjs/slices/SliceInvokerContext.js +40 -0
  30. package/dist/cjs/slices/SliceInvokerContext.js.map +1 -0
  31. package/dist/cjs/slices/createAnnouncingInvoker.js +27 -0
  32. package/dist/cjs/slices/createAnnouncingInvoker.js.map +1 -0
  33. package/dist/cjs/slices/createDryRunInvoker.js +38 -0
  34. package/dist/cjs/slices/createDryRunInvoker.js.map +1 -0
  35. package/dist/cjs/slices/sliceDialogDestination.js +41 -0
  36. package/dist/cjs/slices/sliceDialogDestination.js.map +1 -0
  37. package/dist/esm/core/CoreToolbar.d.ts.map +1 -1
  38. package/dist/esm/core/CoreToolbar.js +47 -7
  39. package/dist/esm/core/CoreToolbar.js.map +1 -1
  40. package/dist/esm/core/coreComponentMetadata.d.ts.map +1 -1
  41. package/dist/esm/core/coreComponentMetadata.js +6 -0
  42. package/dist/esm/core/coreComponentMetadata.js.map +1 -1
  43. package/dist/esm/core/coreComponents.d.ts.map +1 -1
  44. package/dist/esm/core/coreComponents.js +3 -1
  45. package/dist/esm/core/coreComponents.js.map +1 -1
  46. package/dist/esm/core/coreDescriptors.d.ts.map +1 -1
  47. package/dist/esm/core/coreDescriptors.js +27 -0
  48. package/dist/esm/core/coreDescriptors.js.map +1 -1
  49. package/dist/esm/core/corePackage.d.ts.map +1 -1
  50. package/dist/esm/core/corePackage.js +2 -1
  51. package/dist/esm/core/corePackage.js.map +1 -1
  52. package/dist/esm/index.d.ts +1 -0
  53. package/dist/esm/index.d.ts.map +1 -1
  54. package/dist/esm/index.js +7 -0
  55. package/dist/esm/index.js.map +1 -1
  56. package/dist/esm/navigation/SceneNavigationContext.d.ts +1 -0
  57. package/dist/esm/navigation/SceneNavigationContext.d.ts.map +1 -1
  58. package/dist/esm/navigation/SceneNavigationContext.js.map +1 -1
  59. package/dist/esm/navigation/SceneNavigationHost.d.ts.map +1 -1
  60. package/dist/esm/navigation/SceneNavigationHost.js +10 -5
  61. package/dist/esm/navigation/SceneNavigationHost.js.map +1 -1
  62. package/dist/esm/navigation/SceneNavigationState.d.ts +1 -0
  63. package/dist/esm/navigation/SceneNavigationState.d.ts.map +1 -1
  64. package/dist/esm/navigation/useSceneHistoryNavigation.d.ts.map +1 -1
  65. package/dist/esm/navigation/useSceneHistoryNavigation.js +3 -1
  66. package/dist/esm/navigation/useSceneHistoryNavigation.js.map +1 -1
  67. package/dist/esm/slices/CoreSliceDialog.d.ts +3 -0
  68. package/dist/esm/slices/CoreSliceDialog.d.ts.map +1 -0
  69. package/dist/esm/slices/CoreSliceDialog.js +19 -0
  70. package/dist/esm/slices/CoreSliceDialog.js.map +1 -0
  71. package/dist/esm/slices/InvocationResult.d.ts +10 -0
  72. package/dist/esm/slices/InvocationResult.d.ts.map +1 -0
  73. package/dist/esm/slices/InvocationResult.js +2 -0
  74. package/dist/esm/slices/InvocationResult.js.map +1 -0
  75. package/dist/esm/slices/InvocationValidationError.d.ts +5 -0
  76. package/dist/esm/slices/InvocationValidationError.d.ts.map +1 -0
  77. package/dist/esm/slices/InvocationValidationError.js +2 -0
  78. package/dist/esm/slices/InvocationValidationError.js.map +1 -0
  79. package/dist/esm/slices/SliceCommandField.d.ts +8 -0
  80. package/dist/esm/slices/SliceCommandField.d.ts.map +1 -0
  81. package/dist/esm/slices/SliceCommandField.js +2 -0
  82. package/dist/esm/slices/SliceCommandField.js.map +1 -0
  83. package/dist/esm/slices/SliceCommandFieldsContext.d.ts +10 -0
  84. package/dist/esm/slices/SliceCommandFieldsContext.d.ts.map +1 -0
  85. package/dist/esm/slices/SliceCommandFieldsContext.js +19 -0
  86. package/dist/esm/slices/SliceCommandFieldsContext.js.map +1 -0
  87. package/dist/esm/slices/SliceDialogView.d.ts +12 -0
  88. package/dist/esm/slices/SliceDialogView.d.ts.map +1 -0
  89. package/dist/esm/slices/SliceDialogView.js +258 -0
  90. package/dist/esm/slices/SliceDialogView.js.map +1 -0
  91. package/dist/esm/slices/SliceInvoker.d.ts +5 -0
  92. package/dist/esm/slices/SliceInvoker.d.ts.map +1 -0
  93. package/dist/esm/slices/SliceInvoker.js +2 -0
  94. package/dist/esm/slices/SliceInvoker.js.map +1 -0
  95. package/dist/esm/slices/SliceInvokerContext.d.ts +11 -0
  96. package/dist/esm/slices/SliceInvokerContext.d.ts.map +1 -0
  97. package/dist/esm/slices/SliceInvokerContext.js +36 -0
  98. package/dist/esm/slices/SliceInvokerContext.js.map +1 -0
  99. package/dist/esm/slices/createAnnouncingInvoker.d.ts +3 -0
  100. package/dist/esm/slices/createAnnouncingInvoker.d.ts.map +1 -0
  101. package/dist/esm/slices/createAnnouncingInvoker.js +25 -0
  102. package/dist/esm/slices/createAnnouncingInvoker.js.map +1 -0
  103. package/dist/esm/slices/createDryRunInvoker.d.ts +15 -0
  104. package/dist/esm/slices/createDryRunInvoker.d.ts.map +1 -0
  105. package/dist/esm/slices/createDryRunInvoker.js +36 -0
  106. package/dist/esm/slices/createDryRunInvoker.js.map +1 -0
  107. package/dist/esm/slices/index.d.ts +12 -0
  108. package/dist/esm/slices/index.d.ts.map +1 -0
  109. package/dist/esm/slices/index.js +12 -0
  110. package/dist/esm/slices/index.js.map +1 -0
  111. package/dist/esm/slices/sliceDialogDestination.d.ts +10 -0
  112. package/dist/esm/slices/sliceDialogDestination.d.ts.map +1 -0
  113. package/dist/esm/slices/sliceDialogDestination.js +38 -0
  114. package/dist/esm/slices/sliceDialogDestination.js.map +1 -0
  115. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  116. package/index.ts +1 -0
  117. package/navigation/SceneNavigationContext.tsx +3 -0
  118. package/navigation/SceneNavigationHost.tsx +8 -3
  119. package/navigation/SceneNavigationState.ts +3 -0
  120. package/navigation/useSceneHistoryNavigation.ts +2 -1
  121. package/package.json +5 -5
  122. package/slices/CoreSliceDialog.tsx +14 -0
  123. package/slices/InvocationResult.ts +27 -0
  124. package/slices/InvocationValidationError.ts +13 -0
  125. package/slices/SliceCommandField.ts +22 -0
  126. package/slices/SliceCommandFieldsContext.tsx +28 -0
  127. package/slices/SliceDialogView.tsx +187 -0
  128. package/slices/SliceInvoker.ts +19 -0
  129. package/slices/SliceInvokerContext.tsx +47 -0
  130. package/slices/createAnnouncingInvoker.ts +17 -0
  131. package/slices/createDryRunInvoker.ts +43 -0
  132. package/slices/index.ts +14 -0
  133. package/slices/sliceDialogDestination.ts +40 -0
package/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from './renderer';
6
6
  export * from './core';
7
7
  export * from './NavBar';
8
8
  export * from './navigation';
9
+ export * from './slices';
9
10
  export * from './icons';
10
11
  export * from './packages';
11
12
  export * from './theme';
@@ -20,6 +20,9 @@ export interface SceneNavigationContextValue {
20
20
  currentOutlet?: string;
21
21
  currentDialog?: string;
22
22
 
23
+ /** The parameters the open dialog was opened with - a slice dialog carries its slice, command and title. */
24
+ currentDialogParameters?: Record<string, string>;
25
+
23
26
  /** The route parameters of the current entry - from the destination that opened it, or the deep link. */
24
27
  currentParameters: Record<string, string>;
25
28
 
@@ -17,6 +17,8 @@ import { useSceneHistoryNavigation } from './useSceneHistoryNavigation';
17
17
  import { SceneNavigationModel } from './region/SceneNavigationModel';
18
18
  import { SceneNavigationModelProvider } from './region/SceneNavigationModelContext';
19
19
  import { navigationDestinations } from './region/navigationDestinations';
20
+ import { SliceDialogView } from '../slices/SliceDialogView';
21
+ import { sliceDialogParameters } from '../slices/sliceDialogDestination';
20
22
 
21
23
  export interface SceneNavigationHostProps {
22
24
  screens: Record<string, SceneElement>;
@@ -54,7 +56,8 @@ export interface SceneNavigationHostProps {
54
56
  * Executes Scene destinations against an in-memory rendered hierarchy: a destination into a nested outlet -
55
57
  * one a screen declares with `core:outlet` - places its screen there and keeps the screens above it, to any
56
58
  * depth; any other screen route replaces the primary region,
57
- * dialog destinations render dialog content over the screen that opened them, and external destinations
59
+ * dialog destinations render dialog content over the screen that opened them - a toolbar slice button's dialog falls back to
60
+ * the built-in task dialog (`SliceDialogView`) when `dialogs` has no template of that name -, and external destinations
58
61
  * only report their resolved action. Every navigation is a history entry.
59
62
  */
60
63
  export function SceneNavigationHost({ screens, dialogs = {}, initialScreen, registry, bindingScope = {}, history, destinations: authoredDestinations, routes, navigationModel, frame }: SceneNavigationHostProps) {
@@ -69,14 +72,16 @@ export function SceneNavigationHost({ screens, dialogs = {}, initialScreen, regi
69
72
 
70
73
  const screen = screens[value.primaryScreen];
71
74
  const dialog = value.currentDialog ? dialogs[value.currentDialog] : undefined;
75
+ const sliceDialog = !dialog && value.currentDialog && sliceDialogParameters(value.currentDialogParameters) !== undefined;
76
+ const dialogName = sliceDialogParameters(value.currentDialogParameters)?.title ?? value.currentDialog;
72
77
 
73
78
  const outlet = <div data-scene-outlet={value.primaryScreen === value.currentScreen ? value.currentOutlet ?? 'primary' : 'primary'} data-scene-url={value.currentUrl}>
74
79
  {screen && <SceneElementView element={screen} registry={registry} dataContext={bindingScope.dataContext} queryResults={bindingScope.queryResults} componentOutputs={bindingScope.componentOutputs} />}
75
80
  </div>;
76
81
  const page = <SceneNavigationProvider value={value}><SceneOutletContext.Provider value={outletContext}>
77
82
  {frame ? frame(outlet) : outlet}
78
- {dialog && <div role='dialog' aria-modal='true' aria-label={value.currentDialog} data-scene-dialog={value.currentDialog}>
79
- <SceneElementView element={dialog} registry={registry} dataContext={bindingScope.dataContext} queryResults={bindingScope.queryResults} componentOutputs={bindingScope.componentOutputs} />
83
+ {(dialog || sliceDialog) && <div role='dialog' aria-modal='true' aria-label={dialogName} data-scene-dialog={value.currentDialog}>
84
+ {dialog ? <SceneElementView element={dialog} registry={registry} dataContext={bindingScope.dataContext} queryResults={bindingScope.queryResults} componentOutputs={bindingScope.componentOutputs} /> : <SliceDialogView />}
80
85
  </div>}
81
86
  </SceneOutletContext.Provider></SceneNavigationProvider>;
82
87
 
@@ -27,6 +27,9 @@ export interface SceneNavigationState {
27
27
  outlet?: string;
28
28
  dialog?: string;
29
29
 
30
+ /** The parameters the open dialog was opened with, by name. */
31
+ dialogParameters?: Record<string, string>;
32
+
30
33
  /** Route parameters, from the path template and the query string. */
31
34
  parameters: Record<string, string>;
32
35
  }
@@ -61,7 +61,7 @@ export function useSceneHistoryNavigation(
61
61
  const navigate = useCallback((destination: DestinationReference) => {
62
62
  const resolution = resolveDestination(destination, bindingScope);
63
63
  if (resolution.kind === DestinationKind.Dialog) {
64
- if (resolution.dialog) go({ ...state, dialog: resolution.dialog });
64
+ if (resolution.dialog) go({ ...state, dialog: resolution.dialog, dialogParameters: resolution.parameters });
65
65
  } else if (resolution.kind !== DestinationKind.External) {
66
66
  const screen = destination.screen ?? destination.slice ?? state.screen;
67
67
  const placement = placeInOutlet(state, screen, resolution.outlet, outletOwners, destinations);
@@ -84,6 +84,7 @@ export function useSceneHistoryNavigation(
84
84
  currentUrl: state.url,
85
85
  currentOutlet: state.outlet,
86
86
  currentDialog: state.dialog,
87
+ currentDialogParameters: state.dialogParameters,
87
88
  currentParameters: state.parameters,
88
89
  dialogResult,
89
90
  unresolvedUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/scene.react",
3
- "version": "4.29.0",
3
+ "version": "4.31.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.29.0",
55
- "@cratis/scene.model": "4.29.0"
54
+ "@cratis/scene.engine": "4.31.0",
55
+ "@cratis/scene.model": "4.31.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@cratis/scene.engine": "4.29.0",
59
- "@cratis/scene.model": "4.29.0",
58
+ "@cratis/scene.engine": "4.31.0",
59
+ "@cratis/scene.model": "4.31.0",
60
60
  "@storybook/addon-links": "^10.6.1",
61
61
  "@storybook/react": "^10.6.1",
62
62
  "@storybook/react-vite": "^10.6.1",
@@ -0,0 +1,14 @@
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 { RegisteredComponentProps } from '../renderer';
5
+ import { SliceDialogView } from './SliceDialogView';
6
+
7
+ /**
8
+ * The `core:sliceDialog` component: the task dialog of a state change slice. Its `slice`, `command` and `title` properties are
9
+ * optional - opened from a toolbar button the dialog's own parameters supply them - so one dialog template serves every slice.
10
+ */
11
+ export function CoreSliceDialog({ element }: RegisteredComponentProps) {
12
+ const text = (name: string) => typeof element.properties[name] === 'string' ? element.properties[name] as string : undefined;
13
+ return <SliceDialogView slice={text('slice')} command={text('command')} title={text('title')} />;
14
+ }
@@ -0,0 +1,27 @@
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 { InvocationValidationError } from './InvocationValidationError';
5
+
6
+ /**
7
+ * What invoking a state change slice came back with.
8
+ */
9
+ export interface InvocationResult {
10
+ /** Whether the slice accepted the command. A dialog closes on success and shows the problems otherwise. */
11
+ succeeded: boolean;
12
+
13
+ /** The validation problems, shown against their fields; a problem without a field is shown for the whole form. */
14
+ validationErrors?: InvocationValidationError[];
15
+
16
+ /** A message for the user - a failure the server reports, or the note of a dry run. */
17
+ message?: string;
18
+
19
+ /** The command payload that was, or with a dry run would have been, sent. */
20
+ payload?: Record<string, unknown>;
21
+
22
+ /** Whether nothing was sent: the invocation was a dry run. */
23
+ dryRun?: boolean;
24
+
25
+ /** Whatever the invoker returns for the caller, for example the result of the command. */
26
+ value?: unknown;
27
+ }
@@ -0,0 +1,13 @@
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
+ /**
5
+ * One validation problem an invocation reports, to be shown inline in the dialog that submitted it.
6
+ */
7
+ export interface InvocationValidationError {
8
+ /** The command property the problem belongs to; absent for a problem with the command as a whole. */
9
+ field?: string;
10
+
11
+ /** What is wrong. */
12
+ message: string;
13
+ }
@@ -0,0 +1,22 @@
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
+ /**
5
+ * One field of the form a slice's command is submitted from.
6
+ */
7
+ export interface SliceCommandField {
8
+ /** The command property. */
9
+ name: string;
10
+
11
+ /** The property's type: `string`, `number`, `boolean`, `date`, `guid` ... Numbers and booleans are sent as such. */
12
+ type: string;
13
+
14
+ /** The label shown for the field; derived from the name when absent. */
15
+ label?: string;
16
+
17
+ /** Whether the field must have a value. */
18
+ required?: boolean;
19
+
20
+ /** The value the field starts with - a preview fills it from the active sample data pack; a running application leaves it out. */
21
+ initialValue?: string | number | boolean;
22
+ }
@@ -0,0 +1,28 @@
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 { ReactNode, createContext, useContext } from 'react';
5
+ import { SliceCommandField } from './SliceCommandField';
6
+
7
+ /** Tells a task dialog which fields a command has. */
8
+ export type SliceCommandFieldsSource = (command: string, sliceId: string) => SliceCommandField[];
9
+
10
+ const Context = createContext<SliceCommandFieldsSource | undefined>(undefined);
11
+
12
+ export interface SliceCommandFieldsProviderProps {
13
+ fieldsFor: SliceCommandFieldsSource;
14
+ children?: ReactNode;
15
+ }
16
+
17
+ /**
18
+ * Supplies the fields of a command to the task dialogs below it. The fields come from the command's metadata - Stage reads it
19
+ * from the generated schemas, Studio from the model - so this package does not know where commands are defined.
20
+ */
21
+ export function SliceCommandFieldsProvider({ fieldsFor, children }: SliceCommandFieldsProviderProps) {
22
+ return <Context.Provider value={fieldsFor}>{children}</Context.Provider>;
23
+ }
24
+
25
+ /** The fields source in scope, or undefined when a host supplied none. */
26
+ export function useSliceCommandFields(): SliceCommandFieldsSource | undefined {
27
+ return useContext(Context);
28
+ }
@@ -0,0 +1,187 @@
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 { FormEvent, KeyboardEvent, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
5
+ import { ApplicationStructureIndex } from '@cratis/scene.engine';
6
+ import { SceneNavigationContextInternal } from '../navigation/SceneNavigationContext';
7
+ import { useSceneNavigationModel } from '../navigation/region/SceneNavigationModelContext';
8
+ import { InvocationResult } from './InvocationResult';
9
+ import { InvocationValidationError } from './InvocationValidationError';
10
+ import { SliceCommandField } from './SliceCommandField';
11
+ import { useSliceCommandFields } from './SliceCommandFieldsContext';
12
+ import { useSliceInvoker } from './SliceInvokerContext';
13
+ import { sliceDialogParameters } from './sliceDialogDestination';
14
+
15
+ export interface SliceDialogViewProps {
16
+ /** The qualified id of the slice; the open dialog's `slice` parameter when absent. */
17
+ slice?: string;
18
+
19
+ /** The command the slice accepts; the open dialog's `command` parameter when absent. */
20
+ command?: string;
21
+
22
+ /** The title - the toolbar button's - also the label of the submit button; the open dialog's `title` parameter when absent. */
23
+ title?: string;
24
+
25
+ /** Called when the dialog is cancelled or the slice accepted the command; the host's `closeDialog` when absent. */
26
+ onClose?(result?: InvocationResult): void;
27
+ }
28
+
29
+ const focusable = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
30
+
31
+ /** A label for a command property: `customerName` becomes `Customer name`. */
32
+ export function sliceFieldLabel(field: SliceCommandField): string {
33
+ if (field.label) return field.label;
34
+ const spaced = field.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim().toLowerCase();
35
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
36
+ }
37
+
38
+ function inputTypeOf(field: SliceCommandField): string {
39
+ switch (field.type.toLowerCase()) {
40
+ case 'number': case 'int': case 'integer': case 'long': case 'double': case 'decimal': case 'float': case 'single': case 'int32': case 'int64': return 'number';
41
+ case 'boolean': case 'bool': return 'checkbox';
42
+ case 'date': case 'dateonly': return 'date';
43
+ default: return 'text';
44
+ }
45
+ }
46
+
47
+ /** What the form starts with: the fields that carry an initial value, as the strings and booleans the inputs hold. */
48
+ function initialValuesOf(fields: SliceCommandField[]): Record<string, string | boolean> {
49
+ const values: Record<string, string | boolean> = {};
50
+ for (const field of fields) {
51
+ if (field.initialValue === undefined) continue;
52
+ values[field.name] = typeof field.initialValue === 'boolean' && inputTypeOf(field) === 'checkbox' ? field.initialValue : String(field.initialValue);
53
+ }
54
+
55
+ return values;
56
+ }
57
+
58
+ /** The payload a form's values make: numbers and booleans as such, an empty number left out. */
59
+ export function slicePayload(fields: SliceCommandField[], values: Record<string, string | boolean>): Record<string, unknown> {
60
+ const payload: Record<string, unknown> = {};
61
+ for (const field of fields) {
62
+ const value = values[field.name];
63
+ const type = inputTypeOf(field);
64
+ if (type === 'checkbox') payload[field.name] = value === true;
65
+ else if (type === 'number') { if (value !== undefined && value !== '') payload[field.name] = Number(value); }
66
+ else payload[field.name] = value ?? '';
67
+ }
68
+
69
+ return payload;
70
+ }
71
+
72
+ /**
73
+ * The task dialog of a toolbar slice button (TB4): a modal card with the button's title, the slice as its subtitle, a labelled
74
+ * field for each command property, and Cancel and a submit button labelled with the title.
75
+ *
76
+ * - The first field takes focus when it opens, Tab stays inside, Escape cancels, and focus returns to the button that opened it.
77
+ * - Submit validates required fields, then calls the `SliceInvoker` in scope - real, dry-run or the announcing default. The
78
+ * problems it reports are shown against their fields (`aria-invalid`, `aria-describedby`) or for the form; success closes the
79
+ * dialog and hands the result to the host (`dialogResult`).
80
+ * - The fields come from the `SliceCommandFieldsProvider`; the subtitle names the slice from the navigation model.
81
+ *
82
+ * Inside a navigation host it is the content of the dialog a toolbar button opens; `core:sliceDialog` registers it so a host can
83
+ * author the dialog template with it.
84
+ */
85
+ export function SliceDialogView({ slice: sliceProp, command: commandProp, title: titleProp, onClose }: SliceDialogViewProps) {
86
+ const navigation = useContext(SceneNavigationContextInternal);
87
+ const opened = sliceDialogParameters(navigation?.currentDialogParameters);
88
+ const slice = sliceProp ?? opened?.slice ?? '';
89
+ const command = commandProp ?? opened?.command ?? slice;
90
+ const title = titleProp ?? opened?.title ?? command;
91
+
92
+ const invoker = useSliceInvoker();
93
+ const fieldsFor = useSliceCommandFields();
94
+ const model = useSceneNavigationModel();
95
+ const fields = useMemo(() => fieldsFor?.(command, slice) ?? [], [command, fieldsFor, slice]);
96
+ const sliceName = useMemo(() => {
97
+ if (!model) return slice.split('.').pop() ?? slice;
98
+ const index = model.structure instanceof ApplicationStructureIndex ? model.structure : new ApplicationStructureIndex(model.structure);
99
+ return index.findSlice(slice)?.id ?? slice.split('.').pop() ?? slice;
100
+ }, [model, slice]);
101
+
102
+ const [values, setValues] = useState<Record<string, string | boolean>>(() => initialValuesOf(fields));
103
+ const [errors, setErrors] = useState<InvocationValidationError[]>([]);
104
+ const [submitting, setSubmitting] = useState(false);
105
+ const form = useRef<HTMLFormElement>(null);
106
+ const identity = useId();
107
+
108
+ const close = (result?: InvocationResult) => onClose ? onClose(result) : navigation?.closeDialog(result);
109
+
110
+ // Focus the first field, and give focus back to whatever opened the dialog when it goes.
111
+ useEffect(() => {
112
+ const opener = document.activeElement as HTMLElement | null;
113
+ (form.current?.querySelector<HTMLElement>('input, select, textarea') ?? form.current?.querySelector<HTMLElement>('button'))?.focus();
114
+ return () => { if (opener && opener.isConnected) opener.focus(); };
115
+ }, []);
116
+
117
+ const errorsOf = (name: string | undefined) => errors.filter(error => error.field === name);
118
+ const fieldNames = new Set(fields.map(field => field.name));
119
+ const general = errors.filter(error => error.field === undefined || !fieldNames.has(error.field));
120
+
121
+ const submit = async (event: FormEvent) => {
122
+ event.preventDefault();
123
+ if (submitting) return;
124
+
125
+ const missing = fields.filter(field => field.required && (values[field.name] === undefined || values[field.name] === '')).map(field => ({ field: field.name, message: `${sliceFieldLabel(field)} is required.` }));
126
+ setErrors(missing);
127
+ if (missing.length > 0) {
128
+ form.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
129
+ return;
130
+ }
131
+
132
+ setSubmitting(true);
133
+ try {
134
+ const result = await invoker.invoke(slice, slicePayload(fields, values), command);
135
+ if (result.succeeded) {
136
+ close(result);
137
+ } else {
138
+ const reported = result.validationErrors ?? [];
139
+ setErrors(reported.length > 0 || !result.message ? reported : [{ message: result.message }]);
140
+ }
141
+ } finally {
142
+ setSubmitting(false);
143
+ }
144
+ };
145
+
146
+ const onKeyDown = (event: KeyboardEvent<HTMLFormElement>) => {
147
+ if (event.key === 'Escape') {
148
+ event.preventDefault();
149
+ close();
150
+ } else if (event.key === 'Tab') {
151
+ const targets = Array.from(form.current?.querySelectorAll<HTMLElement>(focusable) ?? []);
152
+ if (targets.length === 0) return;
153
+ const first = targets[0];
154
+ const last = targets[targets.length - 1];
155
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
156
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
157
+ }
158
+ };
159
+
160
+ return <form ref={form} className='scene-slice-dialog' data-scene-slice-dialog={slice} data-scene-command={command} noValidate onSubmit={submit} onKeyDown={onKeyDown}>
161
+ <header className='scene-slice-dialog-header'>
162
+ <h2 className='scene-slice-dialog-title'>{title}</h2>
163
+ <p className='scene-slice-dialog-subtitle'>State Change · {sliceName}</p>
164
+ </header>
165
+ <div className='scene-slice-dialog-fields'>
166
+ {fields.map(field => {
167
+ const id = `${identity}-${field.name}`;
168
+ const own = errorsOf(field.name);
169
+ const type = inputTypeOf(field);
170
+ const description = own.length > 0 ? `${id}-error` : undefined;
171
+ return <div key={field.name} className='scene-slice-dialog-field' data-type={type}>
172
+ <label htmlFor={id}>{sliceFieldLabel(field)}{field.required ? <span aria-hidden='true'> *</span> : null}</label>
173
+ <input id={id} name={field.name} type={type} required={field.required} aria-required={field.required || undefined}
174
+ aria-invalid={own.length > 0 ? true : undefined} aria-describedby={description}
175
+ {...(type === 'checkbox' ? { checked: values[field.name] === true } : { value: (values[field.name] as string | undefined) ?? '' })}
176
+ onChange={event => setValues({ ...values, [field.name]: type === 'checkbox' ? event.target.checked : event.target.value })} />
177
+ {own.length > 0 && <p id={description} className='scene-slice-dialog-error' role='alert'>{own.map(error => error.message).join(' ')}</p>}
178
+ </div>;
179
+ })}
180
+ </div>
181
+ {general.length > 0 && <p className='scene-slice-dialog-error scene-slice-dialog-summary' role='alert'>{general.map(error => error.message).join(' ')}</p>}
182
+ <footer className='scene-slice-dialog-footer'>
183
+ <button type='button' className='scene-slice-dialog-cancel' onClick={() => close()}>Cancel</button>
184
+ <button type='submit' className='scene-slice-dialog-submit' disabled={submitting}>{title}</button>
185
+ </footer>
186
+ </form>;
187
+ }
@@ -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 { InvocationResult } from './InvocationResult';
5
+
6
+ /**
7
+ * Invokes a state change slice with a command payload. This is the seam between the toolbar's task dialogs and whatever
8
+ * runs the command: Stage provides one that sends it, the Studio preview one that only checks it (`createDryRunInvoker`),
9
+ * and the default announces it as an event. `@cratis/scene.react` knows no transport.
10
+ */
11
+ export interface SliceInvoker {
12
+ /**
13
+ * Invokes the slice.
14
+ * @param sliceId The qualified id of the slice, for example `sales.orders.PlaceOrder`.
15
+ * @param payload The command's values, by property name.
16
+ * @param command The command the slice accepts, when known.
17
+ */
18
+ invoke(sliceId: string, payload: Record<string, unknown>, command?: string): Promise<InvocationResult>;
19
+ }
@@ -0,0 +1,47 @@
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 { ReactNode, createContext, useContext, useMemo } from 'react';
5
+ import { createAnnouncingInvoker } from './createAnnouncingInvoker';
6
+ import { SliceInvoker } from './SliceInvoker';
7
+
8
+ interface SliceInvokerScope {
9
+ invoker: SliceInvoker;
10
+ warnEmptySlices: boolean;
11
+ }
12
+
13
+ const announcing = createAnnouncingInvoker();
14
+ const defaultScope: SliceInvokerScope = { invoker: announcing, warnEmptySlices: false };
15
+ const Context = createContext<SliceInvokerScope>(defaultScope);
16
+
17
+ export interface SliceInvokerProviderProps {
18
+ /** The invoker toolbar dialogs submit through. */
19
+ invoker: SliceInvoker;
20
+
21
+ /**
22
+ * Whether a toolbar button without a slice shows a warning badge. Design and preview hosts turn it on (TB5); a
23
+ * running application never shows it. A dry-run invoker implies it.
24
+ */
25
+ warnEmptySlices?: boolean;
26
+ children?: ReactNode;
27
+ }
28
+
29
+ /**
30
+ * Supplies the `sliceInvoker` every toolbar dialog below it submits through: a real one in Stage, a dry-run one in the Studio
31
+ * preview. Without a provider the command is announced as `cratis.scene.command`.
32
+ */
33
+ export function SliceInvokerProvider({ invoker, warnEmptySlices, children }: SliceInvokerProviderProps) {
34
+ const warn = warnEmptySlices ?? (invoker as { dryRun?: boolean }).dryRun === true;
35
+ const value = useMemo(() => ({ invoker, warnEmptySlices: warn }), [invoker, warn]);
36
+ return <Context.Provider value={value}>{children}</Context.Provider>;
37
+ }
38
+
39
+ /** The invoker in scope: the provider's, or the one that announces the command. */
40
+ export function useSliceInvoker(): SliceInvoker {
41
+ return useContext(Context).invoker;
42
+ }
43
+
44
+ /** Whether a button without a slice should show a warning badge here. */
45
+ export function useWarnEmptySlices(): boolean {
46
+ return useContext(Context).warnEmptySlices;
47
+ }
@@ -0,0 +1,17 @@
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 { SliceInvoker } from './SliceInvoker';
5
+
6
+ /**
7
+ * The invoker used when a host provides none: it announces the command with the host-neutral `cratis.scene.command` event,
8
+ * as a `core:toolbarItem` action always has, and reports success. A host that listens for the event runs the command.
9
+ */
10
+ export function createAnnouncingInvoker(): SliceInvoker {
11
+ return {
12
+ async invoke(sliceId, payload, command) {
13
+ globalThis.dispatchEvent(new CustomEvent('cratis.scene.command', { detail: { command: command ?? sliceId, slice: sliceId, arguments: payload } }));
14
+ return { succeeded: true, payload };
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,43 @@
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 { InvocationValidationError } from './InvocationValidationError';
5
+ import { SliceInvoker } from './SliceInvoker';
6
+
7
+ /** What a dry run is told about each invocation. */
8
+ export interface DryRunInvocation {
9
+ /** The qualified id of the slice. */
10
+ sliceId: string;
11
+
12
+ /** The command the slice accepts, when known. */
13
+ command?: string;
14
+
15
+ /** The payload that would have been sent. */
16
+ payload: Record<string, unknown>;
17
+ }
18
+
19
+ export interface DryRunInvokerOptions {
20
+ /** Called for every invocation that passes validation, with the would-be payload - the Studio preview shows it as a toast. */
21
+ onInvoked?(invocation: DryRunInvocation): void;
22
+
23
+ /** Extra validation of the payload; the problems it returns are reported like the real ones and nothing is invoked. */
24
+ validate?(invocation: DryRunInvocation): InvocationValidationError[];
25
+ }
26
+
27
+ /**
28
+ * An invoker that sends nothing (PRV5): it validates, reports what it would have sent and succeeds, so a preview can walk a
29
+ * toolbar button through its dialog without touching a backend.
30
+ */
31
+ export function createDryRunInvoker(options: DryRunInvokerOptions = {}): SliceInvoker & { readonly dryRun: true } {
32
+ return {
33
+ dryRun: true,
34
+ async invoke(sliceId, payload, command) {
35
+ const invocation: DryRunInvocation = { sliceId, payload, ...(command === undefined ? {} : { command }) };
36
+ const validationErrors = options.validate?.(invocation) ?? [];
37
+ if (validationErrors.length > 0) return { succeeded: false, validationErrors, payload, dryRun: true };
38
+
39
+ options.onInvoked?.(invocation);
40
+ return { succeeded: true, payload, dryRun: true, message: `Dry run: ${command ?? sliceId} was not sent.` };
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,14 @@
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
+ export * from './InvocationValidationError';
5
+ export * from './InvocationResult';
6
+ export * from './SliceInvoker';
7
+ export * from './createAnnouncingInvoker';
8
+ export * from './createDryRunInvoker';
9
+ export * from './SliceInvokerContext';
10
+ export * from './SliceCommandField';
11
+ export * from './SliceCommandFieldsContext';
12
+ export * from './sliceDialogDestination';
13
+ export * from './SliceDialogView';
14
+ export * from './CoreSliceDialog';
@@ -0,0 +1,40 @@
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 { BindingExpression, BindingSourceKind, DestinationKind, DestinationReference } from '@cratis/scene.model';
5
+ import { ResolvedToolbarButton } from '@cratis/scene.engine';
6
+
7
+ const literal = (value: string): BindingExpression => ({ kind: BindingSourceKind.Literal, path: '', value }) as BindingExpression;
8
+
9
+ /** The dialog parameters a slice dialog is opened with; the navigation host hands them back as `currentDialogParameters`. */
10
+ export interface SliceDialogParameters {
11
+ slice: string;
12
+ command: string;
13
+ title: string;
14
+ }
15
+
16
+ /**
17
+ * The destination that opens a toolbar button's task dialog through the navigation host: the dialog template the slice
18
+ * resolves to (`sliceDialogFor`), with the slice, its command and the button's title as parameters.
19
+ * @param button The resolved button.
20
+ * @returns The destination, or undefined when the button has no state change slice to open.
21
+ */
22
+ export function sliceDialogDestination(button: ResolvedToolbarButton): DestinationReference | undefined {
23
+ if (!button.dialog) return undefined;
24
+ return {
25
+ kind: DestinationKind.Dialog,
26
+ dialog: button.dialog.template,
27
+ routeParameterBindings: {
28
+ slice: literal(button.dialog.slice),
29
+ command: literal(button.dialog.command),
30
+ title: literal(button.title),
31
+ },
32
+ };
33
+ }
34
+
35
+ /** Reads the slice dialog parameters back from what the host carries; undefined when they are not those of a slice dialog. */
36
+ export function sliceDialogParameters(parameters: Record<string, string> | undefined): SliceDialogParameters | undefined {
37
+ const slice = parameters?.slice;
38
+ const command = parameters?.command;
39
+ return slice && command ? { slice, command, title: parameters?.title || command } : undefined;
40
+ }