Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/27589-action-block-variant-entry-element-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/spec': patch
---

fix(spec): the 18.x migration entry for the action-block `destructive` → `danger` rename no longer says `element:button` keeps `destructive` (#27589)

Clause-②: no
Metadata-protocol: none

The D3 entry `ui-page-action-block-variant-destructive-refused` said that components drawing the Button primitive directly, "`element:button` and the like", keep `destructive`. That is false for `element:button`: `ElementButtonPropsSchema.variant` is `primary | secondary | danger | ghost | link`, so it already spells the arm `danger` and refuses `destructive` at the metadata save door and in `os validate`. An author who followed the sentence wrote a page the save door rejects. The entry's `reason`, which the upgrade guide prints, now says so, and names the two record blocks that do still take the React Button primitive's `destructive` because they forward a variant to it unchanged: `record:quick_actions` (`variant`) and a `record:alert` action (`action.variant`). The same correction is made to the docblock of the D2 conversion `page-action-block-variant-destructive-to-danger`. No schema, value or conversion behaviour changes.

**Supersedes, in the same release.** The `@objectstack/spec` 18 note for the rename says "Other components that draw the Button primitive directly (`element:button`, toasts, alerts, badges and the like) keep `destructive`". Read `element:button` out of that list: it spells the arm `danger`, as the action blocks now do.
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import {
ActionGroupPropsSchema,
ActionIconPropsSchema,
ActionMenuPropsSchema,
ElementButtonPropsSchema,
RecordAlertActionSchema,
RecordQuickActionsProps,
} from '../ui/component.zod.js';
import { ActionSchema } from '../ui/action.zod.js';
import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js';
import { applyConversions } from './apply.js';
import { ALL_CONVERSIONS } from './registry.js';
import { applyConversionsToStoredItem } from './stored.js';
Expand All @@ -33,8 +37,11 @@ import type { ConversionNotice } from './types.js';
* - the authoring funnel does NOT replay it;
* - the same seam renames an `action:bar`'s own `variant` and its members',
* though the bar has no row: it draws its members through the blocks;
* - another component's `destructive` (the Button primitive's own word) and
* every other variant are left byte-identical with no notice;
* - another component type's `variant`, even `destructive`, and every other
* variant are left byte-identical with no notice;
* - the D3 entry's text says what the rows measure: `element:button` spells
* the arm `danger` and refuses `destructive`, and only the record blocks
* that forward to the Button primitive still take its `destructive`;
* - idempotence, and copy-on-write identity for a page with nothing to do.
*/
const ID = 'page-action-block-variant-destructive-to-danger';
Expand Down Expand Up @@ -177,7 +184,7 @@ describe('page-action-block-variant-destructive-to-danger (ADR-0087 D2)', () =>
it.each([
['the canonical `danger`', { type: 'action:button', properties: { label: 'Purge', variant: 'danger' } }],
['another variant', { type: 'action:icon', properties: { icon: 'copy', variant: 'outline' } }],
["an `element:button`'s `destructive` (the Button primitive's own word)", { type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } }],
["another component type's `destructive` (an `element:button`, whose own row refuses it)", { type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } }],
["a `destructive` member on a block that has no members", { type: 'action:button', properties: { label: 'Go', actions: [{ variant: 'destructive' }] } }],
['a member that is not an object', { type: 'action:menu', properties: { actions: ['destructive'] } }],
['`actions` that is not a list', { type: 'action:group', properties: { actions: { variant: 'destructive' } } }],
Expand Down Expand Up @@ -216,3 +223,47 @@ describe('page-action-block-variant-destructive-to-danger (ADR-0087 D2)', () =>
expect(notices.filter((n) => n.conversionId === ID)).toEqual([]);
});
});

/**
* The D3 entry's `reason` is compiled into the 18.x upgrade guide, so its
* sentence about the components this rename leaves alone is pinned against
* the rows it describes (#27589). It once said `element:button` keeps
* `destructive`, a value that row refuses: an author who followed the guide
* wrote a page the save door rejects.
*/
describe('the D3 entry says what the untouched components accept (#27589)', () => {
// Read from the generated chain, the source `build-upgrade-guide.ts` prints.
const refusedEntry = MIGRATIONS_BY_MAJOR[18]!.semantic.find(
(s) => s.id === 'ui-page-action-block-variant-destructive-refused',
)!;

it('is registered on the protocol 18 step', () => {
expect(refusedEntry, 'the D3 entry is on step 18').toBeDefined();
});

it('`element:button` spells the arm `danger` and refuses `destructive`', () => {
expect(ElementButtonPropsSchema.safeParse({ label: 'Reset', variant: 'danger' }).success).toBe(true);
expect(ElementButtonPropsSchema.safeParse({ label: 'Reset', variant: 'destructive' }).success).toBe(false);
expect(refusedEntry.reason).toContain(
'`element:button` already spells it `danger` (`ElementButtonPropsSchema.variant`) and refuses `destructive`, '
+ 'so it is not touched.',
);
});

it('the record blocks that forward to the Button primitive still take its `destructive`', () => {
expect(RecordQuickActionsProps.safeParse({ actionNames: ['purge'], variant: 'destructive' }).success).toBe(true);
expect(RecordAlertActionSchema.safeParse({ actionName: 'purge', variant: 'destructive' }).success).toBe(true);
expect(refusedEntry.reason).toContain(
"`destructive` is the React Button primitive's own word; the two record blocks that forward a variant to that "
+ 'primitive unchanged, `record:quick_actions` and a `record:alert` action, still take it and are not touched '
+ 'either.',
);
});

it('no longer tells an author that `element:button` keeps `destructive`', () => {
for (const text of [refusedEntry.reason, refusedEntry.replacement, refusedEntry.acceptanceCriteria]) {
expect(text).not.toMatch(/keeps? `destructive`/);
expect(text).not.toContain('`element:button` and the like');
}
});
});
10 changes: 6 additions & 4 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11530,9 +11530,11 @@ const ACTION_CONTAINER_BLOCK_TYPES = new Set(['action:group', 'action:menu']);
* longer read that word, and would fall back to a plain button with no
* refusal anywhere to say so. Its own `variant` and its members' are renamed
* as the four blocks' are. Every other value is left alone, and so is every
* other component type: `destructive` stays the Button primitive's word on
* `element:button` and the other primitive-backed components, which this
* entry never reads. A member that is not an object, or a list that is not an
* other component type, which this entry never reads: `element:button`
* already spells the arm `danger` and refuses `destructive`, and the record
* blocks that forward a variant to the Button primitive unchanged
* (`record:quick_actions`, a `record:alert` action) keep the primitive's own
* `destructive`. A member that is not an object, or a list that is not an
* array, is left as stored. The reach is every position `mapPageComponents`
* visits — regions, slots, nested containers — the props gate's own reach.
* Idempotent by construction: a rewritten value reads `'danger'`, which this
Expand Down Expand Up @@ -11607,7 +11609,7 @@ const pageActionBlockVariantDestructiveToDanger: MetadataConversion = {
// Canonical already, and another value: untouched.
{ type: 'action:button', properties: { label: 'Archive', actionType: 'script', variant: 'danger' } },
{ type: 'action:button', properties: { label: 'Open', actionType: 'url', target: '/apps/ops', variant: 'outline' } },
// The Button primitive's own word on another component: not this entry's.
// Another component type: not this entry's, whatever it holds (its own row refuses `destructive`).
{ type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } },
// A container's trigger and one of its members: both renamed, the other member untouched.
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ export const entry: SemanticMigration = {
+ "dispatches, says `danger`; the action block rows took the Button primitive's word, `destructive`, and "
+ 'refused `danger`, so one destructive action changed spelling between a declaration and a block, and each '
+ 'block renderer translated between the two at its forward to the runner. The blocks now take `danger` and '
+ 'refuse `destructive` with the rename. Other components that draw the Button primitive directly '
+ "(`element:button` and the like) keep `destructive`, the primitive's own word, and are not touched. A page's "
+ 'refuse `destructive` with the rename. `element:button` already spells it `danger` '
+ '(`ElementButtonPropsSchema.variant`) and refuses `destructive`, so it is not touched. `destructive` is the '
+ "React Button primitive's own word; the two record blocks that forward a variant to that primitive unchanged, "
+ "`record:quick_actions` and a `record:alert` action, still take it and are not touched either. A page's "
+ '`properties` is not parsed on the metadata load path, and the next save of the page meets the '
+ 'component-props gate at the metadata save door; `os validate` reports the old value as a '
+ '`component-props-invalid` finding. The D2 conversion `page-action-block-variant-destructive-to-danger` '
Expand Down
Loading