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/Source/DotNET/Engine/Starters/StarterProfileBuilder.cs b/Source/DotNET/Engine/Starters/StarterProfileBuilder.cs index b5a69be8..7dc9e872 100644 --- a/Source/DotNET/Engine/Starters/StarterProfileBuilder.cs +++ b/Source/DotNET/Engine/Starters/StarterProfileBuilder.cs @@ -22,6 +22,6 @@ public static class StarterProfileBuilder /// The platform the gallery runs on (e.g. web). /// The size class assumed when the renderer cannot otherwise determine one. /// A named after the starter, scoped to exactly its own . - public static UiProfile BuildProfile(UiStarter starter, string targetPlatform, SizeClass? defaultSizeClass = null) => + public static UiProfile BuildProfile(UiStarter starter, string targetPlatform, TargetSizeClass? defaultSizeClass = null) => new(starter.Name, targetPlatform, starter.Packages, defaultSizeClass); } 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..9dee5d84 --- /dev/null +++ b/Source/DotNET/Model/Interactions/CloseDialogAction.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. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Dismisses the innermost dialog. +/// +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 new file mode 100644 index 00000000..5de33c11 --- /dev/null +++ b/Source/DotNET/Model/Interactions/ConfirmAction.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; + +/// +/// Gates the actions that follow on the user agreeing. +/// +/// What to ask. +public record ConfirmAction(InteractionMessage Message) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.Confirm; +} 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..9d641fef --- /dev/null +++ b/Source/DotNET/Model/Interactions/ExecuteCommandAction.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; + +/// +/// Submits a modeled command. +/// +/// The command to submit. +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 new file mode 100644 index 00000000..750603b6 --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionAction.cs @@ -0,0 +1,57 @@ +// 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 which effect this action is. + /// + public abstract InteractionActionKind Kind { get; } + + /// + /// 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/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/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..41315713 --- /dev/null +++ b/Source/DotNET/Model/Interactions/NavigateAction.cs @@ -0,0 +1,18 @@ +// 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 +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.Navigate; +} diff --git a/Source/DotNET/Model/Interactions/NavigateBackAction.cs b/Source/DotNET/Model/Interactions/NavigateBackAction.cs new file mode 100644 index 00000000..3fffb041 --- /dev/null +++ b/Source/DotNET/Model/Interactions/NavigateBackAction.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. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Pops navigation history. +/// +public record NavigateBackAction : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.NavigateBack; +} 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..6a2c047c --- /dev/null +++ b/Source/DotNET/Model/Interactions/NotifyAction.cs @@ -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. + +namespace Cratis.Scene.Model.Interactions; + +/// +/// Surfaces a message. +/// +/// How prominently to surface it. +/// The message. +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 new file mode 100644 index 00000000..5d732e21 --- /dev/null +++ b/Source/DotNET/Model/Interactions/OpenDialogAction.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; + +/// +/// Opens a dialog over the application. +/// +/// The dialog template to open. +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 new file mode 100644 index 00000000..2a4fb9ed --- /dev/null +++ b/Source/DotNET/Model/Interactions/RaiseTriggerAction.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; + +/// +/// Fires a declared application trigger. +/// +/// The application trigger to fire. +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 new file mode 100644 index 00000000..0367222a --- /dev/null +++ b/Source/DotNET/Model/Interactions/RefreshQueryAction.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; + +/// +/// Re-runs a query-backed element. +/// +/// The query to re-run. +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 new file mode 100644 index 00000000..4a6c7352 --- /dev/null +++ b/Source/DotNET/Model/Interactions/SetStateAction.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; + +/// +/// Writes screen state. +/// +/// The declared state name being written. +/// The value to write. +public record SetStateAction(string Target, BindingExpression Value) : InteractionAction +{ + /// + public override InteractionActionKind Kind => InteractionActionKind.SetState; +} 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/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/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/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/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/buildStarterProfile.ts b/Source/JavaScript/engine/buildStarterProfile.ts index 9e2d5010..65314ff4 100644 --- a/Source/JavaScript/engine/buildStarterProfile.ts +++ b/Source/JavaScript/engine/buildStarterProfile.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, UiProfile, UiStarter } from '@cratis/scene.model'; +import { TargetSizeClass, UiProfile, UiStarter } from '@cratis/scene.model'; /** * Builds the sandboxed {@link UiProfile} a {@link UiStarter}'s gallery boots through - part of @@ -16,6 +16,6 @@ import { SizeClass, UiProfile, UiStarter } from '@cratis/scene.model'; * @param defaultSizeClass The size class assumed when the renderer cannot otherwise determine one. * @returns A {@link UiProfile} named after the starter, scoped to exactly its own `packages`. */ -export function buildStarterProfile(starter: UiStarter, targetPlatform: string, defaultSizeClass?: SizeClass): UiProfile { +export function buildStarterProfile(starter: UiStarter, targetPlatform: string, defaultSizeClass?: TargetSizeClass): UiProfile { return { name: starter.name, targetPlatform, packages: starter.packages, defaultSizeClass }; } 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/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/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..b6e40509 --- /dev/null +++ b/Source/JavaScript/engine/resolveBehaviors.ts @@ -0,0 +1,137 @@ +// 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[]; + + /** + * 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; +} + +/** + * 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: attachment.depth ?? 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..1205a4a2 --- /dev/null +++ b/Source/JavaScript/engine/runActions.ts @@ -0,0 +1,284 @@ +// 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, + 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' | 'unresolvedCondition'; + 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 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. + * @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 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/model/elements/SceneElement.ts b/Source/JavaScript/model/elements/SceneElement.ts index cee0d326..af902db5 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..02a74bbc 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..1ff47c3b --- /dev/null +++ b/Source/JavaScript/model/interactions/CloseDialogAction.ts @@ -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. + +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 new file mode 100644 index 00000000..62a47ce5 --- /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', 'kind']; 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..f47a137c --- /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', 'kind']; diff --git a/Source/JavaScript/model/interactions/InteractionAction.ts b/Source/JavaScript/model/interactions/InteractionAction.ts new file mode 100644 index 00000000..94da1a94 --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionAction.ts @@ -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. + +import { InteractionActionKind } from './InteractionActionKind'; +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 { + kind: InteractionActionKind; + arguments?: InteractionArgument[]; + onSuccess?: InteractionAction[]; + onFailure?: InteractionAction[]; + onResult?: InteractionAction[]; +} + +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/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..06695fa7 --- /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', 'kind']; diff --git a/Source/JavaScript/model/interactions/NavigateBackAction.ts b/Source/JavaScript/model/interactions/NavigateBackAction.ts new file mode 100644 index 00000000..4200fd83 --- /dev/null +++ b/Source/JavaScript/model/interactions/NavigateBackAction.ts @@ -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. + +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/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..1eee5e04 --- /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', 'kind']; diff --git a/Source/JavaScript/model/interactions/OpenDialogAction.ts b/Source/JavaScript/model/interactions/OpenDialogAction.ts new file mode 100644 index 00000000..4f4602b9 --- /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', 'kind']; diff --git a/Source/JavaScript/model/interactions/RaiseTriggerAction.ts b/Source/JavaScript/model/interactions/RaiseTriggerAction.ts new file mode 100644 index 00000000..0abc91ef --- /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', 'kind']; diff --git a/Source/JavaScript/model/interactions/RefreshQueryAction.ts b/Source/JavaScript/model/interactions/RefreshQueryAction.ts new file mode 100644 index 00000000..88f96173 --- /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', 'kind']; diff --git a/Source/JavaScript/model/interactions/SetStateAction.ts b/Source/JavaScript/model/interactions/SetStateAction.ts new file mode 100644 index 00000000..4ec549ac --- /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', 'kind']; diff --git a/Source/JavaScript/model/interactions/index.ts b/Source/JavaScript/model/interactions/index.ts new file mode 100644 index 00000000..52466a35 --- /dev/null +++ b/Source/JavaScript/model/interactions/index.ts @@ -0,0 +1,26 @@ +// 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 './InteractionActionKind'; +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..ebafc6f8 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/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/screens/DialogTemplate.ts b/Source/JavaScript/model/screens/DialogTemplate.ts index 3d4325cb..88b0e6ca 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..51c4a62d 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..9ed983d7 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/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/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( + +