Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Documentation/CommandForm/auto-command-form.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,29 @@ A property whose type has no registered provider - a nested object, an array, an
|------|------|---------|-------------|
| `command` | `Constructor<TCommand>` | — | **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';

<AutoCommandForm
command={SampleCommand}
footer={<button type='submit'>Submit</button>}
/>
```

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`:
Expand Down
15 changes: 13 additions & 2 deletions Source/CommandForm/AutoCommandForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,7 +56,7 @@ function formatTitle(propertyName: string): string {
export function AutoCommandForm<TCommand extends object = object, TResponse = object>(
props: AutoCommandFormProps<TCommand, TResponse>,
): 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,
Expand Down Expand Up @@ -88,5 +94,10 @@ export function AutoCommandForm<TCommand extends object = object, TResponse = ob
})
.filter((field): field is React.ReactElement => field !== null);

return <CommandForm {...commandFormProps}>{fields}</CommandForm>;
return (
<CommandForm {...commandFormProps}>
{fields}
{footer}
</CommandForm>
);
}
114 changes: 114 additions & 0 deletions Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx
Original file line number Diff line number Diff line change
@@ -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<SampleCommand>();
return <button type='submit' disabled={isExecuting}>Submit {commandInstance.name}</button>;
}

// 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<Parameters<typeof fetch>, ReturnType<typeof fetch>>;
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(
<AutoCommandForm
command={SampleCommand}
footer={footer}
onBeforeExecute={(command) => { 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(<Submit />);
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(<Submit />);
const input = container.querySelector<HTMLInputElement>('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<HTMLButtonElement>('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(<p>Example content</p>);
container.querySelector('form p')!.textContent!.should.equal('Example content');
container.querySelectorAll('button').length.should.equal(0);
http.callCount.should.equal(0);
});
});
Loading