diff --git a/Documentation/architecture/guarded-actions.md b/Documentation/architecture/guarded-actions.md new file mode 100644 index 00000000..2016afa9 --- /dev/null +++ b/Documentation/architecture/guarded-actions.md @@ -0,0 +1,110 @@ +# Guarded actions and interactions + +A guarded action chooses the first command whose structured condition matches the current item. Scene renders one button, not one per branch. Selection is a presentation decision, **not authorization**: the host must still validate and authorize the command. + +## Guarded command action + +Use `core:guarded-action`. Existing Stage documents using `core:action` with an `alternatives` property are mapped to the same component. An ordinary `core:action` with `command` is unchanged. + +The open `properties` bag has this shape: + +```json +{ + "label": "Approve", + "item": { + "kind": "componentProperty", + "path": "selectedItem", + "componentId": "orders", + "componentPropertyPath": "selectedItem" + }, + "alternatives": [ + { + "command": "ApproveOrder", + "condition": { + "kind": "comparison", + "left": { "subject": "item.status" }, + "operator": "Equal", + "right": "Pending", + "enumeration": ["Pending", "Approved"] + }, + "arguments": { "orderId": { "subject": "item.id" } } + } + ], + "otherwise": { "outcome": "Hidden" }, + "navigateToScreen": "details", + "navigateByParameter": "id" +} +``` + +`item` is a normal Scene binding, resolved reactively by the renderer. When it is omitted, the current render data context is the subject. Scene never guesses a row from a query's first result. No selected item means no action, **even with an executable fallback**. + +Alternatives are checked in order; only the first match is selected. Omit `otherwise`, or use `{"outcome":"Hidden"}`, to hide the button when none matches. An executable fallback uses `{"outcome":"Execute","command":"ReviewOrder","arguments":{"orderId":{"subject":"item.id"}}}`. + +Arguments remain `{ "subject": "item.field" }` references, rather than binding expressions. Scene resolves them only at invocation. The dispatcher receives the selected `item` alongside the explicit arguments; an explicitly mapped argument named `item` takes precedence. Unresolved explicit arguments refuse execution. The button has `data-scene-command`, disables while its command is pending, and displays failures. A changed branch at click time causes a re-render without executing the stale choice. + +With an `InteractionScope`, Scene calls `dispatcher.executeCommand(command, args)` and navigates **only after a successful outcome**, using `dispatcher.navigate(screen, { [navigateByParameter]: item[navigateByParameter] })`. Without a navigation parameter, the arguments are empty. Failure does not select a different branch or run the fallback. + +Without an interaction scope, Scene dispatches `cratis.scene.command` on `window` with this detail: + +```typescript +{ + command, + element, + arguments: resolvedExplicitArguments, + item, + navigateToScreen, + navigateByParameter +} +``` + +That event is the host's execution seam. The host owns completion, failure reporting and navigation after success; Scene does not infer success from event delivery. The host must also prevent duplicate submissions while its asynchronous event handler runs. + +C# callers can use `GuardedActionProperties.Read(externalComponent)` and `ToProperties()` from `Cratis.Scene.Model.Guards` instead of hand-reading a JSON-backed dictionary. The matching TypeScript interfaces are exported by `@cratis/scene.model`. + +## Guard vocabulary + +A comparison contains `kind: "comparison"`, a `left` subject reference, an `operator` and a scalar `right` literal. Supported operators are `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Contains` and `StartsWith`. + +- Paths start with `item.` and may traverse nested object properties. Unsafe prototype paths and malformed paths are unsupported. +- Equality is type-strict. Missing fields never match. A **present null** matches a null literal with `Equal`; `NotEqual` involving null is false. +- Ordering requires numeric values; strings are not coerced to numbers. +- `Contains` supports a string substring or scalar array membership; `StartsWith` requires strings. String matching is case-sensitive. +- An optional `enumeration` lists member names in ordinal order. A subject ordinal or member name resolves to the member name. Enum names compare case-insensitively; unknown names and out-of-range ordinals do not match. +- A logical node is `{"kind":"logical","operator":"And","left":condition,"right":condition}`, or uses `Or`. Both branches must be understood, even if one branch would determine the answer. + +Unknown operators, node kinds and legacy string guards fail closed with `SCENE-GUARD-001`. A guarded action renders a disabled button and diagnostic rather than using its fallback. There is no JavaScript expression execution. + +`GuardConditions` and `GuardedSelection` in `Cratis.Scene.Engine.Guards` mirror `canEvaluateGuard`, `evaluateGuard`, `selectGuardedAction` and `selectGuardedInteraction` in `@cratis/scene.engine`. Their shared examples are in `guarded-selection-fixtures.json`. + +## Guarded interaction binding + +An interaction can select a whole action list instead of a single command: + +```json +{ + "trigger": { "kind": "Click" }, + "actions": [], + "alternatives": [ + { + "condition": { + "kind": "comparison", + "left": { "subject": "item.canApprove" }, + "operator": "Equal", + "right": true + }, + "actions": [{ "kind": "ExecuteCommand", "command": "ApproveOrder" }] + } + ], + "otherwise": [{ "kind": "OpenDialog", "dialogTemplate": "ReviewOrder" }] +} +``` + +Set `actions` to an empty list when supplying `alternatives`; combining a nonempty `actions` list with alternatives is refused. The gesture's subject comes from `InteractionContext.subject()` when supplied, then `resolve({path: 'item'})`. React supplies the render data context when no explicit subject callback exists. + +The engine reads the subject once, selects once, and runs the chosen list with the existing sequencing rules. A command failure never falls through to another branch. With no subject, nothing runs, including `otherwise`. With an evaluable no-match and no `otherwise`, nothing runs. An unknown predicate reports an `unevaluableCondition` finding, with `SCENE-GUARD-001`, and does not execute otherwise. + +The existing binding-expression `condition` remains unchanged, including host-owned `$guard` resolution. Existing `actions`-only bindings and the three-parameter C# `InteractionBinding` constructor remain available. + +## Stage adoption + +After releasing Scene, Stage can bump its Scene npm and NuGet references, emit `core:guarded-action` with an explicit selected-item binding, emit one interaction binding with ordered alternatives, and provide the gesture subject. Stage must still supply command routes and forms for every alternative and executable fallback. Legacy string guards must be converted to structured guards, not evaluated as code. These Stage changes are a separate change; this Scene feature does not modify Stage or its refusal policy. diff --git a/Documentation/toc.yml b/Documentation/toc.yml index ea5fdde5..38ece499 100644 --- a/Documentation/toc.yml +++ b/Documentation/toc.yml @@ -7,6 +7,8 @@ href: architecture/layering.md - name: Binding resolution href: architecture/binding-resolution.md + - name: Guarded actions and interactions + href: architecture/guarded-actions.md - name: Glossary href: glossary.md - name: Packages diff --git a/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs b/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs new file mode 100644 index 00000000..76f84739 --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_GuardedSelection/when_running_the_shared_corpus.cs @@ -0,0 +1,69 @@ +// 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; +using Cratis.Scene.Engine.Guards; +using Cratis.Scene.Model.Guards; + +namespace Cratis.Scene.Engine.for_GuardedSelection; + +public class when_running_the_shared_corpus : Specification +{ + JsonElement _corpus; + + void Establish() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx"))) directory = directory.Parent; + _corpus = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory!.FullName, "guarded-selection-fixtures.json"))).RootElement; + } + + [Fact] + void should_preserve_present_nulls_when_evaluating_a_typed_guard() + { + var condition = new ComparisonGuardCondition(new("item.value"), GuardOperator.Equal, null); + GuardConditions.Evaluate(condition, new { value = (string?)null }).ShouldEqual(true); + } + + [Fact] + void should_evaluate_a_typed_enum_comparison() + { + var condition = new ComparisonGuardCondition(new("item.status"), GuardOperator.Equal, "approved", ["Pending", "Approved"]); + GuardConditions.Evaluate(condition, new { status = 1 }).ShouldEqual(true); + } + + [Fact] + void should_evaluate_every_shared_predicate() + { + foreach (var entry in _corpus.GetProperty("cases").EnumerateArray()) + { + var condition = entry.GetProperty("condition"); + GuardConditions.CanEvaluate(condition).ShouldEqual(entry.GetProperty("evaluable").GetBoolean()); + var expected = entry.GetProperty("result"); + GuardConditions.Evaluate(condition, entry.GetProperty("item")).ShouldEqual(expected.ValueKind == JsonValueKind.Null ? null : expected.GetBoolean()); + } + } + + [Fact] + void should_select_every_shared_interaction() + { + foreach (var entry in _corpus.GetProperty("interactions").EnumerateArray()) + { + var selection = GuardedSelection.SelectInteraction(entry.GetProperty("alternatives"), entry.GetProperty("item"), entry.GetProperty("hasOtherwise").GetBoolean()); + selection.Kind.ShouldEqual(entry.GetProperty("expected").GetProperty("kind").GetString()); + } + } + + [Fact] + void should_select_every_shared_branch() + { + foreach (var entry in _corpus.GetProperty("selections").EnumerateArray()) + { + entry.TryGetProperty("otherwise", out var otherwise); + var selected = GuardedSelection.SelectAction(entry.GetProperty("alternatives"), entry.GetProperty("item"), otherwise); + var expected = entry.GetProperty("expected"); + selected.Kind.ShouldEqual(expected.GetProperty("kind").GetString()); + selected.Index.ShouldEqual(expected.TryGetProperty("index", out var index) ? index.GetInt32() : null); + } + } +} diff --git a/Source/DotNET/Engine.Specs/for_GuardedSelection/when_selecting_from_written_properties.cs b/Source/DotNET/Engine.Specs/for_GuardedSelection/when_selecting_from_written_properties.cs new file mode 100644 index 00000000..8ade8a26 --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_GuardedSelection/when_selecting_from_written_properties.cs @@ -0,0 +1,35 @@ +// 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; +using Cratis.Scene.Engine.Guards; +using Cratis.Scene.Model.Guards; + +namespace Cratis.Scene.Engine.for_GuardedSelection; + +public class when_selecting_from_written_properties : Specification +{ + JsonElement _properties; + GuardedSelection _matching = null!; + GuardedSelection _fallback = null!; + + void Establish() + { + var properties = new GuardedActionProperties( + "Close", + [new("CloseTicket", new LogicalGuardCondition(new ComparisonGuardCondition(new("item.status"), GuardOperator.Equal, "Open"), LogicalGuardOperator.And, new ComparisonGuardCondition(new("item.amount"), GuardOperator.GreaterThan, 0)))], + Otherwise: new(GuardedActionFallbackOutcome.Execute, "CancelTicket")); + _properties = JsonSerializer.SerializeToElement(properties.ToProperties()); + } + + void Because() + { + var alternatives = _properties.GetProperty("alternatives"); + var otherwise = _properties.GetProperty("otherwise"); + _matching = GuardedSelection.SelectAction(alternatives, JsonSerializer.SerializeToElement(new { status = "Open", amount = 3 }), otherwise); + _fallback = GuardedSelection.SelectAction(alternatives, JsonSerializer.SerializeToElement(new { status = "Closed", amount = 3 }), otherwise); + } + + [Fact] void should_select_the_matching_alternative() => _matching.ShouldEqual(new GuardedSelection("alternative", 0)); + [Fact] void should_run_the_executing_fallback_when_nothing_matches() => _fallback.ShouldEqual(new GuardedSelection("otherwise")); +} diff --git a/Source/DotNET/Engine/Guards/GuardConditions.cs b/Source/DotNET/Engine/Guards/GuardConditions.cs new file mode 100644 index 00000000..dff5d361 --- /dev/null +++ b/Source/DotNET/Engine/Guards/GuardConditions.cs @@ -0,0 +1,164 @@ +// 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; +using Cratis.Scene.Model.Guards; + +namespace Cratis.Scene.Engine.Guards; + +/// +/// Evaluates the closed guard vocabulary without executing authored code. +/// +public static class GuardConditions +{ + static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web); + static readonly HashSet _forbidden = ["__proto__", "prototype", "constructor"]; + + /// + /// Evaluates a typed guard. Null means the shape is unsupported. + /// + /// The guard. + /// The selected subject. + /// Whether it holds, or null when unsupported. + public static bool? Evaluate(GuardCondition condition, object? item) => Evaluate(JsonSerializer.SerializeToElement(condition, _options), JsonSerializer.SerializeToElement(item, _options)); + + /// + /// Determines whether every node uses the supported guard vocabulary. + /// + /// The wire predicate. + /// Whether it is understood. + public static bool CanEvaluate(JsonElement condition) + { + var kind = Text(Get(condition, "kind")); + var operation = Text(Get(condition, "operator")); + var left = Get(condition, "left"); + var right = Get(condition, "right"); + if (kind == "logical") return (operation == "And" || operation == "Or") && CanEvaluate(left) && CanEvaluate(right); + if (kind != "comparison") return false; + return ValidComparison(condition, operation); + } + + /// + /// Evaluates a wire predicate against a wire subject. + /// + /// The wire predicate. + /// The selected subject. + /// Whether it holds, or null when unsupported. + public static bool? Evaluate(JsonElement condition, JsonElement item) + { + if (!CanEvaluate(condition)) return null; + var operation = Text(Get(condition, "operator")); + if (Text(Get(condition, "kind")) == "comparison") return Compare(condition, item, operation); + var left = Evaluate(Get(condition, "left"), item) == true; + var right = Evaluate(Get(condition, "right"), item) == true; + return operation == "And" ? left && right : left || right; + } + + static bool ValidComparison(JsonElement condition, string operation) + { + if (!ValidPath(Text(Get(Get(condition, "left"), "subject")))) return false; + if (!Enum.TryParse(operation, out var parsed)) return false; + if (!Enum.IsDefined(parsed) || operation != parsed.ToString()) return false; + return ValidEnumeration(Get(condition, "enumeration")) && ValidLiteral(parsed, Get(condition, "right")); + } + + static bool ValidEnumeration(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Undefined) return true; + if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() == 0) return false; + if (value.EnumerateArray().Any(member => member.ValueKind != JsonValueKind.String)) return false; + return value.EnumerateArray().Select(member => member.GetString()).Distinct().Count() == value.GetArrayLength(); + } + + static bool ValidLiteral(GuardOperator operation, JsonElement right) + { + if (!Scalar(right)) return false; + if (operation is GuardOperator.GreaterThan or GuardOperator.GreaterThanOrEqual or GuardOperator.LessThan or GuardOperator.LessThanOrEqual) return right.ValueKind == JsonValueKind.Number; + return operation != GuardOperator.StartsWith || right.ValueKind == JsonValueKind.String; + } + + static bool ValidPath(string path) => path.StartsWith("item.", StringComparison.Ordinal) && path[5..].Split('.').All(part => part.Length > 0 && (char.IsAsciiLetter(part[0]) || part[0] == '_') && part.All(character => char.IsAsciiLetterOrDigit(character) || character == '_') && !_forbidden.Contains(part)); + + static JsonElement Resolve(JsonElement reference, JsonElement item) + { + var path = Text(Get(reference, "subject")); + if (!ValidPath(path)) return default; + foreach (var part in path[5..].Split('.')) item = Get(item, part); + return item; + } + + static bool Compare(JsonElement condition, JsonElement item, string operation) + { + var enumeration = Get(condition, "enumeration"); + var left = Normalize(Resolve(Get(condition, "left"), item), enumeration); + var right = Normalize(Get(condition, "right"), enumeration); + if (left.ValueKind == JsonValueKind.Undefined || right.ValueKind == JsonValueKind.Undefined) return false; + if (left.ValueKind == JsonValueKind.Null || right.ValueKind == JsonValueKind.Null) return operation == "Equal" && left.ValueKind == JsonValueKind.Null && right.ValueKind == JsonValueKind.Null; + return CompareValues(left, right, operation); + } + + static bool CompareValues(JsonElement left, JsonElement right, string operation) => operation switch + { + "Equal" => Same(left, right), + "NotEqual" => Scalar(left) && Compatible(left, right) && !Same(left, right), + "Contains" => Contains(left, right), + "StartsWith" => left.ValueKind == JsonValueKind.String && right.ValueKind == JsonValueKind.String && left.GetString()!.StartsWith(right.GetString()!, StringComparison.Ordinal), + _ => Numeric(left, right, operation) + }; + + static JsonElement Normalize(JsonElement value, JsonElement enumeration) + { + if (enumeration.ValueKind != JsonValueKind.Array || value.ValueKind == JsonValueKind.Null) return value; + if (Ordinal(value, enumeration.GetArrayLength(), out var ordinal)) return enumeration[ordinal]; + if (value.ValueKind == JsonValueKind.String) return enumeration.EnumerateArray().FirstOrDefault(member => string.Equals(member.GetString(), value.GetString(), StringComparison.OrdinalIgnoreCase)); + return default; + } + + static bool Ordinal(JsonElement value, int length, out int ordinal) + { + ordinal = 0; + if (value.ValueKind != JsonValueKind.Number) return false; + var number = value.GetDouble(); + if (number != Math.Truncate(number)) return false; + if (number < 0 || number >= length) return false; + ordinal = (int)number; + return true; + } + + static bool Numeric(JsonElement left, JsonElement right, string operation) + { + if (left.ValueKind != JsonValueKind.Number || right.ValueKind != JsonValueKind.Number) return false; + var first = left.GetDouble(); + var second = right.GetDouble(); + return CompareNumbers(first, second, operation); + } + + static bool CompareNumbers(double first, double second, string operation) => operation switch + { + "GreaterThan" => first > second, + "GreaterThanOrEqual" => first >= second, + "LessThan" => first < second, + "LessThanOrEqual" => first <= second, + _ => false + }; + + static bool Contains(JsonElement left, JsonElement right) => left.ValueKind switch + { + JsonValueKind.String => right.ValueKind == JsonValueKind.String && left.GetString()!.Contains(right.GetString()!, StringComparison.Ordinal), + JsonValueKind.Array => left.EnumerateArray().Any(value => Same(value, right)), + _ => false + }; + + static bool Same(JsonElement left, JsonElement right) => Compatible(left, right) && left.ValueKind switch + { + JsonValueKind.String => left.GetString() == right.GetString(), + JsonValueKind.Number => left.GetDouble() == right.GetDouble(), + JsonValueKind.True or JsonValueKind.False => left.GetBoolean() == right.GetBoolean(), + _ => false + }; + + static bool Compatible(JsonElement left, JsonElement right) => left.ValueKind == right.ValueKind || (left.ValueKind is JsonValueKind.True or JsonValueKind.False && right.ValueKind is JsonValueKind.True or JsonValueKind.False); + static bool Scalar(JsonElement value) => value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null; + static string Text(JsonElement value) => value.ValueKind == JsonValueKind.String ? value.GetString()! : string.Empty; + static JsonElement Get(JsonElement value, string property) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(property, out var found) ? found : default; +} diff --git a/Source/DotNET/Engine/Guards/GuardedSelection.cs b/Source/DotNET/Engine/Guards/GuardedSelection.cs new file mode 100644 index 00000000..b130c33f --- /dev/null +++ b/Source/DotNET/Engine/Guards/GuardedSelection.cs @@ -0,0 +1,50 @@ +// 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; + +namespace Cratis.Scene.Engine.Guards; + +/// +/// The selected branch or its fail-closed disposition. +/// +/// Alternative, otherwise, hidden, noSubject or unevaluable, in wire casing. +/// The first matching alternative's zero-based index. +public record GuardedSelection(string Kind, int? Index = null) +{ + /// + /// Chooses exactly one ordered action branch. + /// + /// The alternatives containing conditions. + /// The selected subject. + /// The fallback object. + /// A branch index or fallback disposition. + public static GuardedSelection SelectAction(JsonElement alternatives, JsonElement item, JsonElement otherwise = default) + { + var selection = SelectInteraction(alternatives, item, Text(Get(otherwise, "outcome")) == "Execute"); + return selection.Kind == "unmatched" ? new("hidden") : selection; + } + + /// + /// Chooses one whole interaction branch, without executing it. + /// + /// The alternatives containing conditions. + /// The gesture subject. + /// Whether an otherwise action list exists. + /// A branch index or no-match disposition. + public static GuardedSelection SelectInteraction(JsonElement alternatives, JsonElement item, bool hasOtherwise = false) + { + if (item.ValueKind != JsonValueKind.Object) return new("noSubject"); + if (alternatives.ValueKind != JsonValueKind.Array || alternatives.EnumerateArray().Any(alternative => !GuardConditions.CanEvaluate(Get(alternative, "condition")))) return new("unevaluable"); + var index = 0; + foreach (var alternative in alternatives.EnumerateArray()) + { + if (GuardConditions.Evaluate(Get(alternative, "condition"), item) == true) return new("alternative", index); + index++; + } + return new(hasOtherwise ? "otherwise" : "unmatched"); + } + + static string Text(JsonElement value) => value.ValueKind == JsonValueKind.String ? value.GetString()! : string.Empty; + static JsonElement Get(JsonElement value, string property) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(property, out var found) ? found : default; +} diff --git a/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs b/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs index af248d8c..7443dba5 100644 --- a/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.cs +++ b/Source/DotNET/Model.Specs/for_ConstructorCompatibility/when_a_package_built_against_an_earlier_scene_constructs_a_record.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.Common; +using Cratis.Scene.Model.Interactions; using Cratis.Scene.Model.Profiles; using Cratis.Scene.Model.SampleData; using Cratis.Scene.Model.Screens; @@ -20,6 +22,7 @@ static readonly (Type Type, Type[] Parameters)[] _constructors = [ (typeof(UiProfile), [typeof(string), typeof(string), typeof(IReadOnlyList), typeof(TargetSizeClass?), typeof(string), typeof(string), typeof(SampleDataSelection), typeof(RootTemplate), typeof(ProfileAppearance), typeof(ProfileDefaults)]), (typeof(ProfileAppearance), [typeof(ColorScheme?), typeof(string)]), + (typeof(InteractionBinding), [typeof(InteractionTrigger), typeof(IReadOnlyList), typeof(BindingExpression)]), (typeof(SampleDataPackReference), [typeof(string), typeof(string), typeof(string), typeof(string)]), (typeof(TemplateMetadata), [typeof(string), typeof(string), typeof(IReadOnlyList), typeof(TemplateCompatibility), typeof(TemplateAttribution), typeof(string), typeof(string), typeof(IReadOnlyList), typeof(string), typeof(TemplateRole?), typeof(string)]) ]; @@ -30,6 +33,33 @@ static readonly (Type Type, Type[] Parameters)[] _constructors = [Fact] void should_still_have_every_constructor() => _missing.ShouldBeEmpty(); + [Fact] + void should_keep_the_three_output_interaction_deconstruct_signature() + { + var method = typeof(InteractionBinding).GetMethod("Deconstruct", + [ + typeof(InteractionTrigger).MakeByRefType(), + typeof(IReadOnlyList).MakeByRefType(), + typeof(BindingExpression).MakeByRefType() + ]); + method.ShouldNotBeNull(); + method.ReturnType.ShouldEqual(typeof(void)); + method.GetParameters().All(parameter => parameter.IsOut).ShouldBeTrue(); + } + + [Fact] + void should_deconstruct_the_first_three_interaction_members_using_the_earlier_syntax() + { + var expectedTrigger = new BuiltInInteractionTrigger(InteractionTriggerKind.Click); + IReadOnlyList expectedActions = []; + var expectedCondition = new BindingExpression("allowed"); + var (trigger, actions, condition) = new InteractionBinding(expectedTrigger, expectedActions, expectedCondition, Alternatives: [], Otherwise: []); + + trigger.ShouldEqual(expectedTrigger); + actions.ShouldEqual(expectedActions); + condition.ShouldEqual(expectedCondition); + } + [Fact] void should_build_the_same_profile_through_the_earlier_constructor() { diff --git a/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs b/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs new file mode 100644 index 00000000..068b2f0a --- /dev/null +++ b/Source/DotNET/Model.Specs/for_GuardedActionProperties/when_round_tripping_stage_properties.cs @@ -0,0 +1,62 @@ +// 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; +using Cratis.Scene.Model.Elements; +using Cratis.Scene.Model.Guards; +using Cratis.Scene.Model.Interactions; + +namespace Cratis.Scene.Model.for_GuardedActionProperties; + +public class when_round_tripping_stage_properties : Specification +{ + const string StageProperties = """ + {"label":"Approve","alternatives":[{"command":"Approve","condition":{"kind":"logical","operator":"And","left":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"Pending","enumeration":["Pending","Approved"]},"right":{"kind":"comparison","left":{"subject":"item.amount"},"operator":"GreaterThan","right":0}},"arguments":{"id":{"subject":"item.id"}}}],"otherwise":{"outcome":"Hidden"},"navigateToScreen":"detail","navigateByParameter":"id"} + """; + readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web); + GuardedActionProperties _properties = null!; + GuardedActionProperties _roundTrip = null!; + JsonElement _interaction; + + void Establish() + { + var bag = JsonSerializer.Deserialize>(StageProperties, _options)!; + _properties = GuardedActionProperties.Read(new ExternalComponent { Id = "approve", Name = "approve", ComponentName = GuardedActionProperties.ComponentName, Properties = bag }); + } + + void Because() + { + _roundTrip = GuardedActionProperties.Read(new ExternalComponent { Id = "approve", Name = "approve", ComponentName = GuardedActionProperties.ComponentName, Properties = _properties.ToProperties() }); + var binding = new InteractionBinding(new BuiltInInteractionTrigger(InteractionTriggerKind.Click), [], Alternatives: [new(_properties.Alternatives[0].Condition, [])], Otherwise: []); + _interaction = JsonSerializer.SerializeToElement(binding, _options); + } + + [Fact] + void should_preserve_a_null_literal_when_writing_the_property_bag() + { + var properties = new GuardedActionProperties("Check", [new("Check", new ComparisonGuardCondition(new("item.value"), GuardOperator.Equal, null))]); + var json = JsonSerializer.SerializeToElement(properties.ToProperties(), _options); + json.GetProperty("alternatives")[0].GetProperty("condition").GetProperty("right").ValueKind.ShouldEqual(JsonValueKind.Null); + } + + [Fact] + void should_write_canonical_guard_enumeration_values() + { + var properties = new GuardedActionProperties( + "Close", + [new("Close", new LogicalGuardCondition(new ComparisonGuardCondition(new("item.status"), GuardOperator.Equal, "Open"), LogicalGuardOperator.And, new ComparisonGuardCondition(new("item.amount"), GuardOperator.GreaterThan, 0)))], + Otherwise: new(GuardedActionFallbackOutcome.Execute, "Cancel")); + var json = JsonSerializer.SerializeToElement(properties.ToProperties(), _options); + var condition = json.GetProperty("alternatives")[0].GetProperty("condition"); + condition.GetProperty("operator").GetString().ShouldEqual("And"); + condition.GetProperty("left").GetProperty("operator").GetString().ShouldEqual("Equal"); + json.GetProperty("otherwise").GetProperty("outcome").GetString().ShouldEqual("Execute"); + } + + [Fact] void should_preserve_the_command() => _roundTrip.Alternatives[0].Command.ShouldEqual("Approve"); + [Fact] void should_accept_stage_properties_without_a_subject_binding() => _roundTrip.Item.ShouldBeNull(); + [Fact] void should_preserve_explicit_arguments() => _roundTrip.Alternatives[0].Arguments!["id"].Subject.ShouldEqual("item.id"); + [Fact] void should_preserve_the_nested_guard() => _roundTrip.Alternatives[0].Condition.ShouldBeOfExactType(); + [Fact] void should_preserve_interaction_branches() => _interaction.GetProperty("alternatives").GetArrayLength().ShouldEqual(1); + [Fact] void should_preserve_an_empty_fallback() => _interaction.GetProperty("otherwise").GetArrayLength().ShouldEqual(0); +} diff --git a/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs b/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs new file mode 100644 index 00000000..5400244f --- /dev/null +++ b/Source/DotNET/Model/Guards/ComparisonGuardCondition.cs @@ -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. + +using System.Text.Json.Serialization; + +namespace Cratis.Scene.Model.Guards; + +/// +/// Compares a subject field with a scalar literal. +/// +/// The subject field. +/// The comparison. +/// A string, number, boolean or null literal. +/// The member names, in ordinal order. +public record ComparisonGuardCondition( + SubjectReference Left, + GuardOperator Operator, + [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] object? Right, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? Enumeration = null) : GuardCondition; diff --git a/Source/DotNET/Model/Guards/GuardCondition.cs b/Source/DotNET/Model/Guards/GuardCondition.cs new file mode 100644 index 00000000..0b24d96c --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardCondition.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. + +using System.Text.Json.Serialization; + +namespace Cratis.Scene.Model.Guards; + +/// +/// A structured predicate over the selected item. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(ComparisonGuardCondition), "comparison")] +[JsonDerivedType(typeof(LogicalGuardCondition), "logical")] +public abstract record GuardCondition; diff --git a/Source/DotNET/Model/Guards/GuardOperator.cs b/Source/DotNET/Model/Guards/GuardOperator.cs new file mode 100644 index 00000000..3a232f93 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardOperator.cs @@ -0,0 +1,37 @@ +// 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.Guards; + +/// +/// The supported literal comparisons. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GuardOperator +{ + /// Equal values. + Equal = 0, + + /// Different non-null values. + NotEqual = 1, + + /// Numeric greater than. + GreaterThan = 2, + + /// Numeric greater than or equal. + GreaterThanOrEqual = 3, + + /// Numeric less than. + LessThan = 4, + + /// Numeric less than or equal. + LessThanOrEqual = 5, + + /// String or array containment. + Contains = 6, + + /// String prefix. + StartsWith = 7 +} diff --git a/Source/DotNET/Model/Guards/GuardedActionAlternative.cs b/Source/DotNET/Model/Guards/GuardedActionAlternative.cs new file mode 100644 index 00000000..f4f1af86 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardedActionAlternative.cs @@ -0,0 +1,12 @@ +// 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.Guards; + +/// +/// One ordered guarded command choice. +/// +/// The command to execute. +/// The predicate selecting this command. +/// Explicit subject input bindings. +public record GuardedActionAlternative(string Command, GuardCondition Condition, IReadOnlyDictionary? Arguments = null); diff --git a/Source/DotNET/Model/Guards/GuardedActionFallback.cs b/Source/DotNET/Model/Guards/GuardedActionFallback.cs new file mode 100644 index 00000000..647aa031 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardedActionFallback.cs @@ -0,0 +1,12 @@ +// 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.Guards; + +/// +/// The action used when no alternative matches. +/// +/// Whether to hide or execute. +/// The fallback command, when executable. +/// Explicit subject input bindings. +public record GuardedActionFallback(GuardedActionFallbackOutcome Outcome, string? Command = null, IReadOnlyDictionary? Arguments = null); diff --git a/Source/DotNET/Model/Guards/GuardedActionFallbackOutcome.cs b/Source/DotNET/Model/Guards/GuardedActionFallbackOutcome.cs new file mode 100644 index 00000000..1efc7d35 --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardedActionFallbackOutcome.cs @@ -0,0 +1,23 @@ +// 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.Guards; + +/// +/// What an action does when no alternative matches. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GuardedActionFallbackOutcome +{ + /// + /// Hide the action. + /// + Hidden = 0, + + /// + /// Execute the fallback command. + /// + Execute = 1 +} diff --git a/Source/DotNET/Model/Guards/GuardedActionProperties.cs b/Source/DotNET/Model/Guards/GuardedActionProperties.cs new file mode 100644 index 00000000..fc9965ef --- /dev/null +++ b/Source/DotNET/Model/Guards/GuardedActionProperties.cs @@ -0,0 +1,60 @@ +// 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; +using System.Text.Json.Serialization; +using Cratis.Scene.Model.Common; +using Cratis.Scene.Model.Elements; + +namespace Cratis.Scene.Model.Guards; + +/// +/// Typed access to a guarded action's open property bag. +/// +/// The button label. +/// The ordered command choices. +/// The selected-item binding. +/// The fallback; absent means hidden. +/// The screen opened after success. +/// The item field passed to navigation. +public record GuardedActionProperties( + string Label, + IReadOnlyList Alternatives, + BindingExpression? Item = null, + GuardedActionFallback? Otherwise = null, + string? NavigateToScreen = null, + string? NavigateByParameter = null) +{ + /// + /// The registered core component name. + /// + public const string ComponentName = "core:guarded-action"; + + static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + // The guard enumerations keep their canonical PascalCase wire values, which the engines match exactly. + new JsonStringEnumConverter(), + new JsonStringEnumConverter(), + new JsonStringEnumConverter(), + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) + } + }; + + /// + /// Reads properties serialized by Stage or constructed in memory. + /// + /// The component carrying the properties. + /// The typed properties. + public static GuardedActionProperties Read(ExternalComponent element) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(element.Properties, _options), _options)!; + + /// + /// Writes the typed properties into an open property bag. + /// + /// The component properties. + public IReadOnlyDictionary ToProperties() => + JsonSerializer.Deserialize>(JsonSerializer.Serialize(this, _options), _options)!; +} diff --git a/Source/DotNET/Model/Guards/LogicalGuardCondition.cs b/Source/DotNET/Model/Guards/LogicalGuardCondition.cs new file mode 100644 index 00000000..8ca16e40 --- /dev/null +++ b/Source/DotNET/Model/Guards/LogicalGuardCondition.cs @@ -0,0 +1,12 @@ +// 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.Guards; + +/// +/// Combines two structured predicates. +/// +/// The first predicate. +/// The logical operation. +/// The second predicate. +public record LogicalGuardCondition(GuardCondition Left, LogicalGuardOperator Operator, GuardCondition Right) : GuardCondition; diff --git a/Source/DotNET/Model/Guards/LogicalGuardOperator.cs b/Source/DotNET/Model/Guards/LogicalGuardOperator.cs new file mode 100644 index 00000000..d847b94d --- /dev/null +++ b/Source/DotNET/Model/Guards/LogicalGuardOperator.cs @@ -0,0 +1,23 @@ +// 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.Guards; + +/// +/// How two predicates combine. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LogicalGuardOperator +{ + /// + /// Both predicates must match. + /// + And = 0, + + /// + /// Either predicate must match. + /// + Or = 1 +} diff --git a/Source/DotNET/Model/Guards/SubjectReference.cs b/Source/DotNET/Model/Guards/SubjectReference.cs new file mode 100644 index 00000000..6383de3e --- /dev/null +++ b/Source/DotNET/Model/Guards/SubjectReference.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.Guards; + +/// +/// A path starting with item., resolved against the selected subject. +/// +/// The subject path. +public record SubjectReference(string Subject); diff --git a/Source/DotNET/Model/Interactions/InteractionAlternative.cs b/Source/DotNET/Model/Interactions/InteractionAlternative.cs new file mode 100644 index 00000000..cccbfb2d --- /dev/null +++ b/Source/DotNET/Model/Interactions/InteractionAlternative.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.Guards; + +namespace Cratis.Scene.Model.Interactions; + +/// +/// One whole action list selected by a predicate. +/// +/// The predicate selecting the branch. +/// The actions to run in order. +public record InteractionAlternative(GuardCondition Condition, IReadOnlyList Actions); diff --git a/Source/DotNET/Model/Interactions/InteractionBinding.cs b/Source/DotNET/Model/Interactions/InteractionBinding.cs index 76022af3..a0d35852 100644 --- a/Source/DotNET/Model/Interactions/InteractionBinding.cs +++ b/Source/DotNET/Model/Interactions/InteractionBinding.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Text.Json.Serialization; using Cratis.Scene.Model.Common; namespace Cratis.Scene.Model.Interactions; @@ -11,7 +12,37 @@ namespace Cratis.Scene.Model.Interactions; /// 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. +/// Ordered first-match branches; Actions is empty when these are supplied. +/// The action list used when no alternative matches. +[method: JsonConstructor] public record InteractionBinding( InteractionTrigger Trigger, IReadOnlyList Actions, - BindingExpression? Condition = null); + BindingExpression? Condition = null, + IReadOnlyList? Alternatives = null, + IReadOnlyList? Otherwise = null) +{ + /// + /// Preserves the constructor used by earlier packages. + /// + /// What starts it. + /// The actions to run. + /// The existing binding guard. + public InteractionBinding(InteractionTrigger trigger, IReadOnlyList actions, BindingExpression? condition) + : this(trigger, actions, condition, null, null) + { + } + + /// + /// Preserves the three-output deconstruction used by earlier packages. + /// + /// What starts it. + /// The actions to run. + /// The existing binding guard. + public void Deconstruct(out InteractionTrigger trigger, out IReadOnlyList actions, out BindingExpression? condition) + { + trigger = Trigger; + actions = Actions; + condition = Condition; + } +} diff --git a/Source/JavaScript/engine/for_runActions/when_selecting_a_guarded_branch.ts b/Source/JavaScript/engine/for_runActions/when_selecting_a_guarded_branch.ts new file mode 100644 index 00000000..c71de284 --- /dev/null +++ b/Source/JavaScript/engine/for_runActions/when_selecting_a_guarded_branch.ts @@ -0,0 +1,64 @@ +// 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 { GuardCondition, GuardOperator, InteractionBinding, InteractionActionKind, InteractionTriggerKind } from '@cratis/scene.model'; +import { runBindings } from '../runActions'; +import { RecordingDispatcher } from './given/a_recording_dispatcher'; + +const condition: GuardCondition = { kind: 'comparison', left: { subject: 'item.id' }, operator: GuardOperator.Equal, right: 1 }; +const command = (name: string) => ({ kind: InteractionActionKind.ExecuteCommand as const, command: name }); +const binding = (overrides: Partial = {}): InteractionBinding => ({ + trigger: { kind: InteractionTriggerKind.Click }, actions: [], + alternatives: [{ condition, actions: [command('first'), command('second')] }, { condition, actions: [command('never')] }], + otherwise: [command('fallback')], ...overrides, +}); + +async function run(value: InteractionBinding, subject: unknown = { id: 1 }, succeeds = true) { + const dispatcher = new RecordingDispatcher(); + dispatcher.commandSucceeds = succeeds; + let reads = 0; + const result = await runBindings([{ level: 'test', behaviorName: 'test', binding: value }], dispatcher, { resolve: () => undefined, subject: () => { reads++; return subject; } }); + return { dispatcher, result, reads }; +} + +const corpus: { guardedBindingCases: { name: string; subject: unknown; binding: InteractionBinding; expectedCalls: string[] }[] } = JSON.parse(readFileSync(join(import.meta.dirname, '../../../../interaction-fixtures.json'), 'utf8')); + +describe('when selecting a guarded interaction branch', () => { + for (const entry of corpus.guardedBindingCases) { + it(`should satisfy shared case: ${entry.name}`, async () => { + expect((await run(entry.binding, entry.subject)).dispatcher.calls).toEqual(entry.expectedCalls); + }); + } + it('should select once and run one entire action list', async () => { + const { dispatcher, reads } = await run(binding()); + expect(dispatcher.calls).toEqual(['executeCommand:first:{}', 'executeCommand:second:{}']); + expect(reads).toBe(1); + }); + it('should run otherwise only after an evaluable no-match', async () => { + expect((await run(binding(), { id: 2 })).dispatcher.calls).toEqual(['executeCommand:fallback:{}']); + }); + it('should run nothing on no match without otherwise', async () => { + expect((await run(binding({ otherwise: undefined }), { id: 2 })).dispatcher.calls).toEqual([]); + }); + it('should run nothing without a subject, even with otherwise', async () => { + expect((await run(binding(), null)).dispatcher.calls).toEqual([]); + }); + it('should refuse unknown guards instead of running otherwise', async () => { + const result = await run(binding({ alternatives: [{ condition: { kind: 'future' } as unknown as GuardCondition, actions: [command('never')] }] })); + expect(result.dispatcher.calls).toEqual([]); + expect(result.result.findings[0].kind).toBe('unevaluableCondition'); + }); + it('should reject ambiguous actions and alternatives', async () => { + const result = await run(binding({ actions: [command('ambiguous')] })); + expect(result.dispatcher.calls).toEqual([]); + expect(result.result.findings[0].detail).toContain('SCENE-GUARD-001'); + }); + it('should not fall through after a failed selected command', async () => { + expect((await run(binding(), { id: 1 }, false)).dispatcher.calls).toEqual(['executeCommand:first:{}', 'executeCommand:second:{}']); + }); + it('should leave ordinary unguarded interactions unchanged', async () => { + expect((await run(binding({ alternatives: undefined, actions: [command('ordinary')] }))).dispatcher.calls).toEqual(['executeCommand:ordinary:{}']); + }); +}); diff --git a/Source/JavaScript/engine/for_selectGuardedAlternative/when_running_against_the_shared_fixture_corpus.ts b/Source/JavaScript/engine/for_selectGuardedAlternative/when_running_against_the_shared_fixture_corpus.ts new file mode 100644 index 00000000..c439f5b1 --- /dev/null +++ b/Source/JavaScript/engine/for_selectGuardedAlternative/when_running_against_the_shared_fixture_corpus.ts @@ -0,0 +1,33 @@ +// 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 { GuardedActionFallback } from '@cratis/scene.model'; +import { canEvaluateGuard, evaluateGuard, GuardSelection, selectGuardedAction, selectGuardedInteraction } from '../guards'; + +interface Corpus { + interactions: { name: string; alternatives: { condition: unknown }[]; item: unknown; hasOtherwise: boolean; expected: GuardSelection }[]; + cases: { name: string; condition: unknown; item: unknown; evaluable: boolean; result: boolean | null }[]; + selections: { name: string; alternatives: { condition: unknown }[]; item: unknown; otherwise?: GuardedActionFallback; expected: GuardSelection }[]; +} +const corpus: Corpus = JSON.parse(readFileSync(join(import.meta.dirname, '../../../../guarded-selection-fixtures.json'), 'utf8')); + +describe('when evaluating the shared guard corpus', () => { + for (const entry of corpus.cases) { + it(`should evaluate ${entry.name}`, () => { + expect(canEvaluateGuard(entry.condition)).toBe(entry.evaluable); + expect(evaluateGuard(entry.condition, entry.item) ?? null).toBe(entry.result); + }); + } + for (const entry of corpus.interactions) { + it(`should select interaction ${entry.name}`, () => { + expect(selectGuardedInteraction(entry.alternatives, entry.item, entry.hasOtherwise)).toEqual(entry.expected); + }); + } + for (const entry of corpus.selections) { + it(`should select ${entry.name}`, () => { + expect(selectGuardedAction(entry.alternatives, entry.item, entry.otherwise)).toEqual(entry.expected); + }); + } +}); diff --git a/Source/JavaScript/engine/guards/evaluateGuardCondition.ts b/Source/JavaScript/engine/guards/evaluateGuardCondition.ts new file mode 100644 index 00000000..ef35b3e0 --- /dev/null +++ b/Source/JavaScript/engine/guards/evaluateGuardCondition.ts @@ -0,0 +1,115 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { SubjectReference } from '@cratis/scene.model'; + +const forbidden = new Set(['__proto__', 'prototype', 'constructor']); +const numericOperators = new Set(['GreaterThan', 'GreaterThanOrEqual', 'LessThan', 'LessThanOrEqual']); +type Comparison = (left: unknown, right: unknown) => boolean; + +function numbers(left: unknown, right: unknown, compare: (first: number, second: number) => boolean): boolean { + if (typeof left !== 'number' || typeof right !== 'number') return false; + return compare(left, right); +} + +function contains(left: unknown, right: unknown): boolean { + if (typeof left === 'string') return typeof right === 'string' && left.includes(right); + return Array.isArray(left) && left.some(value => scalar(value) && value === right); +} + +const comparisons: Record = { + Equal: (left, right) => scalar(left) && left === right, + NotEqual: (left, right) => scalar(left) && typeof left === typeof right && left !== right, + GreaterThan: (left, right) => numbers(left, right, (first, second) => first > second), + GreaterThanOrEqual: (left, right) => numbers(left, right, (first, second) => first >= second), + LessThan: (left, right) => numbers(left, right, (first, second) => first < second), + LessThanOrEqual: (left, right) => numbers(left, right, (first, second) => first <= second), + Contains: contains, + StartsWith: (left, right) => typeof left === 'string' && typeof right === 'string' && left.startsWith(right), +}; + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function scalar(value: unknown): value is string | number | boolean | null { + return value === null || ['string', 'boolean'].includes(typeof value) || (typeof value === 'number' && Number.isFinite(value)); +} + +function safeSegment(part: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(part) && !forbidden.has(part); +} + +function subjectPath(reference: unknown): string[] | undefined { + if (!record(reference)) return undefined; + if (typeof reference.subject !== 'string') return undefined; + if (!reference.subject.startsWith('item.')) return undefined; + const parts = reference.subject.slice(5).split('.'); + return parts.every(safeSegment) ? parts : undefined; +} + +export function resolveSubjectReference(reference: SubjectReference, item: unknown): unknown { + const parts = subjectPath(reference); + if (!parts) return undefined; + let value = item; + for (const part of parts) { + if (!record(value) || !Object.hasOwn(value, part)) return undefined; + value = value[part]; + } + return value; +} + +function validEnumeration(value: unknown): boolean { + if (value === undefined) return true; + if (!Array.isArray(value) || value.length === 0) return false; + return value.every(member => typeof member === 'string') && new Set(value).size === value.length; +} + +function validLiteral(operator: string, right: unknown): boolean { + if (!scalar(right)) return false; + if (numericOperators.has(operator)) return typeof right === 'number'; + return operator !== 'StartsWith' || typeof right === 'string'; +} + +function validComparison(condition: Record): boolean { + if (!subjectPath(condition.left)) return false; + if (typeof condition.operator !== 'string') return false; + if (!Object.hasOwn(comparisons, condition.operator)) return false; + return validEnumeration(condition.enumeration) && validLiteral(condition.operator, condition.right); +} + +export function canEvaluateGuard(condition: unknown): boolean { + if (!record(condition)) return false; + if (condition.kind === 'comparison') return validComparison(condition); + if (condition.kind !== 'logical') return false; + if (condition.operator !== 'And' && condition.operator !== 'Or') return false; + return canEvaluateGuard(condition.left) && canEvaluateGuard(condition.right); +} + +function ordinal(value: unknown, length: number): value is number { + if (typeof value !== 'number' || !Number.isInteger(value)) return false; + return value >= 0 && value < length; +} + +function normalize(value: unknown, enumeration: unknown): unknown { + if (!Array.isArray(enumeration) || value === null) return value; + if (ordinal(value, enumeration.length)) return enumeration[value]; + if (typeof value === 'string') return enumeration.find(member => member.toLowerCase() === value.toLowerCase()); + return undefined; +} + +function compare(condition: Record, item: unknown): boolean { + const left = normalize(resolveSubjectReference(condition.left as SubjectReference, item), condition.enumeration); + const right = normalize(condition.right, condition.enumeration); + if (left === undefined || right === undefined) return false; + if (left === null || right === null) return condition.operator === 'Equal' && left === right; + return comparisons[condition.operator as string](left, right); +} + +export function evaluateGuard(condition: unknown, item: unknown): boolean | undefined { + if (!canEvaluateGuard(condition) || !record(condition)) return undefined; + if (condition.kind === 'comparison') return compare(condition, item); + const left = evaluateGuard(condition.left, item) === true; + const right = evaluateGuard(condition.right, item) === true; + return condition.operator === 'And' ? left && right : left || right; +} diff --git a/Source/JavaScript/engine/guards/index.ts b/Source/JavaScript/engine/guards/index.ts new file mode 100644 index 00000000..81c7b58c --- /dev/null +++ b/Source/JavaScript/engine/guards/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export * from './evaluateGuardCondition'; +export * from './selectGuardedAlternative'; diff --git a/Source/JavaScript/engine/guards/selectGuardedAlternative.ts b/Source/JavaScript/engine/guards/selectGuardedAlternative.ts new file mode 100644 index 00000000..785fc16b --- /dev/null +++ b/Source/JavaScript/engine/guards/selectGuardedAlternative.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. + +import { GuardedActionFallbackOutcome } from '@cratis/scene.model'; +import { canEvaluateGuard, evaluateGuard } from './evaluateGuardCondition'; + +export type GuardSelection = { kind: 'alternative'; index: number } | { kind: 'otherwise' | 'hidden' | 'noSubject' | 'unevaluable' | 'unmatched' }; + +function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function selectGuardedInteraction(alternatives: readonly unknown[], item: unknown, hasOtherwise = false): GuardSelection { + if (!record(item)) return { kind: 'noSubject' }; + if (alternatives.some(alternative => !record(alternative) || !canEvaluateGuard(alternative.condition))) return { kind: 'unevaluable' }; + const index = alternatives.findIndex(alternative => record(alternative) && evaluateGuard(alternative.condition, item) === true); + if (index >= 0) return { kind: 'alternative', index }; + return { kind: hasOtherwise ? 'otherwise' : 'unmatched' }; +} + +export function selectGuardedAction(alternatives: readonly unknown[], item: unknown, otherwise?: { outcome: GuardedActionFallbackOutcome }): GuardSelection { + const result = selectGuardedInteraction(alternatives, item, otherwise?.outcome === GuardedActionFallbackOutcome.Execute); + return result.kind === 'unmatched' ? { kind: 'hidden' } : result; +} diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 24dde189..06cc7c0e 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -39,6 +39,7 @@ export * from './buildStarterProfile'; export * from './incompatibleStarterThemes'; export * from './ActionDispatcher'; export * from './runActions'; +export * from './guards'; export * from './resolveBehaviors'; export * from './resolveStrings'; export * from './navigation'; diff --git a/Source/JavaScript/engine/runActions.ts b/Source/JavaScript/engine/runActions.ts index 1205a4a2..94d11197 100644 --- a/Source/JavaScript/engine/runActions.ts +++ b/Source/JavaScript/engine/runActions.ts @@ -20,6 +20,7 @@ import { import { ActionDispatcher } from './ActionDispatcher'; import { BindingResolver } from './BindingResolver'; +import { selectGuardedInteraction } from './guards'; /** * Why an interaction stopped before running everything it was given. @@ -45,7 +46,7 @@ export enum InteractionStop { * application, and 'this application produced no findings' has to be checkable rather than a matter of opinion. */ export interface InteractionFinding { - kind: 'unknownAction' | 'unresolvedMessage' | 'unresolvedCondition'; + kind: 'unknownAction' | 'unresolvedMessage' | 'unresolvedCondition' | 'unevaluableCondition'; detail: string; } @@ -64,6 +65,7 @@ export interface InteractionRun { */ export interface InteractionContext { resolve: BindingResolver; + subject?: () => unknown; /** Looks up a localization key. Returns the key itself when the host has no catalogue for it. */ localize?: (key: string) => string; } @@ -258,7 +260,9 @@ export async function runBindings( for (const candidate of bindings) { if (!applies(candidate.binding, context, run)) continue; - await runSequence(candidate.binding.actions ?? [], dispatcher, context, run); + const actions = branchActions(candidate.binding, context, run); + if (!actions) continue; + await runSequence(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. @@ -268,6 +272,19 @@ export async function runBindings( return run; } +function branchActions(binding: InteractionBinding, context: InteractionContext, run: InteractionRun): InteractionAction[] | undefined { + if (binding.alternatives === undefined) return binding.actions ?? []; + if ((binding.actions?.length ?? 0) > 0) { + run.findings.push({ kind: 'unevaluableCondition', detail: 'SCENE-GUARD-001: An interaction cannot combine actions and alternatives' }); + return undefined; + } + const selected = selectGuardedInteraction(binding.alternatives, context.subject?.() ?? context.resolve({ path: 'item' }), binding.otherwise !== undefined); + if (selected.kind === 'alternative') return binding.alternatives[selected.index].actions; + if (selected.kind === 'otherwise') return binding.otherwise; + if (selected.kind === 'unevaluable') run.findings.push({ kind: 'unevaluableCondition', detail: 'SCENE-GUARD-001: The structured guard could not be resolved' }); + return undefined; +} + function applies(binding: InteractionBinding, context: InteractionContext, run: InteractionRun): boolean { if (!binding.condition) return true; diff --git a/Source/JavaScript/model/guards/ComparisonGuardCondition.ts b/Source/JavaScript/model/guards/ComparisonGuardCondition.ts new file mode 100644 index 00000000..a4c11bed --- /dev/null +++ b/Source/JavaScript/model/guards/ComparisonGuardCondition.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 { GuardOperator } from './GuardOperator'; +import { SubjectReference } from './SubjectReference'; + +export interface ComparisonGuardCondition { + kind: 'comparison'; + left: SubjectReference; + operator: GuardOperator; + right: string | number | boolean | null; + enumeration?: string[]; +} + +export const ComparisonGuardConditionPropertyNames: (keyof ComparisonGuardCondition)[] = ['left', 'operator', 'right', 'enumeration']; diff --git a/Source/JavaScript/model/guards/GuardCondition.ts b/Source/JavaScript/model/guards/GuardCondition.ts new file mode 100644 index 00000000..ba5a7017 --- /dev/null +++ b/Source/JavaScript/model/guards/GuardCondition.ts @@ -0,0 +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 { ComparisonGuardCondition } from './ComparisonGuardCondition'; +import { LogicalGuardCondition } from './LogicalGuardCondition'; + +export type GuardCondition = ComparisonGuardCondition | LogicalGuardCondition; diff --git a/Source/JavaScript/model/guards/GuardOperator.ts b/Source/JavaScript/model/guards/GuardOperator.ts new file mode 100644 index 00000000..0e123eef --- /dev/null +++ b/Source/JavaScript/model/guards/GuardOperator.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. + +export enum GuardOperator { + Equal = 'Equal', + NotEqual = 'NotEqual', + GreaterThan = 'GreaterThan', + GreaterThanOrEqual = 'GreaterThanOrEqual', + LessThan = 'LessThan', + LessThanOrEqual = 'LessThanOrEqual', + Contains = 'Contains', + StartsWith = 'StartsWith', +} diff --git a/Source/JavaScript/model/guards/GuardedActionAlternative.ts b/Source/JavaScript/model/guards/GuardedActionAlternative.ts new file mode 100644 index 00000000..2c1e269d --- /dev/null +++ b/Source/JavaScript/model/guards/GuardedActionAlternative.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 { GuardCondition } from './GuardCondition'; +import { SubjectReference } from './SubjectReference'; + +export interface GuardedActionAlternative { + command: string; + condition: GuardCondition; + arguments?: Record; +} + +export const GuardedActionAlternativePropertyNames: (keyof GuardedActionAlternative)[] = ['command', 'condition', 'arguments']; diff --git a/Source/JavaScript/model/guards/GuardedActionFallback.ts b/Source/JavaScript/model/guards/GuardedActionFallback.ts new file mode 100644 index 00000000..1684bd56 --- /dev/null +++ b/Source/JavaScript/model/guards/GuardedActionFallback.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 { GuardedActionFallbackOutcome } from './GuardedActionFallbackOutcome'; +import { SubjectReference } from './SubjectReference'; + +export interface GuardedActionFallback { + outcome: GuardedActionFallbackOutcome; + command?: string; + arguments?: Record; +} + +export const GuardedActionFallbackPropertyNames: (keyof GuardedActionFallback)[] = ['outcome', 'command', 'arguments']; diff --git a/Source/JavaScript/model/guards/GuardedActionFallbackOutcome.ts b/Source/JavaScript/model/guards/GuardedActionFallbackOutcome.ts new file mode 100644 index 00000000..a02cbc00 --- /dev/null +++ b/Source/JavaScript/model/guards/GuardedActionFallbackOutcome.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export enum GuardedActionFallbackOutcome { + Hidden = 'Hidden', + Execute = 'Execute', +} diff --git a/Source/JavaScript/model/guards/GuardedActionProperties.ts b/Source/JavaScript/model/guards/GuardedActionProperties.ts new file mode 100644 index 00000000..00c7f8e1 --- /dev/null +++ b/Source/JavaScript/model/guards/GuardedActionProperties.ts @@ -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. + +import { BindingExpression } from '../common'; +import { GuardedActionAlternative } from './GuardedActionAlternative'; +import { GuardedActionFallback } from './GuardedActionFallback'; + +export const GuardedActionComponentName = 'core:guarded-action'; + +export interface GuardedActionProperties { + label: string; + alternatives: GuardedActionAlternative[]; + item?: BindingExpression; + otherwise?: GuardedActionFallback; + navigateToScreen?: string; + navigateByParameter?: string; +} + +export const GuardedActionPropertiesPropertyNames: (keyof GuardedActionProperties)[] = [ + 'label', 'alternatives', 'item', 'otherwise', 'navigateToScreen', 'navigateByParameter', +]; diff --git a/Source/JavaScript/model/guards/LogicalGuardCondition.ts b/Source/JavaScript/model/guards/LogicalGuardCondition.ts new file mode 100644 index 00000000..92d4a7ec --- /dev/null +++ b/Source/JavaScript/model/guards/LogicalGuardCondition.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 { GuardCondition } from './GuardCondition'; +import { LogicalGuardOperator } from './LogicalGuardOperator'; + +export interface LogicalGuardCondition { + kind: 'logical'; + left: GuardCondition; + operator: LogicalGuardOperator; + right: GuardCondition; +} + +export const LogicalGuardConditionPropertyNames: (keyof LogicalGuardCondition)[] = ['left', 'operator', 'right']; diff --git a/Source/JavaScript/model/guards/LogicalGuardOperator.ts b/Source/JavaScript/model/guards/LogicalGuardOperator.ts new file mode 100644 index 00000000..de05c676 --- /dev/null +++ b/Source/JavaScript/model/guards/LogicalGuardOperator.ts @@ -0,0 +1,7 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export enum LogicalGuardOperator { + And = 'And', + Or = 'Or', +} diff --git a/Source/JavaScript/model/guards/SubjectReference.ts b/Source/JavaScript/model/guards/SubjectReference.ts new file mode 100644 index 00000000..98d0b2c5 --- /dev/null +++ b/Source/JavaScript/model/guards/SubjectReference.ts @@ -0,0 +1,8 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export interface SubjectReference { + subject: string; +} + +export const SubjectReferencePropertyNames: (keyof SubjectReference)[] = ['subject']; diff --git a/Source/JavaScript/model/guards/index.ts b/Source/JavaScript/model/guards/index.ts new file mode 100644 index 00000000..8d7e1811 --- /dev/null +++ b/Source/JavaScript/model/guards/index.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. + +export * from './GuardCondition'; +export * from './ComparisonGuardCondition'; +export * from './LogicalGuardCondition'; +export * from './SubjectReference'; +export * from './GuardOperator'; +export * from './LogicalGuardOperator'; +export * from './GuardedActionAlternative'; +export * from './GuardedActionFallback'; +export * from './GuardedActionFallbackOutcome'; +export * from './GuardedActionProperties'; diff --git a/Source/JavaScript/model/index.ts b/Source/JavaScript/model/index.ts index e3c8c4dd..676e8e6c 100644 --- a/Source/JavaScript/model/index.ts +++ b/Source/JavaScript/model/index.ts @@ -12,6 +12,7 @@ export * from './icons'; export * from './packages'; export * from './sampleData'; export * from './interactions'; +export * from './guards'; export * from './screens'; export * from './starters'; export * from './queries'; diff --git a/Source/JavaScript/model/interactions/InteractionAlternative.ts b/Source/JavaScript/model/interactions/InteractionAlternative.ts new file mode 100644 index 00000000..f2b5c238 --- /dev/null +++ b/Source/JavaScript/model/interactions/InteractionAlternative.ts @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { GuardCondition } from '../guards'; +import { InteractionAction } from './InteractionAction'; + +export interface InteractionAlternative { + condition: GuardCondition; + actions: InteractionAction[]; +} + +export const InteractionAlternativePropertyNames: (keyof InteractionAlternative)[] = ['condition', 'actions']; diff --git a/Source/JavaScript/model/interactions/InteractionBinding.ts b/Source/JavaScript/model/interactions/InteractionBinding.ts index b6d79e14..00aa6e52 100644 --- a/Source/JavaScript/model/interactions/InteractionBinding.ts +++ b/Source/JavaScript/model/interactions/InteractionBinding.ts @@ -3,6 +3,7 @@ import { BindingExpression } from '../common'; import { InteractionAction } from './InteractionAction'; +import { InteractionAlternative } from './InteractionAlternative'; import { InteractionTrigger } from './InteractionTrigger'; /** @@ -12,6 +13,8 @@ export interface InteractionBinding { trigger: InteractionTrigger; actions: InteractionAction[]; condition?: BindingExpression; + alternatives?: InteractionAlternative[]; + otherwise?: InteractionAction[]; } -export const InteractionBindingPropertyNames: (keyof InteractionBinding)[] = ['trigger', 'actions', 'condition']; +export const InteractionBindingPropertyNames: (keyof InteractionBinding)[] = ['trigger', 'actions', 'condition', 'alternatives', 'otherwise']; diff --git a/Source/JavaScript/model/interactions/index.ts b/Source/JavaScript/model/interactions/index.ts index 52466a35..f9fb0504 100644 --- a/Source/JavaScript/model/interactions/index.ts +++ b/Source/JavaScript/model/interactions/index.ts @@ -23,4 +23,5 @@ export * from './NotifyAction'; export * from './ConfirmAction'; export * from './RaiseTriggerAction'; export * from './InteractionBinding'; +export * from './InteractionAlternative'; export * from './Behavior'; diff --git a/Source/JavaScript/react/core/CoreGuardedAction.tsx b/Source/JavaScript/react/core/CoreGuardedAction.tsx new file mode 100644 index 00000000..b7d1e8a5 --- /dev/null +++ b/Source/JavaScript/react/core/CoreGuardedAction.tsx @@ -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 { useRef, useState } from 'react'; +import { ActionDispatcher, resolveBindingExpression, resolveSubjectReference, selectGuardedAction } from '@cratis/scene.engine'; +import { BindingExpression, GuardedActionProperties, SubjectReference } from '@cratis/scene.model'; +import { RegisteredComponentProps, useRenderBindingScope } from '../renderer'; +import { useInteractionScope } from '../interactions/InteractionScope'; + +function selection(properties: GuardedActionProperties, item: unknown) { + const alternatives = Array.isArray(properties.alternatives) ? properties.alternatives : []; + const selected = selectGuardedAction(alternatives, item, properties.otherwise); + const branch = selected.kind === 'alternative' ? alternatives[selected.index] : properties.otherwise; + const unsupported = !Array.isArray(properties.alternatives) || selected.kind === 'unevaluable'; + const hidden = selected.kind === 'noSubject' || selected.kind === 'hidden'; + const key = selected.kind === 'alternative' ? `alternative:${selected.index}` : selected.kind; + return { branch, hidden, unsupported, key }; +} + +interface CommandSelection { + command?: string; + arguments?: Record; + item: unknown; + properties: GuardedActionProperties; + dispatcher?: ActionDispatcher; + element: RegisteredComponentProps['element']; + verify: () => boolean; +} + +function useGuardedCommand(selected: CommandSelection) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(''); + const inFlight = useRef(false); + const [, refresh] = useState(0); + const invoke = async () => { + if (inFlight.current) return; + if (!selected.command) return; + if (!selected.verify()) { refresh(value => value + 1); return; } + inFlight.current = true; + setPending(true); + setError(''); + try { + await execute(selected); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'The guarded action failed.'); + } finally { + inFlight.current = false; + setPending(false); + } + }; + return { pending, error, invoke }; +} + +async function execute(selected: CommandSelection): Promise { + if (!selected.command) return; + const args = resolveArguments(selected.arguments, selected.item); + if (!selected.dispatcher) { + raiseCommand(selected, args); + return; + } + const outcome = await selected.dispatcher.executeCommand(selected.command, { item: selected.item, ...args }); + if (!outcome.isSuccess) throw new Error('The command did not succeed.'); + await navigateAfterSuccess(selected.properties, selected.item, selected.dispatcher); +} + +export function CoreGuardedAction({ element }: RegisteredComponentProps) { + const properties = element.properties as unknown as GuardedActionProperties; + const scope = useRenderBindingScope(); + const interaction = useInteractionScope(); + const rawItem = Object.hasOwn(properties, 'item') ? properties.item : scope.dataContext; + const item = resolveItem(rawItem, binding => resolveBindingExpression(binding, scope)); + const { branch, hidden, unsupported, key } = selection(properties, item); + const command = unsupported ? undefined : branch?.command; + const verify = () => { + const current = selection(properties, item); + return current.key === key && current.branch?.command === command; + }; + const state = useGuardedCommand({ command, arguments: branch?.arguments, item, properties, dispatcher: interaction?.dispatcher, element, verify }); + if (hidden) return null; + const disabled = actionDisabled(state.pending, command); + return + + + ; +} + +function actionLabel(label: string): string { + return label || 'Action'; +} + +function actionDisabled(pending: boolean, command: string | undefined): boolean { + return pending || !command; +} + +function diagnosticTitle(unsupported: boolean): string | undefined { + return unsupported ? 'SCENE-GUARD-001: This action\'s guard could not be resolved.' : undefined; +} + +function resolveItem(value: unknown, resolve: (binding: BindingExpression) => unknown): unknown { + if (value === null || typeof value !== 'object') return value; + return typeof (value as BindingExpression).path === 'string' ? resolve(value as BindingExpression) : value; +} + +function ActionStatus({ unsupported, error }: { unsupported: boolean; error: string }) { + if (unsupported) return SCENE-GUARD-001: This action's guard could not be resolved.; + return error ? {error} : null; +} + +function resolveArguments(bindings: Record = {}, item: unknown): Record { + return Object.fromEntries(Object.entries(bindings).map(([name, reference]) => { + const value = resolveSubjectReference(reference, item); + if (value === undefined || value === null) throw new Error(`The argument '${name}' could not be resolved.`); + return [name, value]; + })); +} + +function raiseCommand(selected: CommandSelection, args: Record): void { + const { command, element, item, properties } = selected; + window.dispatchEvent(new CustomEvent('cratis.scene.command', { detail: { command, element, arguments: args, item, navigateToScreen: properties.navigateToScreen, navigateByParameter: properties.navigateByParameter } })); +} + +async function navigateAfterSuccess(properties: GuardedActionProperties, item: unknown, dispatcher: ActionDispatcher): Promise { + if (!properties.navigateToScreen) return; + await dispatcher.navigate(properties.navigateToScreen, navigationParameters(properties.navigateByParameter, item)); +} + +function navigationParameters(parameter: string | undefined, item: unknown): Record { + const navigation: Record = {}; + if (parameter) { + const value = resolveSubjectReference({ subject: `item.${parameter}` }, item); + if (value === undefined || value === null) throw new Error('The navigation parameter could not be resolved.'); + navigation[parameter] = value; + } + return navigation; +} diff --git a/Source/JavaScript/react/core/ScreenplayCoreComponents.tsx b/Source/JavaScript/react/core/ScreenplayCoreComponents.tsx index fe8a20c7..de75f533 100644 --- a/Source/JavaScript/react/core/ScreenplayCoreComponents.tsx +++ b/Source/JavaScript/react/core/ScreenplayCoreComponents.tsx @@ -4,6 +4,7 @@ import { resolveArgumentSource } from '@cratis/scene.engine'; import type { ExternalComponent } from '@cratis/scene.model'; import { RegisteredComponentProps, useRenderBindingScope } from '../renderer'; +import { CoreGuardedAction } from './CoreGuardedAction'; function textProperty(properties: Record, name: string, fallback = ''): string { return typeof properties[name] === 'string' ? properties[name] : fallback; @@ -37,7 +38,12 @@ interface ArgumentMapping { * An action that names no label is labelled with its command read as a sentence (`Change basket quantity`), and is * drawn as a button of the default blueprint's toolbar look (`scene-toolbar-button`) rather than as bare text. */ -export function CoreAction({ element }: RegisteredComponentProps) { +export function CoreAction(props: RegisteredComponentProps) { + if ('alternatives' in props.element.properties) return ; + return ; +} + +function CoreCommandAction({ element }: RegisteredComponentProps) { const command = textProperty(element.properties, 'command'); const label = textProperty(element.properties, 'label', commandLabel(command)); const scope = useRenderBindingScope(); diff --git a/Source/JavaScript/react/core/coreComponentMetadata.ts b/Source/JavaScript/react/core/coreComponentMetadata.ts index be8c9847..8c0001db 100644 --- a/Source/JavaScript/react/core/coreComponentMetadata.ts +++ b/Source/JavaScript/react/core/coreComponentMetadata.ts @@ -11,6 +11,7 @@ export const coreComponentMetadata: Record = { card: m('Card', ComponentCategory.Layout, 'square', ['panel', 'box', 'container']), data: m('Data', ComponentCategory.DataDisplay, 'database', ['read model', 'query', 'source']), action: m('Command action', ComponentCategory.Actions, 'zap', ['command', 'invoke', 'submit']), + 'guarded-action': m('Guarded command action', ComponentCategory.Actions, 'git-branch', ['command', 'guard', 'condition', 'selected item']), section: m('Section', ComponentCategory.Layout, 'layout-panel-top', ['group', 'area', 'block']), navigate: m('Navigate', ComponentCategory.Navigation, 'corner-down-right', ['link', 'go to', 'route']), title: m('Title', ComponentCategory.DataDisplay, 'heading', ['heading', 'header', 'caption']), diff --git a/Source/JavaScript/react/core/coreComponents.ts b/Source/JavaScript/react/core/coreComponents.ts index 207425fe..9fa52b23 100644 --- a/Source/JavaScript/react/core/coreComponents.ts +++ b/Source/JavaScript/react/core/coreComponents.ts @@ -5,6 +5,7 @@ import { ComponentRegistry } from '../renderer'; import { componentRegistryKey } from '../packages'; import { CoreText } from './CoreText'; import { CoreButton } from './CoreButton'; +import { CoreGuardedAction } from './CoreGuardedAction'; import { CoreCard } from './CoreCard'; import { CoreNavigationBar } from './CoreNavigationBar'; import { CoreToolbar, CoreToolbarItem } from './CoreToolbar'; @@ -34,6 +35,7 @@ export const coreComponents: ComponentRegistry = { [componentRegistryKey('core', 'card')]: CoreCard, [componentRegistryKey('core', 'data')]: CoreData, [componentRegistryKey('core', 'action')]: CoreAction, + [componentRegistryKey('core', 'guarded-action')]: CoreGuardedAction, [componentRegistryKey('core', 'section')]: CoreSection, [componentRegistryKey('core', 'navigate')]: CoreNavigate, [componentRegistryKey('core', 'title')]: CoreTitle, diff --git a/Source/JavaScript/react/core/coreDescriptors.ts b/Source/JavaScript/react/core/coreDescriptors.ts index 8f797647..fa131306 100644 --- a/Source/JavaScript/react/core/coreDescriptors.ts +++ b/Source/JavaScript/react/core/coreDescriptors.ts @@ -46,6 +46,24 @@ const describedCore: ComponentDescriptor[] = [ }, ], }, + { + component: key('guarded-action'), + displayName: 'Guarded command action', + description: 'Runs the first matching command for the selected item. Hidden unless a branch or executable fallback is selected; unsupported guards are refused.', + properties: [ + { path: 'label', label: 'Label', group: 'Content', valueType: PropertyValueType.String }, + { path: 'item', label: 'Selected item', group: 'Data', valueType: PropertyValueType.Object, open: true }, + { path: 'alternatives', label: 'Alternatives', group: 'Behavior', valueType: PropertyValueType.Collection, + item: { label: 'Alternative', positional: true, properties: [ + { path: 'command', label: 'Command', group: 'Behavior', valueType: PropertyValueType.String }, + { path: 'condition', label: 'Condition', group: 'Behavior', valueType: PropertyValueType.Object, open: true }, + { path: 'arguments', label: 'Arguments', group: 'Behavior', valueType: PropertyValueType.Object, open: true }, + ] } }, + { path: 'otherwise', label: 'Fallback', group: 'Behavior', valueType: PropertyValueType.Object, open: true }, + { path: 'navigateToScreen', label: 'Success screen', group: 'Navigation', valueType: PropertyValueType.String }, + { path: 'navigateByParameter', label: 'Navigation parameter', group: 'Navigation', valueType: PropertyValueType.String }, + ], + }, { component: key('toolbarItem'), displayName: 'Toolbar button', diff --git a/Source/JavaScript/react/core/corePackage.ts b/Source/JavaScript/react/core/corePackage.ts index 654b61ae..c4300eb9 100644 --- a/Source/JavaScript/react/core/corePackage.ts +++ b/Source/JavaScript/react/core/corePackage.ts @@ -24,6 +24,7 @@ export const corePackageManifest: ScenePackage = { 'card', 'data', 'action', + 'guarded-action', 'section', 'navigate', 'title', diff --git a/Source/JavaScript/react/core/for_CoreGuardedAction/when_running_a_guarded_command.tsx b/Source/JavaScript/react/core/for_CoreGuardedAction/when_running_a_guarded_command.tsx new file mode 100644 index 00000000..20b61a2f --- /dev/null +++ b/Source/JavaScript/react/core/for_CoreGuardedAction/when_running_a_guarded_command.tsx @@ -0,0 +1,139 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ActionDispatcher, CommandOutcome } from '@cratis/scene.engine'; +import { ExternalComponent, GuardOperator, GuardedActionFallbackOutcome } from '@cratis/scene.model'; +import { CoreGuardedAction } from '../CoreGuardedAction'; +import { CoreAction } from '../ScreenplayCoreComponents'; +import { InteractionScope } from '../../interactions'; +import { RenderBindingScopeProvider } from '../../renderer/RenderBindingScopeContext'; + +const guard = { kind: 'comparison', left: { subject: 'item.status' }, operator: GuardOperator.Equal, right: 'Pending' }; +const alternatives = [{ command: 'Approve', condition: guard, arguments: { orderId: { subject: 'item.id' } } }]; +const item = { id: 42, status: 'Pending', internalValue: 'not-an-argument' }; +const dispatcher = { + executeCommand: vi.fn(), navigate: vi.fn(), navigateBack: vi.fn(), + openDialog: vi.fn(), closeDialog: vi.fn(), refreshQuery: vi.fn(), setState: vi.fn(), notify: vi.fn(), confirm: vi.fn(), raise: vi.fn(), +} satisfies ActionDispatcher; + +function draw(properties: Record = {}, subject: unknown = item, stage = false, scoped = true) { + const element = { id: 'approve', componentName: stage ? 'core:action' : 'core:guarded-action', properties: { label: 'Approve', alternatives, ...properties }, slots: {} } as unknown as ExternalComponent; + const Component = stage ? CoreAction : CoreGuardedAction; + const content = (dataContext: unknown) => { + const button = ; + return scoped ? {button} : button; + }; + const rendered = render(content(subject)); + return { ...rendered, changeSubject: (next: unknown) => rendered.rerender(content(next)) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + dispatcher.executeCommand.mockResolvedValue({ isSuccess: true }); +}); + +describe('when running a guarded command', () => { + it('should re-evaluate when the live subject changes', () => { + const view = draw({}, { ...item, status: 'Approved' }); + expect(screen.queryByRole('button')).toBeNull(); + view.changeSubject(item); + expect(screen.getByRole('button')).toBeTruthy(); + view.changeSubject(null); + expect(screen.queryByRole('button')).toBeNull(); + }); + it('should run only the first matching alternative', async () => { + draw({ alternatives: [...alternatives, { command: 'Never', condition: guard }] }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Approve', { item, orderId: 42 })); + }); + it('should submit explicit arguments with the item and navigate only after success', async () => { + draw({ navigateToScreen: 'details', navigateByParameter: 'id' }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.navigate).toHaveBeenCalledWith('details', { id: 42 })); + expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Approve', { item, orderId: 42 }); + }); + it('should map the Stage core:action shape to guarded execution', async () => { + draw({}, item, true); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Approve', { item, orderId: 42 })); + }); + it('should hide an unmatched action without an executable fallback', () => { + draw({}, { ...item, status: 'Approved' }); + expect(screen.queryByRole('button')).toBeNull(); + }); + it('should hide the action without a subject even when otherwise executes', () => { + draw({ otherwise: { outcome: GuardedActionFallbackOutcome.Execute, command: 'Fallback' } }, null); + expect(screen.queryByRole('button')).toBeNull(); + expect(dispatcher.executeCommand).not.toHaveBeenCalled(); + }); + it('should execute an explicit fallback', async () => { + draw({ otherwise: { outcome: GuardedActionFallbackOutcome.Execute, command: 'Fallback', arguments: { id: { subject: 'item.id' } } } }, { ...item, status: 'Approved' }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Fallback', { item: { ...item, status: 'Approved' }, id: 42 })); + }); + it('should refuse legacy string guards and not execute otherwise', () => { + draw({ alternatives: [{ command: 'Never', guard: 'item.status == Pending' }], otherwise: { outcome: GuardedActionFallbackOutcome.Execute, command: 'Fallback' } }); + const button = screen.getByRole('button') as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(screen.getByRole('alert').textContent).toContain('SCENE-GUARD-001'); + fireEvent.click(button); + expect(dispatcher.executeCommand).not.toHaveBeenCalled(); + }); + it('should prevent repeated clicks while a command is pending', async () => { + let complete!: (outcome: CommandOutcome) => void; + dispatcher.executeCommand.mockReturnValue(new Promise(resolve => { complete = resolve; })); + draw(); + const button = screen.getByRole('button') as HTMLButtonElement; + fireEvent.click(button); + fireEvent.click(button); + expect(button.disabled).toBe(true); + expect(dispatcher.executeCommand).toHaveBeenCalledTimes(1); + complete({ isSuccess: true }); + await waitFor(() => expect(button.disabled).toBe(false)); + }); + it('should show failure and never navigate or fall through', async () => { + dispatcher.executeCommand.mockResolvedValue({ isSuccess: false }); + draw({ navigateToScreen: 'details', otherwise: { outcome: GuardedActionFallbackOutcome.Execute, command: 'Fallback' } }); + fireEvent.click(screen.getByRole('button')); + await screen.findByRole('alert'); + expect(dispatcher.navigate).not.toHaveBeenCalled(); + expect(dispatcher.executeCommand).toHaveBeenCalledTimes(1); + }); + it('should dispatch the host-neutral event when no interaction scope is present', () => { + const listener = vi.fn(); + window.addEventListener('cratis.scene.command', listener, { once: true }); + draw({ navigateToScreen: 'details', navigateByParameter: 'id' }, item, false, false); + fireEvent.click(screen.getByRole('button')); + expect(listener).toHaveBeenCalledTimes(1); + const detail = (listener.mock.calls[0][0] as CustomEvent).detail; + expect(detail).toMatchObject({ command: 'Approve', arguments: { orderId: 42 }, item, navigateToScreen: 'details', navigateByParameter: 'id' }); + expect(dispatcher.navigate).not.toHaveBeenCalled(); + }); + it('should re-select at click time and not execute a stale choice', () => { + const mutable = { ...item }; + draw({}, mutable); + mutable.status = 'Approved'; + fireEvent.click(screen.getByRole('button')); + expect(dispatcher.executeCommand).not.toHaveBeenCalled(); + expect(screen.queryByRole('button')).toBeNull(); + }); + it('should accept an item already resolved by the renderer', async () => { + draw({ item }); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(dispatcher.executeCommand).toHaveBeenCalledExactlyOnceWith('Approve', { item, orderId: 42 })); + }); + it('should end the wait and display thrown command failures', async () => { + dispatcher.executeCommand.mockRejectedValue(new Error('Unavailable')); + draw(); + fireEvent.click(screen.getByRole('button')); + expect((await screen.findByRole('alert')).textContent).toBe('Unavailable'); + expect((screen.getByRole('button') as HTMLButtonElement).disabled).toBe(false); + }); + it('should refuse unresolved explicit arguments', async () => { + draw({ alternatives: [{ command: 'Never', condition: guard, arguments: { id: { subject: 'item.missing' } } }] }); + fireEvent.click(screen.getByRole('button')); + await screen.findByRole('alert'); + expect(dispatcher.executeCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/Source/JavaScript/react/core/for_coreDescriptors/when_describing_the_core_package.ts b/Source/JavaScript/react/core/for_coreDescriptors/when_describing_the_core_package.ts index f65e237c..24a0b391 100644 --- a/Source/JavaScript/react/core/for_coreDescriptors/when_describing_the_core_package.ts +++ b/Source/JavaScript/react/core/for_coreDescriptors/when_describing_the_core_package.ts @@ -16,7 +16,7 @@ describe('when describing the core package', () => { it('should give every layout-free component an author configures properties to edit', () => { coreDescriptors.filter(descriptor => descriptor.properties.length > 0).map(descriptor => descriptor.component) - .should.have.members(['core:button', 'core:action', 'core:toolbarItem', 'core:sliceDialog', 'core:column', 'core:outlet', 'core:navigationBar']); + .should.have.members(['core:button', 'core:action', 'core:guarded-action', 'core:toolbarItem', 'core:sliceDialog', 'core:column', 'core:outlet', 'core:navigationBar']); }); it('should describe every component of the package, the structural ones with nothing to edit', () => { @@ -37,6 +37,11 @@ describe('when describing the core package', () => { action.properties[2].valueType.should.equal(PropertyValueType.Collection); }); + it('should describe the guarded action without changing the ordinary action descriptor', () => { + const action = coreDescriptors.find(descriptor => descriptor.component === 'core:guarded-action')!; + action.properties.map(property => property.path).should.deep.equal(['label', 'item', 'alternatives', 'otherwise', 'navigateToScreen', 'navigateByParameter']); + }); + it('should describe a toolbar button as a title, a state change slice, an emphasis and an icon', () => { const button = coreDescriptors.find(descriptor => descriptor.component === 'core:toolbarItem')!; button.properties.map(property => property.path).should.deep.equal(['label', 'slice', 'emphasis', 'icon', 'confirm', 'presentation', 'target']); diff --git a/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx b/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx new file mode 100644 index 00000000..1b588866 --- /dev/null +++ b/Source/JavaScript/react/for_useInteractions/when_selecting_a_guarded_interaction.tsx @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Behavior, ExecuteCommandAction, GuardOperator, InteractionActionKind, InteractionTriggerKind } from '@cratis/scene.model'; +import { RecordingDispatcher } from '../../engine/for_runActions/given/a_recording_dispatcher'; +import { InteractionScope, useInteractions } from '../interactions'; +import { RenderBindingScopeProvider } from '../renderer/RenderBindingScopeContext'; + +const behavior: Behavior = { + bindings: [{ + trigger: { kind: InteractionTriggerKind.Click }, actions: [], + alternatives: [{ + condition: { kind: 'comparison', left: { subject: 'item.id' }, operator: GuardOperator.Equal, right: 1 }, + actions: [{ kind: InteractionActionKind.ExecuteCommand, command: 'Selected' } as ExecuteCommandAction], + }], + otherwise: [{ kind: InteractionActionKind.ExecuteCommand, command: 'Fallback' } as ExecuteCommandAction], + }], +}; + +const behaviors = [behavior]; + +function Button() { + return ; +} + +describe('when selecting a guarded interaction', () => { + it('should replace the gesture subject while behaviors and interaction scope remain stable', async () => { + const dispatcher = new RecordingDispatcher(); + const attachments: [] = []; + const content = (dataContext: unknown) => +