@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.
- package/README.md +87 -3
- package/bindings/ArcBoundComponents.tsx +40 -0
- package/bindings/BoundConstructor.ts +5 -5
- package/bindings/bindingRegistry.ts +52 -4
- package/bindings/for_bindingRegistry/when_registering_query_identities.ts +81 -0
- package/bindings/for_requireArcProxy/NativeProxies.ts +28 -0
- package/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx +73 -0
- package/bindings/for_requireArcProxy/when_previewing_without_arc.tsx +37 -0
- package/bindings/for_requireArcProxy/when_validating_a_proxy.ts +44 -0
- package/bindings/requireArcProxy.ts +24 -0
- package/cratisComponents.ts +4 -1
- package/cratisComponentsPackage.ts +3 -1
- package/data/SceneDataTable.tsx +1 -1
- package/data/SceneObservableDataTable.tsx +1 -1
- package/data/SceneSingleResult.tsx +29 -0
- package/data/SingleResultRuntime.tsx +103 -0
- package/data/SingleResultStatus.tsx +13 -0
- package/data/for_SceneDataTable/when_forwarding_query_arguments.tsx +23 -2
- package/data/for_SceneSingleResult/when_executing_a_native_query.tsx +290 -0
- package/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx +60 -0
- package/data/index.ts +1 -0
- package/dialogs/SceneCommandDialog.tsx +1 -1
- package/dialogs/SceneStepperCommandDialog.tsx +1 -1
- package/dist/cjs/bindings/ArcBoundComponents.js +55 -0
- package/dist/cjs/bindings/ArcBoundComponents.js.map +1 -0
- package/dist/cjs/bindings/bindingRegistry.js +52 -4
- package/dist/cjs/bindings/bindingRegistry.js.map +1 -1
- package/dist/cjs/bindings/requireArcProxy.js +25 -0
- package/dist/cjs/bindings/requireArcProxy.js.map +1 -0
- package/dist/cjs/cratisComponents.js +4 -0
- package/dist/cjs/cratisComponents.js.map +1 -1
- package/dist/cjs/cratisComponentsPackage.js +3 -1
- package/dist/cjs/cratisComponentsPackage.js.map +1 -1
- package/dist/cjs/data/SceneDataTable.js +1 -1
- package/dist/cjs/data/SceneDataTable.js.map +1 -1
- package/dist/cjs/data/SceneObservableDataTable.js +1 -1
- package/dist/cjs/data/SceneObservableDataTable.js.map +1 -1
- package/dist/cjs/data/SceneSingleResult.js +41 -0
- package/dist/cjs/data/SceneSingleResult.js.map +1 -0
- package/dist/cjs/data/SingleResultRuntime.js +137 -0
- package/dist/cjs/data/SingleResultRuntime.js.map +1 -0
- package/dist/cjs/data/SingleResultStatus.js +23 -0
- package/dist/cjs/data/SingleResultStatus.js.map +1 -0
- package/dist/cjs/dialogs/SceneCommandDialog.js +1 -1
- package/dist/cjs/dialogs/SceneCommandDialog.js.map +1 -1
- package/dist/cjs/dialogs/SceneStepperCommandDialog.js +1 -1
- package/dist/cjs/dialogs/SceneStepperCommandDialog.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/fields/SceneRadioButtonField.js +4 -1
- package/dist/cjs/forms/fields/SceneRadioButtonField.js.map +1 -1
- package/dist/cjs/forms/queryInputs.js +50 -0
- package/dist/cjs/forms/queryInputs.js.map +1 -0
- package/dist/cjs/index.js +8 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/pages/SceneDataPage.js +1 -1
- package/dist/cjs/pages/SceneDataPage.js.map +1 -1
- package/dist/esm/bindings/ArcBoundComponents.d.ts +18 -0
- package/dist/esm/bindings/ArcBoundComponents.d.ts.map +1 -0
- package/dist/esm/bindings/ArcBoundComponents.js +49 -0
- package/dist/esm/bindings/ArcBoundComponents.js.map +1 -0
- 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/NativeProxies.d.ts +25 -0
- package/dist/esm/bindings/for_requireArcProxy/NativeProxies.d.ts.map +1 -0
- package/dist/esm/bindings/for_requireArcProxy/NativeProxies.js +23 -0
- package/dist/esm/bindings/for_requireArcProxy/NativeProxies.js.map +1 -0
- package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.d.ts +2 -0
- package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.d.ts.map +1 -0
- package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.js +56 -0
- package/dist/esm/bindings/for_requireArcProxy/when_mounting_bound_adapters.js.map +1 -0
- package/dist/esm/bindings/for_requireArcProxy/when_previewing_without_arc.d.ts +2 -0
- package/dist/esm/bindings/for_requireArcProxy/when_previewing_without_arc.d.ts.map +1 -0
- package/dist/esm/bindings/for_requireArcProxy/when_previewing_without_arc.js +32 -0
- package/dist/esm/bindings/for_requireArcProxy/when_previewing_without_arc.js.map +1 -0
- package/dist/esm/bindings/for_requireArcProxy/when_validating_a_proxy.d.ts +2 -0
- package/dist/esm/bindings/for_requireArcProxy/when_validating_a_proxy.d.ts.map +1 -0
- package/dist/esm/bindings/for_requireArcProxy/when_validating_a_proxy.js +45 -0
- package/dist/esm/bindings/for_requireArcProxy/when_validating_a_proxy.js.map +1 -0
- package/dist/esm/bindings/requireArcProxy.d.ts +5 -0
- package/dist/esm/bindings/requireArcProxy.d.ts.map +1 -0
- package/dist/esm/bindings/requireArcProxy.js +23 -0
- package/dist/esm/bindings/requireArcProxy.js.map +1 -0
- package/dist/esm/cratisComponents.d.ts.map +1 -1
- package/dist/esm/cratisComponents.js +4 -0
- package/dist/esm/cratisComponents.js.map +1 -1
- package/dist/esm/cratisComponentsPackage.d.ts.map +1 -1
- package/dist/esm/cratisComponentsPackage.js +3 -1
- package/dist/esm/cratisComponentsPackage.js.map +1 -1
- package/dist/esm/data/SceneDataTable.js +1 -1
- package/dist/esm/data/SceneDataTable.js.map +1 -1
- package/dist/esm/data/SceneObservableDataTable.js +1 -1
- package/dist/esm/data/SceneObservableDataTable.js.map +1 -1
- package/dist/esm/data/SceneSingleResult.d.ts +3 -0
- package/dist/esm/data/SceneSingleResult.d.ts.map +1 -0
- package/dist/esm/data/SceneSingleResult.js +39 -0
- package/dist/esm/data/SceneSingleResult.js.map +1 -0
- package/dist/esm/data/SingleResultRuntime.d.ts +10 -0
- package/dist/esm/data/SingleResultRuntime.d.ts.map +1 -0
- package/dist/esm/data/SingleResultRuntime.js +133 -0
- package/dist/esm/data/SingleResultRuntime.js.map +1 -0
- package/dist/esm/data/SingleResultStatus.d.ts +7 -0
- package/dist/esm/data/SingleResultStatus.d.ts.map +1 -0
- package/dist/esm/data/SingleResultStatus.js +21 -0
- package/dist/esm/data/SingleResultStatus.js.map +1 -0
- package/dist/esm/data/for_SceneDataTable/when_forwarding_query_arguments.js +21 -4
- package/dist/esm/data/for_SceneDataTable/when_forwarding_query_arguments.js.map +1 -1
- package/dist/esm/data/for_SceneSingleResult/when_executing_a_native_query.d.ts +2 -0
- package/dist/esm/data/for_SceneSingleResult/when_executing_a_native_query.d.ts.map +1 -0
- package/dist/esm/data/for_SceneSingleResult/when_executing_a_native_query.js +255 -0
- package/dist/esm/data/for_SceneSingleResult/when_executing_a_native_query.js.map +1 -0
- package/dist/esm/data/for_SceneSingleResult/when_gating_the_lazy_runtime.d.ts +2 -0
- package/dist/esm/data/for_SceneSingleResult/when_gating_the_lazy_runtime.d.ts.map +1 -0
- package/dist/esm/data/for_SceneSingleResult/when_gating_the_lazy_runtime.js +54 -0
- package/dist/esm/data/for_SceneSingleResult/when_gating_the_lazy_runtime.js.map +1 -0
- package/dist/esm/data/index.d.ts +1 -0
- package/dist/esm/data/index.d.ts.map +1 -1
- package/dist/esm/data/index.js +1 -0
- package/dist/esm/data/index.js.map +1 -1
- package/dist/esm/dialogs/SceneCommandDialog.js +1 -1
- package/dist/esm/dialogs/SceneCommandDialog.js.map +1 -1
- package/dist/esm/dialogs/SceneStepperCommandDialog.js +1 -1
- package/dist/esm/dialogs/SceneStepperCommandDialog.js.map +1 -1
- 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/for_cratisComponentsPackage/when_describing_single_result.d.ts +2 -0
- package/dist/esm/for_cratisComponentsPackage/when_describing_single_result.d.ts.map +1 -0
- package/dist/esm/for_cratisComponentsPackage/when_describing_single_result.js +16 -0
- package/dist/esm/for_cratisComponentsPackage/when_describing_single_result.js.map +1 -0
- 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/fields/SceneRadioButtonField.d.ts.map +1 -1
- package/dist/esm/forms/fields/SceneRadioButtonField.js +4 -1
- package/dist/esm/forms/fields/SceneRadioButtonField.js.map +1 -1
- package/dist/esm/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.d.ts +2 -0
- package/dist/esm/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.d.ts.map +1 -0
- package/dist/esm/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.js +38 -0
- package/dist/esm/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.js.map +1 -0
- package/dist/esm/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.js +4 -0
- package/dist/esm/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.js.map +1 -1
- 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 +3 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/pages/SceneDataPage.js +1 -1
- package/dist/esm/pages/SceneDataPage.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/for_cratisComponentsPackage/when_describing_query_input_form.ts +17 -0
- package/for_cratisComponentsPackage/when_describing_single_result.ts +20 -0
- package/forms/SceneQueryInputForm.tsx +79 -0
- package/forms/fields/SceneRadioButtonField.tsx +4 -1
- package/forms/fields/for_SceneRadioButtonField/when_mapping_the_group_name.tsx +45 -0
- package/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts +6 -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 +23 -7
- package/pages/SceneDataPage.tsx +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { expect } from 'vitest';
|
|
5
|
+
import { SceneQueryInputForm, cratisComponents, cratisComponentsPackageManifest, registerQueryIdentity, resolveExactQuery, type QueryInput } from '../index';
|
|
6
|
+
|
|
7
|
+
describe('when describing the query input form public API', () => {
|
|
8
|
+
it('exports and registers the component advertised by the manifest', () => {
|
|
9
|
+
expect(cratisComponentsPackageManifest.components).toContain('queryInputForm');
|
|
10
|
+
expect(cratisComponents['Cratis.Components:queryInputForm']).toBe(SceneQueryInputForm);
|
|
11
|
+
expect(cratisComponentsPackageManifest.description).toContain('commits explicit string inputs on submit');
|
|
12
|
+
expect(registerQueryIdentity).toBeTypeOf('function');
|
|
13
|
+
expect(resolveExactQuery).toBeTypeOf('function');
|
|
14
|
+
const input: QueryInput = { parameter: 'projectId', type: 'string', label: 'Project identifier' };
|
|
15
|
+
expect(input.type).toBe('string');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
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 { expect } from 'vitest';
|
|
5
|
+
import { componentRegistryKey } from '@cratis/scene.react';
|
|
6
|
+
import { cratisComponents, cratisComponentsPackageName } from '../cratisComponents';
|
|
7
|
+
import { cratisComponentsPackageManifest } from '../cratisComponentsPackage';
|
|
8
|
+
import { SceneSingleResult } from '../data/SceneSingleResult';
|
|
9
|
+
|
|
10
|
+
describe('when describing the single result view', () => {
|
|
11
|
+
it('registers the same component advertised by the manifest', () => {
|
|
12
|
+
expect(cratisComponentsPackageManifest.components).toContain('singleResult');
|
|
13
|
+
expect(cratisComponents[componentRegistryKey(cratisComponentsPackageName, 'singleResult')]).toBe(SceneSingleResult);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('describes an explicitly enabled read-only own-field capability', () => {
|
|
17
|
+
expect(cratisComponentsPackageManifest.description).toContain('read-only scalar own resultField');
|
|
18
|
+
expect(cratisComponentsPackageManifest.description).toContain('enabled: true');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { lazy, useId, useState, useSyncExternalStore } from 'react';
|
|
5
|
+
import { RegisteredComponentProps } from '@cratis/scene.react';
|
|
6
|
+
import { ArcRuntimeBoundary, BindingKind, BoundConstructor, MissingBinding, resolveExactQuery, subscribeQueryBindings } from '../bindings';
|
|
7
|
+
import { stringProperty } from '../properties';
|
|
8
|
+
import { SingleResultStatus } from '../data/SingleResultStatus';
|
|
9
|
+
import { QueryInput, queryInputError, queryInputs } from './queryInputs';
|
|
10
|
+
|
|
11
|
+
const SingleResultRuntime = lazy(() => import('../data/SingleResultRuntime'));
|
|
12
|
+
|
|
13
|
+
/** Opt-in, form-local string drafts around the existing optional-single execution owner. */
|
|
14
|
+
export function SceneQueryInputForm({ element }: RegisteredComponentProps) {
|
|
15
|
+
const name = stringProperty(element.properties, 'query');
|
|
16
|
+
const target = useSyncExternalStore(subscribeQueryBindings, () => name ? resolveExactQuery(name) : undefined);
|
|
17
|
+
if (target === 'ambiguous') return <div role='alert'>Ambiguous query binding '{name}' on {element.componentName}</div>;
|
|
18
|
+
if (!target) return <MissingBinding element={element} kind={BindingKind.Query} name={name} />;
|
|
19
|
+
|
|
20
|
+
const inputs = queryInputs(element.properties);
|
|
21
|
+
const resultField = stringProperty(element.properties, 'resultField');
|
|
22
|
+
if (!inputs || !resultField) return <div role='alert'>Invalid query input form configuration</div>;
|
|
23
|
+
if (!element.isEnabled) return <SingleResultStatus state='idle' />;
|
|
24
|
+
|
|
25
|
+
// A different form declaration starts a new session; ordinary host rerenders retain drafts.
|
|
26
|
+
return <QueryInputForm key={JSON.stringify([name, inputs, resultField])}
|
|
27
|
+
query={target} inputs={inputs} resultField={resultField}
|
|
28
|
+
label={stringProperty(element.properties, 'label') ?? 'Query input'}
|
|
29
|
+
submitLabel={stringProperty(element.properties, 'submitLabel') ?? 'Search'} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
query: BoundConstructor;
|
|
34
|
+
inputs: QueryInput[];
|
|
35
|
+
resultField: string;
|
|
36
|
+
label: string;
|
|
37
|
+
submitLabel: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function QueryInputForm({ query, inputs, resultField, label, submitLabel }: Props) {
|
|
41
|
+
const id = useId();
|
|
42
|
+
const [drafts, setDrafts] = useState<Record<string, string>>(() => Object.fromEntries(inputs.map(input => [input.parameter, ''])));
|
|
43
|
+
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
|
44
|
+
const [committed, setCommitted] = useState<Record<string, string>>();
|
|
45
|
+
|
|
46
|
+
return <form aria-label={label} noValidate onSubmit={event => {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
const validation = Object.fromEntries(inputs.map(input => [input.parameter, queryInputError(input, drafts[input.parameter])]));
|
|
49
|
+
setErrors(validation);
|
|
50
|
+
if (Object.values(validation).some(Boolean)) {
|
|
51
|
+
setCommitted(undefined);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// New identity even for unchanged values: repeat submit is an explicit retry. No trimming,
|
|
55
|
+
// coercion, generated identifier, property-path evaluation or query while typing.
|
|
56
|
+
setCommitted(Object.freeze(Object.fromEntries(inputs.map(input => [input.parameter, drafts[input.parameter]]))));
|
|
57
|
+
}}>
|
|
58
|
+
{inputs.map((input, index) => {
|
|
59
|
+
const controlId = `${id}-${index}`;
|
|
60
|
+
const error = Object.hasOwn(errors, input.parameter) ? errors[input.parameter] : undefined;
|
|
61
|
+
return <div key={input.parameter}>
|
|
62
|
+
<label htmlFor={controlId}>{input.label}</label>
|
|
63
|
+
<input id={controlId} name={input.parameter} type='text' required={input.required}
|
|
64
|
+
value={drafts[input.parameter]} aria-invalid={!!error} aria-describedby={error ? `${controlId}-error` : undefined}
|
|
65
|
+
onChange={event => {
|
|
66
|
+
setDrafts({ ...drafts, [input.parameter]: event.currentTarget.value });
|
|
67
|
+
setErrors({ ...errors, [input.parameter]: undefined });
|
|
68
|
+
// Unmount synchronously on edit: runtime cleanup aborts and masks late replies.
|
|
69
|
+
setCommitted(undefined);
|
|
70
|
+
}} />
|
|
71
|
+
{error && <div id={`${controlId}-error`} role='alert'>{error}</div>}
|
|
72
|
+
</div>;
|
|
73
|
+
})}
|
|
74
|
+
<button type='submit'>{submitLabel}</button>
|
|
75
|
+
{committed ? <ArcRuntimeBoundary>
|
|
76
|
+
<SingleResultRuntime query={query} queryArguments={committed} resultField={resultField} stringInputs />
|
|
77
|
+
</ArcRuntimeBoundary> : <SingleResultStatus state='idle' />}
|
|
78
|
+
</form>;
|
|
79
|
+
}
|
|
@@ -20,13 +20,16 @@ const RadioButtonField = lazy(async () => ({ default: (await import('@cratis/com
|
|
|
20
20
|
* numeric code is common enough that forcing it through as `'1'` would bind the wrong value.
|
|
21
21
|
*/
|
|
22
22
|
export function SceneRadioButtonField({ element }: RegisteredComponentProps) {
|
|
23
|
+
// Use the same reader as CommandFormField's binding gate; that gate owns missing-property feedback.
|
|
24
|
+
const name = stringProperty(element.properties, 'property');
|
|
23
25
|
const buttonValue = stringProperty(element.properties, 'buttonValue') ?? numberProperty(element.properties, 'buttonValue') ?? '';
|
|
24
26
|
|
|
25
27
|
return (
|
|
26
28
|
<CommandFormField element={element}>
|
|
27
|
-
{binding => (
|
|
29
|
+
{binding => name !== undefined && (
|
|
28
30
|
<RadioButtonField
|
|
29
31
|
{...binding}
|
|
32
|
+
name={name}
|
|
30
33
|
buttonValue={buttonValue}
|
|
31
34
|
label={stringProperty(element.properties, 'label')}
|
|
32
35
|
className={stringProperty(element.properties, 'className')}
|
|
@@ -0,0 +1,45 @@
|
|
|
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, render, screen } from '@testing-library/react';
|
|
5
|
+
import { expect, vi } from 'vitest';
|
|
6
|
+
import { externalComponent } from '../../../given';
|
|
7
|
+
|
|
8
|
+
// The public Components radio props require a native group name independently of the display title.
|
|
9
|
+
describe('when mapping the radio group name', () => {
|
|
10
|
+
const received = vi.fn<(props: { name: string; title: string; value: (instance: Record<string, unknown>) => unknown }) => void>();
|
|
11
|
+
let SceneRadioButtonField: typeof import('../SceneRadioButtonField').SceneRadioButtonField;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
received.mockClear();
|
|
16
|
+
vi.doMock('@cratis/components/CommandForm', () => ({
|
|
17
|
+
RadioButtonField: (props: Parameters<typeof received>[0]) => { received(props); return <input type='radio' name={props.name} />; },
|
|
18
|
+
}));
|
|
19
|
+
({ SceneRadioButtonField } = await import('../SceneRadioButtonField'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
cleanup();
|
|
24
|
+
vi.doUnmock('@cratis/components/CommandForm');
|
|
25
|
+
vi.resetModules();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should group options by the explicit property, regardless of titles or labels', async () => {
|
|
29
|
+
await act(async () => {
|
|
30
|
+
render(<>{['First title', 'Second title'].map((title, index) => <SceneRadioButtonField key={title} element={externalComponent('Cratis.Components:radioButtonField', { property: 'choice', title, label: 'Display label', buttonValue: index })} slots={{}} />)}</>);
|
|
31
|
+
});
|
|
32
|
+
expect(screen.getAllByRole('radio').map(radio => radio.getAttribute('name'))).toEqual(['choice', 'choice']);
|
|
33
|
+
expect(received.mock.calls.map(([props]) => props.value({ choice: 2 }))).toEqual([2, 2]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
for (const property of [undefined, 42, null, {}, []]) {
|
|
37
|
+
it(`should visibly reject malformed property ${JSON.stringify(property)} instead of using the title`, async () => {
|
|
38
|
+
await act(async () => {
|
|
39
|
+
render(<SceneRadioButtonField element={externalComponent('Cratis.Components:radioButtonField', { property, title: 'Not a binding' })} slots={{}} />);
|
|
40
|
+
});
|
|
41
|
+
expect(screen.getByText("Missing 'property' on Cratis.Components:radioButtonField")).toBeTruthy();
|
|
42
|
+
expect(received).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
import { externalComponent } from '../../../given';
|
|
5
5
|
import { resolveFieldBinding } from '../resolveFieldBinding';
|
|
6
|
+
import type { FieldBinding } from '../FieldBinding';
|
|
6
7
|
|
|
7
8
|
describe('when resolving a field binding', () => {
|
|
9
|
+
it('should preserve manually constructed field bindings without a native group name', () => {
|
|
10
|
+
const binding: FieldBinding = { value: instance => instance.customerName, title: 'Customer' };
|
|
11
|
+
binding.value({ customerName: 'Acme' })!.should.equal('Acme');
|
|
12
|
+
});
|
|
13
|
+
|
|
8
14
|
describe('and the element names a property with a title and description', () => {
|
|
9
15
|
const binding = resolveFieldBinding(
|
|
10
16
|
externalComponent('Cratis.Components:inputTextField', {
|
|
@@ -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
|
+
});
|