diff --git a/Documentation/CommandForm/auto-command-form.md b/Documentation/CommandForm/auto-command-form.md index a45caa5b..301e5ce4 100644 --- a/Documentation/CommandForm/auto-command-form.md +++ b/Documentation/CommandForm/auto-command-form.md @@ -25,9 +25,29 @@ A property whose type has no registered provider - a nested object, an array, an |------|------|---------|-------------| | `command` | `Constructor` | — | **Required.** The command type to generate fields for. | | `exclude` | `(keyof TCommand)[]` | — | Property names to leave out of the generated field list. | +| `footer` | `React.ReactNode` | — | Optional content after the generated fields, inside the native Arc form and command context. | `AutoCommandForm` also accepts every other `CommandForm` prop (`initialValues`, `populateFromQuery`, `onSuccess`, `validateOn`, and so on) except `children`, which it generates itself. +## Adding an action inside the form + +The default remains fields-only. Supply `footer` to add content or a native submit control: + +```tsx +import { AutoCommandForm } from '@cratis/components/CommandForm'; +import { SampleCommand } from './SampleCommand'; + +Submit} +/> +``` + +The button submits Arc's existing form; it does not create a second command or executor. +A component placed in `footer` can use Arc's `useCommandFormContext` for execution and +authorization state. `footer` takes React content, not a render callback, and adds no wrapper +or DOM-prop forwarding. Authorization and validation behavior remain Arc's responsibility. + ## Registering a field type provider The built-in providers cover `string`, `number`, `boolean` and `Date`. Register your own for any other property type - a Cratis concept, an enum, a custom value object - with `registerFieldTypeProvider`: diff --git a/Source/CommandForm/AutoCommandForm.tsx b/Source/CommandForm/AutoCommandForm.tsx index 9cc596c2..d5dddfc9 100644 --- a/Source/CommandForm/AutoCommandForm.tsx +++ b/Source/CommandForm/AutoCommandForm.tsx @@ -23,6 +23,12 @@ export interface AutoCommandFormProps< * be user-editable, or one a custom field placed elsewhere on the page already covers. */ exclude?: (keyof TCommand)[]; + + /** + * Optional content rendered after the generated fields, inside the native Arc form and + * command context. Supply a submit control here when needed; no action is added by default. + */ + footer?: React.ReactNode; } function formatTitle(propertyName: string): string { @@ -50,7 +56,7 @@ function formatTitle(propertyName: string): string { export function AutoCommandForm( props: AutoCommandFormProps, ): React.ReactElement { - const { exclude, ...commandFormProps } = props; + const { exclude, footer, ...commandFormProps } = props; // SAFETY: Arc command constructors expose the Command property-descriptor contract at runtime. const propertyDescriptors = useMemo( () => (new props.command() as unknown as Command).propertyDescriptors, @@ -88,5 +94,10 @@ export function AutoCommandForm field !== null); - return {fields}; + return ( + + {fields} + {footer} + + ); } diff --git a/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx b/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx new file mode 100644 index 00000000..9b667dab --- /dev/null +++ b/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx @@ -0,0 +1,114 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Command } from '@cratis/arc/commands'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; +import { useCommandFormContext } from '@cratis/arc.react/commands'; +import sinon from 'sinon'; +import type {} from 'chai/register-should'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { AutoCommandForm } from '../AutoCommandForm'; + +// An independently authored string-only command for the footer composition contract. +class SampleCommand extends Command { + readonly route = '/api/example-command'; + readonly propertyDescriptors = [new PropertyDescriptor('name', String)]; + name = 'Sample User'; + + get requestParameters(): string[] { return []; } + + constructor() { super(Object, false); } +} + +function Submit() { + const { commandInstance, isExecuting } = useCommandFormContext(); + return ; +} + +// Real React, Arc form, context, and executor; only the HTTP boundary is substituted. +describe('when composing a footer inside the automatic command form', () => { + let container: HTMLDivElement; + let root: Root; + let http: sinon.SinonStub, ReturnType>; + let submitted: SampleCommand | undefined; + + beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + submitted = undefined; + http = sinon.stub(globalThis, 'fetch').callsFake(async () => new Response(JSON.stringify({ + correlationId: '00000000-0000-0000-0000-000000000000', + isSuccess: true, + isAuthorized: true, + isValid: true, + hasExceptions: false, + validationResults: [], + exceptionMessages: [], + exceptionStackTrace: '', + authorizationFailureReason: '', + }), { status: 200 })); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + http.restore(); + }); + + const render = async (footer?: ReactNode) => { + await act(async () => root.render( + { submitted = command; return command; }} + />, + )); + }; + + it('should keep the default fields-only form without a submit control', async () => { + await render(); + container.querySelectorAll('form').length.should.equal(1); + container.querySelectorAll('input').length.should.equal(1); + container.querySelectorAll('button').length.should.equal(0); + http.callCount.should.equal(0); + }); + + it('should place the footer after the field inside the same native form and context', async () => { + await render(); + const form = container.querySelector('form')!; + const input = container.querySelector('input')!; + const button = container.querySelector('button')!; + (button.form === form).should.equal(true); + Boolean(input.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).should.equal(true); + button.textContent!.should.equal('Submit Sample User'); + container.querySelectorAll('form').length.should.equal(1); + }); + + it('should submit the edited string through the native command executor', async () => { + await render(); + const input = container.querySelector('input[aria-label="Name"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'Example Updated'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + container.querySelector('button')!.textContent!.should.equal('Submit Example Updated'); + await act(async () => container.querySelector('button[type="submit"]')!.click()); + http.callCount.should.equal(1); + submitted!.name.should.equal('Example Updated'); + new URL(String(http.firstCall.args[0])).pathname.should.equal('/api/example-command'); + JSON.parse(String(http.firstCall.args[1]!.body)).should.deep.equal({ name: 'Example Updated' }); + }); + + it('should allow non-action content without adding a submit control', async () => { + await render(

Example content

); + container.querySelector('form p')!.textContent!.should.equal('Example content'); + container.querySelectorAll('button').length.should.equal(0); + http.callCount.should.equal(0); + }); +});