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 }; +}