diff --git a/Source/JavaScript/components/README.md b/Source/JavaScript/components/README.md index 97536cd6..1242b639 100644 --- a/Source/JavaScript/components/README.md +++ b/Source/JavaScript/components/README.md @@ -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 @@ -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 @@ -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 diff --git a/Source/JavaScript/components/bindings/ArcBoundComponents.tsx b/Source/JavaScript/components/bindings/ArcBoundComponents.tsx new file mode 100644 index 00000000..859ff1c7 --- /dev/null +++ b/Source/JavaScript/components/bindings/ArcBoundComponents.tsx @@ -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 = Omit & { query: BoundConstructor }; +type CommandProps = Omit & { 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>) { + return , 'query')} />; +} + +export function BoundObservableDataTable({ query, ...props }: QueryProps>) { + return , 'observable query')} />; +} + +export function BoundDataPage({ query, ...props }: QueryProps>) { + const base = query.prototype instanceof ObservableQueryFor ? ObservableQueryFor : QueryFor; + // This chooses only the kind of the supplied constructor, never another registered binding. + const proxy = requireArcProxy | ObservableQueryFor>(query, base, 'query'); + return ; +} + +export function BoundCommandDialog({ command, ...props }: CommandProps>) { + return , 'command')} />; +} + +export function BoundStepperCommandDialog({ command, ...props }: CommandProps>) { + return , 'command')} />; +} diff --git a/Source/JavaScript/components/bindings/BoundConstructor.ts b/Source/JavaScript/components/bindings/BoundConstructor.ts index e97c1c8c..3d6cb592 100644 --- a/Source/JavaScript/components/bindings/BoundConstructor.ts +++ b/Source/JavaScript/components/bindings/BoundConstructor.ts @@ -6,14 +6,14 @@ * `[ReadModel]` and `[Command]` types. * * `@cratis/components` types these as `Constructor` from `@cratis/fundamentals`, constrained to - * `IQueryFor` / 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` / 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. diff --git a/Source/JavaScript/components/bindings/for_requireArcProxy/NativeProxies.ts b/Source/JavaScript/components/bindings/for_requireArcProxy/NativeProxies.ts new file mode 100644 index 00000000..eee27403 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/NativeProxies.ts @@ -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 { + 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 { + 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'); } +} diff --git a/Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx b/Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx new file mode 100644 index 00000000..6759716c --- /dev/null +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx @@ -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) => 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) => { received(props); return Mounted native adapter; }; + 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(<> + + + + ); + 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(); + }); + 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(<> + + + ); + expect(screen.getAllByText('Error')).toHaveLength(4); + expect(received).not.toHaveBeenCalled(); + }); +}); diff --git a/Source/JavaScript/components/bindings/for_requireArcProxy/when_previewing_without_arc.tsx b/Source/JavaScript/components/bindings/for_requireArcProxy/when_previewing_without_arc.tsx new file mode 100644 index 00000000..32791272 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/when_previewing_without_arc.tsx @@ -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) => + )}); + }); + expect(screen.getAllByText(/Unresolved .* binding 'missing'/)).toHaveLength(5); + expect(loaded).not.toHaveBeenCalled(); + }); +}); diff --git a/Source/JavaScript/components/bindings/for_requireArcProxy/when_validating_a_proxy.ts b/Source/JavaScript/components/bindings/for_requireArcProxy/when_validating_a_proxy.ts new file mode 100644 index 00000000..e7035637 --- /dev/null +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/when_validating_a_proxy.ts @@ -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['query'] = requireArcProxy(Snapshot, QueryFor, 'query'); + const observable: ComponentProps['query'] = requireArcProxy(Live, ObservableQueryFor, 'observable query'); + const command: ComponentProps['command'] = requireArcProxy(Save, Command, '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, 'query')).toThrow('Invalid query binding'); + expect(() => requireArcProxy(Snapshot, ObservableQueryFor, 'observable query')).toThrow('Invalid observable query binding'); + expect(() => requireArcProxy(Snapshot, Command, 'command')).toThrow('Invalid command binding'); + expect(() => requireArcProxy(Save, QueryFor, '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, '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, 'query')).toThrow('zero-argument Arc'); + }); +}); diff --git a/Source/JavaScript/components/bindings/requireArcProxy.ts b/Source/JavaScript/components/bindings/requireArcProxy.ts new file mode 100644 index 00000000..d426249d --- /dev/null +++ b/Source/JavaScript/components/bindings/requireArcProxy.ts @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { BoundConstructor } from './BoundConstructor'; + +/** + * Bridge for the generated, zero-argument Arc proxies consumed by Components. The registry remains + * open to arbitrary constructors; only mounting an Arc adapter requires this narrower contract. + * Check inheritance without constructing a proxy (constructors can have side effects). + * + * This is not structural interface validation: the host still owns the generated subclass's data, + * argument and implementation correctness. Prototype identity deliberately rejects duck-typed proxies + * and proxies from a second Arc installation. The arity check enforces the generated convention, + * not general JavaScript constructor safety (defaults/rest parameters can hide requirements). + * The single assertion bridges the registry under that host-owned generated-proxy contract; it is + * not a proof of arbitrary subclass behavior or of erased row/argument types. + */ +export function requireArcProxy(target: BoundConstructor, base: { prototype: T }, kind: string): new () => T { + const prototype: unknown = target.prototype; + if (target.length !== 0 || typeof prototype !== 'object' || prototype === null || !Object.prototype.isPrototypeOf.call(base.prototype, prototype)) { + throw new Error(`Invalid ${kind} binding '${target.name}': expected a zero-argument Arc ${kind} proxy`); + } + return target as new () => T; +} diff --git a/Source/JavaScript/components/cratisComponents.ts b/Source/JavaScript/components/cratisComponents.ts index 5702ec76..f39f2b40 100644 --- a/Source/JavaScript/components/cratisComponents.ts +++ b/Source/JavaScript/components/cratisComponents.ts @@ -3,7 +3,7 @@ import { ComponentRegistry, componentRegistryKey } from '@cratis/scene.react'; import { SceneDataPage, SceneFormElement, ScenePage } from './pages'; -import { SceneDataTable, SceneObservableDataTable } from './data'; +import { SceneDataTable, SceneObservableDataTable, SceneSingleResult } from './data'; import { SceneCalendarField, SceneCheckboxField, @@ -49,6 +49,7 @@ export const cratisComponents: ComponentRegistry = { [componentRegistryKey(cratisComponentsPackageName, 'dataPage')]: SceneDataPage, [componentRegistryKey(cratisComponentsPackageName, 'formElement')]: SceneFormElement, + [componentRegistryKey(cratisComponentsPackageName, 'singleResult')]: SceneSingleResult, [componentRegistryKey(cratisComponentsPackageName, 'dataTable')]: SceneDataTable, [componentRegistryKey(cratisComponentsPackageName, 'table')]: SceneDataTable, [componentRegistryKey(cratisComponentsPackageName, 'observableDataTable')]: SceneObservableDataTable, diff --git a/Source/JavaScript/components/cratisComponentsPackage.ts b/Source/JavaScript/components/cratisComponentsPackage.ts index b191c7bf..a43a8e8f 100644 --- a/Source/JavaScript/components/cratisComponentsPackage.ts +++ b/Source/JavaScript/components/cratisComponentsPackage.ts @@ -36,6 +36,7 @@ export const cratisComponentsPackageManifest: ScenePackage = { 'page', 'dataPage', 'formElement', + 'singleResult', 'dataTable', 'table', 'observableDataTable', @@ -88,7 +89,7 @@ export const cratisComponentsPackageManifest: ScenePackage = { themes: [], displayName: 'Cratis Components', - description: "Cratis' Arc-bound data, form and dialog composites, built on PrimeReact and Tailwind. dataTable and table accept optional object-valued queryArguments for collection queries.", + description: "Cratis' Arc-bound data, form and dialog composites, built on PrimeReact and Tailwind. dataTable and table accept optional object-valued queryArguments for collection queries. singleResult displays one read-only scalar own resultField of an optional model using an explicit query, object queryArguments and enabled: true.", module: '@cratis/scene.components', license: 'MIT', licenseUrl: 'https://github.com/Cratis/Scene/blob/main/LICENSE', diff --git a/Source/JavaScript/components/data/SceneDataTable.tsx b/Source/JavaScript/components/data/SceneDataTable.tsx index 226eb13f..776d8a76 100644 --- a/Source/JavaScript/components/data/SceneDataTable.tsx +++ b/Source/JavaScript/components/data/SceneDataTable.tsx @@ -7,7 +7,7 @@ import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding import { objectProperty, stringArrayProperty, stringProperty } from '../properties'; import { Placeholder } from '../bindings/Placeholder'; -const DataTableForQuery = lazy(async () => ({ default: (await import('@cratis/components/DataTables')).DataTableForQuery })); +const DataTableForQuery = lazy(async () => ({ default: (await import('../bindings/ArcBoundComponents')).BoundDataTable })); /** * The `Cratis.Components:dataTable` component, also registered as `table` - `DataTableForQuery` from diff --git a/Source/JavaScript/components/data/SceneObservableDataTable.tsx b/Source/JavaScript/components/data/SceneObservableDataTable.tsx index 3bce1b07..0d81029e 100644 --- a/Source/JavaScript/components/data/SceneObservableDataTable.tsx +++ b/Source/JavaScript/components/data/SceneObservableDataTable.tsx @@ -7,7 +7,7 @@ import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding import { stringArrayProperty, stringProperty } from '../properties'; const DataTableForObservableQuery = lazy(async () => ({ - default: (await import('@cratis/components/DataTables')).DataTableForObservableQuery, + default: (await import('../bindings/ArcBoundComponents')).BoundObservableDataTable, })); /** diff --git a/Source/JavaScript/components/data/SceneSingleResult.tsx b/Source/JavaScript/components/data/SceneSingleResult.tsx new file mode 100644 index 00000000..96893326 --- /dev/null +++ b/Source/JavaScript/components/data/SceneSingleResult.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; +import { booleanProperty, objectProperty, stringProperty } from '../properties'; +import { SingleResultStatus } from './SingleResultStatus'; + +const SingleResultRuntime = lazy(() => import('./SingleResultRuntime')); + +/** Read-only scalar field of an optional single model; the host owns and commits the input. */ +export function SceneSingleResult({ element }: RegisteredComponentProps) { + const { name, target } = resolveElementBinding(element, BindingKind.Query); + if (!target) return ; + + // Nothing, including loading Arc, happens until the host explicitly commits its input. + if (!element.isEnabled || booleanProperty(element.properties, 'enabled') !== true) { + return ; + } + + const queryArguments = objectProperty(element.properties, 'queryArguments'); + const resultField = stringProperty(element.properties, 'resultField'); + if (!queryArguments || !resultField) return ; + + return + + ; +} diff --git a/Source/JavaScript/components/data/SingleResultRuntime.tsx b/Source/JavaScript/components/data/SingleResultRuntime.tsx new file mode 100644 index 00000000..6f3ee8a6 --- /dev/null +++ b/Source/JavaScript/components/data/SingleResultRuntime.tsx @@ -0,0 +1,89 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useContext, useEffect, useMemo, useState } from 'react'; +import { ArcContext } from '@cratis/arc.react'; +import type { QueryFor } from '@cratis/arc/queries'; +import type { BoundConstructor } from '../bindings/BoundConstructor'; +import { SingleResultStatus } from './SingleResultStatus'; + +interface Props { + query: BoundConstructor; + queryArguments: Record; + resultField: string; +} + +type Outcome = { state: 'failure' | 'notFound' } | { state: 'success'; value: string }; + +/** + * Private execution owner. Arc owns validation, routing, HTTP and deserialization; this component + * owns only request lifetime and presentation. No shared cache can hand B the result belonging to A. + */ +export default function SingleResultRuntime({ query, queryArguments, resultField }: Props) { + const arc = useContext(ArcContext); + const { microservice, apiBasePath, origin, httpHeadersCallback, queryVersion } = arc; + // Identity is deliberate: hosts replace immutable committed arguments rather than mutating them. + const request = useMemo(() => ({ query, queryArguments, resultField, microservice, apiBasePath, origin, httpHeadersCallback, queryVersion }), + [query, queryArguments, resultField, microservice, apiBasePath, origin, httpHeadersCallback, queryVersion]); + const [settled, setSettled] = useState<{ request: typeof request; outcome: Outcome }>(); + + useEffect(() => { + // Each effect setup is a generation, including StrictMode's setup/cleanup/setup replay. + let current = true; + let instance: QueryFor> | undefined; + const publish = (outcome: Outcome) => { + if (current) setSettled({ request, outcome }); + }; + const execute = async () => { + try { + // The registry intentionally stores constructors without an eager Arc dependency. + instance = new request.query() as QueryFor>; + if (instance.enumerable !== false) { + publish({ state: 'failure' }); + return; + } + instance.setMicroservice(request.microservice); + instance.setApiBasePath(request.apiBasePath ?? ''); + instance.setOrigin(request.origin ?? ''); + instance.setHttpHeadersCallback(request.httpHeadersCallback ?? (() => ({}))); + // Native perform validates required arguments and proxy rules before HTTP. No copying, + // defaults, business validation, route construction or array wrapping happens here. + const result = await instance.perform(request.queryArguments); + if (!current) return; + if (result.isSuccess !== true || result.isAuthorized !== true || result.isValid !== true || result.hasExceptions !== false || result.isReady === false) { + publish({ state: 'failure' }); + return; + } + if (result.data === null || result.data === undefined) { + publish({ state: 'notFound' }); + return; + } + if (typeof result.data !== 'object' || Array.isArray(result.data)) { + publish({ state: 'failure' }); + return; + } + // Only one own data field, never a path, inherited member, getter or expression. + const descriptor = Object.getOwnPropertyDescriptor(result.data, request.resultField); + const value: unknown = descriptor && 'value' in descriptor ? descriptor.value : undefined; + if (typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) { + publish({ state: 'success', value: String(value) }); + } else { + publish({ state: 'failure' }); + } + } catch { + // Includes native AbortError rejections. Disposed generations cannot publish or leak + // an unhandled promise, even when the HTTP boundary ignores cancellation. + publish({ state: 'failure' }); + } + }; + void execute(); + return () => { + current = false; + instance?.abortController?.abort(); + }; + }, [request]); + + // Mask old data during the render that changes inputs, before effect cleanup/setup runs. + if (settled?.request !== request) return ; + return ; +} diff --git a/Source/JavaScript/components/data/SingleResultStatus.tsx b/Source/JavaScript/components/data/SingleResultStatus.tsx new file mode 100644 index 00000000..e670611f --- /dev/null +++ b/Source/JavaScript/components/data/SingleResultStatus.tsx @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +type State = 'idle' | 'loading' | 'notFound' | 'failure' | 'success'; + +const messages = { idle: 'Idle', loading: 'Loading', notFound: 'Not found', failure: 'Unable to load result', success: '' }; + +/** Internal shared presentation; never exposes query exception messages or server stack traces. */ +export function SingleResultStatus({ state, value }: { state: State; value?: string }) { + return + {state === 'success' ? value : messages[state]} + ; +} diff --git a/Source/JavaScript/components/data/for_SceneDataTable/when_forwarding_query_arguments.tsx b/Source/JavaScript/components/data/for_SceneDataTable/when_forwarding_query_arguments.tsx index 8dbbbc17..43c24ffa 100644 --- a/Source/JavaScript/components/data/for_SceneDataTable/when_forwarding_query_arguments.tsx +++ b/Source/JavaScript/components/data/for_SceneDataTable/when_forwarding_query_arguments.tsx @@ -5,8 +5,8 @@ import { act, cleanup, render, screen } from '@testing-library/react'; import { vi } from 'vitest'; import { externalComponent } from '../../given'; -class InvoicesForProject {} -class AnotherQuery {} +let InvoicesForProject: typeof import('../../bindings/for_requireArcProxy/NativeProxies').Snapshot; +let AnotherQuery: typeof InvoicesForProject; // Load a fresh adapter so its lazy component cannot leak the peer double into other specs. describe('when forwarding query arguments', () => { @@ -24,6 +24,8 @@ describe('when forwarding query arguments', () => { return
; }, })); + ({ Snapshot: InvoicesForProject } = await import('../../bindings/for_requireArcProxy/NativeProxies')); + AnotherQuery = InvoicesForProject; bindings = await import('../../bindings'); bindings.clearBindings(); bindings.registerQuery('InvoicesForProject', InvoicesForProject); diff --git a/Source/JavaScript/components/data/for_SceneSingleResult/when_executing_a_native_query.tsx b/Source/JavaScript/components/data/for_SceneSingleResult/when_executing_a_native_query.tsx new file mode 100644 index 00000000..05fec018 --- /dev/null +++ b/Source/JavaScript/components/data/for_SceneSingleResult/when_executing_a_native_query.tsx @@ -0,0 +1,290 @@ +// 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, waitFor } from '@testing-library/react'; +import { expect, vi } from 'vitest'; +import { StrictMode } from 'react'; +import { ArcContext, type ArcConfiguration } from '@cratis/arc.react'; +import { QueryFor, QueryHttpMethod, QueryValidator } from '@cratis/arc/queries'; +import { clearBindings, registerQuery } from '../../bindings'; +import { externalComponent } from '../../given'; +import { SceneSingleResult } from '../SceneSingleResult'; + +class ProjectName extends QueryFor { + readonly route = '/projects/{projectId}/name'; + readonly parameterDescriptors = []; + readonly requiredRequestParameters = ['projectId']; + defaultValue = undefined; + + constructor() { + super(Object, false); + this.setHttpMethod(QueryHttpMethod.Get); + } +} + +class ProjectNames extends QueryFor { + readonly route = '/projects'; + readonly parameterDescriptors = []; + readonly requiredRequestParameters = []; + defaultValue = []; + + constructor() { super(Object, true); } +} + +class ProjectArgumentsValidator extends QueryValidator<{ projectId: string }> { + static received: unknown; + + constructor() { + super(); + this.ruleFor(args => { + ProjectArgumentsValidator.received = args; + return args.projectId; + }).minLength(2); + } +} + +class ValidatedProjectName extends ProjectName { + readonly validation = new ProjectArgumentsValidator(); +} + +function envelope(data: unknown, overrides: Record = {}) { + return { + data, isSuccess: true, isAuthorized: true, isValid: true, hasExceptions: false, + validationResults: [], exceptionMessages: [], exceptionStackTrace: '', + paging: { page: 0, size: 0, totalItems: 0, totalPages: 0 }, ...overrides, + }; +} + +function response(data: unknown, overrides: Record = {}) { + return { ok: true, status: 200, json: async () => envelope(data, overrides) } as Response; +} + +function deferred() { + let resolve!: (value: Response) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +describe('when executing an optional single result with native Arc 22.16.1', () => { + const headers = () => ({ 'x-host': 'one' }); + const context: ArcConfiguration = { microservice: 'projects', origin: 'https://example.test', apiBasePath: '/backend', httpHeadersCallback: headers }; + const fetch = vi.fn(); + + function view(queryArguments: unknown = { projectId: 'A' }, arc = context, properties: Record = {}) { + return + + ; + } + + beforeEach(() => { + clearBindings(); + registerQuery('ProjectName', ProjectName); + registerQuery('ProjectNames', ProjectNames); + registerQuery('ValidatedProjectName', ValidatedProjectName); + fetch.mockReset(); + vi.stubGlobal('fetch', fetch); + }); + + afterEach(() => { + cleanup(); + clearBindings(); + vi.unstubAllGlobals(); + }); + + it('uses the native URL, arguments and context headers without changing the host object', async () => { + const args = Object.freeze({ projectId: 'AA', locale: 'en' }); + fetch.mockResolvedValue(response({ name: 'Project A' })); + await act(async () => { render(view(args, context, { query: 'ValidatedProjectName' })); }); + expect(await screen.findByText('Project A')).not.toBeNull(); + expect(document.querySelector('b')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toBe('https://example.test/backend/projects/AA/name?locale=en'); + expect(ProjectArgumentsValidator.received).toBe(args); + expect(new Headers(init?.headers).get('x-host')).toBe('one'); + expect(new Headers(init?.headers).get('x-cratis-microservice')).toBe('projects'); + expect(args).toEqual({ projectId: 'AA', locale: 'en' }); + }); + + for (const value of [false, 0, '']) { + it(`renders scalar own-field ${JSON.stringify(value)} as present`, async () => { + fetch.mockResolvedValue(response({ name: value })); + await act(async () => { render(view()); }); + await waitFor(() => expect(screen.getByRole('status').getAttribute('data-scene-single-result')).toBe('success')); + expect(screen.getByRole('status').textContent).toBe(String(value)); + }); + } + + for (const data of [null, undefined]) { + it(`shows not found for successful ${String(data)}`, async () => { + fetch.mockResolvedValue(response(data)); + await act(async () => { render(view()); }); + expect(await screen.findByText('Not found')).not.toBeNull(); + }); + } + + for (const failure of [{ isSuccess: false }, { isAuthorized: false }, { isValid: false }, { hasExceptions: true }]) { + it(`prioritizes failure over missing data: ${JSON.stringify(failure)}`, async () => { + fetch.mockResolvedValue(response(null, { ...failure, exceptionMessages: ['secret'], exceptionStackTrace: 'server stack' })); + await act(async () => { render(view()); }); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(screen.queryByText('Not found')).toBeNull(); + expect(document.body.textContent).not.toMatch(/secret|server stack/); + }); + } + + for (const args of [{}, { projectId: '' }, { projectId: null }, [], null, 'A', undefined]) { + it(`does not send invalid or missing arguments: ${JSON.stringify(args)}`, async () => { + await act(async () => { render(view(args, context, { queryArguments: args })); }); + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + } + + it('lets the native proxy validator reject arguments without HTTP', async () => { + await act(async () => { render(view({ projectId: 'A' }, context, { query: 'ValidatedProjectName' })); }); + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + for (const enabled of [false, undefined, 'true']) { + it(`is idle unless explicitly enabled: ${String(enabled)}`, async () => { + await act(async () => { render(view({ projectId: 'A' }, context, { enabled })); }); + expect(screen.getByText('Idle')).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + } + + it('never chooses another query when the binding is missing', async () => { + await act(async () => { render(view({}, context, { query: 'Missing' })); }); + expect(screen.getByText(/Unresolved query binding 'Missing'/)).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('aborts A and ignores reverse completion when the transport ignores abort', async () => { + const a = deferred(); + const b = deferred(); + fetch.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + expect(screen.getByText('Loading')).not.toBeNull(); + const signal = fetch.mock.calls[0][1]?.signal; + await act(async () => { mounted.rerender(view({ projectId: 'B' })); }); + expect(signal?.aborted).toBe(true); + await act(async () => { b.resolve(response({ name: 'B' })); }); + expect(screen.getByText('B')).not.toBeNull(); + await act(async () => { a.resolve(response({ name: 'A' })); }); + expect(screen.queryByText('A')).toBeNull(); + expect(screen.getByText('B')).not.toBeNull(); + }); + + it('hides a settled A immediately on B and aborts B on disposal', async () => { + const b = deferred(); + fetch.mockResolvedValueOnce(response({ name: 'A' })).mockReturnValueOnce(b.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + expect(screen.getByText('A')).not.toBeNull(); + await act(async () => { mounted.rerender(view({ projectId: 'B' })); }); + expect(screen.queryByText('A')).toBeNull(); + expect(screen.getByText('Loading')).not.toBeNull(); + mounted.unmount(); + expect(fetch.mock.calls[1][1]?.signal?.aborted).toBe(true); + await act(async () => { b.reject(new DOMException('Aborted', 'AbortError')); }); + }); + + it('reexecutes for header-only context changes and ignores the old response', async () => { + const a = deferred(); + const b = deferred(); + const args = { projectId: 'A' }; + fetch.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view(args)); }); + await act(async () => { mounted.rerender(view(args, { ...context, httpHeadersCallback: () => ({ 'x-host': 'two' }) })); }); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + expect(new Headers(fetch.mock.calls[1][1]?.headers).get('x-host')).toBe('two'); + await act(async () => { b.resolve(response({ name: 'New context' })); a.resolve(response({ name: 'Old context' })); }); + expect(screen.getByText('New context')).not.toBeNull(); + expect(screen.queryByText('Old context')).toBeNull(); + }); + + it('rejects enumerable proxies before HTTP rather than adapting them to a single model', async () => { + await act(async () => { render(view({}, context, { query: 'ProjectNames' })); }); + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('handles StrictMode disposal without an unhandled abort promise', async () => { + const a = deferred(); + const b = deferred(); + fetch.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise); + await act(async () => { render({view()}); }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { + a.reject(new DOMException('Aborted', 'AbortError')); + b.resolve(response({ name: 'Current generation' })); + }); + expect(screen.getByText('Current generation')).not.toBeNull(); + }); + + it('ignores successful completion after unmount even if HTTP ignores abort', async () => { + const pending = deferred(); + fetch.mockReturnValueOnce(pending.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + mounted.unmount(); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(response({ name: 'Disposed' })); }); + expect(screen.queryByText('Disposed')).toBeNull(); + }); + + it('turns transport rejection and malformed envelopes into safe failure', async () => { + fetch.mockRejectedValueOnce(new Error('private network detail')); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + fetch.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) } as Response); + await act(async () => { mounted.rerender(view({ projectId: 'B' })); }); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(document.body.textContent).not.toContain('private network detail'); + }); + + it('aborts and clears a pending request when disabled', async () => { + const pending = deferred(); + fetch.mockReturnValueOnce(pending.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view()); }); + await act(async () => { mounted.rerender(view({ projectId: 'A' }, context, { enabled: false })); }); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(response({ name: 'Obsolete' })); }); + expect(screen.getByText('Idle')).not.toBeNull(); + expect(screen.queryByText('Obsolete')).toBeNull(); + }); + + for (const change of [{ origin: 'https://other.test' }, { apiBasePath: '/v2' }, { microservice: 'other' }, { queryVersion: 1 }]) { + it(`invalidates a settled result after context changes: ${JSON.stringify(change)}`, async () => { + const pending = deferred(); + const args = { projectId: 'A' }; + fetch.mockResolvedValueOnce(response({ name: 'Old' })).mockReturnValueOnce(pending.promise); + let mounted!: ReturnType; + await act(async () => { mounted = render(view(args)); }); + await act(async () => { mounted.rerender(view(args, { ...context, ...change })); }); + expect(screen.queryByText('Old')).toBeNull(); + expect(screen.getByText('Loading')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + await act(async () => { pending.resolve(response({ name: 'New' })); }); + expect(screen.getByText('New')).not.toBeNull(); + }); + } + + for (const data of [{ name: {} }, { name: [] }, {}, Object.create({ name: 'Inherited' }), [{ name: 'A' }]]) { + it(`rejects non-scalar or absent own fields: ${JSON.stringify(data)}`, async () => { + fetch.mockResolvedValue(response(data)); + await act(async () => { render(view()); }); + expect(await screen.findByRole('alert')).not.toBeNull(); + }); + } +}); diff --git a/Source/JavaScript/components/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx b/Source/JavaScript/components/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx new file mode 100644 index 00000000..44b21c7f --- /dev/null +++ b/Source/JavaScript/components/data/for_SceneSingleResult/when_gating_the_lazy_runtime.tsx @@ -0,0 +1,60 @@ +// 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'; + +class RegisteredQuery {} + +describe('when gating the single result lazy runtime', () => { + const loaded = vi.fn(); + let SceneSingleResult: typeof import('../SceneSingleResult').SceneSingleResult; + let bindings: typeof import('../../bindings'); + + beforeEach(async () => { + vi.resetModules(); + loaded.mockClear(); + vi.doMock('../SingleResultRuntime', () => { + loaded(); + return { default: () => Runtime mounted }; + }); + bindings = await import('../../bindings'); + bindings.clearBindings(); + bindings.registerQuery('Registered', RegisteredQuery); + ({ SceneSingleResult } = await import('../SceneSingleResult')); + }); + + afterEach(() => { + cleanup(); + bindings.clearBindings(); + vi.doUnmock('../SingleResultRuntime'); + vi.resetModules(); + }); + + for (const properties of [ + { query: 'Missing', enabled: true, queryArguments: {}, resultField: 'name' }, + { query: 'Registered', queryArguments: {}, resultField: 'name' }, + { query: 'Registered', enabled: true, queryArguments: [], resultField: 'name' }, + { query: 'Registered', enabled: true, queryArguments: {}, resultField: '' }, + ]) { + it(`does not import Arc-bound code for ${JSON.stringify(properties)}`, async () => { + await act(async () => { + render(); + }); + expect(loaded).not.toHaveBeenCalled(); + expect(screen.queryByText('Runtime mounted')).toBeNull(); + expect(document.body.textContent).toMatch(/Unresolved query binding|Idle|Unable to load result/); + }); + } + + it('loads only the explicitly bound and enabled view', async () => { + await act(async () => { + render(); + }); + expect(await screen.findByText('Runtime mounted')).not.toBeNull(); + expect(loaded).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Source/JavaScript/components/data/index.ts b/Source/JavaScript/components/data/index.ts index fef4ecaa..c292c2fa 100644 --- a/Source/JavaScript/components/data/index.ts +++ b/Source/JavaScript/components/data/index.ts @@ -1,5 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +export * from './SceneSingleResult'; export * from './SceneDataTable'; export * from './SceneObservableDataTable'; diff --git a/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx b/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx index 7d888d63..c63f1c83 100644 --- a/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx +++ b/Source/JavaScript/components/dialogs/SceneCommandDialog.tsx @@ -6,7 +6,7 @@ import { RegisteredComponentProps } from '@cratis/scene.react'; import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; import { booleanProperty, stringProperty } from '../properties'; -const CommandDialog = lazy(async () => ({ default: (await import('@cratis/components/CommandDialog')).CommandDialog })); +const CommandDialog = lazy(async () => ({ default: (await import('../bindings/ArcBoundComponents')).BoundCommandDialog })); /** * The `Cratis.Components:commandDialog` component - `CommandDialog` from `@cratis/components/CommandDialog`. diff --git a/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx b/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx index dccee755..3b2f70a5 100644 --- a/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx +++ b/Source/JavaScript/components/dialogs/SceneStepperCommandDialog.tsx @@ -7,7 +7,7 @@ import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding import { booleanProperty, stringProperty, unionProperty } from '../properties'; const StepperCommandDialog = lazy(async () => ({ - default: (await import('@cratis/components/CommandDialog')).StepperCommandDialog, + default: (await import('../bindings/ArcBoundComponents')).BoundStepperCommandDialog, })); /** Whether the step headers run across the top or down the side. */ diff --git a/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_single_result.ts b/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_single_result.ts new file mode 100644 index 00000000..7243ddd3 --- /dev/null +++ b/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_single_result.ts @@ -0,0 +1,20 @@ +// 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 { componentRegistryKey } from '@cratis/scene.react'; +import { cratisComponents, cratisComponentsPackageName } from '../cratisComponents'; +import { cratisComponentsPackageManifest } from '../cratisComponentsPackage'; +import { SceneSingleResult } from '../data/SceneSingleResult'; + +describe('when describing the single result view', () => { + it('registers the same component advertised by the manifest', () => { + expect(cratisComponentsPackageManifest.components).toContain('singleResult'); + expect(cratisComponents[componentRegistryKey(cratisComponentsPackageName, 'singleResult')]).toBe(SceneSingleResult); + }); + + it('describes an explicitly enabled read-only own-field capability', () => { + expect(cratisComponentsPackageManifest.description).toContain('read-only scalar own resultField'); + expect(cratisComponentsPackageManifest.description).toContain('enabled: true'); + }); +}); diff --git a/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx b/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx index fb0500f8..7db7ce0c 100644 --- a/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx +++ b/Source/JavaScript/components/forms/fields/SceneRadioButtonField.tsx @@ -20,13 +20,16 @@ const RadioButtonField = lazy(async () => ({ default: (await import('@cratis/com * numeric code is common enough that forcing it through as `'1'` would bind the wrong value. */ export function SceneRadioButtonField({ element }: RegisteredComponentProps) { + // Use the same reader as CommandFormField's binding gate; that gate owns missing-property feedback. + const name = stringProperty(element.properties, 'property'); const buttonValue = stringProperty(element.properties, 'buttonValue') ?? numberProperty(element.properties, 'buttonValue') ?? ''; return ( - {binding => ( + {binding => name !== undefined && ( { + const received = vi.fn<(props: { name: string; title: string; value: (instance: Record) => unknown }) => void>(); + let SceneRadioButtonField: typeof import('../SceneRadioButtonField').SceneRadioButtonField; + + beforeEach(async () => { + vi.resetModules(); + received.mockClear(); + vi.doMock('@cratis/components/CommandForm', () => ({ + RadioButtonField: (props: Parameters[0]) => { received(props); return ; }, + })); + ({ SceneRadioButtonField } = await import('../SceneRadioButtonField')); + }); + + afterEach(() => { + cleanup(); + vi.doUnmock('@cratis/components/CommandForm'); + vi.resetModules(); + }); + + it('should group options by the explicit property, regardless of titles or labels', async () => { + await act(async () => { + render(<>{['First title', 'Second title'].map((title, index) => )}); + }); + expect(screen.getAllByRole('radio').map(radio => radio.getAttribute('name'))).toEqual(['choice', 'choice']); + expect(received.mock.calls.map(([props]) => props.value({ choice: 2 }))).toEqual([2, 2]); + }); + + for (const property of [undefined, 42, null, {}, []]) { + it(`should visibly reject malformed property ${JSON.stringify(property)} instead of using the title`, async () => { + await act(async () => { + render(); + }); + expect(screen.getByText("Missing 'property' on Cratis.Components:radioButtonField")).toBeTruthy(); + expect(received).not.toHaveBeenCalled(); + }); + } +}); diff --git a/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts b/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts index 75af7a84..e75c320c 100644 --- a/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts +++ b/Source/JavaScript/components/forms/fields/for_resolveFieldBinding/when_resolving_a_field_binding.ts @@ -3,8 +3,14 @@ import { externalComponent } from '../../../given'; import { resolveFieldBinding } from '../resolveFieldBinding'; +import type { FieldBinding } from '../FieldBinding'; describe('when resolving a field binding', () => { + it('should preserve manually constructed field bindings without a native group name', () => { + const binding: FieldBinding = { value: instance => instance.customerName, title: 'Customer' }; + binding.value({ customerName: 'Acme' })!.should.equal('Acme'); + }); + describe('and the element names a property with a title and description', () => { const binding = resolveFieldBinding( externalComponent('Cratis.Components:inputTextField', { diff --git a/Source/JavaScript/components/package.json b/Source/JavaScript/components/package.json index 6cb292d0..a6f179e5 100644 --- a/Source/JavaScript/components/package.json +++ b/Source/JavaScript/components/package.json @@ -58,6 +58,8 @@ "@cratis/scene.react": "1.0.0" }, "devDependencies": { + "@cratis/arc": "22.16.1", + "@cratis/arc.react": "22.16.1", "@cratis/components": "^4.6.0", "@cratis/scene.engine": "1.0.0", "@cratis/scene.model": "1.0.0", @@ -71,8 +73,18 @@ "storybook": "^10.6.0" }, "peerDependencies": { + "@cratis/arc": "^22.16.1", + "@cratis/arc.react": "^22.16.1", "@cratis/components": "^4.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" + }, + "peerDependenciesMeta": { + "@cratis/arc": { + "optional": true + }, + "@cratis/arc.react": { + "optional": true + } } } diff --git a/Source/JavaScript/components/pages/SceneDataPage.tsx b/Source/JavaScript/components/pages/SceneDataPage.tsx index 300c59e6..4196346b 100644 --- a/Source/JavaScript/components/pages/SceneDataPage.tsx +++ b/Source/JavaScript/components/pages/SceneDataPage.tsx @@ -6,7 +6,7 @@ import { RegisteredComponentProps } from '@cratis/scene.react'; import { ArcRuntimeBoundary, BindingKind, MissingBinding, resolveElementBinding } from '../bindings'; import { stringArrayProperty, stringProperty } from '../properties'; -const DataPage = lazy(async () => ({ default: (await import('@cratis/components/DataPage')).DataPage })); +const DataPage = lazy(async () => ({ default: (await import('../bindings/ArcBoundComponents')).BoundDataPage })); /** * The `Cratis.Components:dataPage` component - `DataPage` from `@cratis/components/DataPage`.