diff --git a/Documentation/components-package/binding-registry.md b/Documentation/components-package/binding-registry.md index 5e6f07c..651421e 100644 --- a/Documentation/components-package/binding-registry.md +++ b/Documentation/components-package/binding-registry.md @@ -86,6 +86,20 @@ without unwinding the previous run. `clearBindings()` exists because the registr right for a host that registers once at startup, wrong for Studio switching between projects or a spec that must not inherit what the previous one registered. +## Opt-in exact lookup identities + +`queryInputForm` can use `registerQueryIdentity(name, sourceIdentity, proxy)` to distinguish source-name +collisions from hot-reload replacement. `resolveExactQuery(name)` returns a constructor, `'ambiguous'`, or +`undefined`; `unregisterQueryIdentity(name, sourceIdentity)` removes a source. `resolveQuery` keeps +legacy last-registration-wins precedence; only when no legacy registration exists does it resolve a +unique identity. Multiple identity-only candidates return `undefined`, producing the existing adapters' +visible unresolved placeholder without selection. Existing adapters look up on host render, not by +subscription. `registeredQueryNames()` includes the sorted union of both namespaces, deduplicated and +including ambiguous names. Registering a lookup in both namespaces makes strict `resolveExactQuery` +ambiguous, while legacy resolution still prefers its legacy registration. Stage can emit one identity +registration per source for the same semantic name and serve both old adapters and `queryInputForm`. See the [query input form contract](query-input-form.md#collision-aware-registration-for-stage) +for the Stage emission pattern, lifecycle and migration boundary. + ## The classes are not typed as Arc types `registerQuery` takes a `BoundConstructor` — "something that can be constructed" — not diff --git a/Documentation/components-package/components.md b/Documentation/components-package/components.md index 57158f5..f5b06eb 100644 --- a/Documentation/components-package/components.md +++ b/Documentation/components-package/components.md @@ -39,6 +39,11 @@ and re-renders when the read model changes. | Name | Wraps | Properties | Slots | |---|---|---|---| | `commandForm` | `AutoCommandForm` | **`command`** (binding), `exclude` | — | +| `queryInputForm` | Native web inputs + existing optional-single runtime | **`query`** (exact binding), `inputs`, `resultField`, `label`, `submitLabel` | — | + +[`queryInputForm`](query-input-form.md) is opt-in and submits explicit string drafts, never querying while +editing. Unlike forgiving display properties, malformed input declarations fail visibly and execute +nothing. It does not use command fields or a new core form schema. `AutoCommandForm` generates its fields from the command's own property descriptors, so the form follows the command rather than going stale when a property is added on the backend. `exclude` keeps it from generating a diff --git a/Documentation/components-package/index.md b/Documentation/components-package/index.md index a483ff6..0044e50 100644 --- a/Documentation/components-package/index.md +++ b/Documentation/components-package/index.md @@ -17,13 +17,13 @@ package delivers. ## What is in the box -Thirty-seven abstract names across seven families: +Abstract names across seven families: | Family | Names | What they wrap | |---|---|---| | Pages | `page`, `dataPage`, `formElement` | `Page`, `DataPage`, `FormElement` | | Data | `dataTable`, `table`, `observableDataTable` | `DataTableForQuery`, `DataTableForObservableQuery` | -| Forms | `commandForm` and twelve field types | `AutoCommandForm` and the `CommandForm` fields | +| Forms | `commandForm`, `queryInputForm` and twelve field types | `AutoCommandForm`, command fields, and an opt-in string-input query form | | Dialogs | `dialog`, `confirmationDialog`, `busyIndicatorDialog`, `commandDialog`, `stepperCommandDialog` | `Dialogs` and `CommandDialog` | | Common | `icon`, `tooltip`, `dropdown`, `errorBoundary` | `IconDisplay`, `Tooltip`, `Dropdown`, `ErrorBoundary` | | Editors | `objectContentEditor`, `objectNavigationalBar`, `schemaEditor`, `timeMachine`, `filterPanel` | The editing and inspection surfaces | @@ -88,6 +88,7 @@ before you build anything on this package; everything else here assumes it. ## Where to go next - [The binding registry](binding-registry.md) — how a screen names a query, and a host supplies it. +- [Query input form](query-input-form.md) — editable string arguments committed on submit, with exact optional-single binding. - [Naming and shadowing](naming-and-shadowing.md) — why `table` resolves here and not to PrimeReact. - [Theming through design tokens](theming.md) — how a Scene theme drives the library. - [What this package does not cover](coverage.md) — and why each omission is deliberate. diff --git a/Documentation/components-package/query-input-form.md b/Documentation/components-package/query-input-form.md new file mode 100644 index 0000000..a1b9b5d --- /dev/null +++ b/Documentation/components-package/query-input-form.md @@ -0,0 +1,129 @@ +--- +title: Query input form +description: An opt-in web form for explicit string arguments to an optional-single Arc query. +--- + +`Cratis.Components:queryInputForm` owns local string drafts and commits them only on form submission +(the submit button or native Enter submission). It uses the same private `SingleResultRuntime` as +`singleResult`, not another executor. No default screen, platform-neutral Form schema, expression +language, command context or cross-component binding is introduced. + +## Host and Scene payload + +The host supplies the actual generated Arc proxy and its Arc context. Stage can emit the following +registration and ExternalComponent properties; these are package contracts, **not new Screenplay syntax**: + +```typescript +import { registerQueryIdentity, type QueryInput } from '@cratis/scene.components'; +import { ProjectName } from './Projects/Queries/ProjectName'; + +// Identity is a stable, unique generated source identity, not the entered project identifier. +registerQueryIdentity('ProjectName', 'Projects/Queries/ProjectName', ProjectName); + +const inputs: QueryInput[] = [ + { parameter: 'projectId', type: 'string', label: 'Project identifier' }, +]; +const properties = { + query: 'ProjectName', + inputs, + resultField: 'name', + label: 'Find project', + submitLabel: 'Search', +}; +// Place properties on an ExternalComponent with componentName 'Cratis.Components:queryInputForm'. +``` + +Use the **exact case-sensitive query name and argument names** emitted by the proxy. There is no route +in the form, no guessed project, no all-projects fallback and no parameter inference. It requires a +zero-argument native optional-single proxy (`enumerable === false`). Enumerable proxies fail before +`perform`. The host supplies Arc/Arc React 22.16.1 or compatible peers as for `singleResult`, plus the +matching Fundamentals peer (7.19.2 or compatible, already required by Arc) for `Guid` constructor identity. + +| Property | Contract | +|---|---| +| `query` | Required exact registered name. Missing/ambiguous bindings are visible and do not execute. | +| `inputs` | Required nonempty array of string input declarations. Duplicate parameter names or any malformed entry reject the entire form visibly. | +| `resultField` | Required nonempty name of one own scalar result field; not a property path. | +| `label` | Accessible form name; defaults to `Query input`. | +| `submitLabel` | Submit button text; defaults to `Search`. | + +Each input has a nonempty string `parameter`, literal `type: 'string'`, nonempty string `label`, optional +boolean `required` (defaults to true), and optional string `pattern`. `pattern` is an authored whole-value +JavaScript Unicode regular expression without delimiters, for example `[A-Z]{2}-[0-9]{2}`. Invalid regular +expressions are configuration errors. There is no inferred GUID/email/business format. Required inputs +reject empty or whitespace-only strings. Other values, including surrounding whitespace, remain exact: +**no trimming, coercion or generated identifiers**. Optional empty strings remain present in the snapshot; +proxy required-argument rules may still reject them. No initial/default argument values or slots are read. +The pattern wrapper uses Unicode mode without multiline mode: final newline characters do not satisfy +an otherwise digit-only pattern. Values explicitly allowed by a pattern are still submitted unchanged. + +At the actual lazy native boundary, every declared argument must match **exactly one** proxy +`parameterDescriptors` entry by exact name. Only scalar `String` and `Guid` constructor types are +supported for `type: 'string'`; numeric, boolean, unknown/missing, duplicate or enumerable descriptors +fail visibly before `perform`/HTTP. This is a bounded form mapping check, not type inference or a change +to `singleResult`'s host-supplied arguments. Strings are never converted into Guid objects. A generated +`projectId: Guid` with `new ParameterDescriptor('projectId', Guid, false)` is supported unchanged. +Arc 22.16.1 does **not** enforce descriptor argument types or GUID string format in native `perform`. +If format enforcement is desired, Stage must supply an explicit input `pattern` (or the proxy/backend +must validate it); a nonempty malformed GUID can otherwise reach HTTP unchanged. + +## Submission and lifetime + +- Controls are accessible, controlled native web text inputs with associated labels, required state, + inline validation alerts, `aria-invalid` and `aria-describedby`. Components' `InputTextField` is wrapped + in `asCommandFormField` and requires command context; it is deliberately not repurposed with a fake command. +- Editing never imports the execution runtime or calls a query. Submission validates required values and + declared formats first. Invalid inputs stay visible and make no native `perform` or HTTP call. +- Valid submission creates a **new frozen argument object** containing only the declared exact names and + strings. Repeated submission of identical values explicitly retries, including a pending request. +- Editing any draft immediately hides a settled result and unmounts/cancels a pending runtime. Editing + back to the previous string does not resurrect that result: another submission is required. +- Native `QueryFor.perform` still owns proxy validation, required arguments, routing, HTTP and + deserialization. A generated-validator rejection is visible as `Unable to load result`, with no HTTP; + drafts remain editable. Scene does not expose server exception details or duplicate proxy/business + validators. The form-only descriptor compatibility check above precedes native validation. +- Result, `Not found`, loading/failure, Arc context changes, abort and stale-response suppression are the + existing `singleResult` behavior. Result strings are escaped text; only own string/finite-number/boolean + fields are displayed. Successful missing models are not collections. +- Equivalent host rerenders retain drafts. A changed query name/input declaration/result field starts a + fresh session. Disabling the Scene element, losing the binding or unmounting cancels and discards it. + A replacement constructor for the same source identity reexecutes committed arguments through the + existing runtime (and cancels the old generation). Arc context changes do the same. + +## Collision-aware registration for Stage + +`registerQuery`/`registerQueries` **still replace by name** for hot reload. `resolveQuery` always uses +that legacy registration when present, even if there are identity registrations. Without a legacy +registration it returns the unique identity-only constructor. Zero or multiple identities return +`undefined`; existing tables/data pages/`singleResult` show their unresolved-binding placeholder and +select no candidate. These existing adapters retain their render-time lookup contract (host rerender +is needed after registry changes); command resolution is unchanged. + +For collision detection, Stage should emit **one `registerQueryIdentity(name, sourceIdentity, proxy)` +call per source**, not first collapse exports into a name-keyed object. The same `(name, sourceIdentity)` +replaces on hot reload; two different identities are ambiguous even if they share a constructor. +`unregisterQueryIdentity(name, sourceIdentity)` removes a deleted/renamed source. `clearBindings()` clears +both namespaces, including when switching applications. + +`resolveExactQuery(name)` remains stricter: it returns a constructor, `'ambiguous'`, or `undefined`, +counting a legacy registration as one additional candidate. Thus mixed legacy/identity registrations +are ambiguous for `queryInputForm`, even though legacy adapters still prefer the legacy registration. +Stage should emit **only the identity registration per source for the same semantic name**; there is +no need to duplicate `registerQueries` for existing authored tables or `singleResult`. +`registeredQueryNames()` is the sorted, deduplicated union of both namespaces, including ambiguous +names. Removing the final identity removes that name unless a legacy registration remains. +`subscribeQueryBindings(listener)` notifies live exact-binding consumers; `queryInputForm` observes it +and cancels immediately if a collision appears. + +## Verification boundary + +The regression suite renders the real `SceneElementView` with native Arc 22.16.1 `QueryFor.perform`, +replacing only HTTP. It covers drafts, snapshots, validation, retry, exact/missing/ambiguous bindings, +result/not-found, cancellation and stale completion, including the canonical generated ProjectById +shape with Guid descriptors, decorated ProjectSummary, empty-object default and native inherited +`perform`. Native single-result tests and table adapter forwarding tests cover the identity bridge. +These are consumer-shape tests, not execution of a generated application. The DOM environment dispatches the form submit +event; jsdom does not simulate a browser's implicit Enter submission algorithm. + +This is a bounded Scene #39 increment, not standalone generated-application/browser acceptance, a Stage +composition change, a general result-binding model or issue closure. diff --git a/Source/JavaScript/components/README.md b/Source/JavaScript/components/README.md index 1242b63..1d902d6 100644 --- a/Source/JavaScript/components/README.md +++ b/Source/JavaScript/components/README.md @@ -132,9 +132,44 @@ view rather than an invented retry protocol. Enumerable proxies are rejected bef 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. +They do not prove a generated server route or backend persistence. The opt-in `queryInputForm` below +adds form-local editable input/commit semantics; Scene #39 remains open for broader binding and acceptance. +No default profile, generator or schema changed. + +## Query input form (opt-in) + +`Cratis.Components:queryInputForm` composes controlled native web string inputs with the **existing** +`SingleResultRuntime`. Its package-local `QueryInput` declarations name exact parameters, labels, +required state and optional whole-string patterns. It validates on submit, freezes a fresh committed +snapshot, cancels/hides obsolete results on edit and supports repeat submit. There is no query while +editing, fake command context, new executor, core schema, default identifier or inferred route. + +Published Components `InputTextField` is wrapped with `asCommandFormField` and consumes command context; +it is not an exported independent controlled input. Native labelled text inputs are deliberately used +instead. The existing command-field adapters and command-slot behavior are unchanged. + +`registerQueryIdentity(name, sourceIdentity, proxy)` is the explicit opt-in collision-aware API: +identical identities replace on hot reload; different sources with the same name are ambiguous. +`queryInputForm` subscribes to exact-binding changes. Legacy `registerQuery` keeps its last-write-wins +semantics and is authoritative for legacy `resolveQuery` when present. Otherwise a unique identity-only +registration also serves existing tables and `singleResult`; multiple identities show their unresolved +placeholder without selecting a candidate. Existing adapters re-resolve on host render. +`registeredQueryNames` includes the sorted union of both namespaces. Strict `resolveExactQuery` still +counts mixed candidates as ambiguous: Stage should emit one identity registration per source, not also +register the same name through `registerQueries`. + +At the existing lazy runtime boundary, form declarations must map exactly to scalar `String` or `Guid` +proxy descriptors. Unsupported/missing/duplicate descriptors fail before HTTP; no coercion is performed. +The canonical generated Guid proxy shape is tested unchanged. Native Arc validates required arguments +and proxy rules, but does not automatically validate GUID format: declare an explicit pattern if needed. +The lazy runtime uses the optional Fundamentals peer already required by Arc; no Components peer-floor +change is required. + +The [public contract](../../../Documentation/components-package/query-input-form.md) documents the exact +payload and Stage consumption pattern. Tests render through real `SceneElementView`, unmodified native +Arc `QueryFor.perform` and only substituted HTTP, including validation, stale requests and unmount. +They are not generated/browser acceptance; the DOM suite dispatches submit rather than simulating a +browser's implicit Enter algorithm. ## Naming and shadowing @@ -199,7 +234,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, singleResult -forms/ commandForm; forms/fields/ the twelve field types +forms/ commandForm, queryInputForm; forms/fields/ the twelve field types dialogs/ dialog, confirmationDialog, busyIndicatorDialog, commandDialog, stepperCommandDialog common/ icon, tooltip, dropdown, errorBoundary editors/ objectContentEditor, objectNavigationalBar, schemaEditor, timeMachine, filterPanel diff --git a/Source/JavaScript/components/bindings/bindingRegistry.ts b/Source/JavaScript/components/bindings/bindingRegistry.ts index c332bd6..5593ddc 100644 --- a/Source/JavaScript/components/bindings/bindingRegistry.ts +++ b/Source/JavaScript/components/bindings/bindingRegistry.ts @@ -5,6 +5,47 @@ import { BoundConstructor } from './BoundConstructor'; const queries = new Map(); const commands = new Map(); +// Opt-in identities are separate from the legacy last-registration-wins namespace. +const queryIdentities = new Map>(); +const queryBindingListeners = new Set<() => void>(); + +function notifyQueryBindings(): void { + for (const listener of queryBindingListeners) listener(); +} + +/** Subscribe to exact-binding changes. Existing adapters retain their render-time lookup contract. */ +export function subscribeQueryBindings(listener: () => void): () => void { + queryBindingListeners.add(listener); + return () => { queryBindingListeners.delete(listener); }; +} + +/** + * Registers one source identity for an exact query name. The same identity replaces on hot reload; + * different identities remain distinct candidates, even when their constructors happen to match. + * Legacy resolution also accepts a unique identity when no legacy registration exists. + */ +export function registerQueryIdentity(name: string, identity: string, queryClass: BoundConstructor): void { + const candidates = queryIdentities.get(name) ?? new Map(); + candidates.set(identity, queryClass); + queryIdentities.set(name, candidates); + notifyQueryBindings(); +} + +/** Removes a source identity when a generated module is removed or renamed. */ +export function unregisterQueryIdentity(name: string, identity: string): void { + const candidates = queryIdentities.get(name); + candidates?.delete(identity); + if (candidates?.size === 0) queryIdentities.delete(name); + notifyQueryBindings(); +} + +/** Exact binding: a legacy registration counts as one candidate alongside opt-in identities. */ +export function resolveExactQuery(name: string): BoundConstructor | 'ambiguous' | undefined { + const legacy = queries.get(name); + const candidates = queryIdentities.get(name); + if ((legacy ? 1 : 0) + (candidates?.size ?? 0) > 1) return 'ambiguous'; + return legacy ?? candidates?.values().next().value; +} /** * Registers an Arc query proxy under the name screens refer to it by. @@ -24,6 +65,7 @@ const commands = new Map(); */ export function registerQuery(name: string, queryClass: BoundConstructor): void { queries.set(name, queryClass); + notifyQueryBindings(); } /** @@ -40,14 +82,18 @@ export function registerQueries(bindings: Record): voi } /** - * The query proxy registered under a name, or `undefined` when nothing is registered under it. + * Legacy registrations are authoritative. Otherwise resolve a unique source identity, or `undefined` + * when absent or ambiguous (existing adapters display their unresolved-binding placeholder). * * `undefined` rather than a throw: design-time preview in Studio normally has nothing registered at all, * and a screen still has to render so its layout can be worked on. Every adapter turns `undefined` into * a visible placeholder naming the binding it wanted. */ export function resolveQuery(name: string): BoundConstructor | undefined { - return queries.get(name); + const legacy = queries.get(name); + if (legacy) return legacy; + const candidates = queryIdentities.get(name); + return candidates?.size === 1 ? candidates.values().next().value : undefined; } /** @@ -75,14 +121,14 @@ export function resolveCommand(name: string): BoundConstructor | undefined { } /** - * Every registered query name, sorted. + * The sorted union of legacy and identity query names, including ambiguous names. * * A design-time tool uses this to offer the names a screen can actually bind to, and a diagnostics * surface uses it to explain a placeholder - "this screen wants `AllInvoices`, and here is what is * registered" is a far more useful message than the placeholder alone. */ export function registeredQueryNames(): string[] { - return [...queries.keys()].sort(); + return [...new Set([...queries.keys(), ...queryIdentities.keys()])].sort(); } /** @@ -102,4 +148,6 @@ export function registeredCommandNames(): string[] { export function clearBindings(): void { queries.clear(); commands.clear(); + queryIdentities.clear(); + notifyQueryBindings(); } diff --git a/Source/JavaScript/components/bindings/for_bindingRegistry/when_registering_query_identities.ts b/Source/JavaScript/components/bindings/for_bindingRegistry/when_registering_query_identities.ts new file mode 100644 index 0000000..b2850fb --- /dev/null +++ b/Source/JavaScript/components/bindings/for_bindingRegistry/when_registering_query_identities.ts @@ -0,0 +1,81 @@ +// 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 { clearBindings, registerQuery, registerQueryIdentity, registeredQueryNames, resolveExactQuery, resolveQuery, unregisterQueryIdentity } from '../bindingRegistry'; + +class First {} +class Replacement {} + +describe('when opting into collision-aware query identities', () => { + beforeEach(() => clearBindings()); + afterEach(() => clearBindings()); + + it('replaces the same source identity on hot reload', () => { + registerQueryIdentity('Lookup', 'source/Lookup', First); + registerQueryIdentity('Lookup', 'source/Lookup', Replacement); + expect(resolveExactQuery('Lookup')).toBe(Replacement); + expect(resolveQuery('Lookup')).toBe(Replacement); + expect(registeredQueryNames()).toEqual(['Lookup']); + }); + + it('counts different source identities even for the same constructor', () => { + registerQueryIdentity('Lookup', 'one/Lookup', First); + registerQueryIdentity('Lookup', 'two/Lookup', First); + expect(resolveExactQuery('Lookup')).toBe('ambiguous'); + expect(resolveQuery('Lookup')).toBeUndefined(); + expect(registeredQueryNames()).toEqual(['Lookup']); + unregisterQueryIdentity('Lookup', 'one/Lookup'); + expect(resolveExactQuery('Lookup')).toBe(First); + expect(resolveQuery('Lookup')).toBe(First); + unregisterQueryIdentity('Lookup', 'two/Lookup'); + expect(resolveExactQuery('Lookup')).toBeUndefined(); + expect(resolveQuery('Lookup')).toBeUndefined(); + expect(registeredQueryNames()).toEqual([]); + }); + + it('keeps legacy replacement authoritative while exact resolution counts mixed candidates', () => { + registerQuery('Lookup', First); + registerQuery('Lookup', Replacement); + expect(resolveQuery('Lookup')).toBe(Replacement); + expect(resolveExactQuery('Lookup')).toBe(Replacement); + registerQueryIdentity('Lookup', 'source/Lookup', First); + expect(resolveQuery('Lookup')).toBe(Replacement); + expect(resolveExactQuery('Lookup')).toBe('ambiguous'); + }); + + it('bridges a unique identity to legacy consumers with exact case-sensitive names and clears both maps', () => { + registerQueryIdentity('Lookup', 'source/Lookup', First); + expect(resolveQuery('Lookup')).toBe(First); + expect(resolveQuery('lookup')).toBeUndefined(); + expect(resolveExactQuery('lookup')).toBeUndefined(); + clearBindings(); + expect(resolveQuery('Lookup')).toBeUndefined(); + expect(resolveExactQuery('Lookup')).toBeUndefined(); + expect(registeredQueryNames()).toEqual([]); + }); + + it('lists the sorted union without duplicates, retaining legacy names after identity removal', () => { + registerQueryIdentity('Zed', 'source/Zed', First); + registerQueryIdentity('Lookup', 'source/Lookup', First); + registerQuery('Lookup', Replacement); + registerQuery('Alpha', First); + expect(registeredQueryNames()).toEqual(['Alpha', 'Lookup', 'Zed']); + unregisterQueryIdentity('Lookup', 'source/Lookup'); + unregisterQueryIdentity('Zed', 'source/Zed'); + unregisterQueryIdentity('Missing', 'source/Missing'); + expect(registeredQueryNames()).toEqual(['Alpha', 'Lookup']); + expect(resolveQuery('Lookup')).toBe(Replacement); + expect(resolveExactQuery('Lookup')).toBe(Replacement); + }); + + it('gives an existing legacy registration precedence even over multiple identities and later replacements', () => { + registerQueryIdentity('Lookup', 'one/Lookup', First); + registerQueryIdentity('Lookup', 'two/Lookup', First); + registerQuery('Lookup', First); + registerQuery('Lookup', Replacement); + registerQueryIdentity('Lookup', 'one/Lookup', Replacement); + expect(resolveQuery('Lookup')).toBe(Replacement); + expect(resolveExactQuery('Lookup')).toBe('ambiguous'); + }); +}); 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 index 6759716..7f87e6c 100644 --- a/Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx +++ b/Source/JavaScript/components/bindings/for_requireArcProxy/when_mounting_bound_adapters.tsx @@ -55,7 +55,9 @@ describe('when mounting bound adapters', () => { await act(async () => { render(); }); - expect(screen.getByText("Invalid query binding 'Save': expected a zero-argument Arc query proxy")).toBeTruthy(); + // The published Components boundary sanitizes exception text; assert visible failure, not + // its former raw-error presentation. requireArcProxy's specs cover the precise diagnostic. + expect(screen.getByRole('alert')).toBeTruthy(); expect(received).not.toHaveBeenCalled(); }); @@ -65,7 +67,7 @@ describe('when mounting bound adapters', () => { ); - expect(screen.getAllByText('Error')).toHaveLength(4); + expect(screen.getAllByRole('alert')).toHaveLength(4); expect(received).not.toHaveBeenCalled(); }); }); diff --git a/Source/JavaScript/components/cratisComponents.ts b/Source/JavaScript/components/cratisComponents.ts index f39f2b4..aadaef9 100644 --- a/Source/JavaScript/components/cratisComponents.ts +++ b/Source/JavaScript/components/cratisComponents.ts @@ -10,6 +10,7 @@ import { SceneChipsField, SceneColorPickerField, SceneCommandForm, + SceneQueryInputForm, SceneDropdownField, SceneInputTextField, SceneMultiSelectField, @@ -54,6 +55,7 @@ export const cratisComponents: ComponentRegistry = { [componentRegistryKey(cratisComponentsPackageName, 'table')]: SceneDataTable, [componentRegistryKey(cratisComponentsPackageName, 'observableDataTable')]: SceneObservableDataTable, + [componentRegistryKey(cratisComponentsPackageName, 'queryInputForm')]: SceneQueryInputForm, [componentRegistryKey(cratisComponentsPackageName, 'commandForm')]: SceneCommandForm, [componentRegistryKey(cratisComponentsPackageName, 'inputTextField')]: SceneInputTextField, [componentRegistryKey(cratisComponentsPackageName, 'numberField')]: SceneNumberField, diff --git a/Source/JavaScript/components/cratisComponentsPackage.ts b/Source/JavaScript/components/cratisComponentsPackage.ts index a43a8e8..b76f0d3 100644 --- a/Source/JavaScript/components/cratisComponentsPackage.ts +++ b/Source/JavaScript/components/cratisComponentsPackage.ts @@ -37,6 +37,7 @@ export const cratisComponentsPackageManifest: ScenePackage = { 'dataPage', 'formElement', 'singleResult', + 'queryInputForm', 'dataTable', 'table', 'observableDataTable', @@ -89,7 +90,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. singleResult displays one read-only scalar own resultField of an optional model using an explicit query, object queryArguments and enabled: true.", + 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. queryInputForm commits explicit string inputs on submit to the same optional-single runtime; drafts never execute a query.", module: '@cratis/scene.components', license: 'MIT', licenseUrl: 'https://github.com/Cratis/Scene/blob/main/LICENSE', diff --git a/Source/JavaScript/components/data/SingleResultRuntime.tsx b/Source/JavaScript/components/data/SingleResultRuntime.tsx index 6f3ee8a..2a5a88d 100644 --- a/Source/JavaScript/components/data/SingleResultRuntime.tsx +++ b/Source/JavaScript/components/data/SingleResultRuntime.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, useState } from 'react'; import { ArcContext } from '@cratis/arc.react'; import type { QueryFor } from '@cratis/arc/queries'; +import { Guid } from '@cratis/fundamentals'; import type { BoundConstructor } from '../bindings/BoundConstructor'; import { SingleResultStatus } from './SingleResultStatus'; @@ -11,6 +12,8 @@ interface Props { query: BoundConstructor; queryArguments: Record; resultField: string; + /** queryInputForm alone opts into scalar String/Guid descriptor mapping; no value conversion. */ + stringInputs?: boolean; } type Outcome = { state: 'failure' | 'notFound' } | { state: 'success'; value: string }; @@ -19,12 +22,12 @@ type Outcome = { state: 'failure' | 'notFound' } | { state: 'success'; value: st * 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) { +export default function SingleResultRuntime({ query, queryArguments, resultField, stringInputs = false }: 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 request = useMemo(() => ({ query, queryArguments, resultField, stringInputs, microservice, apiBasePath, origin, httpHeadersCallback, queryVersion }), + [query, queryArguments, resultField, stringInputs, microservice, apiBasePath, origin, httpHeadersCallback, queryVersion]); const [settled, setSettled] = useState<{ request: typeof request; outcome: Outcome }>(); useEffect(() => { @@ -42,6 +45,17 @@ export default function SingleResultRuntime({ query, queryArguments, resultField publish({ state: 'failure' }); return; } + // Arc perform does not validate argument-to-descriptor types. The form supports only + // scalar String/Guid inputs, matched by exact name and constructor, never by naming + // convention. This check is lazy and form-only; singleResult's host contract is intact. + if (request.stringInputs && !Object.entries(request.queryArguments).every(([name, value]) => { + const descriptors = instance!.parameterDescriptors.filter(descriptor => descriptor.name === name); + return typeof value === 'string' && descriptors.length === 1 && !descriptors[0].isEnumerable + && (descriptors[0].type === String || descriptors[0].type === Guid); + })) { + publish({ state: 'failure' }); + return; + } instance.setMicroservice(request.microservice); instance.setApiBasePath(request.apiBasePath ?? ''); instance.setOrigin(request.origin ?? ''); 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 43c24ff..ef5c73e 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 @@ -57,6 +57,25 @@ describe('when forwarding query arguments', () => { properties.should.have.property('queryArguments').that.deep.equals({ projectId: suppliedId }); }); + it('should bridge an identity-only query to an existing table without a second legacy registration', async () => { + bindings.clearBindings(); + bindings.registerQueryIdentity('InvoicesForProject', 'Invoices/ForProject', InvoicesForProject); + const queryArguments = { projectId: suppliedId }; + await renderTable({ query: 'InvoicesForProject', queryArguments }); + await screen.findByTestId('query-table'); + (received.mock.lastCall![0].query === InvoicesForProject).should.equal(true); + (received.mock.lastCall![0].queryArguments === queryArguments).should.equal(true); + }); + + it('should visibly reject ambiguous identity-only tables without selecting either native proxy', async () => { + bindings.clearBindings(); + bindings.registerQueryIdentity('InvoicesForProject', 'One/ForProject', InvoicesForProject); + bindings.registerQueryIdentity('InvoicesForProject', 'Two/ForProject', AnotherQuery); + await renderTable({ query: 'InvoicesForProject' }); + (screen.getByText("Unresolved query binding 'InvoicesForProject' on Cratis.Components:dataTable") !== null).should.equal(true); + received.mock.calls.should.have.lengthOf(0); + }); + it('should preserve all supplied keys and values without normalization or defaults', async () => { const queryArguments = { projectId: suppliedId, ProjectID: 'distinct', limit: 0, enabled: false, optional: null, filter: { names: ['one'] } }; await renderTable({ query: 'InvoicesForProject', queryArguments }); diff --git a/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_query_input_form.ts b/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_query_input_form.ts new file mode 100644 index 0000000..9eb471e --- /dev/null +++ b/Source/JavaScript/components/for_cratisComponentsPackage/when_describing_query_input_form.ts @@ -0,0 +1,17 @@ +// 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 { SceneQueryInputForm, cratisComponents, cratisComponentsPackageManifest, registerQueryIdentity, resolveExactQuery, type QueryInput } from '../index'; + +describe('when describing the query input form public API', () => { + it('exports and registers the component advertised by the manifest', () => { + expect(cratisComponentsPackageManifest.components).toContain('queryInputForm'); + expect(cratisComponents['Cratis.Components:queryInputForm']).toBe(SceneQueryInputForm); + expect(cratisComponentsPackageManifest.description).toContain('commits explicit string inputs on submit'); + expect(registerQueryIdentity).toBeTypeOf('function'); + expect(resolveExactQuery).toBeTypeOf('function'); + const input: QueryInput = { parameter: 'projectId', type: 'string', label: 'Project identifier' }; + expect(input.type).toBe('string'); + }); +}); diff --git a/Source/JavaScript/components/forms/SceneQueryInputForm.tsx b/Source/JavaScript/components/forms/SceneQueryInputForm.tsx new file mode 100644 index 0000000..dd30eac --- /dev/null +++ b/Source/JavaScript/components/forms/SceneQueryInputForm.tsx @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { lazy, useId, useState, useSyncExternalStore } from 'react'; +import { RegisteredComponentProps } from '@cratis/scene.react'; +import { ArcRuntimeBoundary, BindingKind, BoundConstructor, MissingBinding, resolveExactQuery, subscribeQueryBindings } from '../bindings'; +import { stringProperty } from '../properties'; +import { SingleResultStatus } from '../data/SingleResultStatus'; +import { QueryInput, queryInputError, queryInputs } from './queryInputs'; + +const SingleResultRuntime = lazy(() => import('../data/SingleResultRuntime')); + +/** Opt-in, form-local string drafts around the existing optional-single execution owner. */ +export function SceneQueryInputForm({ element }: RegisteredComponentProps) { + const name = stringProperty(element.properties, 'query'); + const target = useSyncExternalStore(subscribeQueryBindings, () => name ? resolveExactQuery(name) : undefined); + if (target === 'ambiguous') return
Ambiguous query binding '{name}' on {element.componentName}
; + if (!target) return ; + + const inputs = queryInputs(element.properties); + const resultField = stringProperty(element.properties, 'resultField'); + if (!inputs || !resultField) return
Invalid query input form configuration
; + if (!element.isEnabled) return ; + + // A different form declaration starts a new session; ordinary host rerenders retain drafts. + return ; +} + +interface Props { + query: BoundConstructor; + inputs: QueryInput[]; + resultField: string; + label: string; + submitLabel: string; +} + +function QueryInputForm({ query, inputs, resultField, label, submitLabel }: Props) { + const id = useId(); + const [drafts, setDrafts] = useState>(() => Object.fromEntries(inputs.map(input => [input.parameter, '']))); + const [errors, setErrors] = useState>({}); + const [committed, setCommitted] = useState>(); + + return
{ + event.preventDefault(); + const validation = Object.fromEntries(inputs.map(input => [input.parameter, queryInputError(input, drafts[input.parameter])])); + setErrors(validation); + if (Object.values(validation).some(Boolean)) { + setCommitted(undefined); + return; + } + // New identity even for unchanged values: repeat submit is an explicit retry. No trimming, + // coercion, generated identifier, property-path evaluation or query while typing. + setCommitted(Object.freeze(Object.fromEntries(inputs.map(input => [input.parameter, drafts[input.parameter]])))); + }}> + {inputs.map((input, index) => { + const controlId = `${id}-${index}`; + const error = Object.hasOwn(errors, input.parameter) ? errors[input.parameter] : undefined; + return
+ + { + setDrafts({ ...drafts, [input.parameter]: event.currentTarget.value }); + setErrors({ ...errors, [input.parameter]: undefined }); + // Unmount synchronously on edit: runtime cleanup aborts and masks late replies. + setCommitted(undefined); + }} /> + {error && } +
; + })} + + {committed ? + + : } + ; +} diff --git a/Source/JavaScript/components/forms/for_SceneQueryInputForm/ProjectLookup.ts b/Source/JavaScript/components/forms/for_SceneQueryInputForm/ProjectLookup.ts new file mode 100644 index 0000000..3a1dba6 --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneQueryInputForm/ProjectLookup.ts @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// Canonical Projects/Registration/ProjectLookup/ProjectLookup.ts generated consumer shape. +// Keep Guid parameters, descriptors, model decorators, default value and native methods intact. +import { QueryFor, QueryResultWithState } from '@cratis/arc/queries'; +import { useQuery, useSuspenseQuery, PerformQuery, SetSorting, QueryWhen } from '@cratis/arc.react/queries'; +import { ParameterDescriptor } from '@cratis/arc/reflection'; +import { Guid, field } from '@cratis/fundamentals'; + +export interface ProjectByIdParameters { + projectId: Guid; +} + +export class ProjectById extends QueryFor { + readonly route: string = '/api/projects/registration/project-lookup/project-by-id'; + readonly queryName: string = 'CanonicalProjects.Projects.Registration.ProjectLookup.ProjectSummary.ProjectById'; + readonly treatWarningsAsErrors: boolean = false; + readonly roles: string[] = []; + readonly defaultValue: ProjectSummary = {} as ProjectSummary; + + constructor() { + super(ProjectSummary, false); + } + + get requiredRequestParameters(): string[] { + return ['projectId']; + } + + readonly parameterDescriptors: ParameterDescriptor[] = [ + new ParameterDescriptor('projectId', Guid, false), + ]; + + projectId!: Guid; + + static use(args?: ProjectByIdParameters): [QueryResultWithState, PerformQuery, SetSorting] { + return useQuery(ProjectById, args); + } + + static useSuspense(args?: ProjectByIdParameters): [QueryResultWithState, PerformQuery, SetSorting] { + return useSuspenseQuery(ProjectById, args); + } + + static when(condition: boolean): QueryWhen { + return new QueryWhen(ProjectById, condition); + } +} + +export class ProjectSummary { + @field(String) + name!: string; + @field(Guid) + projectId!: Guid; +} diff --git a/Source/JavaScript/components/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.tsx b/Source/JavaScript/components/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.tsx new file mode 100644 index 0000000..28adf13 --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneQueryInputForm/when_submitting_generated_guid_parameters.tsx @@ -0,0 +1,188 @@ +// 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, fireEvent, render, screen } from '@testing-library/react'; +import { expect, vi } from 'vitest'; +import { ArcContext } from '@cratis/arc.react'; +import { QueryFor } from '@cratis/arc/queries'; +import { ParameterDescriptor } from '@cratis/arc/reflection'; +import { Guid } from '@cratis/fundamentals'; +import { SceneElementView } from '@cratis/scene.react'; +import { clearBindings, registerQuery, registerQueryIdentity, unregisterQueryIdentity } from '../../bindings'; +import { cratisComponents } from '../../cratisComponents'; +import { externalComponent } from '../../given'; +import { ProjectById, ProjectByIdParameters } from './ProjectLookup'; + +const projectId = 'AB123456-7890-4ABC-8DEF-1234567890AB'; +const input = { parameter: 'projectId', type: 'string', label: 'Project identifier' }; +const identity = 'Projects/Registration/ProjectLookup/ProjectLookup.ts:ProjectById'; +const context = { microservice: 'projects', origin: 'https://example.test', apiBasePath: '' }; + +function response(data: unknown) { + return { ok: true, status: 200, json: async () => ({ + data, isSuccess: true, isAuthorized: true, isValid: true, hasExceptions: false, + validationResults: [], exceptionMessages: [], exceptionStackTrace: '', + paging: { page: 0, size: 0, totalItems: 0, totalPages: 0 }, + }) } as Response; +} + +function view(properties: Record = {}, component = 'queryInputForm') { + return + undefined} /> + ; +} + +async function submit(value = projectId) { + fireEvent.change(screen.getByRole('textbox', { name: input.label }), { target: { value } }); + await act(async () => { fireEvent.submit(screen.getByRole('form')); }); +} + +describe('when submitting the canonical generated Guid proxy shape through SceneElementView', () => { + const fetch = vi.fn(); + + beforeEach(() => { + clearBindings(); + registerQueryIdentity('ProjectById', identity, ProjectById); + fetch.mockReset(); + fetch.mockResolvedValue(response({ name: 'Canonical project', projectId })); + vi.stubGlobal('fetch', fetch); + }); + + afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); }); + + it('keeps the generated Guid contract and inherited native perform, sending exact entered text', async () => { + const proxy = new ProjectById(); + expect(proxy.perform).toBe(QueryFor.prototype.perform); + expect(proxy.parameterDescriptors).toEqual([new ParameterDescriptor('projectId', Guid, false)]); + expect(proxy.requiredRequestParameters).toEqual(['projectId']); + expect(proxy.defaultValue).toEqual({}); + expect(proxy.queryName).toBe('CanonicalProjects.Projects.Registration.ProjectLookup.ProjectSummary.ProjectById'); + render(view()); + expect(fetch).not.toHaveBeenCalled(); + await submit(); + expect(await screen.findByText('Canonical project')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toBe(`https://example.test/api/projects/registration/project-lookup/project-by-id?projectId=${projectId}`); + expect(init?.method).toBe('GET'); + expect(new URL(String(url)).searchParams.get('projectId')).toBe(projectId); + expect(screen.getByRole('textbox')).toHaveProperty('value', projectId); + }); + + for (const data of [null, undefined]) { + it(`preserves native absence (${String(data)}) despite the generated empty-object default`, async () => { + fetch.mockResolvedValue(response(data)); + render(view()); await submit(); + expect(await screen.findByText('Not found')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + } + + it('does not claim Guid descriptors enforce format: native perform sends an unformatted string unchanged', async () => { + render(view()); await submit(' not-a-guid '); + expect(await screen.findByText('Canonical project')).not.toBeNull(); + expect(new URL(String(fetch.mock.calls[0][0])).searchParams.get('projectId')).toBe(' not-a-guid '); + }); + + it('lets an explicitly supplied pattern reject invalid Guid text before HTTP and permit correction', async () => { + render(view({ inputs: [{ ...input, pattern: '[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}' }] })); + await submit('not-a-guid'); + expect(screen.getByRole('alert').textContent).toBe('Project identifier has an invalid format'); + expect(fetch).not.toHaveBeenCalled(); + expect(screen.getByRole('textbox')).toHaveProperty('value', 'not-a-guid'); + await submit(); + expect(await screen.findByText('Canonical project')).not.toBeNull(); + expect(new URL(String(fetch.mock.calls[0][0])).searchParams.get('projectId')).toBe(projectId); + }); + + it('retains native required-argument validation when the form permits an empty string', async () => { + render(view({ inputs: [{ ...input, required: false }] })); await submit(''); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(fetch).not.toHaveBeenCalled(); + }); + + for (const descriptors of [ + [new ParameterDescriptor('projectId', Number, false)], + [new ParameterDescriptor('projectId', Boolean, false)], + [new ParameterDescriptor('projectId', Object, false)], + [new ParameterDescriptor('projectId', Guid, true)], + [new ParameterDescriptor('projectId', String, true)], + [new ParameterDescriptor('ProjectID', Guid, false)], + [], + [new ParameterDescriptor('projectId', Guid, false), new ParameterDescriptor('projectId', String, false)], + ]) { + it(`rejects unsupported descriptor mapping before HTTP: ${descriptors.map(d => `${d.name}:${d.type.name}:${d.isEnumerable}`).join(',')}`, async () => { + class Incompatible extends ProjectById { readonly parameterDescriptors = descriptors; } + registerQueryIdentity('ProjectById', identity, Incompatible); + render(view()); await submit(); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(screen.getByRole('textbox')).toHaveProperty('value', projectId); + expect(fetch).not.toHaveBeenCalled(); + }); + } + + it('rejects an unknown extra parameter even when every required native argument is present', async () => { + render(view({ inputs: [input, { parameter: 'unknown', type: 'string', label: 'Unknown', required: false }] })); + await submit(); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('demonstrates why native descriptor mapping alone cannot protect the form', async () => { + class Numeric extends ProjectById { + readonly parameterDescriptors = [new ParameterDescriptor('projectId', Number, false)]; + } + const proxy = new Numeric(); + proxy.setOrigin(context.origin); + // Deliberate runtime mismatch, NOT a change to the emitted consumer's parameter interface. + const args = { projectId: 'not-a-number', unknown: 'extra' } as unknown as ProjectByIdParameters; + const result = await proxy.perform(args); + expect(result.isSuccess).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + const url = new URL(String(fetch.mock.calls[0][0])); + expect(url.searchParams.get('projectId')).toBe('not-a-number'); + expect(url.searchParams.get('unknown')).toBe('extra'); + }); + + it('allows an identity-only registration to serve an existing singleResult with the same semantic name', async () => { + render(view({ queryArguments: { projectId }, enabled: true }, 'singleResult')); + expect(await screen.findByText('Canonical project')).not.toBeNull(); + expect(new URL(String(fetch.mock.calls[0][0])).searchParams.get('projectId')).toBe(projectId); + }); + + it('fails visibly without selecting an ambiguous identity in an existing singleResult', () => { + registerQueryIdentity('ProjectById', 'Other/ProjectById', ProjectById); + render(view({ queryArguments: { projectId }, enabled: true }, 'singleResult')); + expect(screen.getByText("Unresolved query binding 'ProjectById' on Cratis.Components:singleResult")).not.toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('resolves identity replacement and removal on an existing adapter host rerender', async () => { + const mounted = render(view({ queryArguments: { projectId }, enabled: true }, 'singleResult')); + expect(await screen.findByText('Canonical project')).not.toBeNull(); + class Replacement extends ProjectById { readonly route = '/replacement'; } + act(() => { registerQueryIdentity('ProjectById', identity, Replacement); }); + mounted.rerender(view({ queryArguments: { projectId }, enabled: true }, 'singleResult')); + await screen.findByText('Canonical project'); + expect(fetch).toHaveBeenCalledTimes(2); + expect(new URL(String(fetch.mock.calls[1][0])).pathname).toBe('/replacement'); + act(() => { unregisterQueryIdentity('ProjectById', identity); }); + mounted.rerender(view({ queryArguments: { projectId }, enabled: true }, 'singleResult')); + expect(screen.getByText("Unresolved query binding 'ProjectById' on Cratis.Components:singleResult")).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('keeps legacy replacement authoritative for singleResult while queryInputForm rejects mixed candidates', async () => { + class Legacy extends ProjectById { readonly route = '/legacy'; } + registerQuery('ProjectById', ProjectById); + registerQuery('ProjectById', Legacy); + const mounted = render(view({ queryArguments: { projectId }, enabled: true }, 'singleResult')); + expect(await screen.findByText('Canonical project')).not.toBeNull(); + expect(new URL(String(fetch.mock.calls[0][0])).pathname).toBe('/legacy'); + mounted.rerender(view()); + expect(screen.getByRole('alert').textContent).toContain("Ambiguous query binding 'ProjectById'"); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Source/JavaScript/components/forms/for_SceneQueryInputForm/when_submitting_native_queries.tsx b/Source/JavaScript/components/forms/for_SceneQueryInputForm/when_submitting_native_queries.tsx new file mode 100644 index 0000000..2a5353b --- /dev/null +++ b/Source/JavaScript/components/forms/for_SceneQueryInputForm/when_submitting_native_queries.tsx @@ -0,0 +1,423 @@ +// 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, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { expect, vi } from 'vitest'; +import { ArcContext, type ArcConfiguration } from '@cratis/arc.react'; +import { QueryFor, QueryHttpMethod, QueryValidator } from '@cratis/arc/queries'; +import { ParameterDescriptor } from '@cratis/arc/reflection'; +import { SceneElementView } from '@cratis/scene.react'; +import { clearBindings, registerQuery, registerQueryIdentity, unregisterQueryIdentity } from '../../bindings'; +import { cratisComponents } from '../../cratisComponents'; +import { externalComponent } from '../../given'; + +class ArgumentsValidator extends QueryValidator<{ projectId: string }> { + static received: object[] = []; + constructor() { + super(); + this.ruleFor(args => { + // ruleFor first invokes the accessor on a property-name discovery proxy, not request data. + if (Object.hasOwn(args, 'projectId')) ArgumentsValidator.received.push(args); + return args.projectId; + }).minLength(2); + } +} + +// Native published Arc perform, required argument checks, validator, routing and deserialization. +// Only fetch is substituted; neither the renderer nor any runtime/primitive is mocked. +class ProjectName extends QueryFor { + readonly route = '/projects/{projectId}/name'; + readonly requiredRequestParameters = ['projectId']; + readonly parameterDescriptors = [ + new ParameterDescriptor('projectId', String, false), + new ParameterDescriptor('locale', String, false), + new ParameterDescriptor('toString', String, false), + ]; + readonly validation = new ArgumentsValidator(); + defaultValue = undefined; + constructor() { + super(Object, false); + this.setHttpMethod(QueryHttpMethod.Get); + // Model an unset descriptor-backed instance slot, not Object.prototype.toString: + // native Arc collects instance values as well as the explicit perform arguments. + Object.defineProperty(this, 'toString', { value: undefined }); + } +} + +class AllProjects extends QueryFor { + readonly route = '/projects'; + readonly requiredRequestParameters = []; + readonly parameterDescriptors = []; + defaultValue = []; + constructor() { super(Object, true); this.setHttpMethod(QueryHttpMethod.Get); } +} + +function response(data: unknown, overrides: Record = {}) { + return { ok: true, status: 200, json: async () => ({ + data, isSuccess: true, isAuthorized: true, isValid: true, hasExceptions: false, + validationResults: [], exceptionMessages: [], exceptionStackTrace: '', + paging: { page: 0, size: 0, totalItems: 0, totalPages: 0 }, ...overrides, + }) } as Response; +} + +function deferred() { + let resolve!: (response: Response) => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +const input = { parameter: 'projectId', type: 'string', label: 'Project identifier' }; +const context: ArcConfiguration = { + microservice: 'projects', origin: 'https://example.test', apiBasePath: '/backend', + httpHeadersCallback: () => ({ 'x-host': 'original' }), +}; + +function view(properties: Record = {}, arc = context, isEnabled = true) { + const element = { ...externalComponent('Cratis.Components:queryInputForm', { + query: 'ProjectName', inputs: [input], resultField: 'name', ...properties, + }), isEnabled }; + return + undefined} /> + ; +} + +function edit(value: string, label = 'Project identifier') { + fireEvent.change(screen.getByRole('textbox', { name: label }), { target: { value } }); +} + +async function submit() { + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Search' })); }); +} + +describe('when submitting query inputs through SceneElementView and native Arc', () => { + const fetch = vi.fn(); + + beforeEach(() => { + clearBindings(); + registerQueryIdentity('ProjectName', 'Queries/ProjectName', ProjectName); + registerQuery('AllProjects', AllProjects); + ArgumentsValidator.received = []; + fetch.mockReset(); + fetch.mockResolvedValue(response({ name: 'Project result' })); + vi.stubGlobal('fetch', fetch); + }); + + afterEach(() => { cleanup(); clearBindings(); vi.unstubAllGlobals(); }); + + it('starts empty, never executes while editing, and commits exact named strings in an immutable snapshot', async () => { + render(view({ inputs: [input, { parameter: 'locale', type: 'string', label: 'Locale', required: false }] })); + expect(screen.getByRole('textbox', { name: input.label })).toHaveProperty('value', ''); + edit(' exact-ID '); + edit(' nb & en ', 'Locale'); + expect(fetch).not.toHaveBeenCalled(); + expect(ArgumentsValidator.received).toHaveLength(0); + await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toBe('https://example.test/backend/projects/%20%20exact-ID%20%20/name?locale=+nb+%26+en+'); + expect(new Headers(init?.headers).get('x-host')).toBe('original'); + expect(new Headers(init?.headers).get('x-cratis-microservice')).toBe('projects'); + const snapshot = ArgumentsValidator.received[0]; + expect(snapshot).toEqual({ projectId: ' exact-ID ', locale: ' nb & en ' }); + expect(Object.isFrozen(snapshot)).toBe(true); + edit('different'); + expect(screen.queryByText('Project result')).toBeNull(); + expect(snapshot).toEqual({ projectId: ' exact-ID ', locale: ' nb & en ' }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('uses the native form submit event (also emitted by implicit Enter submission)', async () => { + render(view()); + edit('entered-id'); + // jsdom does not implement implicit keyboard submission; dispatch its native submit event. + await act(async () => { fireEvent.submit(screen.getByRole('form', { name: 'Query input' })); }); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(String(fetch.mock.calls[0][0])).toContain('/entered-id/name'); + }); + + for (const blank of ['', ' ', '\t']) { + it(`shows required input feedback without performing for ${JSON.stringify(blank)}`, async () => { + render(view()); + edit(blank); + await submit(); + expect(screen.getByRole('alert').textContent).toBe('Project identifier is required'); + const control = screen.getByRole('textbox', { name: input.label }); + expect(control.getAttribute('aria-invalid')).toBe('true'); + expect(control.getAttribute('aria-describedby')).toBe(screen.getByRole('alert').id); + expect(ArgumentsValidator.received).toHaveLength(0); + expect(fetch).not.toHaveBeenCalled(); + }); + } + + it('validates an explicitly declared whole-string format without coercion and allows correction', async () => { + render(view({ inputs: [{ ...input, pattern: '[A-Z]{2}-[0-9]{2}' }] })); + edit(' xx-12 '); + await submit(); + expect(screen.getByRole('alert').textContent).toBe('Project identifier has an invalid format'); + expect(fetch).not.toHaveBeenCalled(); + expect(ArgumentsValidator.received).toHaveLength(0); + expect(screen.getByRole('textbox')).toHaveProperty('value', ' xx-12 '); + edit('AB-12'); + await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(ArgumentsValidator.received[0]).toEqual({ projectId: 'AB-12' }); + }); + + for (const value of ['12\n', '12\r', '12\r\n', '12\u2028', '12\u2029']) { + it(`rejects a final line terminator in whole-string patterns: ${JSON.stringify(value)}`, async () => { + // Without the multiline flag JS $ requires the actual end. Native text controls + // sanitize CR/LF; inject a raw value to exercise validation without that help. + expect(/^(?:[0-9]+)$/u.test(value)).toBe(false); + render(view({ inputs: [{ ...input, pattern: '[0-9]+' }] })); + const control = screen.getByRole('textbox'); + Object.defineProperty(control, 'value', { configurable: true, get: () => value }); + fireEvent.change(control); + await submit(); + expect(screen.getByRole('alert').textContent).toBe('Project identifier has an invalid format'); + expect(control).toHaveProperty('value', value); + expect(ArgumentsValidator.received).toHaveLength(0); + expect(fetch).not.toHaveBeenCalled(); + }); + } + + it('preserves an explicitly allowed newline through validation and native HTTP', async () => { + const value = '12\n'; + render(view({ inputs: [{ ...input, pattern: '[0-9]+\\s' }] })); + const control = screen.getByRole('textbox'); + Object.defineProperty(control, 'value', { configurable: true, get: () => value }); + fireEvent.change(control); + await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(ArgumentsValidator.received[0]).toEqual({ projectId: value }); + expect(String(fetch.mock.calls[0][0])).toContain('/12%0A/name'); + }); + + it('lets native validation reject without HTTP, retains drafts, and allows retry after correction', async () => { + render(view()); + edit('x'); + await submit(); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(fetch).not.toHaveBeenCalled(); + expect(ArgumentsValidator.received[0]).toEqual({ projectId: 'x' }); + expect(screen.getByRole('textbox')).toHaveProperty('value', 'x'); + edit('xx'); + await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not guess parameter names when a declaration mismatches the proxy', async () => { + render(view({ inputs: [{ ...input, parameter: 'ProjectID' }] })); + edit('explicit-id'); + await submit(); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(fetch).not.toHaveBeenCalled(); + }); + + for (const data of [null, undefined]) { + it(`shows not found for a successful ${String(data)} model and permits repeat submit`, async () => { + fetch.mockResolvedValueOnce(response(data)); + render(view()); edit('missing-id'); await submit(); + expect(await screen.findByText('Not found')).not.toBeNull(); + await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(ArgumentsValidator.received[0]).toEqual(ArgumentsValidator.received[1]); + expect(ArgumentsValidator.received[0]).not.toBe(ArgumentsValidator.received[1]); + }); + } + + it('allows retry of unchanged input after transport failure', async () => { + fetch.mockRejectedValueOnce(new Error('private network details')); + render(view()); edit('retry-id'); await submit(); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(document.body.textContent).not.toContain('private network details'); + await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + for (const failure of [{ isValid: false }, { isAuthorized: false }, { isSuccess: false }, { hasExceptions: true }]) { + it(`preserves native failure precedence for ${JSON.stringify(failure)}`, async () => { + fetch.mockResolvedValue(response(null, { ...failure, exceptionMessages: ['secret'] })); + render(view()); edit('valid-id'); await submit(); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(screen.queryByText('Not found')).toBeNull(); + expect(document.body.textContent).not.toContain('secret'); + }); + } + + it('aborts on edit and ignores stale response after a newer submission even when HTTP ignores abort', async () => { + const a = deferred(); const b = deferred(); + fetch.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise); + render(view()); edit('first'); await submit(); + const signal = fetch.mock.calls[0][1]?.signal; + edit('second'); + expect(signal?.aborted).toBe(true); + expect(screen.getByText('Idle')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + await submit(); + await act(async () => { b.resolve(response({ name: 'Second result' })); }); + await act(async () => { a.resolve(response({ name: 'Obsolete result' })); }); + expect(screen.getByText('Second result')).not.toBeNull(); + expect(screen.queryByText('Obsolete result')).toBeNull(); + }); + + it('never resurrects a result by editing back to the previously submitted value', async () => { + render(view()); edit('first'); await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + edit('second'); edit('first'); + expect(screen.queryByText('Project result')).toBeNull(); + expect(screen.getByText('Idle')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('ignores a pending result after edit without a second submit', async () => { + const pending = deferred(); fetch.mockReturnValueOnce(pending.promise); + render(view()); edit('first'); await submit(); edit('second'); + await act(async () => { pending.resolve(response({ name: 'Obsolete result' })); }); + expect(screen.queryByText('Obsolete result')).toBeNull(); + expect(screen.getByText('Idle')).not.toBeNull(); + }); + + it('aborts on unmount and ignores successful late completion', async () => { + const pending = deferred(); fetch.mockReturnValueOnce(pending.promise); + const mounted = render(view()); edit('first'); await submit(); + mounted.unmount(); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(response({ name: 'Disposed result' })); }); + expect(screen.queryByText('Disposed result')).toBeNull(); + }); + + it('reuses runtime invalidation when Arc context headers change', async () => { + const pending = deferred(); + const mounted = render(view()); edit('first'); await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + fetch.mockReturnValueOnce(pending.promise); + mounted.rerender(view({}, { ...context, httpHeadersCallback: () => ({ 'x-host': 'new' }) })); + expect(screen.queryByText('Project result')).toBeNull(); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(new Headers(fetch.mock.calls[1][1]?.headers).get('x-host')).toBe('new'); + await act(async () => { pending.resolve(response({ name: 'New context' })); }); + expect(screen.getByText('New context')).not.toBeNull(); + }); + + for (const query of [undefined, 'Missing', 'projectname']) { + it(`visibly fails exact missing binding ${String(query)} without choosing AllProjects`, () => { + render(view({ query })); + expect(screen.getByText(query ? `Unresolved query binding '${query}' on Cratis.Components:queryInputForm` : 'Missing query binding on Cratis.Components:queryInputForm')).not.toBeNull(); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + } + + it('shows ambiguous identity registrations and never chooses a candidate', () => { + registerQueryIdentity('ProjectName', 'Other/ProjectName', ProjectName); + render(view()); + expect(screen.getByRole('alert').textContent).toContain("Ambiguous query binding 'ProjectName'"); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('fails closed and cancels when a mounted binding becomes ambiguous, then starts fresh on resolution', async () => { + const pending = deferred(); fetch.mockReturnValueOnce(pending.promise); + render(view()); edit('first'); await submit(); + act(() => { registerQueryIdentity('ProjectName', 'Other/ProjectName', ProjectName); }); + expect(screen.getByRole('alert').textContent).toContain('Ambiguous query binding'); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(response({ name: 'Obsolete result' })); }); + act(() => { unregisterQueryIdentity('ProjectName', 'Other/ProjectName'); }); + expect(screen.getByRole('textbox')).toHaveProperty('value', ''); + expect(screen.queryByText('Obsolete result')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('accepts legacy registration replacement without making it ambiguous', async () => { + clearBindings(); + registerQuery('ProjectName', AllProjects); + registerQuery('ProjectName', ProjectName); + render(view()); edit('legacy-id'); await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('replaces a mounted source identity on hot reload using the runtime cancellation contract', async () => { + const pending = deferred(); fetch.mockReturnValueOnce(pending.promise); + render(view()); edit('reload-id'); await submit(); + class Replacement extends ProjectName {} + await act(async () => { registerQueryIdentity('ProjectName', 'Queries/ProjectName', Replacement); }); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + expect(await screen.findByText('Project result')).not.toBeNull(); + await act(async () => { pending.resolve(response({ name: 'Obsolete result' })); }); + expect(screen.queryByText('Obsolete result')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(ArgumentsValidator.received[0]).toBe(ArgumentsValidator.received[1]); + }); + + it('includes optional empty strings verbatim and supports exact own names without prototype lookup', async () => { + render(view({ inputs: [input, { parameter: 'toString', type: 'string', label: 'Optional input', required: false }] })); + edit('some-id'); await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + expect(ArgumentsValidator.received[0]).toEqual({ projectId: 'some-id', toString: '' }); + expect(new URL(String(fetch.mock.calls[0][0])).searchParams.get('toString')).toBe(''); + }); + + it('still runs native required-argument validation when a declared optional string is empty', async () => { + render(view({ inputs: [{ ...input, required: false }] })); + await submit(); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('allows an unchanged pending submission to be superseded by an explicit retry', async () => { + const pending = deferred(); fetch.mockReturnValueOnce(pending.promise); + render(view()); edit('retry-id'); await submit(); + await submit(); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + expect(await screen.findByText('Project result')).not.toBeNull(); + await act(async () => { pending.resolve(response({ name: 'Old attempt' })); }); + expect(screen.queryByText('Old attempt')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('rejects an enumerable proxy before native perform rather than falling back to a collection', async () => { + render(view({ query: 'AllProjects' })); edit('not-used'); await submit(); + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Unable to load result'); + expect(fetch).not.toHaveBeenCalled(); + }); + + for (const inputs of [undefined, [], {}, [null], [{ ...input, type: 'number' }], [input, input], [{ ...input, parameter: '' }], [{ ...input, label: 7 }], [{ ...input, required: 'true' }], [{ ...input, pattern: '[' }], [{ ...input, pattern: 7 }]]) { + it(`rejects malformed declarations ${JSON.stringify(inputs)} visibly without native execution`, () => { + render(view({ inputs })); + expect(screen.getByRole('alert').textContent).toBe('Invalid query input form configuration'); + expect(screen.queryByRole('textbox')).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + expect(ArgumentsValidator.received).toHaveLength(0); + }); + } + + it('retains drafts across equivalent host rerenders but resets on a new declaration', async () => { + const mounted = render(view()); edit('draft'); + mounted.rerender(view()); + expect(screen.getByRole('textbox')).toHaveProperty('value', 'draft'); + await submit(); + expect(await screen.findByText('Project result')).not.toBeNull(); + mounted.rerender(view({ inputs: [{ ...input, label: 'New identifier' }] })); + expect(screen.getByRole('textbox')).toHaveProperty('value', ''); + expect(screen.queryByText('Project result')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('cancels and clears the session when the element is disabled', async () => { + const pending = deferred(); fetch.mockReturnValueOnce(pending.promise); + const mounted = render(view()); edit('first'); await submit(); + mounted.rerender(view({}, context, false)); + expect(fetch.mock.calls[0][1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(response({ name: 'Obsolete result' })); }); + expect(screen.queryByText('Obsolete result')).toBeNull(); + mounted.rerender(view()); + expect(screen.getByRole('textbox')).toHaveProperty('value', ''); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Source/JavaScript/components/forms/index.ts b/Source/JavaScript/components/forms/index.ts index 11432d0..611a306 100644 --- a/Source/JavaScript/components/forms/index.ts +++ b/Source/JavaScript/components/forms/index.ts @@ -2,4 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. export * from './SceneCommandForm'; +export * from './SceneQueryInputForm'; +export type { QueryInput } from './queryInputs'; export * from './fields'; diff --git a/Source/JavaScript/components/forms/queryInputs.ts b/Source/JavaScript/components/forms/queryInputs.ts new file mode 100644 index 0000000..d1e40e7 --- /dev/null +++ b/Source/JavaScript/components/forms/queryInputs.ts @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { arrayProperty, booleanProperty, objectProperty, stringProperty } from '../properties'; + +/** Package-local web input declaration, not a Scene.Model form or binding expression. */ +export interface QueryInput { + /** Exact generated query argument name. */ + parameter: string; + type: 'string'; + label: string; + /** Defaults to true. Required strings must contain a non-whitespace character. */ + required?: boolean; + /** Optional whole-value JavaScript Unicode regular expression (without delimiters). */ + pattern?: string; +} + +/** Reject the entire declaration rather than silently dropping malformed inputs. */ +export function queryInputs(properties: Record): QueryInput[] | undefined { + const values = arrayProperty(properties, 'inputs'); + if (!values?.length) return undefined; + const inputs: QueryInput[] = []; + const parameters = new Set(); + for (const value of values) { + const input = objectProperty({ value }, 'value'); + if (!input) return undefined; + const parameter = stringProperty(input, 'parameter'); + const label = stringProperty(input, 'label'); + const required = booleanProperty(input, 'required'); + const pattern = stringProperty(input, 'pattern'); + if (!parameter || !label || input.type !== 'string' || parameters.has(parameter) + || (input.required !== undefined && required === undefined) + || (input.pattern !== undefined && pattern === undefined)) return undefined; + if (pattern !== undefined) { + try { new RegExp(`^(?:${pattern})$`, 'u'); } catch { return undefined; } + } + parameters.add(parameter); + inputs.push({ parameter, type: 'string', label, required: required ?? true, pattern }); + } + return inputs; +} + +/** Validation never transforms the submitted string; proxy validators remain authoritative. */ +export function queryInputError(input: QueryInput, value: string): string | undefined { + if (input.required && !/\S/u.test(value)) return `${input.label} is required`; + if (input.pattern !== undefined && !new RegExp(`^(?:${input.pattern})$`, 'u').test(value)) { + return `${input.label} has an invalid format`; + } + return undefined; +} diff --git a/Source/JavaScript/components/package.json b/Source/JavaScript/components/package.json index a6f179e..4f03f65 100644 --- a/Source/JavaScript/components/package.json +++ b/Source/JavaScript/components/package.json @@ -76,6 +76,7 @@ "@cratis/arc": "^22.16.1", "@cratis/arc.react": "^22.16.1", "@cratis/components": "^4.1.1", + "@cratis/fundamentals": "^7.19.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -85,6 +86,9 @@ }, "@cratis/arc.react": { "optional": true + }, + "@cratis/fundamentals": { + "optional": true } } }