@cratis/scene.components 3.4.0 → 3.6.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.
Files changed (188) hide show
  1. package/README.md +87 -3
  2. package/bindings/ArcBoundComponents.tsx +40 -0
  3. package/bindings/BoundConstructor.ts +5 -5
  4. package/bindings/bindingRegistry.ts +52 -4
  5. package/bindings/for_bindingRegistry/when_registering_query_identities.ts +81 -0
  6. package/bindings/for_requireArcProxy/NativeProxies.ts +28 -0
  7. package/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx +73 -0
  8. package/bindings/for_requireArcProxy/when_previewing_without_arc.tsx +37 -0
  9. package/bindings/for_requireArcProxy/when_validating_a_proxy.ts +44 -0
  10. package/bindings/requireArcProxy.ts +24 -0
  11. package/cratisComponents.ts +4 -1
  12. package/cratisComponentsPackage.ts +3 -1
  13. package/data/SceneDataTable.tsx +1 -1
  14. package/data/SceneObservableDataTable.tsx +1 -1
  15. package/data/SceneSingleResult.tsx +29 -0
  16. package/data/SingleResultRuntime.tsx +103 -0
  17. package/data/SingleResultStatus.tsx +13 -0
  18. package/data/for_SceneDataTable/when_forwarding_query_arguments.tsx +23 -2
  19. package/data/for_SceneSingleResult/when_executing_a_native_query.tsx +290 -0
  20. package/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx +60 -0
  21. package/data/index.ts +1 -0
  22. package/dialogs/SceneCommandDialog.tsx +1 -1
  23. package/dialogs/SceneStepperCommandDialog.tsx +1 -1
  24. package/dist/cjs/bindings/ArcBoundComponents.js +55 -0
  25. package/dist/cjs/bindings/ArcBoundComponents.js.map +1 -0
  26. package/dist/cjs/bindings/bindingRegistry.js +52 -4
  27. package/dist/cjs/bindings/bindingRegistry.js.map +1 -1
  28. package/dist/cjs/bindings/requireArcProxy.js +25 -0
  29. package/dist/cjs/bindings/requireArcProxy.js.map +1 -0
  30. package/dist/cjs/cratisComponents.js +4 -0
  31. package/dist/cjs/cratisComponents.js.map +1 -1
  32. package/dist/cjs/cratisComponentsPackage.js +3 -1
  33. package/dist/cjs/cratisComponentsPackage.js.map +1 -1
  34. package/dist/cjs/data/SceneDataTable.js +1 -1
  35. package/dist/cjs/data/SceneDataTable.js.map +1 -1
  36. package/dist/cjs/data/SceneObservableDataTable.js +1 -1
  37. package/dist/cjs/data/SceneObservableDataTable.js.map +1 -1
  38. package/dist/cjs/data/SceneSingleResult.js +41 -0
  39. package/dist/cjs/data/SceneSingleResult.js.map +1 -0
  40. package/dist/cjs/data/SingleResultRuntime.js +137 -0
  41. package/dist/cjs/data/SingleResultRuntime.js.map +1 -0
  42. package/dist/cjs/data/SingleResultStatus.js +23 -0
  43. package/dist/cjs/data/SingleResultStatus.js.map +1 -0
  44. package/dist/cjs/dialogs/SceneCommandDialog.js +1 -1
  45. package/dist/cjs/dialogs/SceneCommandDialog.js.map +1 -1
  46. package/dist/cjs/dialogs/SceneStepperCommandDialog.js +1 -1
  47. package/dist/cjs/dialogs/SceneStepperCommandDialog.js.map +1 -1
  48. package/dist/cjs/forms/SceneQueryInputForm.js +141 -0
  49. package/dist/cjs/forms/SceneQueryInputForm.js.map +1 -0
  50. package/dist/cjs/forms/fields/SceneRadioButtonField.js +4 -1
  51. package/dist/cjs/forms/fields/SceneRadioButtonField.js.map +1 -1
  52. package/dist/cjs/forms/queryInputs.js +50 -0
  53. package/dist/cjs/forms/queryInputs.js.map +1 -0
  54. package/dist/cjs/index.js +8 -0
  55. package/dist/cjs/index.js.map +1 -1
  56. package/dist/cjs/pages/SceneDataPage.js +1 -1
  57. package/dist/cjs/pages/SceneDataPage.js.map +1 -1
  58. package/dist/esm/bindings/ArcBoundComponents.d.ts +18 -0
  59. package/dist/esm/bindings/ArcBoundComponents.d.ts.map +1 -0
  60. package/dist/esm/bindings/ArcBoundComponents.js +49 -0
  61. package/dist/esm/bindings/ArcBoundComponents.js.map +1 -0
  62. package/dist/esm/bindings/bindingRegistry.d.ts +4 -0
  63. package/dist/esm/bindings/bindingRegistry.d.ts.map +1 -1
  64. package/dist/esm/bindings/bindingRegistry.js +49 -5
  65. package/dist/esm/bindings/bindingRegistry.js.map +1 -1
  66. package/dist/esm/bindings/for_bindingRegistry/when_registering_query_identities.d.ts +2 -0
  67. package/dist/esm/bindings/for_bindingRegistry/when_registering_query_identities.d.ts.map +1 -0
  68. package/dist/esm/bindings/for_bindingRegistry/when_registering_query_identities.js +73 -0
  69. package/dist/esm/bindings/for_bindingRegistry/when_registering_query_identities.js.map +1 -0
  70. package/dist/esm/bindings/for_requireArcProxy/NativeProxies.d.ts +25 -0
  71. package/dist/esm/bindings/for_requireArcProxy/NativeProxies.d.ts.map +1 -0
  72. package/dist/esm/bindings/for_requireArcProxy/NativeProxies.js +23 -0
  73. package/dist/esm/bindings/for_requireArcProxy/NativeProxies.js.map +1 -0
  74. package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.d.ts +2 -0
  75. package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.d.ts.map +1 -0
  76. package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.js +56 -0
  77. package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.js.map +1 -0
  78. package/dist/esm/bindings/for_requireArcProxy/when_previewing_without_arc.d.ts +2 -0
  79. package/dist/esm/bindings/for_requireArcProxy/when_previewing_without_arc.d.ts.map +1 -0
  80. package/dist/esm/bindings/for_requireArcProxy/when_previewing_without_arc.js +32 -0
  81. package/dist/esm/bindings/for_requireArcProxy/when_previewing_without_arc.js.map +1 -0
  82. package/dist/esm/bindings/for_requireArcProxy/when_validating_a_proxy.d.ts +2 -0
  83. package/dist/esm/bindings/for_requireArcProxy/when_validating_a_proxy.d.ts.map +1 -0
  84. package/dist/esm/bindings/for_requireArcProxy/when_validating_a_proxy.js +45 -0
  85. package/dist/esm/bindings/for_requireArcProxy/when_validating_a_proxy.js.map +1 -0
  86. package/dist/esm/bindings/requireArcProxy.d.ts +5 -0
  87. package/dist/esm/bindings/requireArcProxy.d.ts.map +1 -0
  88. package/dist/esm/bindings/requireArcProxy.js +23 -0
  89. package/dist/esm/bindings/requireArcProxy.js.map +1 -0
  90. package/dist/esm/cratisComponents.d.ts.map +1 -1
  91. package/dist/esm/cratisComponents.js +4 -0
  92. package/dist/esm/cratisComponents.js.map +1 -1
  93. package/dist/esm/cratisComponentsPackage.d.ts.map +1 -1
  94. package/dist/esm/cratisComponentsPackage.js +3 -1
  95. package/dist/esm/cratisComponentsPackage.js.map +1 -1
  96. package/dist/esm/data/SceneDataTable.js +1 -1
  97. package/dist/esm/data/SceneDataTable.js.map +1 -1
  98. package/dist/esm/data/SceneObservableDataTable.js +1 -1
  99. package/dist/esm/data/SceneObservableDataTable.js.map +1 -1
  100. package/dist/esm/data/SceneSingleResult.d.ts +3 -0
  101. package/dist/esm/data/SceneSingleResult.d.ts.map +1 -0
  102. package/dist/esm/data/SceneSingleResult.js +39 -0
  103. package/dist/esm/data/SceneSingleResult.js.map +1 -0
  104. package/dist/esm/data/SingleResultRuntime.d.ts +10 -0
  105. package/dist/esm/data/SingleResultRuntime.d.ts.map +1 -0
  106. package/dist/esm/data/SingleResultRuntime.js +133 -0
  107. package/dist/esm/data/SingleResultRuntime.js.map +1 -0
  108. package/dist/esm/data/SingleResultStatus.d.ts +7 -0
  109. package/dist/esm/data/SingleResultStatus.d.ts.map +1 -0
  110. package/dist/esm/data/SingleResultStatus.js +21 -0
  111. package/dist/esm/data/SingleResultStatus.js.map +1 -0
  112. package/dist/esm/data/for_SceneDataTable/when_forwarding_query_arguments.js +21 -4
  113. package/dist/esm/data/for_SceneDataTable/when_forwarding_query_arguments.js.map +1 -1
  114. package/dist/esm/data/for_SceneSingleResult/when_executing_a_native_query.d.ts +2 -0
  115. package/dist/esm/data/for_SceneSingleResult/when_executing_a_native_query.d.ts.map +1 -0
  116. package/dist/esm/data/for_SceneSingleResult/when_executing_a_native_query.js +255 -0
  117. package/dist/esm/data/for_SceneSingleResult/when_executing_a_native_query.js.map +1 -0
  118. package/dist/esm/data/for_SceneSingleResult/when_gating_the_lazy_runtime.d.ts +2 -0
  119. package/dist/esm/data/for_SceneSingleResult/when_gating_the_lazy_runtime.d.ts.map +1 -0
  120. package/dist/esm/data/for_SceneSingleResult/when_gating_the_lazy_runtime.js +54 -0
  121. package/dist/esm/data/for_SceneSingleResult/when_gating_the_lazy_runtime.js.map +1 -0
  122. package/dist/esm/data/index.d.ts +1 -0
  123. package/dist/esm/data/index.d.ts.map +1 -1
  124. package/dist/esm/data/index.js +1 -0
  125. package/dist/esm/data/index.js.map +1 -1
  126. package/dist/esm/dialogs/SceneCommandDialog.js +1 -1
  127. package/dist/esm/dialogs/SceneCommandDialog.js.map +1 -1
  128. package/dist/esm/dialogs/SceneStepperCommandDialog.js +1 -1
  129. package/dist/esm/dialogs/SceneStepperCommandDialog.js.map +1 -1
  130. package/dist/esm/for_cratisComponentsPackage/when_describing_query_input_form.d.ts +2 -0
  131. package/dist/esm/for_cratisComponentsPackage/when_describing_query_input_form.d.ts.map +1 -0
  132. package/dist/esm/for_cratisComponentsPackage/when_describing_query_input_form.js +14 -0
  133. package/dist/esm/for_cratisComponentsPackage/when_describing_query_input_form.js.map +1 -0
  134. package/dist/esm/for_cratisComponentsPackage/when_describing_single_result.d.ts +2 -0
  135. package/dist/esm/for_cratisComponentsPackage/when_describing_single_result.d.ts.map +1 -0
  136. package/dist/esm/for_cratisComponentsPackage/when_describing_single_result.js +16 -0
  137. package/dist/esm/for_cratisComponentsPackage/when_describing_single_result.js.map +1 -0
  138. package/dist/esm/forms/SceneQueryInputForm.d.ts +3 -0
  139. package/dist/esm/forms/SceneQueryInputForm.d.ts.map +1 -0
  140. package/dist/esm/forms/SceneQueryInputForm.js +139 -0
  141. package/dist/esm/forms/SceneQueryInputForm.js.map +1 -0
  142. package/dist/esm/forms/fields/SceneRadioButtonField.d.ts.map +1 -1
  143. package/dist/esm/forms/fields/SceneRadioButtonField.js +4 -1
  144. package/dist/esm/forms/fields/SceneRadioButtonField.js.map +1 -1
  145. package/dist/esm/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.d.ts +2 -0
  146. package/dist/esm/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.d.ts.map +1 -0
  147. package/dist/esm/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.js +38 -0
  148. package/dist/esm/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.js.map +1 -0
  149. package/dist/esm/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.js +4 -0
  150. package/dist/esm/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.js.map +1 -1
  151. package/dist/esm/forms/for_SceneQueryInputForm/ProjectLookup.d.ts +26 -0
  152. package/dist/esm/forms/for_SceneQueryInputForm/ProjectLookup.d.ts.map +1 -0
  153. package/dist/esm/forms/for_SceneQueryInputForm/ProjectLookup.js +44 -0
  154. package/dist/esm/forms/for_SceneQueryInputForm/ProjectLookup.js.map +1 -0
  155. package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.d.ts +2 -0
  156. package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.d.ts.map +1 -0
  157. package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.js +175 -0
  158. package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.js.map +1 -0
  159. package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_native_queries.d.ts +2 -0
  160. package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_native_queries.d.ts.map +1 -0
  161. package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_native_queries.js +413 -0
  162. package/dist/esm/forms/for_SceneQueryInputForm/when_submitting_native_queries.js.map +1 -0
  163. package/dist/esm/forms/index.d.ts +2 -0
  164. package/dist/esm/forms/index.d.ts.map +1 -1
  165. package/dist/esm/forms/index.js +1 -0
  166. package/dist/esm/forms/index.js.map +1 -1
  167. package/dist/esm/forms/queryInputs.d.ts +10 -0
  168. package/dist/esm/forms/queryInputs.d.ts.map +1 -0
  169. package/dist/esm/forms/queryInputs.js +47 -0
  170. package/dist/esm/forms/queryInputs.js.map +1 -0
  171. package/dist/esm/index.js +3 -1
  172. package/dist/esm/index.js.map +1 -1
  173. package/dist/esm/pages/SceneDataPage.js +1 -1
  174. package/dist/esm/pages/SceneDataPage.js.map +1 -1
  175. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  176. package/for_cratisComponentsPackage/when_describing_query_input_form.ts +17 -0
  177. package/for_cratisComponentsPackage/when_describing_single_result.ts +20 -0
  178. package/forms/SceneQueryInputForm.tsx +79 -0
  179. package/forms/fields/SceneRadioButtonField.tsx +4 -1
  180. package/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.tsx +45 -0
  181. package/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts +6 -0
  182. package/forms/for_SceneQueryInputForm/ProjectLookup.ts +54 -0
  183. package/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.tsx +188 -0
  184. package/forms/for_SceneQueryInputForm/when_submitting_native_queries.tsx +423 -0
  185. package/forms/index.ts +2 -0
  186. package/forms/queryInputs.ts +50 -0
  187. package/package.json +23 -7
  188. package/pages/SceneDataPage.tsx +1 -1
@@ -0,0 +1,423 @@
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 { expect, vi } from 'vitest';
6
+ import { ArcContext, type ArcConfiguration } from '@cratis/arc.react';
7
+ import { QueryFor, QueryHttpMethod, QueryValidator } from '@cratis/arc/queries';
8
+ import { ParameterDescriptor } from '@cratis/arc/reflection';
9
+ import { SceneElementView } from '@cratis/scene.react';
10
+ import { clearBindings, registerQuery, registerQueryIdentity, unregisterQueryIdentity } from '../../bindings';
11
+ import { cratisComponents } from '../../cratisComponents';
12
+ import { externalComponent } from '../../given';
13
+
14
+ class ArgumentsValidator extends QueryValidator<{ projectId: string }> {
15
+ static received: object[] = [];
16
+ constructor() {
17
+ super();
18
+ this.ruleFor(args => {
19
+ // ruleFor first invokes the accessor on a property-name discovery proxy, not request data.
20
+ if (Object.hasOwn(args, 'projectId')) ArgumentsValidator.received.push(args);
21
+ return args.projectId;
22
+ }).minLength(2);
23
+ }
24
+ }
25
+
26
+ // Native published Arc perform, required argument checks, validator, routing and deserialization.
27
+ // Only fetch is substituted; neither the renderer nor any runtime/primitive is mocked.
28
+ class ProjectName extends QueryFor<object | undefined, { projectId: string }> {
29
+ readonly route = '/projects/{projectId}/name';
30
+ readonly requiredRequestParameters = ['projectId'];
31
+ readonly parameterDescriptors = [
32
+ new ParameterDescriptor('projectId', String, false),
33
+ new ParameterDescriptor('locale', String, false),
34
+ new ParameterDescriptor('toString', String, false),
35
+ ];
36
+ readonly validation = new ArgumentsValidator();
37
+ defaultValue = undefined;
38
+ constructor() {
39
+ super(Object, false);
40
+ this.setHttpMethod(QueryHttpMethod.Get);
41
+ // Model an unset descriptor-backed instance slot, not Object.prototype.toString:
42
+ // native Arc collects instance values as well as the explicit perform arguments.
43
+ Object.defineProperty(this, 'toString', { value: undefined });
44
+ }
45
+ }
46
+
47
+ class AllProjects extends QueryFor<object[]> {
48
+ readonly route = '/projects';
49
+ readonly requiredRequestParameters = [];
50
+ readonly parameterDescriptors = [];
51
+ defaultValue = [];
52
+ constructor() { super(Object, true); this.setHttpMethod(QueryHttpMethod.Get); }
53
+ }
54
+
55
+ function response(data: unknown, overrides: Record<string, unknown> = {}) {
56
+ return { ok: true, status: 200, json: async () => ({
57
+ data, isSuccess: true, isAuthorized: true, isValid: true, hasExceptions: false,
58
+ validationResults: [], exceptionMessages: [], exceptionStackTrace: '',
59
+ paging: { page: 0, size: 0, totalItems: 0, totalPages: 0 }, ...overrides,
60
+ }) } as Response;
61
+ }
62
+
63
+ function deferred() {
64
+ let resolve!: (response: Response) => void;
65
+ const promise = new Promise<Response>(yes => { resolve = yes; });
66
+ return { promise, resolve };
67
+ }
68
+
69
+ const input = { parameter: 'projectId', type: 'string', label: 'Project identifier' };
70
+ const context: ArcConfiguration = {
71
+ microservice: 'projects', origin: 'https://example.test', apiBasePath: '/backend',
72
+ httpHeadersCallback: () => ({ 'x-host': 'original' }),
73
+ };
74
+
75
+ function view(properties: Record<string, unknown> = {}, arc = context, isEnabled = true) {
76
+ const element = { ...externalComponent('Cratis.Components:queryInputForm', {
77
+ query: 'ProjectName', inputs: [input], resultField: 'name', ...properties,
78
+ }), isEnabled };
79
+ return <ArcContext.Provider value={arc}>
80
+ <SceneElementView element={element} registry={cratisComponents} resolveBinding={() => undefined} />
81
+ </ArcContext.Provider>;
82
+ }
83
+
84
+ function edit(value: string, label = 'Project identifier') {
85
+ fireEvent.change(screen.getByRole('textbox', { name: label }), { target: { value } });
86
+ }
87
+
88
+ async function submit() {
89
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Search' })); });
90
+ }
91
+
92
+ describe('when submitting query inputs through SceneElementView and native Arc', () => {
93
+ const fetch = vi.fn<typeof globalThis.fetch>();
94
+
95
+ beforeEach(() => {
96
+ clearBindings();
97
+ registerQueryIdentity('ProjectName', 'Queries/ProjectName', ProjectName);
98
+ registerQuery('AllProjects', AllProjects);
99
+ ArgumentsValidator.received = [];
100
+ fetch.mockReset();
101
+ fetch.mockResolvedValue(response({ name: 'Project result' }));
102
+ vi.stubGlobal('fetch', fetch);
103
+ });
104
+
105
+ afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); });
106
+
107
+ it('starts empty, never executes while editing, and commits exact named strings in an immutable snapshot', async () => {
108
+ render(view({ inputs: [input, { parameter: 'locale', type: 'string', label: 'Locale', required: false }] }));
109
+ expect(screen.getByRole('textbox', { name: input.label })).toHaveProperty('value', '');
110
+ edit(' exact-ID ');
111
+ edit(' nb & en ', 'Locale');
112
+ expect(fetch).not.toHaveBeenCalled();
113
+ expect(ArgumentsValidator.received).toHaveLength(0);
114
+ await submit();
115
+ expect(await screen.findByText('Project result')).not.toBeNull();
116
+ expect(fetch).toHaveBeenCalledTimes(1);
117
+ const [url, init] = fetch.mock.calls[0];
118
+ expect(String(url)).toBe('https://example.test/backend/projects/%20%20exact-ID%20%20/name?locale=+nb+%26+en+');
119
+ expect(new Headers(init?.headers).get('x-host')).toBe('original');
120
+ expect(new Headers(init?.headers).get('x-cratis-microservice')).toBe('projects');
121
+ const snapshot = ArgumentsValidator.received[0];
122
+ expect(snapshot).toEqual({ projectId: ' exact-ID ', locale: ' nb & en ' });
123
+ expect(Object.isFrozen(snapshot)).toBe(true);
124
+ edit('different');
125
+ expect(screen.queryByText('Project result')).toBeNull();
126
+ expect(snapshot).toEqual({ projectId: ' exact-ID ', locale: ' nb & en ' });
127
+ expect(fetch).toHaveBeenCalledTimes(1);
128
+ });
129
+
130
+ it('uses the native form submit event (also emitted by implicit Enter submission)', async () => {
131
+ render(view());
132
+ edit('entered-id');
133
+ // jsdom does not implement implicit keyboard submission; dispatch its native submit event.
134
+ await act(async () => { fireEvent.submit(screen.getByRole('form', { name: 'Query input' })); });
135
+ expect(await screen.findByText('Project result')).not.toBeNull();
136
+ expect(String(fetch.mock.calls[0][0])).toContain('/entered-id/name');
137
+ });
138
+
139
+ for (const blank of ['', ' ', '\t']) {
140
+ it(`shows required input feedback without performing for ${JSON.stringify(blank)}`, async () => {
141
+ render(view());
142
+ edit(blank);
143
+ await submit();
144
+ expect(screen.getByRole('alert').textContent).toBe('Project identifier is required');
145
+ const control = screen.getByRole('textbox', { name: input.label });
146
+ expect(control.getAttribute('aria-invalid')).toBe('true');
147
+ expect(control.getAttribute('aria-describedby')).toBe(screen.getByRole('alert').id);
148
+ expect(ArgumentsValidator.received).toHaveLength(0);
149
+ expect(fetch).not.toHaveBeenCalled();
150
+ });
151
+ }
152
+
153
+ it('validates an explicitly declared whole-string format without coercion and allows correction', async () => {
154
+ render(view({ inputs: [{ ...input, pattern: '[A-Z]{2}-[0-9]{2}' }] }));
155
+ edit(' xx-12 ');
156
+ await submit();
157
+ expect(screen.getByRole('alert').textContent).toBe('Project identifier has an invalid format');
158
+ expect(fetch).not.toHaveBeenCalled();
159
+ expect(ArgumentsValidator.received).toHaveLength(0);
160
+ expect(screen.getByRole('textbox')).toHaveProperty('value', ' xx-12 ');
161
+ edit('AB-12');
162
+ await submit();
163
+ expect(await screen.findByText('Project result')).not.toBeNull();
164
+ expect(ArgumentsValidator.received[0]).toEqual({ projectId: 'AB-12' });
165
+ });
166
+
167
+ for (const value of ['12\n', '12\r', '12\r\n', '12\u2028', '12\u2029']) {
168
+ it(`rejects a final line terminator in whole-string patterns: ${JSON.stringify(value)}`, async () => {
169
+ // Without the multiline flag JS $ requires the actual end. Native text controls
170
+ // sanitize CR/LF; inject a raw value to exercise validation without that help.
171
+ expect(/^(?:[0-9]+)$/u.test(value)).toBe(false);
172
+ render(view({ inputs: [{ ...input, pattern: '[0-9]+' }] }));
173
+ const control = screen.getByRole('textbox');
174
+ Object.defineProperty(control, 'value', { configurable: true, get: () => value });
175
+ fireEvent.change(control);
176
+ await submit();
177
+ expect(screen.getByRole('alert').textContent).toBe('Project identifier has an invalid format');
178
+ expect(control).toHaveProperty('value', value);
179
+ expect(ArgumentsValidator.received).toHaveLength(0);
180
+ expect(fetch).not.toHaveBeenCalled();
181
+ });
182
+ }
183
+
184
+ it('preserves an explicitly allowed newline through validation and native HTTP', async () => {
185
+ const value = '12\n';
186
+ render(view({ inputs: [{ ...input, pattern: '[0-9]+\\s' }] }));
187
+ const control = screen.getByRole('textbox');
188
+ Object.defineProperty(control, 'value', { configurable: true, get: () => value });
189
+ fireEvent.change(control);
190
+ await submit();
191
+ expect(await screen.findByText('Project result')).not.toBeNull();
192
+ expect(ArgumentsValidator.received[0]).toEqual({ projectId: value });
193
+ expect(String(fetch.mock.calls[0][0])).toContain('/12%0A/name');
194
+ });
195
+
196
+ it('lets native validation reject without HTTP, retains drafts, and allows retry after correction', async () => {
197
+ render(view());
198
+ edit('x');
199
+ await submit();
200
+ expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
201
+ expect(fetch).not.toHaveBeenCalled();
202
+ expect(ArgumentsValidator.received[0]).toEqual({ projectId: 'x' });
203
+ expect(screen.getByRole('textbox')).toHaveProperty('value', 'x');
204
+ edit('xx');
205
+ await submit();
206
+ expect(await screen.findByText('Project result')).not.toBeNull();
207
+ expect(fetch).toHaveBeenCalledTimes(1);
208
+ });
209
+
210
+ it('does not guess parameter names when a declaration mismatches the proxy', async () => {
211
+ render(view({ inputs: [{ ...input, parameter: 'ProjectID' }] }));
212
+ edit('explicit-id');
213
+ await submit();
214
+ expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
215
+ expect(fetch).not.toHaveBeenCalled();
216
+ });
217
+
218
+ for (const data of [null, undefined]) {
219
+ it(`shows not found for a successful ${String(data)} model and permits repeat submit`, async () => {
220
+ fetch.mockResolvedValueOnce(response(data));
221
+ render(view()); edit('missing-id'); await submit();
222
+ expect(await screen.findByText('Not found')).not.toBeNull();
223
+ await submit();
224
+ expect(await screen.findByText('Project result')).not.toBeNull();
225
+ expect(fetch).toHaveBeenCalledTimes(2);
226
+ expect(ArgumentsValidator.received[0]).toEqual(ArgumentsValidator.received[1]);
227
+ expect(ArgumentsValidator.received[0]).not.toBe(ArgumentsValidator.received[1]);
228
+ });
229
+ }
230
+
231
+ it('allows retry of unchanged input after transport failure', async () => {
232
+ fetch.mockRejectedValueOnce(new Error('private network details'));
233
+ render(view()); edit('retry-id'); await submit();
234
+ expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
235
+ expect(document.body.textContent).not.toContain('private network details');
236
+ await submit();
237
+ expect(await screen.findByText('Project result')).not.toBeNull();
238
+ expect(fetch).toHaveBeenCalledTimes(2);
239
+ });
240
+
241
+ for (const failure of [{ isValid: false }, { isAuthorized: false }, { isSuccess: false }, { hasExceptions: true }]) {
242
+ it(`preserves native failure precedence for ${JSON.stringify(failure)}`, async () => {
243
+ fetch.mockResolvedValue(response(null, { ...failure, exceptionMessages: ['secret'] }));
244
+ render(view()); edit('valid-id'); await submit();
245
+ expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
246
+ expect(screen.queryByText('Not found')).toBeNull();
247
+ expect(document.body.textContent).not.toContain('secret');
248
+ });
249
+ }
250
+
251
+ it('aborts on edit and ignores stale response after a newer submission even when HTTP ignores abort', async () => {
252
+ const a = deferred(); const b = deferred();
253
+ fetch.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise);
254
+ render(view()); edit('first'); await submit();
255
+ const signal = fetch.mock.calls[0][1]?.signal;
256
+ edit('second');
257
+ expect(signal?.aborted).toBe(true);
258
+ expect(screen.getByText('Idle')).not.toBeNull();
259
+ expect(fetch).toHaveBeenCalledTimes(1);
260
+ await submit();
261
+ await act(async () => { b.resolve(response({ name: 'Second result' })); });
262
+ await act(async () => { a.resolve(response({ name: 'Obsolete result' })); });
263
+ expect(screen.getByText('Second result')).not.toBeNull();
264
+ expect(screen.queryByText('Obsolete result')).toBeNull();
265
+ });
266
+
267
+ it('never resurrects a result by editing back to the previously submitted value', async () => {
268
+ render(view()); edit('first'); await submit();
269
+ expect(await screen.findByText('Project result')).not.toBeNull();
270
+ edit('second'); edit('first');
271
+ expect(screen.queryByText('Project result')).toBeNull();
272
+ expect(screen.getByText('Idle')).not.toBeNull();
273
+ expect(fetch).toHaveBeenCalledTimes(1);
274
+ });
275
+
276
+ it('ignores a pending result after edit without a second submit', async () => {
277
+ const pending = deferred(); fetch.mockReturnValueOnce(pending.promise);
278
+ render(view()); edit('first'); await submit(); edit('second');
279
+ await act(async () => { pending.resolve(response({ name: 'Obsolete result' })); });
280
+ expect(screen.queryByText('Obsolete result')).toBeNull();
281
+ expect(screen.getByText('Idle')).not.toBeNull();
282
+ });
283
+
284
+ it('aborts on unmount and ignores successful late completion', async () => {
285
+ const pending = deferred(); fetch.mockReturnValueOnce(pending.promise);
286
+ const mounted = render(view()); edit('first'); await submit();
287
+ mounted.unmount();
288
+ expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true);
289
+ await act(async () => { pending.resolve(response({ name: 'Disposed result' })); });
290
+ expect(screen.queryByText('Disposed result')).toBeNull();
291
+ });
292
+
293
+ it('reuses runtime invalidation when Arc context headers change', async () => {
294
+ const pending = deferred();
295
+ const mounted = render(view()); edit('first'); await submit();
296
+ expect(await screen.findByText('Project result')).not.toBeNull();
297
+ fetch.mockReturnValueOnce(pending.promise);
298
+ mounted.rerender(view({}, { ...context, httpHeadersCallback: () => ({ 'x-host': 'new' }) }));
299
+ expect(screen.queryByText('Project result')).toBeNull();
300
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
301
+ expect(new Headers(fetch.mock.calls[1][1]?.headers).get('x-host')).toBe('new');
302
+ await act(async () => { pending.resolve(response({ name: 'New context' })); });
303
+ expect(screen.getByText('New context')).not.toBeNull();
304
+ });
305
+
306
+ for (const query of [undefined, 'Missing', 'projectname']) {
307
+ it(`visibly fails exact missing binding ${String(query)} without choosing AllProjects`, () => {
308
+ render(view({ query }));
309
+ expect(screen.getByText(query ? `Unresolved query binding '${query}' on Cratis.Components:queryInputForm` : 'Missing query binding on Cratis.Components:queryInputForm')).not.toBeNull();
310
+ expect(screen.queryByRole('textbox')).toBeNull();
311
+ expect(fetch).not.toHaveBeenCalled();
312
+ });
313
+ }
314
+
315
+ it('shows ambiguous identity registrations and never chooses a candidate', () => {
316
+ registerQueryIdentity('ProjectName', 'Other/ProjectName', ProjectName);
317
+ render(view());
318
+ expect(screen.getByRole('alert').textContent).toContain("Ambiguous query binding 'ProjectName'");
319
+ expect(screen.queryByRole('textbox')).toBeNull();
320
+ expect(fetch).not.toHaveBeenCalled();
321
+ });
322
+
323
+ it('fails closed and cancels when a mounted binding becomes ambiguous, then starts fresh on resolution', async () => {
324
+ const pending = deferred(); fetch.mockReturnValueOnce(pending.promise);
325
+ render(view()); edit('first'); await submit();
326
+ act(() => { registerQueryIdentity('ProjectName', 'Other/ProjectName', ProjectName); });
327
+ expect(screen.getByRole('alert').textContent).toContain('Ambiguous query binding');
328
+ expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true);
329
+ await act(async () => { pending.resolve(response({ name: 'Obsolete result' })); });
330
+ act(() => { unregisterQueryIdentity('ProjectName', 'Other/ProjectName'); });
331
+ expect(screen.getByRole('textbox')).toHaveProperty('value', '');
332
+ expect(screen.queryByText('Obsolete result')).toBeNull();
333
+ expect(fetch).toHaveBeenCalledTimes(1);
334
+ });
335
+
336
+ it('accepts legacy registration replacement without making it ambiguous', async () => {
337
+ clearBindings();
338
+ registerQuery('ProjectName', AllProjects);
339
+ registerQuery('ProjectName', ProjectName);
340
+ render(view()); edit('legacy-id'); await submit();
341
+ expect(await screen.findByText('Project result')).not.toBeNull();
342
+ expect(fetch).toHaveBeenCalledTimes(1);
343
+ });
344
+
345
+ it('replaces a mounted source identity on hot reload using the runtime cancellation contract', async () => {
346
+ const pending = deferred(); fetch.mockReturnValueOnce(pending.promise);
347
+ render(view()); edit('reload-id'); await submit();
348
+ class Replacement extends ProjectName {}
349
+ await act(async () => { registerQueryIdentity('ProjectName', 'Queries/ProjectName', Replacement); });
350
+ expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true);
351
+ expect(await screen.findByText('Project result')).not.toBeNull();
352
+ await act(async () => { pending.resolve(response({ name: 'Obsolete result' })); });
353
+ expect(screen.queryByText('Obsolete result')).toBeNull();
354
+ expect(fetch).toHaveBeenCalledTimes(2);
355
+ expect(ArgumentsValidator.received[0]).toBe(ArgumentsValidator.received[1]);
356
+ });
357
+
358
+ it('includes optional empty strings verbatim and supports exact own names without prototype lookup', async () => {
359
+ render(view({ inputs: [input, { parameter: 'toString', type: 'string', label: 'Optional input', required: false }] }));
360
+ edit('some-id'); await submit();
361
+ expect(await screen.findByText('Project result')).not.toBeNull();
362
+ expect(ArgumentsValidator.received[0]).toEqual({ projectId: 'some-id', toString: '' });
363
+ expect(new URL(String(fetch.mock.calls[0][0])).searchParams.get('toString')).toBe('');
364
+ });
365
+
366
+ it('still runs native required-argument validation when a declared optional string is empty', async () => {
367
+ render(view({ inputs: [{ ...input, required: false }] }));
368
+ await submit();
369
+ expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
370
+ expect(fetch).not.toHaveBeenCalled();
371
+ });
372
+
373
+ it('allows an unchanged pending submission to be superseded by an explicit retry', async () => {
374
+ const pending = deferred(); fetch.mockReturnValueOnce(pending.promise);
375
+ render(view()); edit('retry-id'); await submit();
376
+ await submit();
377
+ expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true);
378
+ expect(await screen.findByText('Project result')).not.toBeNull();
379
+ await act(async () => { pending.resolve(response({ name: 'Old attempt' })); });
380
+ expect(screen.queryByText('Old attempt')).toBeNull();
381
+ expect(fetch).toHaveBeenCalledTimes(2);
382
+ });
383
+
384
+ it('rejects an enumerable proxy before native perform rather than falling back to a collection', async () => {
385
+ render(view({ query: 'AllProjects' })); edit('not-used'); await submit();
386
+ expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result');
387
+ expect(fetch).not.toHaveBeenCalled();
388
+ });
389
+
390
+ for (const inputs of [undefined, [], {}, [null], [{ ...input, type: 'number' }], [input, input], [{ ...input, parameter: '' }], [{ ...input, label: 7 }], [{ ...input, required: 'true' }], [{ ...input, pattern: '[' }], [{ ...input, pattern: 7 }]]) {
391
+ it(`rejects malformed declarations ${JSON.stringify(inputs)} visibly without native execution`, () => {
392
+ render(view({ inputs }));
393
+ expect(screen.getByRole('alert').textContent).toBe('Invalid query input form configuration');
394
+ expect(screen.queryByRole('textbox')).toBeNull();
395
+ expect(fetch).not.toHaveBeenCalled();
396
+ expect(ArgumentsValidator.received).toHaveLength(0);
397
+ });
398
+ }
399
+
400
+ it('retains drafts across equivalent host rerenders but resets on a new declaration', async () => {
401
+ const mounted = render(view()); edit('draft');
402
+ mounted.rerender(view());
403
+ expect(screen.getByRole('textbox')).toHaveProperty('value', 'draft');
404
+ await submit();
405
+ expect(await screen.findByText('Project result')).not.toBeNull();
406
+ mounted.rerender(view({ inputs: [{ ...input, label: 'New identifier' }] }));
407
+ expect(screen.getByRole('textbox')).toHaveProperty('value', '');
408
+ expect(screen.queryByText('Project result')).toBeNull();
409
+ expect(fetch).toHaveBeenCalledTimes(1);
410
+ });
411
+
412
+ it('cancels and clears the session when the element is disabled', async () => {
413
+ const pending = deferred(); fetch.mockReturnValueOnce(pending.promise);
414
+ const mounted = render(view()); edit('first'); await submit();
415
+ mounted.rerender(view({}, context, false));
416
+ expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true);
417
+ await act(async () => { pending.resolve(response({ name: 'Obsolete result' })); });
418
+ expect(screen.queryByText('Obsolete result')).toBeNull();
419
+ mounted.rerender(view());
420
+ expect(screen.getByRole('textbox')).toHaveProperty('value', '');
421
+ expect(fetch).toHaveBeenCalledTimes(1);
422
+ });
423
+ });
package/forms/index.ts CHANGED
@@ -2,4 +2,6 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  export * from './SceneCommandForm';
5
+ export * from './SceneQueryInputForm';
6
+ export type { QueryInput } from './queryInputs';
5
7
  export * from './fields';
@@ -0,0 +1,50 @@
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 { arrayProperty, booleanProperty, objectProperty, stringProperty } from '../properties';
5
+
6
+ /** Package-local web input declaration, not a Scene.Model form or binding expression. */
7
+ export interface QueryInput {
8
+ /** Exact generated query argument name. */
9
+ parameter: string;
10
+ type: 'string';
11
+ label: string;
12
+ /** Defaults to true. Required strings must contain a non-whitespace character. */
13
+ required?: boolean;
14
+ /** Optional whole-value JavaScript Unicode regular expression (without delimiters). */
15
+ pattern?: string;
16
+ }
17
+
18
+ /** Reject the entire declaration rather than silently dropping malformed inputs. */
19
+ export function queryInputs(properties: Record<string, unknown>): QueryInput[] | undefined {
20
+ const values = arrayProperty(properties, 'inputs');
21
+ if (!values?.length) return undefined;
22
+ const inputs: QueryInput[] = [];
23
+ const parameters = new Set<string>();
24
+ for (const value of values) {
25
+ const input = objectProperty({ value }, 'value');
26
+ if (!input) return undefined;
27
+ const parameter = stringProperty(input, 'parameter');
28
+ const label = stringProperty(input, 'label');
29
+ const required = booleanProperty(input, 'required');
30
+ const pattern = stringProperty(input, 'pattern');
31
+ if (!parameter || !label || input.type !== 'string' || parameters.has(parameter)
32
+ || (input.required !== undefined && required === undefined)
33
+ || (input.pattern !== undefined && pattern === undefined)) return undefined;
34
+ if (pattern !== undefined) {
35
+ try { new RegExp(`^(?:${pattern})$`, 'u'); } catch { return undefined; }
36
+ }
37
+ parameters.add(parameter);
38
+ inputs.push({ parameter, type: 'string', label, required: required ?? true, pattern });
39
+ }
40
+ return inputs;
41
+ }
42
+
43
+ /** Validation never transforms the submitted string; proxy validators remain authoritative. */
44
+ export function queryInputError(input: QueryInput, value: string): string | undefined {
45
+ if (input.required && !/\S/u.test(value)) return `${input.label} is required`;
46
+ if (input.pattern !== undefined && !new RegExp(`^(?:${input.pattern})$`, 'u').test(value)) {
47
+ return `${input.label} has an invalid format`;
48
+ }
49
+ return undefined;
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/scene.components",
3
- "version": "3.4.0",
3
+ "version": "3.6.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,15 +53,17 @@
53
53
  "build-storybook": "storybook build"
54
54
  },
55
55
  "dependencies": {
56
- "@cratis/scene.engine": "3.4.0",
57
- "@cratis/scene.model": "3.4.0",
58
- "@cratis/scene.react": "3.4.0"
56
+ "@cratis/scene.engine": "3.6.0",
57
+ "@cratis/scene.model": "3.6.0",
58
+ "@cratis/scene.react": "3.6.0"
59
59
  },
60
60
  "devDependencies": {
61
+ "@cratis/arc": "22.16.1",
62
+ "@cratis/arc.react": "22.16.1",
61
63
  "@cratis/components": "^4.6.0",
62
- "@cratis/scene.engine": "3.4.0",
63
- "@cratis/scene.model": "3.4.0",
64
- "@cratis/scene.react": "3.4.0",
64
+ "@cratis/scene.engine": "3.6.0",
65
+ "@cratis/scene.model": "3.6.0",
66
+ "@cratis/scene.react": "3.6.0",
65
67
  "@storybook/addon-links": "^10.6.0",
66
68
  "@storybook/react": "^10.6.0",
67
69
  "@storybook/react-vite": "^10.6.0",
@@ -71,8 +73,22 @@
71
73
  "storybook": "^10.6.0"
72
74
  },
73
75
  "peerDependencies": {
76
+ "@cratis/arc": "^22.16.1",
77
+ "@cratis/arc.react": "^22.16.1",
74
78
  "@cratis/components": "^4.1.1",
79
+ "@cratis/fundamentals": "^7.19.2",
75
80
  "react": "^19.0.0",
76
81
  "react-dom": "^19.0.0"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "@cratis/arc": {
85
+ "optional": true
86
+ },
87
+ "@cratis/arc.react": {
88
+ "optional": true
89
+ },
90
+ "@cratis/fundamentals": {
91
+ "optional": true
92
+ }
77
93
  }
78
94
  }
@@ -6,7 +6,7 @@ import { RegisteredComponentProps } from '@cratis/scene.react';
6
6
  import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings';
7
7
  import { stringArrayProperty, stringProperty } from '../properties';
8
8
 
9
- const DataPage = lazy(async () => ({ default: (await import('@cratis/components/DataPage')).DataPage }));
9
+ const DataPage = lazy(async () => ({ default: (await import('../bindings/ArcBoundComponents')).BoundDataPage }));
10
10
 
11
11
  /**
12
12
  * The `Cratis.Components:dataPage` component - `DataPage` from `@cratis/components/DataPage`.