@cratis/scene.react 3.7.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/CoreButton.tsx +2 -2
- package/dist/cjs/SceneElementView.js +2 -0
- package/dist/cjs/SceneElementView.js.map +1 -1
- package/dist/cjs/core/CoreButton.js +2 -1
- package/dist/cjs/core/CoreButton.js.map +1 -1
- package/dist/cjs/index.js +18 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/interactions/ConfirmHost.js +71 -0
- package/dist/cjs/interactions/ConfirmHost.js.map +1 -0
- package/dist/cjs/interactions/InteractionScope.js +74 -0
- package/dist/cjs/interactions/InteractionScope.js.map +1 -0
- package/dist/cjs/interactions/NotificationHost.js +73 -0
- package/dist/cjs/interactions/NotificationHost.js.map +1 -0
- package/dist/cjs/interactions/UnresolvedActionHost.js +53 -0
- package/dist/cjs/interactions/UnresolvedActionHost.js.map +1 -0
- package/dist/cjs/interactions/createBrowserDispatcher.js +102 -0
- package/dist/cjs/interactions/createBrowserDispatcher.js.map +1 -0
- package/dist/cjs/interactions/useInteractionHosts.js +109 -0
- package/dist/cjs/interactions/useInteractionHosts.js.map +1 -0
- package/dist/cjs/interactions/useInteractions.js +122 -0
- package/dist/cjs/interactions/useInteractions.js.map +1 -0
- package/dist/cjs/renderer/InteractiveComponent.js +26 -0
- package/dist/cjs/renderer/InteractiveComponent.js.map +1 -0
- package/dist/cjs/renderer/createReactRenderer.js +3 -1
- package/dist/cjs/renderer/createReactRenderer.js.map +1 -1
- package/dist/esm/SceneElementView.js +2 -0
- package/dist/esm/SceneElementView.js.map +1 -1
- package/dist/esm/core/CoreButton.d.ts +1 -1
- package/dist/esm/core/CoreButton.d.ts.map +1 -1
- package/dist/esm/core/CoreButton.js +2 -1
- package/dist/esm/core/CoreButton.js.map +1 -1
- package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.d.ts +2 -0
- package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.d.ts.map +1 -0
- package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.js +73 -0
- package/dist/esm/for_useInteractionHosts/when_a_document_asks_the_user_something.js.map +1 -0
- package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.d.ts +2 -0
- package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.d.ts.map +1 -0
- package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.js +43 -0
- package/dist/esm/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.js.map +1 -0
- package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.d.ts +2 -0
- package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.d.ts.map +1 -0
- package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.js +94 -0
- package/dist/esm/for_useInteractions/when_clicking_an_element_that_executes_a_command.js.map +1 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +8 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/interactions/ConfirmHost.d.ts +8 -0
- package/dist/esm/interactions/ConfirmHost.d.ts.map +1 -0
- package/dist/esm/interactions/ConfirmHost.js +69 -0
- package/dist/esm/interactions/ConfirmHost.js.map +1 -0
- package/dist/esm/interactions/ConfirmRequest.d.ts +5 -0
- package/dist/esm/interactions/ConfirmRequest.d.ts.map +1 -0
- package/dist/esm/interactions/ConfirmRequest.js +2 -0
- package/dist/esm/interactions/ConfirmRequest.js.map +1 -0
- package/dist/esm/interactions/InteractionScope.d.ts +17 -0
- package/dist/esm/interactions/InteractionScope.d.ts.map +1 -0
- package/dist/esm/interactions/InteractionScope.js +70 -0
- package/dist/esm/interactions/InteractionScope.js.map +1 -0
- package/dist/esm/interactions/NotificationHost.d.ts +7 -0
- package/dist/esm/interactions/NotificationHost.d.ts.map +1 -0
- package/dist/esm/interactions/NotificationHost.js +71 -0
- package/dist/esm/interactions/NotificationHost.js.map +1 -0
- package/dist/esm/interactions/SceneNotification.d.ts +7 -0
- package/dist/esm/interactions/SceneNotification.d.ts.map +1 -0
- package/dist/esm/interactions/SceneNotification.js +2 -0
- package/dist/esm/interactions/SceneNotification.js.map +1 -0
- package/dist/esm/interactions/UnresolvedAction.d.ts +5 -0
- package/dist/esm/interactions/UnresolvedAction.d.ts.map +1 -0
- package/dist/esm/interactions/UnresolvedAction.js +2 -0
- package/dist/esm/interactions/UnresolvedAction.js.map +1 -0
- package/dist/esm/interactions/UnresolvedActionHost.d.ts +6 -0
- package/dist/esm/interactions/UnresolvedActionHost.d.ts.map +1 -0
- package/dist/esm/interactions/UnresolvedActionHost.js +51 -0
- package/dist/esm/interactions/UnresolvedActionHost.js.map +1 -0
- package/dist/esm/interactions/createBrowserDispatcher.d.ts +17 -0
- package/dist/esm/interactions/createBrowserDispatcher.d.ts.map +1 -0
- package/dist/esm/interactions/createBrowserDispatcher.js +100 -0
- package/dist/esm/interactions/createBrowserDispatcher.js.map +1 -0
- package/dist/esm/interactions/index.d.ts +11 -0
- package/dist/esm/interactions/index.d.ts.map +1 -0
- package/dist/esm/interactions/index.js +11 -0
- package/dist/esm/interactions/index.js.map +1 -0
- package/dist/esm/interactions/useInteractionHosts.d.ts +11 -0
- package/dist/esm/interactions/useInteractionHosts.d.ts.map +1 -0
- package/dist/esm/interactions/useInteractionHosts.js +107 -0
- package/dist/esm/interactions/useInteractionHosts.js.map +1 -0
- package/dist/esm/interactions/useInteractions.d.ts +16 -0
- package/dist/esm/interactions/useInteractions.d.ts.map +1 -0
- package/dist/esm/interactions/useInteractions.js +120 -0
- package/dist/esm/interactions/useInteractions.js.map +1 -0
- package/dist/esm/renderer/ComponentRegistry.d.ts +2 -0
- package/dist/esm/renderer/ComponentRegistry.d.ts.map +1 -1
- package/dist/esm/renderer/InteractiveComponent.d.ts +10 -0
- package/dist/esm/renderer/InteractiveComponent.d.ts.map +1 -0
- package/dist/esm/renderer/InteractiveComponent.js +24 -0
- package/dist/esm/renderer/InteractiveComponent.js.map +1 -0
- package/dist/esm/renderer/createReactRenderer.d.ts.map +1 -1
- package/dist/esm/renderer/createReactRenderer.js +3 -1
- package/dist/esm/renderer/createReactRenderer.js.map +1 -1
- package/dist/esm/renderer/index.d.ts +1 -0
- package/dist/esm/renderer/index.d.ts.map +1 -1
- package/dist/esm/renderer/index.js +1 -0
- package/dist/esm/renderer/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/for_useInteractionHosts/when_a_document_asks_the_user_something.tsx +106 -0
- package/for_useInteractionHosts/when_the_host_cannot_do_what_was_asked.tsx +66 -0
- package/for_useInteractions/when_clicking_an_element_that_executes_a_command.tsx +141 -0
- package/index.ts +1 -0
- package/interactions/ConfirmHost.tsx +42 -0
- package/interactions/ConfirmRequest.ts +19 -0
- package/interactions/InteractionScope.tsx +94 -0
- package/interactions/NotificationHost.tsx +48 -0
- package/interactions/SceneNotification.ts +18 -0
- package/interactions/UnresolvedAction.ts +17 -0
- package/interactions/UnresolvedActionHost.tsx +33 -0
- package/interactions/createBrowserDispatcher.ts +160 -0
- package/interactions/index.ts +13 -0
- package/interactions/useInteractionHosts.tsx +101 -0
- package/interactions/useInteractions.ts +116 -0
- package/package.json +5 -5
- package/renderer/ComponentRegistry.ts +11 -0
- package/renderer/InteractiveComponent.tsx +25 -0
- package/renderer/createReactRenderer.tsx +2 -1
- package/renderer/index.ts +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { render, screen, within } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
import { InteractionActionKind, InteractionTriggerKind, Behavior, NotificationLevel } from '@cratis/scene.model';
|
|
8
|
+
import { InteractionScope, createBrowserDispatcher, useInteractionHosts, useInteractions } from '../interactions';
|
|
9
|
+
|
|
10
|
+
const executed: string[] = [];
|
|
11
|
+
|
|
12
|
+
const confirmThenExecute: Behavior = {
|
|
13
|
+
name: 'CancelInvoice',
|
|
14
|
+
bindings: [
|
|
15
|
+
{
|
|
16
|
+
trigger: { kind: InteractionTriggerKind.Click } as never,
|
|
17
|
+
actions: [
|
|
18
|
+
{ kind: InteractionActionKind.Confirm, message: { text: 'Cancel this invoice?' } } as never,
|
|
19
|
+
{ kind: InteractionActionKind.ExecuteCommand, command: 'CancelInvoice' } as never,
|
|
20
|
+
{ kind: InteractionActionKind.Notify, level: NotificationLevel.Info, message: { text: 'Invoice cancelled' } } as never,
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function Button({ behaviors }: { behaviors: Behavior[] }) {
|
|
27
|
+
const handlers = useInteractions('element', behaviors);
|
|
28
|
+
return <button type="button" {...handlers}>Cancel</button>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Application({ behaviors }: { behaviors: Behavior[] }) {
|
|
32
|
+
const { host, surfaces } = useInteractionHosts();
|
|
33
|
+
const dispatcher = useMemo(() => createBrowserDispatcher({
|
|
34
|
+
...host,
|
|
35
|
+
executeCommand: async command => {
|
|
36
|
+
executed.push(command);
|
|
37
|
+
return { isSuccess: true };
|
|
38
|
+
},
|
|
39
|
+
}), [host]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<InteractionScope dispatcher={dispatcher} attachments={[]}>
|
|
43
|
+
<Button behaviors={behaviors} />
|
|
44
|
+
{surfaces}
|
|
45
|
+
</InteractionScope>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('when a document asks the user something', () => {
|
|
50
|
+
beforeEach(() => { executed.length = 0; });
|
|
51
|
+
|
|
52
|
+
it('should ask in the page rather than in a browser prompt', async () => {
|
|
53
|
+
render(<Application behaviors={[confirmThenExecute]} />);
|
|
54
|
+
|
|
55
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
56
|
+
|
|
57
|
+
screen.getByRole('alertdialog').getAttribute('aria-label')!.should.equal('Cancel this invoice?');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// The reason a confirm exists at all: what follows it runs only if the answer was yes.
|
|
61
|
+
it('should not run what follows until the question is answered', async () => {
|
|
62
|
+
render(<Application behaviors={[confirmThenExecute]} />);
|
|
63
|
+
|
|
64
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
65
|
+
|
|
66
|
+
executed.should.have.lengthOf(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should run what follows when the answer is yes', async () => {
|
|
70
|
+
render(<Application behaviors={[confirmThenExecute]} />);
|
|
71
|
+
|
|
72
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
73
|
+
await userEvent.click(screen.getByRole('button', { name: 'OK' }));
|
|
74
|
+
|
|
75
|
+
executed.should.eql(['CancelInvoice']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should abandon the sequence when the answer is no', async () => {
|
|
79
|
+
render(<Application behaviors={[confirmThenExecute]} />);
|
|
80
|
+
|
|
81
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
82
|
+
// Two buttons say 'Cancel' now - the modelled one and the declining choice - so the question has to
|
|
83
|
+
// be answered through the dialog it belongs to.
|
|
84
|
+
await userEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }));
|
|
85
|
+
|
|
86
|
+
executed.should.have.lengthOf(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should take the question down once it is answered', async () => {
|
|
90
|
+
render(<Application behaviors={[confirmThenExecute]} />);
|
|
91
|
+
|
|
92
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
93
|
+
await userEvent.click(screen.getByRole('button', { name: 'OK' }));
|
|
94
|
+
|
|
95
|
+
(screen.queryByRole('alertdialog') === null).should.equal(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should show what the document said after the command ran', async () => {
|
|
99
|
+
render(<Application behaviors={[confirmThenExecute]} />);
|
|
100
|
+
|
|
101
|
+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
102
|
+
await userEvent.click(screen.getByRole('button', { name: 'OK' }));
|
|
103
|
+
|
|
104
|
+
screen.getByText('Invoice cancelled').textContent!.should.equal('Invoice cancelled');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
import { InteractionActionKind, InteractionTriggerKind, Behavior } from '@cratis/scene.model';
|
|
8
|
+
import { InteractionScope, createBrowserDispatcher, useInteractionHosts, useInteractions } from '../interactions';
|
|
9
|
+
|
|
10
|
+
const navigateSomewhere: Behavior = {
|
|
11
|
+
name: 'GoToInvoice',
|
|
12
|
+
bindings: [
|
|
13
|
+
{
|
|
14
|
+
trigger: { kind: InteractionTriggerKind.Click } as never,
|
|
15
|
+
actions: [{ kind: InteractionActionKind.Navigate, screen: 'InvoiceDetails' } as never],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function Button() {
|
|
21
|
+
const handlers = useInteractions('element', [navigateSomewhere]);
|
|
22
|
+
return <button type="button" {...handlers}>Open</button>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A host that can do nothing at all - a preview surface, or an application half-way through being wired.
|
|
27
|
+
*/
|
|
28
|
+
function Application() {
|
|
29
|
+
const { host, surfaces } = useInteractionHosts();
|
|
30
|
+
const dispatcher = useMemo(() => createBrowserDispatcher(host), [host]);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<InteractionScope dispatcher={dispatcher} attachments={[]}>
|
|
34
|
+
<Button />
|
|
35
|
+
{surfaces}
|
|
36
|
+
</InteractionScope>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('when the host cannot do what was asked', () => {
|
|
41
|
+
it('should say so on the screen rather than do nothing', async () => {
|
|
42
|
+
render(<Application />);
|
|
43
|
+
|
|
44
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
|
|
45
|
+
|
|
46
|
+
screen.getByText('navigate to InvoiceDetails').textContent!.should.equal('navigate to InvoiceDetails');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// A trigger that repeats would otherwise grow the report until it covered the application it is
|
|
50
|
+
// describing, which turns a useful warning into something to be closed without reading.
|
|
51
|
+
it('should report the same action once however often it is asked for', async () => {
|
|
52
|
+
render(<Application />);
|
|
53
|
+
|
|
54
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
|
|
55
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
|
|
56
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open' }));
|
|
57
|
+
|
|
58
|
+
screen.getAllByText('navigate to InvoiceDetails').should.have.lengthOf(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should say nothing when nothing has failed', () => {
|
|
62
|
+
render(<Application />);
|
|
63
|
+
|
|
64
|
+
(screen.queryByText(/cannot do what the document asked/) === null).should.equal(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { ActionDispatcher, CommandOutcome, InteractionContext } from '@cratis/scene.engine';
|
|
7
|
+
import { Behavior, InteractionActionKind, InteractionTriggerKind, NotificationLevel } from '@cratis/scene.model';
|
|
8
|
+
import { InteractionScope, useInteractions } from '../interactions';
|
|
9
|
+
|
|
10
|
+
const calls: string[] = [];
|
|
11
|
+
|
|
12
|
+
const dispatcher = {
|
|
13
|
+
async executeCommand(command: string, args: Record<string, unknown>): Promise<CommandOutcome> {
|
|
14
|
+
calls.push(`executeCommand:${command}:${JSON.stringify(args)}`);
|
|
15
|
+
return { isSuccess: true };
|
|
16
|
+
},
|
|
17
|
+
navigate: (screenName: string) => { calls.push(`navigate:${screenName}`); },
|
|
18
|
+
navigateBack: () => { calls.push('navigateBack'); },
|
|
19
|
+
openDialog: async () => { calls.push('openDialog'); return { isConfirmed: true }; },
|
|
20
|
+
closeDialog: () => { calls.push('closeDialog'); },
|
|
21
|
+
refreshQuery: async (query: string) => { calls.push(`refreshQuery:${query}`); },
|
|
22
|
+
setState: (target: string) => { calls.push(`setState:${target}`); },
|
|
23
|
+
notify: (level: NotificationLevel, message: string) => { calls.push(`notify:${level}:${message}`); },
|
|
24
|
+
confirm: async () => { calls.push('confirm'); return true; },
|
|
25
|
+
raise: async (trigger: string) => { calls.push(`raise:${trigger}`); },
|
|
26
|
+
} satisfies ActionDispatcher;
|
|
27
|
+
|
|
28
|
+
const context: InteractionContext = { resolve: binding => `<${binding.path}>` };
|
|
29
|
+
|
|
30
|
+
const cancelOnClick: Behavior = {
|
|
31
|
+
name: 'CancelInvoice',
|
|
32
|
+
bindings: [
|
|
33
|
+
{
|
|
34
|
+
trigger: { kind: InteractionTriggerKind.Click } as never,
|
|
35
|
+
actions: [
|
|
36
|
+
{
|
|
37
|
+
kind: InteractionActionKind.ExecuteCommand,
|
|
38
|
+
command: 'CancelInvoice',
|
|
39
|
+
arguments: [{ name: 'invoiceId', value: { path: 'item.invoiceId' } }],
|
|
40
|
+
onSuccess: [{ kind: InteractionActionKind.RefreshQuery, query: 'Invoices' }],
|
|
41
|
+
} as never,
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function Button({ behaviors }: { behaviors?: Behavior[] }) {
|
|
48
|
+
const handlers = useInteractions('element', behaviors);
|
|
49
|
+
return <button type="button" {...handlers}>Cancel</button>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderInScope(behaviors?: Behavior[]) {
|
|
53
|
+
return render(
|
|
54
|
+
<InteractionScope dispatcher={dispatcher} context={context} attachments={[]}>
|
|
55
|
+
<Button behaviors={behaviors} />
|
|
56
|
+
</InteractionScope>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('when clicking an element that executes a command', () => {
|
|
61
|
+
beforeEach(() => { calls.length = 0; });
|
|
62
|
+
|
|
63
|
+
// The whole point of everything beneath this: a document said what a click does, and a click does it.
|
|
64
|
+
it('should execute the command the document declared', async () => {
|
|
65
|
+
renderInScope([cancelOnClick]);
|
|
66
|
+
|
|
67
|
+
await userEvent.click(screen.getByRole('button'));
|
|
68
|
+
|
|
69
|
+
calls.should.eql(['executeCommand:CancelInvoice:{"invoiceId":"<item.invoiceId>"}', 'refreshQuery:Invoices']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should not make an element interactive when nothing is attached', async () => {
|
|
73
|
+
renderInScope();
|
|
74
|
+
|
|
75
|
+
await userEvent.click(screen.getByRole('button'));
|
|
76
|
+
calls.should.have.lengthOf(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// A handler attached for a trigger the document never mentioned would swallow the event from whatever
|
|
80
|
+
// encloses the element, which is a bug you find by accident and spend an afternoon on.
|
|
81
|
+
it('should attach no handler for a trigger the document does not use', async () => {
|
|
82
|
+
renderInScope([cancelOnClick]);
|
|
83
|
+
|
|
84
|
+
await userEvent.dblClick(screen.getByRole('button'));
|
|
85
|
+
|
|
86
|
+
// The two clicks a double click is made of still run the click binding - that is what a double click
|
|
87
|
+
// is. What must not happen is a double-click handler existing when no binding asked for one.
|
|
88
|
+
calls.filter(call => call.startsWith('executeCommand')).should.have.lengthOf(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should run a behavior attached further out as well as its own', async () => {
|
|
92
|
+
render(
|
|
93
|
+
<InteractionScope
|
|
94
|
+
dispatcher={dispatcher}
|
|
95
|
+
context={context}
|
|
96
|
+
attachments={[{
|
|
97
|
+
level: 'module',
|
|
98
|
+
behaviors: [{
|
|
99
|
+
name: 'ModuleWide',
|
|
100
|
+
bindings: [{
|
|
101
|
+
trigger: { kind: InteractionTriggerKind.Click } as never,
|
|
102
|
+
actions: [{ kind: InteractionActionKind.Notify, level: NotificationLevel.Info, message: { text: 'from the module' } } as never],
|
|
103
|
+
}],
|
|
104
|
+
}],
|
|
105
|
+
}]}>
|
|
106
|
+
<Button behaviors={[cancelOnClick]} />
|
|
107
|
+
</InteractionScope>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await userEvent.click(screen.getByRole('button'));
|
|
111
|
+
|
|
112
|
+
calls.should.eql([
|
|
113
|
+
'notify:Info:from the module',
|
|
114
|
+
'executeCommand:CancelInvoice:{"invoiceId":"<item.invoiceId>"}',
|
|
115
|
+
'refreshQuery:Invoices',
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should not run a binding whose guard is false', async () => {
|
|
120
|
+
render(
|
|
121
|
+
<InteractionScope
|
|
122
|
+
dispatcher={dispatcher}
|
|
123
|
+
// The guard has to actually resolve to false; a context echoing the path would make every
|
|
124
|
+
// guard a non-empty string, and so always true.
|
|
125
|
+
context={{ resolve: () => false }}
|
|
126
|
+
attachments={[]}>
|
|
127
|
+
<Button behaviors={[{
|
|
128
|
+
name: 'Guarded',
|
|
129
|
+
bindings: [{
|
|
130
|
+
trigger: { kind: InteractionTriggerKind.Click } as never,
|
|
131
|
+
condition: { path: 'item.canCancel' },
|
|
132
|
+
actions: [{ kind: InteractionActionKind.ExecuteCommand, command: 'CancelInvoice' } as never],
|
|
133
|
+
}],
|
|
134
|
+
}]} />
|
|
135
|
+
</InteractionScope>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await userEvent.click(screen.getByRole('button'));
|
|
139
|
+
calls.should.have.lengthOf(0);
|
|
140
|
+
});
|
|
141
|
+
});
|
package/index.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { ConfirmRequest } from './ConfirmRequest';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The properties of {@link ConfirmHost}.
|
|
8
|
+
*/
|
|
9
|
+
export interface ConfirmHostProps {
|
|
10
|
+
/** The question waiting on an answer, or `undefined` when nothing is being asked. */
|
|
11
|
+
readonly request?: ConfirmRequest;
|
|
12
|
+
|
|
13
|
+
/** The label on the agreeing choice. */
|
|
14
|
+
readonly confirmLabel?: string;
|
|
15
|
+
|
|
16
|
+
/** The label on the declining choice. */
|
|
17
|
+
readonly cancelLabel?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Asks what a document's `confirm` asked, in the page rather than in a browser prompt.
|
|
22
|
+
*
|
|
23
|
+
* The browser's own prompt blocks the event loop and cannot be styled or tested, which is why it is the
|
|
24
|
+
* fallback and not this. Declining is the default action, so dismissing the question stops the sequence - a
|
|
25
|
+
* confirm exists to gate what follows it, and a gate that opens when ignored is not one.
|
|
26
|
+
* @param props The {@link ConfirmHostProps}.
|
|
27
|
+
*/
|
|
28
|
+
export function ConfirmHost({ request, confirmLabel = 'OK', cancelLabel = 'Cancel' }: ConfirmHostProps) {
|
|
29
|
+
if (!request) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div data-scene-confirm style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.35)', zIndex: 1100 }}>
|
|
33
|
+
<div role="alertdialog" aria-modal="true" aria-label={request.message} style={{ minWidth: '18rem', maxWidth: '28rem', padding: '1rem', borderRadius: 4, background: '#fff', color: '#1a1a1a' }}>
|
|
34
|
+
<p style={{ margin: '0 0 1rem' }}>{request.message}</p>
|
|
35
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
|
36
|
+
<button type="button" onClick={() => request.answer(false)}>{cancelLabel}</button>
|
|
37
|
+
<button type="button" onClick={() => request.answer(true)}>{confirmLabel}</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A question an interaction is waiting on the answer to.
|
|
6
|
+
*
|
|
7
|
+
* The sequence that asked is suspended until {@link answer} is called, which is the whole point of a confirm:
|
|
8
|
+
* everything after it in the document runs only if the answer was yes.
|
|
9
|
+
*/
|
|
10
|
+
export interface ConfirmRequest {
|
|
11
|
+
/** What the document asked, already resolved against the interaction's context. */
|
|
12
|
+
readonly message: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Answers the question and lets the suspended sequence continue.
|
|
16
|
+
* @param confirmed Whether the user agreed.
|
|
17
|
+
*/
|
|
18
|
+
answer(confirmed: boolean): void;
|
|
19
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { ReactNode, createContext, useContext, useMemo } from 'react';
|
|
5
|
+
import { ActionDispatcher, BehaviorAttachment, InteractionContext, InteractionFinding } from '@cratis/scene.engine';
|
|
6
|
+
import { Behavior } from '@cratis/scene.model';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the elements beneath a scope need in order to run an interaction.
|
|
10
|
+
*/
|
|
11
|
+
export interface InteractionScopeValue {
|
|
12
|
+
/** The seam that performs the effects. */
|
|
13
|
+
dispatcher: ActionDispatcher;
|
|
14
|
+
|
|
15
|
+
/** How bindings and localization resolve. */
|
|
16
|
+
context: InteractionContext;
|
|
17
|
+
|
|
18
|
+
/** The attachments enclosing this point, outermost first. */
|
|
19
|
+
attachments: BehaviorAttachment[];
|
|
20
|
+
|
|
21
|
+
/** Where a finding goes. Absent means nothing is listening, which is itself worth knowing. */
|
|
22
|
+
onFindings?: (findings: InteractionFinding[]) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The context a scope uses when it was given none.
|
|
27
|
+
*
|
|
28
|
+
* A document whose messages are literal text and whose actions take no arguments resolves nothing, and that is
|
|
29
|
+
* an ordinary document rather than a degenerate one. Requiring a context anyway would make every host invent
|
|
30
|
+
* an empty resolver, and an invented one is a place for a mistake to hide.
|
|
31
|
+
*/
|
|
32
|
+
const resolvesNothing: InteractionContext = { resolve: () => undefined };
|
|
33
|
+
|
|
34
|
+
const Scope = createContext<InteractionScopeValue | undefined>(undefined);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The properties of {@link InteractionScope}.
|
|
38
|
+
*
|
|
39
|
+
* The context is the one thing an author may leave out, because a document whose messages are literal text and
|
|
40
|
+
* whose actions take no arguments resolves nothing. Everything beneath a scope still reads a context that is
|
|
41
|
+
* definitely there, so only the author is spared, not the code doing the work.
|
|
42
|
+
*/
|
|
43
|
+
export interface InteractionScopeProps extends Omit<InteractionScopeValue, 'context'> {
|
|
44
|
+
/** How bindings and localization resolve. Omitted means nothing resolves. */
|
|
45
|
+
context?: InteractionContext;
|
|
46
|
+
|
|
47
|
+
/** What the scope encloses. */
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Provides the dispatcher, the resolution context and the enclosing attachments to everything beneath it.
|
|
53
|
+
*
|
|
54
|
+
* Attachments nest: a scope inside another scope appends to it rather than replacing it, so an element's
|
|
55
|
+
* enclosing chain is assembled by the tree rather than reconstructed at each leaf. That is what keeps the
|
|
56
|
+
* additive rule true in a renderer - a module's behavior is still in scope at a button six levels down,
|
|
57
|
+
* without the button knowing the module exists.
|
|
58
|
+
*/
|
|
59
|
+
export function InteractionScope({ children, dispatcher, context = resolvesNothing, attachments, onFindings }: InteractionScopeProps) {
|
|
60
|
+
const enclosing = useContext(Scope);
|
|
61
|
+
|
|
62
|
+
const value = useMemo<InteractionScopeValue>(() => ({
|
|
63
|
+
dispatcher,
|
|
64
|
+
context,
|
|
65
|
+
attachments: [...(enclosing?.attachments ?? []), ...attachments],
|
|
66
|
+
onFindings: onFindings ?? enclosing?.onFindings,
|
|
67
|
+
}), [dispatcher, context, attachments, onFindings, enclosing]);
|
|
68
|
+
|
|
69
|
+
return <Scope.Provider value={value}>{children}</Scope.Provider>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reads the enclosing interaction scope.
|
|
74
|
+
*
|
|
75
|
+
* @returns The scope, or `undefined` when the tree is rendered without one.
|
|
76
|
+
* @remarks
|
|
77
|
+
* Rendering without a scope is legitimate - a preview surface showing what a screen looks like has nothing to
|
|
78
|
+
* dispatch to - so this answers `undefined` rather than throwing. What must not happen is an element silently
|
|
79
|
+
* appearing interactive when nothing can run; {@link useInteractions} is where that is decided.
|
|
80
|
+
*/
|
|
81
|
+
export function useInteractionScope(): InteractionScopeValue | undefined {
|
|
82
|
+
return useContext(Scope);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Builds the attachment list for one element, so a caller does not have to know the shape.
|
|
87
|
+
*
|
|
88
|
+
* @param level What is being attached to, used when reporting.
|
|
89
|
+
* @param behaviors The behaviors attached there.
|
|
90
|
+
* @returns The attachment, or an empty list when there are none.
|
|
91
|
+
*/
|
|
92
|
+
export function attachmentFor(level: string, behaviors?: Behavior[]): BehaviorAttachment[] {
|
|
93
|
+
return behaviors && behaviors.length > 0 ? [{ level, behaviors }] : [];
|
|
94
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { NotificationLevel } from '@cratis/scene.model';
|
|
5
|
+
import { SceneNotification } from './SceneNotification';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The properties of {@link NotificationHost}.
|
|
9
|
+
*/
|
|
10
|
+
export interface NotificationHostProps {
|
|
11
|
+
/** What is currently being said, oldest first. */
|
|
12
|
+
readonly notifications: readonly SceneNotification[];
|
|
13
|
+
|
|
14
|
+
/** Called when one is dismissed. */
|
|
15
|
+
readonly onDismiss: (id: number) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const colours: Record<NotificationLevel, string> = {
|
|
19
|
+
[NotificationLevel.Info]: '#1e6fbf',
|
|
20
|
+
[NotificationLevel.Warning]: '#b8860b',
|
|
21
|
+
[NotificationLevel.Error]: '#c0392b',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shows what a document's `notify` asked to say.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately plain. An application with a design system of its own passes its own `notify` to the dispatcher
|
|
28
|
+
* and never renders this; what this exists for is that the default is *something* rather than a console line
|
|
29
|
+
* nobody is looking at.
|
|
30
|
+
* @param props The {@link NotificationHostProps}.
|
|
31
|
+
*/
|
|
32
|
+
export function NotificationHost({ notifications, onDismiss }: NotificationHostProps) {
|
|
33
|
+
if (notifications.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div role="log" aria-live="polite" data-scene-notifications style={{ position: 'fixed', top: '1rem', right: '1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', zIndex: 1000 }}>
|
|
37
|
+
{notifications.map(notification => (
|
|
38
|
+
<div
|
|
39
|
+
key={notification.id}
|
|
40
|
+
data-scene-notification={notification.level}
|
|
41
|
+
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', maxWidth: '24rem', padding: '0.625rem 0.75rem', borderRadius: 4, border: `1px solid ${colours[notification.level]}`, borderLeftWidth: 4, background: '#fff', color: '#1a1a1a' }}>
|
|
42
|
+
<span style={{ flex: 1 }}>{notification.message}</span>
|
|
43
|
+
<button type="button" aria-label="Dismiss" onClick={() => onDismiss(notification.id)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: '1rem', lineHeight: 1 }}>x</button>
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { NotificationLevel } from '@cratis/scene.model';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Something a document asked to be said, waiting to be seen.
|
|
8
|
+
*/
|
|
9
|
+
export interface SceneNotification {
|
|
10
|
+
/** Identifies the notification for the lifetime of the surface showing it. */
|
|
11
|
+
readonly id: number;
|
|
12
|
+
|
|
13
|
+
/** How the document classified it. */
|
|
14
|
+
readonly level: NotificationLevel;
|
|
15
|
+
|
|
16
|
+
/** What it says, already resolved against the interaction's context. */
|
|
17
|
+
readonly message: string;
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An action a document asked for that this host cannot carry out.
|
|
6
|
+
*
|
|
7
|
+
* Kept as data rather than written straight to the console, so a surface can show it. A modelled button that
|
|
8
|
+
* silently does nothing is the hardest kind of defect to trace back to its cause: nothing failed, so nothing
|
|
9
|
+
* was reported, and the document looks correct because it is.
|
|
10
|
+
*/
|
|
11
|
+
export interface UnresolvedAction {
|
|
12
|
+
/** Identifies the report for the lifetime of the surface showing it. */
|
|
13
|
+
readonly id: number;
|
|
14
|
+
|
|
15
|
+
/** What the document asked for, phrased as the document phrased it. */
|
|
16
|
+
readonly action: string;
|
|
17
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { UnresolvedAction } from './UnresolvedAction';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The properties of {@link UnresolvedActionHost}.
|
|
8
|
+
*/
|
|
9
|
+
export interface UnresolvedActionHostProps {
|
|
10
|
+
/** What has been asked for and could not be done. */
|
|
11
|
+
readonly unresolved: readonly UnresolvedAction[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shows what a document asked for that this host cannot do.
|
|
16
|
+
*
|
|
17
|
+
* Only useful while an application is being built, which is why an application that has wired everything will
|
|
18
|
+
* never see it. It is rendered rather than logged because the person who would fix it is usually looking at
|
|
19
|
+
* the screen and not at the console.
|
|
20
|
+
* @param props The {@link UnresolvedActionHostProps}.
|
|
21
|
+
*/
|
|
22
|
+
export function UnresolvedActionHost({ unresolved }: UnresolvedActionHostProps) {
|
|
23
|
+
if (unresolved.length === 0) return null;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div role="status" data-scene-unresolved style={{ position: 'fixed', bottom: '1rem', left: '1rem', maxWidth: '28rem', padding: '0.625rem 0.75rem', borderRadius: 4, border: '1px solid #c0392b', background: '#fff', color: '#1a1a1a', zIndex: 1000 }}>
|
|
27
|
+
<strong style={{ display: 'block', marginBottom: '0.25rem' }}>This surface cannot do what the document asked</strong>
|
|
28
|
+
<ul style={{ margin: 0, paddingLeft: '1.25rem' }}>
|
|
29
|
+
{unresolved.map(action => <li key={action.id}>{action.action}</li>)}
|
|
30
|
+
</ul>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|