diff --git a/.changeset/8106-listview-callback-param-types.md b/.changeset/8106-listview-callback-param-types.md new file mode 100644 index 0000000000..44b5f44574 --- /dev/null +++ b/.changeset/8106-listview-callback-param-types.md @@ -0,0 +1,45 @@ +--- +'@object-ui/plugin-list': minor +--- + +`ListViewProps.onSortChange` and `onFilterChange` are declared at the types they +actually fire with, instead of `any`. + +Both were left `any` by the objectui#4528 sweep that named every other prop on +this interface "at the type each one actually lands on". `dataSource?: any` +carries its own written justification right there; these two carried none, which +is what made this an oversight rather than a decision. The visible cost was that +the package README's documented "With Callbacks" example compiled green on all +four callbacks while only two of them were constrained by anything: assigning +`view` or `search` to a `number` inside the block raised TS2322, and assigning +`sort` or `filters` raised nothing at all. + +Each parameter was measured at the emit site rather than read off the +declaration, and the two are asymmetric: + +- `onSortChange` now takes `SortItem[]`. Every emit in the component crosses one + boundary, `emitSortChange`, and both of its legs carry that element type: the + array handed in, and `filterPlatformSortableSort`'s return, which is generic in + the element and so preserves whatever it is given. Normalized-vs-raw therefore + does not move the type here, only whether platform-unsortable entries are still + present. +- `onFilterChange` now takes `FilterGroup`. Its one call site passes the toolbar + `FilterBuilder`'s own `onChange` value straight through, beside a + `setCurrentFilters` that is itself state of that type. It is deliberately NOT + the filter AST `normalizeFilters` / `buildEffectiveFilter` speak — those run + later on the query-building path and nothing they produce reaches this + callback. A host receives the builder's group verbatim, which is what lets it + round-trip back in through `initialFilters`. + +Both types were already exported from `@object-ui/components` and already +imported by this file; nothing new is minted. + +Narrowing a callback parameter is contravariant, so the cost falls only on +handlers that did something with the parameter that `any` alone allowed — a +handler written `(sort: any) => ...` still compiles unchanged. The cost was +measured across every in-repo consumer that passes either callback +(`ObjectView`, in two places, and `InterfaceListPage`) plus every package that +imports `ListView` at all (`@object-ui/app-shell`, `@object-ui/plugin-map`, +`@object-ui/console`): all of them still type-check, and no handler needed +touching. Out-of-repo hosts that destructure or index the parameter in ways only +`any` permitted will now see a real error, which is the point. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index aae0b1da25..8f48a253bb 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -232,8 +232,30 @@ export interface ListViewProps { */ dataSource?: any; onViewChange?: (view: ViewType) => void; - onFilterChange?: (filters: any) => void; - onSortChange?: (sort: any) => void; + /** + * Fires with the advanced-filter group the toolbar's `FilterBuilder` emitted. + * + * `FilterGroup` because that is what the one call site actually passes: the + * builder's own `onChange` value, handed straight through beside + * `setCurrentFilters` — itself `React.useState`. Deliberately NOT + * the filter AST `normalizeFilters` / `buildEffectiveFilter` speak: those run + * later, on the query-building path, and nothing they produce reaches this + * callback. A host receives the BUILDER's group verbatim, which is what lets + * it round-trip back in through `initialFilters`. + */ + onFilterChange?: (filters: FilterGroup) => void; + /** + * Fires with the view's sort after a builder edit, a header click or a + * "reset to the view's default". + * + * `SortItem[]` because every emit crosses exactly one boundary — + * `emitSortChange` — and both of its legs carry that element type: the array + * passed in, and `filterPlatformSortableSort`'s return, which is generic in + * the element (readonly T[] in, T[] out) and so preserves whatever it is + * given. Normalized-vs-raw therefore does not move the TYPE here; it only + * decides whether platform-unsortable entries are still present (#6455). + */ + onSortChange?: (sort: SortItem[]) => void; onSearchChange?: (search: string) => void; /** Called when the user toggles fields via the Hide Fields popover. */ onHiddenFieldsChange?: (hidden: string[]) => void; diff --git a/packages/plugin-list/src/__tests__/ListView.propsResolution.test.ts b/packages/plugin-list/src/__tests__/ListView.propsResolution.test.ts index 8678f99f4d..348b377eeb 100644 --- a/packages/plugin-list/src/__tests__/ListView.propsResolution.test.ts +++ b/packages/plugin-list/src/__tests__/ListView.propsResolution.test.ts @@ -35,6 +35,7 @@ import { describe, it, expect } from 'vitest'; import type { ComponentProps } from 'react'; +import type { SortItem, FilterGroup } from '@object-ui/components'; import { ListView, type ListViewProps } from '../ListView'; type Assert = T; @@ -89,6 +90,31 @@ type _OnBulkDeleteIsDeclared = Assert< Equal void) | undefined> >; +// 8. objectui#8106 — the two callbacks the #4528 sweep left at `any`, pinned +// at the type their emit sites actually fire with. Both were measured at the +// CALL SITE, not read off the declaration, and they are asymmetric on +// purpose: `sort` crosses `emitSortChange`, whose two legs are the array it +// is handed and `filterPlatformSortableSort`'s return — generic in the +// element, so `SortItem[]` either way; `filters` is the toolbar +// `FilterBuilder`'s own `onChange` value passed straight through, which is +// why it is the builder's `FilterGroup` and NOT the later query-path AST. +// +// The `IsAny` halves are the discriminating ones, exactly as in pin 2: a +// re-widening to `any` must fail loudly here rather than silently +// re-admitting every shape the way it did before objectui#8106. +type _OnSortChangeIsDeclared = Assert< + Equal void) | undefined> +>; +type _OnSortChangeParamIsNotAny = Assert< + Equal>[0]>, false> +>; +type _OnFilterChangeIsDeclared = Assert< + Equal void) | undefined> +>; +type _OnFilterChangeParamIsNotAny = Assert< + Equal>[0]>, false> +>; + describe('objectui#4528 — ListView serves its declared props', () => { it('pins the resolved call-site props at compile time', () => { // The assertions are the types above; this body only keeps the file a test. diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index cb5f9c4da7..a22837f6d4 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -793,7 +793,7 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `items`, which the example never declares', }, - 'packages/plugin-list/src/ListView.tsx:808 ListViewHandle': { + 'packages/plugin-list/src/ListView.tsx:830 ListViewHandle': { card: null, codes: [2304, 2686], reason: