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
32 changes: 28 additions & 4 deletions Documentation/components-package/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 17 additions & 1 deletion Source/JavaScript/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>;
peerDependenciesMeta: Record<string, { optional?: boolean }>;
};

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);
});
});
71 changes: 71 additions & 0 deletions Source/JavaScript/components/forms/CommandFormRuntime.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Descriptor>();
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 <div role='alert'>{error}</div>;
// The registry's never-argument constructor is erased; Arc creates this checked proxy with no args.
if (inputs) return <CommandForm command={command as unknown as new () => object} showTitles={false}>
{inputs.map(input => <ExplicitCommandField key={input.property} input={input} />)}
<CommandFormSubmit label={submitLabel} />
</CommandForm>;
return <NativeAutoCommandForm command={command} exclude={exclude} footer={<CommandFormSubmit label={submitLabel} />} />;
}
16 changes: 16 additions & 0 deletions Source/JavaScript/components/forms/CommandFormSubmit.tsx
Original file line number Diff line number Diff line change
@@ -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 <button type='submit' disabled={isExecuting || !isAuthorized} aria-busy={isExecuting}>{label}</button>;
}
86 changes: 86 additions & 0 deletions Source/JavaScript/components/forms/ExplicitCommandField.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(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 <div>
<label htmlFor={id}>{label}</label>
<input id={id} name={property} type='text' required={required} value={text}
aria-invalid={invalid} aria-describedby={errors.length ? `${id}-error` : undefined}
onBlur={onBlur} onChange={event => {
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 && <div id={`${id}-error`} role='alert'>{errors[0]}</div>}
</div>;
}

const NativeTextField = asCommandFormField<TextProps>(TextControl, { defaultValue: '' });

/** Opaque child is self-bound by Arc's native runtime binding, with an exact fieldName. */
export function ExplicitCommandField({ input }: { input: CommandInput }) {
return <NativeTextField fieldName={input.property} value={(command: Record<string, unknown>) => command[input.property]}
property={input.property} guid={input.type === 'guid'} label={input.label} title={input.label} />;
}
57 changes: 30 additions & 27 deletions Source/JavaScript/components/forms/SceneCommandForm.tsx
Original file line number Diff line number Diff line change
@@ -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<AutoCommandFormElementProps>,
}));
// 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<BoundConstructor, number>();
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 <MissingBinding element={element} kind={BindingKind.Command} name={name} />;

const inputs = Object.hasOwn(element.properties, 'inputs') ? commandInputs(element.properties) : undefined;
if (Object.hasOwn(element.properties, 'inputs') && !inputs) return <div role='alert'>Invalid command form inputs declaration</div>;

return (
<ArcRuntimeBoundary>
<AutoCommandForm command={target} exclude={stringArrayProperty(element.properties, 'exclude')} />
<CommandFormRuntime
key={commandKey(target)}
command={target}
inputs={inputs}
exclude={stringArrayProperty(element.properties, 'exclude')}
submitLabel={stringProperty(element.properties, 'submitLabel') ?? 'Submit'}
/>
</ArcRuntimeBoundary>
);
}
Loading
Loading