@cratis/scene.components 3.6.0 → 3.7.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/README.md +17 -1
- package/dist/cjs/forms/CommandFormRuntime.js +81 -0
- package/dist/cjs/forms/CommandFormRuntime.js.map +1 -0
- package/dist/cjs/forms/CommandFormSubmit.js +19 -0
- package/dist/cjs/forms/CommandFormSubmit.js.map +1 -0
- package/dist/cjs/forms/ExplicitCommandField.js +109 -0
- package/dist/cjs/forms/ExplicitCommandField.js.map +1 -0
- package/dist/cjs/forms/SceneCommandForm.js +26 -21
- package/dist/cjs/forms/SceneCommandForm.js.map +1 -1
- package/dist/cjs/forms/commandInputs.js +35 -0
- package/dist/cjs/forms/commandInputs.js.map +1 -0
- package/dist/esm/for_cratisComponentsPackage/when_declaring_command_form_peers.d.ts +2 -0
- package/dist/esm/for_cratisComponentsPackage/when_declaring_command_form_peers.d.ts.map +1 -0
- package/dist/esm/for_cratisComponentsPackage/when_declaring_command_form_peers.js +16 -0
- package/dist/esm/for_cratisComponentsPackage/when_declaring_command_form_peers.js.map +1 -0
- package/dist/esm/forms/CommandFormRuntime.d.ts +11 -0
- package/dist/esm/forms/CommandFormRuntime.d.ts.map +1 -0
- package/dist/esm/forms/CommandFormRuntime.js +77 -0
- package/dist/esm/forms/CommandFormRuntime.js.map +1 -0
- package/dist/esm/forms/CommandFormSubmit.d.ts +6 -0
- package/dist/esm/forms/CommandFormSubmit.d.ts.map +1 -0
- package/dist/esm/forms/CommandFormSubmit.js +17 -0
- package/dist/esm/forms/CommandFormSubmit.js.map +1 -0
- package/dist/esm/forms/ExplicitCommandField.d.ts +5 -0
- package/dist/esm/forms/ExplicitCommandField.d.ts.map +1 -0
- package/dist/esm/forms/ExplicitCommandField.js +107 -0
- package/dist/esm/forms/ExplicitCommandField.js.map +1 -0
- package/dist/esm/forms/SceneCommandForm.d.ts.map +1 -1
- package/dist/esm/forms/SceneCommandForm.js +27 -22
- package/dist/esm/forms/SceneCommandForm.js.map +1 -1
- package/dist/esm/forms/commandInputs.d.ts +7 -0
- package/dist/esm/forms/commandInputs.d.ts.map +1 -0
- package/dist/esm/forms/commandInputs.js +33 -0
- package/dist/esm/forms/commandInputs.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/given/RecordNote.d.ts +22 -0
- package/dist/esm/forms/for_SceneCommandForm/given/RecordNote.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/given/RecordNote.js +30 -0
- package/dist/esm/forms/for_SceneCommandForm/given/RecordNote.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/given/StartProject.d.ts +32 -0
- package/dist/esm/forms/for_SceneCommandForm/given/StartProject.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/given/StartProject.js +32 -0
- package/dist/esm/forms/for_SceneCommandForm/given/StartProject.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.js +66 -0
- package/dist/esm/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.js +26 -0
- package/dist/esm/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.js +44 -0
- package/dist/esm/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.js +49 -0
- package/dist/esm/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_explicit_inputs.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_explicit_inputs.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_explicit_inputs.js +176 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_explicit_inputs.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_the_native_form.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_the_native_form.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_the_native_form.js +110 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_the_native_form.js.map +1 -0
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/for_cratisComponentsPackage/when_declaring_command_form_peers.ts +26 -0
- package/forms/CommandFormRuntime.tsx +71 -0
- package/forms/CommandFormSubmit.tsx +16 -0
- package/forms/ExplicitCommandField.tsx +86 -0
- package/forms/SceneCommandForm.tsx +30 -27
- package/forms/commandInputs.ts +29 -0
- package/forms/for_SceneCommandForm/given/RecordNote.ts +37 -0
- package/forms/for_SceneCommandForm/given/StartProject.ts +41 -0
- package/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.tsx +69 -0
- package/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.tsx +33 -0
- package/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.tsx +50 -0
- package/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.tsx +56 -0
- package/forms/for_SceneCommandForm/when_submitting_explicit_inputs.tsx +189 -0
- package/forms/for_SceneCommandForm/when_submitting_the_native_form.tsx +120 -0
- package/package.json +13 -13
|
@@ -0,0 +1,189 @@
|
|
|
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 { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { vi } from 'vitest';
|
|
6
|
+
import { ArcContext } from '@cratis/arc.react';
|
|
7
|
+
import { SceneElementView } from '@cratis/scene.react';
|
|
8
|
+
import { clearBindings, registerCommand } from '../../bindings';
|
|
9
|
+
import { cratisComponents } from '../../cratisComponents';
|
|
10
|
+
import { externalComponent } from '../../given';
|
|
11
|
+
import { AmbiguousProject, OptionalUnsupported, RequiredUnsupported, RestrictedProject, StartProject } from './given/StartProject';
|
|
12
|
+
import { RecordNote, successfulResponse } from './given/RecordNote';
|
|
13
|
+
|
|
14
|
+
const inputs = [{ property: 'projectId', type: 'guid', label: 'Project ID' }, { property: 'name', type: 'string', label: 'Name' }];
|
|
15
|
+
const guid = 'AABBCCDD-1234-5678-9ABC-001122334455';
|
|
16
|
+
|
|
17
|
+
function view(properties: Record<string, unknown> = {}, explicit = true) {
|
|
18
|
+
return <ArcContext.Provider value={{ origin: 'https://example.test', apiBasePath: '/backend', microservice: 'projects', httpHeadersCallback: () => ({ 'x-host': 'scene' }) }}>
|
|
19
|
+
<SceneElementView element={externalComponent('Cratis.Components:commandForm', { command: 'StartProject', ...(explicit ? { inputs } : {}), submitLabel: 'Create project', ...properties })}
|
|
20
|
+
registry={cratisComponents} resolveBinding={() => undefined} />
|
|
21
|
+
</ArcContext.Provider>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('explicit Scene command form using native Arc proxy and execution', () => {
|
|
25
|
+
const fetch = vi.fn<typeof globalThis.fetch>();
|
|
26
|
+
beforeEach(() => { clearBindings(); registerCommand('StartProject', StartProject); fetch.mockReset(); fetch.mockResolvedValue(successfulResponse()); vi.stubGlobal('fetch', fetch); });
|
|
27
|
+
afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); });
|
|
28
|
+
|
|
29
|
+
it('rejects untouched, blank and malformed GUIDs, and blank name without HTTP; corrected fields submit the typed payload', async () => {
|
|
30
|
+
render(view());
|
|
31
|
+
const button = await screen.findByRole<HTMLButtonElement>('button', { name: 'Create project' });
|
|
32
|
+
const id = screen.getByRole<HTMLInputElement>('textbox', { name: 'Project ID' });
|
|
33
|
+
const name = screen.getByRole<HTMLInputElement>('textbox', { name: 'Name' });
|
|
34
|
+
(button.form === id.closest('form')).should.equal(true);
|
|
35
|
+
await act(async () => { fireEvent.submit(button.form!); });
|
|
36
|
+
(await screen.findAllByText('Project ID is required')).length.should.be.greaterThan(0);
|
|
37
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
38
|
+
fireEvent.change(id, { target: { value: 'not-a-guid' } });
|
|
39
|
+
fireEvent.change(name, { target: { value: '' } });
|
|
40
|
+
await act(async () => { fireEvent.submit(button.form!); });
|
|
41
|
+
(await screen.findAllByText('Project ID must be a valid GUID')).length.should.be.greaterThan(0);
|
|
42
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
43
|
+
fireEvent.change(id, { target: { value: guid } });
|
|
44
|
+
await act(async () => { fireEvent.submit(button.form!); });
|
|
45
|
+
(await screen.findAllByText('Name is required')).length.should.be.greaterThan(0);
|
|
46
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
47
|
+
fireEvent.change(name, { target: { value: ' My Project ' } });
|
|
48
|
+
await act(async () => { fireEvent.click(button); });
|
|
49
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(1));
|
|
50
|
+
const [url, request] = fetch.mock.calls[0];
|
|
51
|
+
String(url).should.equal('https://example.test/backend/synthetic/projects');
|
|
52
|
+
request!.method!.should.equal('POST');
|
|
53
|
+
JSON.parse(String(request!.body)).should.deep.equal({ projectId: guid.toLowerCase(), name: ' My Project ' });
|
|
54
|
+
new Headers(request!.headers).get('x-host')!.should.equal('scene');
|
|
55
|
+
new Headers(request!.headers).get('x-cratis-microservice')!.should.equal('projects');
|
|
56
|
+
fireEvent.change(id, { target: { value: 'broken again' } });
|
|
57
|
+
await act(async () => { fireEvent.submit(button.form!); });
|
|
58
|
+
fetch.mock.calls.should.have.lengthOf(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('keeps a typed Guid valid across a label change and executes exactly once', async () => {
|
|
62
|
+
const result = render(view());
|
|
63
|
+
await screen.findByRole('button', { name: 'Create project' });
|
|
64
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: guid } });
|
|
65
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Renamed label' } });
|
|
66
|
+
result.rerender(view({ inputs: [{ ...inputs[0], label: 'Project identifier' }, inputs[1]] }));
|
|
67
|
+
const id = screen.getByRole('textbox', { name: 'Project identifier' });
|
|
68
|
+
(id as HTMLInputElement).value.should.equal(guid);
|
|
69
|
+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Create project' })); });
|
|
70
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(1));
|
|
71
|
+
JSON.parse(String(fetch.mock.calls[0][1]!.body)).should.deep.equal({ projectId: guid.toLowerCase(), name: 'Renamed label' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps a malformed Guid blocked when its label changes, then submits the corrected typed value', async () => {
|
|
75
|
+
const result = render(view());
|
|
76
|
+
await screen.findByRole('button', { name: 'Create project' });
|
|
77
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'After correction' } });
|
|
78
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: 'not-a-guid' } });
|
|
79
|
+
(await screen.findAllByText('Project ID must be a valid GUID')).length.should.be.greaterThan(0);
|
|
80
|
+
result.rerender(view({ inputs: [{ ...inputs[0], label: 'Project identifier' }, inputs[1]] }));
|
|
81
|
+
const id = screen.getByRole<HTMLInputElement>('textbox', { name: 'Project identifier' });
|
|
82
|
+
id.value.should.equal('not-a-guid');
|
|
83
|
+
(await screen.findAllByText('Project identifier must be a valid GUID')).length.should.be.greaterThan(0);
|
|
84
|
+
(screen.queryByText('Project ID must be a valid GUID') === null).should.equal(true);
|
|
85
|
+
await act(async () => { fireEvent.submit(id.closest('form')!); });
|
|
86
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
87
|
+
fireEvent.change(id, { target: { value: guid } });
|
|
88
|
+
await act(async () => { fireEvent.submit(id.closest('form')!); });
|
|
89
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(1));
|
|
90
|
+
JSON.parse(String(fetch.mock.calls[0][1]!.body)).should.deep.equal({ projectId: guid.toLowerCase(), name: 'After correction' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('remounts the native explicit proxy on a binding switch and same-name hot replacement', async () => {
|
|
94
|
+
class MovedProject extends StartProject { readonly route = '/synthetic/moved-projects'; }
|
|
95
|
+
class HotProject extends StartProject { readonly route = '/synthetic/hot-projects'; }
|
|
96
|
+
registerCommand('MovedProject', MovedProject);
|
|
97
|
+
const result = render(view());
|
|
98
|
+
await screen.findByRole('button', { name: 'Create project' });
|
|
99
|
+
result.rerender(view({ command: 'MovedProject' }));
|
|
100
|
+
await screen.findByRole('textbox', { name: 'Project ID' });
|
|
101
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: guid } });
|
|
102
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Moved' } });
|
|
103
|
+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Create project' })); });
|
|
104
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(1));
|
|
105
|
+
String(fetch.mock.calls[0][0]).should.equal('https://example.test/backend/synthetic/moved-projects');
|
|
106
|
+
registerCommand('MovedProject', RestrictedProject);
|
|
107
|
+
result.rerender(view({ command: 'MovedProject' }));
|
|
108
|
+
const denied = await screen.findByRole<HTMLButtonElement>('button', { name: 'Create project' });
|
|
109
|
+
denied.disabled.should.equal(true);
|
|
110
|
+
fireEvent.click(denied);
|
|
111
|
+
fetch.mock.calls.should.have.lengthOf(1);
|
|
112
|
+
registerCommand('MovedProject', HotProject);
|
|
113
|
+
result.rerender(view({ command: 'MovedProject' }));
|
|
114
|
+
const allowed = await screen.findByRole<HTMLButtonElement>('button', { name: 'Create project' });
|
|
115
|
+
allowed.disabled.should.equal(false);
|
|
116
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: guid } });
|
|
117
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Hot' } });
|
|
118
|
+
await act(async () => { fireEvent.click(allowed); });
|
|
119
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(2));
|
|
120
|
+
String(fetch.mock.calls[1][0]).should.equal('https://example.test/backend/synthetic/hot-projects');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('uses native authorization and executing state', async () => {
|
|
124
|
+
registerCommand('StartProject', RestrictedProject);
|
|
125
|
+
render(view());
|
|
126
|
+
const button = await screen.findByRole<HTMLButtonElement>('button', { name: 'Create project' });
|
|
127
|
+
button.disabled.should.equal(true);
|
|
128
|
+
fireEvent.click(button);
|
|
129
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('reflects native execution state and blocks a second button click while pending', async () => {
|
|
133
|
+
let complete!: (response: Response) => void;
|
|
134
|
+
fetch.mockReturnValueOnce(new Promise<Response>(resolve => { complete = resolve; }));
|
|
135
|
+
render(view());
|
|
136
|
+
const button = await screen.findByRole<HTMLButtonElement>('button', { name: 'Create project' });
|
|
137
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: guid } });
|
|
138
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Pending' } });
|
|
139
|
+
await act(async () => { fireEvent.click(button); });
|
|
140
|
+
await waitFor(() => button.disabled.should.equal(true));
|
|
141
|
+
button.getAttribute('aria-busy')!.should.equal('true');
|
|
142
|
+
fireEvent.click(button);
|
|
143
|
+
fetch.mock.calls.should.have.lengthOf(1);
|
|
144
|
+
await act(async () => { complete(successfulResponse()); });
|
|
145
|
+
await waitFor(() => button.disabled.should.equal(false));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('blocks malformed declarations, unknown names, mismatches, uncovered and ambiguous required descriptors', async () => {
|
|
149
|
+
const invalid = [
|
|
150
|
+
{ inputs: [] }, { inputs: [{ property: 'name', type: 'string', label: 'Name' }, { property: 'name', type: 'string', label: 'Again' }] },
|
|
151
|
+
{ inputs: [{ property: 'PROJECTID', type: 'guid', label: 'ID' }, inputs[1]] },
|
|
152
|
+
{ inputs: [{ ...inputs[0], type: 'string' }, inputs[1]] },
|
|
153
|
+
{ inputs: [inputs[1]] }, { inputs: [{ ...inputs[0], type: 'number' }, inputs[1]] },
|
|
154
|
+
];
|
|
155
|
+
for (const properties of invalid) {
|
|
156
|
+
const result = render(view(properties));
|
|
157
|
+
(await screen.findByRole('alert')).textContent!.length.should.be.greaterThan(0);
|
|
158
|
+
(screen.queryByRole('button', { name: 'Create project' }) === null).should.equal(true);
|
|
159
|
+
result.unmount();
|
|
160
|
+
}
|
|
161
|
+
registerCommand('StartProject', AmbiguousProject);
|
|
162
|
+
render(view());
|
|
163
|
+
(await screen.findByRole('alert')).textContent!.should.contain('Ambiguous');
|
|
164
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('accepts optional unsupported descriptors when unbound but rejects required unsupported descriptors', async () => {
|
|
168
|
+
registerCommand('StartProject', OptionalUnsupported);
|
|
169
|
+
const result = render(view());
|
|
170
|
+
Boolean(await screen.findByRole('button', { name: 'Create project' })).should.equal(true);
|
|
171
|
+
result.unmount();
|
|
172
|
+
registerCommand('StartProject', RequiredUnsupported);
|
|
173
|
+
render(view());
|
|
174
|
+
(await screen.findByRole('alert')).textContent!.should.contain("Required command property 'details'");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('refuses automatic forms with required GUID rather than silently dropping it', async () => {
|
|
178
|
+
render(view({}, false));
|
|
179
|
+
(await screen.findByRole('alert')).textContent!.should.contain("Required command property 'projectId'");
|
|
180
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('does not allow auto exclusions to hide required supported fields', async () => {
|
|
184
|
+
registerCommand('StartProject', RecordNote);
|
|
185
|
+
render(view({ exclude: ['subject'] }, false));
|
|
186
|
+
(await screen.findByRole('alert')).textContent!.should.contain("Required command property 'subject'");
|
|
187
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { vi } from 'vitest';
|
|
6
|
+
import { ArcContext } from '@cratis/arc.react';
|
|
7
|
+
import { SceneElementView } from '@cratis/scene.react';
|
|
8
|
+
import { clearBindings, registerCommand } from '../../bindings';
|
|
9
|
+
import { cratisComponents } from '../../cratisComponents';
|
|
10
|
+
import { externalComponent } from '../../given';
|
|
11
|
+
import { RecordNote, RestrictedNote, successfulResponse } from './given/RecordNote';
|
|
12
|
+
|
|
13
|
+
function view(properties: Record<string, unknown> = {}) {
|
|
14
|
+
return <ArcContext.Provider value={{ origin: 'https://example.test', apiBasePath: '/backend', microservice: 'notes', httpHeadersCallback: () => ({ 'x-host': 'scene' }) }}>
|
|
15
|
+
<SceneElementView element={externalComponent('Cratis.Components:commandForm', {
|
|
16
|
+
command: 'RecordNote', exclude: ['internalValue'], ...properties,
|
|
17
|
+
})} registry={cratisComponents} resolveBinding={() => undefined} />
|
|
18
|
+
</ArcContext.Provider>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// No Components/form/context doubles. Components <=4.12 ignores footer: these tests MUST fail there,
|
|
22
|
+
// not skip or pass by recreating the forthcoming upstream behavior. Only HTTP is substituted.
|
|
23
|
+
describe('when submitting a Scene command form through native AutoCommandForm', () => {
|
|
24
|
+
const fetch = vi.fn<typeof globalThis.fetch>();
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
clearBindings();
|
|
28
|
+
registerCommand('RecordNote', RecordNote);
|
|
29
|
+
fetch.mockReset();
|
|
30
|
+
fetch.mockResolvedValue(successfulResponse());
|
|
31
|
+
vi.stubGlobal('fetch', fetch);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); });
|
|
35
|
+
|
|
36
|
+
it('should submit the actual form with a labeled button and preserve native payload and context', async () => {
|
|
37
|
+
render(view({ submitLabel: 'Record note' }));
|
|
38
|
+
const button = await screen.findByRole<HTMLButtonElement>('button', { name: 'Record note' });
|
|
39
|
+
button.type.should.equal('submit');
|
|
40
|
+
(button.form === screen.getByRole('textbox', { name: 'Subject' }).closest('form')).should.equal(true);
|
|
41
|
+
screen.queryAllByRole('textbox').should.have.lengthOf(1);
|
|
42
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'A synthetic note' } });
|
|
43
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
44
|
+
await act(async () => { fireEvent.click(button); });
|
|
45
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(1));
|
|
46
|
+
const [url, request] = fetch.mock.calls[0];
|
|
47
|
+
String(url).should.equal('https://example.test/backend/synthetic/notes');
|
|
48
|
+
request!.method!.should.equal('POST');
|
|
49
|
+
JSON.parse(String(request!.body)).should.deep.equal({ subject: 'A synthetic note', internalValue: 'proxy default' });
|
|
50
|
+
new Headers(request!.headers).get('x-host')!.should.equal('scene');
|
|
51
|
+
new Headers(request!.headers).get('x-cratis-microservice')!.should.equal('notes');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should allow untouched invalid submit to reveal native feedback and correction to execute', async () => {
|
|
55
|
+
render(view());
|
|
56
|
+
const button = await screen.findByRole<HTMLButtonElement>('button', { name: 'Submit' });
|
|
57
|
+
button.disabled.should.equal(false);
|
|
58
|
+
(screen.queryByText('Subject is required') === null).should.equal(true);
|
|
59
|
+
await act(async () => { fireEvent.click(button); });
|
|
60
|
+
(await screen.findAllByText('Subject is required')).length.should.be.greaterThan(0);
|
|
61
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
62
|
+
button.disabled.should.equal(false);
|
|
63
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Corrected' } });
|
|
64
|
+
await act(async () => { fireEvent.click(button); });
|
|
65
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(1));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should reflect native execution until HTTP settles and prevent a second button click', async () => {
|
|
69
|
+
let complete!: (response: Response) => void;
|
|
70
|
+
fetch.mockReturnValueOnce(new Promise<Response>(resolve => { complete = resolve; }));
|
|
71
|
+
render(view());
|
|
72
|
+
const button = await screen.findByRole<HTMLButtonElement>('button', { name: 'Submit' });
|
|
73
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Pending' } });
|
|
74
|
+
await act(async () => { fireEvent.click(button); });
|
|
75
|
+
await waitFor(() => button.disabled.should.equal(true));
|
|
76
|
+
button.getAttribute('aria-busy')!.should.equal('true');
|
|
77
|
+
fireEvent.click(button);
|
|
78
|
+
fetch.mock.calls.should.have.lengthOf(1);
|
|
79
|
+
await act(async () => { complete(successfulResponse()); });
|
|
80
|
+
await waitFor(() => button.disabled.should.equal(false));
|
|
81
|
+
button.getAttribute('aria-busy')!.should.equal('false');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('remounts the automatic native form on binding switches and same-name hot replacement', async () => {
|
|
85
|
+
class MovedNote extends RecordNote { readonly route = '/synthetic/moved-notes'; }
|
|
86
|
+
class HotNote extends RecordNote { readonly route = '/synthetic/hot-notes'; }
|
|
87
|
+
registerCommand('MovedNote', MovedNote);
|
|
88
|
+
const result = render(view());
|
|
89
|
+
await screen.findByRole('button', { name: 'Submit' });
|
|
90
|
+
result.rerender(view({ command: 'MovedNote' }));
|
|
91
|
+
await screen.findByRole('textbox', { name: 'Subject' });
|
|
92
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Moved' } });
|
|
93
|
+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Submit' })); });
|
|
94
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(1));
|
|
95
|
+
String(fetch.mock.calls[0][0]).should.equal('https://example.test/backend/synthetic/moved-notes');
|
|
96
|
+
registerCommand('MovedNote', RestrictedNote);
|
|
97
|
+
result.rerender(view({ command: 'MovedNote' }));
|
|
98
|
+
const denied = await screen.findByRole<HTMLButtonElement>('button', { name: 'Submit' });
|
|
99
|
+
denied.disabled.should.equal(true);
|
|
100
|
+
fireEvent.click(denied);
|
|
101
|
+
fetch.mock.calls.should.have.lengthOf(1);
|
|
102
|
+
registerCommand('MovedNote', HotNote);
|
|
103
|
+
result.rerender(view({ command: 'MovedNote' }));
|
|
104
|
+
const allowed = await screen.findByRole<HTMLButtonElement>('button', { name: 'Submit' });
|
|
105
|
+
allowed.disabled.should.equal(false);
|
|
106
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Hot' } });
|
|
107
|
+
await act(async () => { fireEvent.click(allowed); });
|
|
108
|
+
await waitFor(() => fetch.mock.calls.should.have.lengthOf(2));
|
|
109
|
+
String(fetch.mock.calls[1][0]).should.equal('https://example.test/backend/synthetic/hot-notes');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should disable the button when native command authorization denies the identity', async () => {
|
|
113
|
+
registerCommand('RecordNote', RestrictedNote);
|
|
114
|
+
render(view());
|
|
115
|
+
const button = await screen.findByRole<HTMLButtonElement>('button', { name: 'Submit' });
|
|
116
|
+
button.disabled.should.equal(true);
|
|
117
|
+
fireEvent.click(button);
|
|
118
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cratis/scene.components",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "The Cratis Components package: exposes @cratis/components' Arc-bound data, form and dialog composites as Scene components. Built on PrimeReact and Tailwind, and declares both as dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cratis",
|
|
@@ -53,17 +53,17 @@
|
|
|
53
53
|
"build-storybook": "storybook build"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@cratis/scene.engine": "3.
|
|
57
|
-
"@cratis/scene.model": "3.
|
|
58
|
-
"@cratis/scene.react": "3.
|
|
56
|
+
"@cratis/scene.engine": "3.7.0",
|
|
57
|
+
"@cratis/scene.model": "3.7.0",
|
|
58
|
+
"@cratis/scene.react": "3.7.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@cratis/arc": "22.
|
|
62
|
-
"@cratis/arc.react": "22.
|
|
63
|
-
"@cratis/components": "
|
|
64
|
-
"@cratis/scene.engine": "3.
|
|
65
|
-
"@cratis/scene.model": "3.
|
|
66
|
-
"@cratis/scene.react": "3.
|
|
61
|
+
"@cratis/arc": "22.19.1",
|
|
62
|
+
"@cratis/arc.react": "22.19.1",
|
|
63
|
+
"@cratis/components": "4.13.0",
|
|
64
|
+
"@cratis/scene.engine": "3.7.0",
|
|
65
|
+
"@cratis/scene.model": "3.7.0",
|
|
66
|
+
"@cratis/scene.react": "3.7.0",
|
|
67
67
|
"@storybook/addon-links": "^10.6.0",
|
|
68
68
|
"@storybook/react": "^10.6.0",
|
|
69
69
|
"@storybook/react-vite": "^10.6.0",
|
|
@@ -73,9 +73,9 @@
|
|
|
73
73
|
"storybook": "^10.6.0"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
|
-
"@cratis/arc": "^22.
|
|
77
|
-
"@cratis/arc.react": "^22.
|
|
78
|
-
"@cratis/components": "^4.
|
|
76
|
+
"@cratis/arc": "^22.19.1",
|
|
77
|
+
"@cratis/arc.react": "^22.19.1",
|
|
78
|
+
"@cratis/components": "^4.13.0",
|
|
79
79
|
"@cratis/fundamentals": "^7.19.2",
|
|
80
80
|
"react": "^19.0.0",
|
|
81
81
|
"react-dom": "^19.0.0"
|