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
14 changes: 14 additions & 0 deletions .changeset/30037-sort-picker-hidden-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-list': patch
---

fix(plugin-list): the list's sort picker no longer offers a field the object marks hidden, such as the queue column or the search index

Clause-②: no
Metadata-protocol: none

**Visible behaviour change on a released page.** A list's 排序 / Sort picker offered every field of the object, including the injected system columns the platform marks `hidden: true` (the queue column, the `__search` companion shown as an untranslated "Search Index", `organization_id`). The Filter panel's field list already left those out; the sort picker now follows the same rule.

- A field the object definition marks `hidden: true` is no longer listed in the sort picker, and "Add sort" never starts on one.
- A view or saved sort that already orders by such a field keeps working: the field stays listed in that row as removable only (disabled), as the picker already does for an unreadable or refused field, so the row is not blank and can be removed.
- A hidden lookup (such as `organization_id`) no longer raises the "relational fields cannot be sorted" hint under the rows, since the picker never offered it.
42 changes: 32 additions & 10 deletions ui/packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1690,6 +1690,22 @@ function canReadField(
return !perms?.isLoaded || !objectName || perms.checkField(objectName, field, 'read');
}

/**
* Whether the object definition marks a field `hidden: true`: the injected
* system columns `@objectstack/spec` hides from pickers (`organization_id`,
* `queue`, the `__search` companion). The toolbar's field lists ask this one
* predicate. The Filter panel's list and the Sort picker's list leave such a
* field out unless the current filter or sort already names it, so that row
* is not blank and can be removed; the hide-fields lists never offer one to
* add. A plain function of the definition's field map, like `canReadField`.
*/
function isDeclaredHidden(
defs: Record<string, { hidden?: unknown } | undefined> | undefined,
field: string,
): boolean {
return defs?.[field]?.hidden === true;
}

/** The item a value none of a picker's options carries is shown by. */
const OUTSIDE_OPTIONS = 'outside';

Expand Down Expand Up @@ -5173,7 +5189,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
*/
const filterFields = React.useMemo(() => {
const defs: Record<string, { hidden?: unknown; system?: boolean } | undefined> | undefined = objectDef?.fields;
const isHidden = (name: string) => defs?.[name]?.hidden === true;
const isHidden = (name: string) => isDeclaredHidden(defs, name);
const held = new Set((currentFilters.conditions ?? []).map((c) => c.field));
const columnRank = new Map<string, number>();
for (const column of effectiveFields) {
Expand Down Expand Up @@ -5257,14 +5273,19 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// field the current sort already names (a stored or URL sort) stays listed,
// so its row is not blank and can be removed.
//
// A field the object definition marks `hidden: true` (`isDeclaredHidden`,
// the rule the Filter panel's list follows) is withheld the same way as an
// unreadable one: not offered, no relational hint, and kept by the in-use
// exception alone.
//
// An entry the exception alone keeps is listed REMOVABLE ONLY
// (objectui#11943): it carries `disabled`, which `SortBuilder` renders as an
// unavailable option. Its own row still shows its label and can be changed
// to another field or removed, but no row's dropdown offers it as a choice
// and "Add sort" never seeds it. That holds for each reason the exception
// keeps a field: unreadable, relational, or refused by the platform (or by
// the type read when no projection is served). A field the two rules list
// anyway carries no flag, whether or not the sort names it.
// keeps a field: unreadable, hidden, relational, or refused by the platform
// (or by the type read when no projection is served). A field the two rules
// list anyway carries no flag, whether or not the sort names it.
//
// Both legs below — the list this picker renders, and the sort it emits for
// a host to persist — read `platformSortability`, the ONE read of the served
Expand All @@ -5279,16 +5300,17 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// stored ID: said in one line under the rows, only while it is true.
let picked = false;
const fields: Array<{ value: string; label: string; disabled?: boolean; type?: string }> = [];
const defs: Record<string, { hidden?: unknown } | undefined> | undefined = objectDef?.fields;
for (const field of candidateFields) {
const readable = canReadField(perms, schema.objectName, field.value);
if (!readable && !inUse.has(field.value)) continue;
const offered = canReadField(perms, schema.objectName, field.value) && !isDeclaredHidden(defs, field.value);
if (!offered && !inUse.has(field.value)) continue;
const relational = EXPANDABLE_FIELD_TYPES.has(field.type);
const platformSortable = platformSortability
? isPlatformSortableField(platformSortability, field.value)
: !UNMATERIALIZED_FIELD_TYPES.has(field.type);
// `type` lets the picker name a date's direction as time
// (objectstack#27520): "Oldest → Newest", not "A → Z".
if (readable && !relational && platformSortable) {
if (offered && !relational && platformSortable) {
fields.push({ value: field.value, label: field.label, type: field.type });
continue;
}
Expand All @@ -5306,7 +5328,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
if (relational) excluded = true;
}
return { sortFields: fields, sortHasRelationalField: excluded, sortPicksRelationalField: picked };
}, [candidateFields, currentSort, t, platformSortability, perms, schema.objectName]);
}, [candidateFields, currentSort, t, platformSortability, perms, schema.objectName, objectDef]);

/**
* [#6455] THE persist boundary: what this picker LISTS is not what it
Expand Down Expand Up @@ -5669,8 +5691,8 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
if (onAddedFieldsChange && objectDef?.fields) {
const defs = objectDef.fields as Record<string, { label?: string; hidden?: unknown; system?: boolean } | undefined>;
const listed = new Set(allFields.map((f) => f.name));
const candidates = Object.entries(defs).filter(([name, def]) =>
!listed.has(name) && def?.hidden !== true && canReadField(perms, schema.objectName, name));
const candidates = Object.entries(defs).filter(([name]) =>
!listed.has(name) && !isDeclaredHidden(defs, name) && canReadField(perms, schema.objectName, name));
for (const tier of [false, true]) {
for (const [name, def] of candidates) {
if (isSystemManagedField(name, def) !== tier) continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,19 @@ describe('the Sort picker field list asks the column read check (objectui#11943)
expect(screen.getByTestId('sort-relational-info')).toBeInTheDocument();
});
});

describe('a lookup the definition marks hidden does not raise the relational hint (objectstack#30037)', () => {
it('withheld like an unreadable lookup: not offered and no hint; a visible lookup still raises it', async () => {
// `organization_id` is the platform's hidden lookup: the picker never
// offers it, so the hint has nothing to explain.
const hiddenLookup = { ...FIELDS, org: { type: 'lookup', label: 'Organization', reference: 'sys_org', hidden: true } };
mount({ perms: FULL_READ, fields: hiddenLookup });
expect(await openSortOptions()).toEqual(FULL_READ_LABELS);
expect(screen.queryByTestId('sort-relational-info')).toBeNull();
cleanup();
const visibleLookup = { ...FIELDS, org: { type: 'lookup', label: 'Organization', reference: 'sys_org' } };
mount({ perms: FULL_READ, fields: visibleLookup });
await openSortOptions();
expect(screen.getByTestId('sort-relational-info')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
* - UNREADABLE: field-level read denies it (`canReadField`, objectui#11943).
* - PLATFORM-REFUSED: the served projection refuses to order by it (#6455).
* - RELATIONAL: a lookup, listed as ordering by ID (objectui#4243).
* - HIDDEN: the object definition marks it `hidden: true` (objectstack#30037),
* the rule the Filter panel's list already follows. `search_index` stands
* in for the platform's `__search` companion and its `queue` column.
*
* In each case the field is declared FIRST on the object, so an "Add sort" that
* still seeded the first entry would seed it. The controls: a field the picker
Expand All @@ -44,6 +47,7 @@ const FIELD_DEFS: Record<string, Record<string, unknown>> = {
owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' },
title: { type: 'text', label: 'Title' },
priority: { type: 'number', label: 'Priority' },
search_index: { type: 'text', label: 'Search Index', hidden: true },
};

/** The object definition with `first` declared first, the rest in `FIELD_DEFS` order. */
Expand Down Expand Up @@ -74,7 +78,7 @@ function attachProjection(def: ReturnType<typeof definition>) {
const resolved = resolveObjectSortability(def) as { fields: Record<string, unknown> };
const fields: Record<string, unknown> = { ...resolved.fields };
// The base the refusal is measured against: every other field is sortable.
for (const name of ['secret_note', 'owner', 'title', 'priority']) {
for (const name of ['secret_note', 'owner', 'title', 'priority', 'search_index']) {
expect(fields[name]).toEqual({ sortable: true });
}
fields.remote_status = { sortable: false };
Expand Down Expand Up @@ -175,6 +179,7 @@ const CASES = [
{ name: 'unreadable', field: 'secret_note', label: 'Secret Note' },
{ name: 'platform-refused', field: 'remote_status', label: 'Remote Status' },
{ name: 'relational', field: 'owner', label: 'Owner (by ID)' },
{ name: 'hidden', field: 'search_index', label: 'Search Index' },
] as const;

describe('a field the Sort picker keeps only for the current sort is removable only (objectui#11943)', () => {
Expand Down
Loading