diff --git a/Documentation/architecture/command-responses.md b/Documentation/architecture/command-responses.md new file mode 100644 index 00000000..35914a4a --- /dev/null +++ b/Documentation/architecture/command-responses.md @@ -0,0 +1,53 @@ +# Command responses and form continuations + +A form's `onSubmit` actions run **after its native command succeeds**, not when the user presses Submit. Arc still owns form execution, validation, authorization and exception feedback. Scene forwards the native success response without executing the command again. An absent or empty `onSubmit` preserves the existing form behavior. + +## Bind the successful response + +For a command returning `{ "projectId": "created-id" }`, a form can navigate using that id: + +```json +{ + "name": "Create project", + "forCommand": "CreateProject", + "fields": [], + "onSubmit": [ + { + "kind": "Navigate", + "screen": "project-detail", + "arguments": [ + { + "name": "id", + "value": { "kind": "commandResponse", "path": "projectId", "expectedValueType": "string" } + } + ] + } + ] +} +``` + +The same binding works inside an `ExecuteCommandAction.onSuccess` list. An empty path selects the entire scalar or object response. False, zero and an empty string are present responses. For a directly returned address, `street` selects its street; for `{ "address": { "street": "Main" } }`, use `address.street`. + +Paths traverse only own, named object properties. Missing members, inherited members, prototype paths, indexing, wildcards and array traversal are refused. An array-valued member can be copied whole when its item type fits the destination; a top-level collection response is not supported. A present nullable object makes projections through it nullable. A missing response or member is never made valid by `nullBehavior`. Arc omits top-level null responses, so they are unavailable to response-dependent actions. + +## Supply source and destination contracts + +Response contracts are producer-supplied validation inputs, not persisted command catalogs. TypeScript hosts implement `ResponseValidationContracts`; C# producers implement `IResponseValidationContracts`: + +- `commandResponse(command)` returns `{ kind: 'void' }`, or `{ kind: 'value', type }`. Unknown metadata returns `undefined` in TypeScript or null in C#; unknown is not void. +- A value type has `kind: 'string'`, `'number'` or `'boolean'`; an object adds `properties`, and an array adds `item`. Any type can be `nullable`. +- `targetType(action, operand)` supplies the actual destination type. An operand is an `argument` with a `name`, a `stateValue`, or a `message`. + +Normalize concepts to their actual JSON types and use emitted wire member names. Input schemas and response-kind flags alone do not describe a response's structure. Object destinations require assignable fields; extra source fields are allowed. Array items are checked structurally, primitives are not coerced, and nullable sources need nullable destinations. `expectedValueType` is an additional category check, never a replacement for source or destination metadata. Response bindings are one-way only. + +Call `validateResponseBindings(entry, contracts)` in TypeScript or `ResponseBindings.Validate(entry, contracts)` in C#. An entry is either `{ kind: 'actions', actions }` or `{ kind: 'form', command, onSubmit }`. Validation walks every continuation, even a branch that is not selected at runtime. Response-independent actions need no metadata. + +## Run in a lexical scope + +Provide the same metadata through `InteractionContext.responseContracts` on the enclosing `InteractionScope`. Scene's shared runner and native form bridge use `commandResponseContext` to decorate the existing resolver; non-response bindings still delegate to it. + +A successful command replaces the response only inside its `onSuccess` continuation. Failure continuations mask any enclosing response, including a payload returned with a failed outcome. Returning from a nested command restores the outer response; later siblings do not acquire a preceding command's response. Non-command continuations inherit their existing scope. Concurrent runs have no shared last-response state. + +Before an effect runs, Scene checks response operands and their actual values. Missing data, unknown metadata or incompatible values produce an `invalidResponseBinding` finding and a `Reported` stop instead of dispatching an undefined argument. Navigated, Declined and Reported stops propagate through enclosing continuations. Response-independent success actions can run even when the command returns nothing. + +Guarded selection is independent of response binding. These bindings do not introduce a guard predicate source or weaken command authorization. diff --git a/Documentation/toc.yml b/Documentation/toc.yml index 38ece499..0afb1246 100644 --- a/Documentation/toc.yml +++ b/Documentation/toc.yml @@ -9,6 +9,8 @@ href: architecture/binding-resolution.md - name: Guarded actions and interactions href: architecture/guarded-actions.md + - name: Command responses and form continuations + href: architecture/command-responses.md - name: Glossary href: glossary.md - name: Packages diff --git a/Source/DotNET/Engine.Specs/for_ResponseBindings/when_running_the_shared_corpus.cs b/Source/DotNET/Engine.Specs/for_ResponseBindings/when_running_the_shared_corpus.cs new file mode 100644 index 00000000..552fce4e --- /dev/null +++ b/Source/DotNET/Engine.Specs/for_ResponseBindings/when_running_the_shared_corpus.cs @@ -0,0 +1,71 @@ +// 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.Bindings; +using Cratis.Scene.Model.Common; +using Cratis.Scene.Model.Interactions; + +namespace Cratis.Scene.Engine.for_ResponseBindings; + +public class when_running_the_shared_corpus : Specification +{ + JsonElement _corpus; + readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web); + + 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, "command-response-fixtures.json"))).RootElement; + } + + [Fact] + void should_validate_every_shared_binding() + { + foreach (var entry in _corpus.GetProperty("cases").EnumerateArray()) + { + var contracts = Substitute.For(); + contracts.CommandResponse("create").Returns(entry.TryGetProperty("response", out var response) ? response.Deserialize(_options) : null); + contracts.TargetType(Arg.Any(), Arg.Any()).Returns(entry.TryGetProperty("target", out var target) ? target.Deserialize(_options) : null); + var binding = new BindingExpression(entry.GetProperty("path").GetString()!, BindingSourceKind.CommandResponse, Mode: entry.TryGetProperty("mode", out _) ? BindingMode.TwoWay : null, ExpectedValueType: entry.TryGetProperty("expectedValueType", out var expected) ? expected.GetString() : null); + var action = new SetStateAction("id", binding); + (ResponseBindings.Validate(new("form", Command: "create", OnSubmit: [action]), contracts).Count == 0).ShouldEqual(entry.GetProperty("valid").GetBoolean()); + } + } + + [Fact] + void should_validate_every_shared_lexical_scope() + { + var contracts = Substitute.For(); + contracts.CommandResponse("known").Returns(new ResponseContract("value", new("string"))); + contracts.TargetType(Arg.Any(), Arg.Any()).Returns(new ResponseValueType("string")); + foreach (var entry in _corpus.GetProperty("scopes").EnumerateArray()) + { + var actions = entry.GetProperty("actions").EnumerateArray().Select(ReadAction).ToArray(); + ResponseBindings.Validate(new("actions", Actions: actions), contracts).Count.ShouldEqual(entry.GetProperty("diagnostics").GetInt32()); + } + } + + InteractionAction ReadAction(JsonElement entry) + { + InteractionAction action = entry.GetProperty("kind").GetString() switch + { + "ExecuteCommand" => new ExecuteCommandAction(entry.GetProperty("command").GetString()!), + "SetState" => new SetStateAction(entry.GetProperty("target").GetString()!, new(entry.GetProperty("value").GetProperty("path").GetString()!, BindingSourceKind.CommandResponse)), + "RefreshQuery" => new RefreshQueryAction(entry.GetProperty("query").GetString()!), + _ => throw new InvalidOperationException("Unknown fixture action") + }; + return action with { OnSuccess = ReadContinuation(entry, "onSuccess"), OnFailure = ReadContinuation(entry, "onFailure"), OnResult = ReadContinuation(entry, "onResult") }; + } + + InteractionAction[] ReadContinuation(JsonElement entry, string name) => entry.TryGetProperty(name, out var actions) ? actions.EnumerateArray().Select(ReadAction).ToArray() : []; + + [Fact] + void should_reject_response_reads_outside_success() + { + var contracts = Substitute.For(); + var action = new SetStateAction("id", new("", BindingSourceKind.CommandResponse)); + ResponseBindings.Validate(new("actions", Actions: [action]), contracts).Count.ShouldEqual(1); + } +} diff --git a/Source/DotNET/Engine/Bindings/IResponseValidationContracts.cs b/Source/DotNET/Engine/Bindings/IResponseValidationContracts.cs new file mode 100644 index 00000000..5ff14df0 --- /dev/null +++ b/Source/DotNET/Engine/Bindings/IResponseValidationContracts.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Scene.Model.Interactions; + +namespace Cratis.Scene.Engine.Bindings; + +/// Supplies semantic types without persisting a command catalog in Scene. +public interface IResponseValidationContracts +{ + /// Looks up a command's response contract. + /// The resolved command name. + /// The response contract, or null when metadata is unknown. + ResponseContract? CommandResponse(string command); + + /// Looks up the actual destination type of a binding-bearing operand. + /// The action owning the destination. + /// The destination to inspect. + /// The destination type, or null when metadata is unknown. + ResponseValueType? TargetType(InteractionAction action, ResponseOperand operand); +} diff --git a/Source/DotNET/Engine/Bindings/ResponseBindings.Types.cs b/Source/DotNET/Engine/Bindings/ResponseBindings.Types.cs new file mode 100644 index 00000000..3372f84d --- /dev/null +++ b/Source/DotNET/Engine/Bindings/ResponseBindings.Types.cs @@ -0,0 +1,75 @@ +// 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.RegularExpressions; + +namespace Cratis.Scene.Engine.Bindings; + +public static partial class ResponseBindings +{ + /// Projects a scalar, object or whole array member through own object properties. + /// The command's response value type. + /// The dotted member path; empty selects a scalar or object response. + /// The projected type, or null for an unsupported path. + public static ResponseValueType? ProjectType(ResponseValueType type, string path) + { + if (path.Length == 0) return RootType(type); + var selected = type; + foreach (var segment in path.Split('.')) + { + var property = MemberType(selected, segment); + if (property is null) return null; + selected = NullableProjection(selected, property); + } + return selected; + } + + /// Checks recursive structural compatibility without coercion. + /// The projected response type. + /// The actual destination type. + /// Whether the destination accepts every value of the source type. + public static bool IsAssignable(ResponseValueType source, ResponseValueType target) + { + if (source.Nullable && !target.Nullable) return false; + if (source.Kind != target.Kind) return false; + return AssignableContents(source, target); + } + + static ResponseValueType? RootType(ResponseValueType type) => type.Kind == "array" ? null : type; + static ResponseValueType NullableProjection(ResponseValueType parent, ResponseValueType property) => property with { Nullable = parent.Nullable || property.Nullable }; + + static bool AssignableContents(ResponseValueType source, ResponseValueType target) => source.Kind switch + { + "object" => ObjectAssignable(source, target), + "array" => ArrayAssignable(source, target), + _ => IsPrimitive(source.Kind) + }; + + static bool IsPrimitive(string kind) => kind == "string" || kind == "number" || kind == "boolean"; + + static bool ObjectAssignable(ResponseValueType source, ResponseValueType target) + { + if (target.Properties is null || source.Properties is null) return false; + return target.Properties.All(property => source.Properties.TryGetValue(property.Key, out var value) && IsAssignable(value, property.Value)); + } + + static bool ArrayAssignable(ResponseValueType source, ResponseValueType target) + { + if (source.Item is null || target.Item is null) return false; + return IsAssignable(source.Item, target.Item); + } + + static IReadOnlyDictionary? ObjectProperties(ResponseValueType type) => type.Kind == "object" ? type.Properties : null; + + static ResponseValueType? MemberType(ResponseValueType type, string segment) + { + if (!ValidMemberName(segment)) return null; + return ObjectProperties(type)?.GetValueOrDefault(segment); + } + + static bool ValidMemberName(string segment) => MemberName().IsMatch(segment) && !ProhibitedMember(segment); + static bool ProhibitedMember(string segment) => segment == "constructor" || segment == "prototype" || segment == "__proto__"; + + [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant, 1000)] + private static partial Regex MemberName(); +} diff --git a/Source/DotNET/Engine/Bindings/ResponseBindings.cs b/Source/DotNET/Engine/Bindings/ResponseBindings.cs new file mode 100644 index 00000000..17efbced --- /dev/null +++ b/Source/DotNET/Engine/Bindings/ResponseBindings.cs @@ -0,0 +1,103 @@ +// 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; + +namespace Cratis.Scene.Engine.Bindings; + +/// Validates command-response bindings in lexical continuation scopes. +public static partial class ResponseBindings +{ + static IReadOnlyDictionary ExpectedCategories { get; } = new Dictionary(StringComparer.Ordinal) + { + ["json"] = ["string", "number", "boolean", "object", "array"], + ["collection"] = ["array"], + ["object"] = ["object"], + ["string"] = ["string", "object"], + ["enum"] = ["string", "object"], + ["icon"] = ["string", "object"], + ["destination"] = ["string", "object"], + ["queryReference"] = ["string", "object"], + ["number"] = ["number"], + ["boolean"] = ["boolean"] + }; + + /// Validates every branch, including branches not reachable at runtime. + /// The action-list or form-success entry point. + /// The producer's source and destination metadata. + /// Diagnostics for unavailable or incompatible response operands. + public static IReadOnlyList Validate(ResponseValidationEntry entry, IResponseValidationContracts contracts) + { + var diagnostics = new List(); + var actions = entry.Kind == "form" ? entry.OnSubmit : entry.Actions; + Visit(Continuation(actions), EntryResponse(entry, contracts), contracts, diagnostics); + return diagnostics; + } + + static ResponseContract? EntryResponse(ResponseValidationEntry entry, IResponseValidationContracts contracts) + { + if (entry.Kind != "form" || entry.Command is null) return null; + return contracts.CommandResponse(entry.Command); + } + + static void Visit(IReadOnlyList actions, ResponseContract? response, IResponseValidationContracts contracts, List diagnostics) + { + foreach (var action in actions) + { + ValidateAction(action, response, contracts, diagnostics); + Visit(Continuation(action.OnSuccess), SuccessResponse(action, response, contracts), contracts, diagnostics); + Visit(Continuation(action.OnFailure), FailureResponse(action, response), contracts, diagnostics); + Visit(Continuation(action.OnResult), response, contracts, diagnostics); + } + } + + static IReadOnlyList Continuation(IReadOnlyList? actions) => actions ?? []; + static ResponseContract? SuccessResponse(InteractionAction action, ResponseContract? enclosing, IResponseValidationContracts contracts) => action is ExecuteCommandAction command ? contracts.CommandResponse(command.Command) : enclosing; + static ResponseContract? FailureResponse(InteractionAction action, ResponseContract? enclosing) => action is ExecuteCommandAction ? null : enclosing; + static ResponseValueType? ResponseType(ResponseContract? response) => response?.Kind == "value" ? response.Type : null; + + static void ValidateAction(InteractionAction action, ResponseContract? response, IResponseValidationContracts contracts, List diagnostics) + { + foreach (var (binding, operand) in Operands(action).Where(value => value.Binding.Kind == BindingSourceKind.CommandResponse)) + { + if (!CompatibleOperand(binding, response, contracts.TargetType(action, operand))) diagnostics.Add(new("invalidResponseBinding", "Command response binding requires an available, compatible source and destination.", binding.Path)); + } + } + + static bool CompatibleOperand(BindingExpression binding, ResponseContract? response, ResponseValueType? target) + { + if (binding.Mode == BindingMode.TwoWay) return false; + var type = ResponseType(response); + if (type is null || target is null) return false; + return CompatibleProjection(binding, type, target); + } + + static bool CompatibleProjection(BindingExpression binding, ResponseValueType type, ResponseValueType target) + { + var source = ProjectType(type, binding.Path); + if (source is null) return false; + return IsAssignable(source, target) && ExpectedCategory(source, binding.ExpectedValueType); + } + + static IEnumerable<(BindingExpression Binding, ResponseOperand Operand)> Operands(InteractionAction action) + { + foreach (var argument in action.Arguments ?? []) yield return (argument.Value, new("argument", argument.Name)); + if (action is SetStateAction state) yield return (state.Value, new("stateValue")); + var binding = MessageBinding(action); + if (binding is not null) yield return (binding, new("message")); + } + + static BindingExpression? MessageBinding(InteractionAction action) => action switch + { + NotifyAction notify => notify.Message.Binding, + ConfirmAction confirm => confirm.Message.Binding, + _ => null + }; + + static bool ExpectedCategory(ResponseValueType type, string? expected) + { + if (expected is null) return true; + return ExpectedCategories.GetValueOrDefault(expected)?.Contains(type.Kind, StringComparer.Ordinal) ?? false; + } +} diff --git a/Source/DotNET/Engine/Bindings/ResponseContract.cs b/Source/DotNET/Engine/Bindings/ResponseContract.cs new file mode 100644 index 00000000..ce410030 --- /dev/null +++ b/Source/DotNET/Engine/Bindings/ResponseContract.cs @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Engine.Bindings; + +/// A known command response, distinct from unavailable metadata. +/// Either void or value. +/// The structural type when the response is a value. +public record ResponseContract(string Kind, ResponseValueType? Type = null); diff --git a/Source/DotNET/Engine/Bindings/ResponseOperand.cs b/Source/DotNET/Engine/Bindings/ResponseOperand.cs new file mode 100644 index 00000000..97c2749a --- /dev/null +++ b/Source/DotNET/Engine/Bindings/ResponseOperand.cs @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Engine.Bindings; + +/// Identifies an action's destination for structural validation. +/// Either argument, stateValue or message. +/// The argument name, when the operand is an argument. +public record ResponseOperand(string Kind, string? Name = null); diff --git a/Source/DotNET/Engine/Bindings/ResponseValidationEntry.cs b/Source/DotNET/Engine/Bindings/ResponseValidationEntry.cs new file mode 100644 index 00000000..82576185 --- /dev/null +++ b/Source/DotNET/Engine/Bindings/ResponseValidationEntry.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.Interactions; + +namespace Cratis.Scene.Engine.Bindings; + +/// The entry scope for recursive command-response validation. +/// Either actions or form. +/// The ordinary action list, initially without a response. +/// The form's command name. +/// The form's successful continuation. +public record ResponseValidationEntry(string Kind, IReadOnlyList? Actions = null, string? Command = null, IReadOnlyList? OnSubmit = null); diff --git a/Source/DotNET/Engine/Bindings/ResponseValueType.cs b/Source/DotNET/Engine/Bindings/ResponseValueType.cs new file mode 100644 index 00000000..17e7f794 --- /dev/null +++ b/Source/DotNET/Engine/Bindings/ResponseValueType.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Scene.Engine.Bindings; + +/// A producer-supplied structural JSON value type. +/// The string, number, boolean, object or array category. +/// The named properties of an object. +/// The element type of an array. +/// Whether a present value may be null. +public record ResponseValueType(string Kind, IReadOnlyDictionary? Properties = null, ResponseValueType? Item = null, bool Nullable = false); diff --git a/Source/DotNET/Model.Specs/for_Form/when_adding_submit_continuations.cs b/Source/DotNET/Model.Specs/for_Form/when_adding_submit_continuations.cs new file mode 100644 index 00000000..ef4a9819 --- /dev/null +++ b/Source/DotNET/Model.Specs/for_Form/when_adding_submit_continuations.cs @@ -0,0 +1,45 @@ +// 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.Forms; +using Cratis.Scene.Model.Interactions; + +namespace Cratis.Scene.Model.for_Form; + +public class when_adding_submit_continuations : Specification +{ + readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; + + [Fact] + void should_keep_the_original_positional_constructor() + { + typeof(Form).GetConstructor([typeof(string), typeof(string), typeof(PopulateSource), typeof(IReadOnlyList), typeof(FormGenerationMode?), typeof(CommandFormLayout)]).ShouldNotBeNull(); + new Form("Create", "Create", null, []).OnSubmit.ShouldBeEmpty(); + } + + [Fact] + void should_load_legacy_json_without_a_continuation() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Scene.slnx"))) directory = directory.Parent; + var corpus = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory!.FullName, "command-response-fixtures.json"))).RootElement; + var form = corpus.GetProperty("legacyForm").Deserialize
(_options)!; + form.ForCommand.ShouldEqual("Create"); + form.OnSubmit.ShouldBeEmpty(); + } + + [Fact] + void should_serialize_polymorphic_submit_actions_and_response_bindings() + { + var form = new Form("Create", "Create", null, []) { OnSubmit = [new ExecuteCommandAction("FollowUp") { OnSuccess = [new NavigateAction("detail") { Arguments = [new("id", new("id", BindingSourceKind.CommandResponse))] }] }] }; + var json = JsonSerializer.SerializeToElement(form, _options); + var action = json.GetProperty("onSubmit")[0]; + action.GetProperty("command").GetString().ShouldEqual("FollowUp"); + var argument = action.GetProperty("onSuccess")[0].GetProperty("arguments")[0]; + argument.GetProperty("value").GetProperty("kind").GetString().ShouldEqual("commandResponse"); + argument.GetProperty("value").GetProperty("path").GetString().ShouldEqual("id"); + } +} diff --git a/Source/DotNET/Model/Common/BindingSourceKind.cs b/Source/DotNET/Model/Common/BindingSourceKind.cs index df279c7b..ebd9e466 100644 --- a/Source/DotNET/Model/Common/BindingSourceKind.cs +++ b/Source/DotNET/Model/Common/BindingSourceKind.cs @@ -19,4 +19,7 @@ public enum BindingSourceKind /// A literal value carried by the binding. Literal = 3, + + /// The successful command's response, scoped to its continuation. + CommandResponse = 4, } diff --git a/Source/DotNET/Model/Forms/Form.cs b/Source/DotNET/Model/Forms/Form.cs index df7f8dab..5a39debb 100644 --- a/Source/DotNET/Model/Forms/Form.cs +++ b/Source/DotNET/Model/Forms/Form.cs @@ -26,4 +26,9 @@ public record Form( /// Gets the behaviors attached to the form. Additive with whatever is attached further out or further in. /// public IReadOnlyList Behaviors { get; init; } = []; + + /// + /// Gets the actions to run after the native command succeeds, with its response in scope. + /// + public IReadOnlyList OnSubmit { get; init; } = []; } diff --git a/Source/JavaScript/blueprint.default/preview-host/composeApplication.ts b/Source/JavaScript/blueprint.default/preview-host/composeApplication.ts index c75b5f30..977cdb98 100644 --- a/Source/JavaScript/blueprint.default/preview-host/composeApplication.ts +++ b/Source/JavaScript/blueprint.default/preview-host/composeApplication.ts @@ -150,6 +150,7 @@ function shellComponentOf(shell: EffectiveShell, screen: Screen, hasRoot: boolea export function defaultForm(screenName: string, form: Form): SceneElement { return externalComponent(`form-${screenName}-${form.name}`, 'commandForm', { command: form.forCommand, + onSubmit: form.onSubmit, label: form.name, generationMode: form.generationMode, layout: form.layout, diff --git a/Source/JavaScript/components/forms/CommandFormRuntime.tsx b/Source/JavaScript/components/forms/CommandFormRuntime.tsx index 125f567f..2be5c7dc 100644 --- a/Source/JavaScript/components/forms/CommandFormRuntime.tsx +++ b/Source/JavaScript/components/forms/CommandFormRuntime.tsx @@ -17,6 +17,7 @@ interface CommandFormRuntimeProps { inputs?: CommandInput[]; exclude?: string[]; submitLabel: string; + onSuccess?: (response: unknown) => void; columns?: number; layout?: CommandFormLayout; @@ -29,6 +30,7 @@ const NativeAutoCommandForm = AutoCommandForm as unknown as ComponentType<{ command: BoundConstructor; exclude?: string[]; footer?: ReactNode; + onSuccess?: (response: unknown) => void; }>; type Descriptor = { name: string; type: unknown; isOptional: boolean }; @@ -66,7 +68,7 @@ function capability(command: BoundConstructor, inputs?: CommandInput[], exclude? } /** Private lazy entry point: importing the Scene registry must not import Arc. */ -export default function CommandFormRuntime({ command, inputs, exclude, submitLabel, columns = 1, layout, compact = false }: CommandFormRuntimeProps) { +export default function CommandFormRuntime({ command, inputs, exclude, submitLabel, onSuccess, columns = 1, layout, compact = false }: CommandFormRuntimeProps) { const error = capability(command, inputs, exclude); if (error) return
{error}
; // The registry's never-argument constructor is erased; Arc creates this checked proxy with no args. @@ -76,12 +78,12 @@ export default function CommandFormRuntime({ command, inputs, exclude, submitLab if (diagnostics.length) return
{diagnostics[0].message}
; const formLayout = compact ? stackCommandFormLayout(authoredLayout) : authoredLayout; const columnsCss = formLayout.columns.map(column => formWidthToCss(column.width) ?? 'minmax(0, 1fr)').join(' ') || `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`; - return object} showTitles={false}> + return object} showTitles={false} onSuccess={onSuccess}>
{inputs.map(input => placement.field === input.property) ?? input.placement} />)}
; } - return } />; + return } />; } diff --git a/Source/JavaScript/components/forms/SceneCommandForm.tsx b/Source/JavaScript/components/forms/SceneCommandForm.tsx index 78d3b7a3..208af544 100644 --- a/Source/JavaScript/components/forms/SceneCommandForm.tsx +++ b/Source/JavaScript/components/forms/SceneCommandForm.tsx @@ -2,8 +2,9 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { lazy } from 'react'; -import { WidthSizeClass } from '@cratis/scene.model'; -import { RegisteredComponentProps } from '@cratis/scene.react'; +import { FormPropertyNames, InteractionAction, WidthSizeClass } from '@cratis/scene.model'; +import { formSuccessContinuation } from './formSuccessContinuation'; +import { RegisteredComponentProps, useInteractionScope } from '@cratis/scene.react'; import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; import type { BoundConstructor } from '../bindings'; import { numberProperty, stringArrayProperty, stringProperty, unionProperty } from '../properties'; @@ -30,7 +31,9 @@ function commandKey(command: BoundConstructor): number { * inside a single native Arc form. Neither mode consumes the content slot. */ export function SceneCommandForm({ element }: RegisteredComponentProps) { + const scope = useInteractionScope(); const { name, target } = resolveElementBinding(element, BindingKind.Command); + const continuation = formSuccessContinuation(element.properties[FormPropertyNames.onSubmit] as InteractionAction[] | undefined, name ?? '', scope); if (!target) return ; const mode = unionProperty(element.properties, 'mode', ['auto', 'manual'] as const) ?? (Object.hasOwn(element.properties, 'inputs') ? 'manual' : 'auto'); @@ -42,6 +45,7 @@ export function SceneCommandForm({ element }: RegisteredComponentProps) { = {}) { ; } +function nativeResponse(failure?: string): Response { + return { status: 200, json: async () => ({ + correlationId: '00000000-0000-0000-0000-000000000001', + isSuccess: failure === undefined, isAuthorized: failure !== 'authorization', isValid: failure !== 'validation', hasExceptions: failure === 'exception', + validationResults: failure === 'validation' ? [{ severity: 2, members: ['subject'], message: 'Refused', state: {} }] : [], + exceptionMessages: failure === 'exception' ? ['Failed commit'] : [], exceptionStackTrace: '', + response: { id: 'created-id' }, + }) } as Response; +} + +function continuationScope() { + const setState = vi.fn(); + const executeCommand = vi.fn(); + const dispatcher: ActionDispatcher = { executeCommand, setState, navigate: vi.fn(), navigateBack: vi.fn(), openDialog: vi.fn(), closeDialog: vi.fn(), refreshQuery: vi.fn(), notify: vi.fn(), confirm: vi.fn(), raise: vi.fn() }; + const context: InteractionContext = { resolve: () => undefined, responseContracts: { commandResponse: () => ({ kind: 'value', type: { kind: 'object', properties: { id: { kind: 'string' } } } }), targetType: () => ({ kind: 'string' }) } }; + return { dispatcher, context, setState, executeCommand }; +} + // No Components/form/context doubles. Components <=4.12 ignores footer: these tests MUST fail there, // not skip or pass by recreating the forthcoming upstream behavior. Only HTTP is substituted. describe('when submitting a Scene command form through native AutoCommandForm', () => { @@ -33,6 +53,52 @@ describe('when submitting a Scene command form through native AutoCommandForm', afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); }); + it('should deserialize the native composite response fixture', async () => { + fetch.mockResolvedValue(nativeResponse()); + const command = new RecordNote(); + command.subject = 'Response proof'; + const result = await command.execute(); + result.exceptionMessages.should.deep.equal([]); + result.isSuccess.should.equal(true); + }); + + for (const explicit of [false, true]) { + it(`should continue exactly once from native ${explicit ? 'explicit' : 'automatic'} success without redispatching`, async () => { + const { dispatcher, context, setState, executeCommand } = continuationScope(); + fetch.mockResolvedValue(nativeResponse()); + render( + {view({ ...(explicit ? { inputs: [{ property: 'subject', type: 'string', label: 'Subject' }] } : {}), onSubmit: [{ kind: InteractionActionKind.SetState, target: 'created', value: { kind: BindingSourceKind.CommandResponse, path: 'id' } }] })} + ); + const button = await screen.findByRole('button', { name: 'Submit' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Response proof' } }); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => setState.mock.calls.should.deep.equal([['created', 'created-id']])); + fetch.mock.calls.should.have.lengthOf(1); + executeCommand.mock.calls.should.have.lengthOf(0); + fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Second submission' } }); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => setState.mock.calls.should.have.lengthOf(2)); + fetch.mock.calls.should.have.lengthOf(2); + }); + + for (const failure of ['validation', 'authorization', 'exception']) { + it(`should not continue native ${explicit ? 'explicit' : 'automatic'} ${failure} failure even with a response payload`, async () => { + const { dispatcher, context, setState, executeCommand } = continuationScope(); + fetch.mockResolvedValue(nativeResponse(failure)); + render( + {view({ ...(explicit ? { inputs: [{ property: 'subject', type: 'string', label: 'Subject' }] } : {}), onSubmit: [{ kind: InteractionActionKind.SetState, target: 'created', value: { kind: BindingSourceKind.CommandResponse, path: 'id' } }] })} + ); + const button = await screen.findByRole('button', { name: 'Submit' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Will fail' } }); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(1)); + await waitFor(() => button.disabled.should.equal(false)); + setState.mock.calls.should.have.lengthOf(0); + executeCommand.mock.calls.should.have.lengthOf(0); + }); + } + } + it('should submit the actual form with a labeled button and preserve native payload and context', async () => { render(view({ submitLabel: 'Record note' })); const button = await screen.findByRole('button', { name: 'Record note' }); diff --git a/Source/JavaScript/components/forms/formSuccessContinuation.ts b/Source/JavaScript/components/forms/formSuccessContinuation.ts new file mode 100644 index 00000000..e3dcce58 --- /dev/null +++ b/Source/JavaScript/components/forms/formSuccessContinuation.ts @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from '@cratis/scene.model'; +import { commandResponseContext, runActions } from '@cratis/scene.engine'; +import { InteractionScopeValue } from '@cratis/scene.react'; + +export function formSuccessContinuation(actions: InteractionAction[] | undefined, command: string, scope: InteractionScopeValue | undefined) { + if (!actions?.length) return {}; + return { onSuccess: (response: unknown) => { + if (!scope) return; + void runActions(actions, scope.dispatcher, commandResponseContext(scope.context, command, response)) + .then(result => { if (result.findings.length) scope.onFindings?.(result.findings); }) + .catch(() => scope.onFindings?.([{ kind: 'invalidResponseBinding', detail: 'Unable to run the form success continuation.' }])); + } }; +} diff --git a/Source/JavaScript/engine/ResponseValidationContracts.ts b/Source/JavaScript/engine/ResponseValidationContracts.ts new file mode 100644 index 00000000..f5e48275 --- /dev/null +++ b/Source/JavaScript/engine/ResponseValidationContracts.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { InteractionAction } from '@cratis/scene.model'; + +export type ResponseValueType = ( + { kind: 'string' | 'number' | 'boolean' } | + { kind: 'object'; properties: Record } | + { kind: 'array'; item: ResponseValueType } +) & { nullable?: boolean }; + +export type ResponseContract = { kind: 'void' } | { kind: 'value'; type: ResponseValueType }; +export type ResponseOperand = { kind: 'argument'; name: string } | { kind: 'stateValue' } | { kind: 'message' }; +export interface ResponseValidationContracts { + commandResponse(command: string): ResponseContract | undefined; + targetType(action: InteractionAction, operand: ResponseOperand): ResponseValueType | undefined; +} +export type ResponseValidationEntry = + { kind: 'actions'; actions: InteractionAction[] } | + { kind: 'form'; command: string; onSubmit: InteractionAction[] }; diff --git a/Source/JavaScript/engine/commandResponseContext.ts b/Source/JavaScript/engine/commandResponseContext.ts new file mode 100644 index 00000000..3e0008cd --- /dev/null +++ b/Source/JavaScript/engine/commandResponseContext.ts @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression, BindingSourceKind } from '@cratis/scene.model'; +import type { InteractionContext } from './runActions'; +import { ResponseContract, ResponseValueType } from './ResponseValidationContracts'; +import { projectResponseType, responseValueMatches } from './validateResponseBindings'; + +export class ResponseBindingUnavailable extends Error {} + +function readMember(value: unknown, segment: string): unknown { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new ResponseBindingUnavailable('The command response member is missing.'); + if (!Object.hasOwn(value, segment)) throw new ResponseBindingUnavailable('The command response member is missing.'); + return (value as Record)[segment]; +} + +function responseType(contract: ResponseContract | undefined, binding: BindingExpression) { + if (contract?.kind !== 'value') throw new ResponseBindingUnavailable('The command response is unavailable.'); + const type = projectResponseType(contract.type, binding.path); + if (!type) throw new ResponseBindingUnavailable('The command response is unavailable.'); + return { root: contract.type, projected: type }; +} + +function readResponsePath(value: unknown, type: ResponseValueType, segments: string[]): unknown { + if (value === null) { + if (!type.nullable) throw new ResponseBindingUnavailable('The command response value is incompatible.'); + return null; + } + if (!segments.length) return value; + const [segment, ...remaining] = segments; + if (type.kind !== 'object' || !Object.hasOwn(type.properties, segment)) throw new ResponseBindingUnavailable('The command response member is missing.'); + return readResponsePath(readMember(value, segment), type.properties[segment], remaining); +} + +function presentResponse(value: unknown): unknown { + if (value === undefined) throw new ResponseBindingUnavailable('The command response is unavailable.'); + if (value === null) throw new ResponseBindingUnavailable('The command response is unavailable.'); + return value; +} + +function resolveResponse(contract: ResponseContract | undefined, value: unknown, binding: BindingExpression): unknown { + const { root, projected } = responseType(contract, binding); + const present = presentResponse(value); + const selected = readResponsePath(present, root, binding.path ? binding.path.split('.') : []); + if (!responseValueMatches(selected, projected)) throw new ResponseBindingUnavailable('The command response value is incompatible.'); + return selected; +} + +export function commandResponseContext(context: InteractionContext, command: string, value: unknown, available = true): InteractionContext { + const responseContract = available ? context.responseContracts?.commandResponse(command) : undefined; + return { + ...context, + responseContract, + resolve: binding => binding.kind === BindingSourceKind.CommandResponse ? resolveResponse(responseContract, value, binding) : context.resolve(binding), + }; +} diff --git a/Source/JavaScript/engine/for_resolveBindingExpression/when_a_response_has_no_continuation_scope.ts b/Source/JavaScript/engine/for_resolveBindingExpression/when_a_response_has_no_continuation_scope.ts new file mode 100644 index 00000000..6f5518d3 --- /dev/null +++ b/Source/JavaScript/engine/for_resolveBindingExpression/when_a_response_has_no_continuation_scope.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 { BindingNullBehavior, BindingSourceKind } from '@cratis/scene.model'; +import { resolveBindingExpression } from '../resolveBindingExpression'; + +describe('when a response has no continuation scope', () => { + for (const nullBehavior of [undefined, BindingNullBehavior.Clear, BindingNullBehavior.Preserve]) { + it(`should not resolve a command response from ordinary data or null behavior ${nullBehavior}`, () => { + expect(resolveBindingExpression({ kind: BindingSourceKind.CommandResponse, path: 'id', nullBehavior }, { dataContext: { id: 'not-a-response' } })).toBeUndefined(); + }); + } +}); diff --git a/Source/JavaScript/engine/for_runActions/when_consuming_command_responses.ts b/Source/JavaScript/engine/for_runActions/when_consuming_command_responses.ts new file mode 100644 index 00000000..9d84c3b7 --- /dev/null +++ b/Source/JavaScript/engine/for_runActions/when_consuming_command_responses.ts @@ -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 { BindingMode, BindingSourceKind, ExecuteCommandAction, InteractionAction, InteractionActionKind, BindingNullBehavior, PropertyValueType, SetStateAction } from '@cratis/scene.model'; +import { InteractionContext, InteractionStop, runActions } from '../runActions'; +import { ResponseContract, ResponseValidationContracts, ResponseValueType } from '../ResponseValidationContracts'; +import { validateResponseBindings } from '../validateResponseBindings'; +import { commandResponseContext } from '../commandResponseContext'; +import { RecordingDispatcher } from './given/a_recording_dispatcher'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const corpus = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', '..', '..', 'command-response-fixtures.json'), 'utf8')) as { + cases: { name: string; response?: ResponseContract; path: string; target?: ResponseValueType; expectedValueType?: PropertyValueType; mode?: BindingMode; valid: boolean }[]; + scopes: { name: string; actions: InteractionAction[]; diagnostics: number }[]; + runtime: { name: string; type: ResponseValueType; value?: unknown; path: string; target: ResponseValueType; valid: boolean; expected?: unknown }[]; +}; + +const binding = { kind: BindingSourceKind.CommandResponse, path: '' }; +const state: SetStateAction = { kind: InteractionActionKind.SetState, target: 'id', value: binding }; +const contracts: ResponseValidationContracts = { commandResponse: () => ({ kind: 'value', type: { kind: 'string' } }), targetType: () => ({ kind: 'string' }) }; +const context: InteractionContext = { resolve: () => undefined, responseContracts: contracts }; +const execute = (command: string, onSuccess: SetStateAction[] = []): ExecuteCommandAction => ({ kind: InteractionActionKind.ExecuteCommand, command, onSuccess }); + +class ResponseDispatcher extends RecordingDispatcher { + override async executeCommand(command: string) { + this.calls.push(`execute:${command}`); + return { isSuccess: command !== 'failed', response: command === 'missing' ? undefined : command }; + } +} + +describe('when consuming command responses', () => { + for (const entry of corpus.cases) { + it(`should validate ${entry.name} identically to C#`, () => { + const target = entry.target as ResponseValueType | undefined; + const action = { ...state, value: { ...binding, path: entry.path, mode: ('mode' in entry ? entry.mode : undefined) as BindingMode | undefined, expectedValueType: ('expectedValueType' in entry ? entry.expectedValueType : undefined) as PropertyValueType | undefined } }; + const supplied: ResponseValidationContracts = { commandResponse: () => entry.response as ResponseContract | undefined, targetType: () => target }; + (validateResponseBindings({ kind: 'form', command: 'create', onSubmit: [action] }, supplied).length === 0).should.equal(entry.valid); + }); + } + + for (const entry of corpus.scopes) { + it(`should validate lexical scope ${entry.name} identically to C#`, () => { + const supplied: ResponseValidationContracts = { commandResponse: command => command === 'known' ? { kind: 'value', type: { kind: 'string' } } : undefined, targetType: () => ({ kind: 'string' }) }; + validateResponseBindings({ kind: 'actions', actions: entry.actions }, supplied).should.have.lengthOf(entry.diagnostics); + }); + } + + for (const entry of corpus.runtime) { + it(`should resolve ${entry.name} before dispatch and ignore nullBehavior for missing data`, async () => { + const dispatcher = new RecordingDispatcher(); + const values: unknown[] = []; + dispatcher.setState = (_, value) => { values.push(value); }; + const supplied: InteractionContext = { resolve: () => undefined, responseContracts: { commandResponse: () => ({ kind: 'value', type: entry.type }), targetType: () => entry.target } }; + const action: SetStateAction = { ...state, value: { ...binding, path: entry.path, nullBehavior: BindingNullBehavior.Clear } }; + const result = await runActions([action], dispatcher, commandResponseContext(supplied, 'create', entry.value)); + result.stop.should.equal(entry.valid ? InteractionStop.Completed : InteractionStop.Reported); + result.findings.map(finding => finding.kind).should.deep.equal(entry.valid ? [] : ['invalidResponseBinding']); + values.should.deep.equal(entry.valid ? [entry.expected] : []); + }); + } + + it('should refuse inherited runtime members', async () => { + const dispatcher = new RecordingDispatcher(); + const supplied: InteractionContext = { resolve: () => undefined, responseContracts: { commandResponse: () => ({ kind: 'value', type: { kind: 'object', properties: { id: { kind: 'string' } } } }), targetType: () => ({ kind: 'string' }) } }; + const response: unknown = Object.create({ id: 'inherited' }); + const action: SetStateAction = { ...state, value: { ...binding, path: 'id' } }; + const result = await runActions([action], dispatcher, commandResponseContext(supplied, 'create', response)); + result.stop.should.equal(InteractionStop.Reported); + dispatcher.calls.should.have.lengthOf(0); + }); + + for (const kind of [InteractionActionKind.Navigate, InteractionActionKind.OpenDialog, InteractionActionKind.Notify, InteractionActionKind.Confirm]) { + it(`should validate response-bearing ${kind} operands before dispatch`, async () => { + const dispatcher = new ResponseDispatcher(); + const action = { kind, screen: 'detail', dialogTemplate: 'detail', arguments: [{ name: 'id', value: binding }], message: { binding } } as InteractionAction; + const result = await runActions([action], dispatcher, context); + result.stop.should.equal(InteractionStop.Reported); + dispatcher.calls.should.have.lengthOf(0); + }); + } + + for (const kind of [InteractionActionKind.NavigateBack, InteractionActionKind.Confirm]) { + it(`should propagate a nested ${kind} stop through the enclosing command`, async () => { + const dispatcher = new ResponseDispatcher(); + dispatcher.confirmAnswer = false; + const result = await runActions([{ ...execute('outer'), onSuccess: [{ kind, message: { text: 'Proceed?' } } as InteractionAction] }, execute('must-not-run')], dispatcher, context); + result.stop.should.equal(kind === InteractionActionKind.NavigateBack ? InteractionStop.Navigated : InteractionStop.Declined); + dispatcher.calls.some(call => call.includes('must-not-run')).should.equal(false); + }); + } + + it('should replace metadata for a nested differently shaped response and restore its parent', async () => { + const dispatcher = new RecordingDispatcher(); + dispatcher.executeCommand = async command => ({ isSuccess: true, response: command === 'inner' ? { id: 'inner-id' } : 'outer-id' }); + const supplied: InteractionContext = { ...context, responseContracts: { ...contracts, commandResponse: command => ({ kind: 'value', type: command === 'inner' ? { kind: 'object', properties: { id: { kind: 'string' } } } : { kind: 'string' } }) } }; + await runActions([{ ...execute('outer'), onSuccess: [execute('inner', [{ ...state, value: { ...binding, path: 'id' } }]), state] }], dispatcher, supplied); + dispatcher.calls.should.deep.equal(['setState:id:inner-id', 'setState:id:outer-id']); + }); + + it('should allow response-independent success actions without metadata or payload', async () => { + const dispatcher = new RecordingDispatcher(); + const result = await runActions([{ ...execute('void'), onSuccess: [{ kind: InteractionActionKind.NavigateBack }] }], dispatcher, { resolve: () => undefined }); + result.stop.should.equal(InteractionStop.Navigated); + }); + + it('should restore outer success after nested success and not leak to later siblings', async () => { + const dispatcher = new ResponseDispatcher(); + const result = await runActions([{ ...execute('outer'), onSuccess: [state, execute('inner', [state]), state] }, state], dispatcher, context); + dispatcher.calls.should.deep.equal(['execute:outer', 'setState:id:outer', 'execute:inner', 'setState:id:inner', 'setState:id:outer']); + result.stop.should.equal(InteractionStop.Reported); + }); + + it('should stop on an unavailable response before effects or later siblings', async () => { + const dispatcher = new ResponseDispatcher(); + const result = await runActions([execute('missing', [state]), execute('later')], dispatcher, context); + dispatcher.calls.should.deep.equal(['execute:missing']); + result.stop.should.equal(InteractionStop.Reported); + }); + + it('should clear response scope in command failure continuations', async () => { + const dispatcher = new ResponseDispatcher(); + const result = await runActions([{ ...execute('outer'), onSuccess: [{ ...execute('failed'), onFailure: [state] }] }], dispatcher, context); + dispatcher.calls.should.deep.equal(['execute:outer', 'execute:failed']); + result.stop.should.equal(InteractionStop.Reported); + }); + + it('should reject response reads in root and failure branches during validation', () => { + validateResponseBindings({ kind: 'actions', actions: [state, { ...execute('outer'), onFailure: [state] }] }, contracts).should.have.lengthOf(2); + }); + + it('should keep concurrent runs independent', async () => { + const first = new ResponseDispatcher(); + const second = new ResponseDispatcher(); + await Promise.all([runActions([execute('first', [state])], first, context), runActions([execute('second', [state])], second, context)]); + first.calls.should.deep.equal(['execute:first', 'setState:id:first']); + second.calls.should.deep.equal(['execute:second', 'setState:id:second']); + }); +}); diff --git a/Source/JavaScript/engine/index.ts b/Source/JavaScript/engine/index.ts index 06cc7c0e..26fe6d7e 100644 --- a/Source/JavaScript/engine/index.ts +++ b/Source/JavaScript/engine/index.ts @@ -39,6 +39,9 @@ export * from './buildStarterProfile'; export * from './incompatibleStarterThemes'; export * from './ActionDispatcher'; export * from './runActions'; +export * from './ResponseValidationContracts'; +export * from './validateResponseBindings'; +export * from './commandResponseContext'; export * from './guards'; export * from './resolveBehaviors'; export * from './resolveStrings'; diff --git a/Source/JavaScript/engine/resolveBindingExpression.ts b/Source/JavaScript/engine/resolveBindingExpression.ts index d6a85998..b96420d2 100644 --- a/Source/JavaScript/engine/resolveBindingExpression.ts +++ b/Source/JavaScript/engine/resolveBindingExpression.ts @@ -9,6 +9,7 @@ import { BindingScope } from './BindingScope'; */ export function resolveBindingExpression(binding: BindingExpression, scope: BindingScope): unknown { const kind = binding.kind ?? BindingSourceKind.DataContext; + if (kind === BindingSourceKind.CommandResponse) return undefined; // A literal without a value is absent, whether the author wrote `null` or nothing: the C# model cannot // tell the two apart, and both engines must resolve the same document to the same value. @@ -39,6 +40,8 @@ function sourceValue(binding: BindingExpression, scope: BindingScope, kind: Bind return binding.componentId ? scope.componentOutputs?.[binding.componentId] : undefined; case BindingSourceKind.Literal: return binding.value; + case BindingSourceKind.CommandResponse: + return undefined; } } diff --git a/Source/JavaScript/engine/responseValueTypes.ts b/Source/JavaScript/engine/responseValueTypes.ts new file mode 100644 index 00000000..5d287caf --- /dev/null +++ b/Source/JavaScript/engine/responseValueTypes.ts @@ -0,0 +1,86 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ResponseValueType } from './ResponseValidationContracts'; + +function memberType(type: ResponseValueType, segment: string): ResponseValueType | undefined { + if (type.kind !== 'object') return undefined; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment)) return undefined; + if (['constructor', 'prototype', '__proto__'].includes(segment)) return undefined; + return Object.hasOwn(type.properties, segment) ? type.properties[segment] : undefined; +} + +function nullableProjection(parent: ResponseValueType, property: ResponseValueType): ResponseValueType { + return { ...property, nullable: parent.nullable || property.nullable }; +} + +function rootType(type: ResponseValueType): ResponseValueType | undefined { + return type.kind === 'array' ? undefined : type; +} + +export function projectResponseType(type: ResponseValueType, path: string): ResponseValueType | undefined { + if (!path) return rootType(type); + let selected = type; + for (const segment of path.split('.')) { + const property = memberType(selected, segment); + if (!property) return undefined; + selected = nullableProjection(selected, property); + } + return selected; +} + +function objectAssignable(source: Extract, target: ResponseValueType): boolean { + if (target.kind !== 'object') return false; + return Object.entries(target.properties).every(([name, type]) => Object.hasOwn(source.properties, name) && responseTypeAssignable(source.properties[name], type)); +} + +function arrayAssignable(source: Extract, target: ResponseValueType): boolean { + if (target.kind !== 'array') return false; + return responseTypeAssignable(source.item, target.item); +} + +function assignableContents(source: ResponseValueType, target: ResponseValueType): boolean { + if (source.kind === 'object') return objectAssignable(source, target); + if (source.kind === 'array') return arrayAssignable(source, target); + return true; +} + +function acceptsNull(source: ResponseValueType, target: ResponseValueType): boolean { + return !source.nullable || target.nullable === true; +} + +export function responseTypeAssignable(source: ResponseValueType, target: ResponseValueType): boolean { + if (!acceptsNull(source, target)) return false; + if (source.kind !== target.kind) return false; + return assignableContents(source, target); +} + +function isObjectValue(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function objectValueMatches(value: unknown, type: Extract): boolean { + if (!isObjectValue(value)) return false; + return Object.entries(type.properties).every(([name, property]) => Object.hasOwn(value, name) && responseValueMatches(value[name], property)); +} + +function arrayValueMatches(value: unknown, type: Extract): boolean { + return Array.isArray(value) && value.every(item => responseValueMatches(item, type.item)); +} + +function numberValueMatches(value: unknown): boolean { + return typeof value === 'number' && Number.isFinite(value); +} + +function presentValueMatches(value: unknown, type: ResponseValueType): boolean { + switch (type.kind) { + case 'object': return objectValueMatches(value, type); + case 'array': return arrayValueMatches(value, type); + case 'number': return numberValueMatches(value); + default: return typeof value === type.kind; + } +} + +export function responseValueMatches(value: unknown, type: ResponseValueType): boolean { + return value === null ? type.nullable === true : presentValueMatches(value, type); +} diff --git a/Source/JavaScript/engine/runActions.ts b/Source/JavaScript/engine/runActions.ts index 94d11197..7476b7cf 100644 --- a/Source/JavaScript/engine/runActions.ts +++ b/Source/JavaScript/engine/runActions.ts @@ -21,6 +21,9 @@ import { import { ActionDispatcher } from './ActionDispatcher'; import { BindingResolver } from './BindingResolver'; import { selectGuardedInteraction } from './guards'; +import { ResponseContract, ResponseValidationContracts } from './ResponseValidationContracts'; +import { validateResponseAction } from './validateResponseBindings'; +import { commandResponseContext, ResponseBindingUnavailable } from './commandResponseContext'; /** * Why an interaction stopped before running everything it was given. @@ -46,7 +49,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' | 'unevaluableCondition'; + kind: 'unknownAction' | 'unresolvedMessage' | 'unresolvedCondition' | 'unevaluableCondition' | 'invalidResponseBinding'; detail: string; } @@ -65,6 +68,8 @@ export interface InteractionRun { */ export interface InteractionContext { resolve: BindingResolver; + responseContracts?: ResponseValidationContracts; + responseContract?: ResponseContract; subject?: () => unknown; /** Looks up a localization key. Returns the key itself when the host has no catalogue for it. */ localize?: (key: string) => string; @@ -148,8 +153,34 @@ async function runSequence( run: InteractionRun ): Promise { for (const action of actions) { - if (await runOne(action, dispatcher, context, run)) return; + if (run.stop !== InteractionStop.Completed) return; + const diagnostics = validateResponseAction(action, context.responseContract, context.responseContracts); + if (diagnostics.length) { + run.findings.push(...diagnostics.map(diagnostic => ({ kind: 'invalidResponseBinding' as const, detail: diagnostic.message }))); + run.stop = InteractionStop.Reported; + return; + } + try { + if (await runOne(action, dispatcher, context, run)) return; + } catch (error) { + if (!(error instanceof ResponseBindingUnavailable)) throw error; + run.findings.push({ kind: 'invalidResponseBinding', detail: error.message }); + run.stop = InteractionStop.Reported; + return; + } + } +} + +async function runConfirmation(action: ConfirmAction, dispatcher: ActionDispatcher, context: InteractionContext, run: InteractionRun) { + run.ran.push(describe(action)); + const confirmed = await dispatcher.confirm(messageOf(action.message, context, run.findings)); + if (!confirmed) { + await runSequence(action.onFailure ?? [], dispatcher, context, run); + if (run.stop === InteractionStop.Completed) run.stop = InteractionStop.Declined; + return true; } + await runSequence(action.onSuccess ?? [], dispatcher, context, run); + return false; } async function runOne( @@ -163,8 +194,10 @@ async function runOne( switch (action.kind) { case InteractionActionKind.ExecuteCommand: { run.ran.push(describe(action)); - const outcome = await dispatcher.executeCommand((action as ExecuteCommandAction).command, args); - await runSequence(outcome.isSuccess ? (action.onSuccess ?? []) : (action.onFailure ?? []), dispatcher, context, run); + const command = (action as ExecuteCommandAction).command; + const outcome = await dispatcher.executeCommand(command, args); + const continuation = commandResponseContext(context, command, outcome.response, outcome.isSuccess); + await runSequence(outcome.isSuccess ? (action.onSuccess ?? []) : (action.onFailure ?? []), dispatcher, continuation, run); return false; } @@ -210,18 +243,8 @@ async function runOne( dispatcher.notify((action as NotifyAction).level, messageOf((action as NotifyAction).message, context, run.findings)); return false; - case InteractionActionKind.Confirm: { - run.ran.push(describe(action)); - const confirmed = await dispatcher.confirm(messageOf((action as ConfirmAction).message, context, run.findings)); - if (!confirmed) { - await runSequence((action.onFailure ?? []), dispatcher, context, run); - run.stop = InteractionStop.Declined; - return true; - } - - await runSequence((action.onSuccess ?? []), dispatcher, context, run); - return false; - } + case InteractionActionKind.Confirm: + return runConfirmation(action as ConfirmAction, dispatcher, context, run); case InteractionActionKind.RaiseTrigger: { run.ran.push(describe(action)); diff --git a/Source/JavaScript/engine/validateResponseBindings.ts b/Source/JavaScript/engine/validateResponseBindings.ts new file mode 100644 index 00000000..427b7526 --- /dev/null +++ b/Source/JavaScript/engine/validateResponseBindings.ts @@ -0,0 +1,93 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { BindingExpression, BindingMode, BindingSourceKind, ExecuteCommandAction, InteractionAction, InteractionActionKind, PropertyValueType, SetStateAction } from '@cratis/scene.model'; +import { BindingDiagnostic } from './BindingDiagnostic'; +import { ResponseContract, ResponseOperand, ResponseValidationContracts, ResponseValidationEntry, ResponseValueType } from './ResponseValidationContracts'; +import { projectResponseType, responseTypeAssignable } from './responseValueTypes'; +export { projectResponseType, responseTypeAssignable, responseValueMatches } from './responseValueTypes'; + +const expectedCategories: Partial> = { + [PropertyValueType.Json]: ['string', 'number', 'boolean', 'object', 'array'], + [PropertyValueType.Collection]: ['array'], + [PropertyValueType.Object]: ['object'], + [PropertyValueType.String]: ['string', 'object'], + [PropertyValueType.Enum]: ['string', 'object'], + [PropertyValueType.Icon]: ['string', 'object'], + [PropertyValueType.Destination]: ['string', 'object'], + [PropertyValueType.QueryReference]: ['string', 'object'], + [PropertyValueType.Number]: ['number'], + [PropertyValueType.Boolean]: ['boolean'], +}; + +function messageBinding(action: InteractionAction): BindingExpression | undefined { + if (!('message' in action)) return undefined; + return (action.message as { binding?: BindingExpression } | undefined)?.binding; +} + +export function responseOperands(action: InteractionAction): { binding: BindingExpression; operand: ResponseOperand }[] { + const operands: { binding: BindingExpression; operand: ResponseOperand }[] = (action.arguments ?? []).map(argument => ({ binding: argument.value, operand: { kind: 'argument', name: argument.name } })); + if (action.kind === InteractionActionKind.SetState) operands.push({ binding: (action as SetStateAction).value, operand: { kind: 'stateValue' } }); + const message = messageBinding(action); + if (message) operands.push({ binding: message, operand: { kind: 'message' } }); + return operands.filter(operand => operand.binding.kind === BindingSourceKind.CommandResponse); +} + +function expectedCategory(type: ResponseValueType, expected?: PropertyValueType): boolean { + if (expected === undefined) return true; + return expectedCategories[expected]?.includes(type.kind) ?? false; +} + +function responseType(response?: ResponseContract): ResponseValueType | undefined { + return response?.kind === 'value' ? response.type : undefined; +} + +function compatibleProjection(binding: BindingExpression, type: ResponseValueType, target: ResponseValueType): boolean { + const source = projectResponseType(type, binding.path); + if (!source) return false; + return responseTypeAssignable(source, target) && expectedCategory(source, binding.expectedValueType); +} + +function compatibleOperand(binding: BindingExpression, response: ResponseContract | undefined, target: ResponseValueType | undefined): boolean { + if (binding.mode === BindingMode.TwoWay) return false; + if (!target) return false; + const type = responseType(response); + if (!type) return false; + return compatibleProjection(binding, type, target); +} + +export function validateResponseAction(action: InteractionAction, response: ResponseContract | undefined, contracts?: ResponseValidationContracts): BindingDiagnostic[] { + return responseOperands(action).flatMap(({ binding, operand }) => compatibleOperand(binding, response, contracts?.targetType(action, operand)) ? [] : [{ code: 'invalidResponseBinding', message: 'Command response binding requires an available, compatible source and destination.', path: binding.path }]); +} + +function successResponse(action: InteractionAction, enclosing: ResponseContract | undefined, contracts: ResponseValidationContracts): ResponseContract | undefined { + return action.kind === InteractionActionKind.ExecuteCommand ? contracts.commandResponse((action as ExecuteCommandAction).command) : enclosing; +} + +function failureResponse(action: InteractionAction, enclosing?: ResponseContract): ResponseContract | undefined { + return action.kind === InteractionActionKind.ExecuteCommand ? undefined : enclosing; +} + +function continuation(actions?: InteractionAction[]): InteractionAction[] { + return actions ?? []; +} + +function visit(actions: InteractionAction[], response: ResponseContract | undefined, contracts: ResponseValidationContracts, diagnostics: BindingDiagnostic[]) { + for (const action of actions) { + diagnostics.push(...validateResponseAction(action, response, contracts)); + visit(continuation(action.onSuccess), successResponse(action, response, contracts), contracts, diagnostics); + visit(continuation(action.onFailure), failureResponse(action, response), contracts, diagnostics); + visit(continuation(action.onResult), response, contracts, diagnostics); + } +} + +function entryResponse(entry: ResponseValidationEntry, contracts: ResponseValidationContracts): ResponseContract | undefined { + return entry.kind === 'form' ? contracts.commandResponse(entry.command) : undefined; +} + +export function validateResponseBindings(entry: ResponseValidationEntry, contracts: ResponseValidationContracts): BindingDiagnostic[] { + const diagnostics: BindingDiagnostic[] = []; + const actions = entry.kind === 'form' ? entry.onSubmit : entry.actions; + visit(actions, entryResponse(entry, contracts), contracts, diagnostics); + return diagnostics; +} diff --git a/Source/JavaScript/model/common/BindingSourceKind.ts b/Source/JavaScript/model/common/BindingSourceKind.ts index 2206bfde..e8b8bf27 100644 --- a/Source/JavaScript/model/common/BindingSourceKind.ts +++ b/Source/JavaScript/model/common/BindingSourceKind.ts @@ -16,4 +16,6 @@ export enum BindingSourceKind { /** A literal value carried by the binding. */ Literal = 'literal', + + CommandResponse = 'commandResponse', } diff --git a/Source/JavaScript/model/forms/Form.ts b/Source/JavaScript/model/forms/Form.ts index 82fd7ca9..85ec5d11 100644 --- a/Source/JavaScript/model/forms/Form.ts +++ b/Source/JavaScript/model/forms/Form.ts @@ -5,7 +5,7 @@ import { CommandFormLayout } from './CommandFormLayout'; import { FormGenerationMode } from './FormGenerationMode'; import { PopulateSource } from './PopulateSource'; import { FormField } from './FormField'; -import { Behavior } from '../interactions'; +import { Behavior, InteractionAction } from '../interactions'; /** * The command-side counterpart to a table or summary: a named, typed form for one command. @@ -22,6 +22,9 @@ export interface Form { * attached further out or further in. */ behaviors?: Behavior[]; + onSubmit?: InteractionAction[]; } -export const FormPropertyNames: (keyof Form)[] = ['name', 'forCommand', 'populateSource', 'generationMode', 'layout', 'fields', 'behaviors']; +const onSubmit = 'onSubmit' satisfies keyof Form; +const propertyNames: (keyof Form)[] = ['name', 'forCommand', 'populateSource', 'generationMode', 'layout', 'fields', 'behaviors', onSubmit]; +export const FormPropertyNames = Object.assign(propertyNames, { onSubmit }); diff --git a/Source/JavaScript/react/for_RenderBindingScope/when_preserving_form_submit_declarations.tsx b/Source/JavaScript/react/for_RenderBindingScope/when_preserving_form_submit_declarations.tsx new file mode 100644 index 00000000..d4353749 --- /dev/null +++ b/Source/JavaScript/react/for_RenderBindingScope/when_preserving_form_submit_declarations.tsx @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { cleanup, render, screen } from '@testing-library/react'; +import { BindingSourceKind, ExternalComponent, FormPropertyNames, HorizontalAlignment, InteractionAction, InteractionActionKind, SetStateAction, VerticalAlignment, Visibility } from '@cratis/scene.model'; +import { commandResponseContext, runActions } from '@cratis/scene.engine'; +import { RecordingDispatcher } from '../../engine/for_runActions/given/a_recording_dispatcher'; +import { InteractiveComponent } from '../renderer/InteractiveComponent'; +import { RegisteredComponentProps } from '../renderer'; +import { RenderBindingScopeProvider } from '../renderer/RenderBindingScopeContext'; + +const response = { kind: BindingSourceKind.CommandResponse, path: 'id' }; +const ordinary = { kind: BindingSourceKind.DataContext, path: 'label' }; +const actions: SetStateAction[] = [{ kind: InteractionActionKind.SetState, target: 'created', value: response }, { kind: InteractionActionKind.SetState, target: 'label', value: ordinary }]; +const element: ExternalComponent = { + id: 'form', name: 'Form', componentName: 'test:form', slots: {}, + properties: { [FormPropertyNames.onSubmit]: actions, label: ordinary, nested: { label: ordinary }, labels: [ordinary] }, + size: {}, minimumSize: {}, maximumSize: {}, margin: { left: 0, top: 0, right: 0, bottom: 0 }, + visibility: Visibility.Visible, isEnabled: true, opacity: 1, zIndex: 0, + horizontalAlignment: HorizontalAlignment.Stretch, verticalAlignment: VerticalAlignment.Stretch, +}; + +describe('when preserving form submit declarations', () => { + const received: ExternalComponent[] = []; + function Capture({ element: rendered }: RegisteredComponentProps) { + received.push(rendered); + return {String(rendered.properties.label)}; + } + beforeEach(() => { received.length = 0; }); + afterEach(cleanup); + + function renderForm() { + render( + + ); + } + + it('should preserve onSubmit identity while resolving every ordinary visual property', () => { + renderForm(); + expect(received[0].properties[FormPropertyNames.onSubmit]).toBe(actions); + expect(screen.getByText('render-time')).toBeDefined(); + expect(received[0].properties.nested).toEqual({ label: 'render-time' }); + expect(received[0].properties.labels).toEqual(['render-time']); + actions[0].should.deep.equal({ kind: InteractionActionKind.SetState, target: 'created', value: response }); + }); + + it('should evaluate non-response bindings inside the continuation at execution, not render', async () => { + renderForm(); + const dispatcher = new RecordingDispatcher(); + let label = 'before-execution'; + const context = commandResponseContext({ resolve: () => label }, 'Create', undefined); + label = 'execution-time'; + const continuation = received[0].properties[FormPropertyNames.onSubmit] as InteractionAction[]; + await runActions(continuation.slice(1), dispatcher, context); + dispatcher.calls.should.deep.equal(['setState:label:execution-time']); + }); +}); diff --git a/Source/JavaScript/react/renderer/InteractiveComponent.tsx b/Source/JavaScript/react/renderer/InteractiveComponent.tsx index 3a158eb1..49814310 100644 --- a/Source/JavaScript/react/renderer/InteractiveComponent.tsx +++ b/Source/JavaScript/react/renderer/InteractiveComponent.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { ComponentType, ReactNode, createElement, useEffect, useMemo } from 'react'; -import { BindingExpression, ExternalComponent } from '@cratis/scene.model'; +import { BindingExpression, ExternalComponent, FormPropertyNames } from '@cratis/scene.model'; import { useInteractions } from '../interactions'; import { useBindingOutputs } from './BindingOutputContext'; import { RegisteredComponentProps } from './ComponentRegistry'; @@ -34,7 +34,7 @@ export function InteractiveComponent({ component, element, slots }: InteractiveC } function resolveProperties(properties: Record, resolveBinding: (binding: BindingExpression) => unknown): Record { - return Object.fromEntries(Object.entries(properties).map(([name, value]) => [name, resolveValue(value, resolveBinding)])); + return Object.fromEntries(Object.entries(properties).map(([name, value]) => [name, name === FormPropertyNames.onSubmit ? value : resolveValue(value, resolveBinding)])); } function resolveValue(value: unknown, resolveBinding: (binding: BindingExpression) => unknown): unknown { diff --git a/command-response-fixtures.json b/command-response-fixtures.json new file mode 100644 index 00000000..4277051a --- /dev/null +++ b/command-response-fixtures.json @@ -0,0 +1,62 @@ +{ + "legacyForm": { "name": "Create", "forCommand": "Create", "populateSource": null, "fields": [] }, + "scopes": [ + { "name": "root unavailable", "actions": [{ "kind": "SetState", "target": "id", "value": { "kind": "commandResponse", "path": "" } }], "diagnostics": 1 }, + { "name": "nested unknown shadows known", "actions": [{ "kind": "ExecuteCommand", "command": "known", "onSuccess": [{ "kind": "ExecuteCommand", "command": "unknown", "onSuccess": [{ "kind": "SetState", "target": "id", "value": { "kind": "commandResponse", "path": "" } }] }] }], "diagnostics": 1 }, + { "name": "failure masks outer", "actions": [{ "kind": "ExecuteCommand", "command": "known", "onSuccess": [{ "kind": "ExecuteCommand", "command": "known", "onFailure": [{ "kind": "SetState", "target": "id", "value": { "kind": "commandResponse", "path": "" } }] }] }], "diagnostics": 1 }, + { "name": "nested success in failure", "actions": [{ "kind": "ExecuteCommand", "command": "known", "onFailure": [{ "kind": "ExecuteCommand", "command": "known", "onSuccess": [{ "kind": "SetState", "target": "id", "value": { "kind": "commandResponse", "path": "" } }] }] }], "diagnostics": 0 }, + { "name": "non-command continuations inherit", "actions": [{ "kind": "ExecuteCommand", "command": "known", "onSuccess": [{ "kind": "RefreshQuery", "query": "query", "onSuccess": [{ "kind": "SetState", "target": "id", "value": { "kind": "commandResponse", "path": "" } }], "onResult": [{ "kind": "SetState", "target": "id", "value": { "kind": "commandResponse", "path": "" } }] }] }], "diagnostics": 0 }, + { "name": "siblings do not inherit", "actions": [{ "kind": "ExecuteCommand", "command": "known", "onSuccess": [] }, { "kind": "SetState", "target": "id", "value": { "kind": "commandResponse", "path": "" } }], "diagnostics": 1 } + ], + "runtime": [ + { "name": "false", "type": { "kind": "boolean" }, "value": false, "path": "", "target": { "kind": "boolean" }, "valid": true, "expected": false }, + { "name": "zero", "type": { "kind": "number" }, "value": 0, "path": "", "target": { "kind": "number" }, "valid": true, "expected": 0 }, + { "name": "empty string", "type": { "kind": "string" }, "value": "", "path": "", "target": { "kind": "string" }, "valid": true, "expected": "" }, + { "name": "whole object", "type": { "kind": "object", "properties": { "street": { "kind": "string" } } }, "value": { "street": "Main" }, "path": "", "target": { "kind": "object", "properties": { "street": { "kind": "string" } } }, "valid": true, "expected": { "street": "Main" } }, + { "name": "direct composite", "type": { "kind": "object", "properties": { "street": { "kind": "string" } } }, "value": { "street": "Main" }, "path": "street", "target": { "kind": "string" }, "valid": true, "expected": "Main" }, + { "name": "nested composite", "type": { "kind": "object", "properties": { "address": { "kind": "object", "properties": { "street": { "kind": "string" } } } } }, "value": { "address": { "street": "Main" } }, "path": "address.street", "target": { "kind": "string" }, "valid": true, "expected": "Main" }, + { "name": "nullable projection", "type": { "kind": "object", "properties": { "address": { "kind": "object", "nullable": true, "properties": { "street": { "kind": "string" } } } } }, "value": { "address": null }, "path": "address.street", "target": { "kind": "string", "nullable": true }, "valid": true, "expected": null }, + { "name": "nonnullable intermediate null with nullable leaf", "type": { "kind": "object", "properties": { "address": { "kind": "object", "properties": { "street": { "kind": "string", "nullable": true } } } } }, "value": { "address": null }, "path": "address.street", "target": { "kind": "string", "nullable": true }, "valid": false }, + { "name": "nonnullable deeper intermediate null", "type": { "kind": "object", "properties": { "address": { "kind": "object", "nullable": true, "properties": { "location": { "kind": "object", "properties": { "street": { "kind": "string", "nullable": true } } } } } } }, "value": { "address": { "location": null } }, "path": "address.location.street", "target": { "kind": "string", "nullable": true }, "valid": false }, + { "name": "nonnullable leaf below present nullable parent", "type": { "kind": "object", "properties": { "address": { "kind": "object", "nullable": true, "properties": { "street": { "kind": "string" } } } } }, "value": { "address": { "street": null } }, "path": "address.street", "target": { "kind": "string", "nullable": true }, "valid": false }, + { "name": "nullable leaf below required parent", "type": { "kind": "object", "properties": { "address": { "kind": "object", "properties": { "street": { "kind": "string", "nullable": true } } } } }, "value": { "address": { "street": null } }, "path": "address.street", "target": { "kind": "string", "nullable": true }, "valid": true, "expected": null }, + { "name": "whole nested array", "type": { "kind": "object", "properties": { "ids": { "kind": "array", "item": { "kind": "string" } } } }, "value": { "ids": ["one", "two"] }, "path": "ids", "target": { "kind": "array", "item": { "kind": "string" } }, "valid": true, "expected": ["one", "two"] }, + { "name": "missing response", "type": { "kind": "string" }, "path": "", "target": { "kind": "string" }, "valid": false }, + { "name": "top level null", "type": { "kind": "string", "nullable": true }, "value": null, "path": "", "target": { "kind": "string", "nullable": true }, "valid": false }, + { "name": "missing member", "type": { "kind": "object", "properties": { "id": { "kind": "string", "nullable": true } } }, "value": {}, "path": "id", "target": { "kind": "string", "nullable": true }, "valid": false }, + { "name": "wrong value category", "type": { "kind": "string" }, "value": 1, "path": "", "target": { "kind": "string" }, "valid": false }, + { "name": "wrong structural member", "type": { "kind": "object", "properties": { "id": { "kind": "string" } } }, "value": { "id": 1 }, "path": "", "target": { "kind": "object", "properties": { "id": { "kind": "string" } } }, "valid": false }, + { "name": "wrong array item", "type": { "kind": "object", "properties": { "ids": { "kind": "array", "item": { "kind": "string" } } } }, "value": { "ids": [1] }, "path": "ids", "target": { "kind": "array", "item": { "kind": "string" } }, "valid": false } + ], + "cases": [ + { "name": "string root", "response": { "kind": "value", "type": { "kind": "string" } }, "path": "", "target": { "kind": "string" }, "valid": true }, + { "name": "number root", "response": { "kind": "value", "type": { "kind": "number" } }, "path": "", "target": { "kind": "number" }, "valid": true }, + { "name": "boolean root", "response": { "kind": "value", "type": { "kind": "boolean" } }, "path": "", "target": { "kind": "boolean" }, "valid": true }, + { "name": "object root", "response": { "kind": "value", "type": { "kind": "object", "properties": { "id": { "kind": "string" } } } }, "path": "", "target": { "kind": "object", "properties": { "id": { "kind": "string" } } }, "valid": true }, + { "name": "whole object category", "response": { "kind": "value", "type": { "kind": "object", "properties": { "id": { "kind": "string" } } } }, "path": "", "expectedValueType": "object", "target": { "kind": "object", "properties": { "id": { "kind": "string" } } }, "valid": true }, + { "name": "projected object category", "response": { "kind": "value", "type": { "kind": "object", "properties": { "address": { "kind": "object", "properties": { "street": { "kind": "string" } } } } } }, "path": "address", "expectedValueType": "object", "target": { "kind": "object", "properties": { "street": { "kind": "string" } } }, "valid": true }, + { "name": "object category does not accept primitives", "response": { "kind": "value", "type": { "kind": "string" } }, "path": "", "expectedValueType": "object", "target": { "kind": "string" }, "valid": false }, + { "name": "object category still requires structural compatibility", "response": { "kind": "value", "type": { "kind": "object", "properties": { "id": { "kind": "string" } } } }, "path": "", "expectedValueType": "object", "target": { "kind": "object", "properties": { "id": { "kind": "number" } } }, "valid": false }, + { "name": "nested member", "response": { "kind": "value", "type": { "kind": "object", "properties": { "order": { "kind": "object", "properties": { "id": { "kind": "string" } } } } } }, "path": "order.id", "target": { "kind": "string" }, "valid": true }, + { "name": "array member", "response": { "kind": "value", "type": { "kind": "object", "properties": { "ids": { "kind": "array", "item": { "kind": "string" } } } } }, "path": "ids", "target": { "kind": "array", "item": { "kind": "string" } }, "valid": true }, + { "name": "void", "response": { "kind": "void" }, "path": "", "target": { "kind": "string" }, "valid": false }, + { "name": "unknown command", "path": "", "target": { "kind": "string" }, "valid": false }, + { "name": "unknown destination", "response": { "kind": "value", "type": { "kind": "string" } }, "path": "", "valid": false }, + { "name": "root array", "response": { "kind": "value", "type": { "kind": "array", "item": { "kind": "string" } } }, "path": "", "target": { "kind": "array", "item": { "kind": "string" } }, "valid": false }, + { "name": "numeric traversal", "response": { "kind": "value", "type": { "kind": "array", "item": { "kind": "string" } } }, "path": "0", "target": { "kind": "string" }, "valid": false }, + { "name": "unknown member", "response": { "kind": "value", "type": { "kind": "object", "properties": {} } }, "path": "id", "target": { "kind": "string" }, "valid": false }, + { "name": "prototype", "response": { "kind": "value", "type": { "kind": "object", "properties": {} } }, "path": "constructor", "target": { "kind": "string" }, "valid": false }, + { "name": "mismatched target", "response": { "kind": "value", "type": { "kind": "number" } }, "path": "", "target": { "kind": "string" }, "valid": false }, + { "name": "expected type does not supply target", "response": { "kind": "value", "type": { "kind": "string" } }, "path": "", "expectedValueType": "string", "valid": false }, + { "name": "expected type narrows", "response": { "kind": "value", "type": { "kind": "string" } }, "path": "", "expectedValueType": "number", "target": { "kind": "string" }, "valid": false }, + { "name": "nullable member", "response": { "kind": "value", "type": { "kind": "object", "nullable": true, "properties": { "id": { "kind": "string" } } } }, "path": "id", "target": { "kind": "string", "nullable": true }, "valid": true }, + { "name": "nullable to required", "response": { "kind": "value", "type": { "kind": "string", "nullable": true } }, "path": "", "target": { "kind": "string" }, "valid": false }, + { "name": "primitive traversal", "response": { "kind": "value", "type": { "kind": "string" } }, "path": "length", "target": { "kind": "number" }, "valid": false }, + { "name": "malformed path", "response": { "kind": "value", "type": { "kind": "object", "properties": {} } }, "path": "id..value", "target": { "kind": "string" }, "valid": false }, + { "name": "wildcard path", "response": { "kind": "value", "type": { "kind": "object", "properties": {} } }, "path": "*", "target": { "kind": "string" }, "valid": false }, + { "name": "structural target mismatch", "response": { "kind": "value", "type": { "kind": "object", "properties": { "id": { "kind": "string" } } } }, "path": "", "target": { "kind": "object", "properties": { "id": { "kind": "number" } } }, "valid": false }, + { "name": "extra source members", "response": { "kind": "value", "type": { "kind": "object", "properties": { "id": { "kind": "string" }, "other": { "kind": "number" } } } }, "path": "", "target": { "kind": "object", "properties": { "id": { "kind": "string" } } }, "valid": true }, + { "name": "array item mismatch", "response": { "kind": "value", "type": { "kind": "object", "properties": { "ids": { "kind": "array", "item": { "kind": "string" } } } } }, "path": "ids", "target": { "kind": "array", "item": { "kind": "number" } }, "valid": false }, + { "name": "two way", "response": { "kind": "value", "type": { "kind": "string" } }, "path": "", "mode": "twoWay", "target": { "kind": "string" }, "valid": false } + ] +} diff --git a/scene-model-shape.json b/scene-model-shape.json index 6cc70483..20c123a3 100644 --- a/scene-model-shape.json +++ b/scene-model-shape.json @@ -185,7 +185,8 @@ "generationMode", "layout", "fields", - "behaviors" + "behaviors", + "onSubmit" ], "ContributionPoint": [ "name" @@ -812,7 +813,8 @@ "DataContext", "QueryResult", "ComponentProperty", - "Literal" + "Literal", + "CommandResponse" ], "BindingMode": [ "OneWay",