Skip to content
Open
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
59 changes: 59 additions & 0 deletions Documentation/CommandForm/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,62 @@ import { InputTextField, NumberField, CheckboxField } from '@cratis/components/C
<CheckboxField<MyCommand> value={c => c.active} label="Active" />
</CommandDialog>
```

## How a child is recognized as a field

`CommandForm`, `CommandDialog` and `CommandStepper` decide which of their children are
fields by inspecting the child's component type. A component is treated as a field when
it carries either of the following:

- an `isCommandFormField` property set to `true` — what `asCommandFormField` and
`markAsCommandFormField` stamp; or
- the legacy `displayName` of `'CommandFormField'`, which is checked as a fallback and
is supported indefinitely.

Columns work the same way, through `isCommandFormColumn` and `'CommandFormColumn'`.

> [!IMPORTANT]
> **`displayName` is load-bearing on field and column components — never overwrite it.**
> A child whose `displayName` has been replaced and that carries no marker is not
> recognized as a field: it renders without its container, so it gets no label, no bound
> value and no change handler. This fails silently — there is no error and no warning.

The marker exists so that this stops being fatal. A build-time transform rewrites
`displayName` and nothing else, so it leaves the marker alone and a field keeps working
even after being renamed. `@cratis/arc.react` marks and reads the same two properties, so a
component marked by either package is recognized by both.

### Build tooling that rewrites `displayName`

The most common way to hit this is Storybook's
`reactDocgen: 'react-docgen-typescript'` setting, whose underlying plugin defaults
`setDisplayName` to `true` and appends `<Component>.displayName = "<ExportName>"` to
every module it processes. If you wrap fields of your own with `asCommandFormField`,
turn that off:

```ts
// .storybook/main.ts
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
setDisplayName: false
}
}
```

### Writing a custom field

Prefer `asCommandFormField` from `@cratis/arc.react`, which marks the component for you.
To mark a component directly — when hand-rolling a field or a column — use the helpers:

```tsx
import { markAsCommandFormField } from '@cratis/components/CommandForm';

const MyField = (props: { value?: (c: MyCommand) => unknown }) => { /* ... */ };

markAsCommandFormField(MyField);
```

Both helpers set the marker *and* the legacy `displayName`, so a component marked this
way is recognized by every version of `@cratis/arc.react` within this package's
supported range.
38 changes: 37 additions & 1 deletion ESLint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Cratis base config, [`@cratis/eslint-config`](https://www.npmjs.com/package/@cra
| `no-primereact-dialog` | Disallows importing `Dialog` from `primereact/dialog`. Use `CommandDialog` from `@cratis/components/CommandDialog`, or `Dialog` from `@cratis/components/Dialogs` — the wrappers add Arc command binding, overlay/focus fixes, and theming. |
| `onbeforeexecute-must-return` | Requires an `onBeforeExecute` callback to return the command values. `onBeforeExecute` is a transformer — a body that can complete without returning executes the command with `undefined` (silent data loss). |
| `no-hooks-in-view-model` | Disallows React hooks (including generated Arc proxies' `.use()`) inside a view model class. View models must be plain, hook-free classes that receive injected abstractions. |
| `no-raw-command-form-marker` | Disallows identifying a CommandForm field or column by a hand-written `displayName` string, in either direction. Use `markAsCommandFormField`/`markAsCommandFormColumn` and `isCommandFormField`/`isCommandFormColumn` from `@cratis/components/CommandForm` — they go through a marker a build transform cannot rewrite. |

The two import rules cover `import` and re-`export … from` forms.

Expand Down Expand Up @@ -44,7 +45,7 @@ export default [
}],
```

`onbeforeexecute-must-return` and `no-hooks-in-view-model` take no options.
`onbeforeexecute-must-return`, `no-hooks-in-view-model` and `no-raw-command-form-marker` take no options.

## Rules

Expand Down Expand Up @@ -96,3 +97,38 @@ A class is treated as a view model when it is registered via `withViewModel(...)
with `@injectable`, or named `*ViewModel`. Both bare hooks (`useState`, `useIdentity`, …) and
proxy member hooks (`.use()`, `.useSuspense()`, `.useChangeStream()`) are flagged. Hooks
inside a nested non–view-model class are not.

### `no-raw-command-form-marker`

`CommandForm`, `CommandDialog` and `CommandStepper` decide which children are fields by
inspecting the child's component type. Historically that test was a single string
comparison against `displayName` — and `displayName` is React's public, writable
*diagnostic* name, a routine target for build tooling. Storybook's
`reactDocgen: 'react-docgen-typescript'` setting rewrites it by default.

When it is rewritten, the child stops being recognized as a field: it renders with no
container, so no label, no bound value and no change handler. There is no error and no
warning, and every gate stays green.

The helpers go through an `isCommandFormField` / `isCommandFormColumn` marker that a rename
does not touch, while still setting and honoring the legacy `displayName` — so they are strictly
more permissive than the literal, never less. `@cratis/arc.react` marks and reads the same two
properties, and that shared shape is what carries the contract across the two packages.

```ts
// ❌ a build transform that rewrites displayName silently unbinds this field
MyField.displayName = 'CommandFormField';
if (component.displayName === 'CommandFormField') { wrap(component); }

// ✅ marker first, legacy displayName still set and still honoured
import { markAsCommandFormField, isCommandFormField } from '@cratis/components/CommandForm';

markAsCommandFormField(MyField);
if (isCommandFormField(component)) { wrap(component); }
```

Flagged in both directions: assignment (`C.displayName = '…'`, including computed and
object-literal forms) and comparison (`===`, `!==`, either operand order). Referring to the
exported `CommandFormFieldDisplayName` / `CommandFormColumnDisplayName` constants is not
flagged, so the declarations themselves and any deliberate legacy-path code stay clean.
Prefer `asCommandFormField` from `@cratis/arc.react` where it applies — it marks for you.
5 changes: 4 additions & 1 deletion ESLint/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { noPrimereactDialog } from './lib/noPrimereactDialog.js';
import { noRootBarrelImport } from './lib/noRootBarrelImport.js';
import { onbeforeexecuteMustReturn } from './lib/onbeforeexecuteMustReturn.js';
import { noHooksInViewModel } from './lib/noHooksInViewModel.js';
import { noRawCommandFormMarker } from './lib/noRawCommandFormMarker.js';

const { version } = createRequire(import.meta.url)('./package.json');

Expand All @@ -17,6 +18,7 @@ const plugin = {
'no-root-barrel-import': noRootBarrelImport,
'onbeforeexecute-must-return': onbeforeexecuteMustReturn,
'no-hooks-in-view-model': noHooksInViewModel,
'no-raw-command-form-marker': noRawCommandFormMarker,
},
configs: {},
};
Expand All @@ -37,11 +39,12 @@ Object.assign(plugin.configs, {
'@cratis/components/no-root-barrel-import': 'error',
'@cratis/components/onbeforeexecute-must-return': 'error',
'@cratis/components/no-hooks-in-view-model': 'error',
'@cratis/components/no-raw-command-form-marker': 'error',
},
},
],
});

export default plugin;
export const { configs, rules, meta } = plugin;
export { noPrimereactDialog, noRootBarrelImport, onbeforeexecuteMustReturn, noHooksInViewModel };
export { noPrimereactDialog, noRootBarrelImport, onbeforeexecuteMustReturn, noHooksInViewModel, noRawCommandFormMarker };
89 changes: 89 additions & 0 deletions ESLint/lib/noRawCommandFormMarker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// The identifiers a CommandForm child is recognized by, and the helpers that own each one.
const MARKERS = {
CommandFormField: { mark: 'markAsCommandFormField', predicate: 'isCommandFormField' },
CommandFormColumn: { mark: 'markAsCommandFormColumn', predicate: 'isCommandFormColumn' },
};

const EQUALITY = new Set(['===', '!==', '==', '!=']);

// True for `x.displayName` and `x['displayName']`.
const isDisplayNameMember = node =>
node?.type === 'MemberExpression' &&
(node.computed
? node.property?.type === 'Literal' && node.property.value === 'displayName'
: node.property?.type === 'Identifier' && node.property.name === 'displayName');

const markerFor = node =>
node?.type === 'Literal' && typeof node.value === 'string'
? MARKERS[node.value]
: undefined;

// Disallow hand-writing the `displayName` strings that identify a CommandForm field or
// column, in either direction — stamping one onto a component, or comparing against one to
// decide what a child is.
//
// `displayName` is React's public, writable diagnostic name and a routine target for build
// tooling: Storybook's `reactDocgen: 'react-docgen-typescript'` setting rewrites it by
// default. A component identified only by that string stops being recognized the moment
// anything renames it, and it fails silently — the field simply renders with no container,
// so no label, no bound value and no change handler, with no error and no warning.
//
// The helpers in '@cratis/components/CommandForm' set and read an `isCommandFormField` /
// `isCommandFormColumn` marker that a rename does not touch, and keep the legacy `displayName`
// alongside it for compatibility, so going through them is both safer and strictly more
// permissive than the literal. `@cratis/arc.react` marks and reads the same two properties;
// that shared shape is what carries the contract between the packages.
export const noRawCommandFormMarker = {
meta: {
type: 'problem',
docs: {
description: 'Disallow identifying CommandForm fields and columns by a raw displayName string; use the marker helpers.',
recommended: true,
url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md',
},
schema: [],
messages: {
useMarkHelper:
"Do not stamp displayName = '{{name}}' by hand — a build transform that rewrites displayName silently unbinds this component. Use {{helper}}() from '@cratis/components/CommandForm'.",
usePredicate:
"Do not identify a CommandForm child by comparing displayName to '{{name}}' — a component whose displayName was rewritten is missed. Use {{helper}}() from '@cratis/components/CommandForm'.",
},
},
create(context) {
const report = (node, messageId, helper, name) =>
context.report({ node, messageId, data: { helper, name } });

return {
// C.displayName = 'CommandFormField'
AssignmentExpression(node) {
if (node.operator !== '=' || !isDisplayNameMember(node.left)) return;
const marker = markerFor(node.right);
if (marker) report(node, 'useMarkHelper', marker.mark, node.right.value);
},

// { displayName: 'CommandFormField' } — e.g. via Object.assign
Property(node) {
const key = node.computed ? undefined : node.key;
const named =
(key?.type === 'Identifier' && key.name === 'displayName') ||
(key?.type === 'Literal' && key.value === 'displayName');
if (!named) return;
const marker = markerFor(node.value);
if (marker) report(node, 'useMarkHelper', marker.mark, node.value.value);
},

// child.displayName === 'CommandFormField' (either operand order)
BinaryExpression(node) {
if (!EQUALITY.has(node.operator)) return;
const [member, literal] = isDisplayNameMember(node.left)
? [node.left, node.right]
: [node.right, node.left];
if (!isDisplayNameMember(member)) return;
const marker = markerFor(literal);
if (marker) report(node, 'usePredicate', marker.predicate, literal.value);
},
};
},
};

export default noRawCommandFormMarker;
2 changes: 1 addition & 1 deletion ESLint/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@cratis/eslint-plugin-components",
"version": "0.0.0",
"description": "Cratis Components ESLint rules: import from subpaths not the root barrel, use the Cratis dialog wrappers instead of primereact/dialog, require onBeforeExecute callbacks to return their values, and keep React hooks out of view models. Compose on top of @cratis/eslint-config.",
"description": "Cratis Components ESLint rules: import from subpaths not the root barrel, use the Cratis dialog wrappers instead of primereact/dialog, require onBeforeExecute callbacks to return their values, keep React hooks out of view models, and identify CommandForm fields by a tamper-resistant marker rather than a raw displayName string. Compose on top of @cratis/eslint-config.",
"author": "Cratis",
"license": "MIT",
"type": "module",
Expand Down
64 changes: 64 additions & 0 deletions ESLint/test/rules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { noPrimereactDialog } from '../lib/noPrimereactDialog.js';
import { noRootBarrelImport } from '../lib/noRootBarrelImport.js';
import { onbeforeexecuteMustReturn } from '../lib/onbeforeexecuteMustReturn.js';
import { noHooksInViewModel } from '../lib/noHooksInViewModel.js';
import { noRawCommandFormMarker } from '../lib/noRawCommandFormMarker.js';

RuleTester.afterAll = afterAll;
RuleTester.describe = describe;
Expand Down Expand Up @@ -142,3 +143,66 @@ tsRuleTester.run('no-hooks-in-view-model', noHooksInViewModel, {
},
],
});

tsRuleTester.run('no-raw-command-form-marker', noRawCommandFormMarker, {
valid: [
// The sanctioned way to mark and to test, in both directions.
"markAsCommandFormField(MyField);",
"markAsCommandFormColumn(MyColumn);",
"if (isCommandFormField(component)) { wrap(component); }",
"if (isCommandFormColumn(component)) { layout(component); }",
// Referring to the exported constant rather than repeating the literal.
"MyField.displayName = CommandFormFieldDisplayName;",
"if (component.displayName === CommandFormFieldDisplayName) { wrap(component); }",
// Declaring the constants themselves — the one place the literal belongs.
"export const CommandFormFieldDisplayName = 'CommandFormField';",
"export const CommandFormColumnDisplayName = 'CommandFormColumn';",
// displayName used as the diagnostic label it is meant to be.
"MyDialog.displayName = 'MyDialog';",
"StepperPanel.displayName = 'StepperPanel';",
"const label = `DialogWrapper(${Component.displayName})`;",
// A different property that happens to hold the same string.
"const meta = { kind: 'CommandFormField' };",
// Comparing a non-displayName property.
"if (component.name === 'CommandFormField') { legacy(component); }",
],
invalid: [
{
code: "MyField.displayName = 'CommandFormField';",
errors: [{ messageId: 'useMarkHelper', data: { helper: 'markAsCommandFormField', name: 'CommandFormField' } }],
},
{
code: "MyColumn.displayName = 'CommandFormColumn';",
errors: [{ messageId: 'useMarkHelper', data: { helper: 'markAsCommandFormColumn', name: 'CommandFormColumn' } }],
},
{
// Computed member access is the same stamp.
code: "MyField['displayName'] = 'CommandFormField';",
errors: [{ messageId: 'useMarkHelper', data: { helper: 'markAsCommandFormField', name: 'CommandFormField' } }],
},
{
// Set through an object literal, e.g. Object.assign.
code: "Object.assign(MyField, { displayName: 'CommandFormField' });",
errors: [{ messageId: 'useMarkHelper', data: { helper: 'markAsCommandFormField', name: 'CommandFormField' } }],
},
{
code: "if (component.displayName === 'CommandFormField') { wrap(component); }",
errors: [{ messageId: 'usePredicate', data: { helper: 'isCommandFormField', name: 'CommandFormField' } }],
},
{
// Reversed operand order.
code: "if ('CommandFormColumn' === component.displayName) { layout(component); }",
errors: [{ messageId: 'usePredicate', data: { helper: 'isCommandFormColumn', name: 'CommandFormColumn' } }],
},
{
// Negated comparison misses a renamed field just as badly.
code: "if (component.displayName !== 'CommandFormField') { return child; }",
errors: [{ messageId: 'usePredicate', data: { helper: 'isCommandFormField', name: 'CommandFormField' } }],
},
{
// The cast form this package used before the marker existed.
code: "if ((component as { displayName?: string }).displayName === 'CommandFormField') { wrap(component); }",
errors: [{ messageId: 'usePredicate', data: { helper: 'isCommandFormField', name: 'CommandFormField' } }],
},
],
});
5 changes: 3 additions & 2 deletions Source/CommandDialog/CommandDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type CommandFormProps
} from '@cratis/arc.react/commands';
import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute';
import { isCommandFormField, markAsCommandFormColumn } from '../CommandForm/commandFormMarkers';

/**
* Props for {@link CommandDialog}. Combines the props of a `CommandForm`
Expand Down Expand Up @@ -148,7 +149,7 @@ const CommandDialogWrapper = <TCommand extends object, TResponse = object>({
if (!React.isValidElement(child)) return child;

const component = child.type as React.ComponentType<unknown>;
if (component.displayName === 'CommandFormField') {
if (isCommandFormField(component)) {
type FieldElement = Parameters<typeof CommandFormFieldWrapper>[0]['field'];
return <CommandFormFieldWrapper field={child as unknown as FieldElement} />;
}
Expand Down Expand Up @@ -349,7 +350,7 @@ const CommandDialogComponent = <TCommand extends object = object, TResponse = ob
const CommandDialogColumnWrapper = ({ children }: { children: React.ReactNode }) => (
<CommandForm.Column>{children}</CommandForm.Column>
);
CommandDialogColumnWrapper.displayName = 'CommandFormColumn';
markAsCommandFormColumn(CommandDialogColumnWrapper);

CommandDialogComponent.Column = CommandDialogColumnWrapper;

Expand Down
5 changes: 3 additions & 2 deletions Source/CommandDialog/CommandStepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type CommandFormProps
} from '@cratis/arc.react/commands';
import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute';
import { isCommandFormField } from '../CommandForm/commandFormMarkers';
import type { StepperPanelProps } from './StepperPanel';
import './CommandStepper.css';

Expand Down Expand Up @@ -145,7 +146,7 @@ const extractFieldNamesFromNode = (nodes: React.ReactNode): string[] => {
React.Children.forEach(nodes, (child) => {
if (!React.isValidElement(child)) return;
const component = child.type as React.ComponentType<unknown>;
if ((component as { displayName?: string }).displayName === 'CommandFormField') {
if (isCommandFormField(component)) {
const fieldProps = child.props as { value?: (obj: unknown) => unknown };
const name = getPropertyName(fieldProps.value);
if (name) names.push(name);
Expand All @@ -164,7 +165,7 @@ const processChildren = (nodes: React.ReactNode): React.ReactNode => {
if (!React.isValidElement(child)) return child;

const component = child.type as React.ComponentType<unknown>;
if ((component as { displayName?: string }).displayName === 'CommandFormField') {
if (isCommandFormField(component)) {
type FieldElement = Parameters<typeof CommandFormFieldWrapper>[0]['field'];
return <CommandFormFieldWrapper field={child as unknown as FieldElement} />;
}
Expand Down
Loading
Loading