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
38 changes: 38 additions & 0 deletions .changeset/10545-filter-candidates-displayfield.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@object-ui/plugin-list': patch
---

fix(plugin-list): list filters label a lookup's values by the `displayField` the object definition declares (objectui#10545)

Once a list view's object definition loads, the filter builder's candidates and
the toolbar's lookup filter read each lookup field's display field off that
definition. They read `display_field`, then `reference_field`, and never
`displayField`, the only one of the three `@objectstack/spec`'s `FieldSchema`
declares. So a spec-compliant lookup's display field never reached the value
picker, which fell back to its default, `name`.

Both now read `displayField`, and only it. This is the same single spelling
`@object-ui/plugin-grid` reads off a definition. `FieldSchema` refuses the other
two: `display_field` with a rename to `displayField`, and `reference_field` with
a pointer to `referenceVia`.

Behaviour change: a field definition that carries only the refused
`display_field` no longer sets the picker's display field, and neither does one
that carries only `reference_field`. The picker falls back to `name`. A stored
definition served through the ObjectStack adapter still works, because that
adapter folds `display_field` onto `displayField` when the schema is loaded.
A definition that reaches the list any other way, such as a host `DataSource`
or an `objectDef` passed straight to `UserFilters`, needs `displayField`.

The two readers also stop reading `id_field` as the lookup's id column.
`FieldSchema` declares no id column for a lookup and refuses both `id_field` and
`idField`, so the picker now always keys a lookup by `id`, its default. A
definition that carried `id_field` used to make the picker match and emit that
column's values instead.

Also (objectui#10547): before a list view's object definition loads, the filter
builder's candidates come from the view's declared `columns`, and that fallback
no longer reads `options` off a list column. `ListColumnSchema` refuses the key
with `unrecognized_keys`, so a spec-compliant view never carries it. A select
field's options now reach the filter builder only from the object definition,
once it loads, which is where they already came from after the load.
29 changes: 20 additions & 9 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3662,7 +3662,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
* The two narrowings now sit downstream of it, one per builder.
*/
const candidateFields = React.useMemo(() => {
let fields: Array<{ value: string; label: string; type: string; options?: any; referenceTo?: string; displayField?: string; idField?: string }>;
let fields: Array<{ value: string; label: string; type: string; options?: any; referenceTo?: string; displayField?: string }>;

// Translate select-field option labels through the i18n resolver.
// fieldDef.options may be an array of { value, label } or a keyed object;
Expand Down Expand Up @@ -3700,8 +3700,8 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// reading it here would resurrect the second spelling.
label: tFieldLabel(fieldName, f.label || fieldName),
type: f.type || 'text',
options: buildOptions(fieldName, f.options),
// objectui#7531 (ruled): a list column declares no relational target; it comes from the object definition once loaded.
// objectui#10547 (same ruling): nor select options — `ListColumnSchema` refuses `options` with `unrecognized_keys`; they come from the object definition once loaded.
}];
});
} else {
Expand All @@ -3722,13 +3722,24 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// with no rename hint. That is a different question and is filed, not
// answered here.
referenceTo: field.reference,
// objectui#7642 CENSUS — verdict KEEP. Bag traced: `objectDef` is
// `dataSource.getObjectSchema(schema.objectName)`, so this IS the
// object-schema def. But the serve path runs no parse, so a stored
// pre-strict def still arrives; and there is no camel leg here, so
// retiring these reads deletes the only read of the value.
displayField: field.display_field || field.reference_field,
idField: field.id_field,
// objectui#10545 — the display field is read in the DECLARED spelling
// and only in it. `objectDef` is
// `dataSource.getObjectSchema(schema.objectName)`, the object-schema
// def, and `FieldSchema` declares `displayField`; it refuses
// `display_field` (renaming it to `displayField`) and
// `reference_field` (pointing at `referenceVia`, a different key)
// with `unrecognized_keys`. This is the single spelling `plugin-grid`'s
// copy set reads (`RELATIONAL_META_READ_SET`, objectui#7155), and it
// supersedes the objectui#7642 census KEEP, which held only while
// this chain had no `displayField` leg. A stored pre-strict
// `display_field` is folded onto `displayField` once, at ingestion
// (`normalizeSchemaReferenceKeys`, objectui#7650), never here.
//
// No id column is read: `FieldSchema` declares none for a lookup (it
// refuses `idField` and `id_field` alike), so the filter's value
// picker keys the lookup by its own `id` default, and `plugin-grid`'s
// copy set copies no id column either.
displayField: field.displayField,
}));
}

Expand Down
26 changes: 15 additions & 11 deletions packages/plugin-list/src/UserFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ interface ResolvedField {
referenceTo?: string;
/** Lookup-like fields: display field on referenced object */
displayField?: string;
/** Lookup-like fields: id field on referenced object */
idField?: string;
}

/**
Expand Down Expand Up @@ -272,7 +270,6 @@ function resolveFields(
let resolvedType: string | undefined = f.type;
let referenceTo: string | undefined;
let displayField: string | undefined;
let idField: string | undefined;
// Object-level field label from objectDef, used as a fallback when the
// view author didn't supply `f.label` (or it was stripped during compile).
// Without this the chip degrades to the raw snake_case field key.
Expand All @@ -297,12 +294,21 @@ function resolveFields(
// legacy-only def is canonicalised ONCE at the ingestion choke point
// (`normalizeSchemaReferenceKeys`, which warns in dev) — never here.
referenceTo = fieldDef.reference;
// objectui#7642 CENSUS — verdict KEEP. `objectDef` is a PUBLIC prop typed
// `any` on a publicly exported component, so the bag cannot be traced past
// this package: the in-repo caller (`ListView`) passes `getObjectSchema`
// output, but an external host's is unknown. No camel leg here either.
displayField = fieldDef.display_field ?? fieldDef.reference_field;
idField = fieldDef.id_field;
// objectui#10545 — the display field is read in the spelling
// `FieldSchema` DECLARES, `displayField`, and only in it; `FieldSchema`
// refuses `display_field` (renaming it to `displayField`) and
// `reference_field` (pointing at `referenceVia`) with
// `unrecognized_keys`. The in-repo caller (`ListView`) passes
// `getObjectSchema` output, and a host's `objectDef` is held to the same
// contract (AGENTS.md #0.1): this matches `ListView`'s own candidate and
// `plugin-grid`'s copy set (`RELATIONAL_META_READ_SET`, objectui#7155),
// and supersedes the objectui#7642 census KEEP, which held only while
// this chain had no `displayField` leg.
//
// No id column is read: `FieldSchema` declares none for a lookup (it
// refuses `idField` and `id_field` alike), so `LookupValuePicker` keys
// the lookup by its own `id` default.
displayField = fieldDef.displayField;

if (options.length === 0 && fieldDef.options) {
if (Array.isArray(fieldDef.options)) {
Expand Down Expand Up @@ -364,7 +370,6 @@ function resolveFields(
options,
referenceTo,
displayField,
idField,
};
});
}
Expand Down Expand Up @@ -599,7 +604,6 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible,
type: f.type,
referenceTo: f.referenceTo,
displayField: f.displayField,
idField: f.idField,
}}
value={selected}
multiple={true}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* 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#10547 — the columns-only candidate fallback reads no select options
* off a list column.
*
* Before the object definition loads, `ListView` builds its filter and sort
* candidates from the view's declared `columns`. That branch used to read
* `options` off each column, and `ListColumnSchema` refuses the key with
* `unrecognized_keys` and no rename hint, so a spec-compliant producer can never
* put it there. The ruling objectui#7531 made for the relational keys
* (5809501335, letter "remove") is reused for this one: select options come from
* the object definition once it loads, never from a column.
*
* The regression window is the pre-load one, so the pin holds `getObjectSchema`
* pending. The lit control then lets the definition load and sees the options
* arrive from it.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ListColumnSchema } from '@objectstack/spec/ui';
import type { DataSource, ListViewSchema } from '@object-ui/types';
import { SchemaRendererProvider } from '@object-ui/react';

/** One filter candidate as ListView builds it. */
type Candidate = {
value: string;
label: string;
type: string;
options?: Array<{ value: unknown; label: string }>;
};

// What ListView hands the filter builder: the candidate list itself.
const captured = vi.hoisted(() => ({ fields: [] as Candidate[][] }));
vi.mock('@object-ui/components', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/components')>();
return {
...actual,
FilterBuilder: (props: { fields: Candidate[] }) => {
captured.fields.push(props.fields);
return null;
},
};
});

import { ListView } from '../ListView';

const STATUS_OPTIONS = [
{ value: 'open', label: 'Open' },
{ value: 'closed', label: 'Closed' },
];

const PLAIN_COLUMNS = [
{ field: 'status', label: 'Status', type: 'select' },
{ field: 'name', label: 'Name' },
];

const COLUMNS_CARRYING_OPTIONS = [{ ...PLAIN_COLUMNS[0], options: STATUS_OPTIONS }, PLAIN_COLUMNS[1]];

function makeDataSource(getObjectSchema: () => Promise<unknown>) {
return {
find: vi.fn().mockResolvedValue({ data: [], total: 0 }),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(getObjectSchema),
};
}

async function renderAndOpenFilter(columns: unknown[], getObjectSchema: () => Promise<unknown>) {
captured.fields.length = 0;
const dataSource = makeDataSource(getObjectSchema);
const schema = {
type: 'list-view',
objectName: 'crm_ticket',
viewType: 'grid',
columns,
} as unknown as ListViewSchema;
render(
<SchemaRendererProvider dataSource={dataSource as unknown as DataSource}>
<ListView schema={schema} dataSource={dataSource} />
</SchemaRendererProvider>,
);
await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', { name: /^filter/i }));
await screen.findByText('Filter Records');
await waitFor(() => expect(captured.fields.length).toBeGreaterThan(0));
}

const latestCandidates = () => captured.fields[captured.fields.length - 1];

/** A definition that never arrives: the columns-only branch is the only one. */
const neverLoads = () => new Promise<unknown>(() => {});

afterEach(() => {
cleanup();
captured.fields.length = 0;
});

describe('objectui#10547 — no select options are read off a list column', () => {
it('the spec refuses `options` on a column (lit control: the bare column parses)', () => {
expect(ListColumnSchema.safeParse(PLAIN_COLUMNS[0]).success).toBe(true);
const result = ListColumnSchema.safeParse(COLUMNS_CARRYING_OPTIONS[0]);
expect(result.success).toBe(false);
const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue && 'keys' in issue ? issue.keys : []).toEqual(['options']);
});

it('before the definition loads, a column carrying `options` offers the same candidates as a plain one', async () => {
await renderAndOpenFilter(PLAIN_COLUMNS, neverLoads);
const plain = latestCandidates();
cleanup();

await renderAndOpenFilter(COLUMNS_CARRYING_OPTIONS, neverLoads);
const carrying = latestCandidates();

// These ARE the columns-only candidates: the declared columns, in order.
expect(carrying.map((f) => [f.value, f.label, f.type])).toEqual([
['status', 'Status', 'select'],
['name', 'Name', 'text'],
]);
expect(carrying[0].options).toBeUndefined();
expect(carrying).toStrictEqual(plain);
});

it('control: the select options arrive with the object definition, and the capture sees them', async () => {
let deliver!: (def: unknown) => void;
const definition = new Promise<unknown>((resolve) => { deliver = resolve; });
await renderAndOpenFilter(PLAIN_COLUMNS, () => definition);

expect(latestCandidates()[0].options).toBeUndefined();

deliver({
name: 'crm_ticket',
fields: {
status: { type: 'select', label: 'Status', options: STATUS_OPTIONS },
name: { type: 'text', label: 'Name' },
},
});

await waitFor(() =>
expect(latestCandidates().find((f) => f.value === 'status')?.options?.map((o) => o.value)).toEqual([
'open',
'closed',
]),
);
});
});
Loading
Loading