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
27 changes: 27 additions & 0 deletions .changeset/6152-resizable-columns-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@object-ui/types': minor
'@object-ui/plugin-grid': minor
---

feat(types)!: retire `object-grid`'s `resizableColumns` on both faces, and stop `ObjectGrid` reading it (objectui#6152, round 7)

**Retired (breaking).** `resizableColumns` was the legacy second spelling of `resizable` on
`object-grid`, read only when `resizable` was absent. `@objectstack/spec` 17.7.0 retired it in the
`object-grid` row (objectstack#21445), so an authored document that writes it, flat on the node or
inside its `properties` bag, was already refused by name before this change. This change retires
the rest of it, with no alias window:

- `@object-ui/types`: `ObjectGridSchema.resizableColumns` is now `?: never`, so writing it on a
typed literal is a `tsc` error. The flat zod `ObjectGridSchema` in `@object-ui/types/zod` (the
node as `ObjectGrid` reads it after the `properties` hoist, or as code composes it) refuses the
key by name at the key, where `.passthrough()` used to keep it unexamined. The refusal names
`properties.resizable`.
- `@object-ui/plugin-grid`: `ObjectGrid` reads `resizable` alone. A node that still carries
`resizableColumns` (one composed in code, past the type) now gets the default, resizable
columns, as if it had written nothing: `resizableColumns: false` no longer turns the column
resize handles off. The `object-grid` registration's `resizable` input no longer describes the
old spelling as a fallback.

Rename the key to `resizable`; on an authored node, that is `properties.resizable`.

An `object-view`'s `table.resizableColumns` is unchanged: the slot already refused it by name.
6 changes: 5 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5431,7 +5431,11 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `editable` produces no such column today. Following the same verdict is
// what makes the gated grid identical to the non-editable one.
rowActions: !!(inlineEditable && hasActions),
resizableColumns: schema.resizable ?? schema.resizableColumns ?? true,
// `resizable` alone (objectui#6152 round 7): the legacy `resizableColumns`
// spelling is retired on both faces and by `@objectstack/spec` 17.7.0, so
// it is no longer read here. (`resizableColumns` on the left is DataTable's
// own prop, a different block's key.)
resizableColumns: schema.resizable ?? true,
reorderableColumns: schema.reorderableColumns ?? false,
// [#5143] The authored key ∧ this principal's write verdict on the object.
editable: inlineEditable,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* objectui#6152 round 7 — `ObjectGrid` reads `resizable` alone.
*
* `resizableColumns` was the legacy second spelling of `resizable`, read only when
* `resizable` was absent (`schema.resizable ?? schema.resizableColumns ?? true`). It is
* retired on both faces of `@object-ui/types` and by `@objectstack/spec` 17.7.0, which
* refuses it in the `object-grid` row, so the fallback read went in the same change:
* a node that still carries the old spelling (one composed in code, past the type)
* gets the default, resizable columns, exactly as if it had written nothing.
*
* Observed in the DOM: each resizable header cell draws one `.cursor-col-resize` drag
* handle (`data-table`'s own markup, the one `columnStatePersistence.test.tsx` drags).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import type { ObjectGridSchema } from '@object-ui/types';

import { ObjectGrid } from '../ObjectGrid';
import { __clearRecordCrudVerdictCache } from '../hooks/useRecordCrudVerdicts';
import { installExplainDouble } from './explainDouble';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';

registerAllFields();

beforeEach(() => {
__clearRecordCrudVerdictCache();
installExplainDouble();
localStorage.clear();
});

const rows = [
{ id: '1', name: 'Alice', amount: 100 },
{ id: '2', name: 'Bob', amount: 200 },
];

/** Render a two-column grid and count the header resize handles it draws. */
async function resizeHandles(extra: Record<string, unknown>): Promise<number> {
// Cast through `unknown`: the retired spelling is a `tsc` error on `ObjectGridSchema`
// now, and this writes it on purpose, as a node composed past the type would.
const schema = {
type: 'object-grid',
objectName: 'test_object',
columns: [
{ field: 'name', label: 'Name' },
{ field: 'amount', label: 'Amount', type: 'number' },
],
data: { provider: 'value', items: rows },
...extra,
} as unknown as ObjectGridSchema;
const { container, unmount } = render(
<ActionProvider>
<ObjectGrid schema={schema} />
</ActionProvider>,
);
await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument());
const count = container.querySelectorAll('th .cursor-col-resize').length;
unmount();
return count;
}

describe('objectui#6152 round 7 — `ObjectGrid` no longer reads `resizableColumns`', () => {
it('CONTROL: with neither key, both columns draw a resize handle (on by default)', async () => {
expect(await resizeHandles({})).toBe(2);
});

it('LIT CONTROL: `resizable: false` turns the handles off, so the count can move', async () => {
expect(await resizeHandles({ resizable: false })).toBe(0);
});

it('`resizableColumns: false` alone is not read: the grid keeps its default handles', async () => {
expect(await resizeHandles({ resizableColumns: false })).toBe(2);
});

it('`resizable: false` still wins when the retired spelling says otherwise', async () => {
expect(await resizeHandles({ resizable: false, resizableColumns: true })).toBe(0);
});
});
5 changes: 4 additions & 1 deletion packages/plugin-grid/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> =
* turned it into a retired-key tombstone the protocol refuses by name, and
* objectui#5861 removed every renderer read of it (ADR-0049 enforce-or-remove).
* It stays off this list because the contract refuses it, not by exemption.
* `resizableColumns` followed the same way: spec 17.7.0 tombstoned it
* (objectstack#21445), and objectui#6152 round 7 removed `ObjectGrid`'s
* `resizable ?? resizableColumns` read, so `resizable` is its only spelling.
*
* ## `description`, `emptyState` and `keyboardNavigation`: the 17.6.0 keys
*
Expand Down Expand Up @@ -298,7 +301,7 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [
// ── presentation ──────────────────────────────────────────────────────────
{ name: 'rowHeight', type: 'enum', enum: ['compact', 'short', 'medium', 'tall', 'extra_tall'], description: 'Row density. An unrecognised value falls back to `compact` rather than erroring.' },
{ name: 'frozenColumns', type: 'number', description: 'How many leading columns stay pinned while the grid scrolls horizontally.' },
{ name: 'resizable', type: 'boolean', description: 'Let users drag column borders to resize. The canonical spelling — the deprecated `resizableColumns` is only read when this is absent.' },
{ name: 'resizable', type: 'boolean', description: 'Let users drag column borders to resize. On by default. The only spelling — the legacy `resizableColumns` is retired and refused by name (objectui#6152).' },
{ name: 'reorderableColumns', type: 'boolean', description: 'Let users drag columns into a different order.' },
{ name: 'showColumnTypeIcons', type: 'boolean', description: 'Show a field-type icon in each column header. Off by default — the type is usually obvious from the cell content, and the icons compete with the column labels.' },
{ name: 'rowColor', type: 'object', description: 'Rules that colour whole rows from a field value.' },
Expand Down
10 changes: 8 additions & 2 deletions packages/types/src/__tests__/object-view-slot-key-lists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,12 @@ const TABLE_WITHHELD_BY_REASON = {
* of these to it.
*/
nodeLevel: ['ariaLabel', 'disabled', 'disabledOn', 'hidden', 'hiddenOn', 'responsiveStyles', 'style', 'testId', 'visible', 'visibleOn', 'visibleWhen'],
/** The legacy alias of a relayed key: `bulkActions`, `resizable`. */
/**
* The legacy alias of a relayed key: `bulkActions`, `resizable`. `resizableColumns`
* is also a retirement tombstone on `ObjectGridSchema` itself since objectui#6152
* round 7 (`ObjectGrid` no longer reads it); the slot still refuses it with its own
* message, as it does the `unread` five.
*/
alias: ['batchActions', 'resizableColumns'],
} as const;

Expand All @@ -163,7 +168,8 @@ const TABLE_WITHHELD_KEYS: readonly string[] = Object.values(TABLE_WITHHELD_BY_R
* faces) that the slot keeps: they type nothing, and they carry the named
* refusal and its guidance onto this face too. The five the grid retired in
* objectui#11068 are in the withheld set above instead, refused by the slot's
* own message.
* own message, and so is `resizableColumns`, which the grid retired in
* objectui#6152 round 7.
*/
const TABLE_INHERITED_TOMBSTONES = ['body', 'children', 'defaultSort'] as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
* The authored `object-grid` node does not move: its bag is the spec row by
* reference, and a prop written flat on the node stays refused by name.
*
* `resizableColumns`, the eleventh key, is NOT mirrored. Its route is open on the
* card, and `zod-mirror-parity.test.ts` keeps it in `UnmirroredDeclared`.
* `resizableColumns`, the eleventh key, was NOT mirrored: its route was open on the
* card until round 7 RETIRED it. It is a member of the mirror since then, as a
* retirement tombstone that refuses every value, and `zod-mirror-parity.test.ts`
* no longer ledgers it (`unmirrored-declared-round7-6152.test.ts` pins the rest).
*/
import { describe, it, expect } from 'vitest';

Expand Down Expand Up @@ -92,8 +94,15 @@ describe('objectui#6152 round 6 — the flat `ObjectGridSchema` mirror judges te
expect(refused.every((i) => i.path[0] === key), JSON.stringify(refused)).toBe(true);
});

it('`resizableColumns` is NOT mirrored: its route is open, and the parity ledger keeps it', () => {
expect('resizableColumns' in ObjectGridSchema.shape).toBe(false);
it('`resizableColumns` was not mirrored but RETIRED (round 7): a member that refuses every value', () => {
// Flipped by objectui#6152 round 7. This read `false` while the key's route was
// open; the retirement made it a tombstone member, which is what a passthrough
// mirror needs to refuse the key instead of keeping it unexamined.
expect('resizableColumns' in ObjectGridSchema.shape).toBe(true);
for (const value of [true, false]) {
expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, resizableColumns: value })))
.toEqual([{ code: 'invalid_type', path: ['resizableColumns'] }]);
}
});

it('the members the twin takes from the spec by name are the spec\'s, by reference', () => {
Expand Down
132 changes: 132 additions & 0 deletions packages/types/src/__tests__/unmirrored-declared-round7-6152.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* objectui#6152 round 7 — `ObjectGridSchema.resizableColumns` RETIRED on both faces.
*
* `resizable` is canonical, and `resizableColumns` (its legacy second spelling, read only
* when `resizable` was absent) retires with no window. Round 6 stopped at the key: the
* authored `object-grid` node's `properties` bag is the spec's
* `ComponentPropsMap['object-grid']` row by reference (objectui#11276), and that row still
* declared the alias, so a local retirement alone would have left the bag accepting a key
* nothing read. `@objectstack/spec` 17.7.0 (objectstack#21445) retired it in the row, and
* objectui resolves 17.7.0 since objectui#11717. So this round makes the four strokes:
*
* - `resizableColumns?: never` on the interface;
* - a `retirementTombstone()` on the flat zod mirror, naming `properties.resizable`;
* - `ObjectGrid`'s `resizable ?? resizableColumns` fallback read dropped (pinned in
* `@object-ui/plugin-grid`, `ObjectGrid.resizableColumnsRetired-6152.test.tsx`);
* - the key off `zod-mirror-parity.test.ts`'s `UnmirroredDeclared` (its entry went with
* it: a tombstone is a mirrored member).
*
* The protocol block below is the precondition, re-read against the INSTALLED spec: the
* local tombstone must never refuse what the installed protocol accepts.
*/
import { describe, it, expect } from 'vitest';

import { ObjectGridPropsSchema as SpecObjectGridPropsSchema } from '@objectstack/spec/ui';
import type { ObjectGridSchema as TsObjectGridSchema } from '../objectql';
import { ObjectGridSchema, ObjectViewSchema } from '../zod/objectql.zod.js';
import { AnyComponentSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js';

type Issue = { code: string; path: PropertyKey[]; message: string };
type Parsed = { success: boolean; error?: { issues: readonly Issue[] } };
type Parse = (doc: unknown) => Parsed;

const codeAndPath = (r: Parsed) => (r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path })));

const GRID = { type: 'object-grid', objectName: 'task' };
const AUTHORED = { type: 'object-grid', properties: { objectName: 'task' } };
const VIEW = { type: 'object-view', objectName: 'task' };

const DOORS: ReadonlyArray<readonly [string, Parse]> = [
['the tolerant face', (d) => AnyComponentSchema.safeParse(d)],
['the strict face', (d) => StrictAnyComponentSchema.safeParse(d)],
['safeValidateSchema', (d) => safeValidateSchema(d)],
];

describe('objectui#6152 round 7 — the installed protocol refuses `resizableColumns` (the precondition)', () => {
it('the `object-grid` row refuses `resizableColumns` by name, and takes `resizable`', () => {
expect(codeAndPath(SpecObjectGridPropsSchema.safeParse({ resizableColumns: false })))
.toEqual([{ code: 'invalid_type', path: ['resizableColumns'] }]);
// Lit control: the canonical spelling parses on the same row.
expect(SpecObjectGridPropsSchema.safeParse({ resizable: false }).success).toBe(true);
});
});

describe('objectui#6152 round 7 — the flat `ObjectGridSchema` mirror refuses the retired spelling', () => {
it('CONTROL: the minimal node and the canonical `resizable` parse', () => {
expect(ObjectGridSchema.safeParse(GRID).success).toBe(true);
expect(ObjectGridSchema.safeParse({ ...GRID, resizable: false }).success).toBe(true);
});

it('`resizableColumns` is a MEMBER, so `.passthrough()` no longer keeps it unexamined', () => {
expect('resizableColumns' in ObjectGridSchema.shape).toBe(true);
});

it.each([true, false])('`resizableColumns: %s` is refused at the key, naming `properties.resizable`', (value) => {
const r = ObjectGridSchema.safeParse({ ...GRID, resizableColumns: value });
expect(codeAndPath(r)).toEqual([{ code: 'invalid_type', path: ['resizableColumns'] }]);
expect(r.success ? '' : r.error.issues[0].message).toContain('properties.resizable');
});
});

describe('objectui#6152 round 7 — every authored door refuses it, flat and in the bag', () => {
describe.each(DOORS)('%s', (_door, parse) => {
it('CONTROL: `properties.resizable` parses', () => {
const r = parse({ ...AUTHORED, properties: { objectName: 'task', resizable: false } });
expect(r.success, JSON.stringify(codeAndPath(r))).toBe(true);
});

it('`properties.resizableColumns` is refused at its path', () => {
expect(codeAndPath(parse({ ...AUTHORED, properties: { objectName: 'task', resizableColumns: false } })))
.toEqual([{ code: 'invalid_type', path: ['properties', 'resizableColumns'] }]);
});

it('a flat `resizableColumns` on the node is refused by name', () => {
expect(codeAndPath(parse({ ...AUTHORED, resizableColumns: false })))
.toEqual([{ code: 'invalid_type', path: ['resizableColumns'] }]);
});

it('an `object-view` `table.resizableColumns` stays refused by the slot\'s own message', () => {
// The slot overrides the grid's tombstone with its own refusal (objectui#10976),
// as it does for the five tombstones objectui#11068 retired on the grid.
const r = parse({ ...VIEW, table: { resizableColumns: false } });
expect(codeAndPath(r)).toEqual([{ code: 'invalid_type', path: ['table', 'resizableColumns'] }]);
expect(r.success ? '' : r.error!.issues[0].message).toContain('objectui#10976');
// Lit control: the slot relays `resizable`.
expect(parse({ ...VIEW, table: { resizable: false } }).success).toBe(true);
});
});

it('the `object-view` mirror\'s own slot agrees with the doors above', () => {
expect(codeAndPath(ObjectViewSchema.safeParse({ ...VIEW, table: { resizableColumns: false } })))
.toEqual([{ code: 'invalid_type', path: ['table', 'resizableColumns'] }]);
});
});

describe('objectui#6152 round 7 — the TypeScript face', () => {
it('`resizableColumns` is a `tsc` error on the interface, while `resizable` type-checks', () => {
// A real directive: this package type-checks its tests (`tsconfig.test.json`), so a
// re-declared member fails on the unused directive.
// @ts-expect-error `resizableColumns` is RETIRED (objectui#6152) — write `resizable`
const retired: TsObjectGridSchema = { type: 'object-grid', objectName: 'task', resizableColumns: false };
const live: TsObjectGridSchema = { type: 'object-grid', objectName: 'task', resizable: false };
expect([retired.type, live.resizable]).toEqual(['object-grid', false]);
});
});

/*
* Read off the member: a deletion would not compile here at all, and a re-declared
* `boolean` would read as `boolean | undefined`.
*/
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

export type assertionResizableColumnsIsRetiredOnTheInterface = [
Expect<Equal<TsObjectGridSchema['resizableColumns'], undefined>>,
Expect<Equal<TsObjectGridSchema['resizable'], boolean | undefined>>,
];
Loading
Loading