diff --git a/Documentation/components-package/components.md b/Documentation/components-package/components.md index f5b06eb4..8f3c5656 100644 --- a/Documentation/components-package/components.md +++ b/Documentation/components-package/components.md @@ -38,16 +38,40 @@ and re-renders when the read model changes. | Name | Wraps | Properties | Slots | |---|---|---|---| -| `commandForm` | `AutoCommandForm` | **`command`** (binding), `exclude` | — | +| `commandForm` | `AutoCommandForm` (legacy) or native Arc `CommandForm` (explicit) | **`command`** (binding), `inputs`, `exclude` (auto only), `submitLabel` | — | | `queryInputForm` | Native web inputs + existing optional-single runtime | **`query`** (exact binding), `inputs`, `resultField`, `label`, `submitLabel` | — | [`queryInputForm`](query-input-form.md) is opt-in and submits explicit string drafts, never querying while editing. Unlike forgiving display properties, malformed input declarations fail visibly and execute nothing. It does not use command fields or a new core form schema. -`AutoCommandForm` generates its fields from the command's own property descriptors, so the form follows the -command rather than going stale when a property is added on the backend. `exclude` keeps it from generating a -second copy of anything placed by hand. +Without `inputs`, `AutoCommandForm` generates fields from native proxy descriptors (String, Number, Boolean, +Date only). A required Guid or other unsupported required descriptor, including one removed with `exclude`, +shows a diagnostic rather than a partial form. The `content` slot is **not** consumed by this adapter; +`exclude` does not connect independently placed fields to the form. Prefer an authored screen for complete +layout control. Opt into exactly named fields with `inputs`, for example: + +```json +[{"property":"projectId","type":"guid","label":"Project ID"},{"property":"name","type":"string","label":"Name"}] +``` + +Explicit mode requires every required descriptor to have an input; optional descriptors may be left out. +Only String and Guid fields can be declared. Missing, duplicate, ambiguous or mismatched names and unsupported +input types fail visibly without execution. Guid drafts must match the canonical hyphenated 8-4-4-4-12 hex +format before native submission. The input is not generated or inferred: valid Guid text is parsed into the +proxy's Guid type, whose JSON representation canonicalizes letter case (the identifier itself is unchanged). +String values are forwarded unchanged, without trimming. Native Arc owns validation, authorization, execution +and feedback; no Scene HTTP executor is introduced. + +`commandForm` adds a native submit button after the generated fields. `submitLabel` accepts a string and +defaults to `Submit` when absent or of another type. The button submits the containing Arc form; Arc owns +validation, execution and feedback. It disables while native `isExecuting` is true or `isAuthorized` is +false, but not merely because the form is invalid: submitting untouched fields must expose validation +feedback. No Scene action handler or command executor is needed. + +**Dependency requirement:** Components >=4.13.0 supplies the auto-form footer; Arc and Arc React +>=22.19.1 supply the custom-field-error execution gate for explicit Guid inputs. These are the declared +peer floors for this package. Arc peers remain optional: unbound previews stay lazy and do not import Arc. ### Field types diff --git a/Source/JavaScript/components/README.md b/Source/JavaScript/components/README.md index 1d902d69..306bb7fb 100644 --- a/Source/JavaScript/components/README.md +++ b/Source/JavaScript/components/README.md @@ -73,7 +73,7 @@ unbound table costs one dashed box, not the whole screen. `@cratis/arc`, `@cratis/arc.react` and `@cratis/fundamentals` are peer dependencies of `@cratis/components` that the *host* supplies. This package also declares Arc and Arc React as optional peers and pins -22.16.1 as development dependencies for native integration tests. A design surface is not a host. +22.19.1 as development dependencies for native integration tests. A design surface is not a host. Every adapter that reaches them does so through a dynamic `import()` inside `React.lazy`, wrapped in `ArcRuntimeBoundary` (`Suspense` + the library's own `ErrorBoundary`). A screen built only from the library's @@ -88,6 +88,22 @@ This splits the library cleanly, and the split is the library's own: The Storybook build externalizes the Arc packages (`.storybook/main.ts`) so the lazy chunk can be emitted with its imports left bare. No story loads that chunk, because no story registers a binding. +## Actionable command forms + +`commandForm` supplies a native submit button via `AutoCommandForm`'s optional `footer` ReactNode seam. +`submitLabel` is an optional string, defaulting to `Submit`. A private lazy runtime keeps Arc out of unbound +previews; its footer child reads the actual native form context for execution and authorization state. +Validity does not disable submission: native validation must be able to show errors on untouched forms. +Scene neither executes the command directly nor creates another form or command context. + +Optional explicit `inputs` use exact native proxy descriptor names and types (`string`/`guid`) in one +Arc CommandForm; missing, ambiguous, uncovered required or unsupported declarations block the form. Auto +mode is fields-only and only supports String/Number/Boolean/Date providers, so required Guid does not silently +disappear. The content slot is not connected to either mode. A valid Guid draft becomes a typed Guid; Guid +JSON canonicalizes textual case without changing identity. No ID is generated. The declared peers require +published Components >=4.13.0 for the footer and Arc/Arc React >=22.19.1 for native custom-error gating. +Arc peers remain optional so unbound previews stay Arc-free. + ## Optional single-result view (read-only) `Cratis.Components:singleResult` is a narrow adapter for an optional **single model**, not a collection. diff --git a/Source/JavaScript/components/for_cratisComponentsPackage/when_declaring_command_form_peers.ts b/Source/JavaScript/components/for_cratisComponentsPackage/when_declaring_command_form_peers.ts new file mode 100644 index 00000000..a6e71671 --- /dev/null +++ b/Source/JavaScript/components/for_cratisComponentsPackage/when_declaring_command_form_peers.ts @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { expect } from 'vitest'; + +// The workspace test command runs from this package root; do not rely on Vite's virtual import.meta.url. +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as { + name: string; + peerDependencies: Record; + peerDependenciesMeta: Record; +}; + +describe('when declaring native command form peers', () => { + it('requires the published footer and native custom-error execution contracts', () => { + expect(packageJson.name).toBe('@cratis/scene.components'); + expect(packageJson.peerDependencies['@cratis/components']).toBe('^4.13.0'); + expect(packageJson.peerDependencies['@cratis/arc']).toBe('^22.19.1'); + expect(packageJson.peerDependencies['@cratis/arc.react']).toBe('^22.19.1'); + }); + + it('keeps Arc optional for an unbound design-time preview', () => { + expect(packageJson.peerDependenciesMeta['@cratis/arc'].optional).toBe(true); + expect(packageJson.peerDependenciesMeta['@cratis/arc.react'].optional).toBe(true); + }); +}); diff --git a/Source/JavaScript/components/forms/CommandFormRuntime.tsx b/Source/JavaScript/components/forms/CommandFormRuntime.tsx new file mode 100644 index 00000000..b48c3d0e --- /dev/null +++ b/Source/JavaScript/components/forms/CommandFormRuntime.tsx @@ -0,0 +1,71 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { ComponentType, ReactNode } from 'react'; +import { AutoCommandForm } from '@cratis/components/CommandForm'; +import { CommandForm } from '@cratis/arc.react/commands'; +import { Guid } from '@cratis/fundamentals'; +import type { BoundConstructor } from '../bindings'; +import type { CommandInput } from './commandInputs'; +import { CommandFormSubmit } from './CommandFormSubmit'; +import { ExplicitCommandField } from './ExplicitCommandField'; + +interface CommandFormRuntimeProps { + command: BoundConstructor; + inputs?: CommandInput[]; + exclude?: string[]; + submitLabel: string; +} + +// Runtime bindings erase the proxy's keys; the optional footer is a Components >=4.13 contract. +const NativeAutoCommandForm = AutoCommandForm as unknown as ComponentType<{ + command: BoundConstructor; + exclude?: string[]; + footer?: ReactNode; +}>; + +type Descriptor = { name: string; type: unknown; isOptional: boolean }; + +/** Verify the whole command before mounting any native form: partial forms must never execute. */ +function capability(command: BoundConstructor, inputs?: CommandInput[], exclude?: string[]): string | undefined { + let descriptors: unknown; + try { descriptors = (new command() as { propertyDescriptors?: unknown }).propertyDescriptors; } + catch { return 'Unable to inspect command proxy'; } + if (!Array.isArray(descriptors)) return 'Command proxy has no property descriptors'; + const byName = new Map(); + for (const descriptor of descriptors) { + if (!descriptor || typeof descriptor.name !== 'string' || !descriptor.name || + typeof descriptor.isOptional !== 'boolean' || byName.has(descriptor.name)) return 'Ambiguous or invalid command property descriptors'; + byName.set(descriptor.name, descriptor); + } + if (inputs) { + for (const input of inputs) { + const descriptor = byName.get(input.property); + if (!descriptor) return `Unknown command property '${input.property}'`; + if (descriptor.type !== (input.type === 'guid' ? Guid : String)) return `Command property '${input.property}' type does not match '${input.type}'`; + } + for (const descriptor of byName.values()) { + if (!descriptor.isOptional && !inputs.some(input => input.property === descriptor.name)) return `Required command property '${descriptor.name}' has no input`; + } + } else { + for (const descriptor of byName.values()) { + if (!descriptor.isOptional && (exclude?.includes(descriptor.name) || + ![String, Number, Boolean, Date].includes(descriptor.type as typeof String))) { + return `Required command property '${descriptor.name}' has no supported automatic field; declare inputs`; + } + } + } + return undefined; +} + +/** Private lazy entry point: importing the Scene registry must not import Arc. */ +export default function CommandFormRuntime({ command, inputs, exclude, submitLabel }: CommandFormRuntimeProps) { + const error = capability(command, inputs, exclude); + if (error) return
{error}
; + // The registry's never-argument constructor is erased; Arc creates this checked proxy with no args. + if (inputs) return object} showTitles={false}> + {inputs.map(input => )} + + ; + return } />; +} diff --git a/Source/JavaScript/components/forms/CommandFormSubmit.tsx b/Source/JavaScript/components/forms/CommandFormSubmit.tsx new file mode 100644 index 00000000..fa835d89 --- /dev/null +++ b/Source/JavaScript/components/forms/CommandFormSubmit.tsx @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useCommandFormContext } from '@cratis/arc.react/commands'; + +interface CommandFormSubmitProps { + label: string; +} + +/** Internal footer child; only rendered under the native form's context. */ +export function CommandFormSubmit({ label }: CommandFormSubmitProps) { + const { isExecuting, isAuthorized } = useCommandFormContext(); + // Do not gate on isValid: submitting untouched invalid fields must reveal native validation. + // No click handler: the containing Arc form owns submission, validation and execution. + return ; +} diff --git a/Source/JavaScript/components/forms/ExplicitCommandField.tsx b/Source/JavaScript/components/forms/ExplicitCommandField.tsx new file mode 100644 index 00000000..eb7909ea --- /dev/null +++ b/Source/JavaScript/components/forms/ExplicitCommandField.tsx @@ -0,0 +1,86 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useId, useLayoutEffect, useRef, useState } from 'react'; +import { asCommandFormField, useCommandFormContext } from '@cratis/arc.react/commands'; +import { Guid } from '@cratis/fundamentals'; +import type { CommandInput } from './commandInputs'; + +interface TextProps { + value: unknown; + onChange: (value: unknown) => void; + onBlur?: () => void; + invalid: boolean; + required: boolean; + errors: string[]; + label: string; + property: string; + guid: boolean; +} + +function TextControl({ value, onChange, onBlur, invalid, required, errors, label, property, guid }: TextProps) { + const id = useId(); + const context = useCommandFormContext(); + const { setCustomFieldError } = context; + // Keep the latest native getter: the context snapshot can be stale when another field sets an + // error and this field unmounts in the same event. + const contextRef = useRef(context); + contextRef.current = context; + // The draft is intentionally independent of the typed proxy value: malformed text must never + // reappear as a stale previously valid Guid in the command's serialized payload. + const [draft, setDraft] = useState(() => value instanceof Guid && !value.equals(Guid.empty) ? value.toString() : ''); + const draftRef = useRef(draft); + const ownedError = useRef(undefined); + const updateError = (error: string | undefined) => { + const current = contextRef.current.getFieldError(property); + // Do not replace or erase another producer's error for this field. + if (ownedError.current !== undefined && current !== ownedError.current) { + ownedError.current = undefined; + return; + } + if (ownedError.current === undefined && Object.hasOwn(contextRef.current.customFieldErrors, property)) return; + if (error === undefined && ownedError.current === undefined) return; + ownedError.current = error; + setCustomFieldError(property, error); + }; + // A label/required change must recalculate the message without disposing the field's error. + // Only a different field/type or an actual unmount relinquishes ownership. + useLayoutEffect(() => () => { + if (ownedError.current !== undefined && contextRef.current.getFieldError(property) === ownedError.current) { + contextRef.current.setCustomFieldError(property, undefined); + } + ownedError.current = undefined; + }, [property, guid]); + useLayoutEffect(() => { + if (guid) { + const current = draftRef.current || (value instanceof Guid && !value.equals(Guid.empty) ? value.toString() : ''); + updateError(!current ? required ? `${label} is required` : undefined : + !Guid.isGuid(current) ? `${label} must be a valid GUID` : undefined); + } + }, [guid, required, property, label, setCustomFieldError]); + const text = guid ? draft : typeof value === 'string' ? value : ''; + return
+ + { + const next = event.currentTarget.value; + if (!guid) { onChange(next); return; } + draftRef.current = next; + setDraft(next); + const error = !next ? required ? `${label} is required` : undefined : !Guid.isGuid(next) ? `${label} must be a valid GUID` : undefined; + // A malformed draft clears the previously typed value, not just its display. + onChange(error || !next ? undefined : Guid.parse(next)); + updateError(error); + }} /> + {errors.length > 0 && } +
; +} + +const NativeTextField = asCommandFormField(TextControl, { defaultValue: '' }); + +/** Opaque child is self-bound by Arc's native runtime binding, with an exact fieldName. */ +export function ExplicitCommandField({ input }: { input: CommandInput }) { + return ) => command[input.property]} + property={input.property} guid={input.type === 'guid'} label={input.label} title={input.label} />; +} diff --git a/Source/JavaScript/components/forms/SceneCommandForm.tsx b/Source/JavaScript/components/forms/SceneCommandForm.tsx index 98cbb256..505e7322 100644 --- a/Source/JavaScript/components/forms/SceneCommandForm.tsx +++ b/Source/JavaScript/components/forms/SceneCommandForm.tsx @@ -1,45 +1,48 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { ComponentType, lazy } from 'react'; +import { lazy } from 'react'; import { RegisteredComponentProps } from '@cratis/scene.react'; -import { ArcRuntimeBoundary, BindingKind, BoundConstructor, MissingBinding, resolveElementBinding } from '../bindings'; -import { stringArrayProperty } from '../properties'; +import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; +import type { BoundConstructor } from '../bindings'; +import { stringArrayProperty, stringProperty } from '../properties'; +import { commandInputs } from './commandInputs'; -interface AutoCommandFormElementProps { - command: BoundConstructor; - exclude?: string[]; -} +const CommandFormRuntime = lazy(() => import('./CommandFormRuntime')); -/** - * `AutoCommandForm` types `exclude` as `(keyof TCommand)[]`, which collapses to `never[]` when the - * command type is only known at runtime - as it always is here, since the class arrives from the binding - * registry rather than from a type annotation. The conversion states the shape this adapter actually - * passes; the underlying component reads `exclude` as property names either way. - */ -const AutoCommandForm = lazy(async () => ({ - default: (await import('@cratis/components/CommandForm')).AutoCommandForm as unknown as ComponentType, -})); +// Arc's useCommand constructs the proxy only on mount. Key by the actual registered class, +// not its Scene name (which may be re-registered with a different route or roles). +const commandKeys = new WeakMap(); +let nextCommandKey = 0; +function commandKey(command: BoundConstructor): number { + let key = commandKeys.get(command); + if (key === undefined) { + key = ++nextCommandKey; + commandKeys.set(command, key); + } + return key; +} /** - * The `Cratis.Components:commandForm` component - `AutoCommandForm` from `@cratis/components/CommandForm`. - * - * `AutoCommandForm` rather than `CommandForm`, because a screen that had to list every field by hand - * would go stale the moment a property is added to the command on the backend. `AutoCommandForm` reads - * the command's own property descriptors and picks a field component per property type, so the form - * follows the command - which is the same guarantee Arc's generated proxies give the rest of the stack. - * - * A screen that does want to place fields itself puts the field components from this package in the - * `content` slot and names them individually; `exclude` keeps `AutoCommandForm` from generating a second - * copy of anything placed that way. + * Auto mode follows supported native proxy descriptors; opt-in inputs bind exactly named fields + * inside a single native Arc form. Neither mode consumes the content slot. */ export function SceneCommandForm({ element }: RegisteredComponentProps) { const { name, target } = resolveElementBinding(element, BindingKind.Command); if (!target) return ; + const inputs = Object.hasOwn(element.properties, 'inputs') ? commandInputs(element.properties) : undefined; + if (Object.hasOwn(element.properties, 'inputs') && !inputs) return
Invalid command form inputs declaration
; + return ( - + ); } diff --git a/Source/JavaScript/components/forms/commandInputs.ts b/Source/JavaScript/components/forms/commandInputs.ts new file mode 100644 index 00000000..6dec10a4 --- /dev/null +++ b/Source/JavaScript/components/forms/commandInputs.ts @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { arrayProperty, objectProperty, stringProperty } from '../properties'; + +/** Exact, case-sensitive names from a generated command's propertyDescriptors. */ +export interface CommandInput { + property: string; + type: 'string' | 'guid'; + label: string; +} + +/** An absent declaration selects legacy auto mode; a present but invalid declaration never does. */ +export function commandInputs(properties: Record): CommandInput[] | undefined { + const values = arrayProperty(properties, 'inputs'); + if (!values?.length) return undefined; + const inputs: CommandInput[] = []; + const seen = new Set(); + for (const value of values) { + const input = objectProperty({ value }, 'value'); + if (!input || Object.keys(input).some(key => !['property', 'type', 'label'].includes(key))) return undefined; + const property = stringProperty(input, 'property'); + const label = stringProperty(input, 'label'); + if (!property?.trim() || !label?.trim() || (input.type !== 'string' && input.type !== 'guid') || seen.has(property)) return undefined; + seen.add(property); + inputs.push({ property, type: input.type, label }); + } + return inputs; +} diff --git a/Source/JavaScript/components/forms/for_SceneCommandForm/given/RecordNote.ts b/Source/JavaScript/components/forms/for_SceneCommandForm/given/RecordNote.ts new file mode 100644 index 00000000..9c3628b0 --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneCommandForm/given/RecordNote.ts @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Command, CommandValidator } from '@cratis/arc/commands'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; + +class NoteValidator extends CommandValidator<{ subject: string }> { + constructor() { + super(); + this.ruleFor(command => command.subject).notEmpty().withMessage('Subject is required'); + } +} + +/** Synthetic proxy: execution, serialization and validation are all native Arc. */ +export class RecordNote extends Command { + readonly route: string = '/synthetic/notes'; + readonly propertyDescriptors = [ + new PropertyDescriptor('subject', String), + new PropertyDescriptor('internalValue', String, true), + ]; + readonly requestParameters = []; + readonly validation = new NoteValidator(); + subject = ''; + internalValue = 'proxy default'; + constructor() { super(Object, false); } +} + +export class RestrictedNote extends RecordNote { + readonly roles = ['note-writer']; +} + +export function successfulResponse() { + return { status: 200, json: async () => ({ + isSuccess: true, isAuthorized: true, isValid: true, hasExceptions: false, + validationResults: [], exceptionMessages: [], exceptionStackTrace: '', response: {}, + }) } as Response; +} diff --git a/Source/JavaScript/components/forms/for_SceneCommandForm/given/StartProject.ts b/Source/JavaScript/components/forms/for_SceneCommandForm/given/StartProject.ts new file mode 100644 index 00000000..750a6c21 --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneCommandForm/given/StartProject.ts @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Command, CommandValidator } from '@cratis/arc/commands'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; +import { Guid } from '@cratis/fundamentals'; + +class ProjectValidator extends CommandValidator<{ name: string }> { + constructor() { + super(); + this.ruleFor(command => command.name).notEmpty().withMessage('Name is required'); + } +} + +/** Generated-shape proxy: native execution and serialization, only fetch is substituted. */ +export class StartProject extends Command { + readonly route: string = '/synthetic/projects'; + readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid), new PropertyDescriptor('name', String)]; + readonly requestParameters = []; + readonly validation = new ProjectValidator(); + projectId: Guid = Guid.empty; + name = ''; + constructor() { super(Object, false); } +} + +export class RestrictedProject extends StartProject { + readonly roles = ['project-writer']; +} + +export class OptionalUnsupported extends StartProject { + readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid), new PropertyDescriptor('name', String), new PropertyDescriptor('details', Object, true)]; + details?: object; +} + +export class RequiredUnsupported extends OptionalUnsupported { + readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid), new PropertyDescriptor('name', String), new PropertyDescriptor('details', Object)]; +} + +export class AmbiguousProject extends StartProject { + readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid), new PropertyDescriptor('name', String), new PropertyDescriptor('projectId', Guid)]; +} diff --git a/Source/JavaScript/components/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.tsx b/Source/JavaScript/components/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.tsx new file mode 100644 index 00000000..e9f18c3d --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneCommandForm/when_forwarding_to_the_lazy_runtime.tsx @@ -0,0 +1,69 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +import { externalComponent } from '../../given'; + +// This seam captures adapter inputs only. It does not simulate AutoCommandForm or its footer. +describe('when forwarding command forms to the lazy runtime', () => { + const received = vi.fn<(props: Record) => void>(); + const loaded = vi.fn(); + let bindings: typeof import('../../bindings/bindingRegistry'); + let Form: typeof import('../SceneCommandForm').SceneCommandForm; + class Original {} + class Replacement {} + + beforeEach(async () => { + vi.resetModules(); + received.mockClear(); + loaded.mockClear(); + vi.doMock('../CommandFormRuntime', () => { + loaded(); + return { default: (props: Record) => { received(props); return Runtime boundary; } }; + }); + bindings = await import('../../bindings/bindingRegistry'); + bindings.clearBindings(); + ({ SceneCommandForm: Form } = await import('../SceneCommandForm')); + }); + + afterEach(() => { + cleanup(); + bindings.clearBindings(); + vi.doUnmock('../CommandFormRuntime'); + vi.resetModules(); + }); + + it('should preserve the exact registered constructor, exclusions and explicit label', async () => { + bindings.registerCommand('RecordNote', Original); + const exclude = ['internalValue']; + await act(async () => { render(
); }); + received.mock.calls.at(-1)![0].should.deep.equal({ command: Original, exclude, inputs: undefined, submitLabel: 'Record note' }); + }); + + for (const submitLabel of [undefined, 42, false]) { + it(`should default a missing or non-string label (${String(submitLabel)}) to Submit`, async () => { + bindings.registerCommand('RecordNote', Original); + await act(async () => { render(); }); + received.mock.calls.at(-1)![0].should.deep.equal({ command: Original, exclude: undefined, inputs: undefined, submitLabel: 'Submit' }); + }); + } + + it('should preserve legacy last-registration-wins command binding', async () => { + bindings.registerCommand('RecordNote', Original); + bindings.registerCommand('RecordNote', Replacement); + await act(async () => { render(); }); + (received.mock.calls.at(-1)![0].command === Replacement).should.equal(true); + }); + + it('should keep an unbound preview Arc-free even when a query has that name', async () => { + bindings.registerQuery('RecordNote', Original); + await act(async () => { render(); }); + Boolean(screen.getByText("Unresolved command binding 'RecordNote' on Cratis.Components:commandForm")).should.equal(true); + loaded.mock.calls.should.have.lengthOf(0); + }); +}); diff --git a/Source/JavaScript/components/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.tsx b/Source/JavaScript/components/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.tsx new file mode 100644 index 00000000..1b155f88 --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneCommandForm/when_native_custom_field_errors_block_submission.tsx @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +import { CommandForm } from '@cratis/arc.react/commands'; +import { InputTextField } from '@cratis/components/CommandForm'; +import { CommandFormSubmit } from '../CommandFormSubmit'; +import { RecordNote, successfulResponse } from './given/RecordNote'; + +// Independent native prerequisite gate: Arc PR #2724 (expected 22.19.1), not a Scene workaround. +// This does not exercise or simulate AutoCommandForm's forthcoming footer seam. +describe('when native custom field errors block submission', () => { + const fetch = vi.fn(); + beforeEach(() => { fetch.mockReset(); fetch.mockResolvedValue(successfulResponse()); vi.stubGlobal('fetch', fetch); }); + afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + + it('should leave submit enabled for feedback but let the native form reject a custom field error', async () => { + await act(async () => { render( 'Subject is rejected by the field'}> + command.subject} title='Subject' /> + + ); }); + const input = screen.getByRole('textbox', { name: 'Subject' }); + fireEvent.change(input, { target: { value: 'Passes the command validator' } }); + await act(async () => { fireEvent.blur(input); }); + (await screen.findAllByText('Subject is rejected by the field')).length.should.be.greaterThan(0); + const button = screen.getByRole('button', { name: 'Record note' }); + button.disabled.should.equal(false); + await act(async () => { fireEvent.click(button); }); + fetch.mock.calls.should.have.lengthOf(0); + }); +}); diff --git a/Source/JavaScript/components/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.tsx b/Source/JavaScript/components/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.tsx new file mode 100644 index 00000000..d260f250 --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneCommandForm/when_preserving_guid_error_ownership.tsx @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { CommandForm, useCommandFormContext } from '@cratis/arc.react/commands'; +import { Guid } from '@cratis/fundamentals'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; +import { ExplicitCommandField } from '../ExplicitCommandField'; +import { StartProject } from './given/StartProject'; + +class OptionalProject extends StartProject { + readonly propertyDescriptors = [new PropertyDescriptor('projectId', Guid, true), new PropertyDescriptor('name', String)]; +} + +const input = { property: 'projectId', type: 'guid' as const, label: 'Project ID' }; + +describe('native explicit Guid field error ownership', () => { + afterEach(cleanup); + + it('leaves a caller-owned error intact when the invalid field unmounts in the same event', async () => { + let context!: ReturnType; + function Capture() { context = useCommandFormContext(); return null; } + function form(show: boolean) { + return {show && }; + } + const result = render(form(true)); + fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: 'bad' } }); + context.getFieldError('projectId')!.should.equal('Project ID must be a valid GUID'); + await act(async () => { + context.setCustomFieldError('projectId', 'Caller-owned error'); + result.rerender(form(false)); + }); + context.getFieldError('projectId')!.should.equal('Caller-owned error'); + }); + + it('clears its own malformed optional Guid error on unmount', async () => { + let context!: ReturnType; + function Capture() { context = useCommandFormContext(); return null; } + function form(show: boolean) { + return {show && }; + } + const result = render(form(true)); + await act(async () => { + fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: 'bad' } }); + context.getFieldError('projectId')!.should.equal('Project ID must be a valid GUID'); + result.rerender(form(false)); + }); + (Object.hasOwn(context.customFieldErrors, 'projectId')).should.equal(false); + }); +}); diff --git a/Source/JavaScript/components/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.tsx b/Source/JavaScript/components/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.tsx new file mode 100644 index 00000000..109aec3e --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneCommandForm/when_rendering_submit_in_native_context.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import { CommandForm } from '@cratis/arc.react/commands'; +import { InputTextField } from '@cratis/components/CommandForm'; +import { CommandFormSubmit } from '../CommandFormSubmit'; +import { RecordNote, RestrictedNote, successfulResponse } from './given/RecordNote'; + +// Exercise the private child with REAL Arc context. This does not claim AutoCommandForm footer support. +describe('when rendering the submit child inside native CommandForm', () => { + const fetch = vi.fn(); + beforeEach(() => { fetch.mockReset(); fetch.mockResolvedValue(successfulResponse()); vi.stubGlobal('fetch', fetch); }); + afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + + it('should let native submit expose invalid untouched fields without HTTP', async () => { + await act(async () => { render( + command.subject} title='Subject' /> + + ); }); + const button = screen.getByRole('button', { name: 'Record note' }); + button.disabled.should.equal(false); + await act(async () => { fireEvent.click(button); }); + (await screen.findAllByText('Subject is required')).length.should.be.greaterThan(0); + fetch.mock.calls.should.have.lengthOf(0); + }); + + it('should follow native busy state through completion without executing directly', async () => { + let complete!: (response: Response) => void; + fetch.mockReturnValueOnce(new Promise(resolve => { complete = resolve; })); + await act(async () => { render( + + ); }); + const button = screen.getByRole('button', { name: 'Record note' }); + button.disabled.should.equal(false); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => button.disabled.should.equal(true)); + button.getAttribute('aria-busy')!.should.equal('true'); + fireEvent.click(button); + fetch.mock.calls.should.have.lengthOf(1); + await act(async () => { complete(successfulResponse()); }); + await waitFor(() => button.disabled.should.equal(false)); + button.getAttribute('aria-busy')!.should.equal('false'); + }); + + it('should respect native role authorization instead of deriving it in Scene', async () => { + await act(async () => { render( + + ); }); + const button = screen.getByRole('button', { name: 'Record note' }); + button.disabled.should.equal(true); + fireEvent.click(button); + fetch.mock.calls.should.have.lengthOf(0); + }); +}); diff --git a/Source/JavaScript/components/forms/for_SceneCommandForm/when_submitting_explicit_inputs.tsx b/Source/JavaScript/components/forms/for_SceneCommandForm/when_submitting_explicit_inputs.tsx new file mode 100644 index 00000000..80daf0e0 --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneCommandForm/when_submitting_explicit_inputs.tsx @@ -0,0 +1,189 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import { ArcContext } from '@cratis/arc.react'; +import { SceneElementView } from '@cratis/scene.react'; +import { clearBindings, registerCommand } from '../../bindings'; +import { cratisComponents } from '../../cratisComponents'; +import { externalComponent } from '../../given'; +import { AmbiguousProject, OptionalUnsupported, RequiredUnsupported, RestrictedProject, StartProject } from './given/StartProject'; +import { RecordNote, successfulResponse } from './given/RecordNote'; + +const inputs = [{ property: 'projectId', type: 'guid', label: 'Project ID' }, { property: 'name', type: 'string', label: 'Name' }]; +const guid = 'AABBCCDD-1234-5678-9ABC-001122334455'; + +function view(properties: Record = {}, explicit = true) { + return ({ 'x-host': 'scene' }) }}> + undefined} /> + ; +} + +describe('explicit Scene command form using native Arc proxy and execution', () => { + const fetch = vi.fn(); + beforeEach(() => { clearBindings(); registerCommand('StartProject', StartProject); fetch.mockReset(); fetch.mockResolvedValue(successfulResponse()); vi.stubGlobal('fetch', fetch); }); + afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); }); + + it('rejects untouched, blank and malformed GUIDs, and blank name without HTTP; corrected fields submit the typed payload', async () => { + render(view()); + const button = await screen.findByRole('button', { name: 'Create project' }); + const id = screen.getByRole('textbox', { name: 'Project ID' }); + const name = screen.getByRole('textbox', { name: 'Name' }); + (button.form === id.closest('form')).should.equal(true); + await act(async () => { fireEvent.submit(button.form!); }); + (await screen.findAllByText('Project ID is required')).length.should.be.greaterThan(0); + fetch.mock.calls.should.have.lengthOf(0); + fireEvent.change(id, { target: { value: 'not-a-guid' } }); + fireEvent.change(name, { target: { value: '' } }); + await act(async () => { fireEvent.submit(button.form!); }); + (await screen.findAllByText('Project ID must be a valid GUID')).length.should.be.greaterThan(0); + fetch.mock.calls.should.have.lengthOf(0); + fireEvent.change(id, { target: { value: guid } }); + await act(async () => { fireEvent.submit(button.form!); }); + (await screen.findAllByText('Name is required')).length.should.be.greaterThan(0); + fetch.mock.calls.should.have.lengthOf(0); + fireEvent.change(name, { target: { value: ' My Project ' } }); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(1)); + const [url, request] = fetch.mock.calls[0]; + String(url).should.equal('https://example.test/backend/synthetic/projects'); + request!.method!.should.equal('POST'); + JSON.parse(String(request!.body)).should.deep.equal({ projectId: guid.toLowerCase(), name: ' My Project ' }); + new Headers(request!.headers).get('x-host')!.should.equal('scene'); + new Headers(request!.headers).get('x-cratis-microservice')!.should.equal('projects'); + fireEvent.change(id, { target: { value: 'broken again' } }); + await act(async () => { fireEvent.submit(button.form!); }); + fetch.mock.calls.should.have.lengthOf(1); + }); + + it('keeps a typed Guid valid across a label change and executes exactly once', async () => { + const result = render(view()); + await screen.findByRole('button', { name: 'Create project' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: guid } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Renamed label' } }); + result.rerender(view({ inputs: [{ ...inputs[0], label: 'Project identifier' }, inputs[1]] })); + const id = screen.getByRole('textbox', { name: 'Project identifier' }); + (id as HTMLInputElement).value.should.equal(guid); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Create project' })); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(1)); + JSON.parse(String(fetch.mock.calls[0][1]!.body)).should.deep.equal({ projectId: guid.toLowerCase(), name: 'Renamed label' }); + }); + + it('keeps a malformed Guid blocked when its label changes, then submits the corrected typed value', async () => { + const result = render(view()); + await screen.findByRole('button', { name: 'Create project' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'After correction' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: 'not-a-guid' } }); + (await screen.findAllByText('Project ID must be a valid GUID')).length.should.be.greaterThan(0); + result.rerender(view({ inputs: [{ ...inputs[0], label: 'Project identifier' }, inputs[1]] })); + const id = screen.getByRole('textbox', { name: 'Project identifier' }); + id.value.should.equal('not-a-guid'); + (await screen.findAllByText('Project identifier must be a valid GUID')).length.should.be.greaterThan(0); + (screen.queryByText('Project ID must be a valid GUID') === null).should.equal(true); + await act(async () => { fireEvent.submit(id.closest('form')!); }); + fetch.mock.calls.should.have.lengthOf(0); + fireEvent.change(id, { target: { value: guid } }); + await act(async () => { fireEvent.submit(id.closest('form')!); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(1)); + JSON.parse(String(fetch.mock.calls[0][1]!.body)).should.deep.equal({ projectId: guid.toLowerCase(), name: 'After correction' }); + }); + + it('remounts the native explicit proxy on a binding switch and same-name hot replacement', async () => { + class MovedProject extends StartProject { readonly route = '/synthetic/moved-projects'; } + class HotProject extends StartProject { readonly route = '/synthetic/hot-projects'; } + registerCommand('MovedProject', MovedProject); + const result = render(view()); + await screen.findByRole('button', { name: 'Create project' }); + result.rerender(view({ command: 'MovedProject' })); + await screen.findByRole('textbox', { name: 'Project ID' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: guid } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Moved' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Create project' })); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(1)); + String(fetch.mock.calls[0][0]).should.equal('https://example.test/backend/synthetic/moved-projects'); + registerCommand('MovedProject', RestrictedProject); + result.rerender(view({ command: 'MovedProject' })); + const denied = await screen.findByRole('button', { name: 'Create project' }); + denied.disabled.should.equal(true); + fireEvent.click(denied); + fetch.mock.calls.should.have.lengthOf(1); + registerCommand('MovedProject', HotProject); + result.rerender(view({ command: 'MovedProject' })); + const allowed = await screen.findByRole('button', { name: 'Create project' }); + allowed.disabled.should.equal(false); + fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: guid } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Hot' } }); + await act(async () => { fireEvent.click(allowed); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(2)); + String(fetch.mock.calls[1][0]).should.equal('https://example.test/backend/synthetic/hot-projects'); + }); + + it('uses native authorization and executing state', async () => { + registerCommand('StartProject', RestrictedProject); + render(view()); + const button = await screen.findByRole('button', { name: 'Create project' }); + button.disabled.should.equal(true); + fireEvent.click(button); + fetch.mock.calls.should.have.lengthOf(0); + }); + + it('reflects native execution state and blocks a second button click while pending', async () => { + let complete!: (response: Response) => void; + fetch.mockReturnValueOnce(new Promise(resolve => { complete = resolve; })); + render(view()); + const button = await screen.findByRole('button', { name: 'Create project' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Project ID' }), { target: { value: guid } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'Pending' } }); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => button.disabled.should.equal(true)); + button.getAttribute('aria-busy')!.should.equal('true'); + fireEvent.click(button); + fetch.mock.calls.should.have.lengthOf(1); + await act(async () => { complete(successfulResponse()); }); + await waitFor(() => button.disabled.should.equal(false)); + }); + + it('blocks malformed declarations, unknown names, mismatches, uncovered and ambiguous required descriptors', async () => { + const invalid = [ + { inputs: [] }, { inputs: [{ property: 'name', type: 'string', label: 'Name' }, { property: 'name', type: 'string', label: 'Again' }] }, + { inputs: [{ property: 'PROJECTID', type: 'guid', label: 'ID' }, inputs[1]] }, + { inputs: [{ ...inputs[0], type: 'string' }, inputs[1]] }, + { inputs: [inputs[1]] }, { inputs: [{ ...inputs[0], type: 'number' }, inputs[1]] }, + ]; + for (const properties of invalid) { + const result = render(view(properties)); + (await screen.findByRole('alert')).textContent!.length.should.be.greaterThan(0); + (screen.queryByRole('button', { name: 'Create project' }) === null).should.equal(true); + result.unmount(); + } + registerCommand('StartProject', AmbiguousProject); + render(view()); + (await screen.findByRole('alert')).textContent!.should.contain('Ambiguous'); + fetch.mock.calls.should.have.lengthOf(0); + }); + + it('accepts optional unsupported descriptors when unbound but rejects required unsupported descriptors', async () => { + registerCommand('StartProject', OptionalUnsupported); + const result = render(view()); + Boolean(await screen.findByRole('button', { name: 'Create project' })).should.equal(true); + result.unmount(); + registerCommand('StartProject', RequiredUnsupported); + render(view()); + (await screen.findByRole('alert')).textContent!.should.contain("Required command property 'details'"); + }); + + it('refuses automatic forms with required GUID rather than silently dropping it', async () => { + render(view({}, false)); + (await screen.findByRole('alert')).textContent!.should.contain("Required command property 'projectId'"); + fetch.mock.calls.should.have.lengthOf(0); + }); + + it('does not allow auto exclusions to hide required supported fields', async () => { + registerCommand('StartProject', RecordNote); + render(view({ exclude: ['subject'] }, false)); + (await screen.findByRole('alert')).textContent!.should.contain("Required command property 'subject'"); + fetch.mock.calls.should.have.lengthOf(0); + }); +}); diff --git a/Source/JavaScript/components/forms/for_SceneCommandForm/when_submitting_the_native_form.tsx b/Source/JavaScript/components/forms/for_SceneCommandForm/when_submitting_the_native_form.tsx new file mode 100644 index 00000000..74947d52 --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneCommandForm/when_submitting_the_native_form.tsx @@ -0,0 +1,120 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import { ArcContext } from '@cratis/arc.react'; +import { SceneElementView } from '@cratis/scene.react'; +import { clearBindings, registerCommand } from '../../bindings'; +import { cratisComponents } from '../../cratisComponents'; +import { externalComponent } from '../../given'; +import { RecordNote, RestrictedNote, successfulResponse } from './given/RecordNote'; + +function view(properties: Record = {}) { + return ({ 'x-host': 'scene' }) }}> + undefined} /> + ; +} + +// No Components/form/context doubles. Components <=4.12 ignores footer: these tests MUST fail there, +// not skip or pass by recreating the forthcoming upstream behavior. Only HTTP is substituted. +describe('when submitting a Scene command form through native AutoCommandForm', () => { + const fetch = vi.fn(); + + beforeEach(() => { + clearBindings(); + registerCommand('RecordNote', RecordNote); + fetch.mockReset(); + fetch.mockResolvedValue(successfulResponse()); + vi.stubGlobal('fetch', fetch); + }); + + afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); }); + + it('should submit the actual form with a labeled button and preserve native payload and context', async () => { + render(view({ submitLabel: 'Record note' })); + const button = await screen.findByRole('button', { name: 'Record note' }); + button.type.should.equal('submit'); + (button.form === screen.getByRole('textbox', { name: 'Subject' }).closest('form')).should.equal(true); + screen.queryAllByRole('textbox').should.have.lengthOf(1); + fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'A synthetic note' } }); + fetch.mock.calls.should.have.lengthOf(0); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(1)); + const [url, request] = fetch.mock.calls[0]; + String(url).should.equal('https://example.test/backend/synthetic/notes'); + request!.method!.should.equal('POST'); + JSON.parse(String(request!.body)).should.deep.equal({ subject: 'A synthetic note', internalValue: 'proxy default' }); + new Headers(request!.headers).get('x-host')!.should.equal('scene'); + new Headers(request!.headers).get('x-cratis-microservice')!.should.equal('notes'); + }); + + it('should allow untouched invalid submit to reveal native feedback and correction to execute', async () => { + render(view()); + const button = await screen.findByRole('button', { name: 'Submit' }); + button.disabled.should.equal(false); + (screen.queryByText('Subject is required') === null).should.equal(true); + await act(async () => { fireEvent.click(button); }); + (await screen.findAllByText('Subject is required')).length.should.be.greaterThan(0); + fetch.mock.calls.should.have.lengthOf(0); + button.disabled.should.equal(false); + fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Corrected' } }); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(1)); + }); + + it('should reflect native execution until HTTP settles and prevent a second button click', async () => { + let complete!: (response: Response) => void; + fetch.mockReturnValueOnce(new Promise(resolve => { complete = resolve; })); + render(view()); + const button = await screen.findByRole('button', { name: 'Submit' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Pending' } }); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => button.disabled.should.equal(true)); + button.getAttribute('aria-busy')!.should.equal('true'); + fireEvent.click(button); + fetch.mock.calls.should.have.lengthOf(1); + await act(async () => { complete(successfulResponse()); }); + await waitFor(() => button.disabled.should.equal(false)); + button.getAttribute('aria-busy')!.should.equal('false'); + }); + + it('remounts the automatic native form on binding switches and same-name hot replacement', async () => { + class MovedNote extends RecordNote { readonly route = '/synthetic/moved-notes'; } + class HotNote extends RecordNote { readonly route = '/synthetic/hot-notes'; } + registerCommand('MovedNote', MovedNote); + const result = render(view()); + await screen.findByRole('button', { name: 'Submit' }); + result.rerender(view({ command: 'MovedNote' })); + await screen.findByRole('textbox', { name: 'Subject' }); + fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Moved' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Submit' })); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(1)); + String(fetch.mock.calls[0][0]).should.equal('https://example.test/backend/synthetic/moved-notes'); + registerCommand('MovedNote', RestrictedNote); + result.rerender(view({ command: 'MovedNote' })); + const denied = await screen.findByRole('button', { name: 'Submit' }); + denied.disabled.should.equal(true); + fireEvent.click(denied); + fetch.mock.calls.should.have.lengthOf(1); + registerCommand('MovedNote', HotNote); + result.rerender(view({ command: 'MovedNote' })); + const allowed = await screen.findByRole('button', { name: 'Submit' }); + allowed.disabled.should.equal(false); + fireEvent.change(screen.getByRole('textbox', { name: 'Subject' }), { target: { value: 'Hot' } }); + await act(async () => { fireEvent.click(allowed); }); + await waitFor(() => fetch.mock.calls.should.have.lengthOf(2)); + String(fetch.mock.calls[1][0]).should.equal('https://example.test/backend/synthetic/hot-notes'); + }); + + it('should disable the button when native command authorization denies the identity', async () => { + registerCommand('RecordNote', RestrictedNote); + render(view()); + const button = await screen.findByRole('button', { name: 'Submit' }); + button.disabled.should.equal(true); + fireEvent.click(button); + fetch.mock.calls.should.have.lengthOf(0); + }); +}); diff --git a/Source/JavaScript/components/package.json b/Source/JavaScript/components/package.json index 4f03f657..7bb05db1 100644 --- a/Source/JavaScript/components/package.json +++ b/Source/JavaScript/components/package.json @@ -58,9 +58,9 @@ "@cratis/scene.react": "1.0.0" }, "devDependencies": { - "@cratis/arc": "22.16.1", - "@cratis/arc.react": "22.16.1", - "@cratis/components": "^4.6.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", + "@cratis/components": "4.13.0", "@cratis/scene.engine": "1.0.0", "@cratis/scene.model": "1.0.0", "@cratis/scene.react": "1.0.0", @@ -73,9 +73,9 @@ "storybook": "^10.6.0" }, "peerDependencies": { - "@cratis/arc": "^22.16.1", - "@cratis/arc.react": "^22.16.1", - "@cratis/components": "^4.1.1", + "@cratis/arc": "^22.19.1", + "@cratis/arc.react": "^22.19.1", + "@cratis/components": "^4.13.0", "@cratis/fundamentals": "^7.19.2", "react": "^19.0.0", "react-dom": "^19.0.0"