From f295248609b5cac0236420377c9608b759c57596 Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 23 Sep 2026 16:55:46 +0200 Subject: [PATCH] Give notify and confirm somewhere to appear A document can ask for two things that have no backend and no route behind them: say something to the user, and ask the user something. Every other action ends up at a host that knows how to do it, but these two are purely what the user sees, so until now a host either invented a surface or the action went to the console. The browser's own prompt was the fallback for confirm, and it is a poor one: it blocks the event loop, cannot be styled, and cannot be driven by a test. A confirm exists to gate what follows it, which makes it worth being able to assert on. The new surface asks in the page, and declining is what happens when the question is dismissed, because a gate that opens when ignored is not one. The third surface reports what this host cannot do at all. That is only useful while an application is being wired, and an application that has wired everything will never see it - but a modelled button that silently does nothing is the hardest kind of defect to trace, because nothing failed and the document is correct. It is shown rather than logged since the person who would fix it is looking at the screen. The same action is reported once however often it is asked for, so an unsupported action on an interval cannot grow until it covers the application it is describing. The three are handed out together because they are wired the same way and forgetting to render one is the failure they exist to prevent. An application with a design system of its own passes its own notify after the spread and keeps the rest. A scope's context is now optional. A document whose messages are literal text and whose actions take no arguments resolves nothing, which is an ordinary document rather than a degenerate one, and requiring a context anyway made every host invent an empty resolver - a place for a mistake to hide. Dialogs are deliberately not here. Showing one means resolving a dialog template from the application model, and nothing in React can reach the application yet. Guessing at that seam now would be harder to correct than leaving it. --- ...hen_a_document_asks_the_user_something.tsx | 106 ++++++++++++++++++ ...when_the_host_cannot_do_what_was_asked.tsx | 66 +++++++++++ .../react/interactions/ConfirmHost.tsx | 42 +++++++ .../react/interactions/ConfirmRequest.ts | 19 ++++ .../react/interactions/InteractionScope.tsx | 24 +++- .../react/interactions/NotificationHost.tsx | 48 ++++++++ .../react/interactions/SceneNotification.ts | 18 +++ .../react/interactions/UnresolvedAction.ts | 17 +++ .../interactions/UnresolvedActionHost.tsx | 33 ++++++ Source/JavaScript/react/interactions/index.ts | 7 ++ .../interactions/useInteractionHosts.tsx | 101 +++++++++++++++++ 11 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 Source/JavaScript/react/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx create mode 100644 Source/JavaScript/react/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.tsx create mode 100644 Source/JavaScript/react/interactions/ConfirmHost.tsx create mode 100644 Source/JavaScript/react/interactions/ConfirmRequest.ts create mode 100644 Source/JavaScript/react/interactions/NotificationHost.tsx create mode 100644 Source/JavaScript/react/interactions/SceneNotification.ts create mode 100644 Source/JavaScript/react/interactions/UnresolvedAction.ts create mode 100644 Source/JavaScript/react/interactions/UnresolvedActionHost.tsx create mode 100644 Source/JavaScript/react/interactions/useInteractionHosts.tsx diff --git a/Source/JavaScript/react/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx b/Source/JavaScript/react/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx new file mode 100644 index 00000000..0d9b1ae8 --- /dev/null +++ b/Source/JavaScript/react/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx @@ -0,0 +1,106 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useMemo } from 'react'; +import { InteractionActionKind, InteractionTriggerKind, Behavior, NotificationLevel } from '@cratis/scene.model'; +import { InteractionScope, createBrowserDispatcher, useInteractionHosts, useInteractions } from '../interactions'; + +const executed: string[] = []; + +const confirmThenExecute: Behavior = { + name: 'CancelInvoice', + bindings: [ + { + trigger: { kind: InteractionTriggerKind.Click } as never, + actions: [ + { kind: InteractionActionKind.Confirm, message: { text: 'Cancel this invoice?' } } as never, + { kind: InteractionActionKind.ExecuteCommand, command: 'CancelInvoice' } as never, + { kind: InteractionActionKind.Notify, level: NotificationLevel.Info, message: { text: 'Invoice cancelled' } } as never, + ], + }, + ], +}; + +function Button({ behaviors }: { behaviors: Behavior[] }) { + const handlers = useInteractions('element', behaviors); + return ; +} + +function Application({ behaviors }: { behaviors: Behavior[] }) { + const { host, surfaces } = useInteractionHosts(); + const dispatcher = useMemo(() => createBrowserDispatcher({ + ...host, + executeCommand: async command => { + executed.push(command); + return { isSuccess: true }; + }, + }), [host]); + + return ( + + ; +} + +/** + * A host that can do nothing at all - a preview surface, or an application half-way through being wired. + */ +function Application() { + const { host, surfaces } = useInteractionHosts(); + const dispatcher = useMemo(() => createBrowserDispatcher(host), [host]); + + return ( + + + + + + + ); +} diff --git a/Source/JavaScript/react/interactions/ConfirmRequest.ts b/Source/JavaScript/react/interactions/ConfirmRequest.ts new file mode 100644 index 00000000..86cd68c6 --- /dev/null +++ b/Source/JavaScript/react/interactions/ConfirmRequest.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * A question an interaction is waiting on the answer to. + * + * The sequence that asked is suspended until {@link answer} is called, which is the whole point of a confirm: + * everything after it in the document runs only if the answer was yes. + */ +export interface ConfirmRequest { + /** What the document asked, already resolved against the interaction's context. */ + readonly message: string; + + /** + * Answers the question and lets the suspended sequence continue. + * @param confirmed Whether the user agreed. + */ + answer(confirmed: boolean): void; +} diff --git a/Source/JavaScript/react/interactions/InteractionScope.tsx b/Source/JavaScript/react/interactions/InteractionScope.tsx index 9aded5e5..9c3ddf2a 100644 --- a/Source/JavaScript/react/interactions/InteractionScope.tsx +++ b/Source/JavaScript/react/interactions/InteractionScope.tsx @@ -22,9 +22,29 @@ export interface InteractionScopeValue { onFindings?: (findings: InteractionFinding[]) => void; } +/** + * The context a scope uses when it was given none. + * + * A document whose messages are literal text and whose actions take no arguments resolves nothing, and that is + * an ordinary document rather than a degenerate one. Requiring a context anyway would make every host invent + * an empty resolver, and an invented one is a place for a mistake to hide. + */ +const resolvesNothing: InteractionContext = { resolve: () => undefined }; + const Scope = createContext(undefined); -export interface InteractionScopeProps extends InteractionScopeValue { +/** + * The properties of {@link InteractionScope}. + * + * The context is the one thing an author may leave out, because a document whose messages are literal text and + * whose actions take no arguments resolves nothing. Everything beneath a scope still reads a context that is + * definitely there, so only the author is spared, not the code doing the work. + */ +export interface InteractionScopeProps extends Omit { + /** How bindings and localization resolve. Omitted means nothing resolves. */ + context?: InteractionContext; + + /** What the scope encloses. */ children: ReactNode; } @@ -36,7 +56,7 @@ export interface InteractionScopeProps extends InteractionScopeValue { * additive rule true in a renderer - a module's behavior is still in scope at a button six levels down, * without the button knowing the module exists. */ -export function InteractionScope({ children, dispatcher, context, attachments, onFindings }: InteractionScopeProps) { +export function InteractionScope({ children, dispatcher, context = resolvesNothing, attachments, onFindings }: InteractionScopeProps) { const enclosing = useContext(Scope); const value = useMemo(() => ({ diff --git a/Source/JavaScript/react/interactions/NotificationHost.tsx b/Source/JavaScript/react/interactions/NotificationHost.tsx new file mode 100644 index 00000000..ddd69240 --- /dev/null +++ b/Source/JavaScript/react/interactions/NotificationHost.tsx @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { NotificationLevel } from '@cratis/scene.model'; +import { SceneNotification } from './SceneNotification'; + +/** + * The properties of {@link NotificationHost}. + */ +export interface NotificationHostProps { + /** What is currently being said, oldest first. */ + readonly notifications: readonly SceneNotification[]; + + /** Called when one is dismissed. */ + readonly onDismiss: (id: number) => void; +} + +const colours: Record = { + [NotificationLevel.Info]: '#1e6fbf', + [NotificationLevel.Warning]: '#b8860b', + [NotificationLevel.Error]: '#c0392b', +}; + +/** + * Shows what a document's `notify` asked to say. + * + * Deliberately plain. An application with a design system of its own passes its own `notify` to the dispatcher + * and never renders this; what this exists for is that the default is *something* rather than a console line + * nobody is looking at. + * @param props The {@link NotificationHostProps}. + */ +export function NotificationHost({ notifications, onDismiss }: NotificationHostProps) { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map(notification => ( +
+ {notification.message} + +
+ ))} +
+ ); +} diff --git a/Source/JavaScript/react/interactions/SceneNotification.ts b/Source/JavaScript/react/interactions/SceneNotification.ts new file mode 100644 index 00000000..92ec3881 --- /dev/null +++ b/Source/JavaScript/react/interactions/SceneNotification.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { NotificationLevel } from '@cratis/scene.model'; + +/** + * Something a document asked to be said, waiting to be seen. + */ +export interface SceneNotification { + /** Identifies the notification for the lifetime of the surface showing it. */ + readonly id: number; + + /** How the document classified it. */ + readonly level: NotificationLevel; + + /** What it says, already resolved against the interaction's context. */ + readonly message: string; +} diff --git a/Source/JavaScript/react/interactions/UnresolvedAction.ts b/Source/JavaScript/react/interactions/UnresolvedAction.ts new file mode 100644 index 00000000..0da060f3 --- /dev/null +++ b/Source/JavaScript/react/interactions/UnresolvedAction.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * An action a document asked for that this host cannot carry out. + * + * Kept as data rather than written straight to the console, so a surface can show it. A modelled button that + * silently does nothing is the hardest kind of defect to trace back to its cause: nothing failed, so nothing + * was reported, and the document looks correct because it is. + */ +export interface UnresolvedAction { + /** Identifies the report for the lifetime of the surface showing it. */ + readonly id: number; + + /** What the document asked for, phrased as the document phrased it. */ + readonly action: string; +} diff --git a/Source/JavaScript/react/interactions/UnresolvedActionHost.tsx b/Source/JavaScript/react/interactions/UnresolvedActionHost.tsx new file mode 100644 index 00000000..cfc92e48 --- /dev/null +++ b/Source/JavaScript/react/interactions/UnresolvedActionHost.tsx @@ -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 { UnresolvedAction } from './UnresolvedAction'; + +/** + * The properties of {@link UnresolvedActionHost}. + */ +export interface UnresolvedActionHostProps { + /** What has been asked for and could not be done. */ + readonly unresolved: readonly UnresolvedAction[]; +} + +/** + * Shows what a document asked for that this host cannot do. + * + * Only useful while an application is being built, which is why an application that has wired everything will + * never see it. It is rendered rather than logged because the person who would fix it is usually looking at + * the screen and not at the console. + * @param props The {@link UnresolvedActionHostProps}. + */ +export function UnresolvedActionHost({ unresolved }: UnresolvedActionHostProps) { + if (unresolved.length === 0) return null; + + return ( +
+ This surface cannot do what the document asked +
    + {unresolved.map(action =>
  • {action.action}
  • )} +
+
+ ); +} diff --git a/Source/JavaScript/react/interactions/index.ts b/Source/JavaScript/react/interactions/index.ts index 3803a5fe..635280b2 100644 --- a/Source/JavaScript/react/interactions/index.ts +++ b/Source/JavaScript/react/interactions/index.ts @@ -4,3 +4,10 @@ export * from './InteractionScope'; export * from './useInteractions'; export * from './createBrowserDispatcher'; +export * from './ConfirmHost'; +export * from './ConfirmRequest'; +export * from './NotificationHost'; +export * from './SceneNotification'; +export * from './UnresolvedAction'; +export * from './UnresolvedActionHost'; +export * from './useInteractionHosts'; diff --git a/Source/JavaScript/react/interactions/useInteractionHosts.tsx b/Source/JavaScript/react/interactions/useInteractionHosts.tsx new file mode 100644 index 00000000..ab910693 --- /dev/null +++ b/Source/JavaScript/react/interactions/useInteractionHosts.tsx @@ -0,0 +1,101 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { NotificationLevel } from '@cratis/scene.model'; +import { BrowserDispatcherHost } from './createBrowserDispatcher'; +import { ConfirmHost } from './ConfirmHost'; +import { ConfirmRequest } from './ConfirmRequest'; +import { NotificationHost } from './NotificationHost'; +import { SceneNotification } from './SceneNotification'; +import { UnresolvedAction } from './UnresolvedAction'; +import { UnresolvedActionHost } from './UnresolvedActionHost'; + +/** + * What {@link useInteractionHosts} gives an application. + */ +export interface InteractionHosts { + /** The parts of a {@link BrowserDispatcherHost} these surfaces provide. Spread it into the host you build. */ + readonly host: Pick; + + /** The surfaces themselves. Render them once, anywhere inside the application. */ + readonly surfaces: ReactNode; +} + +/** + * How long a notification stays before it removes itself, in milliseconds. + */ +export interface InteractionHostOptions { + /** How long a notification stays up. `0` keeps it until it is dismissed. */ + readonly dismissAfter?: number; +} + +/** + * Provides the default surfaces for the actions that need somewhere to appear. + * + * `notify` and `confirm` are the two actions a document can take that have no backend and no route: they are + * purely things the user sees. Without a surface, a host either invents one or the action goes to the console, + * which is why these ship with Scene rather than being every application's first chore. + * + * The three are returned as one unit because they are wired the same way and forgetting to render one of them + * is exactly the failure they exist to prevent: + * + * ```tsx + * const { host, surfaces } = useInteractionHosts(); + * const dispatcher = useMemo(() => createBrowserDispatcher({ ...host, executeCommand, navigate }), [host]); + * + * return {children}{surfaces}; + * ``` + * + * An application with a design system of its own passes its own `notify` after the spread and keeps the rest. + * @param options The {@link InteractionHostOptions}. + * @returns The {@link InteractionHosts}. + */ +export function useInteractionHosts(options: InteractionHostOptions = {}): InteractionHosts { + const { dismissAfter = 6000 } = options; + const [notifications, setNotifications] = useState([]); + const [unresolved, setUnresolved] = useState([]); + const [request, setRequest] = useState(undefined); + const nextId = useRef(0); + + const dismiss = useCallback((id: number) => setNotifications(current => current.filter(_ => _.id !== id)), []); + + const notify = useCallback((level: NotificationLevel, message: string) => { + const id = nextId.current++; + setNotifications(current => [...current, { id, level, message }]); + + if (dismissAfter > 0) { + setTimeout(() => setNotifications(current => current.filter(_ => _.id !== id)), dismissAfter); + } + }, [dismissAfter]); + + const confirm = useCallback((message: string) => new Promise(resolve => { + setRequest({ + message, + answer(confirmed) { + // Cleared before resolving, so the sequence that continues cannot find a question on screen + // that has already been answered. + setRequest(undefined); + resolve(confirmed); + }, + }); + }), []); + + const onUnsupported = useCallback((action: string) => { + // Reported once per distinct action. An unsupported action on a hover or an interval would otherwise + // grow the list until it covered the application it is trying to tell you about. + setUnresolved(current => current.some(_ => _.action === action) ? current : [...current, { id: nextId.current++, action }]); + }, []); + + const host = useMemo(() => ({ notify, confirm, onUnsupported }), [notify, confirm, onUnsupported]); + + const surfaces = useMemo(() => ( + <> + + + + + ), [notifications, dismiss, request, unresolved]); + + return { host, surfaces }; +}