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
25 changes: 25 additions & 0 deletions .changeset/related-list-filter-and-add-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/plugin-detail': patch
---

`record:related_list` — the declared `filter` reaches the query, and the Add button answers to the same gate as its dialog

- **`filter` is consumed** (objectstack#7118). The spec declares
`RecordRelatedListProps.filter` ("additional filter criteria") and this repo
published it as a registry input, but nothing read it: `RelatedList` built its
query from `{ [relationshipField]: parentId }` alone, so an authored filter was
accepted by every gate and silently dropped — the list answered with every child
of the parent. It is now AND-combined with the parent condition (never
substituted for it, so an additional criterion can only narrow), lowered through
the repo's single filter sink so the spec's `[{ field, operator, value }]`
vocabulary and a composed `dataSource` binding both work. With nothing authored
the query is unchanged. As a consequence a saved view named through
`dataSource: { object, view }` no longer contributes its columns/sort/limit while
its filter is discarded — the list can no longer be wider than the view it names.
On the legacy raw-URL fallback path, which cannot express an operator, a declared
filter is refused with a console explanation instead of dropped.
- **The Add button now requires `dataSource`** (objectui#3895), matching the picker
dialog and the add callback. In hosts that supply no `RecordContext` — Studio
designer previews, context-free embeds — the button rendered and did nothing at
all when clicked; the affordance is now withheld where the capability behind it
is absent.
16 changes: 10 additions & 6 deletions content/docs/guide/data-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ ignores would be accepted and dropped, which is the defect this binding removes.
| `list-view` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ |
| `record:related_list` | ✅ | columns / sort / limit | — (see below) | ✅ | ✅ |
| `record:related_list` | ✅ | columns / filter / sort / limit | | ✅ | ✅ |
| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — no row cap |
| `object-kanban` | ✅ | filter | ✅ | — no ordering | — fixed window |
| `object-chart` | ✅ | filter | ✅ | — engine orders | — no page |
Expand All @@ -259,12 +259,16 @@ on that block. A view name that does not resolve is reported as a configuration
error on **every** block in the table, including the ones that take nothing else
from the view — so a typo never passes silently, whatever the block.

Two current gaps, recorded rather than papered over:
On `record:related_list` the composed filter is AND-combined with the parent
relationship condition, never substituted for it: a related list is always scoped
to the record it appears on, and an *additional* criterion can only narrow that
set further. (Until objectstack#7118 this block declared `filter` without reading
it, so a named view contributed its columns / sort / limit while its filter was
dropped — the list could be wider than the view it named. That gap is closed; the
`filter` cell above is what closed it.)

One current gap, recorded rather than papered over:

- `record:related_list` declares a flat `filter` its renderer does not read (the
list scopes itself by the parent relationship alone), so a view named there
contributes columns / sort / limit and its filter is dropped — the list can be
wider than the view it names.
- `object-form` resolves `view` only to report an unresolvable name; a view that
does resolve contributes nothing, because a list view's columns are not a form
layout.
Expand Down
20 changes: 20 additions & 0 deletions packages/plugin-detail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,26 @@ in the opt-in filter box temporarily falls back to the full-fetch client
pipeline (the contains-filter sweeps every field, which no generic server
filter can express).

The node's `filter` (spec `RecordRelatedListProps.filter`, "additional filter
criteria") narrows the list beyond the parent relationship: it is
**AND-combined** with `{ [relationshipField]: parentId }`, never substituted for
it, so a related list stays scoped to the record it appears on and an additional
criterion can only ever narrow that set. Authors write it in the spec's own
vocabulary (`[{ field, operator, value }]`); a `dataSource` binding's composed
filter (component AND saved view AND binding) lands on the same key. Both are
lowered to ObjectQL through the repo's single filter sink, so no second dialect
appears. On the legacy raw-URL fallback path (no `dataSource` adapter, where the
query language is `filter[<field>]=<value>` and cannot carry an operator) a
declared filter is refused with a console explanation rather than dropped —
answering with more rows than the metadata asked for is the failure this key's
wiring exists to remove.

The **Add** affordance renders only where every link in its chain is available:
a spec-valid `add.picker.object` *and* a `dataSource`. The picker dialog and the
add callback both required the adapter already, so without it the button used to
render and do nothing at all when clicked — visible in hosts that supply no
`RecordContext` (Studio designer previews, context-free embeds).

The `record:related_list` renderer is automatically gated on the current
user's object-level `read` permission for the child object: when the
permission system (`@object-ui/permissions`) is loaded and denies read,
Expand Down
97 changes: 89 additions & 8 deletions packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ import {
getRecordDisplayName,
getSortValue,
isExpandableFieldType,
mergeFilterNodes,
toFilterNode,
userActionPredicates,
type FilterNode,
} from '@object-ui/core';
import { useSafeFieldLabel } from '@object-ui/react';
import { usePermissions } from '@object-ui/permissions';
Expand Down Expand Up @@ -136,6 +139,29 @@ export interface RelatedListProps {
* @default false
*/
sortable?: boolean;
/**
* The list's OWN scope filter — spec `RecordRelatedListProps.filter`
* ("Additional filter criteria for related records"), which had no read site
* on this component at all until objectstack#7118: the query was built from
* `{ [referenceField]: parentId }` alone, so an authored `filter` (and the
* FILTER half of a `dataSource` binding's saved view) was accepted by every
* gate and silently dropped — the list answered wider than the metadata asked.
*
* ANDed with the parent-relationship condition, never substituted for it:
* "additional" means it may only narrow this parent's children. That is also
* why it is not routed through `data-table`'s `lookupFilters` — those render
* as filter-bar rows the user can edit, which demotes the author's constraint
* to a suggestion (#3831 argued this for `add.picker.filter`; it holds harder
* for the list's own scope).
*
* Two shapes arrive, both produced by our own layers: the spec vocabulary
* (`ViewFilterRule[]`) as authored, and an ObjectQL AST node as composed by
* `ElementDataSourceGate` (which ANDs component/view/binding filters through
* `mergeFilterNodes` before this component ever sees them). Both are lowered
* here through that same single sink — the repo's one filter→wire exit — so no
* second conversion dialect appears.
*/
filter?: ViewFilterRule[] | FilterNode;
/** Enable text filtering */
filterable?: boolean;
/** Whether the card is collapsible */
Expand Down Expand Up @@ -290,6 +316,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
pageSize,
defaultSort,
sortable = false,
filter,
filterable = false,
collapsible = false,
defaultCollapsed = false,
Expand Down Expand Up @@ -360,6 +387,18 @@ export const RelatedList: React.FC<RelatedListProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
[defaultSortKey],
);
// The list's own scope filter, lowered to an ObjectQL node once. Keyed on
// CONTENT for the reason `defaultSortSpec` is: an inline `filter` array on a
// schema node is a new identity every render, and this value is a dependency
// of the fetch effect — keying on identity would refetch the collection on
// every render. `undefined` means "nothing authored", so the query below stays
// byte-identical to what it sent before this key had a read site.
const filterKey = JSON.stringify(filter ?? null);
const listFilterNode = React.useMemo(
() => toFilterNode(filter),
// eslint-disable-next-line react-hooks/exhaustive-deps
[filterKey],
);

// Sync internal state when data prop changes (e.g., parent fetches async data)
React.useEffect(() => {
Expand Down Expand Up @@ -454,9 +493,19 @@ export const RelatedList: React.FC<RelatedListProps> = ({
return;
}
setLoading(true);
const filter = { [referenceField!]: parentId } as Record<string, any>;
const parentScope = { [referenceField!]: parentId } as Record<string, any>;
// Parent relationship AND the list's own scope (objectstack#7118). The
// parent condition is never negotiable — an "additional" criterion may only
// narrow this parent's children — and with nothing authored the query is
// the untouched MongoDB-style object it has always been, rather than a
// freshly lowered AST that means the same thing (the difference is
// invisible on screen and visible to every caller pinning the wire).
const queryFilter =
listFilterNode === undefined
? parentScope
: mergeFilterNodes(parentScope, listFilterNode);
if (dataSource && typeof dataSource.find === 'function') {
const params: Record<string, any> = { $filter: filter };
const params: Record<string, any> = { $filter: queryFilter };
if (windowed) {
params.$top = effectivePageSize;
params.$skip = fetchPage * effectivePageSize;
Expand Down Expand Up @@ -497,6 +546,22 @@ export const RelatedList: React.FC<RelatedListProps> = ({
console.error('Failed to fetch related data:', err);
if (!cancelled) setLoading(false);
});
} else if (listFilterNode !== undefined) {
// No adapter — the legacy raw-URL path, whose query language is
// `filter[<field>]=<value>` and cannot carry an operator, let alone a
// rule array. Dropping the authored filter here would answer with MORE
// rows than the metadata asked for, silently: the exact class this key's
// wiring exists to remove (objectstack#7118), so it refuses and says so
// instead. Empty-and-loud beats wider-and-quiet; the guard above refuses
// an unscoped fetch on the same reasoning.
// eslint-disable-next-line no-console
console.warn(
`[RelatedList] "${api}" declares a filter but has no dataSource adapter — the raw-URL fallback cannot express it, so no rows are fetched. Pass a dataSource (RecordContext) to use a filtered related list.`,
);
setRelatedData([]);
setTotal(null);
setHasMore(false);
setLoading(false);
} else {
const qs = new URLSearchParams({
[`filter[${referenceField}]`]: String(parentId),
Expand All @@ -519,7 +584,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
return () => {
cancelled = true;
};
}, [api, dataProvided, dataSource, referenceField, parentId, refreshNonce, windowed, effectivePageSize, fetchPage, fetchSortField, fetchSortDirection, defaultSortSpec]);
}, [api, dataProvided, dataSource, referenceField, parentId, refreshNonce, windowed, effectivePageSize, fetchPage, fetchSortField, fetchSortDirection, defaultSortSpec, listFilterNode]);

// Windowed mode: a page beyond the (shrunken) collection — e.g. the last
// row of the last page was just deleted — comes back empty. Step back one
Expand All @@ -531,11 +596,14 @@ export const RelatedList: React.FC<RelatedListProps> = ({
}
}, [windowed, loading, relatedData, currentPage]);

// A different parent (or relationship) is a different collection — restart
// from the first page.
// A different parent (or relationship, or list scope) is a different
// collection — restart from the first page. `filterKey` belongs here for the
// same reason the other three do: page 3 of the unfiltered children is not
// page 3 of the filtered ones.
React.useEffect(() => {
setCurrentPage(0);
}, [api, referenceField, parentId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api, referenceField, parentId, filterKey]);

// Refetch when a mutation elsewhere signals this related object changed —
// e.g. a child row action executed through the host retargets `api` and
Expand Down Expand Up @@ -1154,8 +1222,21 @@ export const RelatedList: React.FC<RelatedListProps> = ({
truthy: an `add` without `picker` is metadata the spec rejects,
and offering a button that could never open a picker is worse
than withholding it (#3838 — the console hint above names the
missing key). */}
{add && pickerObject && (
missing key).

`dataSource` is part of the SAME gate, because the dialog this
button opens (below, `add && pickerObject && dataSource`) and the
callback it ends in (`handleAddRecords`: `if (!add ||
!dataSource || …) return`) both require it. Without it the button
rendered, `setPickerOpen(true)` ran, and no dialog existed to
observe the flag: a click with NO visible reaction and no
message. Hosts where that is real are the ones passing
`dataSource={ctx?.dataSource}` with no `RecordContext` bound —
the Studio designer preview and context-free embeds
(`renderers/record-related-list.tsx`). Same principle as #3838
one condition further: an affordance is offered only where the
capability behind it exists (objectui#3895). */}
{add && pickerObject && dataSource && (
<Button
variant={isEmpty ? 'ghost' : 'outline'}
size="sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@
* requires, so a related list authored with the binding hit the
* "missing objectName" placeholder instead of listing anything.
*
* ## The one key that is NOT mapped, and why it is a finding
* ## The key that used to be unmapped, and what closed it
*
* `filter` stays unmapped: this renderer DECLARES `filter` in its registry
* `inputs` ("Additional filter criteria") and never reads it — `RelatedList`
* builds its query from `{ [referenceField]: parentId }` alone and takes no
* filter prop for the list's own scope. Mapping the composed filter onto
* `schema.filter` would hand it to a key nothing consumes, which is the defect
* objectstack#6953 removes rather than spreads.
* `filter` was deliberately left out of the mapping when this suite was written:
* the renderer DECLARED `filter` and nothing read it — `RelatedList` built its
* query from `{ [referenceField]: parentId }` alone and had no prop for the
* list's own scope — so writing the composed filter onto `schema.filter` would
* have handed it to a dead key, which is the defect objectstack#6953 removes
* rather than spreads. The last test pinned that consequence honestly: a saved
* view named here contributed its columns / sort / limit while its FILTER was
* dropped, so the list could be wider than the view it names.
*
* The consequence is pinned rather than left implicit (last test): while that
* gap is open, a saved view named here contributes its columns / sort / limit and
* its FILTER is dropped, so the list can be wider than the view it names. When
* the flat `filter` gains a read site (objectstack#7118), `filter: true` belongs
* in the mapping and that test is the one that must change.
* objectstack#7118 gave the key a read site, so that test is now the POSITIVE
* assertion its own comment predicted: the composed filter reaches `RelatedList`
* and the list can no longer be wider than the view it names.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
Expand Down Expand Up @@ -117,16 +117,45 @@ describe('record:related_list — dataSource: { object, view } (objectstack#6953
expect(h.captured.pageSize).toBe(7);
});

it('does NOT hand a composed filter to a key this block cannot read (open gap)', async () => {
// Honest pin on the residual gap, not a claim that filtering works: the
// renderer declares `filter` and never reads it, and `RelatedList` has no
// prop for the list's own filter. Writing the view's filter onto
// `schema.filter` would look like wiring and change nothing, so the mapping
// does not — and this asserts that no filter reaches `RelatedList` under any
// spelling. Filed as objectstack#7118; when a read site lands, this flips.
it('hands the composed view-AND-binding filter to the list (objectstack#7118)', async () => {
// The flipped half of this suite. Before objectstack#7118 the assertion here
// was `h.captured.filter` is `undefined` — an honest pin on a dead declared
// key, with the note that a read site would invert it. It did, so this now
// asserts the composition arrives: the view's own filter and the binding's,
// ANDed, on the prop `RelatedList` reads for the list's own scope.
renderRelated({ dataSource: { object: 'contact', view: 'recent', filter: [['x', '=', 1]] } });
await waitFor(() => expect(h.captured).toBeTruthy());
expect(h.captured.filter).toBeUndefined();
expect(h.captured.filter).toEqual(['and', [['is_active', '=', true]], [['x', '=', 1]]]);
// Still not `baseFilter` — that prop is the ADD PICKER's candidate
// restriction (`add.picker.filter`, #3831). Routing the list's scope there
// would filter the dialog and leave the list wide.
expect(h.captured.baseFilter).toBeUndefined();
});

it('narrows, never widens: an authored filter survives alongside the view’s', async () => {
// The binding's contract in one case — "additional filter criteria" means
// the component's own key and the view's both stay in force. A mapping that
// let one replace the other would be able to widen a named view, which is
// the failure class the binding exists to remove.
renderRelated({
filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
dataSource: { object: 'contact', view: 'recent' },
});
await waitFor(() => expect(h.captured).toBeTruthy());
expect(h.captured.filter).toEqual([
'and',
[['stage', 'equals', 'won']],
[['is_active', '=', true]],
]);
});

it('leaves an unbound list’s authored filter exactly as authored', async () => {
// No binding: the schema key passes through by reference, in the spec's own
// vocabulary. `RelatedList` owns the lowering (its own suite pins it), so
// nothing converts it twice on the way down.
const authored = [{ field: 'stage', operator: 'equals', value: 'won' }];
renderRelated({ objectName: 'contact', filter: authored });
await waitFor(() => expect(h.captured).toBeTruthy());
expect(h.captured.filter).toBe(authored);
});
});
Loading
Loading