diff --git a/.changeset/10868-list-view-leftovers.md b/.changeset/10868-list-view-leftovers.md new file mode 100644 index 0000000000..7944c08ab0 --- /dev/null +++ b/.changeset/10868-list-view-leftovers.md @@ -0,0 +1,27 @@ +--- +'@object-ui/core': patch +'@object-ui/types': patch +--- + +fix(core): `normalizeListViewSchema`'s density fold tests an own key, not `in` (objectui#10868) + +The fold turned a legacy `densityMode` into `rowHeight` when the value was `in` +`DENSITY_MODE_TO_ROW_HEIGHT`, and `in` walks the prototype chain. So +`densityMode: 'toString'` came back with `rowHeight` set to +`Object.prototype.toString`, a function, and the key was dropped. The fold now +uses the same own-key test that `rowHeightToDensityMode` uses in the read +direction. An inherited key such as `'toString'` or `'constructor'` is now +treated like any other unrecognized density, such as `'cozy'`: nothing is +folded, `rowHeight` stays unset, and `densityMode` is kept. The three densities +`compact`, `comfortable` and `spacious` fold exactly as before. + +Such a value reaches the fold only on a render path that does not validate. +`@object-ui/types`' zod schemas already refuse it: the `list-view` node declares +`densityMode` as a three-value enum, and a named view refuses `densityMode` by +name. + +docs(types): `NamedListView`'s docblock no longer says the type is used in +`ObjectViewSchema.listViews`. Since objectui#7928 that member has been the +protocol's `ObjectListViewSchema`, by reference. The docblock now says what the +type is: exported, and the type of no member of the contract. The type itself is +unchanged. diff --git a/packages/core/src/utils/__tests__/normalize-list-view.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.test.ts index 844d082a08..6040d34de8 100644 --- a/packages/core/src/utils/__tests__/normalize-list-view.test.ts +++ b/packages/core/src/utils/__tests__/normalize-list-view.test.ts @@ -86,6 +86,35 @@ describe('normalizeListViewSchema (#2890)', () => { expect(out.densityMode).toBe('cozy'); }); + // objectui#10868 — the fold's membership test is an OWN-key test, the same + // trap `rowHeightToDensityMode` guards in the read direction: `in` walks the + // prototype chain, so `'toString'` used to fold to `Object.prototype.toString` + // (a FUNCTION in `rowHeight`) and the key was dropped. An inherited key is + // an unrecognized density like `'cozy'`, and gets exactly its treatment. + it.each(['toString', 'constructor', 'hasOwnProperty', 'valueOf', '__proto__'])( + 'does not read the inherited Object.prototype key %j as a density', + (inherited) => { + const out = normalizeListViewSchema({ densityMode: inherited }) as Record; + expect(out.rowHeight).toBeUndefined(); + expect(out.densityMode).toBe(inherited); + // With a canonical `viewType` there is nothing else to fold, so the + // input comes back by reference: the density fold did not fire. + const canonical = { viewType: 'grid', densityMode: inherited }; + expect(normalizeListViewSchema(canonical)).toBe(canonical); + }, + ); + + it('keeps the unrecognized-density CONTROL and the recognized fold beside the inherited keys', () => { + // `'cozy'` was already unfolded before the own-key test; `'compact'` is an + // own key of the table and still folds. Both hold on either side of it. + const cozy = normalizeListViewSchema({ densityMode: 'cozy' }) as Record; + expect(cozy.rowHeight).toBeUndefined(); + expect(cozy.densityMode).toBe('cozy'); + const compact = normalizeListViewSchema({ densityMode: 'compact' }) as Record; + expect(compact.rowHeight).toBe('compact'); + expect('densityMode' in compact).toBe(false); + }); + it('round-trips every density through the widening and back', () => { // The fold widens 3 values onto 5 and the renderer narrows them back, so // a folded view must render the density the author picked. diff --git a/packages/core/src/utils/normalize-list-view.ts b/packages/core/src/utils/normalize-list-view.ts index 2cb7935053..3b6a6394f7 100644 --- a/packages/core/src/utils/normalize-list-view.ts +++ b/packages/core/src/utils/normalize-list-view.ts @@ -447,7 +447,13 @@ export function normalizeListViewSchema(schema: T): T { const legacyFields = s.fields; const foldColumns = Array.isArray(legacyFields); const legacyDensity = s.densityMode; - const foldRowHeight = typeof legacyDensity === 'string' && legacyDensity in DENSITY_MODE_TO_ROW_HEIGHT; + // `hasOwnProperty`, not `in` — same trap as {@link rowHeightToDensityMode}, + // in the fold direction: `in` walks the prototype chain, so `'toString'` used + // to fold into `rowHeight` as `Object.prototype.toString`, a FUNCTION, and + // drop the key (objectui#10868). An inherited key is an unrecognized density. + const foldRowHeight = + typeof legacyDensity === 'string' && + Object.prototype.hasOwnProperty.call(DENSITY_MODE_TO_ROW_HEIGHT, legacyDensity); const legacyFilters = s.filters; const foldFilter = Array.isArray(legacyFilters); const legacyFlags = Object.keys(SHOW_FLAG_TO_USER_ACTION).filter((k) => typeof s[k] === 'boolean'); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index e1e9557d5d..ab0c2d6bfd 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1749,7 +1749,11 @@ export const ObjectView: React.FC = ({ { ...legacyViewOptions } as Record, ); - // Dev-mode warning for flat property access violations + // Dev-mode warning for flat property access violations. The place it names + // is the view's own top-level `KIND` block — the one `viewOptions[viewType]` + // reads. ⛔ Not `options.KIND`: this component reads no `options` bag since + // objectui#7928, and a named view refuses `options` by name, so that text + // sent an author to a place that is ignored or refused (objectui#10868). if (process.env.NODE_ENV === 'development') { const flatKeys = ['startDateField', 'endDateField', 'dateField', 'groupBy', 'groupField', 'locationField', 'imageField', 'dependenciesField', 'progressField', 'titleField', @@ -1758,8 +1762,8 @@ export const ObjectView: React.FC = ({ const found = flatKeys.filter(k => k in viewOptions && !(k in nestedConfig)); if (found.length > 0) { console.warn( - `[Spec Compliance] View options use flat properties ${JSON.stringify(found)}. ` + - `Move them under options.${viewType} per @objectstack/spec protocol.` + `[Spec Compliance] The view uses flat properties ${JSON.stringify(found)}. ` + + `Move them under its top-level ${viewType} block ({ ${viewType}: { ... } }) per @objectstack/spec protocol.` ); } } diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 9a34496c7c..1112f8f645 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2236,8 +2236,15 @@ export interface ViewTabBarConfig { } /** - * Named List View Definition - * Used in ObjectViewSchema.listViews for named views (e.g., "All", "My Records"). + * Named List View Definition — objectui's own hand-written named-view shape. + * + * No member of this package's contract is typed by it: since objectui#7928, + * `ObjectViewSchema.listViews` is the protocol's `ObjectListViewSchema` by + * reference (see that member), and the pins + * `_ListViewsIsNoLongerTheLocalRecord` / `_ListViewsIsTheSpecRecord` in + * `__tests__/object-view-unmirrored-keys-7779.test.ts` hold that. It stays + * exported from `@object-ui/types`; whether it is retired or narrowed follows + * objectui#7924. */ export interface NamedListView { /** View display label */