From 6a50afa1a11f079326082ad8da17c17c5dcc5b66 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 23 Sep 2026 01:20:47 +0200 Subject: [PATCH 1/3] Support typed automatic forms in Components 5 --- .yarnrc.yml | 6 +- ...hen_using_the_mui_presentation_profile.tsx | 2 +- Adapters/Mui/package.json | 8 +- .../Mui/scripts/verify-packed-package.mjs | 2 +- ...ng_the_primereact_presentation_profile.tsx | 2 +- Adapters/PrimeReact/package.json | 8 +- .../scripts/verify-packed-package.mjs | 2 +- ..._the_primereact10_presentation_profile.tsx | 2 +- Adapters/PrimeReact10/package.json | 8 +- .../scripts/verify-packed-package.mjs | 2 +- Conformance/package.json | 8 +- .../CommandForm/auto-command-form.md | 29 ++- Documentation/CommandForm/guid-field.md | 55 +++++ Documentation/CommandForm/index.md | 6 +- Documentation/CommandForm/toc.yml | 2 + Documentation/Migration/4-to-5.md | 27 +++ Documentation/Migration/index.md | 1 + Documentation/Migration/toc.yml | 2 + Documentation/getting-started.mdx | 20 +- ESLint/package.json | 2 +- Migrator/README.md | 12 +- Migrator/compat-manifest.json | 94 +++++--- Migrator/lib/compatibility.js | 33 +-- Migrator/lib/releasePolicy.js | 82 +++++++ Migrator/package.json | 2 +- Migrator/test/compatibility.test.js | 51 +++- Migrator/test/verify-packed-package.mjs | 12 +- README.md | 10 +- Source/CommandForm/AutoCommandForm.tsx | 25 +- .../CommandForm/defaultFieldTypeProviders.ts | 6 + Source/CommandForm/fields/GuidField.tsx | 122 ++++++++++ Source/CommandForm/fields/index.ts | 1 + .../given/GuidFieldTypes.tsx | 21 ++ .../given/SampleGuidCommand.ts | 27 +++ .../for_AutoCommandForm/given/a_guid_form.tsx | 112 +++++++++ .../when_composing_a_footer.tsx | 133 +++++++++++ .../when_editing_guid_fields.tsx | 218 ++++++++++++++++++ ...xecution_must_veto_invalid_guid_drafts.tsx | 92 ++++++++ .../when_resolving_the_default_providers.ts | 10 + Source/MIGRATION.md | 26 ++- Source/README.md | 28 ++- Source/compat-manifest.json | 94 +++++--- Source/package.json | 6 +- Source/scripts/generate-parts.mjs | 8 + Source/scripts/verify-parts-manifest.mjs | 6 +- .../verify-public-types.exceptions.json | 6 +- Source/types/parts.ts | 4 + compat-manifest.json | 94 +++++--- package.json | 6 +- release.md | 20 +- run-task-on-workspaces.js | 33 +-- scripts/generate-compat-manifest.mjs | 108 ++------- scripts/generate-compat-manifest.test.mjs | 54 ++++- scripts/prepare-release.mjs | 57 +++++ scripts/prepare-release.test.mjs | 65 ++++++ scripts/renderer-adapter-matrix.json | 4 +- scripts/run-task-on-workspaces.test.mjs | 15 +- yarn.lock | 61 +++-- 58 files changed, 1580 insertions(+), 372 deletions(-) create mode 100644 Documentation/CommandForm/guid-field.md create mode 100644 Documentation/Migration/4-to-5.md create mode 100644 Migrator/lib/releasePolicy.js create mode 100644 Source/CommandForm/fields/GuidField.tsx create mode 100644 Source/CommandForm/for_AutoCommandForm/given/GuidFieldTypes.tsx create mode 100644 Source/CommandForm/for_AutoCommandForm/given/SampleGuidCommand.ts create mode 100644 Source/CommandForm/for_AutoCommandForm/given/a_guid_form.tsx create mode 100644 Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx create mode 100644 Source/CommandForm/for_AutoCommandForm/when_editing_guid_fields.tsx create mode 100644 Source/CommandForm/for_AutoCommandForm/when_native_execution_must_veto_invalid_guid_drafts.tsx create mode 100644 scripts/prepare-release.mjs create mode 100644 scripts/prepare-release.test.mjs diff --git a/.yarnrc.yml b/.yarnrc.yml index ef43436a..af16414a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,10 +1,10 @@ nodeLinker: node-modules -# Exact Cratis releases reviewed for the generated-field binding fix. +# Exact Cratis releases reviewed for generated fields and native custom-error execution guards. # Keep Yarn's default release-age guard for every other package/version. npmPreapprovedPackages: - - '@cratis/arc@22.16.0' - - '@cratis/arc.react@22.16.0' + - '@cratis/arc@22.19.1' + - '@cratis/arc.react@22.19.1' - '@cratis/arc.vite@22.16.0' packageExtensions: diff --git a/Adapters/Mui/for_adapter/when_using_the_mui_presentation_profile.tsx b/Adapters/Mui/for_adapter/when_using_the_mui_presentation_profile.tsx index b486e199..4e6e4dea 100644 --- a/Adapters/Mui/for_adapter/when_using_the_mui_presentation_profile.tsx +++ b/Adapters/Mui/for_adapter/when_using_the_mui_presentation_profile.tsx @@ -218,7 +218,7 @@ describe('when using the MUI stable presentation profile', () => { '@emotion/styled': '>=11.11 <12', react: '^19.0.0', 'react-dom': '^19.0.0', - '@cratis/components': '>=4 <5', + '@cratis/components': '>=5 <6', }); const serialized = JSON.stringify(packageJson); expect(serialized).not.to.contain('@mui/x-'); diff --git a/Adapters/Mui/package.json b/Adapters/Mui/package.json index 953f53ad..6949f53c 100644 --- a/Adapters/Mui/package.json +++ b/Adapters/Mui/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.mui", - "version": "4.0.0", + "version": "5.0.0", "description": "Material UI presentation adapter for Cratis Components renderer ABI v1.", "author": "Cratis", "license": "MIT", @@ -98,7 +98,7 @@ "ci": "yarn clean && yarn lint && yarn build && yarn typecheck-specs && yarn test && yarn verify-package" }, "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "@emotion/react": ">=11.5 <12", "@emotion/styled": ">=11.11 <12", "@mui/material": ">=9 <10", @@ -106,8 +106,8 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/components": "workspace:^", "@cratis/components.conformance": "workspace:^", "@cratis/fundamentals": "7.19.2", diff --git a/Adapters/Mui/scripts/verify-packed-package.mjs b/Adapters/Mui/scripts/verify-packed-package.mjs index 210cf3d5..d340717b 100644 --- a/Adapters/Mui/scripts/verify-packed-package.mjs +++ b/Adapters/Mui/scripts/verify-packed-package.mjs @@ -112,7 +112,7 @@ try { ); const expectedPeers = { - '@cratis/components': '>=4 <5', + '@cratis/components': '>=5 <6', '@emotion/react': '>=11.5 <12', '@emotion/styled': '>=11.11 <12', '@mui/material': '>=9 <10', diff --git a/Adapters/PrimeReact/for_adapter/when_using_the_primereact_presentation_profile.tsx b/Adapters/PrimeReact/for_adapter/when_using_the_primereact_presentation_profile.tsx index 7dbddff2..5d875e3d 100644 --- a/Adapters/PrimeReact/for_adapter/when_using_the_primereact_presentation_profile.tsx +++ b/Adapters/PrimeReact/for_adapter/when_using_the_primereact_presentation_profile.tsx @@ -266,7 +266,7 @@ describe('when using the PrimeReact stable presentation profile', () => { it('should declare only bounded peer-hosted PrimeReact 11 vendors', () => { expect(packageJson).not.to.have.property('dependencies'); expect(packageJson.peerDependencies).to.deep.equal({ - '@cratis/components': '>=4 <5', + '@cratis/components': '>=5 <6', '@primereact/core': '>=11 <12', '@primereact/ui': '>=11 <12', '@primeuix/themes': '>=3 <4', diff --git a/Adapters/PrimeReact/package.json b/Adapters/PrimeReact/package.json index 7aaff1dd..4478cf4a 100644 --- a/Adapters/PrimeReact/package.json +++ b/Adapters/PrimeReact/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.primereact", - "version": "4.0.0", + "version": "5.0.0", "description": "PrimeReact 11 presentation adapter for Cratis Components renderer ABI v1.", "author": "Cratis", "license": "MIT", @@ -101,7 +101,7 @@ "ci": "yarn clean && yarn lint && yarn build && yarn typecheck-specs && yarn test && yarn verify-package" }, "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "@primereact/core": ">=11 <12", "@primereact/ui": ">=11 <12", "@primeuix/themes": ">=3 <4", @@ -110,8 +110,8 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/components": "workspace:^", "@cratis/components.conformance": "workspace:^", "@cratis/fundamentals": "7.19.2", diff --git a/Adapters/PrimeReact/scripts/verify-packed-package.mjs b/Adapters/PrimeReact/scripts/verify-packed-package.mjs index 8c62b326..3d5567ca 100644 --- a/Adapters/PrimeReact/scripts/verify-packed-package.mjs +++ b/Adapters/PrimeReact/scripts/verify-packed-package.mjs @@ -112,7 +112,7 @@ try { ); const expectedPeers = { - '@cratis/components': '>=4 <5', + '@cratis/components': '>=5 <6', '@primereact/core': '>=11 <12', '@primereact/ui': '>=11 <12', '@primeuix/themes': '>=3 <4', diff --git a/Adapters/PrimeReact10/for_adapter/when_using_the_primereact10_presentation_profile.tsx b/Adapters/PrimeReact10/for_adapter/when_using_the_primereact10_presentation_profile.tsx index dd6d3b60..b5bb8c7e 100644 --- a/Adapters/PrimeReact10/for_adapter/when_using_the_primereact10_presentation_profile.tsx +++ b/Adapters/PrimeReact10/for_adapter/when_using_the_primereact10_presentation_profile.tsx @@ -257,7 +257,7 @@ describe('when using the PrimeReact 10 stable presentation profile', () => { it('should declare only bounded peer-hosted PrimeReact 10 vendors', () => { expect(packageJson).not.to.have.property('dependencies'); expect(packageJson.peerDependencies).to.deep.equal({ - '@cratis/components': '>=4 <5', + '@cratis/components': '>=5 <6', primereact: '>=10.9.9 <11', react: '^19.0.0', 'react-dom': '^19.0.0', diff --git a/Adapters/PrimeReact10/package.json b/Adapters/PrimeReact10/package.json index 0d73e7af..fad133dd 100644 --- a/Adapters/PrimeReact10/package.json +++ b/Adapters/PrimeReact10/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.primereact10", - "version": "4.0.0", + "version": "5.0.0", "description": "PrimeReact 10 presentation adapter for Cratis Components renderer ABI v1.", "author": "Cratis", "license": "MIT", @@ -98,14 +98,14 @@ "ci": "yarn clean && yarn lint && yarn build && yarn verify-upstream-shim && yarn typecheck-specs && yarn test && yarn verify-isolation && yarn verify-package" }, "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "primereact": ">=10.9.9 <11", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/components": "workspace:^", "@cratis/components.conformance": "workspace:^", "@cratis/fundamentals": "7.19.2", diff --git a/Adapters/PrimeReact10/scripts/verify-packed-package.mjs b/Adapters/PrimeReact10/scripts/verify-packed-package.mjs index 771ab34b..3f55ac78 100644 --- a/Adapters/PrimeReact10/scripts/verify-packed-package.mjs +++ b/Adapters/PrimeReact10/scripts/verify-packed-package.mjs @@ -112,7 +112,7 @@ try { ); const expectedPeers = { - '@cratis/components': '>=4 <5', + '@cratis/components': '>=5 <6', primereact: '>=10.9.9 <11', react: '^19.0.0', 'react-dom': '^19.0.0', diff --git a/Conformance/package.json b/Conformance/package.json index 8a5131e7..6c0cb87a 100644 --- a/Conformance/package.json +++ b/Conformance/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.conformance", - "version": "4.0.0", + "version": "5.0.0", "description": "Programmatic conformance evidence for Cratis Components renderer adapter authors.", "author": "Cratis", "license": "MIT", @@ -47,13 +47,13 @@ "axe-core": "^4.13.0" }, "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/components": "workspace:^", "@cratis/fundamentals": "7.19.2", "react": "^19.3.0", diff --git a/Documentation/CommandForm/auto-command-form.md b/Documentation/CommandForm/auto-command-form.md index a45caa5b..aac94e77 100644 --- a/Documentation/CommandForm/auto-command-form.md +++ b/Documentation/CommandForm/auto-command-form.md @@ -1,10 +1,10 @@ # AutoCommandForm -`AutoCommandForm` generates its field list from the command's own properties instead of you writing one field per property by hand. Each property's type picks its field component through a registry - `string` gets `InputTextField`, `number` gets `NumberField`, `boolean` gets `CheckboxField`, `Date` gets `CalendarField` - the same components you would otherwise use directly. +`AutoCommandForm` generates its field list from the command's own properties instead of you writing one field per property by hand. Each property's type picks its field component through a registry - `string` gets `InputTextField`, `number` gets `NumberField`, `boolean` gets `CheckboxField`, `Date` gets `CalendarField`, scalar `Guid` gets [GuidField](guid-field.md) - the same components you would otherwise use directly. ## Requirements -Use `@cratis/arc` and `@cratis/arc.react` version 22.16.0 or later within the supported 22.x range. Each generated field declares its property name explicitly because its accessor reads a descriptor dynamically. Older Arc versions overwrite that binding, which can send a date or number edit to another property. +Use matching `@cratis/arc` and `@cratis/arc.react` versions in the supported range `>=22.19.1 <23`. Generated fields rely on explicit property-name binding, and Guid validation relies on Arc's native custom-field-error execution guard and live error lookup. Earlier versions can misbind generated fields or allow invalid optional Guid drafts to submit. ## Usage @@ -25,12 +25,32 @@ A property whose type has no registered provider - a nested object, an array, an |------|------|---------|-------------| | `command` | `Constructor` | — | **Required.** The command type to generate fields for. | | `exclude` | `(keyof TCommand)[]` | — | Property names to leave out of the generated field list. | +| `footer` | `React.ReactNode` | — | Optional content after the generated fields, inside the native Arc form and command context. | `AutoCommandForm` also accepts every other `CommandForm` prop (`initialValues`, `populateFromQuery`, `onSuccess`, `validateOn`, and so on) except `children`, which it generates itself. +## Adding an action inside the form + +The default remains fields-only. Supply `footer` to add content or a native submit control: + +```tsx +import { AutoCommandForm } from '@cratis/components/CommandForm'; +import { SampleCommand } from './SampleCommand'; + +Submit} +/> +``` + +The button submits Arc's existing form; it does not create a second command or executor. +A component placed in `footer` can use Arc's `useCommandFormContext` for execution and +authorization state. `footer` takes React content, not a render callback, and adds no wrapper +or DOM-prop forwarding. Authorization and validation behavior remain Arc's responsibility. + ## Registering a field type provider -The built-in providers cover `string`, `number`, `boolean` and `Date`. Register your own for any other property type - a Cratis concept, an enum, a custom value object - with `registerFieldTypeProvider`: +The built-in providers cover `string`, `number`, `boolean`, `Date` and scalar Fundamentals `Guid`. Guid arrays are not handled. Register your own for any other property type - a Cratis concept, an enum, a custom value object - with `registerFieldTypeProvider`: ```tsx import { registerFieldTypeProvider } from '@cratis/components/CommandForm'; @@ -48,6 +68,7 @@ Register once, at module load, before any `AutoCommandForm` renders. Providers a ## Behavior - Field titles are generated from the property name by splitting on capitals and uppercasing the first letter (`dueDate` becomes "Due Date"); there is no way to override an individual generated field's title other than excluding it and writing that one field by hand. -- `required` is **not** derived from the property descriptor's `isOptional` — every generated field is rendered without it. Write the field by hand when you need `required` on it. +- Arc derives `required` from the property descriptor's `isOptional` when no explicit field override is present. An optional empty Guid has no format error; an empty required Guid is invalid. +- Guid edits use `Guid.isGuid` and `Guid.parse`, never generate an identifier, and impose no version or nonzero restriction. Invalid drafts clear the bound value immediately instead of retaining an older identifier. A changed `currentValues` Guid overlay also resets its draft; see [Guid reset behavior](guid-field.md#clearing-and-resetting). - Each generated field binds to its descriptor's property name; editing one property leaves the other properties unchanged. - Every generated field participates in `CommandForm`'s validation, change tracking and initial-value population exactly as a hand-written field does - `AutoCommandForm` only decides *which* fields to render, not how they behave once rendered. diff --git a/Documentation/CommandForm/guid-field.md b/Documentation/CommandForm/guid-field.md new file mode 100644 index 00000000..41815847 --- /dev/null +++ b/Documentation/CommandForm/guid-field.md @@ -0,0 +1,55 @@ +--- +title: GuidField +description: Edit a scalar Fundamentals Guid with native Arc binding and accessible format validation. +--- + +`GuidField` is a native text input bound to a scalar `Guid`, `Guid | undefined`, or `Guid | null` property. [AutoCommandForm](auto-command-form.md) selects it for descriptors whose type is Fundamentals `Guid`, not arrays. + +## Requirements + +Use matching `@cratis/arc` and `@cratis/arc.react` versions in the supported range `>=22.19.1 <23`. This minimum includes Arc's custom-field-error execution guard and live error lookup: invalid drafts block native submission, context execution and `formRef.execute()`, while unmounting a field clears only its own current error. + +## Usage + +```tsx +import { GuidField } from '@cratis/components/CommandForm'; +import { SampleCommand } from './SampleCommand'; + + + value={(command) => command.sampleId} + title='Sample identifier' + placeholder='Enter a Guid' +/> +``` + +Place the field inside Arc's `CommandForm` or a Components command dialog. `SampleCommand.sampleId` must be a native Fundamentals `Guid` property, not a string. + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `value` | `(command: TCommand) => Guid \| undefined \| null` | Required | Scalar property accessor. | +| `fieldName` | `string` | Inferred | Explicit binding for a dynamic accessor. | +| `required` | `boolean` | Descriptor's `!isOptional` | An empty required field has a required-value error. | +| `formatErrorMessage` | `string` | `Enter a valid Guid.` | Error for a nonempty invalid draft. | +| `requiredErrorMessage` | `string` | `A value is required.` | Error for an empty required draft. | +| `resetKey` | `unknown` | — | Change to discard a draft when the command value has not changed. | +| `placeholder` | `string` | — | Native placeholder. | +| `className` | `string` | — | Additional input classes. | +| `pt.root` | `InputHTMLAttributes` | — | Native input attributes; binding and validation attributes remain field-owned. | + +The field also supports Arc population props and the shared [accessible naming props](index.md#accessible-names-and-validation-errors). Native `pt.root.disabled` and `pt.root.readOnly` states are reflected in data attributes. + +## Validation and values + +- Nonempty drafts are checked with `Guid.isGuid` and parsed with `Guid.parse`. Partial or invalid input stays editable and immediately writes `undefined` to the command, never the previous valid identifier. +- Empty optional input writes `undefined` without a format error. Required emptiness produces a required-value error instead. +- No identifier is generated. Zero Guids and any version bits accepted by Fundamentals are accepted here; add explicit command rules when your domain requires more. +- Format and required errors use the real Arc `setCustomFieldError` context API. The field never replaces native command execution. Native command validation rules still apply independently of field presentation. +- Errors are associated with the input through `aria-describedby` and `aria-invalid`. On unmount, the field removes only its own current custom error. + +## Clearing and resetting + +External value changes replace the displayed draft and recalculate its error. A changed `AutoCommandForm.currentValues` Guid overlay also resets the draft, including a change to explicit `undefined` after invalid input has already emptied the native value. Equivalent Guid overlays do not erase ongoing edits. + +An assignment of the same empty value carries no observable reset intent in Arc's field binding. For an explicit same-value reset, change a hand-written field's `resetKey` (or remount the form). A custom Guid provider can forward this prop. Do not reset drafts on every `commandVersion` change: unrelated field edits must preserve partial input. diff --git a/Documentation/CommandForm/index.md b/Documentation/CommandForm/index.md index 1748ee6f..b3e99aa2 100644 --- a/Documentation/CommandForm/index.md +++ b/Documentation/CommandForm/index.md @@ -19,7 +19,7 @@ CommandForm offers a complete set of form field components designed to work seam ## Available Field Components -The CommandForm module exports Cratis-owned semantic fields built with native controls and documented keyboard, naming, and validation behavior. Each field uses `asCommandFormField` for automatic value binding, validation state, and Arc command integration. +The CommandForm module exports Cratis-owned semantic fields built with native controls and documented keyboard, naming, and validation behavior. Each field uses Arc's native field-binding helpers for automatic value binding, validation state, and command integration. [GuidField](guid-field.md) adds an editable text draft while keeping the command value a scalar Fundamentals `Guid`. See the field type pages in this section for documentation on each available field component. To generate a form's fields from a command's own properties instead of writing them out by hand, see [AutoCommandForm](auto-command-form.md). @@ -69,7 +69,7 @@ Checkboxes and switches prefer their inline `label`; radio options use their opt ## Populating Initial Values from a Query -Every field here is built with `asCommandFormField` from `@cratis/arc.react/commands`, so each one automatically supports `CommandForm`'s `populateFromQuery`/`populateFromObservableQuery` props - the form fetches a single-instance query itself and seeds its fields from the result, matched onto the command by property name: +Every field here uses native field binding from `@cratis/arc.react/commands`, so each one automatically supports `CommandForm`'s `populateFromQuery`/`populateFromObservableQuery` props - the form fetches a single-instance query itself and seeds its fields from the result, matched onto the command by property name: ```tsx import { CommandForm } from '@cratis/arc.react/commands'; @@ -87,7 +87,7 @@ import { UpdateProfile } from './commands'; ; ``` -Two field props refine this per field - both work on every field type in this package, since they come from the shared `asCommandFormField` wrapper: +Two field props refine this per field - both work on every field type in this package, since they come from Arc's shared field registration: - `noInitialValue` - skip this field entirely, even if the query result has a same-named property. - `initialValue` - override how the field's value is derived from the query result, either a property accessor matched by name or a function composing a value from the whole result. diff --git a/Documentation/CommandForm/toc.yml b/Documentation/CommandForm/toc.yml index bd3697fc..c58dbde0 100644 --- a/Documentation/CommandForm/toc.yml +++ b/Documentation/CommandForm/toc.yml @@ -12,6 +12,8 @@ href: color-picker-field.md - name: DropdownField href: dropdown-field.md +- name: GuidField + href: guid-field.md - name: InputTextField href: input-text-field.md - name: MultiSelectField diff --git a/Documentation/Migration/4-to-5.md b/Documentation/Migration/4-to-5.md new file mode 100644 index 00000000..eaa74d6f --- /dev/null +++ b/Documentation/Migration/4-to-5.md @@ -0,0 +1,27 @@ +--- +title: Migrate from Components 4 to 5 +description: Align the Components package family and upgrade Arc for typed command forms. +--- + +Components 5.0 is a major release because its dependency contract narrows: both `@cratis/arc` and `@cratis/arc.react` now require `>=22.19.1 <23`, rather than Components 4's declared `>=20.3.1 <23`. Older Arc hosts are not supported by Components 5. There is no runtime version-detection fallback. + +## Upgrade together + +1. Preserve your package manifest and lockfile as a rollback point. +2. Upgrade Arc and Arc React together to matching versions, at least 22.19.1 and below 23. Keep generated proxies aligned with those packages. +3. Upgrade every installed member of the Components family to the same 5.x release: `@cratis/components`, `@cratis/eslint-plugin-components`, `@cratis/components.migrator`, `@cratis/components.conformance`, `@cratis/components.mui`, `@cratis/components.primereact`, and `@cratis/components.primereact10`. Optional adapters and tools need not be installed if unused. Version 5 adapters and Conformance declare Core `>=5 <6`; do not mix the 4.x and 5.x families. +4. Run your type checks, form validation and command-execution tests, and production build before deploying. Roll back the package family and Arc changes together if needed. + +```bash +npm install @cratis/components@^5 @cratis/arc@22.19.1 @cratis/arc.react@22.19.1 +``` + +This guide describes the intended 5.0 release. Verify registry availability before installing; a source checkout is not evidence of publication. + +## What changes — and what does not + +Existing form APIs remain compatible. `AutoCommandForm` now supports composed footer children and resolves generated Guid properties to the new `GuidField`. `GuidField` edits explicit identifiers and uses Arc's native custom-field-error execution guard and live error lookup to prevent invalid Guid drafts from executing. See [Guid fields](../CommandForm/guid-field.md) and [AutoCommandForm](../CommandForm/auto-command-form.md). + +There is no automatic ID generation, authentication or authorization change, new API removal, or renderer ABI change. Renderer ABI 1, `core/v1`, and `stable-presentation/v1` remain unchanged. Other dependency pins and provider, styling, and command contracts are unchanged by this major-version policy. + +No 4-to-5 source codemod is required. Migrator 5 retains the existing 3-to-4 transforms and accepts Components 3 as a source and Components 4 or 5 as recovery targets. This does not remove the staged order in the historical [3-to-4 guide](3-to-4.md). Migrator 4 remains bounded to its original Components 3/4 windows and does not claim Components 5 support. diff --git a/Documentation/Migration/index.md b/Documentation/Migration/index.md index b6426029..00f34c8d 100644 --- a/Documentation/Migration/index.md +++ b/Documentation/Migration/index.md @@ -9,5 +9,6 @@ Guides for moving `@cratis/components` forward across major versions. Each guide | --- | --- | | [2.x → 3.0](2-to-3.md) | PrimeReact 10 → 11, peer dependencies, styling, and PrimeUI licensing | | [3.x → 4.0](3-to-4.md) | Renderer-independent Components APIs, explicit subpaths, styling parts, and migration tooling | +| [4.x → 5.0](4-to-5.md) | Arc 22.19.1 minimum, matching package-family upgrades, Guid fields, and footer composition | When moving from PrimeReact 10 to 11, also read PrimeReact's [Updating to v11](https://primereact.dev/docs/primitive/guides/migration/updating-to-v11) guide alongside [2.x → 3.0](2-to-3.md). The [3.x → 4.0](3-to-4.md) guide covers the separate Components migration away from PrimeReact-backed public APIs. diff --git a/Documentation/Migration/toc.yml b/Documentation/Migration/toc.yml index d659ed3e..913f37a1 100644 --- a/Documentation/Migration/toc.yml +++ b/Documentation/Migration/toc.yml @@ -4,3 +4,5 @@ href: 2-to-3.md - name: 3.x → 4.0 href: 3-to-4.md +- name: 4.x → 5.0 + href: 4-to-5.md diff --git a/Documentation/getting-started.mdx b/Documentation/getting-started.mdx index 51d80ea8..088049e8 100644 --- a/Documentation/getting-started.mdx +++ b/Documentation/getting-started.mdx @@ -18,24 +18,26 @@ You've built an Arc backend—a `RegisterAuthor` command and an `AllAuthors` que 1. **Install Components.** - ```bash title="Install Components 4" - npm install @cratis/components@^4 + ```bash title="Install Components 5" + npm install @cratis/components@^5 ``` + Upgrade installed adapters, Conformance, ESLint, and Migrator with Core to the matching 5.x package family. See [Migrate from 4 to 5](/components/migration/4-to-5/). + React Aria, the internationalized date implementation, and React Icons are internal dependencies. The current package manifest does not declare a separate UI kit or theme runtime, and consumers do not configure an icon-package peer. - The package declares React, Arc, Fundamentals, `reflect-metadata`, and `tsyringe` peer ranges. Its Arc range is `>=20.3.1 <23`. Use Arc 22.16.0 or newer for `AutoCommandForm`'s explicit field binding. Keep `@cratis/arc` and `@cratis/arc.react` on the same version used by the application's generated proxies. A strict installer can use this explicit form (replace the example Arc version when needed): + The package declares React, Arc, Fundamentals, `reflect-metadata`, and `tsyringe` peer ranges. Its Arc range is `>=22.19.1 <23`, required for generated-field binding and native custom-field-error execution guards. Keep `@cratis/arc` and `@cratis/arc.react` on the same version used by the application's generated proxies. A strict installer can use this explicit form (replace the example Arc version when needed): ```bash title="Install explicit peers" - ARC_VERSION=22.16.0 - npm install @cratis/components@^4 \ + ARC_VERSION=22.19.1 + npm install @cratis/components@^5 \ "@cratis/arc@$ARC_VERSION" "@cratis/arc.react@$ARC_VERSION" \ @cratis/fundamentals@^7.19.2 react@^19 react-dom@^19 \ reflect-metadata@0.2.2 tsyringe@4.10.0 @@ -78,7 +80,7 @@ You've built an Arc backend—a `RegisterAuthor` command and an `AllAuthors` que ### Yarn PnP with Arc React 22.6.2 -If you retain `@cratis/arc.react@22.6.2`, that version imports `rxjs` without declaring it. A strict Yarn PnP consumer needs `rxjs@7.8.2` and this version-specific package extension: +For older Components releases that allowed `@cratis/arc.react@22.6.2`, that Arc version imports `rxjs` without declaring it. A strict Yarn PnP consumer needs `rxjs@7.8.2` and this version-specific package extension: ```yaml title=".yarnrc.yml" packageExtensions: @@ -87,7 +89,7 @@ packageExtensions: rxjs: '7.8.2' ``` -This workaround is not needed with Arc React 22.16.0. Remove the old extension when upgrading; do not apply it to every Arc version. +Current Components requires Arc React 22.19.1 or later, which does not need this workaround. Remove the old extension when upgrading; do not apply it to every Arc version. ## What the provider sets up diff --git a/ESLint/package.json b/ESLint/package.json index c18a4314..ed3c1ca1 100644 --- a/ESLint/package.json +++ b/ESLint/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-components", - "version": "4.0.0", + "version": "5.0.0", "description": "Cratis Components ESLint rules: import from subpaths not the root barrel, use the Cratis dialog wrappers instead of primereact/dialog, require onBeforeExecute callbacks to return their values, keep React hooks out of view models, protect the repository-owned React-free kernel, and identify CommandForm fields by a tamper-resistant marker rather than a raw displayName string. Compose on top of @cratis/eslint-config.", "author": "Cratis", "license": "MIT", diff --git a/Migrator/README.md b/Migrator/README.md index abf2083a..655452d9 100644 --- a/Migrator/README.md +++ b/Migrator/README.md @@ -1,10 +1,16 @@ # @cratis/components.migrator -The Components 4 Migrator updates a Components 3 codebase to the Components 4 public contracts. It moves root namespaces to explicit subpath imports, replaces deprecated Button appearance props, and changes legacy event-wrapper callbacks to semantic value callbacks. The CLI uses syntax-aware codemods internally, but each command is named for the migration it performs, can be run independently, and is idempotent. Components 4 keeps only package-wide provider setup at the root; every component is imported from its explicit subpath (`@cratis/components/Canvas`, for example). The companion `@cratis/eslint-plugin-components` package's `no-root-barrel-import` rule enforces this once a consumer has migrated. +The Components 5 Migrator retains the existing transforms that update a Components 3 codebase to the Components 4 public contracts. It moves root namespaces to explicit subpath imports, replaces deprecated Button appearance props, and changes legacy event-wrapper callbacks to semantic value callbacks. The CLI uses syntax-aware codemods internally, but each command is named for the migration it performs, can be run independently, and is idempotent. Components 4 keeps only package-wide provider setup at the root; every component is imported from its explicit subpath (`@cratis/components/Canvas`, for example). The companion `@cratis/eslint-plugin-components` package's `no-root-barrel-import` rule enforces this once a consumer has migrated. -> **Publication status:** The install examples target the owner-authorized 4.0.0 npm release. When +Migrator 5 accepts Components 3 as a source and Components 4 or 5 as recovery targets. +The historical 3-to-4 examples below deliberately retain bounded Migrator 4 commands; +that package does not support Components 5. For a Components 5 package family, use +`@cratis/components.migrator@^5` with matching 5.x tooling. No 4-to-5 source codemod is +required, and no transforms or API removals are added by this release. + +> **Publication status:** The current source targets the intended 5.0.0 npm release. When > reading this README from repository source before that release, verify availability with -> `npm view @cratis/components.migrator@4.0.0 version`; source contributors run the workspace +> `npm view @cratis/components.migrator@5.0.0 version`; source contributors run the workspace > commands from this checkout instead. The Migrator is never an application runtime dependency. The published package is a **CLI-only** tool. Its public surface is the documented `cratis-components-*` executable names (plus `./package.json` for tooling metadata); `lib/`, `scripts/`, and individual transforms are implementation details and are blocked by the package export map. Do not import them from application code or build custom migration APIs on them. diff --git a/Migrator/compat-manifest.json b/Migrator/compat-manifest.json index 89ebc244..f1210d6f 100644 --- a/Migrator/compat-manifest.json +++ b/Migrator/compat-manifest.json @@ -30,9 +30,9 @@ ] }, "toolingCompatibility": { - "componentsCore": ">=4 <5", - "eslint": ">=4 <5", - "migrator": ">=4 <5" + "componentsCore": ">=5 <6", + "eslint": ">=5 <6", + "migrator": ">=5 <6" }, "supportWindows": { "components3": { @@ -40,21 +40,21 @@ "status": "maintenance-security-critical", "migrationRole": "source", "migrationTarget": ">=4 <5", - "tooling": ">=4 <5", + "tooling": ">=4 <6", "eolAt": null, "eolApprovedByOwners": false, "ownerDecisionPolicy": "Set and approve EOL no later than 12 months after Components 4 GA." }, "components4": { "components": ">=4 <5", - "status": "current", + "status": "previous", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", "adapterProfile": "stable-presentation/v1", "tooling": { "eslint": ">=4 <5", - "migrator": ">=4 <5" + "migrator": ">=4 <6" }, "adapters": { "@cratis/components.conformance": ">=4 <5", @@ -62,20 +62,38 @@ "@cratis/components.primereact": ">=4 <5", "@cratis/components.primereact10": ">=4 <5" } + }, + "components5": { + "components": ">=5 <6", + "status": "current", + "migrationRole": "target", + "rendererAbi": 1, + "coreProfile": "core/v1", + "adapterProfile": "stable-presentation/v1", + "tooling": { + "eslint": ">=5 <6", + "migrator": ">=5 <6" + }, + "adapters": { + "@cratis/components.conformance": ">=5 <6", + "@cratis/components.mui": ">=5 <6", + "@cratis/components.primereact": ">=5 <6", + "@cratis/components.primereact10": ">=5 <6" + } } }, "packages": [ { "name": "@cratis/components", "role": "core", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/arc": ">=20.3.1 <23", - "@cratis/arc.react": ">=20.3.1 <23", + "@cratis/arc": ">=22.19.1 <23", + "@cratis/arc.react": ">=22.19.1 <23", "@cratis/fundamentals": "^7.10.3", "pixi.js": "^8.20.0", "react": "^19.0.0", @@ -92,8 +110,8 @@ { "name": "@cratis/eslint-plugin-components", "role": "eslint", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", @@ -105,8 +123,8 @@ { "name": "@cratis/components.migrator", "role": "migrator", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", @@ -116,13 +134,13 @@ { "name": "@cratis/components.conformance", "role": "conformance", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -132,13 +150,13 @@ { "name": "@cratis/components.mui", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "@emotion/react": ">=11.5 <12", "@emotion/styled": ">=11.11 <12", "@mui/material": ">=9 <10", @@ -156,8 +174,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.5.0", "@emotion/styled": "11.11.0", @@ -170,8 +188,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", @@ -188,13 +206,13 @@ { "name": "@cratis/components.primereact", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "@primereact/core": ">=11 <12", "@primereact/ui": ">=11 <12", "@primeuix/themes": ">=3 <4", @@ -214,8 +232,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.0.0", "@primereact/headless": "11.0.0", @@ -233,8 +251,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.1.0", "@primereact/headless": "11.1.0", @@ -256,13 +274,13 @@ { "name": "@cratis/components.primereact10", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "primereact": ">=10.9.9 <11", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -276,8 +294,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@types/react": "19.0.0", "primereact": "10.9.9", @@ -288,8 +306,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@types/react": "19.2.18", "primereact": "10.9.9", diff --git a/Migrator/lib/compatibility.js b/Migrator/lib/compatibility.js index 402860cd..62255700 100644 --- a/Migrator/lib/compatibility.js +++ b/Migrator/lib/compatibility.js @@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import semver from 'semver'; +import { validateReleasePolicy } from './releasePolicy.js'; const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const bundledManifestPath = path.join(packageDirectory, 'compat-manifest.json'); @@ -108,35 +109,7 @@ export function validateBundledManifest(manifest, migratorVersion) { ); } - const windows = Object.values(manifest.supportWindows ?? {}); - const sourceWindows = windows.filter( - ({ migrationRole }) => migrationRole === 'source', - ); - const targetWindows = windows.filter( - ({ migrationRole }) => migrationRole === 'target', - ); - if ( - windows.length !== 2 || - sourceWindows.length !== 1 || - targetWindows.length !== 1 || - sourceWindows[0].components !== '>=3 <4' || - sourceWindows[0].migrationTarget !== '>=4 <5' || - targetWindows[0].components !== '>=4 <5' || - manifest.toolingCompatibility?.componentsCore !== '>=4 <5' || - windows.some((window) => { - const toolingRange = - typeof window.tooling === 'string' - ? window.tooling - : window.tooling?.migrator; - return ( - !semver.validRange(window.components) || !semver.validRange(toolingRange) - ); - }) - ) { - throw new Error( - 'Bundled compatibility manifest has invalid migration support windows.', - ); - } + validateReleasePolicy(manifest, migratorVersion); } function resolvePackageManifest(packageName, cwd) { @@ -149,7 +122,7 @@ function resolvePackageManifest(packageName, cwd) { const detail = error instanceof Error ? error.message : String(error); throw new Error( `Could not resolve installed ${packageName} from '${path.resolve(cwd)}'. ` + - `Install a supported Components 3 or Components 4 package before running the codemod. ${detail}`, + `Install a supported Components 3, 4, or 5 package before running the codemod. ${detail}`, ); } } diff --git a/Migrator/lib/releasePolicy.js b/Migrator/lib/releasePolicy.js new file mode 100644 index 00000000..94409422 --- /dev/null +++ b/Migrator/lib/releasePolicy.js @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import semver from 'semver'; + +export const adapterPackages = [ + '@cratis/components.conformance', + '@cratis/components.mui', + '@cratis/components.primereact', + '@cratis/components.primereact10', +]; + +/** Only reviewed release families are supported; a future major needs an explicit policy. */ +export function releasePolicy(version) { + const major = semver.valid(version) && semver.major(version); + if (![4, 5].includes(major)) { + throw new Error(`Version '${version}' is outside supported release families 4 and 5.`); + } + return { + major, + range: `>=${major} <${major + 1}`, + arcRange: major === 4 ? '>=20.3.1 <23' : '>=22.19.1 <23', + }; +} + +export function supportWindows(version) { + const { major } = releasePolicy(version); + const target = (line) => ({ + components: `>=${line} <${line + 1}`, + status: line === major ? 'current' : 'previous', + migrationRole: 'target', + rendererAbi: 1, + coreProfile: 'core/v1', + adapterProfile: 'stable-presentation/v1', + tooling: { + eslint: `>=${line} <${line + 1}`, + // Migrator 5 retains the existing 3-to-4 transforms and recovery runs. + migrator: `>=${line} <${major + 1}`, + }, + adapters: Object.fromEntries(adapterPackages.map((name) => [name, `>=${line} <${line + 1}`])), + }); + return { + components3: { + components: '>=3 <4', + status: 'maintenance-security-critical', + migrationRole: 'source', + migrationTarget: '>=4 <5', + tooling: `>=4 <${major + 1}`, + eolAt: null, + eolApprovedByOwners: false, + ownerDecisionPolicy: 'Set and approve EOL no later than 12 months after Components 4 GA.', + }, + components4: target(4), + ...(major === 5 ? { components5: target(5) } : {}), + }; +} + +/** Shared by generation and the packed Migrator, without changing any codemod. */ +export function validateReleasePolicy(manifest, version) { + const policy = releasePolicy(version); + const tooling = manifest.toolingCompatibility; + if (['componentsCore', 'eslint', 'migrator'].some((key) => tooling?.[key] !== policy.range)) { + throw new Error(`Core ${policy.major} tooling compatibility must remain bounded to ${policy.range}.`); + } + if (JSON.stringify(manifest.supportWindows) !== JSON.stringify(supportWindows(version))) { + throw new Error('The compatibility manifest has invalid migration support windows.'); + } + for (const entry of manifest.packages ?? []) { + if (entry.version !== version || entry.releaseMajorRange !== policy.range) { + throw new Error(`${entry.name} must match the repository release version ${version} and family ${policy.range}.`); + } + const peers = entry.peerDependencies ?? {}; + if (adapterPackages.includes(entry.name) && peers['@cratis/components'] !== policy.range) { + throw new Error(`${entry.name} must declare the matching Components peer family ${policy.range}.`); + } + for (const name of ['@cratis/arc', '@cratis/arc.react']) { + if ((entry.name === '@cratis/components' || name in peers) && peers[name] !== policy.arcRange) { + throw new Error(`${entry.name} must declare ${name} peer contract '${policy.arcRange}' for Components ${policy.major}.`); + } + } + } +} diff --git a/Migrator/package.json b/Migrator/package.json index 69e203c6..4aa848f8 100644 --- a/Migrator/package.json +++ b/Migrator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.migrator", - "version": "4.0.0", + "version": "5.0.0", "description": "Migration tooling for updating @cratis/components consumers from Components 3 to Components 4.", "author": "Cratis", "license": "MIT", diff --git a/Migrator/test/compatibility.test.js b/Migrator/test/compatibility.test.js index 088cbf7e..f961b898 100644 --- a/Migrator/test/compatibility.test.js +++ b/Migrator/test/compatibility.test.js @@ -38,9 +38,44 @@ const createConsumer = (version) => { }; describe('compatibility preflight', () => { + it('preserves historical Migrator 4 policy without claiming Components 5 support', () => { + const historical = structuredClone(compatibilityManifest); + historical.toolingCompatibility = { componentsCore: '>=4 <5', eslint: '>=4 <5', migrator: '>=4 <5' }; + delete historical.supportWindows.components5; + historical.supportWindows.components3.tooling = '>=4 <5'; + historical.supportWindows.components4.status = 'current'; + historical.supportWindows.components4.tooling.migrator = '>=4 <5'; + for (const entry of historical.packages) { + entry.version = '4.0.0'; + entry.releaseMajorRange = '>=4 <5'; + if (entry.peerDependencies['@cratis/components']) entry.peerDependencies['@cratis/components'] = '>=4 <5'; + for (const name of ['@cratis/arc', '@cratis/arc.react']) { + if (entry.peerDependencies[name]) entry.peerDependencies[name] = '>=20.3.1 <23'; + } + } + expect(() => validateBundledManifest(historical, '4.0.0')).not.toThrow(); + expect(() => validateBundledManifest(historical, '5.0.0')).toThrow('outside bundled range'); + historical.packages[0].peerDependencies['@cratis/arc'] = '>=22.19.1 <23'; + expect(() => validateBundledManifest(historical, '4.0.0')).toThrow('peer contract'); + }); + + it('rejects Components 5 metadata claiming the historical Arc floor', () => { + const invalid = structuredClone(compatibilityManifest); + invalid.packages[0].peerDependencies['@cratis/arc.react'] = '>=20.3.1 <23'; + expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow('peer contract'); + }); + + it('rejects a mismatched adapter family', () => { + const invalid = structuredClone(compatibilityManifest); + invalid.packages.find(({ role }) => role === 'renderer-adapter').peerDependencies['@cratis/components'] = '>=4 <5'; + expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow('matching Components peer family'); + }); it.each([ ['3.6.1', 'source'], ['4.0.0', 'target'], + ['4.99.0', 'target'], + ['5.0.0', 'target'], + ['5.99.0', 'target'], ])('accepts Components %s as a migration %s', (version, role) => { expect(preflightCompatibility({ cwd: createConsumer(version) })).toMatchObject({ componentsVersion: version, @@ -48,9 +83,9 @@ describe('compatibility preflight', () => { }); }); - it('rejects an unsupported Components version', () => { - expect(() => preflightCompatibility({ cwd: createConsumer('5.0.0') })).toThrow( - '@cratis/components@5.0.0 is unsupported', + it.each(['2.99.0', '6.0.0', '5.0.0-rc.1'])('rejects unsupported Components %s', (version) => { + expect(() => preflightCompatibility({ cwd: createConsumer(version) })).toThrow( + `@cratis/components@${version} is unsupported`, ); }); @@ -66,8 +101,8 @@ describe('compatibility preflight', () => { const stale = structuredClone(compatibilityManifest); stale.packages.find( ({ name }) => name === '@cratis/components.migrator', - ).version = '4.0.1'; - expect(() => validateBundledManifest(stale, '4.0.0')).toThrow( + ).version = '5.0.1'; + expect(() => validateBundledManifest(stale, '5.0.0')).toThrow( 'stale migrator package metadata', ); }); @@ -76,7 +111,7 @@ describe('compatibility preflight', () => { const invalid = structuredClone(compatibilityManifest); invalid.supportWindows.components3.tooling = 'not-a-range'; - expect(() => validateBundledManifest(invalid, '4.0.0')).toThrow( + expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow( 'invalid migration support windows', ); }); @@ -85,7 +120,7 @@ describe('compatibility preflight', () => { const invalid = structuredClone(compatibilityManifest); invalid.supportWindows.components3.components = '>=2 <4'; - expect(() => validateBundledManifest(invalid, '4.0.0')).toThrow( + expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow( 'invalid migration support windows', ); }); @@ -94,7 +129,7 @@ describe('compatibility preflight', () => { const invalid = structuredClone(compatibilityManifest); delete invalid.supportWindows.components4; - expect(() => validateBundledManifest(invalid, '4.0.0')).toThrow( + expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow( 'invalid migration support windows', ); }); diff --git a/Migrator/test/verify-packed-package.mjs b/Migrator/test/verify-packed-package.mjs index b552c2b7..fe347d03 100644 --- a/Migrator/test/verify-packed-package.mjs +++ b/Migrator/test/verify-packed-package.mjs @@ -275,7 +275,13 @@ try { ); } - installSyntheticComponents('5.0.0'); + for (const version of ['4.0.0', '5.0.0']) { + installSyntheticComponents(version); + assertRun(`packed Migrator accepts Components ${version} recovery`, + run(binary, ['--check', source], { cwd: consumer })); + } + + installSyntheticComponents('6.0.0'); const unsupportedInput = "import { Canvas } from '@cratis/components';\n"; for (const command of commands) { const unsupportedSource = path.join(consumer, `${command}-unsupported.tsx`); @@ -284,7 +290,7 @@ try { cwd: consumer, }); assertRun(`packed ${command} rejects unsupported Components`, unsupported, 1); - if (!unsupported.stderr.includes('@cratis/components@5.0.0 is unsupported')) { + if (!unsupported.stderr.includes('@cratis/components@6.0.0 is unsupported')) { throw new Error( `Unexpected unsupported-version output:\n${unsupported.stderr}`, ); @@ -297,7 +303,7 @@ try { } console.log( - 'Packed @cratis/components.migrator manifest and CLIs verified with Components 3, 4, and unsupported versions.', + 'Packed @cratis/components.migrator manifest and CLIs verified with Components 3, 4, 5, and unsupported versions.', ); } finally { rmSync(scratch, { recursive: true, force: true }); diff --git a/README.md b/README.md index 831dbd8f..135ce0fd 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,12 @@ Verify those properties for the exact application and component profile shipped. Install the package: ```bash -npm install @cratis/components@^4 +npm install @cratis/components@^5 ``` -> **Publication status:** This example targets the owner-authorized 4.0.0 npm release. When reading +> **Publication status:** This example targets the owner-authorized 5.0.0 npm release. When reading > it from repository source before that release, verify availability with -> `npm view @cratis/components@4.0.0 version`; source contributors use the repository workspace. +> `npm view @cratis/components@5.0.0 version`; source contributors use the repository workspace. Import the semantic tokens and component structure. The baseline theme is optional: @@ -84,6 +84,10 @@ configuration. few surfaces can import `@cratis/components/styles/base` plus one `/styles` entry point per surface instead, and download nothing else. +Components 5 requires matching Arc and Arc React versions in `>=22.19.1 <23`. +Upgrade installed Components-family packages together to the same 5.x release; +see the [4-to-5 migration guide](Documentation/Migration/4-to-5.md). + The current package manifest defines the exact React, Arc, Fundamentals, and optional Pixi peer ranges. Verify those ranges before installing the package. diff --git a/Source/CommandForm/AutoCommandForm.tsx b/Source/CommandForm/AutoCommandForm.tsx index 9cc596c2..c8d353e2 100644 --- a/Source/CommandForm/AutoCommandForm.tsx +++ b/Source/CommandForm/AutoCommandForm.tsx @@ -4,6 +4,7 @@ import type React from 'react'; import { useMemo } from 'react'; import type { Command } from '@cratis/arc/commands'; +import { Guid } from '@cratis/fundamentals'; import { CommandForm, type CommandFormProps } from '@cratis/arc.react/commands'; import { registerDefaultFieldTypeProviders } from './defaultFieldTypeProviders'; import { resolveFieldTypeProvider } from './fieldTypeProviderRegistry'; @@ -23,6 +24,12 @@ export interface AutoCommandFormProps< * be user-editable, or one a custom field placed elsewhere on the page already covers. */ exclude?: (keyof TCommand)[]; + + /** + * Optional content rendered after the generated fields, inside the native Arc form and + * command context. Supply a submit control here when needed; no action is added by default. + */ + footer?: React.ReactNode; } function formatTitle(propertyName: string): string { @@ -36,7 +43,7 @@ function formatTitle(propertyName: string): string { * A `CommandForm` that generates its field list from the command's own properties, choosing each * field's component by the property's type through the {@link FieldTypeProvider} registry - * `registerFieldTypeProvider` for a type the built-in defaults (`string`, `number`, `boolean`, - * `Date`) don't cover, or to override one of them. + * `Date`, `Guid`) don't cover, or to override one of them. * * A property whose type no registered provider handles is left out of the generated list - * `exclude` it explicitly for clarity, or add a `CommandForm` child by hand alongside this @@ -50,7 +57,7 @@ function formatTitle(propertyName: string): string { export function AutoCommandForm( props: AutoCommandFormProps, ): React.ReactElement { - const { exclude, ...commandFormProps } = props; + const { exclude, footer, ...commandFormProps } = props; // SAFETY: Arc command constructors expose the Command property-descriptor contract at runtime. const propertyDescriptors = useMemo( () => (new props.command() as unknown as Command).propertyDescriptors, @@ -70,9 +77,16 @@ export function AutoCommandForm field !== null); - return {fields}; + return ( + + {fields} + {footer} + + ); } diff --git a/Source/CommandForm/defaultFieldTypeProviders.ts b/Source/CommandForm/defaultFieldTypeProviders.ts index c7aa6c69..c30fcfda 100644 --- a/Source/CommandForm/defaultFieldTypeProviders.ts +++ b/Source/CommandForm/defaultFieldTypeProviders.ts @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { Guid } from '@cratis/fundamentals'; +import { GuidField } from './fields/GuidField'; import { registerFieldTypeProvider } from './fieldTypeProviderRegistry'; import { InputTextField } from './fields/InputTextField'; import { NumberField } from './fields/NumberField'; @@ -13,6 +15,10 @@ import { CalendarField } from './fields/CalendarField'; * explicitly when its module loads so production tree shaking cannot discard the registration. */ export function registerDefaultFieldTypeProviders(): void { + registerFieldTypeProvider({ + canHandle: (descriptor) => descriptor.type === Guid, + component: GuidField, + }); registerFieldTypeProvider({ canHandle: (descriptor) => descriptor.type === String, component: InputTextField, diff --git a/Source/CommandForm/fields/GuidField.tsx b/Source/CommandForm/fields/GuidField.tsx new file mode 100644 index 00000000..40ceb2c2 --- /dev/null +++ b/Source/CommandForm/fields/GuidField.tsx @@ -0,0 +1,122 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'; +import { + withCommandFormFieldBinding, + useCommandFormContext, + type BaseCommandFormFieldProps, + type InjectedCommandFormFieldProps, +} from '@cratis/arc.react/commands'; +import { Guid } from '@cratis/fundamentals'; +import { useFieldAccessibility, type FieldAccessibilityProps } from './fieldAccessibility'; + +/** Stable part attributes for {@link GuidField}. */ +export interface GuidParts { + /** Native text input. Value and change handling remain owned by the field. */ + root?: InputHTMLAttributes; +} + +/** A native Guid binding with an independently editable text draft. */ +export interface GuidFieldProps + extends BaseCommandFormFieldProps, InjectedCommandFormFieldProps, FieldAccessibilityProps { + /** Selects a scalar Guid property on the command. */ + value(instance: TCommand): Guid | undefined | null; + /** Hint displayed when the draft is empty. */ + placeholder?: string; + /** Additional classes on the native input. */ + className?: string; + /** Native input pass-through attributes. */ + pt?: GuidParts; + /** Message for a nonempty draft that Fundamentals does not recognize as a Guid. */ + formatErrorMessage?: string; + /** Message for an empty required field. */ + requiredErrorMessage?: string; + /** Change to discard the draft even when the command value is already empty. */ + resetKey?: unknown; +} + +function GuidFieldComponent(props: GuidFieldProps) { + const { currentValue, fieldName, resetKey } = props; + const { getFieldError, customFieldErrors, setCustomFieldError } = useCommandFormContext(); + const required = props.required ?? !props.propertyDescriptor?.isOptional; + const textForValue = (value: unknown) => value instanceof Guid ? value.toString() : ''; + const [draft, setDraft] = useState(() => ({ value: currentValue, resetKey, text: textForValue(currentValue) })); + // Synchronize external population during render, not in a delayed effect that can overwrite typing. + if (!Object.is(draft.value, currentValue) || !Object.is(draft.resetKey, resetKey)) { + setDraft({ value: currentValue, resetKey, text: textForValue(currentValue) }); + } + const text = Object.is(draft.value, currentValue) && Object.is(draft.resetKey, resetKey) ? draft.text : textForValue(currentValue); + const errorForText = (value: string) => value.length === 0 + ? (required ? props.requiredErrorMessage || 'A value is required.' : undefined) + : (Guid.isGuid(value) ? undefined : props.formatErrorMessage || 'Enter a valid Guid.'); + const error = errorForText(text); + const ownedErrorRef = useRef(undefined); + const latestGetFieldErrorRef = useRef(getFieldError); + latestGetFieldErrorRef.current = getFieldError; + + // Arc's getter reads custom errors live, including writes in an event that unmounts this field + // before it renders again. A render-time map (even an optimistic copy) cannot establish ownership. + // Only clear an error this field owns; a caller's onFieldValidate error must survive valid edits. + const publishError = (nextError: string | undefined) => { + if (!fieldName) return; + if (nextError || (ownedErrorRef.current && getFieldError(fieldName) === ownedErrorRef.current)) { + setCustomFieldError(fieldName, nextError); + } + ownedErrorRef.current = nextError; + }; + useEffect(() => { + if (error !== ownedErrorRef.current || (error && fieldName && customFieldErrors[fieldName] !== error)) { + publishError(error); + } + }); + useEffect(() => () => { + if (fieldName && ownedErrorRef.current && latestGetFieldErrorRef.current(fieldName) === ownedErrorRef.current) { + setCustomFieldError(fieldName, undefined); + } + }, [fieldName, setCustomFieldError]); + + const fieldError = fieldName ? getFieldError(fieldName) : undefined; + const errors = [...new Set([error, fieldError].filter((message): message is string => Boolean(message)))]; + const invalid = errors.length > 0; + const accessibility = useFieldAccessibility({ ...props, errors }, { + id: props.pt?.root?.id, + ariaLabel: props.pt?.root?.['aria-label'], + ariaDescribedBy: props.pt?.root?.['aria-describedby'], + }); + return <> + { + const nextText = event.currentTarget.value; + const nextValue = Guid.isGuid(nextText) ? Guid.parse(nextText) : undefined; + setDraft({ value: nextValue, resetKey, text: nextText }); + // Clear the old identifier immediately, including for partial/invalid input. Arc still + // owns command mutation, validation and execution; this field only supplies its error. + publishError(undefined); + props.onValueChange?.(nextValue); + // Publish unconditionally after binding: native callbacks may have replaced the + // previous error synchronously, before context has rendered its new error map. + publishError(errorForText(nextText)); + }} + onBlur={props.onBlur} + placeholder={props.placeholder} + data-disabled={props.pt?.root?.disabled || undefined} + data-invalid={invalid || undefined} + data-readonly={props.pt?.root?.readOnly || undefined} + data-cratis-part='input' + className={['cratis-field-input', 'cratis:w-full', props.pt?.root?.className, props.className].filter(Boolean).join(' ')} + /> + {accessibility.hiddenError} + ; +} + +/** Edits a scalar Fundamentals Guid without generating an identifier or imposing version/nonzero rules. */ +export const GuidField = withCommandFormFieldBinding(GuidFieldComponent); diff --git a/Source/CommandForm/fields/index.ts b/Source/CommandForm/fields/index.ts index 8b69442c..91c7748f 100644 --- a/Source/CommandForm/fields/index.ts +++ b/Source/CommandForm/fields/index.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. export * from './InputTextField'; +export * from './GuidField'; export * from './NumberField'; export * from './NumberInputField'; export * from './CheckboxField'; diff --git a/Source/CommandForm/for_AutoCommandForm/given/GuidFieldTypes.tsx b/Source/CommandForm/for_AutoCommandForm/given/GuidFieldTypes.tsx new file mode 100644 index 00000000..4daefa16 --- /dev/null +++ b/Source/CommandForm/for_AutoCommandForm/given/GuidFieldTypes.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Guid } from '@cratis/fundamentals'; +import { GuidField } from '../../fields/GuidField'; + +interface SampleValues { + sampleId: Guid; + optionalId?: Guid; + nullableId: Guid | null; + sampleIds: Guid[]; + name: string; +} + +export const requiredField = value={(command) => command.sampleId} />; +export const optionalField = value={(command) => command.optionalId} />; +export const nullableField = value={(command) => command.nullableId} />; +// @ts-expect-error A Guid field cannot bind a string property. +export const stringField = value={(command) => command.name} />; +// @ts-expect-error A Guid field cannot bind an array property. +export const arrayField = value={(command) => command.sampleIds} />; diff --git a/Source/CommandForm/for_AutoCommandForm/given/SampleGuidCommand.ts b/Source/CommandForm/for_AutoCommandForm/given/SampleGuidCommand.ts new file mode 100644 index 00000000..40bb7f85 --- /dev/null +++ b/Source/CommandForm/for_AutoCommandForm/given/SampleGuidCommand.ts @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { Command } from '@cratis/arc/commands'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; +import { Guid } from '@cratis/fundamentals'; + +// Independently authored, generated-shape fixture for Components; all values are synthetic. +export const sampleId = Guid.parse('c4cabc30-211b-447d-9ec2-5087e640a5ac'); + +export class SampleGuidCommand extends Command { + readonly route = '/api/sample-command'; + readonly propertyDescriptors = [ + new PropertyDescriptor('sampleId', Guid), + new PropertyDescriptor('name', String), + ]; + sampleId!: Guid; + name = 'Sample User'; + + get requestParameters(): string[] { + return []; + } + + constructor() { + super(Object, false); + } +} diff --git a/Source/CommandForm/for_AutoCommandForm/given/a_guid_form.tsx b/Source/CommandForm/for_AutoCommandForm/given/a_guid_form.tsx new file mode 100644 index 00000000..3ed56452 --- /dev/null +++ b/Source/CommandForm/for_AutoCommandForm/given/a_guid_form.tsx @@ -0,0 +1,112 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { act, createRef, useLayoutEffect, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Command } from '@cratis/arc/commands'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; +import { useCommandFormContext, type CommandFormContextValue, type CommandFormHandle } from '@cratis/arc.react/commands'; +import { Guid } from '@cratis/fundamentals'; +import sinon from 'sinon'; +import { AutoCommandForm, type AutoCommandFormProps } from '../../AutoCommandForm'; +import { clearFieldTypeProviders } from '../../fieldTypeProviderRegistry'; +import { registerDefaultFieldTypeProviders } from '../../defaultFieldTypeProviders'; + +// Independently authored generated-shape command; no domain or consumer fixture is involved. +export class SampleOptionalGuidCommand extends Command { + readonly route: string = '/api/sample-optional-guid'; + readonly propertyDescriptors = [ + new PropertyDescriptor('sampleId', Guid, true), + new PropertyDescriptor('name', String), + ]; + sampleId: Guid | undefined = undefined; + name = 'Sample User'; + get requestParameters(): string[] { return []; } + constructor() { super(Object, false); } +} + +export const sampleGuid = '852afc19-b630-43ba-a612-93e1c522078d'; +export const exampleGuid = '715ad639-c834-4032-b193-42eb599b73d7'; + +export const successfulResponse = () => new Response(JSON.stringify({ + correlationId: Guid.empty.toString(), + isSuccess: true, + isAuthorized: true, + isValid: true, + hasExceptions: false, + validationResults: [], + exceptionMessages: [], + exceptionStackTrace: '', + authorizationFailureReason: '', +}), { status: 200 }); + +function SampleFooter({ capture }: { capture: (context: CommandFormContextValue) => void }) { + const context = useCommandFormContext(); + useLayoutEffect(() => capture(context)); + return ; +} + +function SampleForm({ form, props, excludeGuidOnChange }: { + form: a_guid_form; + props: Partial>; + excludeGuidOnChange: boolean; +}) { + const [excludeGuid, setExcludeGuid] = useState(false); + return { form.validationFailures++; }} + footer={ { form.context = context; }} />} + {...props} + exclude={excludeGuid ? ['sampleId'] : props.exclude} + onFieldChange={(...args) => { + props.onFieldChange?.(...args); + if (excludeGuidOnChange && args[1] === 'sampleId') setExcludeGuid(true); + }} + />; +} + +/** Real React, Arc binding, command and executor; only fetch is substituted. */ +export class a_guid_form { + container!: HTMLDivElement; + root!: Root; + context!: CommandFormContextValue; + formRef = createRef(); + http!: sinon.SinonStub, ReturnType>; + validationFailures = 0; + + setup() { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + clearFieldTypeProviders(); + registerDefaultFieldTypeProviders(); + this.container = document.createElement('div'); + document.body.append(this.container); + this.root = createRoot(this.container); + this.http = sinon.stub(globalThis, 'fetch').callsFake(async () => successfulResponse()); + } + + async cleanup() { + await act(async () => this.root.unmount()); + this.container.remove(); + this.http.restore(); + clearFieldTypeProviders(); + registerDefaultFieldTypeProviders(); + } + + async render(props: Partial> = {}, excludeGuidOnChange = false) { + await act(async () => this.root.render()); + } + + get input() { return this.container.querySelector('input[aria-label="Sample Id"]')!; } + + async edit(value: string, input = this.input) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + async submit() { + await act(async () => this.container.querySelector('button[type="submit"]')!.click()); + } +} diff --git a/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx b/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx new file mode 100644 index 00000000..2a4ec8ce --- /dev/null +++ b/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx @@ -0,0 +1,133 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { useCommandFormContext } from '@cratis/arc.react/commands'; +import { Guid } from '@cratis/fundamentals'; +import sinon from 'sinon'; +import type {} from 'chai/register-should'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { AutoCommandForm } from '../AutoCommandForm'; +import { SampleGuidCommand, sampleId } from './given/SampleGuidCommand'; + +function Submit() { + const { commandInstance, isExecuting } = useCommandFormContext(); + return ; +} + +// No command, form, field, context or executor mocks: only the HTTP boundary is substituted. +describe('when composing a footer inside the automatic command form', () => { + let container: HTMLDivElement; + let root: Root; + let http: sinon.SinonStub, ReturnType>; + let submitted: SampleGuidCommand | undefined; + let validationFailures: number; + + beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + submitted = undefined; + validationFailures = 0; + http = sinon.stub(globalThis, 'fetch').callsFake(async () => new Response(JSON.stringify({ + correlationId: Guid.empty.toString(), + isSuccess: true, + isAuthorized: true, + isValid: true, + hasExceptions: false, + validationResults: [], + exceptionMessages: [], + exceptionStackTrace: '', + authorizationFailureReason: '', + }), { status: 200 })); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + http.restore(); + }); + + const render = async (footer?: ReactNode, currentValues: Partial = { sampleId }) => { + await act(async () => root.render( + { submitted = command; return command; }} + onValidationFailure={() => { validationFailures++; }} + />, + )); + }; + + const submit = async () => { + await act(async () => container.querySelector('button[type="submit"]')!.click()); + }; + + it('should keep the default fields-only form without a submit control', async () => { + await render(); + container.querySelectorAll('form').length.should.equal(1); + container.querySelectorAll('input').length.should.equal(2); + container.querySelectorAll('button').length.should.equal(0); + http.callCount.should.equal(0); + }); + + it('should place the footer after the fields inside the same native form', async () => { + await render(); + const form = container.querySelector('form')!; + const input = container.querySelector('input')!; + const button = container.querySelector('button')!; + (button.form === form).should.equal(true); + Boolean(input.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).should.equal(true); + button.textContent!.should.equal('Submit Sample User'); + container.querySelectorAll('form').length.should.equal(1); + }); + + it('should submit the typed Guid and edited string through native Command execute', async () => { + await render(); + const input = container.querySelector('input[aria-label="Name"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'Sample User Updated'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + container.querySelector('button')!.textContent!.should.equal('Submit Sample User Updated'); + await submit(); + http.callCount.should.equal(1); + submitted!.sampleId!.should.be.instanceOf(Guid); + new URL(String(http.firstCall.args[0])).pathname.should.equal('/api/sample-command'); + JSON.parse(String(http.firstCall.args[1]!.body)).should.deep.equal({ + sampleId: sampleId.toString(), name: 'Sample User Updated', + }); + }); + + it('should retain native required-value validation before reaching HTTP', async () => { + await render(, { sampleId: undefined }); + await submit(); + http.callCount.should.equal(0); + validationFailures.should.equal(1); + }); + + it('should clear and repopulate command values without replacing the native executor', async () => { + await render(); + await render(, { sampleId: undefined }); + await submit(); + http.callCount.should.equal(0); + const repopulated = Guid.parse('4b4869e3-900e-4342-9656-7ad675e43e9f'); + await render(, { sampleId: repopulated }); + await submit(); + http.callCount.should.equal(1); + submitted!.sampleId!.should.be.instanceOf(Guid); + JSON.parse(String(http.firstCall.args[1]!.body)).sampleId.should.equal(repopulated.toString()); + }); + + it('should allow non-action content without adding a submit control', async () => { + await render(

Example content

); + container.querySelector('form p')!.textContent!.should.equal('Example content'); + container.querySelectorAll('button').length.should.equal(0); + http.callCount.should.equal(0); + }); +}); diff --git a/Source/CommandForm/for_AutoCommandForm/when_editing_guid_fields.tsx b/Source/CommandForm/for_AutoCommandForm/when_editing_guid_fields.tsx new file mode 100644 index 00000000..73678dc8 --- /dev/null +++ b/Source/CommandForm/for_AutoCommandForm/when_editing_guid_fields.tsx @@ -0,0 +1,218 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { act } from 'react'; +import { Guid } from '@cratis/fundamentals'; +import type {} from 'chai/register-should'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { GuidField, type GuidFieldProps } from '../fields/GuidField'; +import { registerFieldTypeProvider } from '../fieldTypeProviderRegistry'; +import { SampleGuidCommand } from './given/SampleGuidCommand'; +import { a_guid_form, sampleGuid, exampleGuid, SampleOptionalGuidCommand } from './given/a_guid_form'; + +// These are field/binding gates, separate from the upstream custom-error execution regressions. +describe('when editing native Guid fields in an automatic command form', () => { + let form: a_guid_form; + beforeEach(() => { form = new a_guid_form(); form.setup(); }); + afterEach(async () => form.cleanup()); + + it('should parse text into a native Guid and serialize its value through native execute', async () => { + await form.render(); + await form.edit(sampleGuid); + form.context.commandInstance.sampleId!.should.be.instanceOf(Guid); + form.context.commandInstance.sampleId!.toString().should.equal(sampleGuid); + await form.submit(); + form.http.callCount.should.equal(1); + JSON.parse(String(form.http.firstCall.args[1]!.body)).should.deep.equal({ sampleId: sampleGuid, name: 'Sample User' }); + }); + + it('should retain partial and invalid drafts while immediately removing the old identifier', async () => { + await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); + for (const text of ['852a', 'not a Guid', ' ']) { + await form.edit(text); + form.input.value.should.equal(text); + (form.context.commandInstance.sampleId === undefined).should.equal(true); + form.context.customFieldErrors.sampleId.should.equal('Enter a valid Guid.'); + form.input.getAttribute('aria-invalid')!.should.equal('true'); + document.getElementById(form.input.getAttribute('aria-describedby')!)!.textContent!.should.equal('Enter a valid Guid.'); + } + }); + + it('should preserve an invalid draft when an unrelated field changes', async () => { + await form.render(); + await form.edit('partial'); + await form.edit('Example User', form.container.querySelector('input[aria-label="Name"]')!); + form.input.value.should.equal('partial'); + form.context.customFieldErrors.sampleId.should.equal('Enter a valid Guid.'); + }); + + it('should retain format validation when an empty custom message is supplied', async () => { + function ExampleGuidField(props: GuidFieldProps) { return ; } + registerFieldTypeProvider({ canHandle: (descriptor) => descriptor.type === Guid, component: ExampleGuidField }); + await form.render(); + await form.edit('partial'); + form.context.customFieldErrors.sampleId.should.equal('Enter a valid Guid.'); + }); + + it('should allow optional empty values without a format error or generated identifier', async () => { + await form.render(); + form.input.value.should.equal(''); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + (form.context.commandInstance.sampleId === undefined).should.equal(true); + await form.submit(); + form.http.callCount.should.equal(1); + JSON.parse(String(form.http.firstCall.args[1]!.body)).should.deep.equal({ name: 'Sample User' }); + }); + + it('should report required empty values through the actual context and native validation', async () => { + await form.render({ command: SampleGuidCommand }); + form.context.customFieldErrors.sampleId.should.equal('A value is required.'); + form.input.getAttribute('aria-required')!.should.equal('true'); + await form.submit(); + form.http.callCount.should.equal(0); + form.validationFailures.should.equal(1); + }); + + it('should clear an edited optional identifier and its format error', async () => { + await form.render(); + await form.edit(sampleGuid); + await form.edit('partial'); + await form.edit(''); + form.input.value.should.equal(''); + (form.context.commandInstance.sampleId === undefined).should.equal(true); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + await form.submit(); + JSON.parse(String(form.http.firstCall.args[1]!.body)).should.deep.equal({ name: 'Sample User' }); + }); + + it('should repopulate after invalid input and clear external values', async () => { + await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); + await form.edit('partial'); + await form.render({ currentValues: { sampleId: Guid.parse(exampleGuid) } }); + form.input.value.should.equal(exampleGuid); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + await form.render({ currentValues: { sampleId: undefined } }); + form.input.value.should.equal(''); + (form.context.commandInstance.sampleId === undefined).should.equal(true); + }); + + it('should clear an invalid draft when the external overlay changes to empty', async () => { + await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); + await form.edit('partial'); + await form.render({ currentValues: { sampleId: undefined } }); + form.input.value.should.equal(''); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + }); + + it('should keep a draft when an equivalent Guid overlay is recreated', async () => { + await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); + await form.edit('partial'); + await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); + form.input.value.should.equal('partial'); + }); + + it('should restore the baseline when the command is reverted and the form is refreshed', async () => { + await form.render({ initialValues: { sampleId: Guid.parse(sampleGuid) } }); + await form.edit('partial'); + await act(async () => { + form.context.commandInstance.revertChanges(); + form.context.setCommandValues({} as SampleOptionalGuidCommand); + }); + form.input.value.should.equal(sampleGuid); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + }); + + it('should discard a same-empty-value draft when the explicit reset key changes', async () => { + let resetKey = 0; + function ExampleGuidField(props: GuidFieldProps) { return ; } + registerFieldTypeProvider({ canHandle: (descriptor) => descriptor.type === Guid, component: ExampleGuidField }); + await form.render(); + await form.edit('partial'); + resetKey++; + await form.render(); + form.input.value.should.equal(''); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + }); + + it('should accept zero and unrestricted version bits without an implicit constraint', async () => { + await form.render(); + for (const text of [Guid.empty.toString(), 'ABCDEF01-2345-F678-FFFF-0123456789AB']) { + await form.edit(text); + form.context.commandInstance.sampleId!.should.be.instanceOf(Guid); + form.context.commandInstance.sampleId!.toString().should.equal(text.toLowerCase()); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + } + }); + + it('should let a later provider override Guid presentation while retaining its native binding', async () => { + function ExampleGuidField(props: GuidFieldProps) { return ; } + registerFieldTypeProvider({ canHandle: (descriptor) => descriptor.type === Guid, component: ExampleGuidField }); + await form.render(); + form.input.placeholder.should.equal('Example identifier'); + await form.edit(exampleGuid); + await form.submit(); + JSON.parse(String(form.http.firstCall.args[1]!.body)).sampleId.should.equal(exampleGuid); + }); + + it('should clean up owned validation errors when the Guid field is excluded and unmounted', async () => { + await form.render(); + await form.edit('partial'); + await form.render({ exclude: ['sampleId'] }); + (form.input === null).should.equal(true); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + await form.submit(); + form.http.callCount.should.equal(1); + }); + + it('should remove an error published by the edit that excludes the optional Guid field', async () => { + await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }, true); + await form.edit('partial'); + (form.input === null).should.equal(true); + (form.context.commandInstance.sampleId === undefined).should.equal(true); + (form.context.customFieldErrors.sampleId === undefined).should.equal(true); + await form.submit(); + form.http.callCount.should.equal(1); + JSON.parse(String(form.http.firstCall.args[1]!.body)).should.deep.equal({ name: 'Sample User' }); + }); + + it('should preserve a caller error when a valid edit excludes the optional Guid field', async () => { + await form.render({ onFieldValidate: () => 'Example validation message' }, true); + await form.edit(exampleGuid); + (form.input === null).should.equal(true); + form.context.customFieldErrors.sampleId.should.equal('Example validation message'); + }); + + it('should preserve a caller replacement made in the same batch as exclusion', async () => { + await form.render(); + await form.edit('partial'); + await act(async () => { + form.context.setCustomFieldError('sampleId', 'Example replacement message'); + await form.render({ exclude: ['sampleId'] }); + }); + (form.input === null).should.equal(true); + form.context.customFieldErrors.sampleId.should.equal('Example replacement message'); + }); + + it('should preserve a caller replacement after publication and before any field rerender', async () => { + await form.render({}, true); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(form.input, 'partial'); + form.input.dispatchEvent(new Event('input', { bubbles: true })); + form.context.setCustomFieldError('sampleId', 'Example replacement message'); + }); + (form.input === null).should.equal(true); + form.context.customFieldErrors.sampleId.should.equal('Example replacement message'); + }); + + it('should not clear caller-owned validation errors on a valid edit or unmount', async () => { + const onFieldValidate = () => 'Example validation message'; + await form.render({ onFieldValidate }); + await form.edit('partial'); + await form.edit(exampleGuid); + form.context.customFieldErrors.sampleId.should.equal('Example validation message'); + await form.render({ onFieldValidate, exclude: ['sampleId'] }); + form.context.customFieldErrors.sampleId.should.equal('Example validation message'); + }); +}); diff --git a/Source/CommandForm/for_AutoCommandForm/when_native_execution_must_veto_invalid_guid_drafts.tsx b/Source/CommandForm/for_AutoCommandForm/when_native_execution_must_veto_invalid_guid_drafts.tsx new file mode 100644 index 00000000..68f44eb1 --- /dev/null +++ b/Source/CommandForm/for_AutoCommandForm/when_native_execution_must_veto_invalid_guid_drafts.tsx @@ -0,0 +1,92 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { act } from 'react'; +import { Guid } from '@cratis/fundamentals'; +import type { ICommandResult } from '@cratis/arc/commands'; +import type {} from 'chai/register-should'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { a_guid_form, sampleGuid, successfulResponse } from './given/a_guid_form'; + +// Native execution regressions for Arc React's custom-error guard, required since 22.19.1. +// Keep the real context, handle and command executor: presentation alone is not a validation gate. +describe('when native execution must veto an invalid optional Guid draft', () => { + let form: a_guid_form; + beforeEach(async () => { + form = new a_guid_form(); + form.setup(); + await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); + await form.edit('partial'); + }); + afterEach(async () => form.cleanup()); + + it('should veto DOM form submit before HTTP despite the optional command value being empty', async () => { + await form.submit(); + form.http.callCount.should.equal(0); + form.validationFailures.should.equal(1); + }); + + it('should veto submit in the same event batch as an invalid edit', async () => { + await form.edit(sampleGuid); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(form.input, 'partial'); + form.input.dispatchEvent(new Event('input', { bubbles: true })); + form.container.querySelector('button[type="submit"]')!.click(); + }); + form.http.callCount.should.equal(0); + }); + + it('should keep the veto during successive invalid edits before context rerenders', async () => { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(form.input, 'still partial'); + form.input.dispatchEvent(new Event('input', { bubbles: true })); + form.container.querySelector('button[type="submit"]')!.click(); + }); + form.http.callCount.should.equal(0); + }); + + it('should veto execution through the actual command form context', async () => { + await act(async () => { await form.context.onExecute!(); }); + form.http.callCount.should.equal(0); + }); + + it('should veto execution through a captured native form handle', async () => { + const handle = form.formRef.current!; + await act(async () => { await handle.execute(); }); + form.http.callCount.should.equal(0); + }); + + it('should expose custom format errors in native context and handle validity', () => { + form.context.isValid.should.equal(false); + form.formRef.current!.isValid.should.equal(false); + }); + + it('should return and store a validation failure instead of a successful execution result', async () => { + let result!: ICommandResult; + await act(async () => { result = await form.formRef.current!.execute(); }); + result.isSuccess.should.equal(false); + result.isValid.should.equal(false); + result.validationResults.some((entry) => entry.members.includes('sampleId')).should.equal(true); + form.context.commandResult!.isValid.should.equal(false); + }); + + it('should retain invalid state when an older successful silent HTTP validation completes', async () => { + await form.edit(sampleGuid); + let complete!: (response: Response) => void; + form.http.callsFake(() => new Promise((resolve) => { complete = resolve; })); + const issue = form.context.beginSilentValidation(); + const pending = form.context.commandInstance.validate(); + // Native validate reaches the substituted HTTP boundary without replacing any validation method. + await act(async () => { await Promise.resolve(); }); + await form.edit('partial'); + await act(async () => { + complete(successfulResponse()); + form.context.setSilentValidationResult(await pending, issue); + }); + form.input.value.should.equal('partial'); + form.context.customFieldErrors.sampleId.should.equal('Enter a valid Guid.'); + form.context.isValid.should.equal(false); + }); +}); diff --git a/Source/CommandForm/for_fieldTypeProviderRegistry/when_resolving_the_default_providers.ts b/Source/CommandForm/for_fieldTypeProviderRegistry/when_resolving_the_default_providers.ts index 7d8334e3..21332243 100644 --- a/Source/CommandForm/for_fieldTypeProviderRegistry/when_resolving_the_default_providers.ts +++ b/Source/CommandForm/for_fieldTypeProviderRegistry/when_resolving_the_default_providers.ts @@ -1,6 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { Guid } from '@cratis/fundamentals'; +import { GuidField } from '../fields/GuidField'; import { PropertyDescriptor } from '@cratis/arc/reflection'; import { clearFieldTypeProviders, resolveFieldTypeProvider } from '../fieldTypeProviderRegistry'; import { registerDefaultFieldTypeProviders } from '../defaultFieldTypeProviders'; @@ -15,6 +17,14 @@ describe('when resolving the default providers', () => { registerDefaultFieldTypeProviders(); }); + it('should resolve a scalar Guid property to GuidField', () => { + resolveFieldTypeProvider(new PropertyDescriptor('sampleId', Guid, true))!.component.should.equal(GuidField); + }); + + it('should not resolve an array property to GuidField', () => { + (resolveFieldTypeProvider(new PropertyDescriptor('sampleIds', Array, true)) === undefined).should.equal(true); + }); + it('should resolve a string property to InputTextField', () => { resolveFieldTypeProvider(new PropertyDescriptor('name', String, false))!.component.should.equal(InputTextField); }); diff --git a/Source/MIGRATION.md b/Source/MIGRATION.md index 8a296e02..16abdbb6 100644 --- a/Source/MIGRATION.md +++ b/Source/MIGRATION.md @@ -1,4 +1,28 @@ -# Migrate from Components 3 to 4 +# Migrate from Components 4 to 5 + +Components 5.0 narrows the Arc and Arc React peer contract from `>=20.3.1 <23` to +`>=22.19.1 <23`. Upgrade `@cratis/arc` and `@cratis/arc.react` together to matching +versions, at least 22.19.1 and below 23, and keep generated proxies aligned. Older Arc +hosts are not supported; there is no runtime version-detection fallback. + +Upgrade every installed Components-family package to the same 5.x release: Core, +ESLint, Migrator, Conformance, MUI, PrimeReact 11, and PrimeReact 10 adapters. Optional +packages remain optional. Adapter and Conformance peers are `@cratis/components >=5 <6`. +Preserve the manifest and lockfile first, then run type checks, command/form tests, +and the production build. Roll back the family and Arc changes together if necessary. + +Existing form APIs remain compatible. The additions are `GuidField`, generated Guid +field selection, and composed `AutoCommandForm` footer children. Invalid Guid drafts +use native Arc custom-field-error execution guards. There is no automatic identifier +generation, authentication/authorization change, additional API removal, or renderer +ABI change: ABI 1, `core/v1`, and `stable-presentation/v1` remain unchanged. + +No 4-to-5 source codemod is needed. Migrator 5 retains the existing 3-to-4 transforms +and supports Components 3 source and Components 4/5 recovery targets. Migrator 4 keeps +its original Components 3/4 boundary. Follow the historical staged migration below +when starting from Components 3. + +# Historical migration: Components 3 to 4 Components 4 replaces the PrimeReact-backed Components 3 foundation with Components-owned markup, styling contracts, and public types. React Aria supplies selected interaction primitives internally. The current Components 4 manifest does not declare PrimeReact, PrimeIcons, PrimeUI, or PrimeUI themes as dependencies or peers; applications retaining direct imports keep their own package and license boundaries. diff --git a/Source/README.md b/Source/README.md index 7a912416..a02011da 100644 --- a/Source/README.md +++ b/Source/README.md @@ -17,17 +17,17 @@ interaction primitives internally; consumers do not import or style React Aria. ## Install ```bash -npm install @cratis/components@^4 +npm install @cratis/components@^5 ``` -> **Publication status:** This install example targets the owner-authorized 4.0.0 npm release. When +> **Publication status:** This install example targets the owner-authorized 5.0.0 npm release. When > reading this README from repository source before that release, verify availability with -> `npm view @cratis/components@4.0.0 version`; source contributors use the repository workspace +> `npm view @cratis/components@5.0.0 version`; source contributors use the repository workspace > instead. The current package manifest declares these peer dependencies: -- `@cratis/arc` and `@cratis/arc.react` `>=20.3.1 <23` +- `@cratis/arc` and `@cratis/arc.react` `>=22.19.1 <23` (matching versions) - `@cratis/fundamentals` `^7.10.3` - optional `pixi.js` `^8.20.0` - `react` and `react-dom` `^19.0.0` @@ -37,8 +37,8 @@ The current package manifest declares these peer dependencies: Strict installers can declare them explicitly; keep both Arc packages on the same application version: ```bash -ARC_VERSION=22.6.2 -npm install @cratis/components@^4 \ +ARC_VERSION=22.19.1 +npm install @cratis/components@^5 \ "@cratis/arc@$ARC_VERSION" "@cratis/arc.react@$ARC_VERSION" \ @cratis/fundamentals@^7.10.3 react@^19 react-dom@^19 \ reflect-metadata@0.2.2 tsyringe@4.10.0 @@ -58,7 +58,7 @@ object; runtime behavior still requires `@cratis/components.conformance`. See th [renderer-adapter documentation](https://github.com/Cratis/Components/blob/16dd95b7c894f3275b03714ffdc676d2d3505fd8/Documentation/renderers/index.md#adapter-package-metadata-schema) for the boundary and limitations. -**Yarn PnP note:** the current `@cratis/arc.react@22.6.2` package imports `rxjs` without declaring it. Strict PnP consumers install `rxjs@7.8.2` and add a temporary `packageExtensions` entry for `@cratis/arc.react@22.6.2`; remove it when Arc publishes corrected metadata. The canonical [getting-started guide](https://cratis.io/components/getting-started/) contains the exact YAML. +**Historical Yarn PnP note:** older Components releases allowed `@cratis/arc.react@22.6.2`, which imported `rxjs` without declaring it. That version required a temporary, version-specific `packageExtensions` entry. Components 5 requires Arc React 22.19.1 or later and does not need that workaround; remove the old extension when upgrading. The canonical [getting-started guide](https://cratis.io/components/getting-started/) retains the historical YAML. `pixi.js@^8.20.0` is an additional **optional** peer, required only by `Canvas` and `PivotViewer` (the Spatial capability profile — see [Import from explicit subpaths](#import-from-explicit-subpaths) below). Every other subpath needs nothing beyond the peers above: @@ -109,10 +109,10 @@ stable, nine-slot `stable-presentation/v1` primitive profile; they never replace catalog. They share the Components repository release version and are selected with the provider's `library` prop: -- `@cratis/components.mui@4.0.0` — MUI 9.x / Emotion 11.x stable presentation slots; -- `@cratis/components.primereact@4.0.0` — PrimeReact 11.x stable presentation slots, with an +- `@cratis/components.mui@5.0.0` — MUI 9.x / Emotion 11.x stable presentation slots; +- `@cratis/components.primereact@5.0.0` — PrimeReact 11.x stable presentation slots, with an application-owned outer provider and license key; -- `@cratis/components.primereact10@4.0.0` — PrimeReact 10.9.9+ stable presentation slots, with its +- `@cratis/components.primereact10@5.0.0` — PrimeReact 10.9.9+ stable presentation slots, with its separate MIT-era provider, global theme, and upstream-major boundary. Adapter-specific themes, providers, SSR setup, peers, and license boundaries remain documented by @@ -278,6 +278,14 @@ setup-only so importing the provider does not traverse optional or unrelated component graphs. [MIGRATION.md](./MIGRATION.md) contains the current namespace-to-subpath mapping and migration command for existing root imports. +## Components 4 to 5 migration + +Components 5 narrows the Arc/Arc React minimum to 22.19.1. Upgrade matching Arc +versions and every installed Components-family package together to the same 5.x +release. Existing form APIs remain compatible, with Guid fields and composed footers +added; there is no automatic ID generation, authentication change, or renderer ABI +change. See [MIGRATION.md](./MIGRATION.md) for the upgrade and rollback steps. + ## Components 3 migration The current package manifest does not declare PrimeReact as a required runtime diff --git a/Source/compat-manifest.json b/Source/compat-manifest.json index 89ebc244..f1210d6f 100644 --- a/Source/compat-manifest.json +++ b/Source/compat-manifest.json @@ -30,9 +30,9 @@ ] }, "toolingCompatibility": { - "componentsCore": ">=4 <5", - "eslint": ">=4 <5", - "migrator": ">=4 <5" + "componentsCore": ">=5 <6", + "eslint": ">=5 <6", + "migrator": ">=5 <6" }, "supportWindows": { "components3": { @@ -40,21 +40,21 @@ "status": "maintenance-security-critical", "migrationRole": "source", "migrationTarget": ">=4 <5", - "tooling": ">=4 <5", + "tooling": ">=4 <6", "eolAt": null, "eolApprovedByOwners": false, "ownerDecisionPolicy": "Set and approve EOL no later than 12 months after Components 4 GA." }, "components4": { "components": ">=4 <5", - "status": "current", + "status": "previous", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", "adapterProfile": "stable-presentation/v1", "tooling": { "eslint": ">=4 <5", - "migrator": ">=4 <5" + "migrator": ">=4 <6" }, "adapters": { "@cratis/components.conformance": ">=4 <5", @@ -62,20 +62,38 @@ "@cratis/components.primereact": ">=4 <5", "@cratis/components.primereact10": ">=4 <5" } + }, + "components5": { + "components": ">=5 <6", + "status": "current", + "migrationRole": "target", + "rendererAbi": 1, + "coreProfile": "core/v1", + "adapterProfile": "stable-presentation/v1", + "tooling": { + "eslint": ">=5 <6", + "migrator": ">=5 <6" + }, + "adapters": { + "@cratis/components.conformance": ">=5 <6", + "@cratis/components.mui": ">=5 <6", + "@cratis/components.primereact": ">=5 <6", + "@cratis/components.primereact10": ">=5 <6" + } } }, "packages": [ { "name": "@cratis/components", "role": "core", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/arc": ">=20.3.1 <23", - "@cratis/arc.react": ">=20.3.1 <23", + "@cratis/arc": ">=22.19.1 <23", + "@cratis/arc.react": ">=22.19.1 <23", "@cratis/fundamentals": "^7.10.3", "pixi.js": "^8.20.0", "react": "^19.0.0", @@ -92,8 +110,8 @@ { "name": "@cratis/eslint-plugin-components", "role": "eslint", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", @@ -105,8 +123,8 @@ { "name": "@cratis/components.migrator", "role": "migrator", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", @@ -116,13 +134,13 @@ { "name": "@cratis/components.conformance", "role": "conformance", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -132,13 +150,13 @@ { "name": "@cratis/components.mui", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "@emotion/react": ">=11.5 <12", "@emotion/styled": ">=11.11 <12", "@mui/material": ">=9 <10", @@ -156,8 +174,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.5.0", "@emotion/styled": "11.11.0", @@ -170,8 +188,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", @@ -188,13 +206,13 @@ { "name": "@cratis/components.primereact", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "@primereact/core": ">=11 <12", "@primereact/ui": ">=11 <12", "@primeuix/themes": ">=3 <4", @@ -214,8 +232,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.0.0", "@primereact/headless": "11.0.0", @@ -233,8 +251,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.1.0", "@primereact/headless": "11.1.0", @@ -256,13 +274,13 @@ { "name": "@cratis/components.primereact10", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "primereact": ">=10.9.9 <11", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -276,8 +294,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@types/react": "19.0.0", "primereact": "10.9.9", @@ -288,8 +306,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@types/react": "19.2.18", "primereact": "10.9.9", diff --git a/Source/package.json b/Source/package.json index da93133b..4314aff8 100644 --- a/Source/package.json +++ b/Source/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components", - "version": "4.0.0", + "version": "5.0.0", "description": "React components for CQRS and event-sourced apps — command dialogs, typed forms, and query-backed data tables for Cratis Arc", "keywords": [ "react", @@ -297,8 +297,8 @@ "react-dom": "19.3.0" }, "peerDependencies": { - "@cratis/arc": ">=20.3.1 <23", - "@cratis/arc.react": ">=20.3.1 <23", + "@cratis/arc": ">=22.19.1 <23", + "@cratis/arc.react": ">=22.19.1 <23", "@cratis/fundamentals": "^7.10.3", "pixi.js": "^8.20.0", "react": "^19.0.0", diff --git a/Source/scripts/generate-parts.mjs b/Source/scripts/generate-parts.mjs index 6622145d..fe0ee4e4 100644 --- a/Source/scripts/generate-parts.mjs +++ b/Source/scripts/generate-parts.mjs @@ -126,6 +126,11 @@ export const partDefinitions = { sources: ['CommandForm/fields/PasswordField.tsx'], ptKeys: ['root', 'input', 'toggle'], }, + GuidField: { + parts: ['input'], + sources: ['CommandForm/fields/GuidField.tsx'], + ptKeys: ['root'], + }, InputTextField: { parts: ['input'], sources: ['CommandForm/fields/InputTextField.tsx'], @@ -643,6 +648,9 @@ export const partStateDefinitions = { input: ['disabled', 'invalid', 'readonly'], toggle: ['disabled'], }, + GuidField: { + input: ['disabled', 'invalid', 'readonly'], + }, InputTextField: { input: ['disabled', 'invalid', 'readonly'], }, diff --git a/Source/scripts/verify-parts-manifest.mjs b/Source/scripts/verify-parts-manifest.mjs index be7ab05f..d3b4937d 100644 --- a/Source/scripts/verify-parts-manifest.mjs +++ b/Source/scripts/verify-parts-manifest.mjs @@ -638,9 +638,9 @@ function verifyPublicPtContracts() { `Public pt file inventory differs: source=[${actualFiles.join(', ')}], manifest=[${expectedFiles.join(', ')}].`, ); } - if (actualFiles.length !== 51 || declarationCount !== 54) { + if (actualFiles.length !== 52 || declarationCount !== 55) { problems.push( - `Expected the authoritative 51 pt files / 54 declarations, found ${actualFiles.length} files / ${declarationCount} declarations.`, + `Expected the authoritative 52 pt files / 55 declarations, found ${actualFiles.length} files / ${declarationCount} declarations.`, ); } return problems; @@ -874,7 +874,7 @@ function main() { 0, ); console.log( - `Parts manifest verified: ${componentCount} components, ${partCount} component parts, ${stateCount} component/part states, 51 pt files, and 54 pt declarations.`, + `Parts manifest verified: ${componentCount} components, ${partCount} component parts, ${stateCount} component/part states, 52 pt files, and 55 pt declarations.`, ); } diff --git a/Source/scripts/verify-public-types.exceptions.json b/Source/scripts/verify-public-types.exceptions.json index e08e5483..7ea2a790 100644 --- a/Source/scripts/verify-public-types.exceptions.json +++ b/Source/scripts/verify-public-types.exceptions.json @@ -22,7 +22,7 @@ "id": "arc-react-global-jsx-namespace", "summary": "@cratis/arc.react ships a global.d.ts that declares `namespace JSX` referencing test-only ambient types (vitest/globals, chai, sinon-chai) as part of its published package instead of scoping that augmentation to its own test build. A strict external consumer that does not also install those test packages cannot resolve the bare `JSX` identifiers arc.react's dialog/command declaration files reference.", "reason": "external - @cratis/arc.react's own shipped .d.ts, not a Components declaration; Components has no `namespace JSX` augmentation of its own.", - "upstreamPackages": [{ "name": "@cratis/arc.react", "version": "22.16.0" }], + "upstreamPackages": [{ "name": "@cratis/arc.react", "version": "22.19.1" }], "resolutionModes": ["bundler"], "diagnosticCodes": ["TS2503"], "filePrefixes": ["@cratis/arc.react/"], @@ -33,8 +33,8 @@ "summary": "@cratis/arc, @cratis/arc.react, and @cratis/fundamentals publish ESM declaration files with extensionless relative specifiers (e.g. `export * from './ICommandScope'`). Node16/NodeNext module resolution requires an explicit extension on relative ECMAScript imports, so these upstream barrels fail under --moduleResolution nodenext; downstream symbols that transit through the broken barrel then read as missing (TS2305/TS2694 cascades).", "reason": "external - the extensionless specifiers live in @cratis/arc, @cratis/arc.react, and @cratis/fundamentals's own published dist, not in Components' declarations. Cratis/Components' own declaration rewrite (this PR) already eliminated Components' extensionless specifiers.", "upstreamPackages": [ - { "name": "@cratis/arc", "version": "22.16.0" }, - { "name": "@cratis/arc.react", "version": "22.16.0" }, + { "name": "@cratis/arc", "version": "22.19.1" }, + { "name": "@cratis/arc.react", "version": "22.19.1" }, { "name": "@cratis/fundamentals", "version": "7.19.2" } ], "resolutionModes": ["nodenext"], diff --git a/Source/types/parts.ts b/Source/types/parts.ts index 8a3e3268..cf50276d 100644 --- a/Source/types/parts.ts +++ b/Source/types/parts.ts @@ -18,6 +18,7 @@ export const cratisParts = { NumberInputField: ['root', 'group', 'input', 'prefix', 'suffix', 'step', 'description', 'error'], ColorPickerField: ['root', 'input', 'value'], PasswordField: ['root', 'input', 'toggle'], + GuidField: ['input'], InputTextField: ['input'], TextAreaField: ['textarea'], Dropdown: ['root', 'value', 'filter', 'trigger', 'clear', 'popover', 'listbox', 'option', 'multiple', 'indicator'], @@ -177,6 +178,9 @@ export const cratisPartStates = { 'input': ['disabled', 'invalid', 'readonly'], 'toggle': ['disabled'], }, + GuidField: { + 'input': ['disabled', 'invalid', 'readonly'], + }, InputTextField: { 'input': ['disabled', 'invalid', 'readonly'], }, diff --git a/compat-manifest.json b/compat-manifest.json index 89ebc244..f1210d6f 100644 --- a/compat-manifest.json +++ b/compat-manifest.json @@ -30,9 +30,9 @@ ] }, "toolingCompatibility": { - "componentsCore": ">=4 <5", - "eslint": ">=4 <5", - "migrator": ">=4 <5" + "componentsCore": ">=5 <6", + "eslint": ">=5 <6", + "migrator": ">=5 <6" }, "supportWindows": { "components3": { @@ -40,21 +40,21 @@ "status": "maintenance-security-critical", "migrationRole": "source", "migrationTarget": ">=4 <5", - "tooling": ">=4 <5", + "tooling": ">=4 <6", "eolAt": null, "eolApprovedByOwners": false, "ownerDecisionPolicy": "Set and approve EOL no later than 12 months after Components 4 GA." }, "components4": { "components": ">=4 <5", - "status": "current", + "status": "previous", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", "adapterProfile": "stable-presentation/v1", "tooling": { "eslint": ">=4 <5", - "migrator": ">=4 <5" + "migrator": ">=4 <6" }, "adapters": { "@cratis/components.conformance": ">=4 <5", @@ -62,20 +62,38 @@ "@cratis/components.primereact": ">=4 <5", "@cratis/components.primereact10": ">=4 <5" } + }, + "components5": { + "components": ">=5 <6", + "status": "current", + "migrationRole": "target", + "rendererAbi": 1, + "coreProfile": "core/v1", + "adapterProfile": "stable-presentation/v1", + "tooling": { + "eslint": ">=5 <6", + "migrator": ">=5 <6" + }, + "adapters": { + "@cratis/components.conformance": ">=5 <6", + "@cratis/components.mui": ">=5 <6", + "@cratis/components.primereact": ">=5 <6", + "@cratis/components.primereact10": ">=5 <6" + } } }, "packages": [ { "name": "@cratis/components", "role": "core", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/arc": ">=20.3.1 <23", - "@cratis/arc.react": ">=20.3.1 <23", + "@cratis/arc": ">=22.19.1 <23", + "@cratis/arc.react": ">=22.19.1 <23", "@cratis/fundamentals": "^7.10.3", "pixi.js": "^8.20.0", "react": "^19.0.0", @@ -92,8 +110,8 @@ { "name": "@cratis/eslint-plugin-components", "role": "eslint", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", @@ -105,8 +123,8 @@ { "name": "@cratis/components.migrator", "role": "migrator", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", @@ -116,13 +134,13 @@ { "name": "@cratis/components.conformance", "role": "conformance", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -132,13 +150,13 @@ { "name": "@cratis/components.mui", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "@emotion/react": ">=11.5 <12", "@emotion/styled": ">=11.11 <12", "@mui/material": ">=9 <10", @@ -156,8 +174,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.5.0", "@emotion/styled": "11.11.0", @@ -170,8 +188,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", @@ -188,13 +206,13 @@ { "name": "@cratis/components.primereact", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "@primereact/core": ">=11 <12", "@primereact/ui": ">=11 <12", "@primeuix/themes": ">=3 <4", @@ -214,8 +232,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.0.0", "@primereact/headless": "11.0.0", @@ -233,8 +251,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.1.0", "@primereact/headless": "11.1.0", @@ -256,13 +274,13 @@ { "name": "@cratis/components.primereact10", "role": "renderer-adapter", - "version": "4.0.0", - "releaseMajorRange": ">=4 <5", + "version": "5.0.0", + "releaseMajorRange": ">=5 <6", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=4 <5", + "@cratis/components": ">=5 <6", "primereact": ">=10.9.9 <11", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -276,8 +294,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@types/react": "19.0.0", "primereact": "10.9.9", @@ -288,8 +306,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "@types/react": "19.2.18", "primereact": "10.9.9", diff --git a/package.json b/package.json index d3caad81..51565696 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "verify-compat-manifest": "node ./scripts/generate-compat-manifest.mjs --check", "generate-release-evidence": "node ./scripts/generate-release-evidence.mjs", "test-release-evidence": "node --test ./scripts/generate-release-evidence.test.mjs", - "test-release-policy": "node --test ./scripts/generate-compat-manifest.test.mjs", + "test-release-policy": "node --test ./scripts/generate-compat-manifest.test.mjs ./scripts/prepare-release.test.mjs", "test-renderer-adapter-matrix": "node --test ./scripts/verify-renderer-adapter-matrix.test.mjs", "verify-renderer-adapters": "node ./scripts/verify-renderer-adapters.mjs", "publish-version": "node ./run-task-on-workspaces.js publish-version", @@ -52,8 +52,8 @@ "version": "0.0.0", "packageManager": "yarn@4.17.1", "devDependencies": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/arc.vite": "22.16.0", "@cyclonedx/yarn-plugin-cyclonedx": "3.4.0", "@eslint-react/eslint-plugin": "5.19.0", diff --git a/release.md b/release.md index 325818bc..7589ed7a 100644 --- a/release.md +++ b/release.md @@ -4,6 +4,24 @@ Components follows the standard Cratis label-driven release flow. A pull request with exactly one `patch`, `minor`, or `major` label triggers `.github/workflows/publish.yml`. A `no-release` label explicitly suppresses publication for maintenance changes. +## Next release: 5.0.0 + +The next release requires a **major** label and version **5.0.0**, not a 4.x minor. +The breaking change is dependency support: Core requires matching Arc and Arc React +versions in `>=22.19.1 <23`, replacing the 4.x declared `>=20.3.1 <23` contract. +All seven packages move together to 5.x; adapters and Conformance require Core `>=5 <6`. +Renderer ABI 1 and the existing profiles remain unchanged. Existing forms remain +compatible, with Guid fields and footer composition added; there is no automatic ID +generation or authentication/authorization change. See [4-to-5 migration](Documentation/Migration/4-to-5.md). + +Release validation accepts only explicitly reviewed families (historical 4 and current 5), +checks each family's truthful Arc floor and matching Core peers, and rejects publishing +this source as 4.x or 6.x. The publish runner prepares all seven versions and regenerates +the three compatibility-manifest copies **before** the first publication, preserving +bounded peer contracts. Migrator 5 retains the 3-to-4 transforms and accepts Components +3/4/5 preflight windows; historical Migrator 4 retains its original 3/4 windows. +No source metadata or this document is evidence of an actual publication. + ## Published packages One release publishes these seven public packages at the same version: @@ -29,7 +47,7 @@ whether publication is required. The npm job: 1. checks out the exact merged commit; 2. installs the committed lockfile with `yarn install --immutable`; 3. builds all public workspaces; -4. updates every public workspace and local workspace dependency to the release version; +4. validates the reviewed release family, updates every public workspace and local non-peer workspace dependency to the release version, preserves bounded family peers, and regenerates bundled compatibility metadata; 5. publishes each package publicly with npm provenance; and 6. triggers documentation and sample dependency updates. diff --git a/run-task-on-workspaces.js b/run-task-on-workspaces.js index e678af0e..dc948818 100755 --- a/run-task-on-workspaces.js +++ b/run-task-on-workspaces.js @@ -66,28 +66,17 @@ if ( process.exit(1); } const releaseVersion = isPublishing ? args[0] : undefined; -const workspaceNames = new Set(Object.keys(workspaces)); - -const saveJson = (file, value) => - fs.writeFileSync(file, `${JSON.stringify(value, null, 4)}\n`, 'utf8'); - -const preparePackageForRelease = (packageJson, version) => { - const releasePackage = structuredClone(packageJson); - releasePackage.version = version; - for (const field of [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', - ]) { - for (const dependencyName of Object.keys(releasePackage[field] ?? {})) { - if (workspaceNames.has(dependencyName)) { - releasePackage[field][dependencyName] = version; - } - } +if (isPublishing) { + const prepared = spawn(process.execPath, ['scripts/prepare-release.mjs', releaseVersion], { + cwd: process.cwd(), + stdio: 'inherit', + }); + if (prepared.status !== 0) { + console.error('Release policy validation/preparation failed. Nothing was published.'); + if (prepared.error) console.error(prepared.error); + process.exit(1); } - return releasePackage; -}; +} console.log(`Performing '${task}' on workspaces`); if (args.length > 0) console.log(` Using args : ${args}`); @@ -107,8 +96,6 @@ for (const workspaceName in workspaces) { continue; } if (isPublishing) { - const releasePackage = preparePackageForRelease(packageJson, releaseVersion); - saveJson(packageJsonFile, releasePackage); console.log( `Publishing workspace '${workspaceName}' at '${workspaceRelativeLocation}' as ${releaseVersion}`, ); diff --git a/scripts/generate-compat-manifest.mjs b/scripts/generate-compat-manifest.mjs index 08f76921..36ade9c4 100644 --- a/scripts/generate-compat-manifest.mjs +++ b/scripts/generate-compat-manifest.mjs @@ -7,6 +7,7 @@ import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import semver from 'semver'; import { createFixture, validateMatrix } from './lib/renderer-adapter-matrix.mjs'; +import { releasePolicy, supportWindows, validateReleasePolicy } from '../Migrator/lib/releasePolicy.js'; const repositoryDirectory = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -18,13 +19,13 @@ const outputPaths = [ 'Migrator/compat-manifest.json', ]; const packagePolicies = new Map([ - ['@cratis/components', { role: 'core', range: '>=4 <5' }], - ['@cratis/eslint-plugin-components', { role: 'eslint', range: '>=4 <5' }], - ['@cratis/components.migrator', { role: 'migrator', range: '>=4 <5' }], - ['@cratis/components.conformance', { role: 'conformance', range: '>=4 <5' }], - ['@cratis/components.mui', { role: 'renderer-adapter', range: '>=4 <5' }], - ['@cratis/components.primereact', { role: 'renderer-adapter', range: '>=4 <5' }], - ['@cratis/components.primereact10', { role: 'renderer-adapter', range: '>=4 <5' }], + ['@cratis/components', { role: 'core' }], + ['@cratis/eslint-plugin-components', { role: 'eslint' }], + ['@cratis/components.migrator', { role: 'migrator' }], + ['@cratis/components.conformance', { role: 'conformance' }], + ['@cratis/components.mui', { role: 'renderer-adapter' }], + ['@cratis/components.primereact', { role: 'renderer-adapter' }], + ['@cratis/components.primereact10', { role: 'renderer-adapter' }], ]); const packageOrder = [...packagePolicies.keys()]; const privateEvidence = [ @@ -84,6 +85,8 @@ export function createCompatibilityManifest(rootDirectory = repositoryDirectory) }; } + const version = publicPackages[0].version; + const { range } = releasePolicy(version); const manifest = { schemaVersion: 2, releaseStatus: 'publication-authorized', @@ -99,41 +102,11 @@ export function createCompatibilityManifest(rootDirectory = repositoryDirectory) })), }, toolingCompatibility: { - componentsCore: '>=4 <5', - eslint: '>=4 <5', - migrator: '>=4 <5', - }, - supportWindows: { - components3: { - components: '>=3 <4', - status: 'maintenance-security-critical', - migrationRole: 'source', - migrationTarget: '>=4 <5', - tooling: '>=4 <5', - eolAt: null, - eolApprovedByOwners: false, - ownerDecisionPolicy: - 'Set and approve EOL no later than 12 months after Components 4 GA.', - }, - components4: { - components: '>=4 <5', - status: 'current', - migrationRole: 'target', - rendererAbi: 1, - coreProfile: 'core/v1', - adapterProfile: 'stable-presentation/v1', - tooling: { - eslint: '>=4 <5', - migrator: '>=4 <5', - }, - adapters: { - '@cratis/components.conformance': '>=4 <5', - '@cratis/components.mui': '>=4 <5', - '@cratis/components.primereact': '>=4 <5', - '@cratis/components.primereact10': '>=4 <5', - }, - }, + componentsCore: range, + eslint: range, + migrator: range, }, + supportWindows: supportWindows(version), packages: publicPackages, }; @@ -169,19 +142,20 @@ export function validateCompatibilityManifest( ({ name }) => name === '@cratis/components', )?.version; if (!repositoryVersion) fail('Core package version is missing.'); + const { range } = releasePolicy(repositoryVersion); for (const entry of manifest.packages ?? []) { const policy = packagePolicies.get(entry.name); if (!policy) fail(`Unexpected public package '${entry.name}'.`); - if (entry.role !== policy.role || entry.releaseMajorRange !== policy.range) { + if (entry.role !== policy.role || entry.releaseMajorRange !== range) { fail(`${entry.name} has invalid role or release-major policy metadata.`); } if (semver.valid(entry.version) !== entry.version) { fail(`${entry.name} must declare a valid exact current version.`); } - if (!semver.satisfies(entry.version, policy.range)) { + if (!semver.satisfies(entry.version, range)) { fail( - `${entry.name}@${entry.version} is outside supported release range '${policy.range}'.`, + `${entry.name}@${entry.version} is outside supported release range '${range}'.`, ); } if (entry.private || entry.packageAccess !== 'public') { @@ -208,49 +182,7 @@ export function validateCompatibilityManifest( } } - const tooling = manifest.toolingCompatibility ?? {}; - if ( - tooling.componentsCore !== '>=4 <5' || - tooling.eslint !== '>=4 <5' || - tooling.migrator !== '>=4 <5' - ) { - fail('Core 4 tooling compatibility must remain bounded to >=4 <5.'); - } - - const components3 = manifest.supportWindows?.components3; - const components4 = manifest.supportWindows?.components4; - if ( - components3?.components !== '>=3 <4' || - components3?.status !== 'maintenance-security-critical' || - components3?.migrationTarget !== '>=4 <5' || - components3?.tooling !== '>=4 <5' || - !components3.ownerDecisionPolicy?.includes('12 months after Components 4 GA') - ) { - fail('The Components 3 maintenance and migration support window is incomplete.'); - } - if ( - components4?.components !== '>=4 <5' || - components4?.status !== 'current' || - components4?.rendererAbi !== 1 || - components4?.coreProfile !== 'core/v1' || - components4?.adapterProfile !== 'stable-presentation/v1' || - components4?.tooling?.eslint !== '>=4 <5' || - components4?.tooling?.migrator !== '>=4 <5' - ) { - fail('The Components 4 compatibility window is incomplete.'); - } - const expectedAdapterRanges = Object.fromEntries( - packageOrder - .filter((name) => - ['conformance', 'renderer-adapter'].includes( - packagePolicies.get(name).role, - ), - ) - .map((name) => [name, packagePolicies.get(name).range]), - ); - if (JSON.stringify(components4?.adapters) !== JSON.stringify(expectedAdapterRanges)) { - fail('Components 4 must list the exact Conformance and adapter release ranges.'); - } + validateReleasePolicy(manifest, repositoryVersion); const evidence = manifest.gaScope?.privateEvidence ?? []; if ( @@ -320,7 +252,7 @@ function packageEntry(packageJson) { name: packageJson.name, role: policy.role, version: packageJson.version, - releaseMajorRange: policy.range, + releaseMajorRange: releasePolicy(packageJson.version).range, independentRelease: packageJson.cratisIndependentVersion === true, private: packageJson.private === true, packageAccess: packageJson.publishConfig?.access ?? null, diff --git a/scripts/generate-compat-manifest.test.mjs b/scripts/generate-compat-manifest.test.mjs index 7bd26b2c..c163de6e 100644 --- a/scripts/generate-compat-manifest.test.mjs +++ b/scripts/generate-compat-manifest.test.mjs @@ -19,6 +19,52 @@ const repositoryDirectory = path.resolve( const createManifest = () => createCompatibilityManifest(repositoryDirectory); +const historical4 = () => { + const manifest = createManifest(); + manifest.toolingCompatibility = { componentsCore: '>=4 <5', eslint: '>=4 <5', migrator: '>=4 <5' }; + delete manifest.supportWindows.components5; + manifest.supportWindows.components3.tooling = '>=4 <5'; + manifest.supportWindows.components4.status = 'current'; + manifest.supportWindows.components4.tooling.migrator = '>=4 <5'; + for (const entry of manifest.packages) { + entry.version = '4.12.0'; + entry.releaseMajorRange = '>=4 <5'; + if (entry.peerDependencies['@cratis/components']) entry.peerDependencies['@cratis/components'] = '>=4 <5'; + for (const name of ['@cratis/arc', '@cratis/arc.react']) { + if (entry.peerDependencies[name]) entry.peerDependencies[name] = '>=20.3.1 <23'; + } + } + return manifest; +}; + +for (const [line, create] of [[4, historical4], [5, createManifest]]) { + test(`Components ${line} accepts its reviewed peer contract`, () => { + const manifest = create(); + assert.equal(manifest.packages[0].releaseMajorRange, `>=${line} <${line + 1}`); + assert.equal(manifest.packages[0].peerDependencies['@cratis/arc'], line === 4 ? '>=20.3.1 <23' : '>=22.19.1 <23'); + assert.doesNotThrow(() => validateCompatibilityManifest(manifest, { rootDirectory: null })); + }); + test(`Components ${line} rejects the other line's Arc contract`, () => { + const manifest = create(); + manifest.packages[0].peerDependencies['@cratis/arc'] = line === 4 ? '>=22.19.1 <23' : '>=20.3.1 <23'; + assert.throws(() => validateCompatibilityManifest(manifest, { rootDirectory: null }), /peer contract/); + }); + test(`Components ${line} rejects mixed adapter/Core families`, () => { + const manifest = create(); + manifest.packages.find(({ role }) => role === 'renderer-adapter').peerDependencies['@cratis/components'] = line === 4 ? '>=5 <6' : '>=4 <5'; + assert.throws(() => validateCompatibilityManifest(manifest, { rootDirectory: null }), /matching Components peer family/); + }); +} + +test('5.0.0 is accepted without changing renderer ABI or profiles', () => { + const manifest = createManifest(); + for (const entry of manifest.packages) entry.version = '5.0.0'; + assert.equal(manifest.supportWindows.components5.rendererAbi, 1); + assert.equal(manifest.supportWindows.components5.coreProfile, 'core/v1'); + assert.equal(manifest.supportWindows.components5.adapterProfile, 'stable-presentation/v1'); + assert.doesNotThrow(() => validateCompatibilityManifest(manifest)); +}); + test('generated compatibility copies are deterministic and byte-identical', () => { const serialized = serializeCompatibilityManifest(createManifest()); for (const relativePath of [ @@ -36,7 +82,7 @@ test('generated compatibility copies are deterministic and byte-identical', () = test('all public package versions move together within the repository release', () => { const manifest = createManifest(); for (const packageEntry of manifest.packages) { - packageEntry.version = '4.99.0'; + packageEntry.version = '5.99.0'; } assert.doesNotThrow(() => validateCompatibilityManifest(manifest, { rootDirectory: repositoryDirectory }), @@ -45,7 +91,7 @@ test('all public package versions move together within the repository release', const adapter = manifest.packages.find( ({ name }) => name === '@cratis/components.mui', ); - adapter.version = '4.98.0'; + adapter.version = '5.98.0'; assert.throws( () => validateCompatibilityManifest(manifest, { @@ -73,14 +119,14 @@ test('a public package cannot opt out of repository-wide versioning', () => { test('the shared repository release stays inside the Components major range', () => { const manifest = createManifest(); for (const packageEntry of manifest.packages) { - packageEntry.version = '5.0.0'; + packageEntry.version = '6.0.0'; } assert.throws( () => validateCompatibilityManifest(manifest, { rootDirectory: repositoryDirectory, }), - /outside supported release range/, + /outside supported release families/, ); }); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 00000000..afb12a45 --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import semver from 'semver'; +import { createCompatibilityManifest, serializeCompatibilityManifest } from './generate-compat-manifest.mjs'; +import { releasePolicy } from '../Migrator/lib/releasePolicy.js'; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** Validate before any writes; prepare the entire family and bundled metadata before any publish. */ +export function prepareRelease(version, rootDirectory = repository) { + const manifest = createCompatibilityManifest(rootDirectory); + const currentVersion = manifest.packages[0].version; + if (semver.valid(version) !== version || semver.prerelease(version) || + releasePolicy(version).major !== releasePolicy(currentVersion).major) { + throw new Error(`Release ${version} must stay in the reviewed source family ${releasePolicy(currentVersion).range}.`); + } + const root = JSON.parse(fs.readFileSync(path.join(rootDirectory, 'package.json'), 'utf8')); + const paths = root.workspaces.flatMap((workspace) => workspace.endsWith('/*') + ? fs.readdirSync(path.join(rootDirectory, workspace.slice(0, -2)), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(workspace.slice(0, -2), entry.name, 'package.json')) + : [path.join(workspace, 'package.json')]); + const packages = paths.map((relative) => ({ + file: path.join(rootDirectory, relative), + value: JSON.parse(fs.readFileSync(path.join(rootDirectory, relative), 'utf8')), + })); + const names = new Set(packages.map(({ value }) => value.name)); + for (const { file, value } of packages) { + if (value.private === true) continue; + value.version = version; + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) { + for (const name of Object.keys(value[field] ?? {})) { + if (names.has(name)) value[field][name] = version; + } + } + // Peer family bounds are the reviewed compatibility contract, not exact release pins. + fs.writeFileSync(file, `${JSON.stringify(value, null, 4)}\n`); + } + const serialized = serializeCompatibilityManifest(createCompatibilityManifest(rootDirectory)); + for (const relative of ['compat-manifest.json', 'Source/compat-manifest.json', 'Migrator/compat-manifest.json']) { + fs.writeFileSync(path.join(rootDirectory, relative), serialized); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + prepareRelease(process.argv[2]); + console.log('Prepared all seven release packages and compatibility manifests.'); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs new file mode 100644 index 00000000..3900905e --- /dev/null +++ b/scripts/prepare-release.test.mjs @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import assert from 'node:assert/strict'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { prepareRelease } from './prepare-release.mjs'; +import { createCompatibilityManifest } from './generate-compat-manifest.mjs'; +import { validateBundledManifest } from '../Migrator/lib/compatibility.js'; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const output = path.join(repository, '.ai-work/manual/typed-auto-command-forms-20260922/release-fixtures'); +const files = ['package.json', 'Source/package.json', 'ESLint/package.json', 'Migrator/package.json', + 'Conformance/package.json', 'Adapters/Mui/package.json', 'Adapters/PrimeReact/package.json', + 'Adapters/PrimeReact10/package.json', 'Storybook/package.json', 'scripts/renderer-adapter-matrix.json']; + +function fixture(check) { + mkdirSync(output, { recursive: true }); + const directory = mkdtempSync(path.join(output, 'case-')); + try { + for (const relative of files) { + mkdirSync(path.dirname(path.join(directory, relative)), { recursive: true }); + copyFileSync(path.join(repository, relative), path.join(directory, relative)); + } + mkdirSync(path.join(directory, 'Conformance/for_plain_dom_renderer'), { recursive: true }); + check(directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test('release preparation rejects an untruthful Arc contract before changing any packages', () => fixture((directory) => { + const file = path.join(directory, 'Source/package.json'); + const core = JSON.parse(readFileSync(file, 'utf8')); + core.peerDependencies['@cratis/arc'] = '>=20.3.1 <23'; + writeFileSync(file, JSON.stringify(core)); + const before = files.map((relative) => readFileSync(path.join(directory, relative), 'utf8')); + assert.throws(() => prepareRelease('5.0.0', directory), /peer contract/); + assert.deepEqual(files.map((relative) => readFileSync(path.join(directory, relative), 'utf8')), before); +})); + +for (const version of ['5.0.0', '5.2.1']) { + test(`release preparation accepts ${version} and stamps all bundled metadata before publication`, () => fixture((directory) => { + prepareRelease(version, directory); + const manifest = createCompatibilityManifest(directory); + assert.ok(manifest.packages.every((entry) => entry.version === version)); + assert.ok(manifest.packages.filter((entry) => entry.peerDependencies['@cratis/components']) + .every((entry) => entry.peerDependencies['@cratis/components'] === '>=5 <6')); + for (const relative of ['compat-manifest.json', 'Source/compat-manifest.json', 'Migrator/compat-manifest.json']) { + const bundled = JSON.parse(readFileSync(path.join(directory, relative), 'utf8')); + assert.deepEqual(bundled, manifest); + validateBundledManifest(bundled, version); + } + })); +} + +for (const version of ['4.13.0', '6.0.0', '5.0.0-rc.1', '5.0.0+metadata', 'v5.0.0', 'invalid']) { + test(`release preparation rejects ${version} without changing package metadata`, () => fixture((directory) => { + const before = files.map((relative) => readFileSync(path.join(directory, relative), 'utf8')); + assert.throws(() => prepareRelease(version, directory), /reviewed source family|supported release families/); + assert.deepEqual(files.map((relative) => readFileSync(path.join(directory, relative), 'utf8')), before); + })); +} diff --git a/scripts/renderer-adapter-matrix.json b/scripts/renderer-adapter-matrix.json index 5fc97199..a0bdfbc1 100644 --- a/scripts/renderer-adapter-matrix.json +++ b/scripts/renderer-adapter-matrix.json @@ -5,8 +5,8 @@ "yarn": "4.5.3" }, "sharedPeers": { - "@cratis/arc": "22.16.0", - "@cratis/arc.react": "22.16.0", + "@cratis/arc": "22.19.1", + "@cratis/arc.react": "22.19.1", "@cratis/fundamentals": "7.19.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.2", diff --git a/scripts/run-task-on-workspaces.test.mjs b/scripts/run-task-on-workspaces.test.mjs index bf959ed6..ee13b840 100644 --- a/scripts/run-task-on-workspaces.test.mjs +++ b/scripts/run-task-on-workspaces.test.mjs @@ -12,11 +12,14 @@ const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '. const output = path.join(repository, '.ai-work/workspace-runner-tests'); mkdirSync(output, { recursive: true }); -const runFixture = async ({ command = 'ci', exitCode = 0, outputBytes = 0, missingExecutable = false, signal = undefined } = {}) => { +const runFixture = async ({ command = 'ci', exitCode = 0, outputBytes = 0, missingExecutable = false, signal = undefined, preparationFails = false } = {}) => { const directory = mkdtempSync(path.join(output, 'case-')); try { copyFileSync(path.join(repository, 'run-task-on-workspaces.js'), path.join(directory, 'run-task-on-workspaces.js')); writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ private: true, workspaces: ['first', 'second'] })); + mkdirSync(path.join(directory, 'scripts')); + // Release-policy integration is covered by prepare-release.test.mjs; this fixture tests orchestration only. + writeFileSync(path.join(directory, 'scripts/prepare-release.mjs'), `process.exitCode = ${preparationFails ? 1 : 0};`); for (const name of ['first', 'second']) { mkdirSync(path.join(directory, name)); writeFileSync(path.join(directory, name, 'package.json'), JSON.stringify({ name, version: '1.0.0', scripts: { ci: 'synthetic fixture' } })); @@ -36,7 +39,7 @@ const runFixture = async ({ command = 'ci', exitCode = 0, outputBytes = 0, missi for (const name of ['yarn', 'npm']) writeFileSync(path.join(binaries, name), executable, { mode: 0o755 }); } const result = await new Promise((resolve, reject) => { - const child = spawn(process.execPath, ['run-task-on-workspaces.js', command, ...(command === 'publish-version' ? ['4.7.0'] : [])], { + const child = spawn(process.execPath, ['run-task-on-workspaces.js', command, ...(command === 'publish-version' ? ['5.0.0'] : [])], { cwd: directory, env: { ...process.env, PATH: binaries }, stdio: ['ignore', 'pipe', 'pipe'], @@ -55,6 +58,14 @@ const runFixture = async ({ command = 'ci', exitCode = 0, outputBytes = 0, missi } }; +test('a failed release policy prevents every publication', async () => { + const result = await runFixture({ command: 'publish-version', preparationFails: true }); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /Nothing was published/); + assert.ok(!result.stdout.includes('first: stdout complete')); + assert.ok(!result.stdout.includes('second: stdout complete')); +}); + for (const command of ['ci', 'publish-version']) { test(`${command} streams output larger than the default spawnSync buffer without truncation`, { timeout: 20000 }, async () => { const bytes = 2 * 1024 * 1024; diff --git a/yarn.lock b/yarn.lock index 50aae7e5..125aea4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -273,16 +273,16 @@ __metadata: languageName: node linkType: hard -"@cratis/arc.react@npm:22.16.0": - version: 22.16.0 - resolution: "@cratis/arc.react@npm:22.16.0" +"@cratis/arc.react@npm:22.19.1": + version: 22.19.1 + resolution: "@cratis/arc.react@npm:22.19.1" dependencies: - "@cratis/arc": "npm:22.16.0" - "@cratis/fundamentals": "npm:^7.19.2" + "@cratis/arc": "npm:22.19.1" + "@cratis/fundamentals": "npm:^7.19.3" tsyringe: "npm:^4.10.0" peerDependencies: react: ^18.0.0 || ^19.0.0 - checksum: 10/dee24b72d3eba83500cdd801d6f577b512ef9a6aeb5ca01387fb3ae65f0a7ac5c48995d62d5f314f4e653cc58f279426647bf019c9821d0871a025cfd75e93a5 + checksum: 10/b63264aa5c402bbf9e9b75ea27e5ab0d27058076d2b03dd4a033c155433a51f8bf1375d18f17770c319eebb7a39c2750cf0256a1bbafd351d17d0ca831caaeec languageName: node linkType: hard @@ -308,12 +308,22 @@ __metadata: languageName: node linkType: hard +"@cratis/arc@npm:22.19.1": + version: 22.19.1 + resolution: "@cratis/arc@npm:22.19.1" + dependencies: + "@cratis/fundamentals": "npm:^7.19.3" + rxjs: "npm:^7.8.2" + checksum: 10/75f0d1b29e1d2be24ab6a733c1b1d594fca0b8f72a5d478dc3f38696dd8d3fcd6aadd6e76070615d8f85f4c54a50e565c6b36fd10ae7af0026e43d16a0146572 + languageName: node + linkType: hard + "@cratis/components.conformance@workspace:Conformance, @cratis/components.conformance@workspace:^": version: 0.0.0-use.local resolution: "@cratis/components.conformance@workspace:Conformance" dependencies: - "@cratis/arc": "npm:22.16.0" - "@cratis/arc.react": "npm:22.16.0" + "@cratis/arc": "npm:22.19.1" + "@cratis/arc.react": "npm:22.19.1" "@cratis/components": "workspace:^" "@cratis/fundamentals": "npm:7.19.2" axe-core: "npm:^4.13.0" @@ -322,7 +332,7 @@ __metadata: reflect-metadata: "npm:0.2.2" tsyringe: "npm:4.10.0" peerDependencies: - "@cratis/components": ">=4 <5" + "@cratis/components": ">=5 <6" react: ^19.0.0 react-dom: ^19.0.0 languageName: unknown @@ -345,8 +355,8 @@ __metadata: version: 0.0.0-use.local resolution: "@cratis/components.mui@workspace:Adapters/Mui" dependencies: - "@cratis/arc": "npm:22.16.0" - "@cratis/arc.react": "npm:22.16.0" + "@cratis/arc": "npm:22.19.1" + "@cratis/arc.react": "npm:22.19.1" "@cratis/components": "workspace:^" "@cratis/components.conformance": "workspace:^" "@cratis/fundamentals": "npm:7.19.2" @@ -359,7 +369,7 @@ __metadata: reflect-metadata: "npm:0.2.2" tsyringe: "npm:4.10.0" peerDependencies: - "@cratis/components": ">=4 <5" + "@cratis/components": ">=5 <6" "@emotion/react": ">=11.5 <12" "@emotion/styled": ">=11.11 <12" "@mui/material": ">=9 <10" @@ -372,8 +382,8 @@ __metadata: version: 0.0.0-use.local resolution: "@cratis/components.primereact10@workspace:Adapters/PrimeReact10" dependencies: - "@cratis/arc": "npm:22.16.0" - "@cratis/arc.react": "npm:22.16.0" + "@cratis/arc": "npm:22.19.1" + "@cratis/arc.react": "npm:22.19.1" "@cratis/components": "workspace:^" "@cratis/components.conformance": "workspace:^" "@cratis/fundamentals": "npm:7.19.2" @@ -384,7 +394,7 @@ __metadata: reflect-metadata: "npm:0.2.2" tsyringe: "npm:4.10.0" peerDependencies: - "@cratis/components": ">=4 <5" + "@cratis/components": ">=5 <6" primereact: ">=10.9.9 <11" react: ^19.0.0 react-dom: ^19.0.0 @@ -395,8 +405,8 @@ __metadata: version: 0.0.0-use.local resolution: "@cratis/components.primereact@workspace:Adapters/PrimeReact" dependencies: - "@cratis/arc": "npm:22.16.0" - "@cratis/arc.react": "npm:22.16.0" + "@cratis/arc": "npm:22.19.1" + "@cratis/arc.react": "npm:22.19.1" "@cratis/components": "workspace:^" "@cratis/components.conformance": "workspace:^" "@cratis/fundamentals": "npm:7.19.2" @@ -410,7 +420,7 @@ __metadata: reflect-metadata: "npm:0.2.2" tsyringe: "npm:4.10.0" peerDependencies: - "@cratis/components": ">=4 <5" + "@cratis/components": ">=5 <6" "@primereact/core": ">=11 <12" "@primereact/ui": ">=11 <12" "@primeuix/themes": ">=3 <4" @@ -446,8 +456,8 @@ __metadata: react-icons: "npm:5.7.0" ts-deepmerge: "npm:8.0.0" peerDependencies: - "@cratis/arc": ">=20.3.1 <23" - "@cratis/arc.react": ">=20.3.1 <23" + "@cratis/arc": ">=22.19.1 <23" + "@cratis/arc.react": ">=22.19.1 <23" "@cratis/fundamentals": ^7.10.3 pixi.js: ^8.20.0 react: ^19.0.0 @@ -475,6 +485,13 @@ __metadata: languageName: node linkType: hard +"@cratis/fundamentals@npm:^7.19.3": + version: 7.19.3 + resolution: "@cratis/fundamentals@npm:7.19.3" + checksum: 10/417769b0db8c88ca633b98d1264e96529e55111c889cf970be8fa37e8465946b9ec1fb3be6712f6315fba8a2c64f6679d7c8238b696360715ca1c85fdfbd5321 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -6898,8 +6915,8 @@ __metadata: version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." dependencies: - "@cratis/arc": "npm:22.16.0" - "@cratis/arc.react": "npm:22.16.0" + "@cratis/arc": "npm:22.19.1" + "@cratis/arc.react": "npm:22.19.1" "@cratis/arc.vite": "npm:22.16.0" "@cyclonedx/yarn-plugin-cyclonedx": "npm:3.4.0" "@eslint-react/eslint-plugin": "npm:5.19.0" From 3243bc49b7be36b5996500b5dfc78ca867f1e96a Mon Sep 17 00:00:00 2001 From: "Cratis Stagehand (AI)" Date: Wed, 23 Sep 2026 04:36:24 +0000 Subject: [PATCH 2/3] Fix duplicate H1 heading in MIGRATION.md The Components 4-to-5 rewrite nested the entire prior 3-to-4 guide under a new "Historical migration" section but kept its original heading levels, giving the document two top-level (H1) headings and tripping markdownlint's MD025/single-title rule. Demote every heading in the historical section by one level so there is a single H1. --- Source/MIGRATION.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/Source/MIGRATION.md b/Source/MIGRATION.md index 16abdbb6..0c146a6b 100644 --- a/Source/MIGRATION.md +++ b/Source/MIGRATION.md @@ -22,7 +22,7 @@ and supports Components 3 source and Components 4/5 recovery targets. Migrator 4 its original Components 3/4 boundary. Follow the historical staged migration below when starting from Components 3. -# Historical migration: Components 3 to 4 +## Historical migration: Components 3 to 4 Components 4 replaces the PrimeReact-backed Components 3 foundation with Components-owned markup, styling contracts, and public types. React Aria supplies selected interaction primitives internally. The current Components 4 manifest does not declare PrimeReact, PrimeIcons, PrimeUI, or PrimeUI themes as dependencies or peers; applications retaining direct imports keep their own package and license boundaries. @@ -34,7 +34,7 @@ An application that has not migrated remains on its Components 3 package profile The adapters do not replace complete Components widgets. Core continues to own Dialog, Dropdown, DatePicker, paginator, table, focus, overlay, selection, and keyboard behavior; an adapter only presents button, icon-button, text-input, text-area, checkbox, radio, switch, progress, and surface slots. Installing an adapter neither restores PrimeReact public APIs nor transfers key handling to the adapter. -## Recommended order, stop points, and rollback +### Recommended order, stop points, and rollback 1. Preserve the current source, package manifest, and lockfile as the rollback point, then run the existing Components 3 gates. 2. With installed Core still in the Components 3 **source** window (`>=3 <4`), preview and apply the root-namespace transform. Its subpath output works on Components 3 and 4. Re-run the Components 3 gates and checkpoint that import-only change. @@ -43,7 +43,7 @@ The adapters do not replace complete Components widgets. Core continues to own D The bounded Components 4 Migrator package accepts the supported source or target window at preflight, but the order above avoids introducing Components 4-only props before Core is upgraded. A failed compatibility preflight scans and writes nothing. A transform refusal may annotate or migrate other independently safe syntax, so inspect the diff; restore the preceding checkpoint before retrying if an all-or-nothing rollback is required. -## Update dependencies +### Update dependencies Use the commands for the application's package manager. Remove only Prime packages that were installed for Components and are no longer owned by a retained direct Prime island. @@ -73,7 +73,7 @@ The package declares an Arc peer range of `>=20.3.1 <23`. Conformance and all th adapters declare the final `@cratis/components >=4 <5` peer range, so each remains bounded to the Components major whose renderer ABI and stable presentation profile it implements. -## Import from explicit subpaths +### Import from explicit subpaths The canonical rule going forward: **the package root is setup-only; every component ships from its own subpath.** @@ -158,7 +158,7 @@ Quote real paths containing spaces. Do not type angle-bracket placeholders in a The codemod scans JavaScript/JSX and TypeScript/TSX (including `.mjs`, `.cjs`, `.mts`, and `.cts`), preserves aliases and type-only imports, splits mixed setup/namespace imports, and rewrites a named `export { X } from '@cratis/components'` re-export the same way as the matching import. It reports unsupported cases without guessing: default or whole-package namespace imports, TypeScript `import = require(...)` assignments, dynamic imports, CommonJS `require(...)`, wildcard or whole-package re-exports (`export * from '@cratis/components'` / `export * as X from '@cratis/components'`), side-effect imports, and unknown symbols. Review its diagnostics, then run the consuming project's lint, build, and tests. -### Migrate Button appearance and change callbacks +#### Migrate Button appearance and change callbacks Run the root-import codemod above first. The Button and callback codemods resolve Components-owned identifiers from explicit subpaths, so the authoritative order is: @@ -234,7 +234,7 @@ npm install --save-dev "@cratis/eslint-plugin-components@$TOOLING_RANGE" See the `@cratis/eslint-plugin-components` README included with that package for the flat-config example and the other Components consumer rules. -## Keep the stylesheet entry points +### Keep the stylesheet entry points The three Cratis-owned stylesheet entries remain: @@ -250,7 +250,7 @@ import '@cratis/components/theme'; // optional baseline appearance A custom product design can omit `theme`, define the `--cratis-*` variables itself, and style stable component parts through classes or `pt`. -## Simplify the provider +### Simplify the provider The provider now owns locale and Components-specific labels. Unknown renderer keys are a type error so a migrated app cannot silently lose its theme, license, global pass-through, ripple, or z-index behavior. Remove those keys from `CratisComponentsProvider` and configure any remaining direct Prime provider independently. @@ -290,7 +290,7 @@ export const ApplicationRoot = ({ children }: { children: React.ReactNode }) => `locales` remains temporarily accepted and maps the old paginator/date labels, but new code should use `messages`. Renderer keys such as `license`, `theme`, `defaults`, `pt`, `ripple`, `unstyled`, and z-index settings are not part of this provider. -## Replace renderer presets with tokens +### Replace renderer presets with tokens Remove `styledMode()`, `CratisPreset`, and `primeReactStyles` before upgrading. Components 4 removes three renderer-specific subpaths: @@ -302,7 +302,7 @@ Remove `styledMode()`, `CratisPreset`, and `primeReactStyles` before upgrading. There is no compatibility-package replacement in Components 4. Stay on Components 3 while renderer-specific types or selectors remain. -### Removed symbol mapping +#### Removed symbol mapping | Removed Components 3 export | Components 4 action | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | @@ -347,7 +347,7 @@ Map product tokens directly in CSS: This removes the old product-token → Prime preset → Prime variable → Cratis variable translation chain. -## Removed accidental package exports +### Removed accidental package exports An audit of the package `exports` map ([#173](https://github.com/Cratis/Components/issues/173)) found implementation-only symbols that were unintentionally reachable from a public subpath — each was exported only because the owning module's barrel used a blanket `export *`, not because it was a supported contract. Components 4 stops re-exporting them from their public barrel; the underlying files keep the symbol for their own internal cross-file use, so this is a package-export change only, not a behavior change. @@ -366,7 +366,7 @@ None of these had a documented contract, and none is required by any other publi The surfaces this audit confirmed as intentional and kept public — `ToastRecord`, `getToastSnapshot`, `subscribeToToasts`, `ToastDispatch`, `EmojiMemory`, `ChatAuthorKind`, `DEFAULT_TYPE_FORMATS`, `NavigationItem`, `Json`, and `TimeMachine`'s `Properties` — are unchanged and now carry TSDoc explaining their contract and, where relevant, their extension-point role. -## Migrate pass-through configuration +### Migrate pass-through configuration The `pt` prop remains the per-part customization surface, but its values are now ordinary HTML attributes and its keys are stable Cratis names. `ptOptions` and `unstyled` remain accepted temporarily but have no effect: part attributes always merge, and Components always uses consumer-owned CSS. @@ -389,7 +389,7 @@ The `pt` prop remains the per-part customization surface, but its values are now Every meaningful element also carries `data-cratis-part`. Interactive states use attributes such as `data-selected`, `data-invalid`, `data-disabled`, `data-active`, and `data-position`. Do not target React Aria class names or internal DOM structure. -### Common part mappings +#### Common part mappings | Components 3 renderer slot | Components 4 Cratis part | | ------------------------------ | ---------------------------------------------- | @@ -408,7 +408,7 @@ Every meaningful element also carries `data-cratis-part`. Interactive states use See the published [Stable component parts](https://cratis.io/components/styling/pass-through/) reference for the documented foundation surfaces. -### Migrate a deeply customized product +#### Migrate a deeply customized product Keep the product's own tokens, Tailwind utilities, dark/high-contrast selectors, and accessibility preferences. Remove the renderer preset that translated those values into a third-party token system, then map the product values directly onto `--cratis-*`. @@ -453,7 +453,7 @@ For an existing nested Prime stepper preset, map the slots by rendered responsib The old `stepperpanel.header` wrapper has no one-to-one element. Put list-item layout on `step`, and interactive-header styling on `header`. Replace `data-p-active` selectors with `[data-cratis-part='step'][data-active='true']`. -### Representative migration archetypes +#### Representative migration archetypes For a product-owned design system, remove `styledMode`, `ProductPreset`, Prime locale types, and the PrimeUI license from the Components provider. Keep `--product-*` as the canonical tokens and map them directly to `--cratis-*`. If the product still imports Prime directly, retain a separate Prime provider, preset, dependencies, and license until those imports are removed. PrimeReact 11 receives its license directly — `` — not through the `value={{ license }}` shape used by `CratisComponentsProvider`. The provider scopes runtime context, not CSS: a Prime theme imported from JavaScript remains document-global, so keep it in the smallest host entry point that owns the island and track every retained island's owner, licensing/theme dependencies, and removal condition. Migrate custom filters from `registerMatcher` to `registerDataTableFilterMatcher`, use the returned `matchMode` in each constraint, and use `resolveDataTableFilterMatcher` when a test or application-owned adapter must verify the live registered predicate. @@ -496,7 +496,7 @@ Pass the parts to either query-backed table: `Dropdown.inputId` and `Dropdown.panelClassName` remain migration aliases for `id` and `pt.popover.className`, but new code should use the current names. -## Update DatePicker integration +### Update DatePicker integration `DatePickerInput` still accepts and emits `Date | null`, but its internal value uses `@internationalized/date`. Formatting now follows the active locale and calendar rather than a PrimeReact mask. @@ -507,7 +507,7 @@ Pass the parts to either query-backed table: - `todayLabel` and `clearLabel` override the provider messages for one picker. - `showTime` and `hourFormat` remain in the current API. -## Update Dropdown styling and semantics +### Update Dropdown styling and semantics `Dropdown` preserves the `value`, `options`, `optionLabel`, `optionValue`, filtering, clear, and change-event model. Single selects now follow the WAI-ARIA button/listbox pattern; filtered selects use a combobox. @@ -515,11 +515,11 @@ Do not assume every Dropdown trigger has `role="combobox"`. Query it by its acce Multiple selection uses a native multiple-select when filtering is off and an accessible multi-value combobox when `filter` is enabled. Prefer a dedicated collection picker for a large or highly customized multi-select experience. -## Update Tooltip triggers +### Update Tooltip triggers `Tooltip` now enhances one actual React-element trigger so focus, hover, and `aria-describedby` stay together. Wrap text, fragments, multiple siblings, or conditional content in one appropriate native control. `className` is merged onto that trigger instead of an extra wrapper. -## Update tables +### Update tables `DataTableCore` now renders semantic HTML. Query-backed paging remains owned by Arc. @@ -535,13 +535,13 @@ Common built-in Prime match-mode string values remain compatible because Compone Separate `RadioButtonField` options bound to one property now require the same explicit `name` prop so native arrow-key radio-group navigation works. `RadioGroupField` and `RatingField` generate a shared internal name automatically. -## Update dialogs and steppers +### Update dialogs and steppers Dialog callback, busy, validity, dismissal, and initial-focus contracts remain. The modal/focus implementation is now React Aria-based. Stepper parts are Cratis-owned: `root`, `list`, `step`, `header`, `number`, `title`, `separator`, `panels`, and `panel`. Custom CSS that targeted Prime stepper classes or roles must move to those parts. -## Update notifications +### Update notifications The imperative API remains: @@ -556,7 +556,7 @@ toast.success({ The queue, promise lifecycle, dispatch substitution, timeout pause, focus behavior, frames, and region are Cratis-owned. Toast part keys are `region`, `toast`, `icon`, `content`, `title`, `description`, `action`, and `close`. -## Replace direct Prime imports +### Replace direct Prime imports Components cannot remove PrimeUI licensing from an application that still imports Prime directly. Replace those imports with Components, native HTML, or application-owned primitives. @@ -574,7 +574,7 @@ Typical replacements: Complete PrimeIcons class strings remain usable where a component accepts `Icon`, but Components no longer installs the font or adds a missing base class. Consumers that retain it must load its stylesheet and pass the complete class string. Prefer a React icon component or product-owned SVG. `DataPage.MenuItem.icon` remains a React component type rather than `Icon`. -## Verify the migration +### Verify the migration 1. Remove unused Prime dependencies and the PrimeUI license/provider configuration. 2. Import `tokens` and `styles`; choose the baseline `theme` or map product tokens. From 8ec302e646ec0e12ca6d3aed3db57b454164734d Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 23 Sep 2026 09:42:24 +0200 Subject: [PATCH 3/3] Keep automatic form footer in Components 4 --- .yarnrc.yml | 6 +- ...hen_using_the_mui_presentation_profile.tsx | 2 +- Adapters/Mui/package.json | 8 +- .../Mui/scripts/verify-packed-package.mjs | 2 +- ...ng_the_primereact_presentation_profile.tsx | 2 +- Adapters/PrimeReact/package.json | 8 +- .../scripts/verify-packed-package.mjs | 2 +- ..._the_primereact10_presentation_profile.tsx | 2 +- Adapters/PrimeReact10/package.json | 8 +- .../scripts/verify-packed-package.mjs | 2 +- Conformance/package.json | 8 +- .../CommandForm/auto-command-form.md | 9 +- Documentation/CommandForm/guid-field.md | 55 ----- Documentation/CommandForm/index.md | 6 +- Documentation/CommandForm/toc.yml | 2 - Documentation/Migration/4-to-5.md | 27 --- Documentation/Migration/index.md | 1 - Documentation/Migration/toc.yml | 2 - Documentation/getting-started.mdx | 20 +- ESLint/package.json | 2 +- Migrator/README.md | 12 +- Migrator/compat-manifest.json | 94 +++----- Migrator/lib/compatibility.js | 33 ++- Migrator/lib/releasePolicy.js | 82 ------- Migrator/package.json | 2 +- Migrator/test/compatibility.test.js | 51 +--- Migrator/test/verify-packed-package.mjs | 12 +- README.md | 10 +- Source/CommandForm/AutoCommandForm.tsx | 10 +- .../CommandForm/defaultFieldTypeProviders.ts | 6 - Source/CommandForm/fields/GuidField.tsx | 122 ---------- Source/CommandForm/fields/index.ts | 1 - .../given/GuidFieldTypes.tsx | 21 -- .../given/SampleGuidCommand.ts | 27 --- .../for_AutoCommandForm/given/a_guid_form.tsx | 112 --------- .../when_composing_a_footer.tsx | 75 +++--- .../when_editing_guid_fields.tsx | 218 ------------------ ...xecution_must_veto_invalid_guid_drafts.tsx | 92 -------- .../when_resolving_the_default_providers.ts | 10 - Source/MIGRATION.md | 68 ++---- Source/README.md | 28 +-- Source/compat-manifest.json | 94 +++----- Source/package.json | 6 +- Source/scripts/generate-parts.mjs | 8 - Source/scripts/verify-parts-manifest.mjs | 6 +- .../verify-public-types.exceptions.json | 6 +- Source/types/parts.ts | 4 - compat-manifest.json | 94 +++----- package.json | 6 +- release.md | 20 +- run-task-on-workspaces.js | 33 ++- scripts/generate-compat-manifest.mjs | 108 +++++++-- scripts/generate-compat-manifest.test.mjs | 54 +---- scripts/prepare-release.mjs | 57 ----- scripts/prepare-release.test.mjs | 65 ------ scripts/renderer-adapter-matrix.json | 4 +- scripts/run-task-on-workspaces.test.mjs | 15 +- yarn.lock | 61 ++--- 58 files changed, 419 insertions(+), 1482 deletions(-) delete mode 100644 Documentation/CommandForm/guid-field.md delete mode 100644 Documentation/Migration/4-to-5.md delete mode 100644 Migrator/lib/releasePolicy.js delete mode 100644 Source/CommandForm/fields/GuidField.tsx delete mode 100644 Source/CommandForm/for_AutoCommandForm/given/GuidFieldTypes.tsx delete mode 100644 Source/CommandForm/for_AutoCommandForm/given/SampleGuidCommand.ts delete mode 100644 Source/CommandForm/for_AutoCommandForm/given/a_guid_form.tsx delete mode 100644 Source/CommandForm/for_AutoCommandForm/when_editing_guid_fields.tsx delete mode 100644 Source/CommandForm/for_AutoCommandForm/when_native_execution_must_veto_invalid_guid_drafts.tsx delete mode 100644 scripts/prepare-release.mjs delete mode 100644 scripts/prepare-release.test.mjs diff --git a/.yarnrc.yml b/.yarnrc.yml index af16414a..ef43436a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,10 +1,10 @@ nodeLinker: node-modules -# Exact Cratis releases reviewed for generated fields and native custom-error execution guards. +# Exact Cratis releases reviewed for the generated-field binding fix. # Keep Yarn's default release-age guard for every other package/version. npmPreapprovedPackages: - - '@cratis/arc@22.19.1' - - '@cratis/arc.react@22.19.1' + - '@cratis/arc@22.16.0' + - '@cratis/arc.react@22.16.0' - '@cratis/arc.vite@22.16.0' packageExtensions: diff --git a/Adapters/Mui/for_adapter/when_using_the_mui_presentation_profile.tsx b/Adapters/Mui/for_adapter/when_using_the_mui_presentation_profile.tsx index 4e6e4dea..b486e199 100644 --- a/Adapters/Mui/for_adapter/when_using_the_mui_presentation_profile.tsx +++ b/Adapters/Mui/for_adapter/when_using_the_mui_presentation_profile.tsx @@ -218,7 +218,7 @@ describe('when using the MUI stable presentation profile', () => { '@emotion/styled': '>=11.11 <12', react: '^19.0.0', 'react-dom': '^19.0.0', - '@cratis/components': '>=5 <6', + '@cratis/components': '>=4 <5', }); const serialized = JSON.stringify(packageJson); expect(serialized).not.to.contain('@mui/x-'); diff --git a/Adapters/Mui/package.json b/Adapters/Mui/package.json index 6949f53c..953f53ad 100644 --- a/Adapters/Mui/package.json +++ b/Adapters/Mui/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.mui", - "version": "5.0.0", + "version": "4.0.0", "description": "Material UI presentation adapter for Cratis Components renderer ABI v1.", "author": "Cratis", "license": "MIT", @@ -98,7 +98,7 @@ "ci": "yarn clean && yarn lint && yarn build && yarn typecheck-specs && yarn test && yarn verify-package" }, "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "@emotion/react": ">=11.5 <12", "@emotion/styled": ">=11.11 <12", "@mui/material": ">=9 <10", @@ -106,8 +106,8 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/components": "workspace:^", "@cratis/components.conformance": "workspace:^", "@cratis/fundamentals": "7.19.2", diff --git a/Adapters/Mui/scripts/verify-packed-package.mjs b/Adapters/Mui/scripts/verify-packed-package.mjs index d340717b..210cf3d5 100644 --- a/Adapters/Mui/scripts/verify-packed-package.mjs +++ b/Adapters/Mui/scripts/verify-packed-package.mjs @@ -112,7 +112,7 @@ try { ); const expectedPeers = { - '@cratis/components': '>=5 <6', + '@cratis/components': '>=4 <5', '@emotion/react': '>=11.5 <12', '@emotion/styled': '>=11.11 <12', '@mui/material': '>=9 <10', diff --git a/Adapters/PrimeReact/for_adapter/when_using_the_primereact_presentation_profile.tsx b/Adapters/PrimeReact/for_adapter/when_using_the_primereact_presentation_profile.tsx index 5d875e3d..7dbddff2 100644 --- a/Adapters/PrimeReact/for_adapter/when_using_the_primereact_presentation_profile.tsx +++ b/Adapters/PrimeReact/for_adapter/when_using_the_primereact_presentation_profile.tsx @@ -266,7 +266,7 @@ describe('when using the PrimeReact stable presentation profile', () => { it('should declare only bounded peer-hosted PrimeReact 11 vendors', () => { expect(packageJson).not.to.have.property('dependencies'); expect(packageJson.peerDependencies).to.deep.equal({ - '@cratis/components': '>=5 <6', + '@cratis/components': '>=4 <5', '@primereact/core': '>=11 <12', '@primereact/ui': '>=11 <12', '@primeuix/themes': '>=3 <4', diff --git a/Adapters/PrimeReact/package.json b/Adapters/PrimeReact/package.json index 4478cf4a..7aaff1dd 100644 --- a/Adapters/PrimeReact/package.json +++ b/Adapters/PrimeReact/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.primereact", - "version": "5.0.0", + "version": "4.0.0", "description": "PrimeReact 11 presentation adapter for Cratis Components renderer ABI v1.", "author": "Cratis", "license": "MIT", @@ -101,7 +101,7 @@ "ci": "yarn clean && yarn lint && yarn build && yarn typecheck-specs && yarn test && yarn verify-package" }, "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "@primereact/core": ">=11 <12", "@primereact/ui": ">=11 <12", "@primeuix/themes": ">=3 <4", @@ -110,8 +110,8 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/components": "workspace:^", "@cratis/components.conformance": "workspace:^", "@cratis/fundamentals": "7.19.2", diff --git a/Adapters/PrimeReact/scripts/verify-packed-package.mjs b/Adapters/PrimeReact/scripts/verify-packed-package.mjs index 3d5567ca..8c62b326 100644 --- a/Adapters/PrimeReact/scripts/verify-packed-package.mjs +++ b/Adapters/PrimeReact/scripts/verify-packed-package.mjs @@ -112,7 +112,7 @@ try { ); const expectedPeers = { - '@cratis/components': '>=5 <6', + '@cratis/components': '>=4 <5', '@primereact/core': '>=11 <12', '@primereact/ui': '>=11 <12', '@primeuix/themes': '>=3 <4', diff --git a/Adapters/PrimeReact10/for_adapter/when_using_the_primereact10_presentation_profile.tsx b/Adapters/PrimeReact10/for_adapter/when_using_the_primereact10_presentation_profile.tsx index b5bb8c7e..dd6d3b60 100644 --- a/Adapters/PrimeReact10/for_adapter/when_using_the_primereact10_presentation_profile.tsx +++ b/Adapters/PrimeReact10/for_adapter/when_using_the_primereact10_presentation_profile.tsx @@ -257,7 +257,7 @@ describe('when using the PrimeReact 10 stable presentation profile', () => { it('should declare only bounded peer-hosted PrimeReact 10 vendors', () => { expect(packageJson).not.to.have.property('dependencies'); expect(packageJson.peerDependencies).to.deep.equal({ - '@cratis/components': '>=5 <6', + '@cratis/components': '>=4 <5', primereact: '>=10.9.9 <11', react: '^19.0.0', 'react-dom': '^19.0.0', diff --git a/Adapters/PrimeReact10/package.json b/Adapters/PrimeReact10/package.json index fad133dd..0d73e7af 100644 --- a/Adapters/PrimeReact10/package.json +++ b/Adapters/PrimeReact10/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.primereact10", - "version": "5.0.0", + "version": "4.0.0", "description": "PrimeReact 10 presentation adapter for Cratis Components renderer ABI v1.", "author": "Cratis", "license": "MIT", @@ -98,14 +98,14 @@ "ci": "yarn clean && yarn lint && yarn build && yarn verify-upstream-shim && yarn typecheck-specs && yarn test && yarn verify-isolation && yarn verify-package" }, "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "primereact": ">=10.9.9 <11", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/components": "workspace:^", "@cratis/components.conformance": "workspace:^", "@cratis/fundamentals": "7.19.2", diff --git a/Adapters/PrimeReact10/scripts/verify-packed-package.mjs b/Adapters/PrimeReact10/scripts/verify-packed-package.mjs index 3f55ac78..771ab34b 100644 --- a/Adapters/PrimeReact10/scripts/verify-packed-package.mjs +++ b/Adapters/PrimeReact10/scripts/verify-packed-package.mjs @@ -112,7 +112,7 @@ try { ); const expectedPeers = { - '@cratis/components': '>=5 <6', + '@cratis/components': '>=4 <5', primereact: '>=10.9.9 <11', react: '^19.0.0', 'react-dom': '^19.0.0', diff --git a/Conformance/package.json b/Conformance/package.json index 6c0cb87a..8a5131e7 100644 --- a/Conformance/package.json +++ b/Conformance/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.conformance", - "version": "5.0.0", + "version": "4.0.0", "description": "Programmatic conformance evidence for Cratis Components renderer adapter authors.", "author": "Cratis", "license": "MIT", @@ -47,13 +47,13 @@ "axe-core": "^4.13.0" }, "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/components": "workspace:^", "@cratis/fundamentals": "7.19.2", "react": "^19.3.0", diff --git a/Documentation/CommandForm/auto-command-form.md b/Documentation/CommandForm/auto-command-form.md index aac94e77..301e5ce4 100644 --- a/Documentation/CommandForm/auto-command-form.md +++ b/Documentation/CommandForm/auto-command-form.md @@ -1,10 +1,10 @@ # AutoCommandForm -`AutoCommandForm` generates its field list from the command's own properties instead of you writing one field per property by hand. Each property's type picks its field component through a registry - `string` gets `InputTextField`, `number` gets `NumberField`, `boolean` gets `CheckboxField`, `Date` gets `CalendarField`, scalar `Guid` gets [GuidField](guid-field.md) - the same components you would otherwise use directly. +`AutoCommandForm` generates its field list from the command's own properties instead of you writing one field per property by hand. Each property's type picks its field component through a registry - `string` gets `InputTextField`, `number` gets `NumberField`, `boolean` gets `CheckboxField`, `Date` gets `CalendarField` - the same components you would otherwise use directly. ## Requirements -Use matching `@cratis/arc` and `@cratis/arc.react` versions in the supported range `>=22.19.1 <23`. Generated fields rely on explicit property-name binding, and Guid validation relies on Arc's native custom-field-error execution guard and live error lookup. Earlier versions can misbind generated fields or allow invalid optional Guid drafts to submit. +Use `@cratis/arc` and `@cratis/arc.react` version 22.16.0 or later within the supported 22.x range. Each generated field declares its property name explicitly because its accessor reads a descriptor dynamically. Older Arc versions overwrite that binding, which can send a date or number edit to another property. ## Usage @@ -50,7 +50,7 @@ or DOM-prop forwarding. Authorization and validation behavior remain Arc's respo ## Registering a field type provider -The built-in providers cover `string`, `number`, `boolean`, `Date` and scalar Fundamentals `Guid`. Guid arrays are not handled. Register your own for any other property type - a Cratis concept, an enum, a custom value object - with `registerFieldTypeProvider`: +The built-in providers cover `string`, `number`, `boolean` and `Date`. Register your own for any other property type - a Cratis concept, an enum, a custom value object - with `registerFieldTypeProvider`: ```tsx import { registerFieldTypeProvider } from '@cratis/components/CommandForm'; @@ -68,7 +68,6 @@ Register once, at module load, before any `AutoCommandForm` renders. Providers a ## Behavior - Field titles are generated from the property name by splitting on capitals and uppercasing the first letter (`dueDate` becomes "Due Date"); there is no way to override an individual generated field's title other than excluding it and writing that one field by hand. -- Arc derives `required` from the property descriptor's `isOptional` when no explicit field override is present. An optional empty Guid has no format error; an empty required Guid is invalid. -- Guid edits use `Guid.isGuid` and `Guid.parse`, never generate an identifier, and impose no version or nonzero restriction. Invalid drafts clear the bound value immediately instead of retaining an older identifier. A changed `currentValues` Guid overlay also resets its draft; see [Guid reset behavior](guid-field.md#clearing-and-resetting). +- `required` is **not** derived from the property descriptor's `isOptional` — every generated field is rendered without it. Write the field by hand when you need `required` on it. - Each generated field binds to its descriptor's property name; editing one property leaves the other properties unchanged. - Every generated field participates in `CommandForm`'s validation, change tracking and initial-value population exactly as a hand-written field does - `AutoCommandForm` only decides *which* fields to render, not how they behave once rendered. diff --git a/Documentation/CommandForm/guid-field.md b/Documentation/CommandForm/guid-field.md deleted file mode 100644 index 41815847..00000000 --- a/Documentation/CommandForm/guid-field.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: GuidField -description: Edit a scalar Fundamentals Guid with native Arc binding and accessible format validation. ---- - -`GuidField` is a native text input bound to a scalar `Guid`, `Guid | undefined`, or `Guid | null` property. [AutoCommandForm](auto-command-form.md) selects it for descriptors whose type is Fundamentals `Guid`, not arrays. - -## Requirements - -Use matching `@cratis/arc` and `@cratis/arc.react` versions in the supported range `>=22.19.1 <23`. This minimum includes Arc's custom-field-error execution guard and live error lookup: invalid drafts block native submission, context execution and `formRef.execute()`, while unmounting a field clears only its own current error. - -## Usage - -```tsx -import { GuidField } from '@cratis/components/CommandForm'; -import { SampleCommand } from './SampleCommand'; - - - value={(command) => command.sampleId} - title='Sample identifier' - placeholder='Enter a Guid' -/> -``` - -Place the field inside Arc's `CommandForm` or a Components command dialog. `SampleCommand.sampleId` must be a native Fundamentals `Guid` property, not a string. - -## Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `value` | `(command: TCommand) => Guid \| undefined \| null` | Required | Scalar property accessor. | -| `fieldName` | `string` | Inferred | Explicit binding for a dynamic accessor. | -| `required` | `boolean` | Descriptor's `!isOptional` | An empty required field has a required-value error. | -| `formatErrorMessage` | `string` | `Enter a valid Guid.` | Error for a nonempty invalid draft. | -| `requiredErrorMessage` | `string` | `A value is required.` | Error for an empty required draft. | -| `resetKey` | `unknown` | — | Change to discard a draft when the command value has not changed. | -| `placeholder` | `string` | — | Native placeholder. | -| `className` | `string` | — | Additional input classes. | -| `pt.root` | `InputHTMLAttributes` | — | Native input attributes; binding and validation attributes remain field-owned. | - -The field also supports Arc population props and the shared [accessible naming props](index.md#accessible-names-and-validation-errors). Native `pt.root.disabled` and `pt.root.readOnly` states are reflected in data attributes. - -## Validation and values - -- Nonempty drafts are checked with `Guid.isGuid` and parsed with `Guid.parse`. Partial or invalid input stays editable and immediately writes `undefined` to the command, never the previous valid identifier. -- Empty optional input writes `undefined` without a format error. Required emptiness produces a required-value error instead. -- No identifier is generated. Zero Guids and any version bits accepted by Fundamentals are accepted here; add explicit command rules when your domain requires more. -- Format and required errors use the real Arc `setCustomFieldError` context API. The field never replaces native command execution. Native command validation rules still apply independently of field presentation. -- Errors are associated with the input through `aria-describedby` and `aria-invalid`. On unmount, the field removes only its own current custom error. - -## Clearing and resetting - -External value changes replace the displayed draft and recalculate its error. A changed `AutoCommandForm.currentValues` Guid overlay also resets the draft, including a change to explicit `undefined` after invalid input has already emptied the native value. Equivalent Guid overlays do not erase ongoing edits. - -An assignment of the same empty value carries no observable reset intent in Arc's field binding. For an explicit same-value reset, change a hand-written field's `resetKey` (or remount the form). A custom Guid provider can forward this prop. Do not reset drafts on every `commandVersion` change: unrelated field edits must preserve partial input. diff --git a/Documentation/CommandForm/index.md b/Documentation/CommandForm/index.md index b3e99aa2..1748ee6f 100644 --- a/Documentation/CommandForm/index.md +++ b/Documentation/CommandForm/index.md @@ -19,7 +19,7 @@ CommandForm offers a complete set of form field components designed to work seam ## Available Field Components -The CommandForm module exports Cratis-owned semantic fields built with native controls and documented keyboard, naming, and validation behavior. Each field uses Arc's native field-binding helpers for automatic value binding, validation state, and command integration. [GuidField](guid-field.md) adds an editable text draft while keeping the command value a scalar Fundamentals `Guid`. +The CommandForm module exports Cratis-owned semantic fields built with native controls and documented keyboard, naming, and validation behavior. Each field uses `asCommandFormField` for automatic value binding, validation state, and Arc command integration. See the field type pages in this section for documentation on each available field component. To generate a form's fields from a command's own properties instead of writing them out by hand, see [AutoCommandForm](auto-command-form.md). @@ -69,7 +69,7 @@ Checkboxes and switches prefer their inline `label`; radio options use their opt ## Populating Initial Values from a Query -Every field here uses native field binding from `@cratis/arc.react/commands`, so each one automatically supports `CommandForm`'s `populateFromQuery`/`populateFromObservableQuery` props - the form fetches a single-instance query itself and seeds its fields from the result, matched onto the command by property name: +Every field here is built with `asCommandFormField` from `@cratis/arc.react/commands`, so each one automatically supports `CommandForm`'s `populateFromQuery`/`populateFromObservableQuery` props - the form fetches a single-instance query itself and seeds its fields from the result, matched onto the command by property name: ```tsx import { CommandForm } from '@cratis/arc.react/commands'; @@ -87,7 +87,7 @@ import { UpdateProfile } from './commands'; ; ``` -Two field props refine this per field - both work on every field type in this package, since they come from Arc's shared field registration: +Two field props refine this per field - both work on every field type in this package, since they come from the shared `asCommandFormField` wrapper: - `noInitialValue` - skip this field entirely, even if the query result has a same-named property. - `initialValue` - override how the field's value is derived from the query result, either a property accessor matched by name or a function composing a value from the whole result. diff --git a/Documentation/CommandForm/toc.yml b/Documentation/CommandForm/toc.yml index c58dbde0..bd3697fc 100644 --- a/Documentation/CommandForm/toc.yml +++ b/Documentation/CommandForm/toc.yml @@ -12,8 +12,6 @@ href: color-picker-field.md - name: DropdownField href: dropdown-field.md -- name: GuidField - href: guid-field.md - name: InputTextField href: input-text-field.md - name: MultiSelectField diff --git a/Documentation/Migration/4-to-5.md b/Documentation/Migration/4-to-5.md deleted file mode 100644 index eaa74d6f..00000000 --- a/Documentation/Migration/4-to-5.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: Migrate from Components 4 to 5 -description: Align the Components package family and upgrade Arc for typed command forms. ---- - -Components 5.0 is a major release because its dependency contract narrows: both `@cratis/arc` and `@cratis/arc.react` now require `>=22.19.1 <23`, rather than Components 4's declared `>=20.3.1 <23`. Older Arc hosts are not supported by Components 5. There is no runtime version-detection fallback. - -## Upgrade together - -1. Preserve your package manifest and lockfile as a rollback point. -2. Upgrade Arc and Arc React together to matching versions, at least 22.19.1 and below 23. Keep generated proxies aligned with those packages. -3. Upgrade every installed member of the Components family to the same 5.x release: `@cratis/components`, `@cratis/eslint-plugin-components`, `@cratis/components.migrator`, `@cratis/components.conformance`, `@cratis/components.mui`, `@cratis/components.primereact`, and `@cratis/components.primereact10`. Optional adapters and tools need not be installed if unused. Version 5 adapters and Conformance declare Core `>=5 <6`; do not mix the 4.x and 5.x families. -4. Run your type checks, form validation and command-execution tests, and production build before deploying. Roll back the package family and Arc changes together if needed. - -```bash -npm install @cratis/components@^5 @cratis/arc@22.19.1 @cratis/arc.react@22.19.1 -``` - -This guide describes the intended 5.0 release. Verify registry availability before installing; a source checkout is not evidence of publication. - -## What changes — and what does not - -Existing form APIs remain compatible. `AutoCommandForm` now supports composed footer children and resolves generated Guid properties to the new `GuidField`. `GuidField` edits explicit identifiers and uses Arc's native custom-field-error execution guard and live error lookup to prevent invalid Guid drafts from executing. See [Guid fields](../CommandForm/guid-field.md) and [AutoCommandForm](../CommandForm/auto-command-form.md). - -There is no automatic ID generation, authentication or authorization change, new API removal, or renderer ABI change. Renderer ABI 1, `core/v1`, and `stable-presentation/v1` remain unchanged. Other dependency pins and provider, styling, and command contracts are unchanged by this major-version policy. - -No 4-to-5 source codemod is required. Migrator 5 retains the existing 3-to-4 transforms and accepts Components 3 as a source and Components 4 or 5 as recovery targets. This does not remove the staged order in the historical [3-to-4 guide](3-to-4.md). Migrator 4 remains bounded to its original Components 3/4 windows and does not claim Components 5 support. diff --git a/Documentation/Migration/index.md b/Documentation/Migration/index.md index 00f34c8d..b6426029 100644 --- a/Documentation/Migration/index.md +++ b/Documentation/Migration/index.md @@ -9,6 +9,5 @@ Guides for moving `@cratis/components` forward across major versions. Each guide | --- | --- | | [2.x → 3.0](2-to-3.md) | PrimeReact 10 → 11, peer dependencies, styling, and PrimeUI licensing | | [3.x → 4.0](3-to-4.md) | Renderer-independent Components APIs, explicit subpaths, styling parts, and migration tooling | -| [4.x → 5.0](4-to-5.md) | Arc 22.19.1 minimum, matching package-family upgrades, Guid fields, and footer composition | When moving from PrimeReact 10 to 11, also read PrimeReact's [Updating to v11](https://primereact.dev/docs/primitive/guides/migration/updating-to-v11) guide alongside [2.x → 3.0](2-to-3.md). The [3.x → 4.0](3-to-4.md) guide covers the separate Components migration away from PrimeReact-backed public APIs. diff --git a/Documentation/Migration/toc.yml b/Documentation/Migration/toc.yml index 913f37a1..d659ed3e 100644 --- a/Documentation/Migration/toc.yml +++ b/Documentation/Migration/toc.yml @@ -4,5 +4,3 @@ href: 2-to-3.md - name: 3.x → 4.0 href: 3-to-4.md -- name: 4.x → 5.0 - href: 4-to-5.md diff --git a/Documentation/getting-started.mdx b/Documentation/getting-started.mdx index 088049e8..51d80ea8 100644 --- a/Documentation/getting-started.mdx +++ b/Documentation/getting-started.mdx @@ -18,26 +18,24 @@ You've built an Arc backend—a `RegisterAuthor` command and an `AllAuthors` que 1. **Install Components.** - ```bash title="Install Components 5" - npm install @cratis/components@^5 + ```bash title="Install Components 4" + npm install @cratis/components@^4 ``` - Upgrade installed adapters, Conformance, ESLint, and Migrator with Core to the matching 5.x package family. See [Migrate from 4 to 5](/components/migration/4-to-5/). - React Aria, the internationalized date implementation, and React Icons are internal dependencies. The current package manifest does not declare a separate UI kit or theme runtime, and consumers do not configure an icon-package peer. - The package declares React, Arc, Fundamentals, `reflect-metadata`, and `tsyringe` peer ranges. Its Arc range is `>=22.19.1 <23`, required for generated-field binding and native custom-field-error execution guards. Keep `@cratis/arc` and `@cratis/arc.react` on the same version used by the application's generated proxies. A strict installer can use this explicit form (replace the example Arc version when needed): + The package declares React, Arc, Fundamentals, `reflect-metadata`, and `tsyringe` peer ranges. Its Arc range is `>=20.3.1 <23`. Use Arc 22.16.0 or newer for `AutoCommandForm`'s explicit field binding. Keep `@cratis/arc` and `@cratis/arc.react` on the same version used by the application's generated proxies. A strict installer can use this explicit form (replace the example Arc version when needed): ```bash title="Install explicit peers" - ARC_VERSION=22.19.1 - npm install @cratis/components@^5 \ + ARC_VERSION=22.16.0 + npm install @cratis/components@^4 \ "@cratis/arc@$ARC_VERSION" "@cratis/arc.react@$ARC_VERSION" \ @cratis/fundamentals@^7.19.2 react@^19 react-dom@^19 \ reflect-metadata@0.2.2 tsyringe@4.10.0 @@ -80,7 +78,7 @@ You've built an Arc backend—a `RegisterAuthor` command and an `AllAuthors` que ### Yarn PnP with Arc React 22.6.2 -For older Components releases that allowed `@cratis/arc.react@22.6.2`, that Arc version imports `rxjs` without declaring it. A strict Yarn PnP consumer needs `rxjs@7.8.2` and this version-specific package extension: +If you retain `@cratis/arc.react@22.6.2`, that version imports `rxjs` without declaring it. A strict Yarn PnP consumer needs `rxjs@7.8.2` and this version-specific package extension: ```yaml title=".yarnrc.yml" packageExtensions: @@ -89,7 +87,7 @@ packageExtensions: rxjs: '7.8.2' ``` -Current Components requires Arc React 22.19.1 or later, which does not need this workaround. Remove the old extension when upgrading; do not apply it to every Arc version. +This workaround is not needed with Arc React 22.16.0. Remove the old extension when upgrading; do not apply it to every Arc version. ## What the provider sets up diff --git a/ESLint/package.json b/ESLint/package.json index ed3c1ca1..c18a4314 100644 --- a/ESLint/package.json +++ b/ESLint/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-components", - "version": "5.0.0", + "version": "4.0.0", "description": "Cratis Components ESLint rules: import from subpaths not the root barrel, use the Cratis dialog wrappers instead of primereact/dialog, require onBeforeExecute callbacks to return their values, keep React hooks out of view models, protect the repository-owned React-free kernel, and identify CommandForm fields by a tamper-resistant marker rather than a raw displayName string. Compose on top of @cratis/eslint-config.", "author": "Cratis", "license": "MIT", diff --git a/Migrator/README.md b/Migrator/README.md index 655452d9..abf2083a 100644 --- a/Migrator/README.md +++ b/Migrator/README.md @@ -1,16 +1,10 @@ # @cratis/components.migrator -The Components 5 Migrator retains the existing transforms that update a Components 3 codebase to the Components 4 public contracts. It moves root namespaces to explicit subpath imports, replaces deprecated Button appearance props, and changes legacy event-wrapper callbacks to semantic value callbacks. The CLI uses syntax-aware codemods internally, but each command is named for the migration it performs, can be run independently, and is idempotent. Components 4 keeps only package-wide provider setup at the root; every component is imported from its explicit subpath (`@cratis/components/Canvas`, for example). The companion `@cratis/eslint-plugin-components` package's `no-root-barrel-import` rule enforces this once a consumer has migrated. +The Components 4 Migrator updates a Components 3 codebase to the Components 4 public contracts. It moves root namespaces to explicit subpath imports, replaces deprecated Button appearance props, and changes legacy event-wrapper callbacks to semantic value callbacks. The CLI uses syntax-aware codemods internally, but each command is named for the migration it performs, can be run independently, and is idempotent. Components 4 keeps only package-wide provider setup at the root; every component is imported from its explicit subpath (`@cratis/components/Canvas`, for example). The companion `@cratis/eslint-plugin-components` package's `no-root-barrel-import` rule enforces this once a consumer has migrated. -Migrator 5 accepts Components 3 as a source and Components 4 or 5 as recovery targets. -The historical 3-to-4 examples below deliberately retain bounded Migrator 4 commands; -that package does not support Components 5. For a Components 5 package family, use -`@cratis/components.migrator@^5` with matching 5.x tooling. No 4-to-5 source codemod is -required, and no transforms or API removals are added by this release. - -> **Publication status:** The current source targets the intended 5.0.0 npm release. When +> **Publication status:** The install examples target the owner-authorized 4.0.0 npm release. When > reading this README from repository source before that release, verify availability with -> `npm view @cratis/components.migrator@5.0.0 version`; source contributors run the workspace +> `npm view @cratis/components.migrator@4.0.0 version`; source contributors run the workspace > commands from this checkout instead. The Migrator is never an application runtime dependency. The published package is a **CLI-only** tool. Its public surface is the documented `cratis-components-*` executable names (plus `./package.json` for tooling metadata); `lib/`, `scripts/`, and individual transforms are implementation details and are blocked by the package export map. Do not import them from application code or build custom migration APIs on them. diff --git a/Migrator/compat-manifest.json b/Migrator/compat-manifest.json index f1210d6f..89ebc244 100644 --- a/Migrator/compat-manifest.json +++ b/Migrator/compat-manifest.json @@ -30,9 +30,9 @@ ] }, "toolingCompatibility": { - "componentsCore": ">=5 <6", - "eslint": ">=5 <6", - "migrator": ">=5 <6" + "componentsCore": ">=4 <5", + "eslint": ">=4 <5", + "migrator": ">=4 <5" }, "supportWindows": { "components3": { @@ -40,21 +40,21 @@ "status": "maintenance-security-critical", "migrationRole": "source", "migrationTarget": ">=4 <5", - "tooling": ">=4 <6", + "tooling": ">=4 <5", "eolAt": null, "eolApprovedByOwners": false, "ownerDecisionPolicy": "Set and approve EOL no later than 12 months after Components 4 GA." }, "components4": { "components": ">=4 <5", - "status": "previous", + "status": "current", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", "adapterProfile": "stable-presentation/v1", "tooling": { "eslint": ">=4 <5", - "migrator": ">=4 <6" + "migrator": ">=4 <5" }, "adapters": { "@cratis/components.conformance": ">=4 <5", @@ -62,38 +62,20 @@ "@cratis/components.primereact": ">=4 <5", "@cratis/components.primereact10": ">=4 <5" } - }, - "components5": { - "components": ">=5 <6", - "status": "current", - "migrationRole": "target", - "rendererAbi": 1, - "coreProfile": "core/v1", - "adapterProfile": "stable-presentation/v1", - "tooling": { - "eslint": ">=5 <6", - "migrator": ">=5 <6" - }, - "adapters": { - "@cratis/components.conformance": ">=5 <6", - "@cratis/components.mui": ">=5 <6", - "@cratis/components.primereact": ">=5 <6", - "@cratis/components.primereact10": ">=5 <6" - } } }, "packages": [ { "name": "@cratis/components", "role": "core", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/arc": ">=22.19.1 <23", - "@cratis/arc.react": ">=22.19.1 <23", + "@cratis/arc": ">=20.3.1 <23", + "@cratis/arc.react": ">=20.3.1 <23", "@cratis/fundamentals": "^7.10.3", "pixi.js": "^8.20.0", "react": "^19.0.0", @@ -110,8 +92,8 @@ { "name": "@cratis/eslint-plugin-components", "role": "eslint", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", @@ -123,8 +105,8 @@ { "name": "@cratis/components.migrator", "role": "migrator", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", @@ -134,13 +116,13 @@ { "name": "@cratis/components.conformance", "role": "conformance", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -150,13 +132,13 @@ { "name": "@cratis/components.mui", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "@emotion/react": ">=11.5 <12", "@emotion/styled": ">=11.11 <12", "@mui/material": ">=9 <10", @@ -174,8 +156,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.5.0", "@emotion/styled": "11.11.0", @@ -188,8 +170,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", @@ -206,13 +188,13 @@ { "name": "@cratis/components.primereact", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "@primereact/core": ">=11 <12", "@primereact/ui": ">=11 <12", "@primeuix/themes": ">=3 <4", @@ -232,8 +214,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.0.0", "@primereact/headless": "11.0.0", @@ -251,8 +233,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.1.0", "@primereact/headless": "11.1.0", @@ -274,13 +256,13 @@ { "name": "@cratis/components.primereact10", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "primereact": ">=10.9.9 <11", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -294,8 +276,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@types/react": "19.0.0", "primereact": "10.9.9", @@ -306,8 +288,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@types/react": "19.2.18", "primereact": "10.9.9", diff --git a/Migrator/lib/compatibility.js b/Migrator/lib/compatibility.js index 62255700..402860cd 100644 --- a/Migrator/lib/compatibility.js +++ b/Migrator/lib/compatibility.js @@ -6,7 +6,6 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import semver from 'semver'; -import { validateReleasePolicy } from './releasePolicy.js'; const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const bundledManifestPath = path.join(packageDirectory, 'compat-manifest.json'); @@ -109,7 +108,35 @@ export function validateBundledManifest(manifest, migratorVersion) { ); } - validateReleasePolicy(manifest, migratorVersion); + const windows = Object.values(manifest.supportWindows ?? {}); + const sourceWindows = windows.filter( + ({ migrationRole }) => migrationRole === 'source', + ); + const targetWindows = windows.filter( + ({ migrationRole }) => migrationRole === 'target', + ); + if ( + windows.length !== 2 || + sourceWindows.length !== 1 || + targetWindows.length !== 1 || + sourceWindows[0].components !== '>=3 <4' || + sourceWindows[0].migrationTarget !== '>=4 <5' || + targetWindows[0].components !== '>=4 <5' || + manifest.toolingCompatibility?.componentsCore !== '>=4 <5' || + windows.some((window) => { + const toolingRange = + typeof window.tooling === 'string' + ? window.tooling + : window.tooling?.migrator; + return ( + !semver.validRange(window.components) || !semver.validRange(toolingRange) + ); + }) + ) { + throw new Error( + 'Bundled compatibility manifest has invalid migration support windows.', + ); + } } function resolvePackageManifest(packageName, cwd) { @@ -122,7 +149,7 @@ function resolvePackageManifest(packageName, cwd) { const detail = error instanceof Error ? error.message : String(error); throw new Error( `Could not resolve installed ${packageName} from '${path.resolve(cwd)}'. ` + - `Install a supported Components 3, 4, or 5 package before running the codemod. ${detail}`, + `Install a supported Components 3 or Components 4 package before running the codemod. ${detail}`, ); } } diff --git a/Migrator/lib/releasePolicy.js b/Migrator/lib/releasePolicy.js deleted file mode 100644 index 94409422..00000000 --- a/Migrator/lib/releasePolicy.js +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import semver from 'semver'; - -export const adapterPackages = [ - '@cratis/components.conformance', - '@cratis/components.mui', - '@cratis/components.primereact', - '@cratis/components.primereact10', -]; - -/** Only reviewed release families are supported; a future major needs an explicit policy. */ -export function releasePolicy(version) { - const major = semver.valid(version) && semver.major(version); - if (![4, 5].includes(major)) { - throw new Error(`Version '${version}' is outside supported release families 4 and 5.`); - } - return { - major, - range: `>=${major} <${major + 1}`, - arcRange: major === 4 ? '>=20.3.1 <23' : '>=22.19.1 <23', - }; -} - -export function supportWindows(version) { - const { major } = releasePolicy(version); - const target = (line) => ({ - components: `>=${line} <${line + 1}`, - status: line === major ? 'current' : 'previous', - migrationRole: 'target', - rendererAbi: 1, - coreProfile: 'core/v1', - adapterProfile: 'stable-presentation/v1', - tooling: { - eslint: `>=${line} <${line + 1}`, - // Migrator 5 retains the existing 3-to-4 transforms and recovery runs. - migrator: `>=${line} <${major + 1}`, - }, - adapters: Object.fromEntries(adapterPackages.map((name) => [name, `>=${line} <${line + 1}`])), - }); - return { - components3: { - components: '>=3 <4', - status: 'maintenance-security-critical', - migrationRole: 'source', - migrationTarget: '>=4 <5', - tooling: `>=4 <${major + 1}`, - eolAt: null, - eolApprovedByOwners: false, - ownerDecisionPolicy: 'Set and approve EOL no later than 12 months after Components 4 GA.', - }, - components4: target(4), - ...(major === 5 ? { components5: target(5) } : {}), - }; -} - -/** Shared by generation and the packed Migrator, without changing any codemod. */ -export function validateReleasePolicy(manifest, version) { - const policy = releasePolicy(version); - const tooling = manifest.toolingCompatibility; - if (['componentsCore', 'eslint', 'migrator'].some((key) => tooling?.[key] !== policy.range)) { - throw new Error(`Core ${policy.major} tooling compatibility must remain bounded to ${policy.range}.`); - } - if (JSON.stringify(manifest.supportWindows) !== JSON.stringify(supportWindows(version))) { - throw new Error('The compatibility manifest has invalid migration support windows.'); - } - for (const entry of manifest.packages ?? []) { - if (entry.version !== version || entry.releaseMajorRange !== policy.range) { - throw new Error(`${entry.name} must match the repository release version ${version} and family ${policy.range}.`); - } - const peers = entry.peerDependencies ?? {}; - if (adapterPackages.includes(entry.name) && peers['@cratis/components'] !== policy.range) { - throw new Error(`${entry.name} must declare the matching Components peer family ${policy.range}.`); - } - for (const name of ['@cratis/arc', '@cratis/arc.react']) { - if ((entry.name === '@cratis/components' || name in peers) && peers[name] !== policy.arcRange) { - throw new Error(`${entry.name} must declare ${name} peer contract '${policy.arcRange}' for Components ${policy.major}.`); - } - } - } -} diff --git a/Migrator/package.json b/Migrator/package.json index 4aa848f8..69e203c6 100644 --- a/Migrator/package.json +++ b/Migrator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components.migrator", - "version": "5.0.0", + "version": "4.0.0", "description": "Migration tooling for updating @cratis/components consumers from Components 3 to Components 4.", "author": "Cratis", "license": "MIT", diff --git a/Migrator/test/compatibility.test.js b/Migrator/test/compatibility.test.js index f961b898..088cbf7e 100644 --- a/Migrator/test/compatibility.test.js +++ b/Migrator/test/compatibility.test.js @@ -38,44 +38,9 @@ const createConsumer = (version) => { }; describe('compatibility preflight', () => { - it('preserves historical Migrator 4 policy without claiming Components 5 support', () => { - const historical = structuredClone(compatibilityManifest); - historical.toolingCompatibility = { componentsCore: '>=4 <5', eslint: '>=4 <5', migrator: '>=4 <5' }; - delete historical.supportWindows.components5; - historical.supportWindows.components3.tooling = '>=4 <5'; - historical.supportWindows.components4.status = 'current'; - historical.supportWindows.components4.tooling.migrator = '>=4 <5'; - for (const entry of historical.packages) { - entry.version = '4.0.0'; - entry.releaseMajorRange = '>=4 <5'; - if (entry.peerDependencies['@cratis/components']) entry.peerDependencies['@cratis/components'] = '>=4 <5'; - for (const name of ['@cratis/arc', '@cratis/arc.react']) { - if (entry.peerDependencies[name]) entry.peerDependencies[name] = '>=20.3.1 <23'; - } - } - expect(() => validateBundledManifest(historical, '4.0.0')).not.toThrow(); - expect(() => validateBundledManifest(historical, '5.0.0')).toThrow('outside bundled range'); - historical.packages[0].peerDependencies['@cratis/arc'] = '>=22.19.1 <23'; - expect(() => validateBundledManifest(historical, '4.0.0')).toThrow('peer contract'); - }); - - it('rejects Components 5 metadata claiming the historical Arc floor', () => { - const invalid = structuredClone(compatibilityManifest); - invalid.packages[0].peerDependencies['@cratis/arc.react'] = '>=20.3.1 <23'; - expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow('peer contract'); - }); - - it('rejects a mismatched adapter family', () => { - const invalid = structuredClone(compatibilityManifest); - invalid.packages.find(({ role }) => role === 'renderer-adapter').peerDependencies['@cratis/components'] = '>=4 <5'; - expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow('matching Components peer family'); - }); it.each([ ['3.6.1', 'source'], ['4.0.0', 'target'], - ['4.99.0', 'target'], - ['5.0.0', 'target'], - ['5.99.0', 'target'], ])('accepts Components %s as a migration %s', (version, role) => { expect(preflightCompatibility({ cwd: createConsumer(version) })).toMatchObject({ componentsVersion: version, @@ -83,9 +48,9 @@ describe('compatibility preflight', () => { }); }); - it.each(['2.99.0', '6.0.0', '5.0.0-rc.1'])('rejects unsupported Components %s', (version) => { - expect(() => preflightCompatibility({ cwd: createConsumer(version) })).toThrow( - `@cratis/components@${version} is unsupported`, + it('rejects an unsupported Components version', () => { + expect(() => preflightCompatibility({ cwd: createConsumer('5.0.0') })).toThrow( + '@cratis/components@5.0.0 is unsupported', ); }); @@ -101,8 +66,8 @@ describe('compatibility preflight', () => { const stale = structuredClone(compatibilityManifest); stale.packages.find( ({ name }) => name === '@cratis/components.migrator', - ).version = '5.0.1'; - expect(() => validateBundledManifest(stale, '5.0.0')).toThrow( + ).version = '4.0.1'; + expect(() => validateBundledManifest(stale, '4.0.0')).toThrow( 'stale migrator package metadata', ); }); @@ -111,7 +76,7 @@ describe('compatibility preflight', () => { const invalid = structuredClone(compatibilityManifest); invalid.supportWindows.components3.tooling = 'not-a-range'; - expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow( + expect(() => validateBundledManifest(invalid, '4.0.0')).toThrow( 'invalid migration support windows', ); }); @@ -120,7 +85,7 @@ describe('compatibility preflight', () => { const invalid = structuredClone(compatibilityManifest); invalid.supportWindows.components3.components = '>=2 <4'; - expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow( + expect(() => validateBundledManifest(invalid, '4.0.0')).toThrow( 'invalid migration support windows', ); }); @@ -129,7 +94,7 @@ describe('compatibility preflight', () => { const invalid = structuredClone(compatibilityManifest); delete invalid.supportWindows.components4; - expect(() => validateBundledManifest(invalid, '5.0.0')).toThrow( + expect(() => validateBundledManifest(invalid, '4.0.0')).toThrow( 'invalid migration support windows', ); }); diff --git a/Migrator/test/verify-packed-package.mjs b/Migrator/test/verify-packed-package.mjs index fe347d03..b552c2b7 100644 --- a/Migrator/test/verify-packed-package.mjs +++ b/Migrator/test/verify-packed-package.mjs @@ -275,13 +275,7 @@ try { ); } - for (const version of ['4.0.0', '5.0.0']) { - installSyntheticComponents(version); - assertRun(`packed Migrator accepts Components ${version} recovery`, - run(binary, ['--check', source], { cwd: consumer })); - } - - installSyntheticComponents('6.0.0'); + installSyntheticComponents('5.0.0'); const unsupportedInput = "import { Canvas } from '@cratis/components';\n"; for (const command of commands) { const unsupportedSource = path.join(consumer, `${command}-unsupported.tsx`); @@ -290,7 +284,7 @@ try { cwd: consumer, }); assertRun(`packed ${command} rejects unsupported Components`, unsupported, 1); - if (!unsupported.stderr.includes('@cratis/components@6.0.0 is unsupported')) { + if (!unsupported.stderr.includes('@cratis/components@5.0.0 is unsupported')) { throw new Error( `Unexpected unsupported-version output:\n${unsupported.stderr}`, ); @@ -303,7 +297,7 @@ try { } console.log( - 'Packed @cratis/components.migrator manifest and CLIs verified with Components 3, 4, 5, and unsupported versions.', + 'Packed @cratis/components.migrator manifest and CLIs verified with Components 3, 4, and unsupported versions.', ); } finally { rmSync(scratch, { recursive: true, force: true }); diff --git a/README.md b/README.md index 135ce0fd..831dbd8f 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,12 @@ Verify those properties for the exact application and component profile shipped. Install the package: ```bash -npm install @cratis/components@^5 +npm install @cratis/components@^4 ``` -> **Publication status:** This example targets the owner-authorized 5.0.0 npm release. When reading +> **Publication status:** This example targets the owner-authorized 4.0.0 npm release. When reading > it from repository source before that release, verify availability with -> `npm view @cratis/components@5.0.0 version`; source contributors use the repository workspace. +> `npm view @cratis/components@4.0.0 version`; source contributors use the repository workspace. Import the semantic tokens and component structure. The baseline theme is optional: @@ -84,10 +84,6 @@ configuration. few surfaces can import `@cratis/components/styles/base` plus one `/styles` entry point per surface instead, and download nothing else. -Components 5 requires matching Arc and Arc React versions in `>=22.19.1 <23`. -Upgrade installed Components-family packages together to the same 5.x release; -see the [4-to-5 migration guide](Documentation/Migration/4-to-5.md). - The current package manifest defines the exact React, Arc, Fundamentals, and optional Pixi peer ranges. Verify those ranges before installing the package. diff --git a/Source/CommandForm/AutoCommandForm.tsx b/Source/CommandForm/AutoCommandForm.tsx index c8d353e2..d5dddfc9 100644 --- a/Source/CommandForm/AutoCommandForm.tsx +++ b/Source/CommandForm/AutoCommandForm.tsx @@ -4,7 +4,6 @@ import type React from 'react'; import { useMemo } from 'react'; import type { Command } from '@cratis/arc/commands'; -import { Guid } from '@cratis/fundamentals'; import { CommandForm, type CommandFormProps } from '@cratis/arc.react/commands'; import { registerDefaultFieldTypeProviders } from './defaultFieldTypeProviders'; import { resolveFieldTypeProvider } from './fieldTypeProviderRegistry'; @@ -43,7 +42,7 @@ function formatTitle(propertyName: string): string { * A `CommandForm` that generates its field list from the command's own properties, choosing each * field's component by the property's type through the {@link FieldTypeProvider} registry - * `registerFieldTypeProvider` for a type the built-in defaults (`string`, `number`, `boolean`, - * `Date`, `Guid`) don't cover, or to override one of them. + * `Date`) don't cover, or to override one of them. * * A property whose type no registered provider handles is left out of the generated list - * `exclude` it explicitly for clarity, or add a `CommandForm` child by hand alongside this @@ -77,16 +76,9 @@ export function AutoCommandForm descriptor.type === Guid, - component: GuidField, - }); registerFieldTypeProvider({ canHandle: (descriptor) => descriptor.type === String, component: InputTextField, diff --git a/Source/CommandForm/fields/GuidField.tsx b/Source/CommandForm/fields/GuidField.tsx deleted file mode 100644 index 40ceb2c2..00000000 --- a/Source/CommandForm/fields/GuidField.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'; -import { - withCommandFormFieldBinding, - useCommandFormContext, - type BaseCommandFormFieldProps, - type InjectedCommandFormFieldProps, -} from '@cratis/arc.react/commands'; -import { Guid } from '@cratis/fundamentals'; -import { useFieldAccessibility, type FieldAccessibilityProps } from './fieldAccessibility'; - -/** Stable part attributes for {@link GuidField}. */ -export interface GuidParts { - /** Native text input. Value and change handling remain owned by the field. */ - root?: InputHTMLAttributes; -} - -/** A native Guid binding with an independently editable text draft. */ -export interface GuidFieldProps - extends BaseCommandFormFieldProps, InjectedCommandFormFieldProps, FieldAccessibilityProps { - /** Selects a scalar Guid property on the command. */ - value(instance: TCommand): Guid | undefined | null; - /** Hint displayed when the draft is empty. */ - placeholder?: string; - /** Additional classes on the native input. */ - className?: string; - /** Native input pass-through attributes. */ - pt?: GuidParts; - /** Message for a nonempty draft that Fundamentals does not recognize as a Guid. */ - formatErrorMessage?: string; - /** Message for an empty required field. */ - requiredErrorMessage?: string; - /** Change to discard the draft even when the command value is already empty. */ - resetKey?: unknown; -} - -function GuidFieldComponent(props: GuidFieldProps) { - const { currentValue, fieldName, resetKey } = props; - const { getFieldError, customFieldErrors, setCustomFieldError } = useCommandFormContext(); - const required = props.required ?? !props.propertyDescriptor?.isOptional; - const textForValue = (value: unknown) => value instanceof Guid ? value.toString() : ''; - const [draft, setDraft] = useState(() => ({ value: currentValue, resetKey, text: textForValue(currentValue) })); - // Synchronize external population during render, not in a delayed effect that can overwrite typing. - if (!Object.is(draft.value, currentValue) || !Object.is(draft.resetKey, resetKey)) { - setDraft({ value: currentValue, resetKey, text: textForValue(currentValue) }); - } - const text = Object.is(draft.value, currentValue) && Object.is(draft.resetKey, resetKey) ? draft.text : textForValue(currentValue); - const errorForText = (value: string) => value.length === 0 - ? (required ? props.requiredErrorMessage || 'A value is required.' : undefined) - : (Guid.isGuid(value) ? undefined : props.formatErrorMessage || 'Enter a valid Guid.'); - const error = errorForText(text); - const ownedErrorRef = useRef(undefined); - const latestGetFieldErrorRef = useRef(getFieldError); - latestGetFieldErrorRef.current = getFieldError; - - // Arc's getter reads custom errors live, including writes in an event that unmounts this field - // before it renders again. A render-time map (even an optimistic copy) cannot establish ownership. - // Only clear an error this field owns; a caller's onFieldValidate error must survive valid edits. - const publishError = (nextError: string | undefined) => { - if (!fieldName) return; - if (nextError || (ownedErrorRef.current && getFieldError(fieldName) === ownedErrorRef.current)) { - setCustomFieldError(fieldName, nextError); - } - ownedErrorRef.current = nextError; - }; - useEffect(() => { - if (error !== ownedErrorRef.current || (error && fieldName && customFieldErrors[fieldName] !== error)) { - publishError(error); - } - }); - useEffect(() => () => { - if (fieldName && ownedErrorRef.current && latestGetFieldErrorRef.current(fieldName) === ownedErrorRef.current) { - setCustomFieldError(fieldName, undefined); - } - }, [fieldName, setCustomFieldError]); - - const fieldError = fieldName ? getFieldError(fieldName) : undefined; - const errors = [...new Set([error, fieldError].filter((message): message is string => Boolean(message)))]; - const invalid = errors.length > 0; - const accessibility = useFieldAccessibility({ ...props, errors }, { - id: props.pt?.root?.id, - ariaLabel: props.pt?.root?.['aria-label'], - ariaDescribedBy: props.pt?.root?.['aria-describedby'], - }); - return <> - { - const nextText = event.currentTarget.value; - const nextValue = Guid.isGuid(nextText) ? Guid.parse(nextText) : undefined; - setDraft({ value: nextValue, resetKey, text: nextText }); - // Clear the old identifier immediately, including for partial/invalid input. Arc still - // owns command mutation, validation and execution; this field only supplies its error. - publishError(undefined); - props.onValueChange?.(nextValue); - // Publish unconditionally after binding: native callbacks may have replaced the - // previous error synchronously, before context has rendered its new error map. - publishError(errorForText(nextText)); - }} - onBlur={props.onBlur} - placeholder={props.placeholder} - data-disabled={props.pt?.root?.disabled || undefined} - data-invalid={invalid || undefined} - data-readonly={props.pt?.root?.readOnly || undefined} - data-cratis-part='input' - className={['cratis-field-input', 'cratis:w-full', props.pt?.root?.className, props.className].filter(Boolean).join(' ')} - /> - {accessibility.hiddenError} - ; -} - -/** Edits a scalar Fundamentals Guid without generating an identifier or imposing version/nonzero rules. */ -export const GuidField = withCommandFormFieldBinding(GuidFieldComponent); diff --git a/Source/CommandForm/fields/index.ts b/Source/CommandForm/fields/index.ts index 91c7748f..8b69442c 100644 --- a/Source/CommandForm/fields/index.ts +++ b/Source/CommandForm/fields/index.ts @@ -2,7 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. export * from './InputTextField'; -export * from './GuidField'; export * from './NumberField'; export * from './NumberInputField'; export * from './CheckboxField'; diff --git a/Source/CommandForm/for_AutoCommandForm/given/GuidFieldTypes.tsx b/Source/CommandForm/for_AutoCommandForm/given/GuidFieldTypes.tsx deleted file mode 100644 index 4daefa16..00000000 --- a/Source/CommandForm/for_AutoCommandForm/given/GuidFieldTypes.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import { Guid } from '@cratis/fundamentals'; -import { GuidField } from '../../fields/GuidField'; - -interface SampleValues { - sampleId: Guid; - optionalId?: Guid; - nullableId: Guid | null; - sampleIds: Guid[]; - name: string; -} - -export const requiredField = value={(command) => command.sampleId} />; -export const optionalField = value={(command) => command.optionalId} />; -export const nullableField = value={(command) => command.nullableId} />; -// @ts-expect-error A Guid field cannot bind a string property. -export const stringField = value={(command) => command.name} />; -// @ts-expect-error A Guid field cannot bind an array property. -export const arrayField = value={(command) => command.sampleIds} />; diff --git a/Source/CommandForm/for_AutoCommandForm/given/SampleGuidCommand.ts b/Source/CommandForm/for_AutoCommandForm/given/SampleGuidCommand.ts deleted file mode 100644 index 40bb7f85..00000000 --- a/Source/CommandForm/for_AutoCommandForm/given/SampleGuidCommand.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import { Command } from '@cratis/arc/commands'; -import { PropertyDescriptor } from '@cratis/arc/reflection'; -import { Guid } from '@cratis/fundamentals'; - -// Independently authored, generated-shape fixture for Components; all values are synthetic. -export const sampleId = Guid.parse('c4cabc30-211b-447d-9ec2-5087e640a5ac'); - -export class SampleGuidCommand extends Command { - readonly route = '/api/sample-command'; - readonly propertyDescriptors = [ - new PropertyDescriptor('sampleId', Guid), - new PropertyDescriptor('name', String), - ]; - sampleId!: Guid; - name = 'Sample User'; - - get requestParameters(): string[] { - return []; - } - - constructor() { - super(Object, false); - } -} diff --git a/Source/CommandForm/for_AutoCommandForm/given/a_guid_form.tsx b/Source/CommandForm/for_AutoCommandForm/given/a_guid_form.tsx deleted file mode 100644 index 3ed56452..00000000 --- a/Source/CommandForm/for_AutoCommandForm/given/a_guid_form.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import { act, createRef, useLayoutEffect, useState } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { Command } from '@cratis/arc/commands'; -import { PropertyDescriptor } from '@cratis/arc/reflection'; -import { useCommandFormContext, type CommandFormContextValue, type CommandFormHandle } from '@cratis/arc.react/commands'; -import { Guid } from '@cratis/fundamentals'; -import sinon from 'sinon'; -import { AutoCommandForm, type AutoCommandFormProps } from '../../AutoCommandForm'; -import { clearFieldTypeProviders } from '../../fieldTypeProviderRegistry'; -import { registerDefaultFieldTypeProviders } from '../../defaultFieldTypeProviders'; - -// Independently authored generated-shape command; no domain or consumer fixture is involved. -export class SampleOptionalGuidCommand extends Command { - readonly route: string = '/api/sample-optional-guid'; - readonly propertyDescriptors = [ - new PropertyDescriptor('sampleId', Guid, true), - new PropertyDescriptor('name', String), - ]; - sampleId: Guid | undefined = undefined; - name = 'Sample User'; - get requestParameters(): string[] { return []; } - constructor() { super(Object, false); } -} - -export const sampleGuid = '852afc19-b630-43ba-a612-93e1c522078d'; -export const exampleGuid = '715ad639-c834-4032-b193-42eb599b73d7'; - -export const successfulResponse = () => new Response(JSON.stringify({ - correlationId: Guid.empty.toString(), - isSuccess: true, - isAuthorized: true, - isValid: true, - hasExceptions: false, - validationResults: [], - exceptionMessages: [], - exceptionStackTrace: '', - authorizationFailureReason: '', -}), { status: 200 }); - -function SampleFooter({ capture }: { capture: (context: CommandFormContextValue) => void }) { - const context = useCommandFormContext(); - useLayoutEffect(() => capture(context)); - return ; -} - -function SampleForm({ form, props, excludeGuidOnChange }: { - form: a_guid_form; - props: Partial>; - excludeGuidOnChange: boolean; -}) { - const [excludeGuid, setExcludeGuid] = useState(false); - return { form.validationFailures++; }} - footer={ { form.context = context; }} />} - {...props} - exclude={excludeGuid ? ['sampleId'] : props.exclude} - onFieldChange={(...args) => { - props.onFieldChange?.(...args); - if (excludeGuidOnChange && args[1] === 'sampleId') setExcludeGuid(true); - }} - />; -} - -/** Real React, Arc binding, command and executor; only fetch is substituted. */ -export class a_guid_form { - container!: HTMLDivElement; - root!: Root; - context!: CommandFormContextValue; - formRef = createRef(); - http!: sinon.SinonStub, ReturnType>; - validationFailures = 0; - - setup() { - (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - clearFieldTypeProviders(); - registerDefaultFieldTypeProviders(); - this.container = document.createElement('div'); - document.body.append(this.container); - this.root = createRoot(this.container); - this.http = sinon.stub(globalThis, 'fetch').callsFake(async () => successfulResponse()); - } - - async cleanup() { - await act(async () => this.root.unmount()); - this.container.remove(); - this.http.restore(); - clearFieldTypeProviders(); - registerDefaultFieldTypeProviders(); - } - - async render(props: Partial> = {}, excludeGuidOnChange = false) { - await act(async () => this.root.render()); - } - - get input() { return this.container.querySelector('input[aria-label="Sample Id"]')!; } - - async edit(value: string, input = this.input) { - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); - input.dispatchEvent(new Event('input', { bubbles: true })); - }); - } - - async submit() { - await act(async () => this.container.querySelector('button[type="submit"]')!.click()); - } -} diff --git a/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx b/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx index 2a4ec8ce..9b667dab 100644 --- a/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx +++ b/Source/CommandForm/for_AutoCommandForm/when_composing_a_footer.tsx @@ -5,26 +5,36 @@ import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import { Command } from '@cratis/arc/commands'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; import { useCommandFormContext } from '@cratis/arc.react/commands'; -import { Guid } from '@cratis/fundamentals'; import sinon from 'sinon'; import type {} from 'chai/register-should'; import { afterEach, beforeEach, describe, it } from 'vitest'; import { AutoCommandForm } from '../AutoCommandForm'; -import { SampleGuidCommand, sampleId } from './given/SampleGuidCommand'; + +// An independently authored string-only command for the footer composition contract. +class SampleCommand extends Command { + readonly route = '/api/example-command'; + readonly propertyDescriptors = [new PropertyDescriptor('name', String)]; + name = 'Sample User'; + + get requestParameters(): string[] { return []; } + + constructor() { super(Object, false); } +} function Submit() { - const { commandInstance, isExecuting } = useCommandFormContext(); + const { commandInstance, isExecuting } = useCommandFormContext(); return ; } -// No command, form, field, context or executor mocks: only the HTTP boundary is substituted. +// Real React, Arc form, context, and executor; only the HTTP boundary is substituted. describe('when composing a footer inside the automatic command form', () => { let container: HTMLDivElement; let root: Root; let http: sinon.SinonStub, ReturnType>; - let submitted: SampleGuidCommand | undefined; - let validationFailures: number; + let submitted: SampleCommand | undefined; beforeEach(() => { (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -32,9 +42,8 @@ describe('when composing a footer inside the automatic command form', () => { document.body.append(container); root = createRoot(container); submitted = undefined; - validationFailures = 0; http = sinon.stub(globalThis, 'fetch').callsFake(async () => new Response(JSON.stringify({ - correlationId: Guid.empty.toString(), + correlationId: '00000000-0000-0000-0000-000000000000', isSuccess: true, isAuthorized: true, isValid: true, @@ -52,31 +61,25 @@ describe('when composing a footer inside the automatic command form', () => { http.restore(); }); - const render = async (footer?: ReactNode, currentValues: Partial = { sampleId }) => { + const render = async (footer?: ReactNode) => { await act(async () => root.render( { submitted = command; return command; }} - onValidationFailure={() => { validationFailures++; }} />, )); }; - const submit = async () => { - await act(async () => container.querySelector('button[type="submit"]')!.click()); - }; - it('should keep the default fields-only form without a submit control', async () => { await render(); container.querySelectorAll('form').length.should.equal(1); - container.querySelectorAll('input').length.should.equal(2); + container.querySelectorAll('input').length.should.equal(1); container.querySelectorAll('button').length.should.equal(0); http.callCount.should.equal(0); }); - it('should place the footer after the fields inside the same native form', async () => { + it('should place the footer after the field inside the same native form and context', async () => { await render(); const form = container.querySelector('form')!; const input = container.querySelector('input')!; @@ -87,41 +90,19 @@ describe('when composing a footer inside the automatic command form', () => { container.querySelectorAll('form').length.should.equal(1); }); - it('should submit the typed Guid and edited string through native Command execute', async () => { + it('should submit the edited string through the native command executor', async () => { await render(); const input = container.querySelector('input[aria-label="Name"]')!; await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'Sample User Updated'); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'Example Updated'); input.dispatchEvent(new Event('input', { bubbles: true })); }); - container.querySelector('button')!.textContent!.should.equal('Submit Sample User Updated'); - await submit(); - http.callCount.should.equal(1); - submitted!.sampleId!.should.be.instanceOf(Guid); - new URL(String(http.firstCall.args[0])).pathname.should.equal('/api/sample-command'); - JSON.parse(String(http.firstCall.args[1]!.body)).should.deep.equal({ - sampleId: sampleId.toString(), name: 'Sample User Updated', - }); - }); - - it('should retain native required-value validation before reaching HTTP', async () => { - await render(, { sampleId: undefined }); - await submit(); - http.callCount.should.equal(0); - validationFailures.should.equal(1); - }); - - it('should clear and repopulate command values without replacing the native executor', async () => { - await render(); - await render(, { sampleId: undefined }); - await submit(); - http.callCount.should.equal(0); - const repopulated = Guid.parse('4b4869e3-900e-4342-9656-7ad675e43e9f'); - await render(, { sampleId: repopulated }); - await submit(); + container.querySelector('button')!.textContent!.should.equal('Submit Example Updated'); + await act(async () => container.querySelector('button[type="submit"]')!.click()); http.callCount.should.equal(1); - submitted!.sampleId!.should.be.instanceOf(Guid); - JSON.parse(String(http.firstCall.args[1]!.body)).sampleId.should.equal(repopulated.toString()); + submitted!.name.should.equal('Example Updated'); + new URL(String(http.firstCall.args[0])).pathname.should.equal('/api/example-command'); + JSON.parse(String(http.firstCall.args[1]!.body)).should.deep.equal({ name: 'Example Updated' }); }); it('should allow non-action content without adding a submit control', async () => { diff --git a/Source/CommandForm/for_AutoCommandForm/when_editing_guid_fields.tsx b/Source/CommandForm/for_AutoCommandForm/when_editing_guid_fields.tsx deleted file mode 100644 index 73678dc8..00000000 --- a/Source/CommandForm/for_AutoCommandForm/when_editing_guid_fields.tsx +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -// @vitest-environment jsdom - -import { act } from 'react'; -import { Guid } from '@cratis/fundamentals'; -import type {} from 'chai/register-should'; -import { afterEach, beforeEach, describe, it } from 'vitest'; -import { GuidField, type GuidFieldProps } from '../fields/GuidField'; -import { registerFieldTypeProvider } from '../fieldTypeProviderRegistry'; -import { SampleGuidCommand } from './given/SampleGuidCommand'; -import { a_guid_form, sampleGuid, exampleGuid, SampleOptionalGuidCommand } from './given/a_guid_form'; - -// These are field/binding gates, separate from the upstream custom-error execution regressions. -describe('when editing native Guid fields in an automatic command form', () => { - let form: a_guid_form; - beforeEach(() => { form = new a_guid_form(); form.setup(); }); - afterEach(async () => form.cleanup()); - - it('should parse text into a native Guid and serialize its value through native execute', async () => { - await form.render(); - await form.edit(sampleGuid); - form.context.commandInstance.sampleId!.should.be.instanceOf(Guid); - form.context.commandInstance.sampleId!.toString().should.equal(sampleGuid); - await form.submit(); - form.http.callCount.should.equal(1); - JSON.parse(String(form.http.firstCall.args[1]!.body)).should.deep.equal({ sampleId: sampleGuid, name: 'Sample User' }); - }); - - it('should retain partial and invalid drafts while immediately removing the old identifier', async () => { - await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); - for (const text of ['852a', 'not a Guid', ' ']) { - await form.edit(text); - form.input.value.should.equal(text); - (form.context.commandInstance.sampleId === undefined).should.equal(true); - form.context.customFieldErrors.sampleId.should.equal('Enter a valid Guid.'); - form.input.getAttribute('aria-invalid')!.should.equal('true'); - document.getElementById(form.input.getAttribute('aria-describedby')!)!.textContent!.should.equal('Enter a valid Guid.'); - } - }); - - it('should preserve an invalid draft when an unrelated field changes', async () => { - await form.render(); - await form.edit('partial'); - await form.edit('Example User', form.container.querySelector('input[aria-label="Name"]')!); - form.input.value.should.equal('partial'); - form.context.customFieldErrors.sampleId.should.equal('Enter a valid Guid.'); - }); - - it('should retain format validation when an empty custom message is supplied', async () => { - function ExampleGuidField(props: GuidFieldProps) { return ; } - registerFieldTypeProvider({ canHandle: (descriptor) => descriptor.type === Guid, component: ExampleGuidField }); - await form.render(); - await form.edit('partial'); - form.context.customFieldErrors.sampleId.should.equal('Enter a valid Guid.'); - }); - - it('should allow optional empty values without a format error or generated identifier', async () => { - await form.render(); - form.input.value.should.equal(''); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - (form.context.commandInstance.sampleId === undefined).should.equal(true); - await form.submit(); - form.http.callCount.should.equal(1); - JSON.parse(String(form.http.firstCall.args[1]!.body)).should.deep.equal({ name: 'Sample User' }); - }); - - it('should report required empty values through the actual context and native validation', async () => { - await form.render({ command: SampleGuidCommand }); - form.context.customFieldErrors.sampleId.should.equal('A value is required.'); - form.input.getAttribute('aria-required')!.should.equal('true'); - await form.submit(); - form.http.callCount.should.equal(0); - form.validationFailures.should.equal(1); - }); - - it('should clear an edited optional identifier and its format error', async () => { - await form.render(); - await form.edit(sampleGuid); - await form.edit('partial'); - await form.edit(''); - form.input.value.should.equal(''); - (form.context.commandInstance.sampleId === undefined).should.equal(true); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - await form.submit(); - JSON.parse(String(form.http.firstCall.args[1]!.body)).should.deep.equal({ name: 'Sample User' }); - }); - - it('should repopulate after invalid input and clear external values', async () => { - await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); - await form.edit('partial'); - await form.render({ currentValues: { sampleId: Guid.parse(exampleGuid) } }); - form.input.value.should.equal(exampleGuid); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - await form.render({ currentValues: { sampleId: undefined } }); - form.input.value.should.equal(''); - (form.context.commandInstance.sampleId === undefined).should.equal(true); - }); - - it('should clear an invalid draft when the external overlay changes to empty', async () => { - await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); - await form.edit('partial'); - await form.render({ currentValues: { sampleId: undefined } }); - form.input.value.should.equal(''); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - }); - - it('should keep a draft when an equivalent Guid overlay is recreated', async () => { - await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); - await form.edit('partial'); - await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); - form.input.value.should.equal('partial'); - }); - - it('should restore the baseline when the command is reverted and the form is refreshed', async () => { - await form.render({ initialValues: { sampleId: Guid.parse(sampleGuid) } }); - await form.edit('partial'); - await act(async () => { - form.context.commandInstance.revertChanges(); - form.context.setCommandValues({} as SampleOptionalGuidCommand); - }); - form.input.value.should.equal(sampleGuid); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - }); - - it('should discard a same-empty-value draft when the explicit reset key changes', async () => { - let resetKey = 0; - function ExampleGuidField(props: GuidFieldProps) { return ; } - registerFieldTypeProvider({ canHandle: (descriptor) => descriptor.type === Guid, component: ExampleGuidField }); - await form.render(); - await form.edit('partial'); - resetKey++; - await form.render(); - form.input.value.should.equal(''); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - }); - - it('should accept zero and unrestricted version bits without an implicit constraint', async () => { - await form.render(); - for (const text of [Guid.empty.toString(), 'ABCDEF01-2345-F678-FFFF-0123456789AB']) { - await form.edit(text); - form.context.commandInstance.sampleId!.should.be.instanceOf(Guid); - form.context.commandInstance.sampleId!.toString().should.equal(text.toLowerCase()); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - } - }); - - it('should let a later provider override Guid presentation while retaining its native binding', async () => { - function ExampleGuidField(props: GuidFieldProps) { return ; } - registerFieldTypeProvider({ canHandle: (descriptor) => descriptor.type === Guid, component: ExampleGuidField }); - await form.render(); - form.input.placeholder.should.equal('Example identifier'); - await form.edit(exampleGuid); - await form.submit(); - JSON.parse(String(form.http.firstCall.args[1]!.body)).sampleId.should.equal(exampleGuid); - }); - - it('should clean up owned validation errors when the Guid field is excluded and unmounted', async () => { - await form.render(); - await form.edit('partial'); - await form.render({ exclude: ['sampleId'] }); - (form.input === null).should.equal(true); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - await form.submit(); - form.http.callCount.should.equal(1); - }); - - it('should remove an error published by the edit that excludes the optional Guid field', async () => { - await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }, true); - await form.edit('partial'); - (form.input === null).should.equal(true); - (form.context.commandInstance.sampleId === undefined).should.equal(true); - (form.context.customFieldErrors.sampleId === undefined).should.equal(true); - await form.submit(); - form.http.callCount.should.equal(1); - JSON.parse(String(form.http.firstCall.args[1]!.body)).should.deep.equal({ name: 'Sample User' }); - }); - - it('should preserve a caller error when a valid edit excludes the optional Guid field', async () => { - await form.render({ onFieldValidate: () => 'Example validation message' }, true); - await form.edit(exampleGuid); - (form.input === null).should.equal(true); - form.context.customFieldErrors.sampleId.should.equal('Example validation message'); - }); - - it('should preserve a caller replacement made in the same batch as exclusion', async () => { - await form.render(); - await form.edit('partial'); - await act(async () => { - form.context.setCustomFieldError('sampleId', 'Example replacement message'); - await form.render({ exclude: ['sampleId'] }); - }); - (form.input === null).should.equal(true); - form.context.customFieldErrors.sampleId.should.equal('Example replacement message'); - }); - - it('should preserve a caller replacement after publication and before any field rerender', async () => { - await form.render({}, true); - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(form.input, 'partial'); - form.input.dispatchEvent(new Event('input', { bubbles: true })); - form.context.setCustomFieldError('sampleId', 'Example replacement message'); - }); - (form.input === null).should.equal(true); - form.context.customFieldErrors.sampleId.should.equal('Example replacement message'); - }); - - it('should not clear caller-owned validation errors on a valid edit or unmount', async () => { - const onFieldValidate = () => 'Example validation message'; - await form.render({ onFieldValidate }); - await form.edit('partial'); - await form.edit(exampleGuid); - form.context.customFieldErrors.sampleId.should.equal('Example validation message'); - await form.render({ onFieldValidate, exclude: ['sampleId'] }); - form.context.customFieldErrors.sampleId.should.equal('Example validation message'); - }); -}); diff --git a/Source/CommandForm/for_AutoCommandForm/when_native_execution_must_veto_invalid_guid_drafts.tsx b/Source/CommandForm/for_AutoCommandForm/when_native_execution_must_veto_invalid_guid_drafts.tsx deleted file mode 100644 index 68f44eb1..00000000 --- a/Source/CommandForm/for_AutoCommandForm/when_native_execution_must_veto_invalid_guid_drafts.tsx +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -// @vitest-environment jsdom - -import { act } from 'react'; -import { Guid } from '@cratis/fundamentals'; -import type { ICommandResult } from '@cratis/arc/commands'; -import type {} from 'chai/register-should'; -import { afterEach, beforeEach, describe, it } from 'vitest'; -import { a_guid_form, sampleGuid, successfulResponse } from './given/a_guid_form'; - -// Native execution regressions for Arc React's custom-error guard, required since 22.19.1. -// Keep the real context, handle and command executor: presentation alone is not a validation gate. -describe('when native execution must veto an invalid optional Guid draft', () => { - let form: a_guid_form; - beforeEach(async () => { - form = new a_guid_form(); - form.setup(); - await form.render({ currentValues: { sampleId: Guid.parse(sampleGuid) } }); - await form.edit('partial'); - }); - afterEach(async () => form.cleanup()); - - it('should veto DOM form submit before HTTP despite the optional command value being empty', async () => { - await form.submit(); - form.http.callCount.should.equal(0); - form.validationFailures.should.equal(1); - }); - - it('should veto submit in the same event batch as an invalid edit', async () => { - await form.edit(sampleGuid); - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(form.input, 'partial'); - form.input.dispatchEvent(new Event('input', { bubbles: true })); - form.container.querySelector('button[type="submit"]')!.click(); - }); - form.http.callCount.should.equal(0); - }); - - it('should keep the veto during successive invalid edits before context rerenders', async () => { - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(form.input, 'still partial'); - form.input.dispatchEvent(new Event('input', { bubbles: true })); - form.container.querySelector('button[type="submit"]')!.click(); - }); - form.http.callCount.should.equal(0); - }); - - it('should veto execution through the actual command form context', async () => { - await act(async () => { await form.context.onExecute!(); }); - form.http.callCount.should.equal(0); - }); - - it('should veto execution through a captured native form handle', async () => { - const handle = form.formRef.current!; - await act(async () => { await handle.execute(); }); - form.http.callCount.should.equal(0); - }); - - it('should expose custom format errors in native context and handle validity', () => { - form.context.isValid.should.equal(false); - form.formRef.current!.isValid.should.equal(false); - }); - - it('should return and store a validation failure instead of a successful execution result', async () => { - let result!: ICommandResult; - await act(async () => { result = await form.formRef.current!.execute(); }); - result.isSuccess.should.equal(false); - result.isValid.should.equal(false); - result.validationResults.some((entry) => entry.members.includes('sampleId')).should.equal(true); - form.context.commandResult!.isValid.should.equal(false); - }); - - it('should retain invalid state when an older successful silent HTTP validation completes', async () => { - await form.edit(sampleGuid); - let complete!: (response: Response) => void; - form.http.callsFake(() => new Promise((resolve) => { complete = resolve; })); - const issue = form.context.beginSilentValidation(); - const pending = form.context.commandInstance.validate(); - // Native validate reaches the substituted HTTP boundary without replacing any validation method. - await act(async () => { await Promise.resolve(); }); - await form.edit('partial'); - await act(async () => { - complete(successfulResponse()); - form.context.setSilentValidationResult(await pending, issue); - }); - form.input.value.should.equal('partial'); - form.context.customFieldErrors.sampleId.should.equal('Enter a valid Guid.'); - form.context.isValid.should.equal(false); - }); -}); diff --git a/Source/CommandForm/for_fieldTypeProviderRegistry/when_resolving_the_default_providers.ts b/Source/CommandForm/for_fieldTypeProviderRegistry/when_resolving_the_default_providers.ts index 21332243..7d8334e3 100644 --- a/Source/CommandForm/for_fieldTypeProviderRegistry/when_resolving_the_default_providers.ts +++ b/Source/CommandForm/for_fieldTypeProviderRegistry/when_resolving_the_default_providers.ts @@ -1,8 +1,6 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { Guid } from '@cratis/fundamentals'; -import { GuidField } from '../fields/GuidField'; import { PropertyDescriptor } from '@cratis/arc/reflection'; import { clearFieldTypeProviders, resolveFieldTypeProvider } from '../fieldTypeProviderRegistry'; import { registerDefaultFieldTypeProviders } from '../defaultFieldTypeProviders'; @@ -17,14 +15,6 @@ describe('when resolving the default providers', () => { registerDefaultFieldTypeProviders(); }); - it('should resolve a scalar Guid property to GuidField', () => { - resolveFieldTypeProvider(new PropertyDescriptor('sampleId', Guid, true))!.component.should.equal(GuidField); - }); - - it('should not resolve an array property to GuidField', () => { - (resolveFieldTypeProvider(new PropertyDescriptor('sampleIds', Array, true)) === undefined).should.equal(true); - }); - it('should resolve a string property to InputTextField', () => { resolveFieldTypeProvider(new PropertyDescriptor('name', String, false))!.component.should.equal(InputTextField); }); diff --git a/Source/MIGRATION.md b/Source/MIGRATION.md index 0c146a6b..8a296e02 100644 --- a/Source/MIGRATION.md +++ b/Source/MIGRATION.md @@ -1,28 +1,4 @@ -# Migrate from Components 4 to 5 - -Components 5.0 narrows the Arc and Arc React peer contract from `>=20.3.1 <23` to -`>=22.19.1 <23`. Upgrade `@cratis/arc` and `@cratis/arc.react` together to matching -versions, at least 22.19.1 and below 23, and keep generated proxies aligned. Older Arc -hosts are not supported; there is no runtime version-detection fallback. - -Upgrade every installed Components-family package to the same 5.x release: Core, -ESLint, Migrator, Conformance, MUI, PrimeReact 11, and PrimeReact 10 adapters. Optional -packages remain optional. Adapter and Conformance peers are `@cratis/components >=5 <6`. -Preserve the manifest and lockfile first, then run type checks, command/form tests, -and the production build. Roll back the family and Arc changes together if necessary. - -Existing form APIs remain compatible. The additions are `GuidField`, generated Guid -field selection, and composed `AutoCommandForm` footer children. Invalid Guid drafts -use native Arc custom-field-error execution guards. There is no automatic identifier -generation, authentication/authorization change, additional API removal, or renderer -ABI change: ABI 1, `core/v1`, and `stable-presentation/v1` remain unchanged. - -No 4-to-5 source codemod is needed. Migrator 5 retains the existing 3-to-4 transforms -and supports Components 3 source and Components 4/5 recovery targets. Migrator 4 keeps -its original Components 3/4 boundary. Follow the historical staged migration below -when starting from Components 3. - -## Historical migration: Components 3 to 4 +# Migrate from Components 3 to 4 Components 4 replaces the PrimeReact-backed Components 3 foundation with Components-owned markup, styling contracts, and public types. React Aria supplies selected interaction primitives internally. The current Components 4 manifest does not declare PrimeReact, PrimeIcons, PrimeUI, or PrimeUI themes as dependencies or peers; applications retaining direct imports keep their own package and license boundaries. @@ -34,7 +10,7 @@ An application that has not migrated remains on its Components 3 package profile The adapters do not replace complete Components widgets. Core continues to own Dialog, Dropdown, DatePicker, paginator, table, focus, overlay, selection, and keyboard behavior; an adapter only presents button, icon-button, text-input, text-area, checkbox, radio, switch, progress, and surface slots. Installing an adapter neither restores PrimeReact public APIs nor transfers key handling to the adapter. -### Recommended order, stop points, and rollback +## Recommended order, stop points, and rollback 1. Preserve the current source, package manifest, and lockfile as the rollback point, then run the existing Components 3 gates. 2. With installed Core still in the Components 3 **source** window (`>=3 <4`), preview and apply the root-namespace transform. Its subpath output works on Components 3 and 4. Re-run the Components 3 gates and checkpoint that import-only change. @@ -43,7 +19,7 @@ The adapters do not replace complete Components widgets. Core continues to own D The bounded Components 4 Migrator package accepts the supported source or target window at preflight, but the order above avoids introducing Components 4-only props before Core is upgraded. A failed compatibility preflight scans and writes nothing. A transform refusal may annotate or migrate other independently safe syntax, so inspect the diff; restore the preceding checkpoint before retrying if an all-or-nothing rollback is required. -### Update dependencies +## Update dependencies Use the commands for the application's package manager. Remove only Prime packages that were installed for Components and are no longer owned by a retained direct Prime island. @@ -73,7 +49,7 @@ The package declares an Arc peer range of `>=20.3.1 <23`. Conformance and all th adapters declare the final `@cratis/components >=4 <5` peer range, so each remains bounded to the Components major whose renderer ABI and stable presentation profile it implements. -### Import from explicit subpaths +## Import from explicit subpaths The canonical rule going forward: **the package root is setup-only; every component ships from its own subpath.** @@ -158,7 +134,7 @@ Quote real paths containing spaces. Do not type angle-bracket placeholders in a The codemod scans JavaScript/JSX and TypeScript/TSX (including `.mjs`, `.cjs`, `.mts`, and `.cts`), preserves aliases and type-only imports, splits mixed setup/namespace imports, and rewrites a named `export { X } from '@cratis/components'` re-export the same way as the matching import. It reports unsupported cases without guessing: default or whole-package namespace imports, TypeScript `import = require(...)` assignments, dynamic imports, CommonJS `require(...)`, wildcard or whole-package re-exports (`export * from '@cratis/components'` / `export * as X from '@cratis/components'`), side-effect imports, and unknown symbols. Review its diagnostics, then run the consuming project's lint, build, and tests. -#### Migrate Button appearance and change callbacks +### Migrate Button appearance and change callbacks Run the root-import codemod above first. The Button and callback codemods resolve Components-owned identifiers from explicit subpaths, so the authoritative order is: @@ -234,7 +210,7 @@ npm install --save-dev "@cratis/eslint-plugin-components@$TOOLING_RANGE" See the `@cratis/eslint-plugin-components` README included with that package for the flat-config example and the other Components consumer rules. -### Keep the stylesheet entry points +## Keep the stylesheet entry points The three Cratis-owned stylesheet entries remain: @@ -250,7 +226,7 @@ import '@cratis/components/theme'; // optional baseline appearance A custom product design can omit `theme`, define the `--cratis-*` variables itself, and style stable component parts through classes or `pt`. -### Simplify the provider +## Simplify the provider The provider now owns locale and Components-specific labels. Unknown renderer keys are a type error so a migrated app cannot silently lose its theme, license, global pass-through, ripple, or z-index behavior. Remove those keys from `CratisComponentsProvider` and configure any remaining direct Prime provider independently. @@ -290,7 +266,7 @@ export const ApplicationRoot = ({ children }: { children: React.ReactNode }) => `locales` remains temporarily accepted and maps the old paginator/date labels, but new code should use `messages`. Renderer keys such as `license`, `theme`, `defaults`, `pt`, `ripple`, `unstyled`, and z-index settings are not part of this provider. -### Replace renderer presets with tokens +## Replace renderer presets with tokens Remove `styledMode()`, `CratisPreset`, and `primeReactStyles` before upgrading. Components 4 removes three renderer-specific subpaths: @@ -302,7 +278,7 @@ Remove `styledMode()`, `CratisPreset`, and `primeReactStyles` before upgrading. There is no compatibility-package replacement in Components 4. Stay on Components 3 while renderer-specific types or selectors remain. -#### Removed symbol mapping +### Removed symbol mapping | Removed Components 3 export | Components 4 action | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | @@ -347,7 +323,7 @@ Map product tokens directly in CSS: This removes the old product-token → Prime preset → Prime variable → Cratis variable translation chain. -### Removed accidental package exports +## Removed accidental package exports An audit of the package `exports` map ([#173](https://github.com/Cratis/Components/issues/173)) found implementation-only symbols that were unintentionally reachable from a public subpath — each was exported only because the owning module's barrel used a blanket `export *`, not because it was a supported contract. Components 4 stops re-exporting them from their public barrel; the underlying files keep the symbol for their own internal cross-file use, so this is a package-export change only, not a behavior change. @@ -366,7 +342,7 @@ None of these had a documented contract, and none is required by any other publi The surfaces this audit confirmed as intentional and kept public — `ToastRecord`, `getToastSnapshot`, `subscribeToToasts`, `ToastDispatch`, `EmojiMemory`, `ChatAuthorKind`, `DEFAULT_TYPE_FORMATS`, `NavigationItem`, `Json`, and `TimeMachine`'s `Properties` — are unchanged and now carry TSDoc explaining their contract and, where relevant, their extension-point role. -### Migrate pass-through configuration +## Migrate pass-through configuration The `pt` prop remains the per-part customization surface, but its values are now ordinary HTML attributes and its keys are stable Cratis names. `ptOptions` and `unstyled` remain accepted temporarily but have no effect: part attributes always merge, and Components always uses consumer-owned CSS. @@ -389,7 +365,7 @@ The `pt` prop remains the per-part customization surface, but its values are now Every meaningful element also carries `data-cratis-part`. Interactive states use attributes such as `data-selected`, `data-invalid`, `data-disabled`, `data-active`, and `data-position`. Do not target React Aria class names or internal DOM structure. -#### Common part mappings +### Common part mappings | Components 3 renderer slot | Components 4 Cratis part | | ------------------------------ | ---------------------------------------------- | @@ -408,7 +384,7 @@ Every meaningful element also carries `data-cratis-part`. Interactive states use See the published [Stable component parts](https://cratis.io/components/styling/pass-through/) reference for the documented foundation surfaces. -#### Migrate a deeply customized product +### Migrate a deeply customized product Keep the product's own tokens, Tailwind utilities, dark/high-contrast selectors, and accessibility preferences. Remove the renderer preset that translated those values into a third-party token system, then map the product values directly onto `--cratis-*`. @@ -453,7 +429,7 @@ For an existing nested Prime stepper preset, map the slots by rendered responsib The old `stepperpanel.header` wrapper has no one-to-one element. Put list-item layout on `step`, and interactive-header styling on `header`. Replace `data-p-active` selectors with `[data-cratis-part='step'][data-active='true']`. -#### Representative migration archetypes +### Representative migration archetypes For a product-owned design system, remove `styledMode`, `ProductPreset`, Prime locale types, and the PrimeUI license from the Components provider. Keep `--product-*` as the canonical tokens and map them directly to `--cratis-*`. If the product still imports Prime directly, retain a separate Prime provider, preset, dependencies, and license until those imports are removed. PrimeReact 11 receives its license directly — `` — not through the `value={{ license }}` shape used by `CratisComponentsProvider`. The provider scopes runtime context, not CSS: a Prime theme imported from JavaScript remains document-global, so keep it in the smallest host entry point that owns the island and track every retained island's owner, licensing/theme dependencies, and removal condition. Migrate custom filters from `registerMatcher` to `registerDataTableFilterMatcher`, use the returned `matchMode` in each constraint, and use `resolveDataTableFilterMatcher` when a test or application-owned adapter must verify the live registered predicate. @@ -496,7 +472,7 @@ Pass the parts to either query-backed table: `Dropdown.inputId` and `Dropdown.panelClassName` remain migration aliases for `id` and `pt.popover.className`, but new code should use the current names. -### Update DatePicker integration +## Update DatePicker integration `DatePickerInput` still accepts and emits `Date | null`, but its internal value uses `@internationalized/date`. Formatting now follows the active locale and calendar rather than a PrimeReact mask. @@ -507,7 +483,7 @@ Pass the parts to either query-backed table: - `todayLabel` and `clearLabel` override the provider messages for one picker. - `showTime` and `hourFormat` remain in the current API. -### Update Dropdown styling and semantics +## Update Dropdown styling and semantics `Dropdown` preserves the `value`, `options`, `optionLabel`, `optionValue`, filtering, clear, and change-event model. Single selects now follow the WAI-ARIA button/listbox pattern; filtered selects use a combobox. @@ -515,11 +491,11 @@ Do not assume every Dropdown trigger has `role="combobox"`. Query it by its acce Multiple selection uses a native multiple-select when filtering is off and an accessible multi-value combobox when `filter` is enabled. Prefer a dedicated collection picker for a large or highly customized multi-select experience. -### Update Tooltip triggers +## Update Tooltip triggers `Tooltip` now enhances one actual React-element trigger so focus, hover, and `aria-describedby` stay together. Wrap text, fragments, multiple siblings, or conditional content in one appropriate native control. `className` is merged onto that trigger instead of an extra wrapper. -### Update tables +## Update tables `DataTableCore` now renders semantic HTML. Query-backed paging remains owned by Arc. @@ -535,13 +511,13 @@ Common built-in Prime match-mode string values remain compatible because Compone Separate `RadioButtonField` options bound to one property now require the same explicit `name` prop so native arrow-key radio-group navigation works. `RadioGroupField` and `RatingField` generate a shared internal name automatically. -### Update dialogs and steppers +## Update dialogs and steppers Dialog callback, busy, validity, dismissal, and initial-focus contracts remain. The modal/focus implementation is now React Aria-based. Stepper parts are Cratis-owned: `root`, `list`, `step`, `header`, `number`, `title`, `separator`, `panels`, and `panel`. Custom CSS that targeted Prime stepper classes or roles must move to those parts. -### Update notifications +## Update notifications The imperative API remains: @@ -556,7 +532,7 @@ toast.success({ The queue, promise lifecycle, dispatch substitution, timeout pause, focus behavior, frames, and region are Cratis-owned. Toast part keys are `region`, `toast`, `icon`, `content`, `title`, `description`, `action`, and `close`. -### Replace direct Prime imports +## Replace direct Prime imports Components cannot remove PrimeUI licensing from an application that still imports Prime directly. Replace those imports with Components, native HTML, or application-owned primitives. @@ -574,7 +550,7 @@ Typical replacements: Complete PrimeIcons class strings remain usable where a component accepts `Icon`, but Components no longer installs the font or adds a missing base class. Consumers that retain it must load its stylesheet and pass the complete class string. Prefer a React icon component or product-owned SVG. `DataPage.MenuItem.icon` remains a React component type rather than `Icon`. -### Verify the migration +## Verify the migration 1. Remove unused Prime dependencies and the PrimeUI license/provider configuration. 2. Import `tokens` and `styles`; choose the baseline `theme` or map product tokens. diff --git a/Source/README.md b/Source/README.md index a02011da..7a912416 100644 --- a/Source/README.md +++ b/Source/README.md @@ -17,17 +17,17 @@ interaction primitives internally; consumers do not import or style React Aria. ## Install ```bash -npm install @cratis/components@^5 +npm install @cratis/components@^4 ``` -> **Publication status:** This install example targets the owner-authorized 5.0.0 npm release. When +> **Publication status:** This install example targets the owner-authorized 4.0.0 npm release. When > reading this README from repository source before that release, verify availability with -> `npm view @cratis/components@5.0.0 version`; source contributors use the repository workspace +> `npm view @cratis/components@4.0.0 version`; source contributors use the repository workspace > instead. The current package manifest declares these peer dependencies: -- `@cratis/arc` and `@cratis/arc.react` `>=22.19.1 <23` (matching versions) +- `@cratis/arc` and `@cratis/arc.react` `>=20.3.1 <23` - `@cratis/fundamentals` `^7.10.3` - optional `pixi.js` `^8.20.0` - `react` and `react-dom` `^19.0.0` @@ -37,8 +37,8 @@ The current package manifest declares these peer dependencies: Strict installers can declare them explicitly; keep both Arc packages on the same application version: ```bash -ARC_VERSION=22.19.1 -npm install @cratis/components@^5 \ +ARC_VERSION=22.6.2 +npm install @cratis/components@^4 \ "@cratis/arc@$ARC_VERSION" "@cratis/arc.react@$ARC_VERSION" \ @cratis/fundamentals@^7.10.3 react@^19 react-dom@^19 \ reflect-metadata@0.2.2 tsyringe@4.10.0 @@ -58,7 +58,7 @@ object; runtime behavior still requires `@cratis/components.conformance`. See th [renderer-adapter documentation](https://github.com/Cratis/Components/blob/16dd95b7c894f3275b03714ffdc676d2d3505fd8/Documentation/renderers/index.md#adapter-package-metadata-schema) for the boundary and limitations. -**Historical Yarn PnP note:** older Components releases allowed `@cratis/arc.react@22.6.2`, which imported `rxjs` without declaring it. That version required a temporary, version-specific `packageExtensions` entry. Components 5 requires Arc React 22.19.1 or later and does not need that workaround; remove the old extension when upgrading. The canonical [getting-started guide](https://cratis.io/components/getting-started/) retains the historical YAML. +**Yarn PnP note:** the current `@cratis/arc.react@22.6.2` package imports `rxjs` without declaring it. Strict PnP consumers install `rxjs@7.8.2` and add a temporary `packageExtensions` entry for `@cratis/arc.react@22.6.2`; remove it when Arc publishes corrected metadata. The canonical [getting-started guide](https://cratis.io/components/getting-started/) contains the exact YAML. `pixi.js@^8.20.0` is an additional **optional** peer, required only by `Canvas` and `PivotViewer` (the Spatial capability profile — see [Import from explicit subpaths](#import-from-explicit-subpaths) below). Every other subpath needs nothing beyond the peers above: @@ -109,10 +109,10 @@ stable, nine-slot `stable-presentation/v1` primitive profile; they never replace catalog. They share the Components repository release version and are selected with the provider's `library` prop: -- `@cratis/components.mui@5.0.0` — MUI 9.x / Emotion 11.x stable presentation slots; -- `@cratis/components.primereact@5.0.0` — PrimeReact 11.x stable presentation slots, with an +- `@cratis/components.mui@4.0.0` — MUI 9.x / Emotion 11.x stable presentation slots; +- `@cratis/components.primereact@4.0.0` — PrimeReact 11.x stable presentation slots, with an application-owned outer provider and license key; -- `@cratis/components.primereact10@5.0.0` — PrimeReact 10.9.9+ stable presentation slots, with its +- `@cratis/components.primereact10@4.0.0` — PrimeReact 10.9.9+ stable presentation slots, with its separate MIT-era provider, global theme, and upstream-major boundary. Adapter-specific themes, providers, SSR setup, peers, and license boundaries remain documented by @@ -278,14 +278,6 @@ setup-only so importing the provider does not traverse optional or unrelated component graphs. [MIGRATION.md](./MIGRATION.md) contains the current namespace-to-subpath mapping and migration command for existing root imports. -## Components 4 to 5 migration - -Components 5 narrows the Arc/Arc React minimum to 22.19.1. Upgrade matching Arc -versions and every installed Components-family package together to the same 5.x -release. Existing form APIs remain compatible, with Guid fields and composed footers -added; there is no automatic ID generation, authentication change, or renderer ABI -change. See [MIGRATION.md](./MIGRATION.md) for the upgrade and rollback steps. - ## Components 3 migration The current package manifest does not declare PrimeReact as a required runtime diff --git a/Source/compat-manifest.json b/Source/compat-manifest.json index f1210d6f..89ebc244 100644 --- a/Source/compat-manifest.json +++ b/Source/compat-manifest.json @@ -30,9 +30,9 @@ ] }, "toolingCompatibility": { - "componentsCore": ">=5 <6", - "eslint": ">=5 <6", - "migrator": ">=5 <6" + "componentsCore": ">=4 <5", + "eslint": ">=4 <5", + "migrator": ">=4 <5" }, "supportWindows": { "components3": { @@ -40,21 +40,21 @@ "status": "maintenance-security-critical", "migrationRole": "source", "migrationTarget": ">=4 <5", - "tooling": ">=4 <6", + "tooling": ">=4 <5", "eolAt": null, "eolApprovedByOwners": false, "ownerDecisionPolicy": "Set and approve EOL no later than 12 months after Components 4 GA." }, "components4": { "components": ">=4 <5", - "status": "previous", + "status": "current", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", "adapterProfile": "stable-presentation/v1", "tooling": { "eslint": ">=4 <5", - "migrator": ">=4 <6" + "migrator": ">=4 <5" }, "adapters": { "@cratis/components.conformance": ">=4 <5", @@ -62,38 +62,20 @@ "@cratis/components.primereact": ">=4 <5", "@cratis/components.primereact10": ">=4 <5" } - }, - "components5": { - "components": ">=5 <6", - "status": "current", - "migrationRole": "target", - "rendererAbi": 1, - "coreProfile": "core/v1", - "adapterProfile": "stable-presentation/v1", - "tooling": { - "eslint": ">=5 <6", - "migrator": ">=5 <6" - }, - "adapters": { - "@cratis/components.conformance": ">=5 <6", - "@cratis/components.mui": ">=5 <6", - "@cratis/components.primereact": ">=5 <6", - "@cratis/components.primereact10": ">=5 <6" - } } }, "packages": [ { "name": "@cratis/components", "role": "core", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/arc": ">=22.19.1 <23", - "@cratis/arc.react": ">=22.19.1 <23", + "@cratis/arc": ">=20.3.1 <23", + "@cratis/arc.react": ">=20.3.1 <23", "@cratis/fundamentals": "^7.10.3", "pixi.js": "^8.20.0", "react": "^19.0.0", @@ -110,8 +92,8 @@ { "name": "@cratis/eslint-plugin-components", "role": "eslint", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", @@ -123,8 +105,8 @@ { "name": "@cratis/components.migrator", "role": "migrator", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", @@ -134,13 +116,13 @@ { "name": "@cratis/components.conformance", "role": "conformance", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -150,13 +132,13 @@ { "name": "@cratis/components.mui", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "@emotion/react": ">=11.5 <12", "@emotion/styled": ">=11.11 <12", "@mui/material": ">=9 <10", @@ -174,8 +156,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.5.0", "@emotion/styled": "11.11.0", @@ -188,8 +170,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", @@ -206,13 +188,13 @@ { "name": "@cratis/components.primereact", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "@primereact/core": ">=11 <12", "@primereact/ui": ">=11 <12", "@primeuix/themes": ">=3 <4", @@ -232,8 +214,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.0.0", "@primereact/headless": "11.0.0", @@ -251,8 +233,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.1.0", "@primereact/headless": "11.1.0", @@ -274,13 +256,13 @@ { "name": "@cratis/components.primereact10", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "primereact": ">=10.9.9 <11", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -294,8 +276,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@types/react": "19.0.0", "primereact": "10.9.9", @@ -306,8 +288,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@types/react": "19.2.18", "primereact": "10.9.9", diff --git a/Source/package.json b/Source/package.json index 4314aff8..da93133b 100644 --- a/Source/package.json +++ b/Source/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/components", - "version": "5.0.0", + "version": "4.0.0", "description": "React components for CQRS and event-sourced apps — command dialogs, typed forms, and query-backed data tables for Cratis Arc", "keywords": [ "react", @@ -297,8 +297,8 @@ "react-dom": "19.3.0" }, "peerDependencies": { - "@cratis/arc": ">=22.19.1 <23", - "@cratis/arc.react": ">=22.19.1 <23", + "@cratis/arc": ">=20.3.1 <23", + "@cratis/arc.react": ">=20.3.1 <23", "@cratis/fundamentals": "^7.10.3", "pixi.js": "^8.20.0", "react": "^19.0.0", diff --git a/Source/scripts/generate-parts.mjs b/Source/scripts/generate-parts.mjs index fe0ee4e4..6622145d 100644 --- a/Source/scripts/generate-parts.mjs +++ b/Source/scripts/generate-parts.mjs @@ -126,11 +126,6 @@ export const partDefinitions = { sources: ['CommandForm/fields/PasswordField.tsx'], ptKeys: ['root', 'input', 'toggle'], }, - GuidField: { - parts: ['input'], - sources: ['CommandForm/fields/GuidField.tsx'], - ptKeys: ['root'], - }, InputTextField: { parts: ['input'], sources: ['CommandForm/fields/InputTextField.tsx'], @@ -648,9 +643,6 @@ export const partStateDefinitions = { input: ['disabled', 'invalid', 'readonly'], toggle: ['disabled'], }, - GuidField: { - input: ['disabled', 'invalid', 'readonly'], - }, InputTextField: { input: ['disabled', 'invalid', 'readonly'], }, diff --git a/Source/scripts/verify-parts-manifest.mjs b/Source/scripts/verify-parts-manifest.mjs index d3b4937d..be7ab05f 100644 --- a/Source/scripts/verify-parts-manifest.mjs +++ b/Source/scripts/verify-parts-manifest.mjs @@ -638,9 +638,9 @@ function verifyPublicPtContracts() { `Public pt file inventory differs: source=[${actualFiles.join(', ')}], manifest=[${expectedFiles.join(', ')}].`, ); } - if (actualFiles.length !== 52 || declarationCount !== 55) { + if (actualFiles.length !== 51 || declarationCount !== 54) { problems.push( - `Expected the authoritative 52 pt files / 55 declarations, found ${actualFiles.length} files / ${declarationCount} declarations.`, + `Expected the authoritative 51 pt files / 54 declarations, found ${actualFiles.length} files / ${declarationCount} declarations.`, ); } return problems; @@ -874,7 +874,7 @@ function main() { 0, ); console.log( - `Parts manifest verified: ${componentCount} components, ${partCount} component parts, ${stateCount} component/part states, 52 pt files, and 55 pt declarations.`, + `Parts manifest verified: ${componentCount} components, ${partCount} component parts, ${stateCount} component/part states, 51 pt files, and 54 pt declarations.`, ); } diff --git a/Source/scripts/verify-public-types.exceptions.json b/Source/scripts/verify-public-types.exceptions.json index 7ea2a790..e08e5483 100644 --- a/Source/scripts/verify-public-types.exceptions.json +++ b/Source/scripts/verify-public-types.exceptions.json @@ -22,7 +22,7 @@ "id": "arc-react-global-jsx-namespace", "summary": "@cratis/arc.react ships a global.d.ts that declares `namespace JSX` referencing test-only ambient types (vitest/globals, chai, sinon-chai) as part of its published package instead of scoping that augmentation to its own test build. A strict external consumer that does not also install those test packages cannot resolve the bare `JSX` identifiers arc.react's dialog/command declaration files reference.", "reason": "external - @cratis/arc.react's own shipped .d.ts, not a Components declaration; Components has no `namespace JSX` augmentation of its own.", - "upstreamPackages": [{ "name": "@cratis/arc.react", "version": "22.19.1" }], + "upstreamPackages": [{ "name": "@cratis/arc.react", "version": "22.16.0" }], "resolutionModes": ["bundler"], "diagnosticCodes": ["TS2503"], "filePrefixes": ["@cratis/arc.react/"], @@ -33,8 +33,8 @@ "summary": "@cratis/arc, @cratis/arc.react, and @cratis/fundamentals publish ESM declaration files with extensionless relative specifiers (e.g. `export * from './ICommandScope'`). Node16/NodeNext module resolution requires an explicit extension on relative ECMAScript imports, so these upstream barrels fail under --moduleResolution nodenext; downstream symbols that transit through the broken barrel then read as missing (TS2305/TS2694 cascades).", "reason": "external - the extensionless specifiers live in @cratis/arc, @cratis/arc.react, and @cratis/fundamentals's own published dist, not in Components' declarations. Cratis/Components' own declaration rewrite (this PR) already eliminated Components' extensionless specifiers.", "upstreamPackages": [ - { "name": "@cratis/arc", "version": "22.19.1" }, - { "name": "@cratis/arc.react", "version": "22.19.1" }, + { "name": "@cratis/arc", "version": "22.16.0" }, + { "name": "@cratis/arc.react", "version": "22.16.0" }, { "name": "@cratis/fundamentals", "version": "7.19.2" } ], "resolutionModes": ["nodenext"], diff --git a/Source/types/parts.ts b/Source/types/parts.ts index cf50276d..8a3e3268 100644 --- a/Source/types/parts.ts +++ b/Source/types/parts.ts @@ -18,7 +18,6 @@ export const cratisParts = { NumberInputField: ['root', 'group', 'input', 'prefix', 'suffix', 'step', 'description', 'error'], ColorPickerField: ['root', 'input', 'value'], PasswordField: ['root', 'input', 'toggle'], - GuidField: ['input'], InputTextField: ['input'], TextAreaField: ['textarea'], Dropdown: ['root', 'value', 'filter', 'trigger', 'clear', 'popover', 'listbox', 'option', 'multiple', 'indicator'], @@ -178,9 +177,6 @@ export const cratisPartStates = { 'input': ['disabled', 'invalid', 'readonly'], 'toggle': ['disabled'], }, - GuidField: { - 'input': ['disabled', 'invalid', 'readonly'], - }, InputTextField: { 'input': ['disabled', 'invalid', 'readonly'], }, diff --git a/compat-manifest.json b/compat-manifest.json index f1210d6f..89ebc244 100644 --- a/compat-manifest.json +++ b/compat-manifest.json @@ -30,9 +30,9 @@ ] }, "toolingCompatibility": { - "componentsCore": ">=5 <6", - "eslint": ">=5 <6", - "migrator": ">=5 <6" + "componentsCore": ">=4 <5", + "eslint": ">=4 <5", + "migrator": ">=4 <5" }, "supportWindows": { "components3": { @@ -40,21 +40,21 @@ "status": "maintenance-security-critical", "migrationRole": "source", "migrationTarget": ">=4 <5", - "tooling": ">=4 <6", + "tooling": ">=4 <5", "eolAt": null, "eolApprovedByOwners": false, "ownerDecisionPolicy": "Set and approve EOL no later than 12 months after Components 4 GA." }, "components4": { "components": ">=4 <5", - "status": "previous", + "status": "current", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", "adapterProfile": "stable-presentation/v1", "tooling": { "eslint": ">=4 <5", - "migrator": ">=4 <6" + "migrator": ">=4 <5" }, "adapters": { "@cratis/components.conformance": ">=4 <5", @@ -62,38 +62,20 @@ "@cratis/components.primereact": ">=4 <5", "@cratis/components.primereact10": ">=4 <5" } - }, - "components5": { - "components": ">=5 <6", - "status": "current", - "migrationRole": "target", - "rendererAbi": 1, - "coreProfile": "core/v1", - "adapterProfile": "stable-presentation/v1", - "tooling": { - "eslint": ">=5 <6", - "migrator": ">=5 <6" - }, - "adapters": { - "@cratis/components.conformance": ">=5 <6", - "@cratis/components.mui": ">=5 <6", - "@cratis/components.primereact": ">=5 <6", - "@cratis/components.primereact10": ">=5 <6" - } } }, "packages": [ { "name": "@cratis/components", "role": "core", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/arc": ">=22.19.1 <23", - "@cratis/arc.react": ">=22.19.1 <23", + "@cratis/arc": ">=20.3.1 <23", + "@cratis/arc.react": ">=20.3.1 <23", "@cratis/fundamentals": "^7.10.3", "pixi.js": "^8.20.0", "react": "^19.0.0", @@ -110,8 +92,8 @@ { "name": "@cratis/eslint-plugin-components", "role": "eslint", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", @@ -123,8 +105,8 @@ { "name": "@cratis/components.migrator", "role": "migrator", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", @@ -134,13 +116,13 @@ { "name": "@cratis/components.conformance", "role": "conformance", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -150,13 +132,13 @@ { "name": "@cratis/components.mui", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "@emotion/react": ">=11.5 <12", "@emotion/styled": ">=11.11 <12", "@mui/material": ">=9 <10", @@ -174,8 +156,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.5.0", "@emotion/styled": "11.11.0", @@ -188,8 +170,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", @@ -206,13 +188,13 @@ { "name": "@cratis/components.primereact", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "@primereact/core": ">=11 <12", "@primereact/ui": ">=11 <12", "@primeuix/themes": ">=3 <4", @@ -232,8 +214,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.0.0", "@primereact/headless": "11.0.0", @@ -251,8 +233,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@primereact/core": "11.1.0", "@primereact/headless": "11.1.0", @@ -274,13 +256,13 @@ { "name": "@cratis/components.primereact10", "role": "renderer-adapter", - "version": "5.0.0", - "releaseMajorRange": ">=5 <6", + "version": "4.0.0", + "releaseMajorRange": ">=4 <5", "independentRelease": false, "private": false, "packageAccess": "public", "peerDependencies": { - "@cratis/components": ">=5 <6", + "@cratis/components": ">=4 <5", "primereact": ">=10.9.9 <11", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -294,8 +276,8 @@ "rendererProfile": "stable-presentation/v1", "verifiedPeers": { "minimum": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@types/react": "19.0.0", "primereact": "10.9.9", @@ -306,8 +288,8 @@ "tsyringe": "4.10.0" }, "current": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "@types/react": "19.2.18", "primereact": "10.9.9", diff --git a/package.json b/package.json index 51565696..d3caad81 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "verify-compat-manifest": "node ./scripts/generate-compat-manifest.mjs --check", "generate-release-evidence": "node ./scripts/generate-release-evidence.mjs", "test-release-evidence": "node --test ./scripts/generate-release-evidence.test.mjs", - "test-release-policy": "node --test ./scripts/generate-compat-manifest.test.mjs ./scripts/prepare-release.test.mjs", + "test-release-policy": "node --test ./scripts/generate-compat-manifest.test.mjs", "test-renderer-adapter-matrix": "node --test ./scripts/verify-renderer-adapter-matrix.test.mjs", "verify-renderer-adapters": "node ./scripts/verify-renderer-adapters.mjs", "publish-version": "node ./run-task-on-workspaces.js publish-version", @@ -52,8 +52,8 @@ "version": "0.0.0", "packageManager": "yarn@4.17.1", "devDependencies": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/arc.vite": "22.16.0", "@cyclonedx/yarn-plugin-cyclonedx": "3.4.0", "@eslint-react/eslint-plugin": "5.19.0", diff --git a/release.md b/release.md index 7589ed7a..325818bc 100644 --- a/release.md +++ b/release.md @@ -4,24 +4,6 @@ Components follows the standard Cratis label-driven release flow. A pull request with exactly one `patch`, `minor`, or `major` label triggers `.github/workflows/publish.yml`. A `no-release` label explicitly suppresses publication for maintenance changes. -## Next release: 5.0.0 - -The next release requires a **major** label and version **5.0.0**, not a 4.x minor. -The breaking change is dependency support: Core requires matching Arc and Arc React -versions in `>=22.19.1 <23`, replacing the 4.x declared `>=20.3.1 <23` contract. -All seven packages move together to 5.x; adapters and Conformance require Core `>=5 <6`. -Renderer ABI 1 and the existing profiles remain unchanged. Existing forms remain -compatible, with Guid fields and footer composition added; there is no automatic ID -generation or authentication/authorization change. See [4-to-5 migration](Documentation/Migration/4-to-5.md). - -Release validation accepts only explicitly reviewed families (historical 4 and current 5), -checks each family's truthful Arc floor and matching Core peers, and rejects publishing -this source as 4.x or 6.x. The publish runner prepares all seven versions and regenerates -the three compatibility-manifest copies **before** the first publication, preserving -bounded peer contracts. Migrator 5 retains the 3-to-4 transforms and accepts Components -3/4/5 preflight windows; historical Migrator 4 retains its original 3/4 windows. -No source metadata or this document is evidence of an actual publication. - ## Published packages One release publishes these seven public packages at the same version: @@ -47,7 +29,7 @@ whether publication is required. The npm job: 1. checks out the exact merged commit; 2. installs the committed lockfile with `yarn install --immutable`; 3. builds all public workspaces; -4. validates the reviewed release family, updates every public workspace and local non-peer workspace dependency to the release version, preserves bounded family peers, and regenerates bundled compatibility metadata; +4. updates every public workspace and local workspace dependency to the release version; 5. publishes each package publicly with npm provenance; and 6. triggers documentation and sample dependency updates. diff --git a/run-task-on-workspaces.js b/run-task-on-workspaces.js index dc948818..e678af0e 100755 --- a/run-task-on-workspaces.js +++ b/run-task-on-workspaces.js @@ -66,17 +66,28 @@ if ( process.exit(1); } const releaseVersion = isPublishing ? args[0] : undefined; -if (isPublishing) { - const prepared = spawn(process.execPath, ['scripts/prepare-release.mjs', releaseVersion], { - cwd: process.cwd(), - stdio: 'inherit', - }); - if (prepared.status !== 0) { - console.error('Release policy validation/preparation failed. Nothing was published.'); - if (prepared.error) console.error(prepared.error); - process.exit(1); +const workspaceNames = new Set(Object.keys(workspaces)); + +const saveJson = (file, value) => + fs.writeFileSync(file, `${JSON.stringify(value, null, 4)}\n`, 'utf8'); + +const preparePackageForRelease = (packageJson, version) => { + const releasePackage = structuredClone(packageJson); + releasePackage.version = version; + for (const field of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ]) { + for (const dependencyName of Object.keys(releasePackage[field] ?? {})) { + if (workspaceNames.has(dependencyName)) { + releasePackage[field][dependencyName] = version; + } + } } -} + return releasePackage; +}; console.log(`Performing '${task}' on workspaces`); if (args.length > 0) console.log(` Using args : ${args}`); @@ -96,6 +107,8 @@ for (const workspaceName in workspaces) { continue; } if (isPublishing) { + const releasePackage = preparePackageForRelease(packageJson, releaseVersion); + saveJson(packageJsonFile, releasePackage); console.log( `Publishing workspace '${workspaceName}' at '${workspaceRelativeLocation}' as ${releaseVersion}`, ); diff --git a/scripts/generate-compat-manifest.mjs b/scripts/generate-compat-manifest.mjs index 36ade9c4..08f76921 100644 --- a/scripts/generate-compat-manifest.mjs +++ b/scripts/generate-compat-manifest.mjs @@ -7,7 +7,6 @@ import process from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import semver from 'semver'; import { createFixture, validateMatrix } from './lib/renderer-adapter-matrix.mjs'; -import { releasePolicy, supportWindows, validateReleasePolicy } from '../Migrator/lib/releasePolicy.js'; const repositoryDirectory = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -19,13 +18,13 @@ const outputPaths = [ 'Migrator/compat-manifest.json', ]; const packagePolicies = new Map([ - ['@cratis/components', { role: 'core' }], - ['@cratis/eslint-plugin-components', { role: 'eslint' }], - ['@cratis/components.migrator', { role: 'migrator' }], - ['@cratis/components.conformance', { role: 'conformance' }], - ['@cratis/components.mui', { role: 'renderer-adapter' }], - ['@cratis/components.primereact', { role: 'renderer-adapter' }], - ['@cratis/components.primereact10', { role: 'renderer-adapter' }], + ['@cratis/components', { role: 'core', range: '>=4 <5' }], + ['@cratis/eslint-plugin-components', { role: 'eslint', range: '>=4 <5' }], + ['@cratis/components.migrator', { role: 'migrator', range: '>=4 <5' }], + ['@cratis/components.conformance', { role: 'conformance', range: '>=4 <5' }], + ['@cratis/components.mui', { role: 'renderer-adapter', range: '>=4 <5' }], + ['@cratis/components.primereact', { role: 'renderer-adapter', range: '>=4 <5' }], + ['@cratis/components.primereact10', { role: 'renderer-adapter', range: '>=4 <5' }], ]); const packageOrder = [...packagePolicies.keys()]; const privateEvidence = [ @@ -85,8 +84,6 @@ export function createCompatibilityManifest(rootDirectory = repositoryDirectory) }; } - const version = publicPackages[0].version; - const { range } = releasePolicy(version); const manifest = { schemaVersion: 2, releaseStatus: 'publication-authorized', @@ -102,11 +99,41 @@ export function createCompatibilityManifest(rootDirectory = repositoryDirectory) })), }, toolingCompatibility: { - componentsCore: range, - eslint: range, - migrator: range, + componentsCore: '>=4 <5', + eslint: '>=4 <5', + migrator: '>=4 <5', + }, + supportWindows: { + components3: { + components: '>=3 <4', + status: 'maintenance-security-critical', + migrationRole: 'source', + migrationTarget: '>=4 <5', + tooling: '>=4 <5', + eolAt: null, + eolApprovedByOwners: false, + ownerDecisionPolicy: + 'Set and approve EOL no later than 12 months after Components 4 GA.', + }, + components4: { + components: '>=4 <5', + status: 'current', + migrationRole: 'target', + rendererAbi: 1, + coreProfile: 'core/v1', + adapterProfile: 'stable-presentation/v1', + tooling: { + eslint: '>=4 <5', + migrator: '>=4 <5', + }, + adapters: { + '@cratis/components.conformance': '>=4 <5', + '@cratis/components.mui': '>=4 <5', + '@cratis/components.primereact': '>=4 <5', + '@cratis/components.primereact10': '>=4 <5', + }, + }, }, - supportWindows: supportWindows(version), packages: publicPackages, }; @@ -142,20 +169,19 @@ export function validateCompatibilityManifest( ({ name }) => name === '@cratis/components', )?.version; if (!repositoryVersion) fail('Core package version is missing.'); - const { range } = releasePolicy(repositoryVersion); for (const entry of manifest.packages ?? []) { const policy = packagePolicies.get(entry.name); if (!policy) fail(`Unexpected public package '${entry.name}'.`); - if (entry.role !== policy.role || entry.releaseMajorRange !== range) { + if (entry.role !== policy.role || entry.releaseMajorRange !== policy.range) { fail(`${entry.name} has invalid role or release-major policy metadata.`); } if (semver.valid(entry.version) !== entry.version) { fail(`${entry.name} must declare a valid exact current version.`); } - if (!semver.satisfies(entry.version, range)) { + if (!semver.satisfies(entry.version, policy.range)) { fail( - `${entry.name}@${entry.version} is outside supported release range '${range}'.`, + `${entry.name}@${entry.version} is outside supported release range '${policy.range}'.`, ); } if (entry.private || entry.packageAccess !== 'public') { @@ -182,7 +208,49 @@ export function validateCompatibilityManifest( } } - validateReleasePolicy(manifest, repositoryVersion); + const tooling = manifest.toolingCompatibility ?? {}; + if ( + tooling.componentsCore !== '>=4 <5' || + tooling.eslint !== '>=4 <5' || + tooling.migrator !== '>=4 <5' + ) { + fail('Core 4 tooling compatibility must remain bounded to >=4 <5.'); + } + + const components3 = manifest.supportWindows?.components3; + const components4 = manifest.supportWindows?.components4; + if ( + components3?.components !== '>=3 <4' || + components3?.status !== 'maintenance-security-critical' || + components3?.migrationTarget !== '>=4 <5' || + components3?.tooling !== '>=4 <5' || + !components3.ownerDecisionPolicy?.includes('12 months after Components 4 GA') + ) { + fail('The Components 3 maintenance and migration support window is incomplete.'); + } + if ( + components4?.components !== '>=4 <5' || + components4?.status !== 'current' || + components4?.rendererAbi !== 1 || + components4?.coreProfile !== 'core/v1' || + components4?.adapterProfile !== 'stable-presentation/v1' || + components4?.tooling?.eslint !== '>=4 <5' || + components4?.tooling?.migrator !== '>=4 <5' + ) { + fail('The Components 4 compatibility window is incomplete.'); + } + const expectedAdapterRanges = Object.fromEntries( + packageOrder + .filter((name) => + ['conformance', 'renderer-adapter'].includes( + packagePolicies.get(name).role, + ), + ) + .map((name) => [name, packagePolicies.get(name).range]), + ); + if (JSON.stringify(components4?.adapters) !== JSON.stringify(expectedAdapterRanges)) { + fail('Components 4 must list the exact Conformance and adapter release ranges.'); + } const evidence = manifest.gaScope?.privateEvidence ?? []; if ( @@ -252,7 +320,7 @@ function packageEntry(packageJson) { name: packageJson.name, role: policy.role, version: packageJson.version, - releaseMajorRange: releasePolicy(packageJson.version).range, + releaseMajorRange: policy.range, independentRelease: packageJson.cratisIndependentVersion === true, private: packageJson.private === true, packageAccess: packageJson.publishConfig?.access ?? null, diff --git a/scripts/generate-compat-manifest.test.mjs b/scripts/generate-compat-manifest.test.mjs index c163de6e..7bd26b2c 100644 --- a/scripts/generate-compat-manifest.test.mjs +++ b/scripts/generate-compat-manifest.test.mjs @@ -19,52 +19,6 @@ const repositoryDirectory = path.resolve( const createManifest = () => createCompatibilityManifest(repositoryDirectory); -const historical4 = () => { - const manifest = createManifest(); - manifest.toolingCompatibility = { componentsCore: '>=4 <5', eslint: '>=4 <5', migrator: '>=4 <5' }; - delete manifest.supportWindows.components5; - manifest.supportWindows.components3.tooling = '>=4 <5'; - manifest.supportWindows.components4.status = 'current'; - manifest.supportWindows.components4.tooling.migrator = '>=4 <5'; - for (const entry of manifest.packages) { - entry.version = '4.12.0'; - entry.releaseMajorRange = '>=4 <5'; - if (entry.peerDependencies['@cratis/components']) entry.peerDependencies['@cratis/components'] = '>=4 <5'; - for (const name of ['@cratis/arc', '@cratis/arc.react']) { - if (entry.peerDependencies[name]) entry.peerDependencies[name] = '>=20.3.1 <23'; - } - } - return manifest; -}; - -for (const [line, create] of [[4, historical4], [5, createManifest]]) { - test(`Components ${line} accepts its reviewed peer contract`, () => { - const manifest = create(); - assert.equal(manifest.packages[0].releaseMajorRange, `>=${line} <${line + 1}`); - assert.equal(manifest.packages[0].peerDependencies['@cratis/arc'], line === 4 ? '>=20.3.1 <23' : '>=22.19.1 <23'); - assert.doesNotThrow(() => validateCompatibilityManifest(manifest, { rootDirectory: null })); - }); - test(`Components ${line} rejects the other line's Arc contract`, () => { - const manifest = create(); - manifest.packages[0].peerDependencies['@cratis/arc'] = line === 4 ? '>=22.19.1 <23' : '>=20.3.1 <23'; - assert.throws(() => validateCompatibilityManifest(manifest, { rootDirectory: null }), /peer contract/); - }); - test(`Components ${line} rejects mixed adapter/Core families`, () => { - const manifest = create(); - manifest.packages.find(({ role }) => role === 'renderer-adapter').peerDependencies['@cratis/components'] = line === 4 ? '>=5 <6' : '>=4 <5'; - assert.throws(() => validateCompatibilityManifest(manifest, { rootDirectory: null }), /matching Components peer family/); - }); -} - -test('5.0.0 is accepted without changing renderer ABI or profiles', () => { - const manifest = createManifest(); - for (const entry of manifest.packages) entry.version = '5.0.0'; - assert.equal(manifest.supportWindows.components5.rendererAbi, 1); - assert.equal(manifest.supportWindows.components5.coreProfile, 'core/v1'); - assert.equal(manifest.supportWindows.components5.adapterProfile, 'stable-presentation/v1'); - assert.doesNotThrow(() => validateCompatibilityManifest(manifest)); -}); - test('generated compatibility copies are deterministic and byte-identical', () => { const serialized = serializeCompatibilityManifest(createManifest()); for (const relativePath of [ @@ -82,7 +36,7 @@ test('generated compatibility copies are deterministic and byte-identical', () = test('all public package versions move together within the repository release', () => { const manifest = createManifest(); for (const packageEntry of manifest.packages) { - packageEntry.version = '5.99.0'; + packageEntry.version = '4.99.0'; } assert.doesNotThrow(() => validateCompatibilityManifest(manifest, { rootDirectory: repositoryDirectory }), @@ -91,7 +45,7 @@ test('all public package versions move together within the repository release', const adapter = manifest.packages.find( ({ name }) => name === '@cratis/components.mui', ); - adapter.version = '5.98.0'; + adapter.version = '4.98.0'; assert.throws( () => validateCompatibilityManifest(manifest, { @@ -119,14 +73,14 @@ test('a public package cannot opt out of repository-wide versioning', () => { test('the shared repository release stays inside the Components major range', () => { const manifest = createManifest(); for (const packageEntry of manifest.packages) { - packageEntry.version = '6.0.0'; + packageEntry.version = '5.0.0'; } assert.throws( () => validateCompatibilityManifest(manifest, { rootDirectory: repositoryDirectory, }), - /outside supported release families/, + /outside supported release range/, ); }); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs deleted file mode 100644 index afb12a45..00000000 --- a/scripts/prepare-release.mjs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import semver from 'semver'; -import { createCompatibilityManifest, serializeCompatibilityManifest } from './generate-compat-manifest.mjs'; -import { releasePolicy } from '../Migrator/lib/releasePolicy.js'; - -const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - -/** Validate before any writes; prepare the entire family and bundled metadata before any publish. */ -export function prepareRelease(version, rootDirectory = repository) { - const manifest = createCompatibilityManifest(rootDirectory); - const currentVersion = manifest.packages[0].version; - if (semver.valid(version) !== version || semver.prerelease(version) || - releasePolicy(version).major !== releasePolicy(currentVersion).major) { - throw new Error(`Release ${version} must stay in the reviewed source family ${releasePolicy(currentVersion).range}.`); - } - const root = JSON.parse(fs.readFileSync(path.join(rootDirectory, 'package.json'), 'utf8')); - const paths = root.workspaces.flatMap((workspace) => workspace.endsWith('/*') - ? fs.readdirSync(path.join(rootDirectory, workspace.slice(0, -2)), { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(workspace.slice(0, -2), entry.name, 'package.json')) - : [path.join(workspace, 'package.json')]); - const packages = paths.map((relative) => ({ - file: path.join(rootDirectory, relative), - value: JSON.parse(fs.readFileSync(path.join(rootDirectory, relative), 'utf8')), - })); - const names = new Set(packages.map(({ value }) => value.name)); - for (const { file, value } of packages) { - if (value.private === true) continue; - value.version = version; - for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) { - for (const name of Object.keys(value[field] ?? {})) { - if (names.has(name)) value[field][name] = version; - } - } - // Peer family bounds are the reviewed compatibility contract, not exact release pins. - fs.writeFileSync(file, `${JSON.stringify(value, null, 4)}\n`); - } - const serialized = serializeCompatibilityManifest(createCompatibilityManifest(rootDirectory)); - for (const relative of ['compat-manifest.json', 'Source/compat-manifest.json', 'Migrator/compat-manifest.json']) { - fs.writeFileSync(path.join(rootDirectory, relative), serialized); - } -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - try { - prepareRelease(process.argv[2]); - console.log('Prepared all seven release packages and compatibility manifests.'); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - } -} diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs deleted file mode 100644 index 3900905e..00000000 --- a/scripts/prepare-release.test.mjs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import assert from 'node:assert/strict'; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test } from 'node:test'; -import { prepareRelease } from './prepare-release.mjs'; -import { createCompatibilityManifest } from './generate-compat-manifest.mjs'; -import { validateBundledManifest } from '../Migrator/lib/compatibility.js'; - -const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const output = path.join(repository, '.ai-work/manual/typed-auto-command-forms-20260922/release-fixtures'); -const files = ['package.json', 'Source/package.json', 'ESLint/package.json', 'Migrator/package.json', - 'Conformance/package.json', 'Adapters/Mui/package.json', 'Adapters/PrimeReact/package.json', - 'Adapters/PrimeReact10/package.json', 'Storybook/package.json', 'scripts/renderer-adapter-matrix.json']; - -function fixture(check) { - mkdirSync(output, { recursive: true }); - const directory = mkdtempSync(path.join(output, 'case-')); - try { - for (const relative of files) { - mkdirSync(path.dirname(path.join(directory, relative)), { recursive: true }); - copyFileSync(path.join(repository, relative), path.join(directory, relative)); - } - mkdirSync(path.join(directory, 'Conformance/for_plain_dom_renderer'), { recursive: true }); - check(directory); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -} - -test('release preparation rejects an untruthful Arc contract before changing any packages', () => fixture((directory) => { - const file = path.join(directory, 'Source/package.json'); - const core = JSON.parse(readFileSync(file, 'utf8')); - core.peerDependencies['@cratis/arc'] = '>=20.3.1 <23'; - writeFileSync(file, JSON.stringify(core)); - const before = files.map((relative) => readFileSync(path.join(directory, relative), 'utf8')); - assert.throws(() => prepareRelease('5.0.0', directory), /peer contract/); - assert.deepEqual(files.map((relative) => readFileSync(path.join(directory, relative), 'utf8')), before); -})); - -for (const version of ['5.0.0', '5.2.1']) { - test(`release preparation accepts ${version} and stamps all bundled metadata before publication`, () => fixture((directory) => { - prepareRelease(version, directory); - const manifest = createCompatibilityManifest(directory); - assert.ok(manifest.packages.every((entry) => entry.version === version)); - assert.ok(manifest.packages.filter((entry) => entry.peerDependencies['@cratis/components']) - .every((entry) => entry.peerDependencies['@cratis/components'] === '>=5 <6')); - for (const relative of ['compat-manifest.json', 'Source/compat-manifest.json', 'Migrator/compat-manifest.json']) { - const bundled = JSON.parse(readFileSync(path.join(directory, relative), 'utf8')); - assert.deepEqual(bundled, manifest); - validateBundledManifest(bundled, version); - } - })); -} - -for (const version of ['4.13.0', '6.0.0', '5.0.0-rc.1', '5.0.0+metadata', 'v5.0.0', 'invalid']) { - test(`release preparation rejects ${version} without changing package metadata`, () => fixture((directory) => { - const before = files.map((relative) => readFileSync(path.join(directory, relative), 'utf8')); - assert.throws(() => prepareRelease(version, directory), /reviewed source family|supported release families/); - assert.deepEqual(files.map((relative) => readFileSync(path.join(directory, relative), 'utf8')), before); - })); -} diff --git a/scripts/renderer-adapter-matrix.json b/scripts/renderer-adapter-matrix.json index a0bdfbc1..5fc97199 100644 --- a/scripts/renderer-adapter-matrix.json +++ b/scripts/renderer-adapter-matrix.json @@ -5,8 +5,8 @@ "yarn": "4.5.3" }, "sharedPeers": { - "@cratis/arc": "22.19.1", - "@cratis/arc.react": "22.19.1", + "@cratis/arc": "22.16.0", + "@cratis/arc.react": "22.16.0", "@cratis/fundamentals": "7.19.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.2", diff --git a/scripts/run-task-on-workspaces.test.mjs b/scripts/run-task-on-workspaces.test.mjs index ee13b840..bf959ed6 100644 --- a/scripts/run-task-on-workspaces.test.mjs +++ b/scripts/run-task-on-workspaces.test.mjs @@ -12,14 +12,11 @@ const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '. const output = path.join(repository, '.ai-work/workspace-runner-tests'); mkdirSync(output, { recursive: true }); -const runFixture = async ({ command = 'ci', exitCode = 0, outputBytes = 0, missingExecutable = false, signal = undefined, preparationFails = false } = {}) => { +const runFixture = async ({ command = 'ci', exitCode = 0, outputBytes = 0, missingExecutable = false, signal = undefined } = {}) => { const directory = mkdtempSync(path.join(output, 'case-')); try { copyFileSync(path.join(repository, 'run-task-on-workspaces.js'), path.join(directory, 'run-task-on-workspaces.js')); writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ private: true, workspaces: ['first', 'second'] })); - mkdirSync(path.join(directory, 'scripts')); - // Release-policy integration is covered by prepare-release.test.mjs; this fixture tests orchestration only. - writeFileSync(path.join(directory, 'scripts/prepare-release.mjs'), `process.exitCode = ${preparationFails ? 1 : 0};`); for (const name of ['first', 'second']) { mkdirSync(path.join(directory, name)); writeFileSync(path.join(directory, name, 'package.json'), JSON.stringify({ name, version: '1.0.0', scripts: { ci: 'synthetic fixture' } })); @@ -39,7 +36,7 @@ const runFixture = async ({ command = 'ci', exitCode = 0, outputBytes = 0, missi for (const name of ['yarn', 'npm']) writeFileSync(path.join(binaries, name), executable, { mode: 0o755 }); } const result = await new Promise((resolve, reject) => { - const child = spawn(process.execPath, ['run-task-on-workspaces.js', command, ...(command === 'publish-version' ? ['5.0.0'] : [])], { + const child = spawn(process.execPath, ['run-task-on-workspaces.js', command, ...(command === 'publish-version' ? ['4.7.0'] : [])], { cwd: directory, env: { ...process.env, PATH: binaries }, stdio: ['ignore', 'pipe', 'pipe'], @@ -58,14 +55,6 @@ const runFixture = async ({ command = 'ci', exitCode = 0, outputBytes = 0, missi } }; -test('a failed release policy prevents every publication', async () => { - const result = await runFixture({ command: 'publish-version', preparationFails: true }); - assert.notEqual(result.code, 0); - assert.match(result.stderr, /Nothing was published/); - assert.ok(!result.stdout.includes('first: stdout complete')); - assert.ok(!result.stdout.includes('second: stdout complete')); -}); - for (const command of ['ci', 'publish-version']) { test(`${command} streams output larger than the default spawnSync buffer without truncation`, { timeout: 20000 }, async () => { const bytes = 2 * 1024 * 1024; diff --git a/yarn.lock b/yarn.lock index 125aea4d..50aae7e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -273,16 +273,16 @@ __metadata: languageName: node linkType: hard -"@cratis/arc.react@npm:22.19.1": - version: 22.19.1 - resolution: "@cratis/arc.react@npm:22.19.1" +"@cratis/arc.react@npm:22.16.0": + version: 22.16.0 + resolution: "@cratis/arc.react@npm:22.16.0" dependencies: - "@cratis/arc": "npm:22.19.1" - "@cratis/fundamentals": "npm:^7.19.3" + "@cratis/arc": "npm:22.16.0" + "@cratis/fundamentals": "npm:^7.19.2" tsyringe: "npm:^4.10.0" peerDependencies: react: ^18.0.0 || ^19.0.0 - checksum: 10/b63264aa5c402bbf9e9b75ea27e5ab0d27058076d2b03dd4a033c155433a51f8bf1375d18f17770c319eebb7a39c2750cf0256a1bbafd351d17d0ca831caaeec + checksum: 10/dee24b72d3eba83500cdd801d6f577b512ef9a6aeb5ca01387fb3ae65f0a7ac5c48995d62d5f314f4e653cc58f279426647bf019c9821d0871a025cfd75e93a5 languageName: node linkType: hard @@ -308,22 +308,12 @@ __metadata: languageName: node linkType: hard -"@cratis/arc@npm:22.19.1": - version: 22.19.1 - resolution: "@cratis/arc@npm:22.19.1" - dependencies: - "@cratis/fundamentals": "npm:^7.19.3" - rxjs: "npm:^7.8.2" - checksum: 10/75f0d1b29e1d2be24ab6a733c1b1d594fca0b8f72a5d478dc3f38696dd8d3fcd6aadd6e76070615d8f85f4c54a50e565c6b36fd10ae7af0026e43d16a0146572 - languageName: node - linkType: hard - "@cratis/components.conformance@workspace:Conformance, @cratis/components.conformance@workspace:^": version: 0.0.0-use.local resolution: "@cratis/components.conformance@workspace:Conformance" dependencies: - "@cratis/arc": "npm:22.19.1" - "@cratis/arc.react": "npm:22.19.1" + "@cratis/arc": "npm:22.16.0" + "@cratis/arc.react": "npm:22.16.0" "@cratis/components": "workspace:^" "@cratis/fundamentals": "npm:7.19.2" axe-core: "npm:^4.13.0" @@ -332,7 +322,7 @@ __metadata: reflect-metadata: "npm:0.2.2" tsyringe: "npm:4.10.0" peerDependencies: - "@cratis/components": ">=5 <6" + "@cratis/components": ">=4 <5" react: ^19.0.0 react-dom: ^19.0.0 languageName: unknown @@ -355,8 +345,8 @@ __metadata: version: 0.0.0-use.local resolution: "@cratis/components.mui@workspace:Adapters/Mui" dependencies: - "@cratis/arc": "npm:22.19.1" - "@cratis/arc.react": "npm:22.19.1" + "@cratis/arc": "npm:22.16.0" + "@cratis/arc.react": "npm:22.16.0" "@cratis/components": "workspace:^" "@cratis/components.conformance": "workspace:^" "@cratis/fundamentals": "npm:7.19.2" @@ -369,7 +359,7 @@ __metadata: reflect-metadata: "npm:0.2.2" tsyringe: "npm:4.10.0" peerDependencies: - "@cratis/components": ">=5 <6" + "@cratis/components": ">=4 <5" "@emotion/react": ">=11.5 <12" "@emotion/styled": ">=11.11 <12" "@mui/material": ">=9 <10" @@ -382,8 +372,8 @@ __metadata: version: 0.0.0-use.local resolution: "@cratis/components.primereact10@workspace:Adapters/PrimeReact10" dependencies: - "@cratis/arc": "npm:22.19.1" - "@cratis/arc.react": "npm:22.19.1" + "@cratis/arc": "npm:22.16.0" + "@cratis/arc.react": "npm:22.16.0" "@cratis/components": "workspace:^" "@cratis/components.conformance": "workspace:^" "@cratis/fundamentals": "npm:7.19.2" @@ -394,7 +384,7 @@ __metadata: reflect-metadata: "npm:0.2.2" tsyringe: "npm:4.10.0" peerDependencies: - "@cratis/components": ">=5 <6" + "@cratis/components": ">=4 <5" primereact: ">=10.9.9 <11" react: ^19.0.0 react-dom: ^19.0.0 @@ -405,8 +395,8 @@ __metadata: version: 0.0.0-use.local resolution: "@cratis/components.primereact@workspace:Adapters/PrimeReact" dependencies: - "@cratis/arc": "npm:22.19.1" - "@cratis/arc.react": "npm:22.19.1" + "@cratis/arc": "npm:22.16.0" + "@cratis/arc.react": "npm:22.16.0" "@cratis/components": "workspace:^" "@cratis/components.conformance": "workspace:^" "@cratis/fundamentals": "npm:7.19.2" @@ -420,7 +410,7 @@ __metadata: reflect-metadata: "npm:0.2.2" tsyringe: "npm:4.10.0" peerDependencies: - "@cratis/components": ">=5 <6" + "@cratis/components": ">=4 <5" "@primereact/core": ">=11 <12" "@primereact/ui": ">=11 <12" "@primeuix/themes": ">=3 <4" @@ -456,8 +446,8 @@ __metadata: react-icons: "npm:5.7.0" ts-deepmerge: "npm:8.0.0" peerDependencies: - "@cratis/arc": ">=22.19.1 <23" - "@cratis/arc.react": ">=22.19.1 <23" + "@cratis/arc": ">=20.3.1 <23" + "@cratis/arc.react": ">=20.3.1 <23" "@cratis/fundamentals": ^7.10.3 pixi.js: ^8.20.0 react: ^19.0.0 @@ -485,13 +475,6 @@ __metadata: languageName: node linkType: hard -"@cratis/fundamentals@npm:^7.19.3": - version: 7.19.3 - resolution: "@cratis/fundamentals@npm:7.19.3" - checksum: 10/417769b0db8c88ca633b98d1264e96529e55111c889cf970be8fa37e8465946b9ec1fb3be6712f6315fba8a2c64f6679d7c8238b696360715ca1c85fdfbd5321 - languageName: node - linkType: hard - "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -6915,8 +6898,8 @@ __metadata: version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." dependencies: - "@cratis/arc": "npm:22.19.1" - "@cratis/arc.react": "npm:22.19.1" + "@cratis/arc": "npm:22.16.0" + "@cratis/arc.react": "npm:22.16.0" "@cratis/arc.vite": "npm:22.16.0" "@cyclonedx/yarn-plugin-cyclonedx": "npm:3.4.0" "@eslint-react/eslint-plugin": "npm:5.19.0"