diff --git a/.changeset/6108-sort-axis-convergence.md b/.changeset/6108-sort-axis-convergence.md new file mode 100644 index 000000000..b03122852 --- /dev/null +++ b/.changeset/6108-sort-axis-convergence.md @@ -0,0 +1,32 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/plugin-detail': patch +--- + +The last three sort-axis consumers read the platform's per-column sortability signal instead +of re-deriving it from the field's type (objectui#6108, inheriting objectstack#10235 ruling A +through objectui#5729's landed contract). ListView's toolbar sort picker and both of +RelatedList's sort entry points — the embedded table's column headers and the `data-list` +sort-button row — now go through `isPlatformSortableField`, the same spelling the grid header +adopted; their `UNMATERIALIZED_FIELD_TYPES` / `isUnmaterializedFieldType` re-derivations are +deleted. + +The re-derivation was not wrong about `formula`: the platform computes its own projection from +the same `@objectstack/spec` storage fact, which is why the drift went unnoticed across two +cards. It parts company on everything the projection encodes as ABSENCE — an unknown name, a +dotted path a caller can put in a related list's `columns`, an unprovisioned audit column — +where a type read finds no field definition, answers "sortable", and offers a control the +runtime meets with `400 INVALID_SORT`. It parts company again on any refusal that carries no +`reason: virtual-type`, and it cannot follow the platform in the other direction either: a +field the platform now DOES order by stays withheld forever on its type alone. + +Two behaviours are deliberately unchanged. The relational carve-out stays separate from the +signal — the projection answers `sortable: true` for a `lookup` because the platform can order +by the stored foreign key, while the UI withholds because that order means nothing beside a +column of names — so a relational column does not get its sort back. And ListView's picker +still lists a field the CURRENT sort already names, which is the only way to remove a sort the +server refuses outright; that exception now covers platform-refused fields, not just formulas. + +A deployment that served no `sortability` key at all is a different case from "nothing is +sortable": that branch keeps the type read as a compatibility floor, so behaviour on a backend +older than objectstack#10235 (or an inline/mock data source) is byte-identical to before. diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 775c006c6..045d0d3e6 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -48,8 +48,10 @@ import { getRecordDisplayName, getSortValue, isExpandableFieldType, + isPlatformSortableField, isUnmaterializedFieldType, mergeFilterNodes, + readObjectSortability, toFilterNode, userActionPredicates, type FilterNode, @@ -1117,6 +1119,61 @@ export const RelatedList: React.FC = ({ return pruned.slice(0, Math.max(1, maxColumns)); }, [columns, objectSchema, objectName, api, resolveFieldLabel, referenceField, relatedData, maxColumns, lookupLabels, perms]); + /** + * [#6108] The SERVED per-column sortability projection for this object — + * objectstack#10235's ruling A, consumed rather than re-derived, through + * #5729's landed spelling in `@object-ui/core`. + * + * `undefined` means the metadata response carried no `sortability` key at + * all: a backend older than the upstream change, an inline/mock data source, + * or the schema fetch not yet landed. That is NOT "nothing is sortable" — + * see the branch in `withheldFromServerSort` below. + */ + const platformSortability = readObjectSortability(objectSchema); + + /** + * [#6108] Does a server `$orderby` on this flat field name have to be + * withheld? THE one predicate behind both of this list's sort entry points — + * the embedded table's column headers and the `data-list` sort-button row. + * They never shared a derivation before, which is how the same refused sort + * stayed reachable through whichever control the other one did not cover. + * + * TWO reasons, kept separate on purpose — the same split the grid header + * makes (`ObjectGrid.withSortability`, #5729): + * + * - RELATIONAL, and deliberately NOT delegated to the platform signal. The + * projection answers `sortable: true` for a `lookup`: the platform's + * question is whether it can order by the STORED foreign key, which it + * can. Ours is whether that order means anything next to a column of + * related-record names, and it does not (objectstack#4256 settled that no + * relation join is coming). Two different questions; folding this one into + * the signal would hand every relational column its sort back. + * - PLATFORM. `isPlatformSortableField` is the contract: an entry must EXIST + * in the served projection and say `sortable: true`. Absence is a refusal + * — an unknown name, a dotted path (a caller may hand `columns` either), + * an unprovisioned audit column — never a default of `true`. + * + * Both entry points used to read `isUnmaterializedFieldType` off the field's + * TYPE instead. That agrees with the projection about `formula` — the + * platform computes its own from the same `@objectstack/spec` storage fact — + * which is exactly why the drift went unnoticed. It parts company on + * everything the projection encodes as ABSENCE, and on any verdict the + * runtime doors add later; and it cannot follow the platform when it moves. + * + * NO SIGNAL SERVED keeps the type read as a compatibility floor: behaviour + * identical to before this card, unreachable the moment a backend serves the + * signal, and meant to be deleted when the supported floor passes that + * release. + */ + const withheldFromServerSort = React.useCallback( + (field: string | undefined, fieldDef: unknown) => { + if (isExpandableFieldType(fieldDef)) return true; + if (platformSortability) return !isPlatformSortableField(platformSortability, field); + return isUnmaterializedFieldType(fieldDef); + }, + [platformSortability], + ); + /** * The same columns, with the sort affordance withheld from the ones a server * `$orderby` cannot honestly order by. @@ -1127,9 +1184,11 @@ export const RelatedList: React.FC = ({ * - a relational column stores a foreign-key id, so "sort by Owner" would * order the collection by `rec_7f3…` while the cells show names * (objectstack#4256 settled that no relation join is coming); - * - a `formula` column has no materialised column to order by at all — - * silently unordered rows under a `200` before objectstack#6994, a - * `400 INVALID_SORT` after it. + * - a column the PLATFORM will not order by — a `formula` with no + * materialised column behind it (silently unordered rows under a `200` + * before objectstack#6994, a `400 INVALID_SORT` after it), and every + * other name the served projection refuses. `withheldFromServerSort` + * above is the whole judgement; this memo only applies it. * * This is the rule the sort-button row already applied; the headers inherit it * rather than re-opening the same door. @@ -1143,11 +1202,9 @@ export const RelatedList: React.FC = ({ return effectiveColumns.map((col: any) => { const field = col.accessorKey || columnIdentity(col); const fieldDef = field ? (objectSchema?.fields as any)?.[field] : undefined; - return isExpandableFieldType(fieldDef) || isUnmaterializedFieldType(fieldDef) - ? { ...col, sortable: false } - : col; + return withheldFromServerSort(field, fieldDef) ? { ...col, sortable: false } : col; }); - }, [effectiveColumns, windowed, objectSchema]); + }, [effectiveColumns, windowed, objectSchema, withheldFromServerSort]); // A `grid`/`table` list renders a real table, whose column headers carry the // sort. `list` renders `data-list`, which has none — so it keeps the button @@ -1380,15 +1437,16 @@ export const RelatedList: React.FC = ({ // A windowed sort goes out as a server `$orderby` on the flat // field name, so a relational column would order the collection by // its stored foreign-key id while the cells show related-record - // names — sorting looks broken (objectui#3096) — and a `formula` - // column has no materialised column to order by at all, which the - // platform now refuses outright (objectstack#6994, objectui#3950). + // names — sorting looks broken (objectui#3096) — and a name the + // PLATFORM refuses to order by has nothing behind it at all + // (objectstack#6994, objectui#3950, and #6108 for reading that + // verdict off the served projection instead of the field's type). // No button rather than a button that sorts by something invisible // or that cannot be answered. The client-mode branch keeps its // button: there the sort key is the value the cell shows (see // `sortedData`). const fieldDef = (objectSchema?.fields as any)?.[field]; - if (windowed && (isExpandableFieldType(fieldDef) || isUnmaterializedFieldType(fieldDef))) { + if (windowed && withheldFromServerSort(field, fieldDef)) { return null; } const label = col.header || col.label || field; diff --git a/packages/plugin-detail/src/__tests__/RelatedList.sortabilitySignal.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.sortabilitySignal.test.tsx new file mode 100644 index 000000000..93870d51c --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.sortabilitySignal.test.tsx @@ -0,0 +1,355 @@ +/** + * 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. + */ + +/** + * [#6108] Both of a related list's sort entry points consume the platform's + * per-column sortability signal — objectstack#10235 ruling A, through #5729's + * landed spelling (`isPlatformSortableField`). They used to re-derive the same + * verdict from the field's TYPE, via `isUnmaterializedFieldType`. + * + * TWO surfaces, pinned separately and on purpose. They never shared a + * derivation before this card, which is how the same refused sort stayed + * reachable through whichever control the other one did not cover: + * + * - the embedded table's column headers (`type: 'table' | 'grid'`), read off + * the column's own `sortable` flag; + * - the sort-button row that survives for `type: 'list'` (`data-list` has no + * headers), read off which buttons are rendered. + * + * ## Why the cells below are the ones they are + * + * The deleted predicate and the contract that replaced it AGREE about + * `formula` — the platform computes its own projection from the same + * `@objectstack/spec` storage fact — which is exactly why the drift went + * unnoticed. A pin over a formula field would pass against the re-derivation + * too and prove nothing. Every cell here is an input where the two DISAGREE: + * + * - ABSENCE (`account.name`, a dotted path, and `audited_at`, an + * unprovisioned audit column). The projection's domain is "the served field + * map plus the always-provisioned `id`"; a name outside it has no platform + * sort behind it (`ObjectSortabilitySchema.fields`, spec). The contract + * withholds. The type read resolves NO field definition for either name, so + * `isUnmaterializedFieldType(undefined)` is `false` and it offers them — + * the exact family a caller-supplied `columns` prop can put on screen. + * - A NON-VIRTUAL REFUSAL (`remote_status`). `sortable: false` with no + * `reason: virtual-type` — how "any future verdict the runtime doors add" + * arrives. The contract withholds; a type read sees `text` and offers it. + * - THE PLATFORM MOVING (`rolled_total`). A `formula` the projection answers + * `sortable: true` for. The contract OFFERS it — the one cell running the + * opposite direction, and the one no type read can ever follow. + * + * Controls sit in the same render throughout: `total` (a formula both readings + * withhold), `owner` (the relational carve-out, deliberately NOT delegated to + * the signal — the projection says `sortable: true` for it), and `name` (a + * stored column that stays sortable, which also proves the list rendered). + * + * AGREEMENT over hardcoding for everything that is not a drift cell: the base + * projection comes from the platform's own `resolveObjectSortability` + * (`@objectstack/spec/api`), the resolver the REST layer serves it from. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { resolveObjectSortability } from '@objectstack/spec/api'; +import { attachObjectSortability } from '@object-ui/core'; +import { RelatedList } from '../RelatedList'; + +// Capture the schema RelatedList hands to SchemaRenderer, so the column's own +// `sortable` flag can be read without the table in the way. +const h = vi.hoisted(() => ({ schema: null as any })); +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + SchemaRenderer: (props: any) => { + h.schema = props.schema; + return null; + }, + }; +}); + +const objectSchema = { + name: 'line_item', + fields: { + name: { type: 'text', label: 'Name' }, + // Agreement control — a formula both readings withhold. + total: { type: 'formula', label: 'Total' }, + // DRIFT: refused with no `reason: virtual-type`. + remote_status: { type: 'text', label: 'Remote Status' }, + // DRIFT: a formula the platform DOES order by. + rolled_total: { type: 'formula', label: 'Rolled Total' }, + // DRIFT: present on the object, absent from the served projection. + audited_at: { type: 'datetime', label: 'Audited At' }, + // Relational carve-out — the projection answers `sortable: true` here. + owner: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, + }, +}; + +/** + * Columns as a CALLER declares them — which is where `account.name` gets in: + * a dotted path is a legal thing to put in a related list's `columns`, it + * resolves to no field definition at all, and the platform has no sort behind + * it either. + */ +const columns = [ + { accessorKey: 'name', header: 'Name' }, + { accessorKey: 'total', header: 'Total' }, + { accessorKey: 'remote_status', header: 'Remote Status' }, + { accessorKey: 'rolled_total', header: 'Rolled Total' }, + { accessorKey: 'audited_at', header: 'Audited At' }, + { accessorKey: 'account.name', header: 'Account Name' }, + { accessorKey: 'owner', header: 'Owner' }, +]; + +function servedProjection() { + const resolved = resolveObjectSortability(objectSchema) as { fields: Record }; + const fields: Record = { ...resolved.fields }; + // Sanity on the base the drift cells are measured against. + expect(fields.name).toEqual({ sortable: true }); + expect(fields.total.sortable).toBe(false); + expect(fields.owner).toEqual({ sortable: true }); + // The resolver never had an entry for the dotted path — absence, not a + // deletion. Assert that rather than assume it. + expect(fields['account.name']).toBeUndefined(); + + delete fields.audited_at; + fields.remote_status = { sortable: false }; + fields.rolled_total = { sortable: true }; + return { fields }; +} + +const items = Array.from({ length: 9 }, (_, i) => ({ + id: `li${i}`, + name: `Item ${i}`, + total: i * 100, + remote_status: 'open', + rolled_total: i, + audited_at: '2026-01-01', + // The dotted path arrives FLAT on the row, which is how an expanded related + // record reaches a related list — and why `pruneEmpty` keeps the column. + 'account.name': `Acme ${i}`, + owner: `usr_${i}`, +})); + +const makeDataSource = (opts: { servesSignal?: boolean } = {}) => ({ + getObjectSchema: vi.fn(async (api: string) => { + if (api !== 'line_item') return { name: api, fields: {} }; + const schema = JSON.parse(JSON.stringify(objectSchema)); + if (opts.servesSignal !== false) attachObjectSortability(schema, servedProjection()); + return schema; + }), + find: vi.fn(async (api: string, params: any) => { + if (api !== 'line_item') return { data: [] }; + const skip = params?.$skip ?? 0; + const top = params?.$top ?? items.length; + return { data: items.slice(skip, skip + top), total: items.length }; + }), +}); + +/** Whether the embedded table offers a sort on this column. */ +const columnSortable = (accessorKey: string) => { + const col = h.schema?.columns?.find((c: any) => c.accessorKey === accessorKey); + return col ? col.sortable !== false : undefined; +}; + +/** Windowed: no `data` prop, an `api`, a page size and a `find`. */ +function renderWindowed(type: 'table' | 'list', dsOpts = {}) { + const dataSource = makeDataSource(dsOpts); + render( + , + ); + return dataSource; +} + +/** Which sort BUTTONS the `data-list` variant rendered. */ +const sortButtonLabels = () => + screen + .getAllByRole('button') + .map((b) => b.textContent?.trim() ?? '') + .filter((label) => columns.some((c) => label.startsWith(c.header))); + +beforeEach(() => { + h.schema = null; +}); + +describe('RelatedList surface 1 — the embedded table headers (#6108)', () => { + it('offers exactly what the platform will order by, across all three drift families', async () => { + renderWindowed('table'); + // A stored column stays sortable — which also proves the table rendered. + await waitFor(() => expect(columnSortable('name')).toBe(true)); + + // DRIFT, contract withholds / type read would offer: + expect(columnSortable('audited_at')).toBe(false); // absent from projection + expect(columnSortable('account.name')).toBe(false); // dotted path, no entry + expect(columnSortable('remote_status')).toBe(false); // refusal, no virtual reason + // DRIFT, contract offers / type read would withhold: + expect(columnSortable('rolled_total')).toBe(true); + // Agreement control — still withheld. + expect(columnSortable('total')).toBe(false); + }); + + it('withholds a name the projection has no entry for — absence is a refusal, not a default', async () => { + renderWindowed('table'); + await waitFor(() => expect(columnSortable('name')).toBe(true)); + + // An unprovisioned audit column: on the object, outside the projection. + expect(columnSortable('audited_at')).toBe(false); + }); + + it('withholds a dotted path — a caller can declare one, and no field def resolves for it', async () => { + renderWindowed('table'); + await waitFor(() => expect(columnSortable('name')).toBe(true)); + + // `isUnmaterializedFieldType(undefined)` is `false`, so the deleted type + // read offered this column its header click. The projection has no entry. + expect(columnSortable('account.name')).toBe(false); + }); + + it('withholds a refusal that carries no `virtual-type` reason', async () => { + renderWindowed('table'); + await waitFor(() => expect(columnSortable('name')).toBe(true)); + + expect(columnSortable('remote_status')).toBe(false); + }); + + it('follows the platform when it moves: a formula it DOES order by keeps its header', async () => { + renderWindowed('table'); + await waitFor(() => expect(columnSortable('name')).toBe(true)); + + // The direction no type read can follow. + expect(columnSortable('rolled_total')).toBe(true); + // …while the formula the platform still refuses stays withheld — so this + // is the served verdict being read, not "formulas are back". + expect(columnSortable('total')).toBe(false); + }); + + it('keeps the relational carve-out separate from the signal', async () => { + renderWindowed('table'); + await waitFor(() => expect(columnSortable('name')).toBe(true)); + + // The projection answers `sortable: true` for `owner`: the platform CAN + // order by the stored foreign key. The UI withholds for its own reason — + // that order means nothing beside a column of related-record names. + expect(columnSortable('owner')).toBe(false); + }); + + it('falls back to the type read when the deployment served no projection', async () => { + renderWindowed('table', { servesSignal: false }); + await waitFor(() => expect(columnSortable('name')).toBe(true)); + + // The pre-#6108 verdicts, exactly: only `formula` and relational withheld. + expect(columnSortable('total')).toBe(false); + expect(columnSortable('rolled_total')).toBe(false); + expect(columnSortable('owner')).toBe(false); + expect(columnSortable('audited_at')).toBe(true); + expect(columnSortable('account.name')).toBe(true); + expect(columnSortable('remote_status')).toBe(true); + }); + + it('leaves every header live in client mode, where the key is the value the cell shows', async () => { + const dataSource = makeDataSource(); + render( + , + ); + await waitFor(() => expect(h.schema?.type).toBe('data-table')); + + // Not windowed ⇒ no server `$orderby` ⇒ the signal is not this question's + // answer. The platform's refusals must not leak into the in-memory sort. + expect(columnSortable('remote_status')).toBe(true); + expect(columnSortable('audited_at')).toBe(true); + expect(columnSortable('total')).toBe(true); + }); +}); + +describe('RelatedList surface 2 — the `data-list` sort-button row (#6108)', () => { + it('renders a button for exactly what the platform will order by', async () => { + renderWindowed('list'); + await waitFor(() => expect(h.schema?.type).toBe('data-list')); + + const labels = sortButtonLabels(); + // Controls, offered under both readings — and proof the row rendered. + expect(labels).toContain('Name'); + // DRIFT, contract offers / type read would withhold: + expect(labels).toContain('Rolled Total'); + // DRIFT, contract withholds / type read would offer: + expect(labels).not.toContain('Audited At'); + expect(labels).not.toContain('Account Name'); + expect(labels).not.toContain('Remote Status'); + // Agreement control and the relational carve-out. + expect(labels).not.toContain('Total'); + expect(labels).not.toContain('Owner'); + }); + + it('drops the button for an absent name, a dotted path, and a reasonless refusal', async () => { + renderWindowed('list'); + await waitFor(() => expect(h.schema?.type).toBe('data-list')); + + const labels = sortButtonLabels(); + // Positive control in the same render — the row exists and is populated. + expect(labels).toContain('Name'); + expect(labels).not.toContain('Audited At'); + expect(labels).not.toContain('Account Name'); + expect(labels).not.toContain('Remote Status'); + }); + + it('follows the platform when it moves: a formula it DOES order by keeps its button', async () => { + renderWindowed('list'); + await waitFor(() => expect(h.schema?.type).toBe('data-list')); + + const labels = sortButtonLabels(); + expect(labels).toContain('Rolled Total'); + expect(labels).not.toContain('Total'); + }); + + it('keeps the relational carve-out separate from the signal', async () => { + renderWindowed('list'); + await waitFor(() => expect(h.schema?.type).toBe('data-list')); + + // The projection answers `sortable: true` for `owner`; this row withholds + // for its own reason. + expect(sortButtonLabels()).not.toContain('Owner'); + }); + + it('falls back to the type read when the deployment served no projection', async () => { + renderWindowed('list', { servesSignal: false }); + await waitFor(() => expect(h.schema?.type).toBe('data-list')); + + const labels = sortButtonLabels(); + // The pre-#6108 button row, exactly. + expect(labels).toContain('Name'); + expect(labels).toContain('Audited At'); + expect(labels).toContain('Account Name'); + expect(labels).toContain('Remote Status'); + expect(labels).not.toContain('Rolled Total'); + expect(labels).not.toContain('Total'); + expect(labels).not.toContain('Owner'); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 322126feb..a4ff10e1e 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -21,7 +21,7 @@ import { useDensityMode } from '@object-ui/react'; import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; import { detectStatusField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveEffectiveCrudAffordances, isObjectInlineEditable, partitionRowsByPredicate, normalizeListViewSchema, rowHeightToDensityMode, mergeFilterNodes, columnIdentity, collectPredicateFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES } from '@object-ui/core'; +import { resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveEffectiveCrudAffordances, isObjectInlineEditable, partitionRowsByPredicate, normalizeListViewSchema, rowHeightToDensityMode, mergeFilterNodes, columnIdentity, collectPredicateFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField } from '@object-ui/core'; import { useObjectTranslation, useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -2422,7 +2422,7 @@ export const ListView = React.forwardRef(({ // sort on a field this picker then refused to list — the declared sort // worked on load, while its rows rendered blank and the user could neither // reproduce nor modify it. The whitelist is a FILTER contract; sortability - // is a property of the field's type, which is what the two rules below read. + // is a separate question, answered by the two rules below. // // This view's sort becomes a server `$orderby` on the FLAT field name, and a @@ -2435,33 +2435,46 @@ export const ListView = React.forwardRef(({ // below points at the supported alternative (a stored field that // denormalizes the name onto this object, written when the source changes). // - // Second rule — `UNMATERIALIZED_FIELD_TYPES` (`@object-ui/core`, bound to the - // spec's own storage fact): a `formula` field has no materialised column, so - // the server answers a sort naming one with a 400. It matters here precisely - // BECAUSE the base set widened: a formula field used to reach this picker only - // if someone had whitelisted it, and now every formula field on the object - // would be offered. Withheld silently — the relational hint below stays - // strictly about relations, which is what its sentence describes. + // Second rule — THE PLATFORM SAYS which field names it will order by + // (objectstack#10235 ruling A, consumed here via #5729's landed spelling in + // `@object-ui/core`). `isPlatformSortableField` is the contract: an entry + // must EXIST in the served projection and say `sortable: true`. Absence is a + // refusal — an unknown name, a dotted path, an unprovisioned audit column — + // never a default of `true`. Withheld silently, so the relational hint below + // stays strictly about relations, which is what its sentence describes. // - // The set used to be a private copy in this file, on the reasoning that it was - // one sortability rule for one picker. objectui#3950 made it three more - // consumers (this grid's own column headers, and both of RelatedList's sort - // entry points), so it moved to core where the relational family already - // lives — one judgement, not four copies drifting apart. + // This picker used to re-derive that verdict from the field's TYPE, reading + // `UNMATERIALIZED_FIELD_TYPES` (#3950 consolidated the local copy into core). + // The two agree about `formula` — the platform computes its own projection + // from the same `@objectstack/spec` storage fact — which is exactly why the + // drift went unnoticed: they part company on everything the projection + // encodes as ABSENCE, and on any verdict the runtime doors add later, where + // a type read answers `sortable` and the platform answers `400 INVALID_SORT`. + // One judgement, served; not a fourth copy of it drifting apart. + // + // NO SIGNAL SERVED (`undefined`) is a different question from "nothing is + // sortable": a deployment older than objectstack#10235, an inline/mock data + // source, or `objectDef` not yet loaded. That branch keeps the type read as a + // compatibility floor — behaviour identical to before this card — and is + // meant to be deleted when the supported floor passes that release. // // Exception (both rules): a field the CURRENT sort already uses stays listed // — relational ones flagged as ordering by ID — so opening this popover on a // view that was authored (or saved before this change) with such a sort // neither renders a blank row nor silently drops that sort on the next edit. - // For a formula field that exception is the only way to REMOVE the offending - // row, since the sort it names is one the server refuses outright. + // For a platform-refused field that exception is the only way to REMOVE the + // offending row, since the sort it names is one the server refuses outright. const { sortFields, sortHasRelationalField } = React.useMemo(() => { + const platformSortability = readObjectSortability(objectDef); const inUse = new Set(currentSort.map((item) => item.field).filter(Boolean)); let excluded = false; const fields: Array<{ value: string; label: string }> = []; for (const field of candidateFields) { const relational = EXPANDABLE_FIELD_TYPES.has(field.type); - if (!relational && !UNMATERIALIZED_FIELD_TYPES.has(field.type)) { + const platformSortable = platformSortability + ? isPlatformSortableField(platformSortability, field.value) + : !UNMATERIALIZED_FIELD_TYPES.has(field.type); + if (!relational && platformSortable) { fields.push({ value: field.value, label: field.label }); continue; } @@ -2475,7 +2488,7 @@ export const ListView = React.forwardRef(({ if (relational) excluded = true; } return { sortFields: fields, sortHasRelationalField: excluded }; - }, [candidateFields, currentSort, t]); + }, [candidateFields, currentSort, t, objectDef]); /** * A column-header sort from the child grid (#3106). diff --git a/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx b/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx new file mode 100644 index 000000000..4758de0d0 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.sortabilitySignal.test.tsx @@ -0,0 +1,256 @@ +/** + * 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. + */ + +/** + * [#6108] The toolbar sort picker consumes the platform's per-column + * sortability signal — objectstack#10235 ruling A, through #5729's landed + * spelling (`isPlatformSortableField`). It used to re-derive the same verdict + * from the field's TYPE, via `UNMATERIALIZED_FIELD_TYPES`. + * + * ## Why the cells below are the ones they are + * + * The predicate that was deleted and the contract that replaced it AGREE about + * `formula` — the platform computes its own projection from the same + * `@objectstack/spec` storage fact — and that agreement is exactly why the + * drift went unnoticed for two cards. So a pin over a formula field would pass + * against the re-derivation too and prove nothing at all. + * + * Every cell here is therefore an input where the two DISAGREE, and this file + * names each one: + * + * - ABSENCE (`audited_at`). The projection's domain is "the served field map + * plus the always-provisioned `id`"; a name absent from it — an unknown + * field, a dotted path, an unprovisioned audit column — has no platform + * sort behind it (`ObjectSortabilitySchema.fields`, spec). The contract + * withholds. A type read sees `datetime`, not `formula`, and offers it. + * - A NON-VIRTUAL REFUSAL (`remote_status`). `sortable: false` with no + * `reason: virtual-type` — the shape "any future verdict the runtime doors + * add" arrives in. The contract withholds; a type read sees `text` and + * offers it. + * - THE PLATFORM MOVING (`rolled_total`). A `formula` the projection answers + * `sortable: true` for. The contract OFFERS it — the one cell that runs the + * opposite direction from the rest, and the one no type read can ever + * follow: it withholds on the type alone, forever. + * + * Controls sit beside them in the same render, because "the picker offers + * fewer options" is trivially satisfied by a picker that offers none: + * `expected_revenue` (a formula both readings withhold), `owner` (the + * relational carve-out, deliberately NOT delegated to the signal), and the two + * plain stored columns that stay offered throughout. + * + * AGREEMENT over hardcoding, for everything that is not a drift cell: the base + * projection is produced by the platform's own `resolveObjectSortability` + * (`@objectstack/spec/api`) — the resolver the REST layer serves it from — so + * the control cells follow the runtime's predicate rather than a copied table. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { resolveObjectSortability } from '@objectstack/spec/api'; +import { attachObjectSortability } from '@object-ui/core'; +import { ListView } from '../ListView'; +import type { ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; + +const objectDef = { + name: 'crm_opportunity', + label: 'Opportunity', + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'currency', label: 'Amount' }, + // Agreement control — a formula the platform refuses and a type read + // refuses too. + expected_revenue: { type: 'formula', label: 'Expected Revenue' }, + // DRIFT: absent from the served projection. + audited_at: { type: 'datetime', label: 'Audited At' }, + // DRIFT: refused with no `reason: virtual-type`. + remote_status: { type: 'text', label: 'Remote Status' }, + // DRIFT: a formula the platform DOES order by. + rolled_total: { type: 'formula', label: 'Rolled Total' }, + // Relational carve-out — the projection answers `sortable: true` here. + owner: { type: 'lookup', label: 'Owner', reference_to: 'sys_user' }, + }, +}; + +/** + * The served projection: the platform's own resolver, then the three drift + * cells set to what a platform that has moved past a type read would serve. + */ +function servedProjection() { + const resolved = resolveObjectSortability(objectDef) as { fields: Record }; + const fields: Record = { ...resolved.fields }; + // Sanity on the base the drift cells are measured against — if the platform + // resolver ever stopped answering these the way this file assumes, the drift + // cells below would be measuring something else. + expect(fields.amount).toEqual({ sortable: true }); + expect(fields.expected_revenue.sortable).toBe(false); + expect(fields.owner).toEqual({ sortable: true }); + + delete fields.audited_at; + fields.remote_status = { sortable: false }; + fields.rolled_total = { sortable: true }; + return { fields }; +} + +const makeDataSource = (opts: { servesSignal?: boolean } = {}) => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => { + const schema = JSON.parse(JSON.stringify(objectDef)); + if (opts.servesSignal !== false) attachObjectSortability(schema, servedProjection()); + return schema; + }), +}); + +const baseSchema: ListViewSchema = { + type: 'list-view', + objectName: 'crm_opportunity', + viewType: 'grid', + columns: [ + 'name', + 'amount', + 'expected_revenue', + 'audited_at', + 'remote_status', + 'rolled_total', + 'owner', + ] as any, +}; + +async function openSortPopover(schema: ListViewSchema, dsOpts = {}) { + const dataSource = makeDataSource(dsOpts); + render( + + + , + ); + await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); + fireEvent.click(screen.getByRole('button', { name: /^sort/i })); + await screen.findByText('Sort Records'); + return dataSource; +} + +/** Labels offered by the sort field