@cratis/scene.components 3.5.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 +56 -5
- package/bindings/bindingRegistry.ts +52 -4
- package/bindings/for_bindingRegistry/when_registering_query_identities.ts +81 -0
- package/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx +4 -2
- package/cratisComponents.ts +2 -0
- package/cratisComponentsPackage.ts +2 -1
- package/data/SingleResultRuntime.tsx +17 -3
- package/data/for_SceneDataTable/when_forwarding_query_arguments.tsx +19 -0
- package/dist/cjs/bindings/bindingRegistry.js +52 -4
- package/dist/cjs/bindings/bindingRegistry.js.map +1 -1
- package/dist/cjs/cratisComponents.js +2 -0
- package/dist/cjs/cratisComponents.js.map +1 -1
- package/dist/cjs/cratisComponentsPackage.js +2 -1
- package/dist/cjs/cratisComponentsPackage.js.map +1 -1
- package/dist/cjs/data/SingleResultRuntime.js +16 -1
- package/dist/cjs/data/SingleResultRuntime.js.map +1 -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/SceneQueryInputForm.js +141 -0
- package/dist/cjs/forms/SceneQueryInputForm.js.map +1 -0
- package/dist/cjs/forms/commandInputs.js +35 -0
- package/dist/cjs/forms/commandInputs.js.map +1 -0
- package/dist/cjs/forms/queryInputs.js +50 -0
- package/dist/cjs/forms/queryInputs.js.map +1 -0
- package/dist/cjs/index.js +6 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/bindings/bindingRegistry.d.ts +4 -0
- package/dist/esm/bindings/bindingRegistry.d.ts.map +1 -1
- package/dist/esm/bindings/bindingRegistry.js +49 -5
- package/dist/esm/bindings/bindingRegistry.js.map +1 -1
- package/dist/esm/bindings/for_bindingRegistry/when_registering_query_identities.d.ts +2 -0
- package/dist/esm/bindings/for_bindingRegistry/when_registering_query_identities.d.ts.map +1 -0
- package/dist/esm/bindings/for_bindingRegistry/when_registering_query_identities.js +73 -0
- package/dist/esm/bindings/for_bindingRegistry/when_registering_query_identities.js.map +1 -0
- package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.js +2 -2
- package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.js.map +1 -1
- package/dist/esm/cratisComponents.d.ts.map +1 -1
- package/dist/esm/cratisComponents.js +2 -0
- package/dist/esm/cratisComponents.js.map +1 -1
- package/dist/esm/cratisComponentsPackage.d.ts.map +1 -1
- package/dist/esm/cratisComponentsPackage.js +2 -1
- package/dist/esm/cratisComponentsPackage.js.map +1 -1
- package/dist/esm/data/SingleResultRuntime.d.ts +2 -1
- package/dist/esm/data/SingleResultRuntime.d.ts.map +1 -1
- package/dist/esm/data/SingleResultRuntime.js +16 -1
- package/dist/esm/data/SingleResultRuntime.js.map +1 -1
- package/dist/esm/data/for_SceneDataTable/when_forwarding_query_arguments.js +17 -0
- package/dist/esm/data/for_SceneDataTable/when_forwarding_query_arguments.js.map +1 -1
- 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/for_cratisComponentsPackage/when_describing_query_input_form.d.ts +2 -0
- package/dist/esm/for_cratisComponentsPackage/when_describing_query_input_form.d.ts.map +1 -0
- package/dist/esm/for_cratisComponentsPackage/when_describing_query_input_form.js +14 -0
- package/dist/esm/for_cratisComponentsPackage/when_describing_query_input_form.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/SceneQueryInputForm.d.ts +3 -0
- package/dist/esm/forms/SceneQueryInputForm.d.ts.map +1 -0
- package/dist/esm/forms/SceneQueryInputForm.js +139 -0
- package/dist/esm/forms/SceneQueryInputForm.js.map +1 -0
- 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/forms/for_SceneQueryInputForm/ProjectLookup.d.ts +26 -0
- package/dist/esm/forms/for_SceneQueryInputForm/ProjectLookup.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneQueryInputForm/ProjectLookup.js +44 -0
- package/dist/esm/forms/for_SceneQueryInputForm/ProjectLookup.js.map +1 -0
- package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.d.ts +2 -0
- package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.js +175 -0
- package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.js.map +1 -0
- package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_native_queries.d.ts +2 -0
- package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_native_queries.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_native_queries.js +413 -0
- package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_native_queries.js.map +1 -0
- package/dist/esm/forms/index.d.ts +2 -0
- package/dist/esm/forms/index.d.ts.map +1 -1
- package/dist/esm/forms/index.js +1 -0
- package/dist/esm/forms/index.js.map +1 -1
- package/dist/esm/forms/queryInputs.d.ts +10 -0
- package/dist/esm/forms/queryInputs.d.ts.map +1 -0
- package/dist/esm/forms/queryInputs.js +47 -0
- package/dist/esm/forms/queryInputs.js.map +1 -0
- package/dist/esm/index.js +2 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/for_cratisComponentsPackage/when_declaring_command_form_peers.ts +26 -0
- package/for_cratisComponentsPackage/when_describing_query_input_form.ts +17 -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/SceneQueryInputForm.tsx +79 -0
- 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/forms/for_SceneQueryInputForm/ProjectLookup.ts +54 -0
- package/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.tsx +188 -0
- package/forms/for_SceneQueryInputForm/when_submitting_native_queries.tsx +423 -0
- package/forms/index.ts +2 -0
- package/forms/queryInputs.ts +50 -0
- package/package.json +17 -13
|
@@ -0,0 +1,56 @@
|
|
|
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 { CommandForm } from '@cratis/arc.react/commands';
|
|
7
|
+
import { InputTextField } from '@cratis/components/CommandForm';
|
|
8
|
+
import { CommandFormSubmit } from '../CommandFormSubmit';
|
|
9
|
+
import { RecordNote, RestrictedNote, successfulResponse } from './given/RecordNote';
|
|
10
|
+
|
|
11
|
+
// Exercise the private child with REAL Arc context. This does not claim AutoCommandForm footer support.
|
|
12
|
+
describe('when rendering the submit child inside native CommandForm', () => {
|
|
13
|
+
const fetch = vi.fn<typeof globalThis.fetch>();
|
|
14
|
+
beforeEach(() => { fetch.mockReset(); fetch.mockResolvedValue(successfulResponse()); vi.stubGlobal('fetch', fetch); });
|
|
15
|
+
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
|
16
|
+
|
|
17
|
+
it('should let native submit expose invalid untouched fields without HTTP', async () => {
|
|
18
|
+
await act(async () => { render(<CommandForm command={RecordNote}>
|
|
19
|
+
<InputTextField value={(command: RecordNote) => command.subject} title='Subject' />
|
|
20
|
+
<CommandFormSubmit label='Record note' />
|
|
21
|
+
</CommandForm>); });
|
|
22
|
+
const button = screen.getByRole<HTMLButtonElement>('button', { name: 'Record note' });
|
|
23
|
+
button.disabled.should.equal(false);
|
|
24
|
+
await act(async () => { fireEvent.click(button); });
|
|
25
|
+
(await screen.findAllByText('Subject is required')).length.should.be.greaterThan(0);
|
|
26
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should follow native busy state through completion without executing directly', async () => {
|
|
30
|
+
let complete!: (response: Response) => void;
|
|
31
|
+
fetch.mockReturnValueOnce(new Promise<Response>(resolve => { complete = resolve; }));
|
|
32
|
+
await act(async () => { render(<CommandForm command={RecordNote} initialValues={{ subject: 'Valid' }}>
|
|
33
|
+
<CommandFormSubmit label='Record note' />
|
|
34
|
+
</CommandForm>); });
|
|
35
|
+
const button = screen.getByRole<HTMLButtonElement>('button', { name: 'Record note' });
|
|
36
|
+
button.disabled.should.equal(false);
|
|
37
|
+
await act(async () => { fireEvent.click(button); });
|
|
38
|
+
await waitFor(() => button.disabled.should.equal(true));
|
|
39
|
+
button.getAttribute('aria-busy')!.should.equal('true');
|
|
40
|
+
fireEvent.click(button);
|
|
41
|
+
fetch.mock.calls.should.have.lengthOf(1);
|
|
42
|
+
await act(async () => { complete(successfulResponse()); });
|
|
43
|
+
await waitFor(() => button.disabled.should.equal(false));
|
|
44
|
+
button.getAttribute('aria-busy')!.should.equal('false');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should respect native role authorization instead of deriving it in Scene', async () => {
|
|
48
|
+
await act(async () => { render(<CommandForm command={RestrictedNote}>
|
|
49
|
+
<CommandFormSubmit label='Record note' />
|
|
50
|
+
</CommandForm>); });
|
|
51
|
+
const button = screen.getByRole<HTMLButtonElement>('button', { name: 'Record note' });
|
|
52
|
+
button.disabled.should.equal(true);
|
|
53
|
+
fireEvent.click(button);
|
|
54
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
// Canonical Projects/Registration/ProjectLookup/ProjectLookup.ts generated consumer shape.
|
|
5
|
+
// Keep Guid parameters, descriptors, model decorators, default value and native methods intact.
|
|
6
|
+
import { QueryFor, QueryResultWithState } from '@cratis/arc/queries';
|
|
7
|
+
import { useQuery, useSuspenseQuery, PerformQuery, SetSorting, QueryWhen } from '@cratis/arc.react/queries';
|
|
8
|
+
import { ParameterDescriptor } from '@cratis/arc/reflection';
|
|
9
|
+
import { Guid, field } from '@cratis/fundamentals';
|
|
10
|
+
|
|
11
|
+
export interface ProjectByIdParameters {
|
|
12
|
+
projectId: Guid;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ProjectById extends QueryFor<ProjectSummary, ProjectByIdParameters> {
|
|
16
|
+
readonly route: string = '/api/projects/registration/project-lookup/project-by-id';
|
|
17
|
+
readonly queryName: string = 'CanonicalProjects.Projects.Registration.ProjectLookup.ProjectSummary.ProjectById';
|
|
18
|
+
readonly treatWarningsAsErrors: boolean = false;
|
|
19
|
+
readonly roles: string[] = [];
|
|
20
|
+
readonly defaultValue: ProjectSummary = {} as ProjectSummary;
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
super(ProjectSummary, false);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get requiredRequestParameters(): string[] {
|
|
27
|
+
return ['projectId'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
readonly parameterDescriptors: ParameterDescriptor[] = [
|
|
31
|
+
new ParameterDescriptor('projectId', Guid, false),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
projectId!: Guid;
|
|
35
|
+
|
|
36
|
+
static use(args?: ProjectByIdParameters): [QueryResultWithState<ProjectSummary>, PerformQuery<ProjectByIdParameters>, SetSorting] {
|
|
37
|
+
return useQuery<ProjectSummary, ProjectById, ProjectByIdParameters>(ProjectById, args);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static useSuspense(args?: ProjectByIdParameters): [QueryResultWithState<ProjectSummary>, PerformQuery<ProjectByIdParameters>, SetSorting] {
|
|
41
|
+
return useSuspenseQuery<ProjectSummary, ProjectById, ProjectByIdParameters>(ProjectById, args);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static when(condition: boolean): QueryWhen<ProjectById, ProjectSummary, ProjectByIdParameters> {
|
|
45
|
+
return new QueryWhen<ProjectById, ProjectSummary, ProjectByIdParameters>(ProjectById, condition);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ProjectSummary {
|
|
50
|
+
@field(String)
|
|
51
|
+
name!: string;
|
|
52
|
+
@field(Guid)
|
|
53
|
+
projectId!: Guid;
|
|
54
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
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 } from '@testing-library/react';
|
|
5
|
+
import { expect, vi } from 'vitest';
|
|
6
|
+
import { ArcContext } from '@cratis/arc.react';
|
|
7
|
+
import { QueryFor } from '@cratis/arc/queries';
|
|
8
|
+
import { ParameterDescriptor } from '@cratis/arc/reflection';
|
|
9
|
+
import { Guid } from '@cratis/fundamentals';
|
|
10
|
+
import { SceneElementView } from '@cratis/scene.react';
|
|
11
|
+
import { clearBindings, registerQuery, registerQueryIdentity, unregisterQueryIdentity } from '../../bindings';
|
|
12
|
+
import { cratisComponents } from '../../cratisComponents';
|
|
13
|
+
import { externalComponent } from '../../given';
|
|
14
|
+
import { ProjectById, ProjectByIdParameters } from './ProjectLookup';
|
|
15
|
+
|
|
16
|
+
const projectId = 'AB123456-7890-4ABC-8DEF-1234567890AB';
|
|
17
|
+
const input = { parameter: 'projectId', type: 'string', label: 'Project identifier' };
|
|
18
|
+
const identity = 'Projects/Registration/ProjectLookup/ProjectLookup.ts:ProjectById';
|
|
19
|
+
const context = { microservice: 'projects', origin: 'https://example.test', apiBasePath: '' };
|
|
20
|
+
|
|
21
|
+
function response(data: unknown) {
|
|
22
|
+
return { ok: true, status: 200, json: async () => ({
|
|
23
|
+
data, isSuccess: true, isAuthorized: true, isValid: true, hasExceptions: false,
|
|
24
|
+
validationResults: [], exceptionMessages: [], exceptionStackTrace: '',
|
|
25
|
+
paging: { page: 0, size: 0, totalItems: 0, totalPages: 0 },
|
|
26
|
+
}) } as Response;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function view(properties: Record<string, unknown> = {}, component = 'queryInputForm') {
|
|
30
|
+
return <ArcContext.Provider value={context}>
|
|
31
|
+
<SceneElementView element={externalComponent(`Cratis.Components:${component}`, {
|
|
32
|
+
query: 'ProjectById', inputs: [input], resultField: 'name', ...properties,
|
|
33
|
+
})} registry={cratisComponents} resolveBinding={() => undefined} />
|
|
34
|
+
</ArcContext.Provider>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function submit(value = projectId) {
|
|
38
|
+
fireEvent.change(screen.getByRole('textbox', { name: input.label }), { target: { value } });
|
|
39
|
+
await act(async () => { fireEvent.submit(screen.getByRole('form')); });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('when submitting the canonical generated Guid proxy shape through SceneElementView', () => {
|
|
43
|
+
const fetch = vi.fn<typeof globalThis.fetch>();
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
clearBindings();
|
|
47
|
+
registerQueryIdentity('ProjectById', identity, ProjectById);
|
|
48
|
+
fetch.mockReset();
|
|
49
|
+
fetch.mockResolvedValue(response({ name: 'Canonical project', projectId }));
|
|
50
|
+
vi.stubGlobal('fetch', fetch);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); });
|
|
54
|
+
|
|
55
|
+
it('keeps the generated Guid contract and inherited native perform, sending exact entered text', async () => {
|
|
56
|
+
const proxy = new ProjectById();
|
|
57
|
+
expect(proxy.perform).toBe(QueryFor.prototype.perform);
|
|
58
|
+
expect(proxy.parameterDescriptors).toEqual([new ParameterDescriptor('projectId', Guid, false)]);
|
|
59
|
+
expect(proxy.requiredRequestParameters).toEqual(['projectId']);
|
|
60
|
+
expect(proxy.defaultValue).toEqual({});
|
|
61
|
+
expect(proxy.queryName).toBe('CanonicalProjects.Projects.Registration.ProjectLookup.ProjectSummary.ProjectById');
|
|
62
|
+
render(view());
|
|
63
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
64
|
+
await submit();
|
|
65
|
+
expect(await screen.findByText('Canonical project')).not.toBeNull();
|
|
66
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
67
|
+
const [url, init] = fetch.mock.calls[0];
|
|
68
|
+
expect(String(url)).toBe(`https://example.test/api/projects/registration/project-lookup/project-by-id?projectId=${projectId}`);
|
|
69
|
+
expect(init?.method).toBe('GET');
|
|
70
|
+
expect(new URL(String(url)).searchParams.get('projectId')).toBe(projectId);
|
|
71
|
+
expect(screen.getByRole('textbox')).toHaveProperty('value', projectId);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const data of [null, undefined]) {
|
|
75
|
+
it(`preserves native absence (${String(data)}) despite the generated empty-object default`, async () => {
|
|
76
|
+
fetch.mockResolvedValue(response(data));
|
|
77
|
+
render(view()); await submit();
|
|
78
|
+
expect(await screen.findByText('Not found')).not.toBeNull();
|
|
79
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('does not claim Guid descriptors enforce format: native perform sends an unformatted string unchanged', async () => {
|
|
84
|
+
render(view()); await submit(' not-a-guid ');
|
|
85
|
+
expect(await screen.findByText('Canonical project')).not.toBeNull();
|
|
86
|
+
expect(new URL(String(fetch.mock.calls[0][0])).searchParams.get('projectId')).toBe(' not-a-guid ');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('lets an explicitly supplied pattern reject invalid Guid text before HTTP and permit correction', async () => {
|
|
90
|
+
render(view({ inputs: [{ ...input, pattern: '[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}' }] }));
|
|
91
|
+
await submit('not-a-guid');
|
|
92
|
+
expect(screen.getByRole('alert').textContent).toBe('Project identifier has an invalid format');
|
|
93
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
94
|
+
expect(screen.getByRole('textbox')).toHaveProperty('value', 'not-a-guid');
|
|
95
|
+
await submit();
|
|
96
|
+
expect(await screen.findByText('Canonical project')).not.toBeNull();
|
|
97
|
+
expect(new URL(String(fetch.mock.calls[0][0])).searchParams.get('projectId')).toBe(projectId);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('retains native required-argument validation when the form permits an empty string', async () => {
|
|
101
|
+
render(view({ inputs: [{ ...input, required: false }] })); await submit('');
|
|
102
|
+
expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
|
|
103
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
for (const descriptors of [
|
|
107
|
+
[new ParameterDescriptor('projectId', Number, false)],
|
|
108
|
+
[new ParameterDescriptor('projectId', Boolean, false)],
|
|
109
|
+
[new ParameterDescriptor('projectId', Object, false)],
|
|
110
|
+
[new ParameterDescriptor('projectId', Guid, true)],
|
|
111
|
+
[new ParameterDescriptor('projectId', String, true)],
|
|
112
|
+
[new ParameterDescriptor('ProjectID', Guid, false)],
|
|
113
|
+
[],
|
|
114
|
+
[new ParameterDescriptor('projectId', Guid, false), new ParameterDescriptor('projectId', String, false)],
|
|
115
|
+
]) {
|
|
116
|
+
it(`rejects unsupported descriptor mapping before HTTP: ${descriptors.map(d => `${d.name}:${d.type.name}:${d.isEnumerable}`).join(',')}`, async () => {
|
|
117
|
+
class Incompatible extends ProjectById { readonly parameterDescriptors = descriptors; }
|
|
118
|
+
registerQueryIdentity('ProjectById', identity, Incompatible);
|
|
119
|
+
render(view()); await submit();
|
|
120
|
+
expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
|
|
121
|
+
expect(screen.getByRole('textbox')).toHaveProperty('value', projectId);
|
|
122
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('rejects an unknown extra parameter even when every required native argument is present', async () => {
|
|
127
|
+
render(view({ inputs: [input, { parameter: 'unknown', type: 'string', label: 'Unknown', required: false }] }));
|
|
128
|
+
await submit();
|
|
129
|
+
expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
|
|
130
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('demonstrates why native descriptor mapping alone cannot protect the form', async () => {
|
|
134
|
+
class Numeric extends ProjectById {
|
|
135
|
+
readonly parameterDescriptors = [new ParameterDescriptor('projectId', Number, false)];
|
|
136
|
+
}
|
|
137
|
+
const proxy = new Numeric();
|
|
138
|
+
proxy.setOrigin(context.origin);
|
|
139
|
+
// Deliberate runtime mismatch, NOT a change to the emitted consumer's parameter interface.
|
|
140
|
+
const args = { projectId: 'not-a-number', unknown: 'extra' } as unknown as ProjectByIdParameters;
|
|
141
|
+
const result = await proxy.perform(args);
|
|
142
|
+
expect(result.isSuccess).toBe(true);
|
|
143
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
144
|
+
const url = new URL(String(fetch.mock.calls[0][0]));
|
|
145
|
+
expect(url.searchParams.get('projectId')).toBe('not-a-number');
|
|
146
|
+
expect(url.searchParams.get('unknown')).toBe('extra');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('allows an identity-only registration to serve an existing singleResult with the same semantic name', async () => {
|
|
150
|
+
render(view({ queryArguments: { projectId }, enabled: true }, 'singleResult'));
|
|
151
|
+
expect(await screen.findByText('Canonical project')).not.toBeNull();
|
|
152
|
+
expect(new URL(String(fetch.mock.calls[0][0])).searchParams.get('projectId')).toBe(projectId);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('fails visibly without selecting an ambiguous identity in an existing singleResult', () => {
|
|
156
|
+
registerQueryIdentity('ProjectById', 'Other/ProjectById', ProjectById);
|
|
157
|
+
render(view({ queryArguments: { projectId }, enabled: true }, 'singleResult'));
|
|
158
|
+
expect(screen.getByText("Unresolved query binding 'ProjectById' on Cratis.Components:singleResult")).not.toBeNull();
|
|
159
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('resolves identity replacement and removal on an existing adapter host rerender', async () => {
|
|
163
|
+
const mounted = render(view({ queryArguments: { projectId }, enabled: true }, 'singleResult'));
|
|
164
|
+
expect(await screen.findByText('Canonical project')).not.toBeNull();
|
|
165
|
+
class Replacement extends ProjectById { readonly route = '/replacement'; }
|
|
166
|
+
act(() => { registerQueryIdentity('ProjectById', identity, Replacement); });
|
|
167
|
+
mounted.rerender(view({ queryArguments: { projectId }, enabled: true }, 'singleResult'));
|
|
168
|
+
await screen.findByText('Canonical project');
|
|
169
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
170
|
+
expect(new URL(String(fetch.mock.calls[1][0])).pathname).toBe('/replacement');
|
|
171
|
+
act(() => { unregisterQueryIdentity('ProjectById', identity); });
|
|
172
|
+
mounted.rerender(view({ queryArguments: { projectId }, enabled: true }, 'singleResult'));
|
|
173
|
+
expect(screen.getByText("Unresolved query binding 'ProjectById' on Cratis.Components:singleResult")).not.toBeNull();
|
|
174
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('keeps legacy replacement authoritative for singleResult while queryInputForm rejects mixed candidates', async () => {
|
|
178
|
+
class Legacy extends ProjectById { readonly route = '/legacy'; }
|
|
179
|
+
registerQuery('ProjectById', ProjectById);
|
|
180
|
+
registerQuery('ProjectById', Legacy);
|
|
181
|
+
const mounted = render(view({ queryArguments: { projectId }, enabled: true }, 'singleResult'));
|
|
182
|
+
expect(await screen.findByText('Canonical project')).not.toBeNull();
|
|
183
|
+
expect(new URL(String(fetch.mock.calls[0][0])).pathname).toBe('/legacy');
|
|
184
|
+
mounted.rerender(view());
|
|
185
|
+
expect(screen.getByRole('alert').textContent).toContain("Ambiguous query binding 'ProjectById'");
|
|
186
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
187
|
+
});
|
|
188
|
+
});
|