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
53 changes: 51 additions & 2 deletions Source/JavaScript/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ unbound table costs one dashed box, not the whole screen.
### Arc is loaded lazily

`@cratis/arc`, `@cratis/arc.react` and `@cratis/fundamentals` are peer dependencies of `@cratis/components`
that the *host* supplies. They are not installed in this repository, and a design surface is not a host.
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.

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 @@ -87,6 +88,54 @@ 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.

## Optional single-result view (read-only)

`Cratis.Components:singleResult` is a narrow adapter for an optional **single model**, not a collection.
For example, a host registers its generated `ProjectName` proxy and supplies these ExternalComponent
properties (the identifier comes from the host's committed input, never a guessed project):

```typescript
registerQuery('ProjectName', ProjectName);
const properties = {
query: 'ProjectName',
queryArguments: Object.freeze({ projectId: committedProjectId }),
enabled: true,
resultField: 'name',
};
```

Use the actual own-field name of the generated model. `resultField` selects exactly one own data field:
no dotted-path traversal, getters, inherited members, expressions, JSON display or result-binding DSL.
Strings, finite numbers and booleans render as escaped text, including `0`, `false` and empty strings.
An absent/non-scalar field is a visible failure, not a missing model. A successful null/undefined model
is `Not found`; unsuccessful, unauthorized, invalid and exception envelopes are `Unable to load result`.
Idle, loading and failure states never display server exception messages or stacks.

`enabled` defaults to false and must be exactly true; the element must also be enabled. The host owns
input controls, validation feedback and committing/replacing immutable `queryArguments` objects. Even
parameterless queries require an explicit `{}`. Missing bindings take precedence over all other checks
and never import Arc. Disabled or malformed adapter properties do not import the lazy runtime either.
Native Arc `perform` owns required-parameter and generated-validator checks before HTTP; Scene adds no
business validation and forwards the argument object by identity.

The private lazy runtime creates a fresh native proxy per request generation, configures all four Arc
context connection/header values, and aborts on replacement or unmount. It also ignores late completion
when cancellation is ignored, masks previous results on the input-changing render, and responds to
header-callback identity and `queryVersion` changes. Hosts must replace the callback or bump the version
when credentials change invisibly inside a stable callback. There is no shared cache, command-triggered
refresh, polling, retry button, or observable-query subscription.

Published Arc 22.16.1 itself normalizes top-level falsy primitive payloads to null in `QueryResult`.
This adapter therefore promises scalar **fields of models**, not top-level primitive queries; it does
not fork Arc deserialization to recover those payloads. `isReady: false` is a failure for this one-shot
view rather than an invented retry protocol. Enumerable proxies are rejected before execution.

Tests use a real `QueryFor` subclass with unmodified `perform`, native validation and only the HTTP
boundary replaced. Separate lazy-boundary doubles test Arc-free gating, not native client correctness.
They do not prove a generated server route, backend persistence or end-to-end editable input. Scene #39
must remain open: editable Scene parameter binding, input/commit semantics, cross-component binding and
a general result-binding model are still unimplemented. No default profile, generator or schema changed.

## Naming and shadowing

Bare names are `lowerCamelCase` and abstract — `dataTable`, not `DataTableForQuery` — registered under
Expand Down Expand Up @@ -149,7 +198,7 @@ both packages write themes in one language: `primary.color`, `primary.contrastCo
```
bindings/ the registry, BindingKind, BoundConstructor, MissingBinding, ArcRuntimeBoundary
pages/ page, dataPage, formElement
data/ dataTable, table, observableDataTable
data/ dataTable, table, observableDataTable, singleResult
forms/ commandForm; forms/fields/ the twelve field types
dialogs/ dialog, confirmationDialog, busyIndicatorDialog, commandDialog, stepperCommandDialog
common/ icon, tooltip, dropdown, errorBoundary
Expand Down
40 changes: 40 additions & 0 deletions Source/JavaScript/components/bindings/ArcBoundComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// Runtime-only module: import dynamically beneath ArcRuntimeBoundary, never through the barrel.
import type { ComponentProps } from 'react';
import { QueryFor, ObservableQueryFor } from '@cratis/arc/queries';
import { Command } from '@cratis/arc/commands';
import { DataTableForQuery, DataTableForObservableQuery } from '@cratis/components/DataTables';
import { DataPage } from '@cratis/components/DataPage';
import { CommandDialog, StepperCommandDialog } from '@cratis/components/CommandDialog';
import type { BoundConstructor } from './BoundConstructor';
import { requireArcProxy } from './requireArcProxy';

type QueryProps<T> = Omit<T, 'query'> & { query: BoundConstructor };
type CommandProps<T> = Omit<T, 'command'> & { command: BoundConstructor };

// Scene carries property names and argument objects, not compile-time row schemas. These adapters
// support generated object/collection queries, as required by the Components public prop contracts.
export function BoundDataTable({ query, ...props }: QueryProps<ComponentProps<typeof DataTableForQuery>>) {
return <DataTableForQuery {...props} query={requireArcProxy(query, QueryFor<object, object>, 'query')} />;
}

export function BoundObservableDataTable({ query, ...props }: QueryProps<ComponentProps<typeof DataTableForObservableQuery>>) {
return <DataTableForObservableQuery {...props} query={requireArcProxy(query, ObservableQueryFor<object, object>, 'observable query')} />;
}

export function BoundDataPage({ query, ...props }: QueryProps<ComponentProps<typeof DataPage>>) {
const base = query.prototype instanceof ObservableQueryFor ? ObservableQueryFor<object, object> : QueryFor<object, object>;
// This chooses only the kind of the supplied constructor, never another registered binding.
const proxy = requireArcProxy<QueryFor<object, object> | ObservableQueryFor<object, object>>(query, base, 'query');
return <DataPage {...props} query={proxy} />;
}

export function BoundCommandDialog({ command, ...props }: CommandProps<ComponentProps<typeof CommandDialog>>) {
return <CommandDialog {...props} command={requireArcProxy(command, Command<object, object>, 'command')} />;
}

export function BoundStepperCommandDialog({ command, ...props }: CommandProps<ComponentProps<typeof StepperCommandDialog>>) {
return <StepperCommandDialog {...props} command={requireArcProxy(command, Command<object, object>, 'command')} />;
}
10 changes: 5 additions & 5 deletions Source/JavaScript/components/bindings/BoundConstructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
* `[ReadModel]` and `[Command]` types.
*
* `@cratis/components` types these as `Constructor<T>` from `@cratis/fundamentals`, constrained to
* `IQueryFor<T>` / a command shape. This package cannot use those types: `@cratis/arc` and
* `@cratis/fundamentals` are peer dependencies of `@cratis/components` that the *host* provides, and
* Scene deliberately does not depend on Arc - a Scene screen is a UI model, and the whole point of the
* registry is that Scene never needs to know what an Arc proxy is.
* `IQueryFor<T>` / a command shape. This registry intentionally does not use those types: optional
* Arc peers must not make an Arc-free preview load Arc. The lazy Components bridge validates native
* proxy inheritance and the zero-argument constructor convention before passing a binding on.
*
* So the registry stores the widest honest shape: something that can be constructed. Whether a
* registered class really is a query or a command is the host's responsibility, and it is checkable
* where the host registers it, with the real Arc types in scope - which is exactly where it should be.
* where the host registers it, with the real Arc types in scope. Arbitrary registrations remain usable
* by other consumers; the lazy Arc bridge rejects unsupported constructors visibly.
*
* `never[]` rather than `unknown[]` for the parameters so that any constructor is assignable, regardless
* of what it takes; `unknown[]` would only accept constructors whose parameters accept anything.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { QueryFor, ObservableQueryFor } from '@cratis/arc/queries';
import { Command } from '@cratis/arc/commands';

export class Snapshot extends QueryFor<object[], { projectId: string }> {
readonly route = '/projects';
readonly parameterDescriptors = [];
readonly requiredRequestParameters = ['projectId'];
defaultValue = [];
constructor() { super(Object, true); throw new Error('Must not construct during binding'); }
}

export class Live extends ObservableQueryFor<object[], object> {
readonly route = '/projects';
readonly parameterDescriptors = [];
readonly requiredRequestParameters = [];
readonly defaultValue = [];
constructor() { super(Object, true); throw new Error('Must not construct during binding'); }
}

export class Save extends Command {
readonly route = '/save';
readonly propertyDescriptors = [];
readonly requestParameters = [];
constructor() { super(Object, false); throw new Error('Must not construct during binding'); }
}
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 { act, cleanup, render, screen } from '@testing-library/react';
import { expect, vi } from 'vitest';
import { externalComponent } from '../../given';

// Keep the real Arc base classes; replace only the final Components renderers.
describe('when mounting bound adapters', () => {
const received = vi.fn<(props: Record<string, unknown>) => void>();
let bindings: typeof import('../bindingRegistry');
let proxies: typeof import('./NativeProxies');
let adapters: typeof import('../ArcBoundComponents');
let Boundary: typeof import('../ArcRuntimeBoundary').ArcRuntimeBoundary;

beforeEach(async () => {
vi.resetModules();
received.mockClear();
const component = (props: Record<string, unknown>) => { received(props); return <span>Mounted native adapter</span>; };
vi.doMock('@cratis/components/DataTables', () => ({ DataTableForQuery: component, DataTableForObservableQuery: component }));
vi.doMock('@cratis/components/DataPage', () => ({ DataPage: component }));
vi.doMock('@cratis/components/CommandDialog', () => ({ CommandDialog: component, StepperCommandDialog: component }));
bindings = await import('../bindingRegistry');
bindings.clearBindings();
proxies = await import('./NativeProxies');
adapters = await import('../ArcBoundComponents');
({ ArcRuntimeBoundary: Boundary } = await import('../ArcRuntimeBoundary'));
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
cleanup();
bindings.clearBindings();
vi.restoreAllMocks();
vi.doUnmock('@cratis/components/DataTables');
vi.doUnmock('@cratis/components/DataPage');
vi.doUnmock('@cratis/components/CommandDialog');
vi.resetModules();
});

it('should pass both supported page kinds and both command dialog constructors unchanged', () => {
const { BoundDataPage, BoundObservableDataTable, BoundCommandDialog, BoundStepperCommandDialog } = adapters;
render(<><BoundDataPage query={proxies.Snapshot} title='' emptyMessage='' children={undefined} />
<BoundDataPage query={proxies.Live} title='' emptyMessage='' children={undefined} />
<BoundObservableDataTable query={proxies.Live} emptyMessage='' />
<BoundCommandDialog command={proxies.Save} title='' visible />
<BoundStepperCommandDialog command={proxies.Save} title='' visible /></>);
expect(received.mock.calls.map(([props]) => props.query ?? props.command)).toEqual([proxies.Snapshot, proxies.Live, proxies.Live, proxies.Save, proxies.Save]);
});

it('should visibly reject a wrong-kind registered query without selecting another query', async () => {
bindings.registerQuery('wrong', proxies.Save);
bindings.registerQuery('valid', proxies.Snapshot);
const { SceneDataTable } = await import('../../data/SceneDataTable');
await act(async () => {
render(<SceneDataTable element={externalComponent('Cratis.Components:dataTable', { query: 'wrong' })} slots={{}} />);
});
expect(screen.getByText("Invalid query binding 'Save': expected a zero-argument Arc query proxy")).toBeTruthy();
expect(received).not.toHaveBeenCalled();
});

it('should visibly reject snapshot bindings in observable tables and query bindings in dialogs', () => {
const { BoundObservableDataTable, BoundCommandDialog, BoundStepperCommandDialog, BoundDataPage } = adapters;
render(<><Boundary><BoundObservableDataTable query={proxies.Snapshot} emptyMessage='' /></Boundary>
<Boundary><BoundCommandDialog command={proxies.Snapshot} title='' visible /></Boundary>
<Boundary><BoundStepperCommandDialog command={proxies.Live} title='' visible /></Boundary>
<Boundary><BoundDataPage query={proxies.Save} title='' emptyMessage='' children={undefined} /></Boundary></>);
expect(screen.getAllByText('Error')).toHaveLength(4);
expect(received).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -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 { act, cleanup, render, screen } from '@testing-library/react';
import { expect, vi } from 'vitest';
import { externalComponent } from '../../given';

describe('when previewing bound adapters without Arc', () => {
const loaded = vi.fn();

afterEach(() => {
cleanup();
vi.doUnmock('../ArcBoundComponents');
vi.resetModules();
});

it('should show missing bindings without importing the Arc runtime module', async () => {
vi.resetModules();
vi.doMock('../ArcBoundComponents', () => {
loaded();
throw new Error('Arc is unavailable in preview');
});
const { clearBindings } = await import('../bindingRegistry');
clearBindings();
const { SceneDataTable } = await import('../../data/SceneDataTable');
const { SceneObservableDataTable } = await import('../../data/SceneObservableDataTable');
const { SceneDataPage } = await import('../../pages/SceneDataPage');
const { SceneCommandDialog } = await import('../../dialogs/SceneCommandDialog');
const { SceneStepperCommandDialog } = await import('../../dialogs/SceneStepperCommandDialog');
await act(async () => {
render(<>{[SceneDataTable, SceneObservableDataTable, SceneDataPage, SceneCommandDialog, SceneStepperCommandDialog].map((Adapter, index) =>
<Adapter key={index} element={externalComponent('Cratis.Components:preview', { query: 'missing', command: 'missing' })} slots={{}} />)}</>);
});
expect(screen.getAllByText(/Unresolved .* binding 'missing'/)).toHaveLength(5);
expect(loaded).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { expect } from 'vitest';
import { QueryFor, ObservableQueryFor } from '@cratis/arc/queries';
import { Command } from '@cratis/arc/commands';
import type { ComponentProps } from 'react';
import type { DataTableForQuery, DataTableForObservableQuery } from '@cratis/components/DataTables';
import type { CommandDialog } from '@cratis/components/CommandDialog';
import { requireArcProxy } from '../requireArcProxy';
import { registerQuery, resolveQuery, clearBindings } from '../bindingRegistry';
import { Snapshot, Live, Save } from './NativeProxies';

describe('when validating a proxy at the lazy Arc boundary', () => {
afterEach(clearBindings);

it('should preserve native constructors without constructing them and satisfy public component props', () => {
const query: ComponentProps<typeof DataTableForQuery>['query'] = requireArcProxy(Snapshot, QueryFor<object, object>, 'query');
const observable: ComponentProps<typeof DataTableForObservableQuery>['query'] = requireArcProxy(Live, ObservableQueryFor<object, object>, 'observable query');
const command: ComponentProps<typeof CommandDialog>['command'] = requireArcProxy(Save, Command<object, object>, 'command');
expect(query).toBe(Snapshot);
expect(observable).toBe(Live);
expect(command).toBe(Save);
});

it('should reject wrong kinds rather than perform an observable query as a snapshot', () => {
expect(() => requireArcProxy(Live, QueryFor<object, object>, 'query')).toThrow('Invalid query binding');
expect(() => requireArcProxy(Snapshot, ObservableQueryFor<object, object>, 'observable query')).toThrow('Invalid observable query binding');
expect(() => requireArcProxy(Snapshot, Command<object, object>, 'command')).toThrow('Invalid command binding');
expect(() => requireArcProxy(Save, QueryFor<object, object>, 'query')).toThrow('Invalid query binding');
});

it('should preserve arbitrary constructor registration but reject it at an Arc adapter', () => {
class Arbitrary { constructor(readonly input: string) {} }
registerQuery('arbitrary', Arbitrary);
expect(resolveQuery('arbitrary')).toBe(Arbitrary);
expect(() => requireArcProxy(Arbitrary, QueryFor<object, object>, 'query')).toThrow('Invalid query binding');
});

it('should reject a native subclass requiring constructor arguments without invoking it', () => {
class NeedsArguments extends Snapshot { constructor(readonly input: string) { super(); } }
expect(() => requireArcProxy(NeedsArguments, QueryFor<object, object>, 'query')).toThrow('zero-argument Arc');
});
});
Loading
Loading