@cratis/scene.components 3.6.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/dist/cjs/forms/CommandFormRuntime.js +81 -0
- package/dist/cjs/forms/CommandFormRuntime.js.map +1 -0
- package/dist/cjs/forms/CommandFormSubmit.js +19 -0
- package/dist/cjs/forms/CommandFormSubmit.js.map +1 -0
- package/dist/cjs/forms/ExplicitCommandField.js +109 -0
- package/dist/cjs/forms/ExplicitCommandField.js.map +1 -0
- package/dist/cjs/forms/SceneCommandForm.js +26 -21
- package/dist/cjs/forms/SceneCommandForm.js.map +1 -1
- package/dist/cjs/forms/commandInputs.js +35 -0
- package/dist/cjs/forms/commandInputs.js.map +1 -0
- package/dist/esm/for_cratisComponentsPackage/when_declaring_command_form_peers.d.ts +2 -0
- package/dist/esm/for_cratisComponentsPackage/when_declaring_command_form_peers.d.ts.map +1 -0
- package/dist/esm/for_cratisComponentsPackage/when_declaring_command_form_peers.js +16 -0
- package/dist/esm/for_cratisComponentsPackage/when_declaring_command_form_peers.js.map +1 -0
- package/dist/esm/forms/CommandFormRuntime.d.ts +11 -0
- package/dist/esm/forms/CommandFormRuntime.d.ts.map +1 -0
- package/dist/esm/forms/CommandFormRuntime.js +77 -0
- package/dist/esm/forms/CommandFormRuntime.js.map +1 -0
- package/dist/esm/forms/CommandFormSubmit.d.ts +6 -0
- package/dist/esm/forms/CommandFormSubmit.d.ts.map +1 -0
- package/dist/esm/forms/CommandFormSubmit.js +17 -0
- package/dist/esm/forms/CommandFormSubmit.js.map +1 -0
- package/dist/esm/forms/ExplicitCommandField.d.ts +5 -0
- package/dist/esm/forms/ExplicitCommandField.d.ts.map +1 -0
- package/dist/esm/forms/ExplicitCommandField.js +107 -0
- package/dist/esm/forms/ExplicitCommandField.js.map +1 -0
- package/dist/esm/forms/SceneCommandForm.d.ts.map +1 -1
- package/dist/esm/forms/SceneCommandForm.js +27 -22
- package/dist/esm/forms/SceneCommandForm.js.map +1 -1
- package/dist/esm/forms/commandInputs.d.ts +7 -0
- package/dist/esm/forms/commandInputs.d.ts.map +1 -0
- package/dist/esm/forms/commandInputs.js +33 -0
- package/dist/esm/forms/commandInputs.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/given/RecordNote.d.ts +22 -0
- package/dist/esm/forms/for_SceneCommandForm/given/RecordNote.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/given/RecordNote.js +30 -0
- package/dist/esm/forms/for_SceneCommandForm/given/RecordNote.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/given/StartProject.d.ts +32 -0
- package/dist/esm/forms/for_SceneCommandForm/given/StartProject.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/given/StartProject.js +32 -0
- package/dist/esm/forms/for_SceneCommandForm/given/StartProject.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.js +66 -0
- package/dist/esm/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.js +26 -0
- package/dist/esm/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.js +44 -0
- package/dist/esm/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.js +49 -0
- package/dist/esm/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_explicit_inputs.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_explicit_inputs.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_explicit_inputs.js +176 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_explicit_inputs.js.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_the_native_form.d.ts +2 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_the_native_form.d.ts.map +1 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_the_native_form.js +110 -0
- package/dist/esm/forms/for_SceneCommandForm/when_submitting_the_native_form.js.map +1 -0
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/for_cratisComponentsPackage/when_declaring_command_form_peers.ts +26 -0
- package/forms/CommandFormRuntime.tsx +71 -0
- package/forms/CommandFormSubmit.tsx +16 -0
- package/forms/ExplicitCommandField.tsx +86 -0
- package/forms/SceneCommandForm.tsx +30 -27
- package/forms/commandInputs.ts +29 -0
- package/forms/for_SceneCommandForm/given/RecordNote.ts +37 -0
- package/forms/for_SceneCommandForm/given/StartProject.ts +41 -0
- package/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.tsx +69 -0
- package/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.tsx +33 -0
- package/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.tsx +50 -0
- package/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.tsx +56 -0
- package/forms/for_SceneCommandForm/when_submitting_explicit_inputs.tsx +189 -0
- package/forms/for_SceneCommandForm/when_submitting_the_native_form.tsx +120 -0
- package/package.json +13 -13
|
@@ -0,0 +1,26 @@
|
|
|
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 { readFileSync } from 'node:fs';
|
|
5
|
+
import { expect } from 'vitest';
|
|
6
|
+
|
|
7
|
+
// The workspace test command runs from this package root; do not rely on Vite's virtual import.meta.url.
|
|
8
|
+
const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as {
|
|
9
|
+
name: string;
|
|
10
|
+
peerDependencies: Record<string, string>;
|
|
11
|
+
peerDependenciesMeta: Record<string, { optional?: boolean }>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('when declaring native command form peers', () => {
|
|
15
|
+
it('requires the published footer and native custom-error execution contracts', () => {
|
|
16
|
+
expect(packageJson.name).toBe('@cratis/scene.components');
|
|
17
|
+
expect(packageJson.peerDependencies['@cratis/components']).toBe('^4.13.0');
|
|
18
|
+
expect(packageJson.peerDependencies['@cratis/arc']).toBe('^22.19.1');
|
|
19
|
+
expect(packageJson.peerDependencies['@cratis/arc.react']).toBe('^22.19.1');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('keeps Arc optional for an unbound design-time preview', () => {
|
|
23
|
+
expect(packageJson.peerDependenciesMeta['@cratis/arc'].optional).toBe(true);
|
|
24
|
+
expect(packageJson.peerDependenciesMeta['@cratis/arc.react'].optional).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
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 type { ComponentType, ReactNode } from 'react';
|
|
5
|
+
import { AutoCommandForm } from '@cratis/components/CommandForm';
|
|
6
|
+
import { CommandForm } from '@cratis/arc.react/commands';
|
|
7
|
+
import { Guid } from '@cratis/fundamentals';
|
|
8
|
+
import type { BoundConstructor } from '../bindings';
|
|
9
|
+
import type { CommandInput } from './commandInputs';
|
|
10
|
+
import { CommandFormSubmit } from './CommandFormSubmit';
|
|
11
|
+
import { ExplicitCommandField } from './ExplicitCommandField';
|
|
12
|
+
|
|
13
|
+
interface CommandFormRuntimeProps {
|
|
14
|
+
command: BoundConstructor;
|
|
15
|
+
inputs?: CommandInput[];
|
|
16
|
+
exclude?: string[];
|
|
17
|
+
submitLabel: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Runtime bindings erase the proxy's keys; the optional footer is a Components >=4.13 contract.
|
|
21
|
+
const NativeAutoCommandForm = AutoCommandForm as unknown as ComponentType<{
|
|
22
|
+
command: BoundConstructor;
|
|
23
|
+
exclude?: string[];
|
|
24
|
+
footer?: ReactNode;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
type Descriptor = { name: string; type: unknown; isOptional: boolean };
|
|
28
|
+
|
|
29
|
+
/** Verify the whole command before mounting any native form: partial forms must never execute. */
|
|
30
|
+
function capability(command: BoundConstructor, inputs?: CommandInput[], exclude?: string[]): string | undefined {
|
|
31
|
+
let descriptors: unknown;
|
|
32
|
+
try { descriptors = (new command() as { propertyDescriptors?: unknown }).propertyDescriptors; }
|
|
33
|
+
catch { return 'Unable to inspect command proxy'; }
|
|
34
|
+
if (!Array.isArray(descriptors)) return 'Command proxy has no property descriptors';
|
|
35
|
+
const byName = new Map<string, Descriptor>();
|
|
36
|
+
for (const descriptor of descriptors) {
|
|
37
|
+
if (!descriptor || typeof descriptor.name !== 'string' || !descriptor.name ||
|
|
38
|
+
typeof descriptor.isOptional !== 'boolean' || byName.has(descriptor.name)) return 'Ambiguous or invalid command property descriptors';
|
|
39
|
+
byName.set(descriptor.name, descriptor);
|
|
40
|
+
}
|
|
41
|
+
if (inputs) {
|
|
42
|
+
for (const input of inputs) {
|
|
43
|
+
const descriptor = byName.get(input.property);
|
|
44
|
+
if (!descriptor) return `Unknown command property '${input.property}'`;
|
|
45
|
+
if (descriptor.type !== (input.type === 'guid' ? Guid : String)) return `Command property '${input.property}' type does not match '${input.type}'`;
|
|
46
|
+
}
|
|
47
|
+
for (const descriptor of byName.values()) {
|
|
48
|
+
if (!descriptor.isOptional && !inputs.some(input => input.property === descriptor.name)) return `Required command property '${descriptor.name}' has no input`;
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
for (const descriptor of byName.values()) {
|
|
52
|
+
if (!descriptor.isOptional && (exclude?.includes(descriptor.name) ||
|
|
53
|
+
![String, Number, Boolean, Date].includes(descriptor.type as typeof String))) {
|
|
54
|
+
return `Required command property '${descriptor.name}' has no supported automatic field; declare inputs`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Private lazy entry point: importing the Scene registry must not import Arc. */
|
|
62
|
+
export default function CommandFormRuntime({ command, inputs, exclude, submitLabel }: CommandFormRuntimeProps) {
|
|
63
|
+
const error = capability(command, inputs, exclude);
|
|
64
|
+
if (error) return <div role='alert'>{error}</div>;
|
|
65
|
+
// The registry's never-argument constructor is erased; Arc creates this checked proxy with no args.
|
|
66
|
+
if (inputs) return <CommandForm command={command as unknown as new () => object} showTitles={false}>
|
|
67
|
+
{inputs.map(input => <ExplicitCommandField key={input.property} input={input} />)}
|
|
68
|
+
<CommandFormSubmit label={submitLabel} />
|
|
69
|
+
</CommandForm>;
|
|
70
|
+
return <NativeAutoCommandForm command={command} exclude={exclude} footer={<CommandFormSubmit label={submitLabel} />} />;
|
|
71
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 { useCommandFormContext } from '@cratis/arc.react/commands';
|
|
5
|
+
|
|
6
|
+
interface CommandFormSubmitProps {
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Internal footer child; only rendered under the native form's context. */
|
|
11
|
+
export function CommandFormSubmit({ label }: CommandFormSubmitProps) {
|
|
12
|
+
const { isExecuting, isAuthorized } = useCommandFormContext();
|
|
13
|
+
// Do not gate on isValid: submitting untouched invalid fields must reveal native validation.
|
|
14
|
+
// No click handler: the containing Arc form owns submission, validation and execution.
|
|
15
|
+
return <button type='submit' disabled={isExecuting || !isAuthorized} aria-busy={isExecuting}>{label}</button>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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 { useId, useLayoutEffect, useRef, useState } from 'react';
|
|
5
|
+
import { asCommandFormField, useCommandFormContext } from '@cratis/arc.react/commands';
|
|
6
|
+
import { Guid } from '@cratis/fundamentals';
|
|
7
|
+
import type { CommandInput } from './commandInputs';
|
|
8
|
+
|
|
9
|
+
interface TextProps {
|
|
10
|
+
value: unknown;
|
|
11
|
+
onChange: (value: unknown) => void;
|
|
12
|
+
onBlur?: () => void;
|
|
13
|
+
invalid: boolean;
|
|
14
|
+
required: boolean;
|
|
15
|
+
errors: string[];
|
|
16
|
+
label: string;
|
|
17
|
+
property: string;
|
|
18
|
+
guid: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function TextControl({ value, onChange, onBlur, invalid, required, errors, label, property, guid }: TextProps) {
|
|
22
|
+
const id = useId();
|
|
23
|
+
const context = useCommandFormContext();
|
|
24
|
+
const { setCustomFieldError } = context;
|
|
25
|
+
// Keep the latest native getter: the context snapshot can be stale when another field sets an
|
|
26
|
+
// error and this field unmounts in the same event.
|
|
27
|
+
const contextRef = useRef(context);
|
|
28
|
+
contextRef.current = context;
|
|
29
|
+
// The draft is intentionally independent of the typed proxy value: malformed text must never
|
|
30
|
+
// reappear as a stale previously valid Guid in the command's serialized payload.
|
|
31
|
+
const [draft, setDraft] = useState(() => value instanceof Guid && !value.equals(Guid.empty) ? value.toString() : '');
|
|
32
|
+
const draftRef = useRef(draft);
|
|
33
|
+
const ownedError = useRef<string | undefined>(undefined);
|
|
34
|
+
const updateError = (error: string | undefined) => {
|
|
35
|
+
const current = contextRef.current.getFieldError(property);
|
|
36
|
+
// Do not replace or erase another producer's error for this field.
|
|
37
|
+
if (ownedError.current !== undefined && current !== ownedError.current) {
|
|
38
|
+
ownedError.current = undefined;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (ownedError.current === undefined && Object.hasOwn(contextRef.current.customFieldErrors, property)) return;
|
|
42
|
+
if (error === undefined && ownedError.current === undefined) return;
|
|
43
|
+
ownedError.current = error;
|
|
44
|
+
setCustomFieldError(property, error);
|
|
45
|
+
};
|
|
46
|
+
// A label/required change must recalculate the message without disposing the field's error.
|
|
47
|
+
// Only a different field/type or an actual unmount relinquishes ownership.
|
|
48
|
+
useLayoutEffect(() => () => {
|
|
49
|
+
if (ownedError.current !== undefined && contextRef.current.getFieldError(property) === ownedError.current) {
|
|
50
|
+
contextRef.current.setCustomFieldError(property, undefined);
|
|
51
|
+
}
|
|
52
|
+
ownedError.current = undefined;
|
|
53
|
+
}, [property, guid]);
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
if (guid) {
|
|
56
|
+
const current = draftRef.current || (value instanceof Guid && !value.equals(Guid.empty) ? value.toString() : '');
|
|
57
|
+
updateError(!current ? required ? `${label} is required` : undefined :
|
|
58
|
+
!Guid.isGuid(current) ? `${label} must be a valid GUID` : undefined);
|
|
59
|
+
}
|
|
60
|
+
}, [guid, required, property, label, setCustomFieldError]);
|
|
61
|
+
const text = guid ? draft : typeof value === 'string' ? value : '';
|
|
62
|
+
return <div>
|
|
63
|
+
<label htmlFor={id}>{label}</label>
|
|
64
|
+
<input id={id} name={property} type='text' required={required} value={text}
|
|
65
|
+
aria-invalid={invalid} aria-describedby={errors.length ? `${id}-error` : undefined}
|
|
66
|
+
onBlur={onBlur} onChange={event => {
|
|
67
|
+
const next = event.currentTarget.value;
|
|
68
|
+
if (!guid) { onChange(next); return; }
|
|
69
|
+
draftRef.current = next;
|
|
70
|
+
setDraft(next);
|
|
71
|
+
const error = !next ? required ? `${label} is required` : undefined : !Guid.isGuid(next) ? `${label} must be a valid GUID` : undefined;
|
|
72
|
+
// A malformed draft clears the previously typed value, not just its display.
|
|
73
|
+
onChange(error || !next ? undefined : Guid.parse(next));
|
|
74
|
+
updateError(error);
|
|
75
|
+
}} />
|
|
76
|
+
{errors.length > 0 && <div id={`${id}-error`} role='alert'>{errors[0]}</div>}
|
|
77
|
+
</div>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const NativeTextField = asCommandFormField<TextProps>(TextControl, { defaultValue: '' });
|
|
81
|
+
|
|
82
|
+
/** Opaque child is self-bound by Arc's native runtime binding, with an exact fieldName. */
|
|
83
|
+
export function ExplicitCommandField({ input }: { input: CommandInput }) {
|
|
84
|
+
return <NativeTextField fieldName={input.property} value={(command: Record<string, unknown>) => command[input.property]}
|
|
85
|
+
property={input.property} guid={input.type === 'guid'} label={input.label} title={input.label} />;
|
|
86
|
+
}
|
|
@@ -1,45 +1,48 @@
|
|
|
1
1
|
// Copyright (c) Cratis. All rights reserved.
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { lazy } from 'react';
|
|
5
5
|
import { RegisteredComponentProps } from '@cratis/scene.react';
|
|
6
|
-
import { ArcRuntimeBoundary, BindingKind,
|
|
7
|
-
import {
|
|
6
|
+
import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings';
|
|
7
|
+
import type { BoundConstructor } from '../bindings';
|
|
8
|
+
import { stringArrayProperty, stringProperty } from '../properties';
|
|
9
|
+
import { commandInputs } from './commandInputs';
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
command: BoundConstructor;
|
|
11
|
-
exclude?: string[];
|
|
12
|
-
}
|
|
11
|
+
const CommandFormRuntime = lazy(() => import('./CommandFormRuntime'));
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
// Arc's useCommand constructs the proxy only on mount. Key by the actual registered class,
|
|
14
|
+
// not its Scene name (which may be re-registered with a different route or roles).
|
|
15
|
+
const commandKeys = new WeakMap<BoundConstructor, number>();
|
|
16
|
+
let nextCommandKey = 0;
|
|
17
|
+
function commandKey(command: BoundConstructor): number {
|
|
18
|
+
let key = commandKeys.get(command);
|
|
19
|
+
if (key === undefined) {
|
|
20
|
+
key = ++nextCommandKey;
|
|
21
|
+
commandKeys.set(command, key);
|
|
22
|
+
}
|
|
23
|
+
return key;
|
|
24
|
+
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `AutoCommandForm` rather than `CommandForm`, because a screen that had to list every field by hand
|
|
28
|
-
* would go stale the moment a property is added to the command on the backend. `AutoCommandForm` reads
|
|
29
|
-
* the command's own property descriptors and picks a field component per property type, so the form
|
|
30
|
-
* follows the command - which is the same guarantee Arc's generated proxies give the rest of the stack.
|
|
31
|
-
*
|
|
32
|
-
* A screen that does want to place fields itself puts the field components from this package in the
|
|
33
|
-
* `content` slot and names them individually; `exclude` keeps `AutoCommandForm` from generating a second
|
|
34
|
-
* copy of anything placed that way.
|
|
27
|
+
* Auto mode follows supported native proxy descriptors; opt-in inputs bind exactly named fields
|
|
28
|
+
* inside a single native Arc form. Neither mode consumes the content slot.
|
|
35
29
|
*/
|
|
36
30
|
export function SceneCommandForm({ element }: RegisteredComponentProps) {
|
|
37
31
|
const { name, target } = resolveElementBinding(element, BindingKind.Command);
|
|
38
32
|
if (!target) return <MissingBinding element={element} kind={BindingKind.Command} name={name} />;
|
|
39
33
|
|
|
34
|
+
const inputs = Object.hasOwn(element.properties, 'inputs') ? commandInputs(element.properties) : undefined;
|
|
35
|
+
if (Object.hasOwn(element.properties, 'inputs') && !inputs) return <div role='alert'>Invalid command form inputs declaration</div>;
|
|
36
|
+
|
|
40
37
|
return (
|
|
41
38
|
<ArcRuntimeBoundary>
|
|
42
|
-
<
|
|
39
|
+
<CommandFormRuntime
|
|
40
|
+
key={commandKey(target)}
|
|
41
|
+
command={target}
|
|
42
|
+
inputs={inputs}
|
|
43
|
+
exclude={stringArrayProperty(element.properties, 'exclude')}
|
|
44
|
+
submitLabel={stringProperty(element.properties, 'submitLabel') ?? 'Submit'}
|
|
45
|
+
/>
|
|
43
46
|
</ArcRuntimeBoundary>
|
|
44
47
|
);
|
|
45
48
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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, objectProperty, stringProperty } from '../properties';
|
|
5
|
+
|
|
6
|
+
/** Exact, case-sensitive names from a generated command's propertyDescriptors. */
|
|
7
|
+
export interface CommandInput {
|
|
8
|
+
property: string;
|
|
9
|
+
type: 'string' | 'guid';
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** An absent declaration selects legacy auto mode; a present but invalid declaration never does. */
|
|
14
|
+
export function commandInputs(properties: Record<string, unknown>): CommandInput[] | undefined {
|
|
15
|
+
const values = arrayProperty(properties, 'inputs');
|
|
16
|
+
if (!values?.length) return undefined;
|
|
17
|
+
const inputs: CommandInput[] = [];
|
|
18
|
+
const seen = new Set<string>();
|
|
19
|
+
for (const value of values) {
|
|
20
|
+
const input = objectProperty({ value }, 'value');
|
|
21
|
+
if (!input || Object.keys(input).some(key => !['property', 'type', 'label'].includes(key))) return undefined;
|
|
22
|
+
const property = stringProperty(input, 'property');
|
|
23
|
+
const label = stringProperty(input, 'label');
|
|
24
|
+
if (!property?.trim() || !label?.trim() || (input.type !== 'string' && input.type !== 'guid') || seen.has(property)) return undefined;
|
|
25
|
+
seen.add(property);
|
|
26
|
+
inputs.push({ property, type: input.type, label });
|
|
27
|
+
}
|
|
28
|
+
return inputs;
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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 { Command, CommandValidator } from '@cratis/arc/commands';
|
|
5
|
+
import { PropertyDescriptor } from '@cratis/arc/reflection';
|
|
6
|
+
|
|
7
|
+
class NoteValidator extends CommandValidator<{ subject: string }> {
|
|
8
|
+
constructor() {
|
|
9
|
+
super();
|
|
10
|
+
this.ruleFor(command => command.subject).notEmpty().withMessage('Subject is required');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Synthetic proxy: execution, serialization and validation are all native Arc. */
|
|
15
|
+
export class RecordNote extends Command {
|
|
16
|
+
readonly route: string = '/synthetic/notes';
|
|
17
|
+
readonly propertyDescriptors = [
|
|
18
|
+
new PropertyDescriptor('subject', String),
|
|
19
|
+
new PropertyDescriptor('internalValue', String, true),
|
|
20
|
+
];
|
|
21
|
+
readonly requestParameters = [];
|
|
22
|
+
readonly validation = new NoteValidator();
|
|
23
|
+
subject = '';
|
|
24
|
+
internalValue = 'proxy default';
|
|
25
|
+
constructor() { super(Object, false); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class RestrictedNote extends RecordNote {
|
|
29
|
+
readonly roles = ['note-writer'];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function successfulResponse() {
|
|
33
|
+
return { status: 200, json: async () => ({
|
|
34
|
+
isSuccess: true, isAuthorized: true, isValid: true, hasExceptions: false,
|
|
35
|
+
validationResults: [], exceptionMessages: [], exceptionStackTrace: '', response: {},
|
|
36
|
+
}) } as Response;
|
|
37
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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 { Command, CommandValidator } from '@cratis/arc/commands';
|
|
5
|
+
import { PropertyDescriptor } from '@cratis/arc/reflection';
|
|
6
|
+
import { Guid } from '@cratis/fundamentals';
|
|
7
|
+
|
|
8
|
+
class ProjectValidator extends CommandValidator<{ name: string }> {
|
|
9
|
+
constructor() {
|
|
10
|
+
super();
|
|
11
|
+
this.ruleFor(command => command.name).notEmpty().withMessage('Name is required');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Generated-shape proxy: native execution and serialization, only fetch is substituted. */
|
|
16
|
+
export class StartProject extends Command {
|
|
17
|
+
readonly route: string = '/synthetic/projects';
|
|
18
|
+
readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid), new PropertyDescriptor('name', String)];
|
|
19
|
+
readonly requestParameters = [];
|
|
20
|
+
readonly validation = new ProjectValidator();
|
|
21
|
+
projectId: Guid = Guid.empty;
|
|
22
|
+
name = '';
|
|
23
|
+
constructor() { super(Object, false); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class RestrictedProject extends StartProject {
|
|
27
|
+
readonly roles = ['project-writer'];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class OptionalUnsupported extends StartProject {
|
|
31
|
+
readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid), new PropertyDescriptor('name', String), new PropertyDescriptor('details', Object, true)];
|
|
32
|
+
details?: object;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class RequiredUnsupported extends OptionalUnsupported {
|
|
36
|
+
readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid), new PropertyDescriptor('name', String), new PropertyDescriptor('details', Object)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class AmbiguousProject extends StartProject {
|
|
40
|
+
readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid), new PropertyDescriptor('name', String), new PropertyDescriptor('projectId', Guid)];
|
|
41
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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 { vi } from 'vitest';
|
|
6
|
+
import { externalComponent } from '../../given';
|
|
7
|
+
|
|
8
|
+
// This seam captures adapter inputs only. It does not simulate AutoCommandForm or its footer.
|
|
9
|
+
describe('when forwarding command forms to the lazy runtime', () => {
|
|
10
|
+
const received = vi.fn<(props: Record<string, unknown>) => void>();
|
|
11
|
+
const loaded = vi.fn();
|
|
12
|
+
let bindings: typeof import('../../bindings/bindingRegistry');
|
|
13
|
+
let Form: typeof import('../SceneCommandForm').SceneCommandForm;
|
|
14
|
+
class Original {}
|
|
15
|
+
class Replacement {}
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
vi.resetModules();
|
|
19
|
+
received.mockClear();
|
|
20
|
+
loaded.mockClear();
|
|
21
|
+
vi.doMock('../CommandFormRuntime', () => {
|
|
22
|
+
loaded();
|
|
23
|
+
return { default: (props: Record<string, unknown>) => { received(props); return <span>Runtime boundary</span>; } };
|
|
24
|
+
});
|
|
25
|
+
bindings = await import('../../bindings/bindingRegistry');
|
|
26
|
+
bindings.clearBindings();
|
|
27
|
+
({ SceneCommandForm: Form } = await import('../SceneCommandForm'));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
bindings.clearBindings();
|
|
33
|
+
vi.doUnmock('../CommandFormRuntime');
|
|
34
|
+
vi.resetModules();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should preserve the exact registered constructor, exclusions and explicit label', async () => {
|
|
38
|
+
bindings.registerCommand('RecordNote', Original);
|
|
39
|
+
const exclude = ['internalValue'];
|
|
40
|
+
await act(async () => { render(<Form element={externalComponent('Cratis.Components:commandForm', {
|
|
41
|
+
command: 'RecordNote', exclude, submitLabel: 'Record note',
|
|
42
|
+
})} slots={{}} />); });
|
|
43
|
+
received.mock.calls.at(-1)![0].should.deep.equal({ command: Original, exclude, inputs: undefined, submitLabel: 'Record note' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for (const submitLabel of [undefined, 42, false]) {
|
|
47
|
+
it(`should default a missing or non-string label (${String(submitLabel)}) to Submit`, async () => {
|
|
48
|
+
bindings.registerCommand('RecordNote', Original);
|
|
49
|
+
await act(async () => { render(<Form element={externalComponent('Cratis.Components:commandForm', {
|
|
50
|
+
command: 'RecordNote', exclude: 'not-an-array', submitLabel,
|
|
51
|
+
})} slots={{}} />); });
|
|
52
|
+
received.mock.calls.at(-1)![0].should.deep.equal({ command: Original, exclude: undefined, inputs: undefined, submitLabel: 'Submit' });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it('should preserve legacy last-registration-wins command binding', async () => {
|
|
57
|
+
bindings.registerCommand('RecordNote', Original);
|
|
58
|
+
bindings.registerCommand('RecordNote', Replacement);
|
|
59
|
+
await act(async () => { render(<Form element={externalComponent('Cratis.Components:commandForm', { command: 'RecordNote' })} slots={{}} />); });
|
|
60
|
+
(received.mock.calls.at(-1)![0].command === Replacement).should.equal(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should keep an unbound preview Arc-free even when a query has that name', async () => {
|
|
64
|
+
bindings.registerQuery('RecordNote', Original);
|
|
65
|
+
await act(async () => { render(<Form element={externalComponent('Cratis.Components:commandForm', { command: 'RecordNote' })} slots={{}} />); });
|
|
66
|
+
Boolean(screen.getByText("Unresolved command binding 'RecordNote' on Cratis.Components:commandForm")).should.equal(true);
|
|
67
|
+
loaded.mock.calls.should.have.lengthOf(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen } 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, successfulResponse } from './given/RecordNote';
|
|
10
|
+
|
|
11
|
+
// Independent native prerequisite gate: Arc PR #2724 (expected 22.19.1), not a Scene workaround.
|
|
12
|
+
// This does not exercise or simulate AutoCommandForm's forthcoming footer seam.
|
|
13
|
+
describe('when native custom field errors block submission', () => {
|
|
14
|
+
const fetch = vi.fn<typeof globalThis.fetch>();
|
|
15
|
+
beforeEach(() => { fetch.mockReset(); fetch.mockResolvedValue(successfulResponse()); vi.stubGlobal('fetch', fetch); });
|
|
16
|
+
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
|
17
|
+
|
|
18
|
+
it('should leave submit enabled for feedback but let the native form reject a custom field error', async () => {
|
|
19
|
+
await act(async () => { render(<CommandForm command={RecordNote}
|
|
20
|
+
onFieldValidate={() => 'Subject is rejected by the field'}>
|
|
21
|
+
<InputTextField value={(command: RecordNote) => command.subject} title='Subject' />
|
|
22
|
+
<CommandFormSubmit label='Record note' />
|
|
23
|
+
</CommandForm>); });
|
|
24
|
+
const input = screen.getByRole('textbox', { name: 'Subject' });
|
|
25
|
+
fireEvent.change(input, { target: { value: 'Passes the command validator' } });
|
|
26
|
+
await act(async () => { fireEvent.blur(input); });
|
|
27
|
+
(await screen.findAllByText('Subject is rejected by the field')).length.should.be.greaterThan(0);
|
|
28
|
+
const button = screen.getByRole<HTMLButtonElement>('button', { name: 'Record note' });
|
|
29
|
+
button.disabled.should.equal(false);
|
|
30
|
+
await act(async () => { fireEvent.click(button); });
|
|
31
|
+
fetch.mock.calls.should.have.lengthOf(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -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 { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
import { CommandForm, useCommandFormContext } from '@cratis/arc.react/commands';
|
|
6
|
+
import { Guid } from '@cratis/fundamentals';
|
|
7
|
+
import { PropertyDescriptor } from '@cratis/arc/reflection';
|
|
8
|
+
import { ExplicitCommandField } from '../ExplicitCommandField';
|
|
9
|
+
import { StartProject } from './given/StartProject';
|
|
10
|
+
|
|
11
|
+
class OptionalProject extends StartProject {
|
|
12
|
+
readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid, true), new PropertyDescriptor('name', String)];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const input = { property: 'projectId', type: 'guid' as const, label: 'Project ID' };
|
|
16
|
+
|
|
17
|
+
describe('native explicit Guid field error ownership', () => {
|
|
18
|
+
afterEach(cleanup);
|
|
19
|
+
|
|
20
|
+
it('leaves a caller-owned error intact when the invalid field unmounts in the same event', async () => {
|
|
21
|
+
let context!: ReturnType<typeof useCommandFormContext>;
|
|
22
|
+
function Capture() { context = useCommandFormContext(); return null; }
|
|
23
|
+
function form(show: boolean) {
|
|
24
|
+
return <CommandForm command={StartProject}><Capture />{show && <ExplicitCommandField input={input} />}</CommandForm>;
|
|
25
|
+
}
|
|
26
|
+
const result = render(form(true));
|
|
27
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: 'bad' } });
|
|
28
|
+
context.getFieldError('projectId')!.should.equal('Project ID must be a valid GUID');
|
|
29
|
+
await act(async () => {
|
|
30
|
+
context.setCustomFieldError('projectId', 'Caller-owned error');
|
|
31
|
+
result.rerender(form(false));
|
|
32
|
+
});
|
|
33
|
+
context.getFieldError('projectId')!.should.equal('Caller-owned error');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('clears its own malformed optional Guid error on unmount', async () => {
|
|
37
|
+
let context!: ReturnType<typeof useCommandFormContext>;
|
|
38
|
+
function Capture() { context = useCommandFormContext(); return null; }
|
|
39
|
+
function form(show: boolean) {
|
|
40
|
+
return <CommandForm command={OptionalProject}><Capture />{show && <ExplicitCommandField input={input} />}</CommandForm>;
|
|
41
|
+
}
|
|
42
|
+
const result = render(form(true));
|
|
43
|
+
await act(async () => {
|
|
44
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: 'bad' } });
|
|
45
|
+
context.getFieldError('projectId')!.should.equal('Project ID must be a valid GUID');
|
|
46
|
+
result.rerender(form(false));
|
|
47
|
+
});
|
|
48
|
+
(Object.hasOwn(context.customFieldErrors, 'projectId')).should.equal(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -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
|
+
});
|