From cd35bc341284a3f8748b93a1a2e75cbcb50b2f4d Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 23 Sep 2026 11:51:30 +0200 Subject: [PATCH 1/8] Enforce the engine's framework and URL independence in lint The engine being platform-neutral was a convention held up by review. Two invariants now fail the build instead: I3 - engine/** and model/** may not import react, react-dom or the renderer, and may not reach for window, document, location, fetch or their siblings. A pure engine is what lets 'entering this screen runs that query once with these parameters' be a unit test rather than a browser run. I4 - no route-template literal in engine/** or model/**. The model says 'navigate to '; deciding that means /invoicing/invoices belongs to a renderer. The same literal in react/** lints clean, and that asymmetry is the seam. Both report an explanation rather than a rule name, because the fix is usually 'inject a seam', not 'delete the line'. Verified against deliberate violations of every clause, and the existing tree is clean. Documents the layering, the four seam consequences and all seven invariants, and adds the glossary Scene shares with Screenplay. --- Documentation/architecture/layering.md | 102 +++++++++++++++++++++++++ Documentation/glossary.md | 73 ++++++++++++++++++ Documentation/toc.yml | 4 + eslint.config.mjs | 65 ++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 Documentation/architecture/layering.md create mode 100644 Documentation/glossary.md diff --git a/Documentation/architecture/layering.md b/Documentation/architecture/layering.md new file mode 100644 index 00000000..0c7bfc10 --- /dev/null +++ b/Documentation/architecture/layering.md @@ -0,0 +1,102 @@ +--- +title: Layering and seams +description: The six layers a modeled application passes through, the rule that keeps them apart, and the invariants CI enforces so they stay that way. +--- + +A Screenplay document describes an application. Something has to turn that description into pixels a user can +click. Scene is the middle of that journey, and the whole design rests on one rule about who is allowed to know +what. + +## The layers + +```text +Screenplay (.play) The model. Declarative, portable, human-authored. + │ Knows nothing about React, URLs, HTTP or CSS. + ▼ compile +Screenplay semantic model The compiler's answer: syntax → semantics. + │ + ▼ convert +Scene model The platform-neutral UI model. Serializable — the wire contract. + Cratis.Scene.Model Still knows nothing about React or URLs. + ≡ @cratis/scene.model + │ + ▼ serve +Scene engine Pure functions over the model: resolve, evaluate, aggregate, bind. + @cratis/scene.engine Platform-neutral. No DOM. No framework. + │ + ▼ +Scene renderer Turns engine output into platform widgets. + @cratis/scene.react Owns URLs, the DOM, effects and transports. + │ + ▼ +Packages and blueprints core → components → primereact/tailwind → blueprints. + Blueprints supply the shell chrome and the template set. +``` + +## The seam rule + +**Each layer may only know about the layer directly above it.** Four consequences do all the work: + +| Rule | Why | +| --- | --- | +| The Scene **model** contains no URL, CSS class, React type or HTTP verb. | It is the wire contract, and a future MAUI or Compose renderer has to be able to honour it. | +| The Scene **engine** imports no framework and touches no `window`. | Its behaviour must be assertable without a DOM. That is what makes the data lifecycle testable rather than observable-by-screenshot. | +| The **renderer** is the only place a URL is constructed. | The model says `navigate to `; deciding that means `/invoicing/invoices` is a platform decision. | +| Only **Stage** knows what an Arc endpoint is. | The engine asks a transport for a query's result; it never knows a route existed. | + +The existing `NavBar` is the pattern in miniature: it takes a `renderRoute` callback rather than building a path, +because at that layer the path is not yet knowable. Every new surface follows it. + +### What "platform-neutral" buys + +It is tempting to read the seam rule as ceremony — the application is a web application, so why should the +engine pretend otherwise? Two reasons, both practical: + +- **Testability.** A pure engine lets "entering this screen runs that query once, with these parameters, and does + not re-run on an unrelated change" be a unit test. Behind a DOM, the same assertion needs a browser and becomes + flaky. +- **Honesty about the model.** If the engine may reach for `window`, the model slowly stops being a model. The + first `location.href` is the point where the document stops describing an application and starts describing a + web page. + +## The invariants + +These are checked, not aspirational. + +| # | Invariant | How it is checked | +| --- | --- | --- | +| I1 | The Scene model serializes to canonical, stable JSON. | `Stage` — `for_CanonicalSceneJson/when_serializing_the_same_scene_twice`. | +| I2 | The C# model and the TS mirror agree. | `scene-model-shape.json` plus both sides' shape specs. | +| I3 | The engine and model have no framework or DOM dependency. | ESLint: restricted imports and globals over `engine/**` and `model/**`. | +| I4 | No URL is constructed outside a renderer package. | ESLint: no route-template literals in `engine/**` or `model/**`. | +| I5 | Every reference either resolves or produces a diagnostic. | Compiler diagnostics plus the frontend finding surface. | +| I6 | A construct that reaches the model reaches the renderer, or is reported. | `Stage` — `for_UnrenderedConstructs`. | +| I7 | Enum members are never silently renumbered. | The append-only rule in Screenplay's `ast-compatibility.md`. | + +### I3 and I4 in practice + +Both live in `eslint.config.mjs` as a configuration block scoped to `Source/JavaScript/engine/**` and +`Source/JavaScript/model/**`. A violation fails the lint run with an explanation rather than a rule name: + +```text +error I3: 'document' is a host environment API. The engine and the model must run without a DOM — take it + as an injected seam (a transport, a dispatcher, a store) supplied by the renderer. +error I4: no URL is constructed outside a renderer package. The model and the engine deal in screen + references and parameter maps; a renderer turns those into a path. +``` + +The same code in `react/**` lints clean. That asymmetry *is* the seam. + +When the engine genuinely needs an effect, the answer is an injected seam, not an exception: a transport for +data, a dispatcher for actions, a store for state. The engine sequences; the renderer performs. + +## Where decisions are recorded + +The layering is the consequence of a set of recorded decisions rather than a matter of taste. The architecture +decision records covering it: interaction location, trigger vocabulary, blueprint as a selection, the URL +scheme, screen state ownership, the frontend failure posture, and multi-file project semantics. + +## See also + +- [Glossary](../glossary.md) — one meaning per word, shared with Screenplay. +- [Blueprints](../blueprints/index.md) — what a blueprint bundles and how a profile selects one. diff --git a/Documentation/glossary.md b/Documentation/glossary.md new file mode 100644 index 00000000..aeb30bb1 --- /dev/null +++ b/Documentation/glossary.md @@ -0,0 +1,73 @@ +--- +title: Glossary +description: The vocabulary Scene shares with Screenplay — layouts, slots, templates, elements, behaviors, actions and routes — with one meaning per word. +--- + +Scene renders what Screenplay declares, so the two share a vocabulary. Where a word already means something in +Screenplay, that meaning wins here — this page says what each term is *in Scene*, and which record or type is +authoritative. + +## The document's shapes + +These arrive from the model. Scene does not reinterpret them. + +| Term | Meaning | Authoritative in | +| --- | --- | --- | +| **Layout** | The application's base navigational shell. One per build, selected by a UI profile. | `Model/Layouts/Layout` | +| **Slot** | A named region a layout or template declares. Filled by one parent, or opened to many by declaring a contribution point. | `Model/Layouts/Slot` | +| **Screen template** | A reusable shape inside the shell. Says which slot of its parent it fills with `fits slot`; nesting is matched by slot name, to any depth. | `Model/Screens/ScreenTemplate` | +| **Dialog template** | A shape that opens *over* the application. Never fills a slot. | `Model/Screens/DialogTemplate` | +| **Screen** | An instance filling a template's slots. Never names a layout directly. | `Model/Screens/Screen` | +| **Element** | A node in a screen's content tree. | `Model/Elements/SceneElement` | +| **Arrangement** | How a layout or template positions its slots — `flow` or `freeform`, varying by size class. | `Model/Layouts/Arrangement` | +| **Size class** | `compact`, `regular` or `expanded`, on the width and height axes. A class, not a pixel breakpoint. | `Model/SizeClasses/` | +| **Contribution point** | A named many-to-one extension point declared on a slot. | `Model/ContributionPoints/ContributionPoint` | +| **Contribution** | One piece of content targeting a contribution point. | `Model/ContributionPoints/Contribution` | +| **Form** | A command or query input surface with its fields and how they populate. | `Model/Forms/Form` | +| **UI profile** | A build's selections: platform, size, layout, theme, packages, blueprint, start screen. Names artifacts; never contains them. | `Model/Profiles/UiProfile` | + +## Interaction + +What happens when a user does something. The vocabulary is closed and every operand is a model reference, which +is what makes an interaction that names nothing real a diagnostic instead of a dead control. + +| Term | Meaning | +| --- | --- | +| **Interaction trigger** | What starts an interaction — a click, a submit, a screen becoming live, an observed domain event, an elapsed interval, or a declared application trigger firing. Anonymous: it exists only inside a behavior. | +| **Application trigger** | Screenplay's declared top-level signal consumed by backend reactions. A different concept with a similar name — an interaction may `raise` one, which is how the two connect. | +| **Action** | One declarative effect: execute a command, navigate, open or close a dialog, refresh a query, set state, notify, confirm, raise. | +| **Continuation** | The `on success` / `on failure` / `on result` actions that run after an action that can fail. | +| **Behavior** | A bundle of interaction-trigger-to-action bindings, attachable to an element, form, screen, template, layout, module or feature. Anonymous when written inline; named when reusable. Attachments are additive — a template's behavior and an element's both run. | +| **Action dispatcher** | The seam a renderer implements, one method per action kind. The engine sequences actions and handles continuations; the dispatcher performs the effects. | +| **Interaction context** | What an action's bindings resolve against: route parameters, screen state, the triggering item, the form's values, the trigger's payload. | + +## Runtime + +| Term | Meaning | +| --- | --- | +| **Route** | The renderer's concrete address for a screen. The model never contains one. | +| **Screen state** | A screen's declared values: route-backed `accepts`, and transient `state`. Owned by the engine, subscribed to by the renderer. | +| **Query state** | Where a query-backed element stands: idle, loading, loaded or failed. | +| **Finding** | A structured report that something did not resolve. Rendered in place and collectable, so "this application has no findings" is a check rather than an opinion. | + +## Packaging + +| Term | Meaning | +| --- | --- | +| **Package** | A named set of components a profile draws from, in override-priority order. `core` is the final fallback. | +| **Blueprint** | A shipped bundle: layouts, shell chrome, a template set and theme tokens. Selected by name in a UI profile, the same way a theme is. | +| **Theme** | A set of visual tokens, compatible with one or more packages. | +| **Shadowing** | A higher-priority package supplying a component by the same name, overriding a lower one. | + +## The layers themselves + +| Term | Meaning | +| --- | --- | +| **Scene model** | The platform-neutral UI model: `Cratis.Scene.Model` in C#, mirrored by `@cratis/scene.model`. The wire contract. | +| **Scene engine** | `@cratis/scene.engine` — pure functions over the model. No DOM, no framework. | +| **Scene renderer** | `@cratis/scene.react` and its siblings. Owns URLs, the DOM, effects and transports. | + +## See also + +- [Layering and seams](architecture/layering.md) — which layer may know what, and the invariants that enforce it. +- Screenplay's own glossary — the language-side definition of every modeled construct. diff --git a/Documentation/toc.yml b/Documentation/toc.yml index d72673ba..1bed7dd9 100644 --- a/Documentation/toc.yml +++ b/Documentation/toc.yml @@ -3,6 +3,10 @@ - name: Understand items: + - name: Layering and seams + href: architecture/layering.md + - name: Glossary + href: glossary.md - name: Packages href: packages/toc.yml diff --git a/eslint.config.mjs b/eslint.config.mjs index bc5cd07e..fd2a155c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -79,6 +79,70 @@ const rules = { }, }; +// Seam invariants from UIPlan/01-foundations-architecture.md §3 (I3, I4). +// +// I3 — the engine and the model have no framework or DOM dependency. The engine is pure functions over the +// model so its behaviour can be asserted without a DOM, and so a non-React renderer inherits correct +// semantics rather than reimplementing them. Enforced by ADR-005 and ADR-006. +// +// I4 — no URL string is constructed outside a renderer package. The model declares `navigate to `; +// deciding that this means `/invoicing/invoices` is renderer work, per ADR-004. A route-template literal in +// `model/` or `engine/` means that decision has leaked down a layer. +const seamRestrictedPackages = [ + { name: 'react', message: 'I3: the engine and the model are framework-neutral. Move React usage into a renderer package such as @cratis/scene.react.' }, + { name: 'react-dom', message: 'I3: the engine and the model are framework-neutral. Move React usage into a renderer package such as @cratis/scene.react.' }, + { name: '@cratis/scene.react', message: 'I3: the engine and the model must not depend on a renderer. The dependency runs the other way.' }, +]; + +const seamRestrictedGlobals = [ + 'window', + 'document', + 'navigator', + 'location', + 'history', + 'localStorage', + 'sessionStorage', + 'fetch', + 'XMLHttpRequest', + 'alert', + 'confirm', +].map(name => ({ + name, + message: `I3: '${name}' is a host environment API. The engine and the model must run without a DOM — take it as an injected seam (a transport, a dispatcher, a store) supplied by the renderer.`, +})); + +const routeLiteralMessage = + 'I4: no URL is constructed outside a renderer package. The model and the engine deal in screen references and parameter maps; a renderer turns those into a path (ADR-004).'; + +const seamConfig = [ + { + files: ['Source/JavaScript/engine/**/*.ts', 'Source/JavaScript/model/**/*.ts'], + languageOptions: { + globals: {}, + }, + rules: { + 'no-restricted-imports': ['error', { paths: seamRestrictedPackages }], + 'no-restricted-globals': ['error', ...seamRestrictedGlobals], + 'no-restricted-syntax': [ + 'error', + { + selector: 'Literal[value=/^\\//]', + message: routeLiteralMessage, + }, + { + // A relative route template — caught separately so it is reported once, not twice. + selector: 'Literal[value=/^[^/].*\\/:/]', + message: routeLiteralMessage, + }, + { + selector: 'TemplateElement[value.raw=/^\\//]', + message: routeLiteralMessage, + }, + ], + }, + }, +]; + const reactCompat = compat.extends('plugin:react/recommended'); const reactPlugin = reactCompat[0].plugins.react; @@ -133,6 +197,7 @@ const defaultConfig = [ 'no-restricted-globals': 0, }, }, + ...seamConfig, ]; const config = tseslint.config(...defaultConfig); From 3bcf9c41e8d2067fce0bc5cf736dc61dd9c7766a Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 23 Sep 2026 12:36:08 +0200 Subject: [PATCH 2/8] Add the interaction model to the Scene model and its mirror The platform-neutral half of the interaction spine: what the compiler produces and what a renderer consumes. Behavior, InteractionBinding, the four trigger kinds and the ten action kinds, in C# and in the hand-maintained TypeScript mirror, with the shared shape manifest updated so each side fails its own build on a desync rather than only the other's. Two things the model deliberately does not carry. A navigation names a screen rather than an address, because turning that into a URL is a renderer's decision and a non-web renderer has no URLs to turn it into. An interval is normalized to seconds at conversion, so nothing downstream carries a unit or converts one. A message is three explicit members - literal, localization key, binding - rather than one string a consumer sniffs, because 'does this start with $strings.' is exactly the rule that ends up implemented differently in every renderer. Behaviors attach on SceneElement, Screen, Form, Layout, ScreenTemplate and DialogTemplate. On the element itself rather than a wrapper, since interaction belongs to the thing being interacted with. Continuations live on the action base so a new action kind stays an append. --- Source/DotNET/Model/Elements/SceneElement.cs | 10 + Source/DotNET/Model/Forms/Form.cs | 10 +- .../ApplicationTriggerInteractionTrigger.cs | 10 + Source/DotNET/Model/Interactions/Behavior.cs | 24 + .../Interactions/BuiltInInteractionTrigger.cs | 10 + .../Model/Interactions/CloseDialogAction.cs | 9 + .../Model/Interactions/ConfirmAction.cs | 10 + .../Interactions/EventInteractionTrigger.cs | 10 + .../Interactions/ExecuteCommandAction.cs | 10 + .../Model/Interactions/InteractionAction.cs | 52 ++ .../Model/Interactions/InteractionArgument.cs | 13 + .../Model/Interactions/InteractionBinding.cs | 17 + .../Model/Interactions/InteractionMessage.cs | 22 + .../Model/Interactions/InteractionTrigger.cs | 21 + .../Interactions/InteractionTriggerKind.cs | 42 + .../IntervalInteractionTrigger.cs | 14 + .../Model/Interactions/NavigateAction.cs | 14 + .../Model/Interactions/NavigateBackAction.cs | 9 + .../Model/Interactions/NotificationLevel.cs | 20 + .../DotNET/Model/Interactions/NotifyAction.cs | 11 + .../Model/Interactions/OpenDialogAction.cs | 10 + .../Model/Interactions/RaiseTriggerAction.cs | 10 + .../Model/Interactions/RefreshQueryAction.cs | 10 + .../Model/Interactions/SetStateAction.cs | 13 + Source/DotNET/Model/Layouts/Layout.cs | 10 +- Source/DotNET/Model/Screens/DialogTemplate.cs | 9 +- Source/DotNET/Model/Screens/Screen.cs | 9 +- Source/DotNET/Model/Screens/ScreenTemplate.cs | 9 +- .../JavaScript/model/elements/SceneElement.ts | 9 +- Source/JavaScript/model/forms/Form.ts | 8 +- Source/JavaScript/model/index.ts | 1 + .../ApplicationTriggerInteractionTrigger.ts | 13 + .../JavaScript/model/interactions/Behavior.ts | 19 + .../interactions/BuiltInInteractionTrigger.ts | 14 + .../model/interactions/CloseDialogAction.ts | 9 + .../model/interactions/ConfirmAction.ts | 14 + .../interactions/EventInteractionTrigger.ts | 13 + .../interactions/ExecuteCommandAction.ts | 13 + .../model/interactions/InteractionAction.ts | 20 + .../model/interactions/InteractionArgument.ts | 14 + .../model/interactions/InteractionBinding.ts | 17 + .../model/interactions/InteractionMessage.ts | 16 + .../model/interactions/InteractionTrigger.ts | 10 + .../interactions/InteractionTriggerKind.ts | 20 + .../IntervalInteractionTrigger.ts | 13 + .../model/interactions/NavigateAction.ts | 13 + .../model/interactions/NavigateBackAction.ts | 9 + .../model/interactions/NotificationLevel.ts | 13 + .../model/interactions/NotifyAction.ts | 16 + .../model/interactions/OpenDialogAction.ts | 13 + .../model/interactions/RaiseTriggerAction.ts | 13 + .../model/interactions/RefreshQueryAction.ts | 13 + .../model/interactions/SetStateAction.ts | 15 + Source/JavaScript/model/interactions/index.ts | 25 + Source/JavaScript/model/layouts/Layout.ts | 8 +- .../model/screens/DialogTemplate.ts | 7 + Source/JavaScript/model/screens/Screen.ts | 8 +- .../model/screens/ScreenTemplate.ts | 7 + scene-model-shape.json | 752 ++++++++++-------- 59 files changed, 1212 insertions(+), 341 deletions(-) create mode 100644 Source/DotNET/Model/Interactions/ApplicationTriggerInteractionTrigger.cs create mode 100644 Source/DotNET/Model/Interactions/Behavior.cs create mode 100644 Source/DotNET/Model/Interactions/BuiltInInteractionTrigger.cs create mode 100644 Source/DotNET/Model/Interactions/CloseDialogAction.cs create mode 100644 Source/DotNET/Model/Interactions/ConfirmAction.cs create mode 100644 Source/DotNET/Model/Interactions/EventInteractionTrigger.cs create mode 100644 Source/DotNET/Model/Interactions/ExecuteCommandAction.cs create mode 100644 Source/DotNET/Model/Interactions/InteractionAction.cs create mode 100644 Source/DotNET/Model/Interactions/InteractionArgument.cs create mode 100644 Source/DotNET/Model/Interactions/InteractionBinding.cs create mode 100644 Source/DotNET/Model/Interactions/InteractionMessage.cs create mode 100644 Source/DotNET/Model/Interactions/InteractionTrigger.cs create mode 100644 Source/DotNET/Model/Interactions/InteractionTriggerKind.cs create mode 100644 Source/DotNET/Model/Interactions/IntervalInteractionTrigger.cs create mode 100644 Source/DotNET/Model/Interactions/NavigateAction.cs create mode 100644 Source/DotNET/Model/Interactions/NavigateBackAction.cs create mode 100644 Source/DotNET/Model/Interactions/NotificationLevel.cs create mode 100644 Source/DotNET/Model/Interactions/NotifyAction.cs create mode 100644 Source/DotNET/Model/Interactions/OpenDialogAction.cs create mode 100644 Source/DotNET/Model/Interactions/RaiseTriggerAction.cs create mode 100644 Source/DotNET/Model/Interactions/RefreshQueryAction.cs create mode 100644 Source/DotNET/Model/Interactions/SetStateAction.cs create mode 100644 Source/JavaScript/model/interactions/ApplicationTriggerInteractionTrigger.ts create mode 100644 Source/JavaScript/model/interactions/Behavior.ts create mode 100644 Source/JavaScript/model/interactions/BuiltInInteractionTrigger.ts create mode 100644 Source/JavaScript/model/interactions/CloseDialogAction.ts create mode 100644 Source/JavaScript/model/interactions/ConfirmAction.ts create mode 100644 Source/JavaScript/model/interactions/EventInteractionTrigger.ts create mode 100644 Source/JavaScript/model/interactions/ExecuteCommandAction.ts create mode 100644 Source/JavaScript/model/interactions/InteractionAction.ts create mode 100644 Source/JavaScript/model/interactions/InteractionArgument.ts create mode 100644 Source/JavaScript/model/interactions/InteractionBinding.ts create mode 100644 Source/JavaScript/model/interactions/InteractionMessage.ts create mode 100644 Source/JavaScript/model/interactions/InteractionTrigger.ts create mode 100644 Source/JavaScript/model/interactions/InteractionTriggerKind.ts create mode 100644 Source/JavaScript/model/interactions/IntervalInteractionTrigger.ts create mode 100644 Source/JavaScript/model/interactions/NavigateAction.ts create mode 100644 Source/JavaScript/model/interactions/NavigateBackAction.ts create mode 100644 Source/JavaScript/model/interactions/NotificationLevel.ts create mode 100644 Source/JavaScript/model/interactions/NotifyAction.ts create mode 100644 Source/JavaScript/model/interactions/OpenDialogAction.ts create mode 100644 Source/JavaScript/model/interactions/RaiseTriggerAction.ts create mode 100644 Source/JavaScript/model/interactions/RefreshQueryAction.ts create mode 100644 Source/JavaScript/model/interactions/SetStateAction.ts create mode 100644 Source/JavaScript/model/interactions/index.ts diff --git a/Source/DotNET/Model/Elements/SceneElement.cs b/Source/DotNET/Model/Elements/SceneElement.cs index bdc2c9ef..4956ed10 100644 --- a/Source/DotNET/Model/Elements/SceneElement.cs +++ b/Source/DotNET/Model/Elements/SceneElement.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Cratis.Scene.Model.Elements.Panels; +using Cratis.Scene.Model.Interactions; namespace Cratis.Scene.Model.Elements; @@ -29,4 +30,13 @@ public abstract record SceneElement /// Gets an open bag of properties not otherwise captured by the typed hierarchy below this type. /// public IReadOnlyDictionary Properties { get; init; } = new Dictionary(); + + /// + /// Gets the behaviors attached to the element - what happens when someone interacts with it. + /// + /// + /// On the element rather than on a wrapper, because interaction belongs to the thing being interacted with. + /// Attachments are additive with whatever a screen, template, layout, module or feature attached further out. + /// + public IReadOnlyList Behaviors { get; init; } = []; } diff --git a/Source/DotNET/Model/Forms/Form.cs b/Source/DotNET/Model/Forms/Form.cs index 132a60c6..9da2022b 100644 --- a/Source/DotNET/Model/Forms/Form.cs +++ b/Source/DotNET/Model/Forms/Form.cs @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Scene.Model.Interactions; + namespace Cratis.Scene.Model.Forms; /// @@ -10,4 +12,10 @@ namespace Cratis.Scene.Model.Forms; /// The resolved name of the command the form builds. /// Where the form's initial values come from, if any. /// The form's fields. -public record Form(string Name, string ForCommand, PopulateSource? PopulateSource, IReadOnlyList Fields); +public record Form(string Name, string ForCommand, PopulateSource? PopulateSource, IReadOnlyList Fields) +{ + /// + /// Gets the behaviors attached to the form. Additive with whatever is attached further out or further in. + /// + public IReadOnlyList Behaviors { get; init; } = []; +} diff --git a/Source/DotNET/Model/Interactions/ApplicationTriggerInteractionTrigger.cs b/Source/DotNET/Model/Interactions/ApplicationTriggerInteractionTrigger.cs new file mode 100644 index 00000000..5688b7b6 --- /dev/null +++ b/Source/DotNET/Model/Interactions/ApplicationTriggerInteractionTrigger.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents an interaction started by a declared application trigger firing. +/// +/// The declared application trigger. +public record ApplicationTriggerInteractionTrigger(string TriggerName) : InteractionTrigger; diff --git a/Source/DotNET/Model/Interactions/Behavior.cs b/Source/DotNET/Model/Interactions/Behavior.cs new file mode 100644 index 00000000..a2d8f561 --- /dev/null +++ b/Source/DotNET/Model/Interactions/Behavior.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents a bundle of interaction trigger to action bindings, attached to something that can be +/// interacted with. +/// +/// The behavior's name, or when it was written inline on what it is attached to. +/// The trigger to action bindings. +/// Where this behavior runs among the others attached to the same thing, when it says. +/// +/// A named behavior and an inline one are the same shape - anonymity is a property rather than a separate kind +/// of node - so the engine has one resolution path and a renderer has one thing to subscribe to. +/// +/// Attachments are additive: a behavior on a template and a behavior on an element both run, outermost first, +/// unless says otherwise. +/// +/// +public record Behavior( + string? Name, + IReadOnlyList Bindings, + int? Order = null); diff --git a/Source/DotNET/Model/Interactions/BuiltInInteractionTrigger.cs b/Source/DotNET/Model/Interactions/BuiltInInteractionTrigger.cs new file mode 100644 index 00000000..ab6a73ca --- /dev/null +++ b/Source/DotNET/Model/Interactions/BuiltInInteractionTrigger.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents one of the built-in interaction kinds. +/// +/// The kind. +public record BuiltInInteractionTrigger(InteractionTriggerKind Kind) : InteractionTrigger; diff --git a/Source/DotNET/Model/Interactions/CloseDialogAction.cs b/Source/DotNET/Model/Interactions/CloseDialogAction.cs new file mode 100644 index 00000000..45248984 --- /dev/null +++ b/Source/DotNET/Model/Interactions/CloseDialogAction.cs @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Dismisses the innermost dialog. +/// +public record CloseDialogAction : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/ConfirmAction.cs b/Source/DotNET/Model/Interactions/ConfirmAction.cs new file mode 100644 index 00000000..31a00a67 --- /dev/null +++ b/Source/DotNET/Model/Interactions/ConfirmAction.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Gates the actions that follow on the user agreeing. +/// +/// What to ask. +public record ConfirmAction(InteractionMessage Message) : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/EventInteractionTrigger.cs b/Source/DotNET/Model/Interactions/EventInteractionTrigger.cs new file mode 100644 index 00000000..b79ed302 --- /dev/null +++ b/Source/DotNET/Model/Interactions/EventInteractionTrigger.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents an interaction started by observing a modeled domain event. +/// +/// The event being observed. +public record EventInteractionTrigger(string EventName) : InteractionTrigger; diff --git a/Source/DotNET/Model/Interactions/ExecuteCommandAction.cs b/Source/DotNET/Model/Interactions/ExecuteCommandAction.cs new file mode 100644 index 00000000..ace3a5ad --- /dev/null +++ b/Source/DotNET/Model/Interactions/ExecuteCommandAction.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Submits a modeled command. +/// +/// The command to submit. +public record ExecuteCommandAction(string Command) : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/InteractionAction.cs b/Source/DotNET/Model/Interactions/InteractionAction.cs new file mode 100644 index 00000000..bad08f49 --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionAction.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents one declarative effect inside a binding. +/// +/// +/// The set is closed, and every operand is a reference to something the model declares rather than a string a +/// renderer interprets. That is what lets an action naming nothing be reported instead of becoming a control +/// that does nothing when clicked. +/// +/// Continuations live on the base so that adding an action kind stays an append rather than a change to every +/// existing record. +/// +/// +[JsonPolymorphic] +[JsonDerivedType(typeof(ExecuteCommandAction))] +[JsonDerivedType(typeof(NavigateAction))] +[JsonDerivedType(typeof(NavigateBackAction))] +[JsonDerivedType(typeof(OpenDialogAction))] +[JsonDerivedType(typeof(CloseDialogAction))] +[JsonDerivedType(typeof(RefreshQueryAction))] +[JsonDerivedType(typeof(SetStateAction))] +[JsonDerivedType(typeof(NotifyAction))] +[JsonDerivedType(typeof(ConfirmAction))] +[JsonDerivedType(typeof(RaiseTriggerAction))] +public abstract record InteractionAction +{ + /// + /// Gets the arguments supplied to the action. + /// + public IReadOnlyList Arguments { get; init; } = []; + + /// + /// Gets the actions that run when this one succeeds. + /// + public IReadOnlyList OnSuccess { get; init; } = []; + + /// + /// Gets the actions that run when this one fails. + /// + public IReadOnlyList OnFailure { get; init; } = []; + + /// + /// Gets the actions that run with a dialog's result. Only carries these. + /// + public IReadOnlyList OnResult { get; init; } = []; +} diff --git a/Source/DotNET/Model/Interactions/InteractionArgument.cs b/Source/DotNET/Model/Interactions/InteractionArgument.cs new file mode 100644 index 00000000..b8f51969 --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionArgument.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents one argument passed to an action. +/// +/// The argument name on the target. +/// Where the value comes from. +public record InteractionArgument(string Name, BindingExpression Value); diff --git a/Source/DotNET/Model/Interactions/InteractionBinding.cs b/Source/DotNET/Model/Interactions/InteractionBinding.cs new file mode 100644 index 00000000..76022af3 --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionBinding.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents one interaction trigger to actions binding. +/// +/// What starts it. +/// The actions to run, in declared order. +/// The guard that has to hold for the actions to run, when there is one. +public record InteractionBinding( + InteractionTrigger Trigger, + IReadOnlyList Actions, + BindingExpression? Condition = null); diff --git a/Source/DotNET/Model/Interactions/InteractionMessage.cs b/Source/DotNET/Model/Interactions/InteractionMessage.cs new file mode 100644 index 00000000..a6545003 --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionMessage.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents the text an action shows: a literal, a localization key, or a binding resolved at the moment the +/// action runs. +/// +/// The literal text, when the message is one. +/// The localization key, when the message is one. +/// The binding to resolve, when the message is one. +/// +/// Exactly one is set. Kept as three explicit members rather than one string a consumer has to sniff, because +/// 'does this start with $strings.' is the kind of rule that ends up implemented differently in every renderer. +/// +public record InteractionMessage( + string? Text = null, + string? StringsKey = null, + BindingExpression? Binding = null); diff --git a/Source/DotNET/Model/Interactions/InteractionTrigger.cs b/Source/DotNET/Model/Interactions/InteractionTrigger.cs new file mode 100644 index 00000000..2bab652b --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionTrigger.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents what starts an interaction. +/// +/// +/// An interaction trigger is never declared - it exists only as part of a binding. That makes it a different +/// concept from the application trigger a backend reaction consumes, which +/// refers to by name. +/// +[JsonPolymorphic] +[JsonDerivedType(typeof(BuiltInInteractionTrigger))] +[JsonDerivedType(typeof(EventInteractionTrigger))] +[JsonDerivedType(typeof(IntervalInteractionTrigger))] +[JsonDerivedType(typeof(ApplicationTriggerInteractionTrigger))] +public abstract record InteractionTrigger; diff --git a/Source/DotNET/Model/Interactions/InteractionTriggerKind.cs b/Source/DotNET/Model/Interactions/InteractionTriggerKind.cs new file mode 100644 index 00000000..858a105d --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionTriggerKind.cs @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Defines the built-in interaction kinds - what a user or a lifecycle did, rather than something the +/// document declared. +/// +/// +/// Append only. A renderer that does not know a member reports it rather than guessing, so members are never +/// renumbered or reordered. +/// +public enum InteractionTriggerKind +{ + /// The element was activated - clicked, tapped, or confirmed from the keyboard. + Click = 0, + + /// The element was activated twice. + DoubleClick = 1, + + /// Selection changed in an items control. + Select = 2, + + /// A form was submitted and passed the modeled validation. + Submit = 3, + + /// A bound value changed. + Change = 4, + + /// The element or screen became live. + Load = 5, + + /// The element or screen was torn down. + Unload = 6, + + /// The screen was navigated to. + Enter = 7, + + /// The screen was navigated away from. + Leave = 8 +} diff --git a/Source/DotNET/Model/Interactions/IntervalInteractionTrigger.cs b/Source/DotNET/Model/Interactions/IntervalInteractionTrigger.cs new file mode 100644 index 00000000..f0a76035 --- /dev/null +++ b/Source/DotNET/Model/Interactions/IntervalInteractionTrigger.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Represents an interaction started by an elapsed interval. +/// +/// How long elapses between occurrences, normalized to seconds. +/// +/// Normalized at conversion so nothing downstream has to carry a unit or convert one. A renderer schedules +/// seconds; it does not need to know the document said 'interval 5 minutes'. +/// +public record IntervalInteractionTrigger(int Seconds) : InteractionTrigger; diff --git a/Source/DotNET/Model/Interactions/NavigateAction.cs b/Source/DotNET/Model/Interactions/NavigateAction.cs new file mode 100644 index 00000000..a9b52be3 --- /dev/null +++ b/Source/DotNET/Model/Interactions/NavigateAction.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Changes the active screen. +/// +/// The screen to navigate to. +/// +/// The screen is named, never addressed. Turning a screen reference into a URL is a renderer's job, so this +/// model stays meaningful to a renderer that has no URLs at all. +/// +public record NavigateAction(string Screen) : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/NavigateBackAction.cs b/Source/DotNET/Model/Interactions/NavigateBackAction.cs new file mode 100644 index 00000000..f75bc911 --- /dev/null +++ b/Source/DotNET/Model/Interactions/NavigateBackAction.cs @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Pops navigation history. +/// +public record NavigateBackAction : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/NotificationLevel.cs b/Source/DotNET/Model/Interactions/NotificationLevel.cs new file mode 100644 index 00000000..df60d572 --- /dev/null +++ b/Source/DotNET/Model/Interactions/NotificationLevel.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Defines how prominently a surfaces its message. +/// +/// Append only. +public enum NotificationLevel +{ + /// Informational. + Info = 0, + + /// A warning. + Warning = 1, + + /// An error. + Error = 2 +} diff --git a/Source/DotNET/Model/Interactions/NotifyAction.cs b/Source/DotNET/Model/Interactions/NotifyAction.cs new file mode 100644 index 00000000..141acc8b --- /dev/null +++ b/Source/DotNET/Model/Interactions/NotifyAction.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Surfaces a message. +/// +/// How prominently to surface it. +/// The message. +public record NotifyAction(NotificationLevel Level, InteractionMessage Message) : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/OpenDialogAction.cs b/Source/DotNET/Model/Interactions/OpenDialogAction.cs new file mode 100644 index 00000000..2ff86dbf --- /dev/null +++ b/Source/DotNET/Model/Interactions/OpenDialogAction.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Opens a dialog over the application. +/// +/// The dialog template to open. +public record OpenDialogAction(string DialogTemplate) : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/RaiseTriggerAction.cs b/Source/DotNET/Model/Interactions/RaiseTriggerAction.cs new file mode 100644 index 00000000..b922cc08 --- /dev/null +++ b/Source/DotNET/Model/Interactions/RaiseTriggerAction.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Fires a declared application trigger. +/// +/// The application trigger to fire. +public record RaiseTriggerAction(string Trigger) : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/RefreshQueryAction.cs b/Source/DotNET/Model/Interactions/RefreshQueryAction.cs new file mode 100644 index 00000000..da14055e --- /dev/null +++ b/Source/DotNET/Model/Interactions/RefreshQueryAction.cs @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Re-runs a query-backed element. +/// +/// The query to re-run. +public record RefreshQueryAction(string Query) : InteractionAction; diff --git a/Source/DotNET/Model/Interactions/SetStateAction.cs b/Source/DotNET/Model/Interactions/SetStateAction.cs new file mode 100644 index 00000000..7ed54bf9 --- /dev/null +++ b/Source/DotNET/Model/Interactions/SetStateAction.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Common; + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Writes screen state. +/// +/// The declared state name being written. +/// The value to write. +public record SetStateAction(string Target, BindingExpression Value) : InteractionAction; diff --git a/Source/DotNET/Model/Layouts/Layout.cs b/Source/DotNET/Model/Layouts/Layout.cs index 2af1a2eb..fa07fe96 100644 --- a/Source/DotNET/Model/Layouts/Layout.cs +++ b/Source/DotNET/Model/Layouts/Layout.cs @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Scene.Model.Interactions; + namespace Cratis.Scene.Model.Layouts; /// @@ -24,4 +26,10 @@ namespace Cratis.Scene.Model.Layouts; /// 's own , which positions that one slot's filled /// content instead of positioning the slots themselves. /// -public record Layout(string Name, IReadOnlyList Slots, Arrangement? Arrangement = null); +public record Layout(string Name, IReadOnlyList Slots, Arrangement? Arrangement = null) +{ + /// + /// Gets the behaviors attached to the layout. Additive with whatever is attached further out or further in. + /// + public IReadOnlyList Behaviors { get; init; } = []; +} diff --git a/Source/DotNET/Model/Screens/DialogTemplate.cs b/Source/DotNET/Model/Screens/DialogTemplate.cs index bd3c45be..27452505 100644 --- a/Source/DotNET/Model/Screens/DialogTemplate.cs +++ b/Source/DotNET/Model/Screens/DialogTemplate.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Scene.Model.Elements; +using Cratis.Scene.Model.Interactions; using Cratis.Scene.Model.Layouts; namespace Cratis.Scene.Model.Screens; @@ -31,4 +32,10 @@ public record DialogTemplate( Arrangement? Arrangement = null, IReadOnlyDictionary>? Content = null, string? DisplayName = null, - string? Description = null); + string? Description = null) +{ + /// + /// Gets the behaviors attached to the dialog template. Additive with whatever is attached further out or further in. + /// + public IReadOnlyList Behaviors { get; init; } = []; +} diff --git a/Source/DotNET/Model/Screens/Screen.cs b/Source/DotNET/Model/Screens/Screen.cs index 05dd492c..aab76523 100644 --- a/Source/DotNET/Model/Screens/Screen.cs +++ b/Source/DotNET/Model/Screens/Screen.cs @@ -4,6 +4,7 @@ using Cratis.Scene.Model.ContributionPoints; using Cratis.Scene.Model.Elements; using Cratis.Scene.Model.Forms; +using Cratis.Scene.Model.Interactions; namespace Cratis.Scene.Model.Screens; @@ -34,4 +35,10 @@ public record Screen( IReadOnlyDictionary> SlotContent, IReadOnlyList
Forms, IReadOnlyList Contributions, - string? ScreenTemplate = null); + string? ScreenTemplate = null) +{ + /// + /// Gets the behaviors attached to the screen. Additive with whatever is attached further out or further in. + /// + public IReadOnlyList Behaviors { get; init; } = []; +} diff --git a/Source/DotNET/Model/Screens/ScreenTemplate.cs b/Source/DotNET/Model/Screens/ScreenTemplate.cs index f5a0fb1c..67b38e28 100644 --- a/Source/DotNET/Model/Screens/ScreenTemplate.cs +++ b/Source/DotNET/Model/Screens/ScreenTemplate.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Scene.Model.Elements; +using Cratis.Scene.Model.Interactions; using Cratis.Scene.Model.Layouts; namespace Cratis.Scene.Model.Screens; @@ -51,4 +52,10 @@ public record ScreenTemplate( Arrangement? Arrangement = null, IReadOnlyDictionary>? Content = null, string? DisplayName = null, - string? Description = null); + string? Description = null) +{ + /// + /// Gets the behaviors attached to the screen template. Additive with whatever is attached further out or further in. + /// + public IReadOnlyList Behaviors { get; init; } = []; +} diff --git a/Source/JavaScript/model/elements/SceneElement.ts b/Source/JavaScript/model/elements/SceneElement.ts index cee0d326..2e550470 100644 --- a/Source/JavaScript/model/elements/SceneElement.ts +++ b/Source/JavaScript/model/elements/SceneElement.ts @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { Behavior } from '../interactions'; + /** * The root of every node in a Scene element tree. Named `SceneElement` rather than `Element` to avoid * colliding with the DOM's own global `Element` type, which every consumer of this package also has in scope. @@ -8,6 +10,11 @@ export interface SceneElement { id: string; properties: Record; + /** + * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is + * attached further out or further in. + */ + behaviors: Behavior[]; } -export const SceneElementPropertyNames: (keyof SceneElement)[] = ['id', 'properties']; +export const SceneElementPropertyNames: (keyof SceneElement)[] = ['id', 'properties', 'behaviors']; diff --git a/Source/JavaScript/model/forms/Form.ts b/Source/JavaScript/model/forms/Form.ts index 49bb726f..51415518 100644 --- a/Source/JavaScript/model/forms/Form.ts +++ b/Source/JavaScript/model/forms/Form.ts @@ -3,6 +3,7 @@ import { PopulateSource } from './PopulateSource'; import { FormField } from './FormField'; +import { Behavior } from '../interactions'; /** * The command-side counterpart to a table or summary: a named, typed form for one command. @@ -12,6 +13,11 @@ export interface Form { forCommand: string; populateSource?: PopulateSource; fields: FormField[]; + /** + * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is + * attached further out or further in. + */ + behaviors: Behavior[]; } -export const FormPropertyNames: (keyof Form)[] = ['name', 'forCommand', 'populateSource', 'fields']; +export const FormPropertyNames: (keyof Form)[] = ['name', 'forCommand', 'populateSource', 'fields', 'behaviors']; diff --git a/Source/JavaScript/model/index.ts b/Source/JavaScript/model/index.ts index c12c99a0..10826d60 100644 --- a/Source/JavaScript/model/index.ts +++ b/Source/JavaScript/model/index.ts @@ -9,5 +9,6 @@ export * from './forms'; export * from './contributionPoints'; export * from './profiles'; export * from './packages'; +export * from './interactions'; export * from './screens'; export * from './starters'; diff --git a/Source/JavaScript/model/interactions/ApplicationTriggerInteractionTrigger.ts b/Source/JavaScript/model/interactions/ApplicationTriggerInteractionTrigger.ts new file mode 100644 index 00000000..aa1c85e4 --- /dev/null +++ b/Source/JavaScript/model/interactions/ApplicationTriggerInteractionTrigger.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionTrigger } from './InteractionTrigger'; + +/** + * An interaction started by a declared application trigger firing. + */ +export interface ApplicationTriggerInteractionTrigger extends InteractionTrigger { + triggerName: string; +} + +export const ApplicationTriggerInteractionTriggerPropertyNames: (keyof ApplicationTriggerInteractionTrigger)[] = ['triggerName']; diff --git a/Source/JavaScript/model/interactions/Behavior.ts b/Source/JavaScript/model/interactions/Behavior.ts new file mode 100644 index 00000000..9f5b718c --- /dev/null +++ b/Source/JavaScript/model/interactions/Behavior.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionBinding } from './InteractionBinding'; + +/** + * A bundle of interaction trigger to action bindings, attached to something that can be interacted with. + * + * A named behavior and an inline one are the same shape - anonymity is a property rather than a separate kind + * of node. Attachments are additive: a behavior on a template and one on an element both run, outermost first, + * unless the order says otherwise. + */ +export interface Behavior { + name?: string; + bindings: InteractionBinding[]; + order?: number; +} + +export const BehaviorPropertyNames: (keyof Behavior)[] = ['name', 'bindings', 'order']; diff --git a/Source/JavaScript/model/interactions/BuiltInInteractionTrigger.ts b/Source/JavaScript/model/interactions/BuiltInInteractionTrigger.ts new file mode 100644 index 00000000..d5dd73a9 --- /dev/null +++ b/Source/JavaScript/model/interactions/BuiltInInteractionTrigger.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionTrigger } from './InteractionTrigger'; +import { InteractionTriggerKind } from './InteractionTriggerKind'; + +/** + * One of the built-in interaction kinds. + */ +export interface BuiltInInteractionTrigger extends InteractionTrigger { + kind: InteractionTriggerKind; +} + +export const BuiltInInteractionTriggerPropertyNames: (keyof BuiltInInteractionTrigger)[] = ['kind']; diff --git a/Source/JavaScript/model/interactions/CloseDialogAction.ts b/Source/JavaScript/model/interactions/CloseDialogAction.ts new file mode 100644 index 00000000..7472ddd3 --- /dev/null +++ b/Source/JavaScript/model/interactions/CloseDialogAction.ts @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; + +/** + * Dismisses the innermost dialog. + */ +export interface CloseDialogAction extends InteractionAction {} diff --git a/Source/JavaScript/model/interactions/ConfirmAction.ts b/Source/JavaScript/model/interactions/ConfirmAction.ts new file mode 100644 index 00000000..ce8a9810 --- /dev/null +++ b/Source/JavaScript/model/interactions/ConfirmAction.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; +import { InteractionMessage } from './InteractionMessage'; + +/** + * Gates the actions that follow on the user agreeing. + */ +export interface ConfirmAction extends InteractionAction { + message: InteractionMessage; +} + +export const ConfirmActionPropertyNames: (keyof ConfirmAction)[] = ['message']; diff --git a/Source/JavaScript/model/interactions/EventInteractionTrigger.ts b/Source/JavaScript/model/interactions/EventInteractionTrigger.ts new file mode 100644 index 00000000..0caf26a6 --- /dev/null +++ b/Source/JavaScript/model/interactions/EventInteractionTrigger.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionTrigger } from './InteractionTrigger'; + +/** + * An interaction started by observing a modeled domain event. + */ +export interface EventInteractionTrigger extends InteractionTrigger { + eventName: string; +} + +export const EventInteractionTriggerPropertyNames: (keyof EventInteractionTrigger)[] = ['eventName']; diff --git a/Source/JavaScript/model/interactions/ExecuteCommandAction.ts b/Source/JavaScript/model/interactions/ExecuteCommandAction.ts new file mode 100644 index 00000000..2d2c63dc --- /dev/null +++ b/Source/JavaScript/model/interactions/ExecuteCommandAction.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; + +/** + * Submits a modeled command. + */ +export interface ExecuteCommandAction extends InteractionAction { + command: string; +} + +export const ExecuteCommandActionPropertyNames: (keyof ExecuteCommandAction)[] = ['command']; diff --git a/Source/JavaScript/model/interactions/InteractionAction.ts b/Source/JavaScript/model/interactions/InteractionAction.ts new file mode 100644 index 00000000..909e4d5b --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionAction.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionArgument } from './InteractionArgument'; + +/** + * One declarative effect inside a binding. + * + * The set is closed and every operand references something the model declares rather than being a string a + * renderer interprets - that is what lets an action naming nothing be reported instead of becoming a control + * that does nothing when clicked. + */ +export interface InteractionAction { + arguments: InteractionArgument[]; + onSuccess: InteractionAction[]; + onFailure: InteractionAction[]; + onResult: InteractionAction[]; +} + +export const InteractionActionPropertyNames: (keyof InteractionAction)[] = ['arguments', 'onSuccess', 'onFailure', 'onResult']; diff --git a/Source/JavaScript/model/interactions/InteractionArgument.ts b/Source/JavaScript/model/interactions/InteractionArgument.ts new file mode 100644 index 00000000..e3316fed --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionArgument.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression } from '../common'; + +/** + * One argument passed to an action. + */ +export interface InteractionArgument { + name: string; + value: BindingExpression; +} + +export const InteractionArgumentPropertyNames: (keyof InteractionArgument)[] = ['name', 'value']; diff --git a/Source/JavaScript/model/interactions/InteractionBinding.ts b/Source/JavaScript/model/interactions/InteractionBinding.ts new file mode 100644 index 00000000..b6d79e14 --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionBinding.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression } from '../common'; +import { InteractionAction } from './InteractionAction'; +import { InteractionTrigger } from './InteractionTrigger'; + +/** + * One interaction trigger to actions binding. + */ +export interface InteractionBinding { + trigger: InteractionTrigger; + actions: InteractionAction[]; + condition?: BindingExpression; +} + +export const InteractionBindingPropertyNames: (keyof InteractionBinding)[] = ['trigger', 'actions', 'condition']; diff --git a/Source/JavaScript/model/interactions/InteractionMessage.ts b/Source/JavaScript/model/interactions/InteractionMessage.ts new file mode 100644 index 00000000..8efc9552 --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionMessage.ts @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression } from '../common'; + +/** + * The text an action shows: a literal, a localization key, or a binding resolved when the action runs. Exactly + * one is set. + */ +export interface InteractionMessage { + text?: string; + stringsKey?: string; + binding?: BindingExpression; +} + +export const InteractionMessagePropertyNames: (keyof InteractionMessage)[] = ['text', 'stringsKey', 'binding']; diff --git a/Source/JavaScript/model/interactions/InteractionTrigger.ts b/Source/JavaScript/model/interactions/InteractionTrigger.ts new file mode 100644 index 00000000..3ea8218d --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionTrigger.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * What starts an interaction. + * + * Never declared - it exists only as part of a binding, which is what makes it a different concept from the + * application trigger a backend reaction consumes. + */ +export interface InteractionTrigger {} diff --git a/Source/JavaScript/model/interactions/InteractionTriggerKind.ts b/Source/JavaScript/model/interactions/InteractionTriggerKind.ts new file mode 100644 index 00000000..8b64b2d3 --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionTriggerKind.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The built-in interaction kinds - what a user or a lifecycle did, rather than something the document declared. + * + * Append only: a renderer that does not know a member reports it rather than guessing, so members are never + * renumbered or reordered. + */ +export enum InteractionTriggerKind { + Click = 'Click', + DoubleClick = 'DoubleClick', + Select = 'Select', + Submit = 'Submit', + Change = 'Change', + Load = 'Load', + Unload = 'Unload', + Enter = 'Enter', + Leave = 'Leave', +} diff --git a/Source/JavaScript/model/interactions/IntervalInteractionTrigger.ts b/Source/JavaScript/model/interactions/IntervalInteractionTrigger.ts new file mode 100644 index 00000000..c2bbebbf --- /dev/null +++ b/Source/JavaScript/model/interactions/IntervalInteractionTrigger.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionTrigger } from './InteractionTrigger'; + +/** + * An interaction started by an elapsed interval, normalized to seconds so nothing downstream carries a unit. + */ +export interface IntervalInteractionTrigger extends InteractionTrigger { + seconds: number; +} + +export const IntervalInteractionTriggerPropertyNames: (keyof IntervalInteractionTrigger)[] = ['seconds']; diff --git a/Source/JavaScript/model/interactions/NavigateAction.ts b/Source/JavaScript/model/interactions/NavigateAction.ts new file mode 100644 index 00000000..20a9f4c2 --- /dev/null +++ b/Source/JavaScript/model/interactions/NavigateAction.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; + +/** + * Changes the active screen. The screen is named, never addressed - turning that into a URL is a renderer's job. + */ +export interface NavigateAction extends InteractionAction { + screen: string; +} + +export const NavigateActionPropertyNames: (keyof NavigateAction)[] = ['screen']; diff --git a/Source/JavaScript/model/interactions/NavigateBackAction.ts b/Source/JavaScript/model/interactions/NavigateBackAction.ts new file mode 100644 index 00000000..5144d7c9 --- /dev/null +++ b/Source/JavaScript/model/interactions/NavigateBackAction.ts @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; + +/** + * Pops navigation history. + */ +export interface NavigateBackAction extends InteractionAction {} diff --git a/Source/JavaScript/model/interactions/NotificationLevel.ts b/Source/JavaScript/model/interactions/NotificationLevel.ts new file mode 100644 index 00000000..47ec8ec6 --- /dev/null +++ b/Source/JavaScript/model/interactions/NotificationLevel.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * How prominently a {@link NotifyAction} surfaces its message. + * + * Append only. + */ +export enum NotificationLevel { + Info = 'Info', + Warning = 'Warning', + Error = 'Error', +} diff --git a/Source/JavaScript/model/interactions/NotifyAction.ts b/Source/JavaScript/model/interactions/NotifyAction.ts new file mode 100644 index 00000000..5507e1a4 --- /dev/null +++ b/Source/JavaScript/model/interactions/NotifyAction.ts @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; +import { InteractionMessage } from './InteractionMessage'; +import { NotificationLevel } from './NotificationLevel'; + +/** + * Surfaces a message. + */ +export interface NotifyAction extends InteractionAction { + level: NotificationLevel; + message: InteractionMessage; +} + +export const NotifyActionPropertyNames: (keyof NotifyAction)[] = ['level', 'message']; diff --git a/Source/JavaScript/model/interactions/OpenDialogAction.ts b/Source/JavaScript/model/interactions/OpenDialogAction.ts new file mode 100644 index 00000000..30d1b354 --- /dev/null +++ b/Source/JavaScript/model/interactions/OpenDialogAction.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; + +/** + * Opens a dialog over the application. + */ +export interface OpenDialogAction extends InteractionAction { + dialogTemplate: string; +} + +export const OpenDialogActionPropertyNames: (keyof OpenDialogAction)[] = ['dialogTemplate']; diff --git a/Source/JavaScript/model/interactions/RaiseTriggerAction.ts b/Source/JavaScript/model/interactions/RaiseTriggerAction.ts new file mode 100644 index 00000000..46f90793 --- /dev/null +++ b/Source/JavaScript/model/interactions/RaiseTriggerAction.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; + +/** + * Fires a declared application trigger. + */ +export interface RaiseTriggerAction extends InteractionAction { + trigger: string; +} + +export const RaiseTriggerActionPropertyNames: (keyof RaiseTriggerAction)[] = ['trigger']; diff --git a/Source/JavaScript/model/interactions/RefreshQueryAction.ts b/Source/JavaScript/model/interactions/RefreshQueryAction.ts new file mode 100644 index 00000000..029350f0 --- /dev/null +++ b/Source/JavaScript/model/interactions/RefreshQueryAction.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from './InteractionAction'; + +/** + * Re-runs a query-backed element. + */ +export interface RefreshQueryAction extends InteractionAction { + query: string; +} + +export const RefreshQueryActionPropertyNames: (keyof RefreshQueryAction)[] = ['query']; diff --git a/Source/JavaScript/model/interactions/SetStateAction.ts b/Source/JavaScript/model/interactions/SetStateAction.ts new file mode 100644 index 00000000..9502fab7 --- /dev/null +++ b/Source/JavaScript/model/interactions/SetStateAction.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression } from '../common'; +import { InteractionAction } from './InteractionAction'; + +/** + * Writes screen state. + */ +export interface SetStateAction extends InteractionAction { + target: string; + value: BindingExpression; +} + +export const SetStateActionPropertyNames: (keyof SetStateAction)[] = ['target', 'value']; diff --git a/Source/JavaScript/model/interactions/index.ts b/Source/JavaScript/model/interactions/index.ts new file mode 100644 index 00000000..2b14d756 --- /dev/null +++ b/Source/JavaScript/model/interactions/index.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './InteractionTriggerKind'; +export * from './NotificationLevel'; +export * from './InteractionTrigger'; +export * from './BuiltInInteractionTrigger'; +export * from './EventInteractionTrigger'; +export * from './IntervalInteractionTrigger'; +export * from './ApplicationTriggerInteractionTrigger'; +export * from './InteractionMessage'; +export * from './InteractionArgument'; +export * from './InteractionAction'; +export * from './ExecuteCommandAction'; +export * from './NavigateAction'; +export * from './NavigateBackAction'; +export * from './OpenDialogAction'; +export * from './CloseDialogAction'; +export * from './RefreshQueryAction'; +export * from './SetStateAction'; +export * from './NotifyAction'; +export * from './ConfirmAction'; +export * from './RaiseTriggerAction'; +export * from './InteractionBinding'; +export * from './Behavior'; diff --git a/Source/JavaScript/model/layouts/Layout.ts b/Source/JavaScript/model/layouts/Layout.ts index 409e0c15..086090a8 100644 --- a/Source/JavaScript/model/layouts/Layout.ts +++ b/Source/JavaScript/model/layouts/Layout.ts @@ -3,6 +3,7 @@ import { Arrangement } from './Arrangement'; import { Slot } from './Slot'; +import { Behavior } from '../interactions'; /** * A named template of slots that a {@link Screen} fills with content. Mirrors Screenplay's `layout` @@ -21,6 +22,11 @@ export interface Layout { * themselves. */ arrangement?: Arrangement; + /** + * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is + * attached further out or further in. + */ + behaviors: Behavior[]; } -export const LayoutPropertyNames: (keyof Layout)[] = ['name', 'slots', 'arrangement']; +export const LayoutPropertyNames: (keyof Layout)[] = ['name', 'slots', 'arrangement', 'behaviors']; diff --git a/Source/JavaScript/model/screens/DialogTemplate.ts b/Source/JavaScript/model/screens/DialogTemplate.ts index 3d4325cb..9934b7c2 100644 --- a/Source/JavaScript/model/screens/DialogTemplate.ts +++ b/Source/JavaScript/model/screens/DialogTemplate.ts @@ -3,6 +3,7 @@ import { SceneElement } from '../elements'; import { Arrangement, Slot } from '../layouts'; +import { Behavior } from '../interactions'; /** * A reusable dialog structure — the same idea as a {@link ScreenTemplate}, for content that opens over an @@ -45,6 +46,11 @@ export interface DialogTemplate { * A one-line description for a template picker. */ description?: string; + /** + * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is + * attached further out or further in. + */ + behaviors: Behavior[]; } export const DialogTemplatePropertyNames: (keyof DialogTemplate)[] = [ @@ -54,4 +60,5 @@ export const DialogTemplatePropertyNames: (keyof DialogTemplate)[] = [ 'content', 'displayName', 'description', + 'behaviors', ]; diff --git a/Source/JavaScript/model/screens/Screen.ts b/Source/JavaScript/model/screens/Screen.ts index 61d83a6f..44f8f6a4 100644 --- a/Source/JavaScript/model/screens/Screen.ts +++ b/Source/JavaScript/model/screens/Screen.ts @@ -4,6 +4,7 @@ import { SceneElement } from '../elements'; import { Form } from '../forms'; import { Contribution } from '../contributionPoints'; +import { Behavior } from '../interactions'; /** * A named screen: the structure it fills, the content that fills it, the forms it hosts, and whatever it @@ -45,6 +46,11 @@ export interface Screen { * never has to state its own position. */ screenTemplate?: string; + /** + * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is + * attached further out or further in. + */ + behaviors: Behavior[]; } -export const ScreenPropertyNames: (keyof Screen)[] = ['name', 'layout', 'slotContent', 'forms', 'contributions', 'screenTemplate']; +export const ScreenPropertyNames: (keyof Screen)[] = ['name', 'layout', 'slotContent', 'forms', 'contributions', 'screenTemplate', 'behaviors']; diff --git a/Source/JavaScript/model/screens/ScreenTemplate.ts b/Source/JavaScript/model/screens/ScreenTemplate.ts index 2e5861af..d23c3fae 100644 --- a/Source/JavaScript/model/screens/ScreenTemplate.ts +++ b/Source/JavaScript/model/screens/ScreenTemplate.ts @@ -3,6 +3,7 @@ import { SceneElement } from '../elements'; import { Arrangement, Slot } from '../layouts'; +import { Behavior } from '../interactions'; /** * A reusable screen structure that fills a named slot on whatever contains it, and offers slots of its @@ -57,6 +58,11 @@ export interface ScreenTemplate { * A one-line description for a template picker. */ description?: string; + /** + * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is + * attached further out or further in. + */ + behaviors: Behavior[]; } export const ScreenTemplatePropertyNames: (keyof ScreenTemplate)[] = [ @@ -67,4 +73,5 @@ export const ScreenTemplatePropertyNames: (keyof ScreenTemplate)[] = [ 'content', 'displayName', 'description', + 'behaviors', ]; diff --git a/scene-model-shape.json b/scene-model-shape.json index 67d7eaee..2de74bd4 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -1,334 +1,422 @@ { - "description": "Shared shape contract between Cratis.Scene.Model (C#, source of truth) and @cratis/scene.model (the hand-maintained TypeScript mirror). Both a C# spec (Model.Specs/for_SceneModelShape) and a TypeScript spec (model/for_shape) assert their own side's reflected/declared shape against this file independently, so a desync on either side fails its own build rather than only the other one's.", - "types": { - "Color": [ - "red", - "green", - "blue", - "alpha" - ], - "Size": [ - "width", - "height" - ], - "Thickness": [ - "left", - "top", - "right", - "bottom" - ], - "BindingExpression": [ - "path" - ], - "SizeClass": [ - "width", - "height" - ], - "SceneElement": [ - "id", - "properties" - ], - "VisualElement": [ - "visibility", - "isEnabled", - "opacity", - "size", - "zIndex" - ], - "FrameworkElement": [ - "name", - "minimumSize", - "maximumSize", - "margin", - "horizontalAlignment", - "verticalAlignment" - ], - "Control": [ - "background", - "foreground", - "borderColor", - "borderThickness", - "padding", - "fontFamily", - "fontSize", - "tabIndex" - ], - "Panel": [ - "children" - ], - "Canvas": [ - "extent" - ], - "ItemsControl": [ - "itemsSource", - "itemTemplate" - ], - "ContentControl": [ - "content" - ], - "ExternalComponent": [ - "componentName", - "slots" - ], - "Layout": [ - "name", - "slots", - "arrangement" - ], - "Slot": [ - "name", - "arrangement" - ], - "Arrangement": [], - "FlowArrangement": [ - "root", - "overrides" - ], - "FlowOverride": [ - "width", - "height", - "root" - ], - "FlowNode": [ - "grow", - "span" - ], - "FlowContainer": [ - "gap", - "children", - "kind" - ], - "FlowRow": [ - "kind" - ], - "FlowColumn": [ - "kind" - ], - "FlowGrid": [ - "kind", - "columns", - "rows" - ], - "FlowLeaf": [ - "content" - ], - "FlowSlotLeaf": [ - "slotName" - ], - "FreeformArrangement": [ - "variants" - ], - "FreeformVariant": [ - "sizeClass", - "placements" - ], - "ElementPlacement": [ - "element", - "x", - "y", - "width", - "height" - ], - "FreeformSlotArrangement": [ - "variants" - ], - "FreeformSlotVariant": [ - "sizeClass", - "placements" - ], - "SlotPlacement": [ - "slotName", - "x", - "y", - "width", - "height" - ], - "PopulateSource": [], - "PopulateViaQuery": [ - "queryName", - "parameterBindings" - ], - "PopulateFromItem": [ - "itemBinding" - ], - "FormField": [ - "name", - "sourceProperty", - "composeUsing", - "label" - ], - "Form": [ - "name", - "forCommand", - "populateSource", - "fields" - ], - "ContributionPoint": [ - "name" - ], - "Contribution": [ - "contributionPointName", - "content", - "order" - ], - "NavigationItem": [ - "label", - "targetScreen", - "routeParameterBindings", - "order", - "group" - ], - "UiProfile": [ - "name", - "targetPlatform", - "packages", - "defaultSizeClass", - "layout", - "theme" - ], - "Theme": [ - "name", - "compatibleWith", - "tokens", - "isDark", - "author", - "authorUrl", - "license", - "description", - "licenseUrl" - ], - "PackageDependency": [ - "name", - "versionRange" - ], - "ScenePackage": [ - "name", - "version", - "kind", - "dependencies", - "components", - "layouts", - "screenTemplates", - "dialogTemplates", - "themes", - "displayName", - "description", - "module", - "license", - "licenseUrl" - ], - "Screen": [ - "name", - "layout", - "slotContent", - "forms", - "contributions", - "screenTemplate" - ], - "UiStarter": [ - "name", - "packages", - "themes", - "gallery" - ], - "ScreenTemplate": [ - "name", - "fitsSlot", - "slots", - "arrangement", - "content", - "displayName", - "description" - ], - "DialogTemplate": [ - "name", - "slots", - "arrangement", - "content", - "displayName", - "description" - ], - "GridLength": [ - "value", - "unitType" - ], - "StackPanel": [ - "orientation", - "spacing" - ], - "DockPanel": [ - "lastChildFill" - ], - "WrapPanel": [ - "orientation", - "itemWidth", - "itemHeight" - ], - "RowDefinition": [ - "height", - "minimumHeight", - "maximumHeight" - ], - "ColumnDefinition": [ - "width", - "minimumWidth", - "maximumWidth" - ], - "Grid": [ - "rows", - "columns" - ] - }, - "enums": { - "PackageKind": [ - "ComponentLibrary", - "Styling", - "Blueprint" - ], - "WidthSizeClass": [ - "Compact", - "Regular" - ], - "HeightSizeClass": [ - "Compact", - "Regular" - ], - "HorizontalAlignment": [ - "Stretch", - "Left", - "Center", - "Right" - ], - "VerticalAlignment": [ - "Stretch", - "Top", - "Center", - "Bottom" - ], - "Visibility": [ - "Visible", - "Hidden", - "Collapsed" - ], - "FlowContainerKind": [ - "Row", - "Column", - "Grid" - ], - "Orientation": [ - "Horizontal", - "Vertical" - ], - "Dock": [ - "Left", - "Top", - "Right", - "Bottom" - ], - "GridUnitType": [ - "Auto", - "Absolute", - "Star" - ] - } + "description": "Shared shape contract between Cratis.Scene.Model (C#, source of truth) and @cratis/scene.model (the hand-maintained TypeScript mirror). Both a C# spec (Model.Specs/for_SceneModelShape) and a TypeScript spec (model/for_shape) assert their own side's reflected/declared shape against this file independently, so a desync on either side fails its own build rather than only the other one's.", + "types": { + "Color": [ + "red", + "green", + "blue", + "alpha" + ], + "Size": [ + "width", + "height" + ], + "Thickness": [ + "left", + "top", + "right", + "bottom" + ], + "BindingExpression": [ + "path" + ], + "SizeClass": [ + "width", + "height" + ], + "SceneElement": [ + "id", + "properties", + "behaviors" + ], + "VisualElement": [ + "visibility", + "isEnabled", + "opacity", + "size", + "zIndex" + ], + "FrameworkElement": [ + "name", + "minimumSize", + "maximumSize", + "margin", + "horizontalAlignment", + "verticalAlignment" + ], + "Control": [ + "background", + "foreground", + "borderColor", + "borderThickness", + "padding", + "fontFamily", + "fontSize", + "tabIndex" + ], + "Panel": [ + "children" + ], + "Canvas": [ + "extent" + ], + "ItemsControl": [ + "itemsSource", + "itemTemplate" + ], + "ContentControl": [ + "content" + ], + "ExternalComponent": [ + "componentName", + "slots" + ], + "Layout": [ + "name", + "slots", + "arrangement", + "behaviors" + ], + "Slot": [ + "name", + "arrangement" + ], + "Arrangement": [], + "FlowArrangement": [ + "root", + "overrides" + ], + "FlowOverride": [ + "width", + "height", + "root" + ], + "FlowNode": [ + "grow", + "span" + ], + "FlowContainer": [ + "gap", + "children", + "kind" + ], + "FlowRow": [ + "kind" + ], + "FlowColumn": [ + "kind" + ], + "FlowGrid": [ + "kind", + "columns", + "rows" + ], + "FlowLeaf": [ + "content" + ], + "FlowSlotLeaf": [ + "slotName" + ], + "FreeformArrangement": [ + "variants" + ], + "FreeformVariant": [ + "sizeClass", + "placements" + ], + "ElementPlacement": [ + "element", + "x", + "y", + "width", + "height" + ], + "FreeformSlotArrangement": [ + "variants" + ], + "FreeformSlotVariant": [ + "sizeClass", + "placements" + ], + "SlotPlacement": [ + "slotName", + "x", + "y", + "width", + "height" + ], + "PopulateSource": [], + "PopulateViaQuery": [ + "queryName", + "parameterBindings" + ], + "PopulateFromItem": [ + "itemBinding" + ], + "FormField": [ + "name", + "sourceProperty", + "composeUsing", + "label" + ], + "Form": [ + "name", + "forCommand", + "populateSource", + "fields", + "behaviors" + ], + "ContributionPoint": [ + "name" + ], + "Contribution": [ + "contributionPointName", + "content", + "order" + ], + "NavigationItem": [ + "label", + "targetScreen", + "routeParameterBindings", + "order", + "group" + ], + "UiProfile": [ + "name", + "targetPlatform", + "packages", + "defaultSizeClass", + "layout", + "theme" + ], + "Theme": [ + "name", + "compatibleWith", + "tokens", + "isDark", + "author", + "authorUrl", + "license", + "description", + "licenseUrl" + ], + "PackageDependency": [ + "name", + "versionRange" + ], + "ScenePackage": [ + "name", + "version", + "kind", + "dependencies", + "components", + "layouts", + "screenTemplates", + "dialogTemplates", + "themes", + "displayName", + "description", + "module", + "license", + "licenseUrl" + ], + "Screen": [ + "name", + "layout", + "slotContent", + "forms", + "contributions", + "screenTemplate", + "behaviors" + ], + "UiStarter": [ + "name", + "packages", + "themes", + "gallery" + ], + "ScreenTemplate": [ + "name", + "fitsSlot", + "slots", + "arrangement", + "content", + "displayName", + "description", + "behaviors" + ], + "DialogTemplate": [ + "name", + "slots", + "arrangement", + "content", + "displayName", + "description", + "behaviors" + ], + "GridLength": [ + "value", + "unitType" + ], + "StackPanel": [ + "orientation", + "spacing" + ], + "DockPanel": [ + "lastChildFill" + ], + "WrapPanel": [ + "orientation", + "itemWidth", + "itemHeight" + ], + "RowDefinition": [ + "height", + "minimumHeight", + "maximumHeight" + ], + "ColumnDefinition": [ + "width", + "minimumWidth", + "maximumWidth" + ], + "Grid": [ + "rows", + "columns" + ], + "Behavior": [ + "name", + "bindings", + "order" + ], + "InteractionBinding": [ + "trigger", + "actions", + "condition" + ], + "InteractionTrigger": [], + "BuiltInInteractionTrigger": [ + "kind" + ], + "EventInteractionTrigger": [ + "eventName" + ], + "IntervalInteractionTrigger": [ + "seconds" + ], + "ApplicationTriggerInteractionTrigger": [ + "triggerName" + ], + "InteractionAction": [ + "arguments", + "onSuccess", + "onFailure", + "onResult" + ], + "ExecuteCommandAction": [ + "command" + ], + "NavigateAction": [ + "screen" + ], + "NavigateBackAction": [], + "OpenDialogAction": [ + "dialogTemplate" + ], + "CloseDialogAction": [], + "RefreshQueryAction": [ + "query" + ], + "SetStateAction": [ + "target", + "value" + ], + "NotifyAction": [ + "level", + "message" + ], + "ConfirmAction": [ + "message" + ], + "RaiseTriggerAction": [ + "trigger" + ], + "InteractionArgument": [ + "name", + "value" + ], + "InteractionMessage": [ + "text", + "stringsKey", + "binding" + ] + }, + "enums": { + "PackageKind": [ + "ComponentLibrary", + "Styling", + "Blueprint" + ], + "WidthSizeClass": [ + "Compact", + "Regular" + ], + "HeightSizeClass": [ + "Compact", + "Regular" + ], + "HorizontalAlignment": [ + "Stretch", + "Left", + "Center", + "Right" + ], + "VerticalAlignment": [ + "Stretch", + "Top", + "Center", + "Bottom" + ], + "Visibility": [ + "Visible", + "Hidden", + "Collapsed" + ], + "FlowContainerKind": [ + "Row", + "Column", + "Grid" + ], + "Orientation": [ + "Horizontal", + "Vertical" + ], + "Dock": [ + "Left", + "Top", + "Right", + "Bottom" + ], + "GridUnitType": [ + "Auto", + "Absolute", + "Star" + ], + "InteractionTriggerKind": [ + "Click", + "DoubleClick", + "Select", + "Submit", + "Change", + "Load", + "Unload", + "Enter", + "Leave" + ], + "NotificationLevel": [ + "Info", + "Warning", + "Error" + ] + } } From a3acd92902f45e2c0a4c86dff1d99b0001caf4d4 Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 23 Sep 2026 12:44:52 +0200 Subject: [PATCH 3/8] Run interactions in the engine, not the renderer The sequencing rules of an interaction - order, continuations, short-circuits - now live in pure functions over the model. That is what makes 'a declined confirm stops the sequence' a unit test instead of a browser run, and what lets a non-React renderer inherit the semantics rather than reimplement them. resolveBehaviors collects the bindings a trigger fires, additively and in a deterministic order: outermost attachment first, declaration order within a level, an explicit order winning over both. Additive is the rule that lets a module gate every destructive action beneath it without each screen cooperating - and determinism is why this is a pure function over an ordered list rather than something assembled while rendering. An intermittent ordering bug is far worse than a wrong one. runActions sequences them against an ActionDispatcher, the seam a renderer implements with one method per action kind. Anything that can fail returns an outcome rather than throwing, so a failure is something to branch on with 'on failure' instead of something that unwinds the interaction. An action kind the engine does not know is reported as a finding and the sequence abandoned - never silently skipped, which is what lets a newer document be opened by an older renderer without pretending it worked. Actions carry an explicit kind discriminator. The house style sniffs members, but 'navigate back' and 'close dialog' both carry no operand, so nothing about their shape tells them apart - and a finding needs a name to report. --- .../Model/Interactions/CloseDialogAction.cs | 6 +- .../Model/Interactions/ConfirmAction.cs | 6 +- .../Interactions/ExecuteCommandAction.cs | 6 +- .../Model/Interactions/InteractionAction.cs | 5 + .../Interactions/InteractionActionKind.cs | 47 ++++ .../Model/Interactions/NavigateAction.cs | 6 +- .../Model/Interactions/NavigateBackAction.cs | 6 +- .../DotNET/Model/Interactions/NotifyAction.cs | 6 +- .../Model/Interactions/OpenDialogAction.cs | 6 +- .../Model/Interactions/RaiseTriggerAction.cs | 6 +- .../Model/Interactions/RefreshQueryAction.cs | 6 +- .../Model/Interactions/SetStateAction.cs | 6 +- Source/JavaScript/engine/ActionDispatcher.ts | 87 +++++++ .../when_resolving_additive_attachments.ts | 100 ++++++++ .../given/a_recording_dispatcher.ts | 72 ++++++ .../when_running_a_sequence_of_actions.ts | 226 +++++++++++++++++ Source/JavaScript/engine/index.ts | 3 + Source/JavaScript/engine/resolveBehaviors.ts | 128 ++++++++++ Source/JavaScript/engine/runActions.ts | 233 ++++++++++++++++++ .../JavaScript/model/elements/SceneElement.ts | 2 +- Source/JavaScript/model/forms/Form.ts | 2 +- .../model/interactions/CloseDialogAction.ts | 2 + .../model/interactions/ConfirmAction.ts | 2 +- .../interactions/ExecuteCommandAction.ts | 2 +- .../model/interactions/InteractionAction.ts | 12 +- .../interactions/InteractionActionKind.ts | 24 ++ .../model/interactions/NavigateAction.ts | 2 +- .../model/interactions/NavigateBackAction.ts | 2 + .../model/interactions/NotifyAction.ts | 2 +- .../model/interactions/OpenDialogAction.ts | 2 +- .../model/interactions/RaiseTriggerAction.ts | 2 +- .../model/interactions/RefreshQueryAction.ts | 2 +- .../model/interactions/SetStateAction.ts | 2 +- Source/JavaScript/model/interactions/index.ts | 1 + Source/JavaScript/model/layouts/Layout.ts | 2 +- .../model/screens/DialogTemplate.ts | 2 +- Source/JavaScript/model/screens/Screen.ts | 2 +- .../model/screens/ScreenTemplate.ts | 2 +- scene-model-shape.json | 45 +++- 39 files changed, 1036 insertions(+), 39 deletions(-) create mode 100644 Source/DotNET/Model/Interactions/InteractionActionKind.cs create mode 100644 Source/JavaScript/engine/ActionDispatcher.ts create mode 100644 Source/JavaScript/engine/for_resolveBehaviors/when_resolving_additive_attachments.ts create mode 100644 Source/JavaScript/engine/for_runActions/given/a_recording_dispatcher.ts create mode 100644 Source/JavaScript/engine/for_runActions/when_running_a_sequence_of_actions.ts create mode 100644 Source/JavaScript/engine/resolveBehaviors.ts create mode 100644 Source/JavaScript/engine/runActions.ts create mode 100644 Source/JavaScript/model/interactions/InteractionActionKind.ts diff --git a/Source/DotNET/Model/Interactions/CloseDialogAction.cs b/Source/DotNET/Model/Interactions/CloseDialogAction.cs index 45248984..9dee5d84 100644 --- a/Source/DotNET/Model/Interactions/CloseDialogAction.cs +++ b/Source/DotNET/Model/Interactions/CloseDialogAction.cs @@ -6,4 +6,8 @@ namespace Cratis.Scene.Model.Interactions; /// /// Dismisses the innermost dialog. /// -public record CloseDialogAction : InteractionAction; +public record CloseDialogAction : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.CloseDialog; +} diff --git a/Source/DotNET/Model/Interactions/ConfirmAction.cs b/Source/DotNET/Model/Interactions/ConfirmAction.cs index 31a00a67..5de33c11 100644 --- a/Source/DotNET/Model/Interactions/ConfirmAction.cs +++ b/Source/DotNET/Model/Interactions/ConfirmAction.cs @@ -7,4 +7,8 @@ namespace Cratis.Scene.Model.Interactions; /// Gates the actions that follow on the user agreeing. ///
/// What to ask. -public record ConfirmAction(InteractionMessage Message) : InteractionAction; +public record ConfirmAction(InteractionMessage Message) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.Confirm; +} diff --git a/Source/DotNET/Model/Interactions/ExecuteCommandAction.cs b/Source/DotNET/Model/Interactions/ExecuteCommandAction.cs index ace3a5ad..9d641fef 100644 --- a/Source/DotNET/Model/Interactions/ExecuteCommandAction.cs +++ b/Source/DotNET/Model/Interactions/ExecuteCommandAction.cs @@ -7,4 +7,8 @@ namespace Cratis.Scene.Model.Interactions; /// Submits a modeled command. ///
/// The command to submit. -public record ExecuteCommandAction(string Command) : InteractionAction; +public record ExecuteCommandAction(string Command) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.ExecuteCommand; +} diff --git a/Source/DotNET/Model/Interactions/InteractionAction.cs b/Source/DotNET/Model/Interactions/InteractionAction.cs index bad08f49..750603b6 100644 --- a/Source/DotNET/Model/Interactions/InteractionAction.cs +++ b/Source/DotNET/Model/Interactions/InteractionAction.cs @@ -30,6 +30,11 @@ namespace Cratis.Scene.Model.Interactions; [JsonDerivedType(typeof(RaiseTriggerAction))] public abstract record InteractionAction { + /// + /// Gets which effect this action is. + /// + public abstract InteractionActionKind Kind { get; } + /// /// Gets the arguments supplied to the action. /// diff --git a/Source/DotNET/Model/Interactions/InteractionActionKind.cs b/Source/DotNET/Model/Interactions/InteractionActionKind.cs new file mode 100644 index 00000000..f3238b62 --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionActionKind.cs @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Defines which effect an is. +/// +/// +/// An explicit discriminator rather than a structural one: and +/// both carry no operand, so nothing about their shape distinguishes them. It +/// also means a renderer meeting an action it does not know can name it in a finding instead of reporting that +/// something unidentifiable failed. +/// Append only. +/// +public enum InteractionActionKind +{ + /// Submit a modeled command. + ExecuteCommand = 0, + + /// Change the active screen. + Navigate = 1, + + /// Pop navigation history. + NavigateBack = 2, + + /// Open a dialog over the application. + OpenDialog = 3, + + /// Dismiss the innermost dialog. + CloseDialog = 4, + + /// Re-run a query. + RefreshQuery = 5, + + /// Write screen state. + SetState = 6, + + /// Surface a message. + Notify = 7, + + /// Gate the actions that follow on the user agreeing. + Confirm = 8, + + /// Fire a declared application trigger. + RaiseTrigger = 9 +} diff --git a/Source/DotNET/Model/Interactions/NavigateAction.cs b/Source/DotNET/Model/Interactions/NavigateAction.cs index a9b52be3..41315713 100644 --- a/Source/DotNET/Model/Interactions/NavigateAction.cs +++ b/Source/DotNET/Model/Interactions/NavigateAction.cs @@ -11,4 +11,8 @@ namespace Cratis.Scene.Model.Interactions; /// The screen is named, never addressed. Turning a screen reference into a URL is a renderer's job, so this /// model stays meaningful to a renderer that has no URLs at all. /// -public record NavigateAction(string Screen) : InteractionAction; +public record NavigateAction(string Screen) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.Navigate; +} diff --git a/Source/DotNET/Model/Interactions/NavigateBackAction.cs b/Source/DotNET/Model/Interactions/NavigateBackAction.cs index f75bc911..3fffb041 100644 --- a/Source/DotNET/Model/Interactions/NavigateBackAction.cs +++ b/Source/DotNET/Model/Interactions/NavigateBackAction.cs @@ -6,4 +6,8 @@ namespace Cratis.Scene.Model.Interactions; /// /// Pops navigation history. /// -public record NavigateBackAction : InteractionAction; +public record NavigateBackAction : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.NavigateBack; +} diff --git a/Source/DotNET/Model/Interactions/NotifyAction.cs b/Source/DotNET/Model/Interactions/NotifyAction.cs index 141acc8b..6a2c047c 100644 --- a/Source/DotNET/Model/Interactions/NotifyAction.cs +++ b/Source/DotNET/Model/Interactions/NotifyAction.cs @@ -8,4 +8,8 @@ namespace Cratis.Scene.Model.Interactions; /// /// How prominently to surface it. /// The message. -public record NotifyAction(NotificationLevel Level, InteractionMessage Message) : InteractionAction; +public record NotifyAction(NotificationLevel Level, InteractionMessage Message) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.Notify; +} diff --git a/Source/DotNET/Model/Interactions/OpenDialogAction.cs b/Source/DotNET/Model/Interactions/OpenDialogAction.cs index 2ff86dbf..5d732e21 100644 --- a/Source/DotNET/Model/Interactions/OpenDialogAction.cs +++ b/Source/DotNET/Model/Interactions/OpenDialogAction.cs @@ -7,4 +7,8 @@ namespace Cratis.Scene.Model.Interactions; /// Opens a dialog over the application. /// /// The dialog template to open. -public record OpenDialogAction(string DialogTemplate) : InteractionAction; +public record OpenDialogAction(string DialogTemplate) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.OpenDialog; +} diff --git a/Source/DotNET/Model/Interactions/RaiseTriggerAction.cs b/Source/DotNET/Model/Interactions/RaiseTriggerAction.cs index b922cc08..2a4fb9ed 100644 --- a/Source/DotNET/Model/Interactions/RaiseTriggerAction.cs +++ b/Source/DotNET/Model/Interactions/RaiseTriggerAction.cs @@ -7,4 +7,8 @@ namespace Cratis.Scene.Model.Interactions; /// Fires a declared application trigger. /// /// The application trigger to fire. -public record RaiseTriggerAction(string Trigger) : InteractionAction; +public record RaiseTriggerAction(string Trigger) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.RaiseTrigger; +} diff --git a/Source/DotNET/Model/Interactions/RefreshQueryAction.cs b/Source/DotNET/Model/Interactions/RefreshQueryAction.cs index da14055e..0367222a 100644 --- a/Source/DotNET/Model/Interactions/RefreshQueryAction.cs +++ b/Source/DotNET/Model/Interactions/RefreshQueryAction.cs @@ -7,4 +7,8 @@ namespace Cratis.Scene.Model.Interactions; /// Re-runs a query-backed element. /// /// The query to re-run. -public record RefreshQueryAction(string Query) : InteractionAction; +public record RefreshQueryAction(string Query) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.RefreshQuery; +} diff --git a/Source/DotNET/Model/Interactions/SetStateAction.cs b/Source/DotNET/Model/Interactions/SetStateAction.cs index 7ed54bf9..4a6c7352 100644 --- a/Source/DotNET/Model/Interactions/SetStateAction.cs +++ b/Source/DotNET/Model/Interactions/SetStateAction.cs @@ -10,4 +10,8 @@ namespace Cratis.Scene.Model.Interactions; /// /// The declared state name being written. /// The value to write. -public record SetStateAction(string Target, BindingExpression Value) : InteractionAction; +public record SetStateAction(string Target, BindingExpression Value) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.SetState; +} diff --git a/Source/JavaScript/engine/ActionDispatcher.ts b/Source/JavaScript/engine/ActionDispatcher.ts new file mode 100644 index 00000000..58249c12 --- /dev/null +++ b/Source/JavaScript/engine/ActionDispatcher.ts @@ -0,0 +1,87 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { NotificationLevel } from '@cratis/scene.model'; + +/** + * What came back from executing a command. + * + * Validation and authorization failures are outcomes rather than exceptions, because the model already describes + * them and a form has to show them next to the field they belong to. + */ +export interface CommandOutcome { + isSuccess: boolean; + validationErrors?: { member: string; message: string }[]; + exceptionMessages?: string[]; + response?: unknown; +} + +/** + * What came back from a dialog that closed. + */ +export interface DialogOutcome { + isConfirmed: boolean; + result?: unknown; +} + +/** + * The seam a renderer implements, one method per action kind. + * + * The engine decides *what* happens and in what order; the dispatcher performs the effects. That split is what + * keeps the sequencing - continuations, short-circuits, ordering - testable without a DOM, and what lets a + * non-React renderer inherit the semantics instead of reimplementing them. + * + * Every method that can fail returns an outcome rather than throwing, so a failure is something the engine can + * branch on with `on failure` instead of something that unwinds the whole interaction. + */ +export interface ActionDispatcher { + /** + * Submits a command. + */ + executeCommand(command: string, args: Record): Promise; + + /** + * Goes to a screen. The dispatcher decides what address that is, if its platform has addresses at all. + */ + navigate(screen: string, args: Record): Promise | void; + + /** + * Goes back. + */ + navigateBack(): Promise | void; + + /** + * Opens a dialog and resolves when it closes. + */ + openDialog(dialogTemplate: string, args: Record): Promise; + + /** + * Closes the innermost dialog. + */ + closeDialog(result?: unknown): Promise | void; + + /** + * Re-runs a query. + */ + refreshQuery(query: string): Promise; + + /** + * Writes screen state. + */ + setState(target: string, value: unknown): void; + + /** + * Surfaces a message. + */ + notify(level: NotificationLevel, message: string): void; + + /** + * Asks the user to confirm, and resolves with what they chose. + */ + confirm(message: string): Promise; + + /** + * Fires an application trigger. + */ + raise(trigger: string, args: Record): Promise; +} diff --git a/Source/JavaScript/engine/for_resolveBehaviors/when_resolving_additive_attachments.ts b/Source/JavaScript/engine/for_resolveBehaviors/when_resolving_additive_attachments.ts new file mode 100644 index 00000000..8a6ce36e --- /dev/null +++ b/Source/JavaScript/engine/for_resolveBehaviors/when_resolving_additive_attachments.ts @@ -0,0 +1,100 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Behavior, InteractionActionKind, InteractionTriggerKind } from '@cratis/scene.model'; +import { BehaviorAttachment, resolveApplicationTriggerBehaviors, resolveBehaviors } from '../resolveBehaviors'; + +const behavior = (name: string | undefined, kind: InteractionTriggerKind, order?: number): Behavior => ({ + name, + order, + bindings: [ + { + trigger: { kind } as never, + actions: [{ kind: InteractionActionKind.RefreshQuery, query: name ?? 'inline' } as never], + }, + ], +}); + +describe('when resolving additive attachments', () => { + const attachments: BehaviorAttachment[] = [ + { level: 'layout', behaviors: [behavior('LayoutWide', InteractionTriggerKind.Click)] }, + { level: 'module', behaviors: [behavior('ModuleWide', InteractionTriggerKind.Click)] }, + { level: 'element', behaviors: [behavior(undefined, InteractionTriggerKind.Click)] }, + ]; + + // The point of the additive rule: a module saying 'confirm every destructive action' does not need every + // screen beneath it to cooperate, and an element attaching its own does not silently cancel it. + it('should run every matching attachment rather than only the most specific', () => { + const resolved = resolveBehaviors(attachments, InteractionTriggerKind.Click); + + resolved.should.have.lengthOf(3); + }); + + it('should run the outermost attachment first', () => { + const resolved = resolveBehaviors(attachments, InteractionTriggerKind.Click); + + resolved.map(candidate => candidate.level).should.eql(['layout', 'module', 'element']); + }); + + it('should keep an inline behavior anonymous', () => { + const resolved = resolveBehaviors(attachments, InteractionTriggerKind.Click); + + (resolved[2].behaviorName === undefined).should.be.true; + }); + + it('should ignore bindings for a different trigger', () => { + const resolved = resolveBehaviors( + [{ level: 'element', behaviors: [behavior('OnSubmitOnly', InteractionTriggerKind.Submit)] }], + InteractionTriggerKind.Click + ); + + resolved.should.be.empty; + }); + + it('should let an explicit order win over position', () => { + const resolved = resolveBehaviors( + [ + { level: 'layout', behaviors: [behavior('LayoutWide', InteractionTriggerKind.Click)] }, + { level: 'element', behaviors: [behavior('RunsFirst', InteractionTriggerKind.Click, 1)] }, + ], + InteractionTriggerKind.Click + ); + + resolved.map(candidate => candidate.behaviorName).should.eql(['RunsFirst', 'LayoutWide']); + }); + + // Determinism is the reason this is a pure function over an ordered list: the same document has to produce + // the same order on every run, or an interaction becomes intermittently wrong rather than wrong. + it('should order deterministically across repeated resolutions', () => { + const once = resolveBehaviors(attachments, InteractionTriggerKind.Click).map(c => c.level); + const twice = resolveBehaviors(attachments, InteractionTriggerKind.Click).map(c => c.level); + + once.should.eql(twice); + }); + + it('should resolve a named application trigger separately from the built-in kinds', () => { + const resolved = resolveApplicationTriggerBehaviors( + [ + { + level: 'screen', + behaviors: [ + { + name: undefined, + bindings: [ + { trigger: { triggerName: 'InvoiceImported' } as never, actions: [] }, + { trigger: { triggerName: 'SomethingElse' } as never, actions: [] }, + ], + }, + ], + }, + ], + 'InvoiceImported' + ); + + resolved.should.have.lengthOf(1); + }); + + it('should tolerate an attachment with no behaviors at all', () => { + resolveBehaviors([{ level: 'element', behaviors: [] }], InteractionTriggerKind.Click).should.be.empty; + }); +}); diff --git a/Source/JavaScript/engine/for_runActions/given/a_recording_dispatcher.ts b/Source/JavaScript/engine/for_runActions/given/a_recording_dispatcher.ts new file mode 100644 index 00000000..0ba35d7a --- /dev/null +++ b/Source/JavaScript/engine/for_runActions/given/a_recording_dispatcher.ts @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression, NotificationLevel } from '@cratis/scene.model'; +import { ActionDispatcher, CommandOutcome, DialogOutcome } from '../../ActionDispatcher'; +import { InteractionContext } from '../../runActions'; + +/** + * A dispatcher that records what it was asked to do instead of doing it. + * + * The whole point of the engine owning the sequencing is that this is possible: no DOM, no React, no transport - + * the rules are assertable as a list of calls. + */ +export class RecordingDispatcher implements ActionDispatcher { + readonly calls: string[] = []; + + commandSucceeds = true; + dialogConfirmed = true; + confirmAnswer = true; + + async executeCommand(command: string, args: Record): Promise { + this.calls.push(`executeCommand:${command}:${JSON.stringify(args)}`); + return this.commandSucceeds + ? { isSuccess: true } + : { isSuccess: false, validationErrors: [{ member: 'amount', message: 'Must be positive' }] }; + } + + navigate(screen: string): void { + this.calls.push(`navigate:${screen}`); + } + + navigateBack(): void { + this.calls.push('navigateBack'); + } + + async openDialog(dialogTemplate: string, args: Record): Promise { + this.calls.push(`openDialog:${dialogTemplate}:${JSON.stringify(args)}`); + return { isConfirmed: this.dialogConfirmed, result: 'the-result' }; + } + + closeDialog(): void { + this.calls.push('closeDialog'); + } + + async refreshQuery(query: string): Promise { + this.calls.push(`refreshQuery:${query}`); + } + + setState(target: string, value: unknown): void { + this.calls.push(`setState:${target}:${String(value)}`); + } + + notify(level: NotificationLevel, message: string): void { + this.calls.push(`notify:${level}:${message}`); + } + + async confirm(message: string): Promise { + this.calls.push(`confirm:${message}`); + return this.confirmAnswer; + } + + async raise(trigger: string): Promise { + this.calls.push(`raise:${trigger}`); + } +} + +/** + * A context that resolves a binding to its path, so an assertion can see which binding was used. + */ +export const pathEchoingContext: InteractionContext = { + resolve: (binding: BindingExpression) => `<${binding.path}>`, +}; diff --git a/Source/JavaScript/engine/for_runActions/when_running_a_sequence_of_actions.ts b/Source/JavaScript/engine/for_runActions/when_running_a_sequence_of_actions.ts new file mode 100644 index 00000000..c0d08675 --- /dev/null +++ b/Source/JavaScript/engine/for_runActions/when_running_a_sequence_of_actions.ts @@ -0,0 +1,226 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction, InteractionActionKind, NotificationLevel } from '@cratis/scene.model'; +import { InteractionStop, runActions } from '../runActions'; +import { pathEchoingContext, RecordingDispatcher } from './given/a_recording_dispatcher'; + +const action = (kind: InteractionActionKind, extra: Record = {}): InteractionAction => + ({ kind, ...extra }) as InteractionAction; + +describe('when running a sequence of actions', () => { + it('should run them in the order they were declared', async () => { + const dispatcher = new RecordingDispatcher(); + + await runActions( + [ + action(InteractionActionKind.RefreshQuery, { query: 'Invoices' }), + action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { text: 'Done' } }), + ], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['refreshQuery:Invoices', 'notify:Info:Done']); + }); + + it('should resolve an action\'s arguments through the binding resolver', async () => { + const dispatcher = new RecordingDispatcher(); + + await runActions( + [ + action(InteractionActionKind.ExecuteCommand, { + command: 'CancelInvoice', + arguments: [{ name: 'invoiceId', value: { path: 'item.invoiceId' } }], + }), + ], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['executeCommand:CancelInvoice:{"invoiceId":""}']); + }); + + it('should take the success branch when a command succeeds', async () => { + const dispatcher = new RecordingDispatcher(); + + const run = await runActions( + [ + action(InteractionActionKind.ExecuteCommand, { + command: 'RegisterInvoice', + onSuccess: [action(InteractionActionKind.CloseDialog)], + onFailure: [action(InteractionActionKind.Notify, { level: NotificationLevel.Error, message: { text: 'Failed' } })], + }), + ], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['executeCommand:RegisterInvoice:{}', 'closeDialog']); + run.stop.should.equal(InteractionStop.Completed); + }); + + it('should take the failure branch when a command fails', async () => { + const dispatcher = new RecordingDispatcher(); + dispatcher.commandSucceeds = false; + + await runActions( + [ + action(InteractionActionKind.ExecuteCommand, { + command: 'RegisterInvoice', + onSuccess: [action(InteractionActionKind.CloseDialog)], + onFailure: [action(InteractionActionKind.Notify, { level: NotificationLevel.Error, message: { text: 'Failed' } })], + }), + ], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['executeCommand:RegisterInvoice:{}', 'notify:Error:Failed']); + }); + + // A confirm exists to gate what follows it. If a decline let the rest run, the gate would be decorative. + it('should stop the sequence when a confirm is declined', async () => { + const dispatcher = new RecordingDispatcher(); + dispatcher.confirmAnswer = false; + + const run = await runActions( + [ + action(InteractionActionKind.Confirm, { + message: { text: 'Are you sure?' }, + onSuccess: [action(InteractionActionKind.ExecuteCommand, { command: 'CancelInvoice' })], + }), + action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { text: 'Cancelled' } }), + ], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['confirm:Are you sure?']); + run.stop.should.equal(InteractionStop.Declined); + }); + + it('should run the gated actions when a confirm is accepted', async () => { + const dispatcher = new RecordingDispatcher(); + + await runActions( + [ + action(InteractionActionKind.Confirm, { + message: { text: 'Are you sure?' }, + onSuccess: [action(InteractionActionKind.ExecuteCommand, { command: 'CancelInvoice' })], + }), + ], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['confirm:Are you sure?', 'executeCommand:CancelInvoice:{}']); + }); + + // The screen the remaining actions were written for is gone. The compiler reports this as unreachable, so + // reaching it at runtime means the document changed underneath - still not a reason to run them. + it('should stop the sequence after a navigation', async () => { + const dispatcher = new RecordingDispatcher(); + + const run = await runActions( + [ + action(InteractionActionKind.Navigate, { screen: 'InvoiceList' }), + action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { text: 'Never runs' } }), + ], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['navigate:InvoiceList']); + run.stop.should.equal(InteractionStop.Navigated); + }); + + it('should stop the sequence after navigating back', async () => { + const dispatcher = new RecordingDispatcher(); + + const run = await runActions( + [action(InteractionActionKind.NavigateBack), action(InteractionActionKind.CloseDialog)], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['navigateBack']); + run.stop.should.equal(InteractionStop.Navigated); + }); + + it('should run a dialog\'s result continuation after it closes', async () => { + const dispatcher = new RecordingDispatcher(); + + await runActions( + [ + action(InteractionActionKind.OpenDialog, { + dialogTemplate: 'InvoiceDetails', + arguments: [{ name: 'invoiceId', value: { path: 'item.invoiceId' } }], + onResult: [action(InteractionActionKind.RefreshQuery, { query: 'Invoices' })], + }), + ], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql([ + 'openDialog:InvoiceDetails:{"invoiceId":""}', + 'refreshQuery:Invoices', + ]); + }); + + it('should resolve a message from a binding when it has one', async () => { + const dispatcher = new RecordingDispatcher(); + + await runActions( + [action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { binding: { path: 'state.message' } } })], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.eql(['notify:Info:']); + }); + + it('should localize a message given a strings key', async () => { + const dispatcher = new RecordingDispatcher(); + + await runActions( + [action(InteractionActionKind.Notify, { level: NotificationLevel.Info, message: { stringsKey: 'invoiceRegistered' } })], + dispatcher, + { resolve: () => undefined, localize: key => `localized(${key})` } + ); + + dispatcher.calls.should.eql(['notify:Info:localized(invoiceRegistered)']); + }); + + // Fail closed and visible: an action kind this engine does not know is reported, and the rest of the + // sequence is abandoned rather than half-applied silently. + it('should report an action kind it does not know instead of ignoring it', async () => { + const dispatcher = new RecordingDispatcher(); + + const run = await runActions( + [action('SomethingFromALaterVersion' as InteractionActionKind)], + dispatcher, + pathEchoingContext + ); + + dispatcher.calls.should.be.empty; + run.stop.should.equal(InteractionStop.Reported); + run.findings.should.have.lengthOf(1); + run.findings[0].kind.should.equal('unknownAction'); + run.findings[0].detail.should.contain('SomethingFromALaterVersion'); + }); + + it('should report a message binding that resolved to nothing', async () => { + const dispatcher = new RecordingDispatcher(); + + const run = await runActions( + [action(InteractionActionKind.Confirm, { message: { binding: { path: 'state.missing' } } })], + dispatcher, + { resolve: () => undefined } + ); + + run.findings[0].kind.should.equal('unresolvedMessage'); + run.findings[0].detail.should.contain('state.missing'); + }); +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index fe37e500..6cbe6f01 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -22,3 +22,6 @@ export * from './packageCatalog'; export * from './resolveScreenTemplates'; export * from './buildStarterProfile'; export * from './incompatibleStarterThemes'; +export * from './ActionDispatcher'; +export * from './runActions'; +export * from './resolveBehaviors'; diff --git a/Source/JavaScript/engine/resolveBehaviors.ts b/Source/JavaScript/engine/resolveBehaviors.ts new file mode 100644 index 00000000..a4725007 --- /dev/null +++ b/Source/JavaScript/engine/resolveBehaviors.ts @@ -0,0 +1,128 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Behavior, InteractionBinding, InteractionTrigger, InteractionTriggerKind } from '@cratis/scene.model'; + +/** + * One level of the containment tree a behavior can be attached to, outermost first. + * + * Named rather than positional so a finding can say *where* a behavior came from - 'the module's behavior ran + * before the element's' is only a useful thing to report if the level has a name. + */ +export interface BehaviorAttachment { + level: string; + behaviors: Behavior[]; +} + +/** + * One binding to run, with the attachment it came from. + */ +export interface ResolvedBinding { + level: string; + behaviorName?: string; + binding: InteractionBinding; +} + +/** + * Whether the trigger is the built-in kind asked for. + */ +function isKind(trigger: InteractionTrigger, kind: InteractionTriggerKind): boolean { + return (trigger as { kind?: InteractionTriggerKind }).kind === kind; +} + +/** + * Collects the bindings that respond to a built-in interaction kind, in the order they run. + * + * Attachments are **additive**: a behavior on a template and a behavior on an element both run, rather than the + * more specific one replacing the less specific one. The order is outermost attachment first, declaration order + * within a level, and any behavior that declares an `order` sorts by it. + * + * The additive rule is what lets a module say 'confirm every destructive action' without every screen beneath it + * having to cooperate - but it also means the order has to be deterministic, or the same document behaves + * differently between runs. That is why this is a pure function over an ordered list rather than something + * assembled while rendering. + * + * @param attachments The attachment levels, outermost first. + * @param kind The built-in interaction kind that occurred. + * @returns The bindings to run, in order. + */ +export function resolveBehaviors(attachments: BehaviorAttachment[], kind: InteractionTriggerKind): ResolvedBinding[] { + const resolved: { entry: ResolvedBinding; level: number; declaration: number; order?: number }[] = []; + + attachments.forEach((attachment, level) => { + let declaration = 0; + for (const behavior of (attachment.behaviors ?? [])) { + for (const binding of (behavior.bindings ?? [])) { + if (!isKind(binding.trigger, kind)) continue; + + resolved.push({ + entry: { level: attachment.level, behaviorName: behavior.name, binding }, + level, + declaration: declaration++, + order: behavior.order, + }); + } + } + }); + + // An explicit order wins over position; everything else keeps the order it was written in. Sorting is + // stable on (order, level, declaration) so the result never depends on the sort implementation. + return resolved + .sort((left, right) => { + if (left.order !== right.order) { + if (left.order === undefined) return 1; + if (right.order === undefined) return -1; + return left.order - right.order; + } + + return left.level !== right.level ? left.level - right.level : left.declaration - right.declaration; + }) + .map(candidate => candidate.entry); +} + +/** + * Collects the bindings that respond to a named application trigger firing. + * + * @param attachments The attachment levels, outermost first. + * @param triggerName The application trigger that fired. + * @returns The bindings to run, in order. + */ +export function resolveApplicationTriggerBehaviors(attachments: BehaviorAttachment[], triggerName: string): ResolvedBinding[] { + return collect(attachments, trigger => (trigger as { triggerName?: string }).triggerName === triggerName); +} + +/** + * Collects the bindings that respond to a modeled domain event being observed. + * + * @param attachments The attachment levels, outermost first. + * @param eventName The event that was observed. + * @returns The bindings to run, in order. + */ +export function resolveEventBehaviors(attachments: BehaviorAttachment[], eventName: string): ResolvedBinding[] { + return collect(attachments, trigger => (trigger as { eventName?: string }).eventName === eventName); +} + +/** + * Collects every interval binding, so a host can schedule them. + * + * @param attachments The attachment levels, outermost first. + * @returns The interval bindings, in order. + */ +export function resolveIntervalBehaviors(attachments: BehaviorAttachment[]): ResolvedBinding[] { + return collect(attachments, trigger => typeof (trigger as { seconds?: number }).seconds === 'number'); +} + +function collect(attachments: BehaviorAttachment[], matches: (trigger: InteractionTrigger) => boolean): ResolvedBinding[] { + const resolved: ResolvedBinding[] = []; + for (const attachment of attachments) { + for (const behavior of (attachment.behaviors ?? [])) { + for (const binding of (behavior.bindings ?? [])) { + if (matches(binding.trigger)) { + resolved.push({ level: attachment.level, behaviorName: behavior.name, binding }); + } + } + } + } + + return resolved; +} diff --git a/Source/JavaScript/engine/runActions.ts b/Source/JavaScript/engine/runActions.ts new file mode 100644 index 00000000..c15faf76 --- /dev/null +++ b/Source/JavaScript/engine/runActions.ts @@ -0,0 +1,233 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { + ConfirmAction, + ExecuteCommandAction, + InteractionAction, + InteractionActionKind, + InteractionArgument, + InteractionMessage, + NavigateAction, + NotifyAction, + OpenDialogAction, + RaiseTriggerAction, + RefreshQueryAction, + SetStateAction, +} from '@cratis/scene.model'; + +import { ActionDispatcher } from './ActionDispatcher'; +import { BindingResolver } from './BindingResolver'; + +/** + * Why an interaction stopped before running everything it was given. + */ +export enum InteractionStop { + /** Everything ran. */ + Completed = 'Completed', + + /** A `confirm` was declined, so the actions it gates did not run. */ + Declined = 'Declined', + + /** A navigation happened, so anything after it belonged to a screen that is gone. */ + Navigated = 'Navigated', + + /** An action could not be realized and was reported. */ + Reported = 'Reported', +} + +/** + * Something an interaction could not do. + * + * Findings are collected rather than thrown: one unresolvable action must not take down the rest of the + * application, and 'this application produced no findings' has to be checkable rather than a matter of opinion. + */ +export interface InteractionFinding { + kind: 'unknownAction' | 'unresolvedMessage'; + detail: string; +} + +/** + * What an interaction did. + */ +export interface InteractionRun { + stop: InteractionStop; + findings: InteractionFinding[]; + /** The actions that actually ran, in order - the thing a spec asserts on. */ + ran: string[]; +} + +/** + * How to resolve the values an action needs. + */ +export interface InteractionContext { + resolve: BindingResolver; + /** Looks up a localization key. Returns the key itself when the host has no catalogue for it. */ + localize?: (key: string) => string; +} + +function describe(action: InteractionAction): string { + switch (action.kind) { + case InteractionActionKind.ExecuteCommand: return `execute ${(action as ExecuteCommandAction).command}`; + case InteractionActionKind.Navigate: return `navigate to ${(action as NavigateAction).screen}`; + case InteractionActionKind.NavigateBack: return 'navigate back'; + case InteractionActionKind.OpenDialog: return `open dialog ${(action as OpenDialogAction).dialogTemplate}`; + case InteractionActionKind.CloseDialog: return 'close dialog'; + case InteractionActionKind.RefreshQuery: return `refresh ${(action as RefreshQueryAction).query}`; + case InteractionActionKind.SetState: return `set ${(action as SetStateAction).target}`; + case InteractionActionKind.Notify: return 'notify'; + case InteractionActionKind.Confirm: return 'confirm'; + case InteractionActionKind.RaiseTrigger: return `raise ${(action as RaiseTriggerAction).trigger}`; + default: return String(action.kind); + } +} + +function argumentsOf(action: InteractionAction, context: InteractionContext): Record { + const args: Record = {}; + for (const argument of (action.arguments ?? []) as InteractionArgument[]) { + args[argument.name] = context.resolve(argument.value); + } + + return args; +} + +function messageOf(message: InteractionMessage, context: InteractionContext, findings: InteractionFinding[]): string { + if (message.text !== undefined) return message.text; + if (message.stringsKey !== undefined) return context.localize ? context.localize(message.stringsKey) : message.stringsKey; + if (message.binding !== undefined) { + const resolved = context.resolve(message.binding); + if (resolved === undefined || resolved === null) { + findings.push({ kind: 'unresolvedMessage', detail: `The message binding '${message.binding.path}' resolved to nothing` }); + return ''; + } + + return String(resolved); + } + + findings.push({ kind: 'unresolvedMessage', detail: 'The action carries no message' }); + return ''; +} + +/** + * Runs a sequence of actions, in order, handling continuations. + * + * The rules, all of which live here rather than in a renderer so they can be asserted without a DOM: + * + * - Actions run in the order they were declared. + * - A declined `confirm` stops the sequence. Gating what follows is the whole point of it. + * - A navigation stops the sequence, because the screen the remaining actions were written for is gone. The + * compiler reports that as unreachable, so reaching it at runtime means the document changed underneath. + * - An action that can fail branches into `onSuccess` or `onFailure`; a dialog also runs `onResult`. + * - An action kind the engine does not know is **reported and skipped**, never silently ignored - which is what + * lets a newer document be opened by an older renderer without pretending it worked. + * + * @param actions The actions to run. + * @param dispatcher The seam that performs the effects. + * @param context How to resolve bindings and localization. + * @returns What happened. + */ +export async function runActions( + actions: InteractionAction[], + dispatcher: ActionDispatcher, + context: InteractionContext +): Promise { + const run: InteractionRun = { stop: InteractionStop.Completed, findings: [], ran: [] }; + await runSequence(actions, dispatcher, context, run); + return run; +} + +async function runSequence( + actions: InteractionAction[], + dispatcher: ActionDispatcher, + context: InteractionContext, + run: InteractionRun +): Promise { + for (const action of actions) { + if (await runOne(action, dispatcher, context, run)) return; + } +} + +async function runOne( + action: InteractionAction, + dispatcher: ActionDispatcher, + context: InteractionContext, + run: InteractionRun +): Promise { + const args = argumentsOf(action, context); + + switch (action.kind) { + case InteractionActionKind.ExecuteCommand: { + run.ran.push(describe(action)); + const outcome = await dispatcher.executeCommand((action as ExecuteCommandAction).command, args); + await runSequence(outcome.isSuccess ? (action.onSuccess ?? []) : (action.onFailure ?? []), dispatcher, context, run); + return false; + } + + case InteractionActionKind.Navigate: + run.ran.push(describe(action)); + await dispatcher.navigate((action as NavigateAction).screen, args); + run.stop = InteractionStop.Navigated; + return true; + + case InteractionActionKind.NavigateBack: + run.ran.push(describe(action)); + await dispatcher.navigateBack(); + run.stop = InteractionStop.Navigated; + return true; + + case InteractionActionKind.OpenDialog: { + run.ran.push(describe(action)); + const outcome = await dispatcher.openDialog((action as OpenDialogAction).dialogTemplate, args); + await runSequence(outcome.isConfirmed ? (action.onSuccess ?? []) : (action.onFailure ?? []), dispatcher, context, run); + await runSequence((action.onResult ?? []), dispatcher, context, run); + return false; + } + + case InteractionActionKind.CloseDialog: + run.ran.push(describe(action)); + await dispatcher.closeDialog(); + return false; + + case InteractionActionKind.RefreshQuery: { + run.ran.push(describe(action)); + await dispatcher.refreshQuery((action as RefreshQueryAction).query); + await runSequence((action.onSuccess ?? []), dispatcher, context, run); + return false; + } + + case InteractionActionKind.SetState: + run.ran.push(describe(action)); + dispatcher.setState((action as SetStateAction).target, context.resolve((action as SetStateAction).value)); + return false; + + case InteractionActionKind.Notify: + run.ran.push(describe(action)); + dispatcher.notify((action as NotifyAction).level, messageOf((action as NotifyAction).message, context, run.findings)); + return false; + + case InteractionActionKind.Confirm: { + run.ran.push(describe(action)); + const confirmed = await dispatcher.confirm(messageOf((action as ConfirmAction).message, context, run.findings)); + if (!confirmed) { + await runSequence((action.onFailure ?? []), dispatcher, context, run); + run.stop = InteractionStop.Declined; + return true; + } + + await runSequence((action.onSuccess ?? []), dispatcher, context, run); + return false; + } + + case InteractionActionKind.RaiseTrigger: { + run.ran.push(describe(action)); + await dispatcher.raise((action as RaiseTriggerAction).trigger, args); + await runSequence((action.onSuccess ?? []), dispatcher, context, run); + return false; + } + + default: + run.findings.push({ kind: 'unknownAction', detail: `The renderer does not know the action kind '${String(action.kind)}'` }); + run.stop = InteractionStop.Reported; + return false; + } +} diff --git a/Source/JavaScript/model/elements/SceneElement.ts b/Source/JavaScript/model/elements/SceneElement.ts index 2e550470..af902db5 100644 --- a/Source/JavaScript/model/elements/SceneElement.ts +++ b/Source/JavaScript/model/elements/SceneElement.ts @@ -14,7 +14,7 @@ export interface SceneElement { * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is * attached further out or further in. */ - behaviors: Behavior[]; + behaviors?: Behavior[]; } export const SceneElementPropertyNames: (keyof SceneElement)[] = ['id', 'properties', 'behaviors']; diff --git a/Source/JavaScript/model/forms/Form.ts b/Source/JavaScript/model/forms/Form.ts index 51415518..02a74bbc 100644 --- a/Source/JavaScript/model/forms/Form.ts +++ b/Source/JavaScript/model/forms/Form.ts @@ -17,7 +17,7 @@ export interface Form { * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is * attached further out or further in. */ - behaviors: Behavior[]; + behaviors?: Behavior[]; } export const FormPropertyNames: (keyof Form)[] = ['name', 'forCommand', 'populateSource', 'fields', 'behaviors']; diff --git a/Source/JavaScript/model/interactions/CloseDialogAction.ts b/Source/JavaScript/model/interactions/CloseDialogAction.ts index 7472ddd3..1ff47c3b 100644 --- a/Source/JavaScript/model/interactions/CloseDialogAction.ts +++ b/Source/JavaScript/model/interactions/CloseDialogAction.ts @@ -7,3 +7,5 @@ import { InteractionAction } from './InteractionAction'; * Dismisses the innermost dialog. */ export interface CloseDialogAction extends InteractionAction {} + +export const CloseDialogActionPropertyNames: (keyof CloseDialogAction)[] = ['kind']; diff --git a/Source/JavaScript/model/interactions/ConfirmAction.ts b/Source/JavaScript/model/interactions/ConfirmAction.ts index ce8a9810..62a47ce5 100644 --- a/Source/JavaScript/model/interactions/ConfirmAction.ts +++ b/Source/JavaScript/model/interactions/ConfirmAction.ts @@ -11,4 +11,4 @@ export interface ConfirmAction extends InteractionAction { message: InteractionMessage; } -export const ConfirmActionPropertyNames: (keyof ConfirmAction)[] = ['message']; +export const ConfirmActionPropertyNames: (keyof ConfirmAction)[] = ['message', 'kind']; diff --git a/Source/JavaScript/model/interactions/ExecuteCommandAction.ts b/Source/JavaScript/model/interactions/ExecuteCommandAction.ts index 2d2c63dc..f47a137c 100644 --- a/Source/JavaScript/model/interactions/ExecuteCommandAction.ts +++ b/Source/JavaScript/model/interactions/ExecuteCommandAction.ts @@ -10,4 +10,4 @@ export interface ExecuteCommandAction extends InteractionAction { command: string; } -export const ExecuteCommandActionPropertyNames: (keyof ExecuteCommandAction)[] = ['command']; +export const ExecuteCommandActionPropertyNames: (keyof ExecuteCommandAction)[] = ['command', 'kind']; diff --git a/Source/JavaScript/model/interactions/InteractionAction.ts b/Source/JavaScript/model/interactions/InteractionAction.ts index 909e4d5b..94da1a94 100644 --- a/Source/JavaScript/model/interactions/InteractionAction.ts +++ b/Source/JavaScript/model/interactions/InteractionAction.ts @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { InteractionActionKind } from './InteractionActionKind'; import { InteractionArgument } from './InteractionArgument'; /** @@ -11,10 +12,11 @@ import { InteractionArgument } from './InteractionArgument'; * that does nothing when clicked. */ export interface InteractionAction { - arguments: InteractionArgument[]; - onSuccess: InteractionAction[]; - onFailure: InteractionAction[]; - onResult: InteractionAction[]; + kind: InteractionActionKind; + arguments?: InteractionArgument[]; + onSuccess?: InteractionAction[]; + onFailure?: InteractionAction[]; + onResult?: InteractionAction[]; } -export const InteractionActionPropertyNames: (keyof InteractionAction)[] = ['arguments', 'onSuccess', 'onFailure', 'onResult']; +export const InteractionActionPropertyNames: (keyof InteractionAction)[] = ['kind', 'arguments', 'onSuccess', 'onFailure', 'onResult']; diff --git a/Source/JavaScript/model/interactions/InteractionActionKind.ts b/Source/JavaScript/model/interactions/InteractionActionKind.ts new file mode 100644 index 00000000..a7d41a43 --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionActionKind.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Which effect an {@link InteractionAction} is. + * + * An explicit discriminator rather than a structural one: NavigateBackAction and CloseDialogAction both carry no + * operand, so nothing about their shape tells them apart. It also means a renderer meeting an action it does not + * know can name it in a finding instead of reporting that something unidentifiable failed. + * + * Append only. + */ +export enum InteractionActionKind { + ExecuteCommand = 'ExecuteCommand', + Navigate = 'Navigate', + NavigateBack = 'NavigateBack', + OpenDialog = 'OpenDialog', + CloseDialog = 'CloseDialog', + RefreshQuery = 'RefreshQuery', + SetState = 'SetState', + Notify = 'Notify', + Confirm = 'Confirm', + RaiseTrigger = 'RaiseTrigger', +} diff --git a/Source/JavaScript/model/interactions/NavigateAction.ts b/Source/JavaScript/model/interactions/NavigateAction.ts index 20a9f4c2..06695fa7 100644 --- a/Source/JavaScript/model/interactions/NavigateAction.ts +++ b/Source/JavaScript/model/interactions/NavigateAction.ts @@ -10,4 +10,4 @@ export interface NavigateAction extends InteractionAction { screen: string; } -export const NavigateActionPropertyNames: (keyof NavigateAction)[] = ['screen']; +export const NavigateActionPropertyNames: (keyof NavigateAction)[] = ['screen', 'kind']; diff --git a/Source/JavaScript/model/interactions/NavigateBackAction.ts b/Source/JavaScript/model/interactions/NavigateBackAction.ts index 5144d7c9..4200fd83 100644 --- a/Source/JavaScript/model/interactions/NavigateBackAction.ts +++ b/Source/JavaScript/model/interactions/NavigateBackAction.ts @@ -7,3 +7,5 @@ import { InteractionAction } from './InteractionAction'; * Pops navigation history. */ export interface NavigateBackAction extends InteractionAction {} + +export const NavigateBackActionPropertyNames: (keyof NavigateBackAction)[] = ['kind']; diff --git a/Source/JavaScript/model/interactions/NotifyAction.ts b/Source/JavaScript/model/interactions/NotifyAction.ts index 5507e1a4..1eee5e04 100644 --- a/Source/JavaScript/model/interactions/NotifyAction.ts +++ b/Source/JavaScript/model/interactions/NotifyAction.ts @@ -13,4 +13,4 @@ export interface NotifyAction extends InteractionAction { message: InteractionMessage; } -export const NotifyActionPropertyNames: (keyof NotifyAction)[] = ['level', 'message']; +export const NotifyActionPropertyNames: (keyof NotifyAction)[] = ['level', 'message', 'kind']; diff --git a/Source/JavaScript/model/interactions/OpenDialogAction.ts b/Source/JavaScript/model/interactions/OpenDialogAction.ts index 30d1b354..4f4602b9 100644 --- a/Source/JavaScript/model/interactions/OpenDialogAction.ts +++ b/Source/JavaScript/model/interactions/OpenDialogAction.ts @@ -10,4 +10,4 @@ export interface OpenDialogAction extends InteractionAction { dialogTemplate: string; } -export const OpenDialogActionPropertyNames: (keyof OpenDialogAction)[] = ['dialogTemplate']; +export const OpenDialogActionPropertyNames: (keyof OpenDialogAction)[] = ['dialogTemplate', 'kind']; diff --git a/Source/JavaScript/model/interactions/RaiseTriggerAction.ts b/Source/JavaScript/model/interactions/RaiseTriggerAction.ts index 46f90793..0abc91ef 100644 --- a/Source/JavaScript/model/interactions/RaiseTriggerAction.ts +++ b/Source/JavaScript/model/interactions/RaiseTriggerAction.ts @@ -10,4 +10,4 @@ export interface RaiseTriggerAction extends InteractionAction { trigger: string; } -export const RaiseTriggerActionPropertyNames: (keyof RaiseTriggerAction)[] = ['trigger']; +export const RaiseTriggerActionPropertyNames: (keyof RaiseTriggerAction)[] = ['trigger', 'kind']; diff --git a/Source/JavaScript/model/interactions/RefreshQueryAction.ts b/Source/JavaScript/model/interactions/RefreshQueryAction.ts index 029350f0..88f96173 100644 --- a/Source/JavaScript/model/interactions/RefreshQueryAction.ts +++ b/Source/JavaScript/model/interactions/RefreshQueryAction.ts @@ -10,4 +10,4 @@ export interface RefreshQueryAction extends InteractionAction { query: string; } -export const RefreshQueryActionPropertyNames: (keyof RefreshQueryAction)[] = ['query']; +export const RefreshQueryActionPropertyNames: (keyof RefreshQueryAction)[] = ['query', 'kind']; diff --git a/Source/JavaScript/model/interactions/SetStateAction.ts b/Source/JavaScript/model/interactions/SetStateAction.ts index 9502fab7..4ec549ac 100644 --- a/Source/JavaScript/model/interactions/SetStateAction.ts +++ b/Source/JavaScript/model/interactions/SetStateAction.ts @@ -12,4 +12,4 @@ export interface SetStateAction extends InteractionAction { value: BindingExpression; } -export const SetStateActionPropertyNames: (keyof SetStateAction)[] = ['target', 'value']; +export const SetStateActionPropertyNames: (keyof SetStateAction)[] = ['target', 'value', 'kind']; diff --git a/Source/JavaScript/model/interactions/index.ts b/Source/JavaScript/model/interactions/index.ts index 2b14d756..52466a35 100644 --- a/Source/JavaScript/model/interactions/index.ts +++ b/Source/JavaScript/model/interactions/index.ts @@ -10,6 +10,7 @@ export * from './IntervalInteractionTrigger'; export * from './ApplicationTriggerInteractionTrigger'; export * from './InteractionMessage'; export * from './InteractionArgument'; +export * from './InteractionActionKind'; export * from './InteractionAction'; export * from './ExecuteCommandAction'; export * from './NavigateAction'; diff --git a/Source/JavaScript/model/layouts/Layout.ts b/Source/JavaScript/model/layouts/Layout.ts index 086090a8..ebafc6f8 100644 --- a/Source/JavaScript/model/layouts/Layout.ts +++ b/Source/JavaScript/model/layouts/Layout.ts @@ -26,7 +26,7 @@ export interface Layout { * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is * attached further out or further in. */ - behaviors: Behavior[]; + behaviors?: Behavior[]; } export const LayoutPropertyNames: (keyof Layout)[] = ['name', 'slots', 'arrangement', 'behaviors']; diff --git a/Source/JavaScript/model/screens/DialogTemplate.ts b/Source/JavaScript/model/screens/DialogTemplate.ts index 9934b7c2..88b0e6ca 100644 --- a/Source/JavaScript/model/screens/DialogTemplate.ts +++ b/Source/JavaScript/model/screens/DialogTemplate.ts @@ -50,7 +50,7 @@ export interface DialogTemplate { * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is * attached further out or further in. */ - behaviors: Behavior[]; + behaviors?: Behavior[]; } export const DialogTemplatePropertyNames: (keyof DialogTemplate)[] = [ diff --git a/Source/JavaScript/model/screens/Screen.ts b/Source/JavaScript/model/screens/Screen.ts index 44f8f6a4..51c4a62d 100644 --- a/Source/JavaScript/model/screens/Screen.ts +++ b/Source/JavaScript/model/screens/Screen.ts @@ -50,7 +50,7 @@ export interface Screen { * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is * attached further out or further in. */ - behaviors: Behavior[]; + behaviors?: Behavior[]; } export const ScreenPropertyNames: (keyof Screen)[] = ['name', 'layout', 'slotContent', 'forms', 'contributions', 'screenTemplate', 'behaviors']; diff --git a/Source/JavaScript/model/screens/ScreenTemplate.ts b/Source/JavaScript/model/screens/ScreenTemplate.ts index d23c3fae..9ed983d7 100644 --- a/Source/JavaScript/model/screens/ScreenTemplate.ts +++ b/Source/JavaScript/model/screens/ScreenTemplate.ts @@ -62,7 +62,7 @@ export interface ScreenTemplate { * The behaviors attached here - what happens when someone interacts with it. Additive with whatever is * attached further out or further in. */ - behaviors: Behavior[]; + behaviors?: Behavior[]; } export const ScreenTemplatePropertyNames: (keyof ScreenTemplate)[] = [ diff --git a/scene-model-shape.json b/scene-model-shape.json index 2de74bd4..613eb044 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -308,38 +308,51 @@ "triggerName" ], "InteractionAction": [ + "kind", "arguments", "onSuccess", "onFailure", "onResult" ], "ExecuteCommandAction": [ - "command" + "command", + "kind" ], "NavigateAction": [ - "screen" + "screen", + "kind" + ], + "NavigateBackAction": [ + "kind" ], - "NavigateBackAction": [], "OpenDialogAction": [ - "dialogTemplate" + "dialogTemplate", + "kind" + ], + "CloseDialogAction": [ + "kind" ], - "CloseDialogAction": [], "RefreshQueryAction": [ - "query" + "query", + "kind" ], "SetStateAction": [ "target", - "value" + "value", + "kind" ], "NotifyAction": [ "level", - "message" + "message", + "kind" ], "ConfirmAction": [ - "message" + "message", + "kind" ], "RaiseTriggerAction": [ - "trigger" + "trigger", + "kind" ], "InteractionArgument": [ "name", @@ -417,6 +430,18 @@ "Info", "Warning", "Error" + ], + "InteractionActionKind": [ + "ExecuteCommand", + "Navigate", + "NavigateBack", + "OpenDialog", + "CloseDialog", + "RefreshQuery", + "SetState", + "Notify", + "Confirm", + "RaiseTrigger" ] } } From 522719cca944852d9713314262e2970b4d92bb0d Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 23 Sep 2026 13:01:40 +0200 Subject: [PATCH 4/8] Pin the interaction rules with a shared fixture corpus The ordering and sequencing rules are the two most likely to drift, because both are easy to restate slightly differently and neither is visible in a type. They are now data rather than prose. Writing the corpus immediately found two defects the unit specs had missed, which is the argument for having it. Resolution did not really order by containment at all - it preserved whatever order the caller happened to pass. The unit spec only agreed with it because it listed attachments outermost-first. An attachment now carries an optional depth, so a caller walking a tree upwards from the element gets the same resolved order as one walking downwards, rather than silently getting the reverse. An unknown action kind was reported and then the sequence carried on. Reporting it and continuing is close to the worst option available: it half-applies a sequence whose gate may well have been the action this renderer could not understand. It now abandons the sequence, which is what the fail-closed posture said all along and what the single-action unit spec was too small to catch. Unlike the other corpora in this repository this one currently has a single consumer, because there is no C# interaction engine - the engine runs where the interaction happens. That is recorded in the corpus itself so the asymmetry is deliberate rather than an oversight waiting to be discovered. --- ...nning_against_the_shared_fixture_corpus.ts | 124 ++++++ Source/JavaScript/engine/resolveBehaviors.ts | 11 +- Source/JavaScript/engine/runActions.ts | 7 +- interaction-fixtures.json | 373 ++++++++++++++++++ 4 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 Source/JavaScript/engine/for_runActions/when_running_against_the_shared_fixture_corpus.ts create mode 100644 interaction-fixtures.json diff --git a/Source/JavaScript/engine/for_runActions/when_running_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_runActions/when_running_against_the_shared_fixture_corpus.ts new file mode 100644 index 00000000..f2f2c710 --- /dev/null +++ b/Source/JavaScript/engine/for_runActions/when_running_against_the_shared_fixture_corpus.ts @@ -0,0 +1,124 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { InteractionAction, InteractionActionKind, InteractionTriggerKind, NotificationLevel } from '@cratis/scene.model'; +import { BehaviorAttachment, resolveBehaviors } from '../resolveBehaviors'; +import { InteractionStop, runActions } from '../runActions'; +import { pathEchoingContext, RecordingDispatcher } from './given/a_recording_dispatcher'; + +interface FixtureBehavior { + name: string | null; + trigger: keyof typeof InteractionTriggerKind; + order?: number; +} + +interface FixtureAttachment { + level: string; + depth?: number; + behaviors: FixtureBehavior[]; +} + +interface ResolutionCase { + name: string; + trigger: keyof typeof InteractionTriggerKind; + attachments: FixtureAttachment[]; + expected: (string | null)[]; +} + +interface FixtureAction { + kind: string; + command?: string; + query?: string; + screen?: string; + dialogTemplate?: string; + level?: keyof typeof NotificationLevel; + text?: string; + onSuccess?: FixtureAction[]; + onFailure?: FixtureAction[]; + onResult?: FixtureAction[]; +} + +interface SequencingCase { + name: string; + actions: FixtureAction[]; + commandSucceeds: boolean; + confirmAnswer: boolean; + expectedCalls: string[]; + expectedStop: keyof typeof InteractionStop; + expectedFindings?: string[]; +} + +interface FixtureCorpus { + resolutionCases: ResolutionCase[]; + sequencingCases: SequencingCase[]; +} + +const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', 'interaction-fixtures.json'); +const corpus = JSON.parse(readFileSync(manifestPath, 'utf-8')) as FixtureCorpus; + +function attachment(fixture: FixtureAttachment): BehaviorAttachment { + return { + level: fixture.level, + depth: fixture.depth, + behaviors: fixture.behaviors.map(behavior => ({ + name: behavior.name ?? undefined, + order: behavior.order, + bindings: [ + { + trigger: { kind: InteractionTriggerKind[behavior.trigger] } as never, + actions: [], + }, + ], + })), + }; +} + +function action(fixture: FixtureAction): InteractionAction { + return { + kind: InteractionActionKind[fixture.kind as keyof typeof InteractionActionKind] ?? fixture.kind, + command: fixture.command, + query: fixture.query, + screen: fixture.screen, + dialogTemplate: fixture.dialogTemplate, + level: fixture.level === undefined ? undefined : NotificationLevel[fixture.level], + message: fixture.text === undefined ? undefined : { text: fixture.text }, + onSuccess: (fixture.onSuccess ?? []).map(action), + onFailure: (fixture.onFailure ?? []).map(action), + onResult: (fixture.onResult ?? []).map(action), + } as InteractionAction; +} + +// The dispatcher records the command name without its arguments, because the corpus pins the sequencing rule +// and not how a binding resolves - that is asserted by the unit specs next to this file. +function callsWithoutArguments(calls: string[]): string[] { + return calls.map(call => call.replace(/:\{.*\}$/, '')); +} + +describe('when running against the shared fixture corpus', () => { + for (const fixture of corpus.resolutionCases) { + it(`should resolve the case where ${fixture.name}`, () => { + const resolved = resolveBehaviors( + fixture.attachments.map(attachment), + InteractionTriggerKind[fixture.trigger] + ); + + resolved.map(candidate => candidate.behaviorName ?? null).should.eql(fixture.expected); + }); + } + + for (const fixture of corpus.sequencingCases) { + it(`should sequence the case where ${fixture.name}`, async () => { + const dispatcher = new RecordingDispatcher(); + dispatcher.commandSucceeds = fixture.commandSucceeds; + dispatcher.confirmAnswer = fixture.confirmAnswer; + + const run = await runActions(fixture.actions.map(action), dispatcher, pathEchoingContext); + + callsWithoutArguments(dispatcher.calls).should.eql(fixture.expectedCalls); + run.stop.should.equal(InteractionStop[fixture.expectedStop]); + run.findings.map(finding => finding.kind).should.eql(fixture.expectedFindings ?? []); + }); + } +}); diff --git a/Source/JavaScript/engine/resolveBehaviors.ts b/Source/JavaScript/engine/resolveBehaviors.ts index a4725007..b6e40509 100644 --- a/Source/JavaScript/engine/resolveBehaviors.ts +++ b/Source/JavaScript/engine/resolveBehaviors.ts @@ -12,6 +12,15 @@ import { Behavior, InteractionBinding, InteractionTrigger, InteractionTriggerKin export interface BehaviorAttachment { level: string; behaviors: Behavior[]; + + /** + * How deeply nested this attachment is, with 0 the outermost. + * + * Optional, defaulting to the position in the list. Stating it explicitly is what lets a caller assemble the + * list in whatever order is convenient - walking a tree upwards from the element, for instance - without the + * resolved order silently depending on that choice. + */ + depth?: number; } /** @@ -57,7 +66,7 @@ export function resolveBehaviors(attachments: BehaviorAttachment[], kind: Intera resolved.push({ entry: { level: attachment.level, behaviorName: behavior.name, binding }, - level, + level: attachment.depth ?? level, declaration: declaration++, order: behavior.order, }); diff --git a/Source/JavaScript/engine/runActions.ts b/Source/JavaScript/engine/runActions.ts index c15faf76..04646a22 100644 --- a/Source/JavaScript/engine/runActions.ts +++ b/Source/JavaScript/engine/runActions.ts @@ -118,8 +118,9 @@ function messageOf(message: InteractionMessage, context: InteractionContext, fin * - A navigation stops the sequence, because the screen the remaining actions were written for is gone. The * compiler reports that as unreachable, so reaching it at runtime means the document changed underneath. * - An action that can fail branches into `onSuccess` or `onFailure`; a dialog also runs `onResult`. - * - An action kind the engine does not know is **reported and skipped**, never silently ignored - which is what - * lets a newer document be opened by an older renderer without pretending it worked. + * - An action kind the engine does not know is **reported and the sequence abandoned**, never silently skipped. + * Continuing would half-apply a sequence whose gate may well have been the action this renderer could not + * understand. This is what lets a newer document be opened by an older renderer without pretending it worked. * * @param actions The actions to run. * @param dispatcher The seam that performs the effects. @@ -228,6 +229,6 @@ async function runOne( default: run.findings.push({ kind: 'unknownAction', detail: `The renderer does not know the action kind '${String(action.kind)}'` }); run.stop = InteractionStop.Reported; - return false; + return true; } } diff --git a/interaction-fixtures.json b/interaction-fixtures.json new file mode 100644 index 00000000..f65712e6 --- /dev/null +++ b/interaction-fixtures.json @@ -0,0 +1,373 @@ +{ + "description": "Shared behavior corpus for the interaction engine - resolveBehaviors.ts and runActions.ts (@cratis/scene.engine). Every case here is asserted independently of the unit specs, so the two rules most likely to drift - which behaviors a trigger runs and in what order, and where a sequence stops - are pinned by data rather than by prose. Same pattern as screen-template-fixtures.json and layout-evaluation-fixtures.json. NOTE: unlike those corpora, this one currently has a single consumer. There is no C# interaction engine yet; the sequencing rules live only in TypeScript, because the engine runs where the interaction happens. When a second implementation appears it asserts these same cases, which is the reason the corpus is a file rather than a fixture module.", + "resolutionCases": [ + { + "name": "every matching attachment runs, not only the most specific", + "trigger": "Click", + "attachments": [ + { + "level": "layout", + "behaviors": [ + { + "name": "LayoutWide", + "trigger": "Click" + } + ] + }, + { + "level": "module", + "behaviors": [ + { + "name": "ModuleWide", + "trigger": "Click" + } + ] + }, + { + "level": "element", + "behaviors": [ + { + "name": null, + "trigger": "Click" + } + ] + } + ], + "expected": [ + "LayoutWide", + "ModuleWide", + null + ] + }, + { + "name": "the outermost attachment runs first even when the list was built innermost-first", + "trigger": "Click", + "attachments": [ + { + "level": "element", + "depth": 2, + "behaviors": [ + { + "name": "Innermost", + "trigger": "Click" + } + ] + }, + { + "level": "screen", + "depth": 1, + "behaviors": [ + { + "name": "Middle", + "trigger": "Click" + } + ] + }, + { + "level": "module", + "depth": 0, + "behaviors": [ + { + "name": "Outermost", + "trigger": "Click" + } + ] + } + ], + "expected": [ + "Outermost", + "Middle", + "Innermost" + ] + }, + { + "name": "an explicit order wins over position", + "trigger": "Click", + "attachments": [ + { + "level": "layout", + "behaviors": [ + { + "name": "LayoutWide", + "trigger": "Click" + } + ] + }, + { + "level": "element", + "behaviors": [ + { + "name": "RunsFirst", + "trigger": "Click", + "order": 1 + } + ] + } + ], + "expected": [ + "RunsFirst", + "LayoutWide" + ] + }, + { + "name": "declaration order decides between two behaviors at the same level", + "trigger": "Click", + "attachments": [ + { + "level": "screen", + "behaviors": [ + { + "name": "First", + "trigger": "Click" + }, + { + "name": "Second", + "trigger": "Click" + } + ] + } + ], + "expected": [ + "First", + "Second" + ] + }, + { + "name": "a binding for another trigger is not resolved", + "trigger": "Click", + "attachments": [ + { + "level": "screen", + "behaviors": [ + { + "name": "OnSubmitOnly", + "trigger": "Submit" + }, + { + "name": "OnClick", + "trigger": "Click" + } + ] + } + ], + "expected": [ + "OnClick" + ] + }, + { + "name": "an attachment point with nothing attached resolves to nothing", + "trigger": "Click", + "attachments": [ + { + "level": "element", + "behaviors": [] + } + ], + "expected": [] + } + ], + "sequencingCases": [ + { + "name": "actions run in the order they were declared", + "actions": [ + { + "kind": "RefreshQuery", + "query": "Invoices" + }, + { + "kind": "Notify", + "level": "Info", + "text": "Done" + } + ], + "commandSucceeds": true, + "confirmAnswer": true, + "expectedCalls": [ + "refreshQuery:Invoices", + "notify:Info:Done" + ], + "expectedStop": "Completed" + }, + { + "name": "a command that succeeds takes the success branch", + "actions": [ + { + "kind": "ExecuteCommand", + "command": "RegisterInvoice", + "onSuccess": [ + { + "kind": "CloseDialog" + } + ], + "onFailure": [ + { + "kind": "Notify", + "level": "Error", + "text": "Failed" + } + ] + } + ], + "commandSucceeds": true, + "confirmAnswer": true, + "expectedCalls": [ + "executeCommand:RegisterInvoice", + "closeDialog" + ], + "expectedStop": "Completed" + }, + { + "name": "a command that fails takes the failure branch", + "actions": [ + { + "kind": "ExecuteCommand", + "command": "RegisterInvoice", + "onSuccess": [ + { + "kind": "CloseDialog" + } + ], + "onFailure": [ + { + "kind": "Notify", + "level": "Error", + "text": "Failed" + } + ] + } + ], + "commandSucceeds": false, + "confirmAnswer": true, + "expectedCalls": [ + "executeCommand:RegisterInvoice", + "notify:Error:Failed" + ], + "expectedStop": "Completed" + }, + { + "name": "a declined confirm stops the sequence, because gating what follows is the whole point of it", + "actions": [ + { + "kind": "Confirm", + "text": "Are you sure?", + "onSuccess": [ + { + "kind": "ExecuteCommand", + "command": "CancelInvoice" + } + ] + }, + { + "kind": "Notify", + "level": "Info", + "text": "Never runs" + } + ], + "commandSucceeds": true, + "confirmAnswer": false, + "expectedCalls": [ + "confirm:Are you sure?" + ], + "expectedStop": "Declined" + }, + { + "name": "an accepted confirm runs what it gates", + "actions": [ + { + "kind": "Confirm", + "text": "Are you sure?", + "onSuccess": [ + { + "kind": "ExecuteCommand", + "command": "CancelInvoice" + } + ] + } + ], + "commandSucceeds": true, + "confirmAnswer": true, + "expectedCalls": [ + "confirm:Are you sure?", + "executeCommand:CancelInvoice" + ], + "expectedStop": "Completed" + }, + { + "name": "a navigation stops the sequence, because the screen the rest was written for is gone", + "actions": [ + { + "kind": "Navigate", + "screen": "InvoiceList" + }, + { + "kind": "Notify", + "level": "Info", + "text": "Never runs" + } + ], + "commandSucceeds": true, + "confirmAnswer": true, + "expectedCalls": [ + "navigate:InvoiceList" + ], + "expectedStop": "Navigated" + }, + { + "name": "navigating back stops the sequence for the same reason", + "actions": [ + { + "kind": "NavigateBack" + }, + { + "kind": "CloseDialog" + } + ], + "commandSucceeds": true, + "confirmAnswer": true, + "expectedCalls": [ + "navigateBack" + ], + "expectedStop": "Navigated" + }, + { + "name": "a dialog's result continuation runs after it closes", + "actions": [ + { + "kind": "OpenDialog", + "dialogTemplate": "InvoiceDetails", + "onResult": [ + { + "kind": "RefreshQuery", + "query": "Invoices" + } + ] + } + ], + "commandSucceeds": true, + "confirmAnswer": true, + "expectedCalls": [ + "openDialog:InvoiceDetails", + "refreshQuery:Invoices" + ], + "expectedStop": "Completed" + }, + { + "name": "an action kind the engine does not know is reported and the sequence abandoned", + "actions": [ + { + "kind": "SomethingFromALaterVersion" + }, + { + "kind": "CloseDialog" + } + ], + "commandSucceeds": true, + "confirmAnswer": true, + "expectedCalls": [], + "expectedStop": "Reported", + "expectedFindings": [ + "unknownAction" + ] + } + ] +} From 3629981f59ca55734f5c80bc8c359bdec83d10d2 Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 23 Sep 2026 14:04:39 +0200 Subject: [PATCH 5/8] Give a deployment target its own size vocabulary A ui profile's 'target size' and an arrangement's size class were the same enum, and they are not the same idea. An arrangement axis distinguishes one thing - whether it is cramped - so it has two values. What a target assumes is a coarser statement about the device, and Screenplay gives it three: compact, regular and expanded. Sharing the two-valued enum meant a document saying 'target size expanded' - valid Screenplay, used in Screenplay's own documentation - threw on translation instead of rendering. Reusing an enum because its first two values happened to match is the kind of economy that reads as correct until the third value exists. TargetSizeClass is that third value's home. Nothing consumed the profile's size class as a matrix point, and nothing could have meant one: Screenplay has no per-axis target size syntax, so the only reachable values were the two diagonal ones. Mapping the coarse class onto the matrix is now a single explicit step where an arrangement is evaluated, which is where the two vocabularies have to meet and the only place that should know how. --- Source/DotNET/Model/Profiles/UiProfile.cs | 2 +- .../Model/SizeClasses/TargetSizeClass.cs | 29 +++++++++++++++++++ Source/JavaScript/model/profiles/UiProfile.ts | 4 +-- .../model/sizeClasses/TargetSizeClass.ts | 16 ++++++++++ Source/JavaScript/model/sizeClasses/index.ts | 1 + scene-model-shape.json | 5 ++++ 6 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 Source/DotNET/Model/SizeClasses/TargetSizeClass.cs create mode 100644 Source/JavaScript/model/sizeClasses/TargetSizeClass.ts diff --git a/Source/DotNET/Model/Profiles/UiProfile.cs b/Source/DotNET/Model/Profiles/UiProfile.cs index 235ad076..d38bbb05 100644 --- a/Source/DotNET/Model/Profiles/UiProfile.cs +++ b/Source/DotNET/Model/Profiles/UiProfile.cs @@ -36,6 +36,6 @@ public record UiProfile( string Name, string TargetPlatform, IReadOnlyList Packages, - SizeClass? DefaultSizeClass = null, + TargetSizeClass? DefaultSizeClass = null, string? Layout = null, string? Theme = null); diff --git a/Source/DotNET/Model/SizeClasses/TargetSizeClass.cs b/Source/DotNET/Model/SizeClasses/TargetSizeClass.cs new file mode 100644 index 00000000..5a105e89 --- /dev/null +++ b/Source/DotNET/Model/SizeClasses/TargetSizeClass.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Model.SizeClasses; + +/// +/// Defines the size a deployment target assumes by default. +/// +/// +/// Deliberately a different vocabulary from and . +/// Those two form the matrix an arrangement resolves against, where the distinction that matters is only +/// whether an axis is cramped. What a target assumes is a coarser, one-dimensional statement about the device +/// - a desktop is not merely "regular" - and it carries a third value because of it. +/// +/// Treating the two as one vocabulary is what made a document declaring target size expanded - which +/// Screenplay admits and its own documentation uses - crash the translation instead of rendering. +/// +/// +public enum TargetSizeClass +{ + /// A phone, or a window narrowed to the same effect. + Compact = 0, + + /// A tablet, or a partially sized window. + Regular = 1, + + /// A desktop, or anything with room to spare. + Expanded = 2 +} diff --git a/Source/JavaScript/model/profiles/UiProfile.ts b/Source/JavaScript/model/profiles/UiProfile.ts index 4904acc7..4101c0ec 100644 --- a/Source/JavaScript/model/profiles/UiProfile.ts +++ b/Source/JavaScript/model/profiles/UiProfile.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { SizeClass } from '../sizeClasses'; +import { TargetSizeClass } from '../sizeClasses'; /** * A named target: a platform, a default size class, an ordered list of component packages, and the @@ -33,7 +33,7 @@ export interface UiProfile { /** * The size class assumed when the renderer cannot otherwise determine one. */ - defaultSizeClass?: SizeClass; + defaultSizeClass?: TargetSizeClass; /** * The name of the {@link Layout} this profile renders inside — the application's base navigational diff --git a/Source/JavaScript/model/sizeClasses/TargetSizeClass.ts b/Source/JavaScript/model/sizeClasses/TargetSizeClass.ts new file mode 100644 index 00000000..8ecfa180 --- /dev/null +++ b/Source/JavaScript/model/sizeClasses/TargetSizeClass.ts @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Defines the size a deployment target assumes by default. + * + * Deliberately a different vocabulary from {@link WidthSizeClass} and {@link HeightSizeClass}. Those two form + * the matrix an arrangement resolves against, where the only distinction that matters is whether an axis is + * cramped. What a target assumes is a coarser, one-dimensional statement about the device - a desktop is not + * merely 'regular' - and it carries a third value because of it. + */ +export enum TargetSizeClass { + Compact = 'Compact', + Regular = 'Regular', + Expanded = 'Expanded' +} diff --git a/Source/JavaScript/model/sizeClasses/index.ts b/Source/JavaScript/model/sizeClasses/index.ts index 4911a7ed..1723cee6 100644 --- a/Source/JavaScript/model/sizeClasses/index.ts +++ b/Source/JavaScript/model/sizeClasses/index.ts @@ -4,3 +4,4 @@ export * from './WidthSizeClass'; export * from './HeightSizeClass'; export * from './SizeClass'; +export * from './TargetSizeClass'; diff --git a/scene-model-shape.json b/scene-model-shape.json index 613eb044..ae60e47d 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -442,6 +442,11 @@ "Notify", "Confirm", "RaiseTrigger" + ], + "TargetSizeClass": [ + "Compact", + "Regular", + "Expanded" ] } } From da74815243a47356b9b491081ec4db98d0ee80e8 Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 23 Sep 2026 15:49:36 +0200 Subject: [PATCH 6/8] Wire interactions into the React renderer Until now a document could declare what a click does and the model could carry it, but nothing in a browser ran it. This is the last hop. useInteractions resolves and runs; it decides nothing. Which bindings a trigger runs, in what order, whether a guard lets them, and where a sequence stops all stay in the engine, so a renderer that is not React inherits the same semantics rather than reimplementing them - and so all of it remains testable without a DOM. Handlers are attached only for triggers a document actually uses. An empty handler on every element would make everything look interactive and, worse, would stop clicks reaching whatever encloses it. For the same reason a handler that runs stops propagation: a row inside a table where both declare a click would otherwise run the table's binding twice. Lifecycle triggers are the hook's because React is what knows when an element mounts. Running unload and leave from the cleanup is what makes them reliable - React guarantees it, a component remembering to call something on the way out does not. An interval becomes a timer cleared on unmount, because a document asking for something every minute means while it is on screen. The scope carries the dispatcher and the enclosing attachments, and nests: a scope inside another appends rather than replaces. That is what keeps the additive rule true in a renderer, so a module's behavior is still in scope at a button six levels down without the button knowing the module exists. Handlers go to the component rather than onto a wrapper the renderer invents, because only the component knows which node they belong on - on a button it is the button, on a table it is the row. Wrapping would have meant a renderer silently changing the layout of every interactive element. The browser dispatcher reports what the host cannot do instead of ignoring it, and an unavailable command reports failure so the document's own 'on failure' runs. A command that never reached a backend must not look like one that worked. --- Source/JavaScript/engine/runActions.ts | 52 +++++- Source/JavaScript/react/core/CoreButton.tsx | 4 +- ...ing_an_element_that_executes_a_command.tsx | 141 +++++++++++++++ Source/JavaScript/react/index.ts | 1 + .../react/interactions/InteractionScope.tsx | 74 ++++++++ .../interactions/createBrowserDispatcher.ts | 160 ++++++++++++++++++ Source/JavaScript/react/interactions/index.ts | 6 + .../react/interactions/useInteractions.ts | 116 +++++++++++++ .../react/renderer/ComponentRegistry.ts | 10 ++ .../react/renderer/InteractiveComponent.tsx | 25 +++ .../react/renderer/createReactRenderer.tsx | 3 +- Source/JavaScript/react/renderer/index.ts | 1 + 12 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 Source/JavaScript/react/for_useInteractions/when_clicking_an_element_that_executes_a_command.tsx create mode 100644 Source/JavaScript/react/interactions/InteractionScope.tsx create mode 100644 Source/JavaScript/react/interactions/createBrowserDispatcher.ts create mode 100644 Source/JavaScript/react/interactions/index.ts create mode 100644 Source/JavaScript/react/interactions/useInteractions.ts create mode 100644 Source/JavaScript/react/renderer/InteractiveComponent.tsx diff --git a/Source/JavaScript/engine/runActions.ts b/Source/JavaScript/engine/runActions.ts index 04646a22..1205a4a2 100644 --- a/Source/JavaScript/engine/runActions.ts +++ b/Source/JavaScript/engine/runActions.ts @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { InteractionBinding } from '@cratis/scene.model'; +import { ResolvedBinding } from './resolveBehaviors'; import { ConfirmAction, ExecuteCommandAction, @@ -43,7 +45,7 @@ export enum InteractionStop { * application, and 'this application produced no findings' has to be checkable rather than a matter of opinion. */ export interface InteractionFinding { - kind: 'unknownAction' | 'unresolvedMessage'; + kind: 'unknownAction' | 'unresolvedMessage' | 'unresolvedCondition'; detail: string; } @@ -232,3 +234,51 @@ async function runOne( return true; } } + +/** + * Runs the bindings a trigger resolved to, in order, honouring each one's guard. + * + * The `where` guard belongs here rather than in a renderer for the same reason the sequencing does: whether a + * binding applies is a property of the document, not of the event that arrived. A guard that resolves to + * nothing is treated as false and reported - an interaction that silently does nothing is indistinguishable + * from one that is broken, and the whole point of a finding is to tell those apart. + * + * @param bindings The bindings to run, already resolved and ordered. + * @param dispatcher The seam that performs the effects. + * @param context How to resolve bindings and localization. + * @returns What happened across all of them. + */ +export async function runBindings( + bindings: ResolvedBinding[], + dispatcher: ActionDispatcher, + context: InteractionContext +): Promise { + const run: InteractionRun = { stop: InteractionStop.Completed, findings: [], ran: [] }; + + for (const candidate of bindings) { + if (!applies(candidate.binding, context, run)) continue; + + await runSequence(candidate.binding.actions ?? [], dispatcher, context, run); + + // A navigation or a declined confirm ends the interaction, not merely the binding that caused it. + // The screen the later bindings were attached to is gone, or the gate they sat behind was refused. + if (run.stop !== InteractionStop.Completed) break; + } + + return run; +} + +function applies(binding: InteractionBinding, context: InteractionContext, run: InteractionRun): boolean { + if (!binding.condition) return true; + + const value = context.resolve(binding.condition); + if (value === undefined || value === null) { + run.findings.push({ + kind: 'unresolvedCondition', + detail: `The guard '${binding.condition.path}' resolved to nothing, so the binding did not run`, + }); + return false; + } + + return Boolean(value); +} diff --git a/Source/JavaScript/react/core/CoreButton.tsx b/Source/JavaScript/react/core/CoreButton.tsx index 8c6211f2..52fb4f94 100644 --- a/Source/JavaScript/react/core/CoreButton.tsx +++ b/Source/JavaScript/react/core/CoreButton.tsx @@ -6,10 +6,10 @@ import { RegisteredComponentProps } from '../renderer'; /** * The `core:button` component - part of the minimum vocabulary a `ui profile` can always fall back to. */ -export function CoreButton({ element, slots }: RegisteredComponentProps) { +export function CoreButton({ element, slots, interactions }: RegisteredComponentProps) { const label = typeof element.properties.label === 'string' ? element.properties.label : ''; return ( - diff --git a/Source/JavaScript/react/for_useInteractions/when_clicking_an_element_that_executes_a_command.tsx b/Source/JavaScript/react/for_useInteractions/when_clicking_an_element_that_executes_a_command.tsx new file mode 100644 index 00000000..8b43587d --- /dev/null +++ b/Source/JavaScript/react/for_useInteractions/when_clicking_an_element_that_executes_a_command.tsx @@ -0,0 +1,141 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ActionDispatcher, CommandOutcome, InteractionContext } from '@cratis/scene.engine'; +import { Behavior, InteractionActionKind, InteractionTriggerKind, NotificationLevel } from '@cratis/scene.model'; +import { InteractionScope, useInteractions } from '../interactions'; + +const calls: string[] = []; + +const dispatcher = { + async executeCommand(command: string, args: Record): Promise { + calls.push(`executeCommand:${command}:${JSON.stringify(args)}`); + return { isSuccess: true }; + }, + navigate: (screenName: string) => { calls.push(`navigate:${screenName}`); }, + navigateBack: () => { calls.push('navigateBack'); }, + openDialog: async () => { calls.push('openDialog'); return { isConfirmed: true }; }, + closeDialog: () => { calls.push('closeDialog'); }, + refreshQuery: async (query: string) => { calls.push(`refreshQuery:${query}`); }, + setState: (target: string) => { calls.push(`setState:${target}`); }, + notify: (level: NotificationLevel, message: string) => { calls.push(`notify:${level}:${message}`); }, + confirm: async () => { calls.push('confirm'); return true; }, + raise: async (trigger: string) => { calls.push(`raise:${trigger}`); }, +} satisfies ActionDispatcher; + +const context: InteractionContext = { resolve: binding => `<${binding.path}>` }; + +const cancelOnClick: Behavior = { + name: 'CancelInvoice', + bindings: [ + { + trigger: { kind: InteractionTriggerKind.Click } as never, + actions: [ + { + kind: InteractionActionKind.ExecuteCommand, + command: 'CancelInvoice', + arguments: [{ name: 'invoiceId', value: { path: 'item.invoiceId' } }], + onSuccess: [{ kind: InteractionActionKind.RefreshQuery, query: 'Invoices' }], + } as never, + ], + }, + ], +}; + +function Button({ behaviors }: { behaviors?: Behavior[] }) { + const handlers = useInteractions('element', behaviors); + return ; +} + +function renderInScope(behaviors?: Behavior[]) { + return render( + +