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
15 changes: 15 additions & 0 deletions .changeset/11810-filter-builder-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/plugin-list': patch
'@object-ui/components': minor
'@object-ui/i18n': minor
---

The list filter builder starts on the view's first column, hides hidden fields, and offers one empty check where "empty" and "null" mean the same records (objectui#11810).

- **Field list.** The list view's Filter panel no longer offers a field the object definition marks `hidden: true` (Organization, Owning Business Unit, the search index). Its fields follow the view's columns in the order the grid shows them, then the other business fields, then the system fields (created / modified / owner). A hidden field stays listed only when the view names it in `filterableFields`, or when a condition the panel already holds filters on it, so a restored filter still shows its field.
- **"Add filter".** A new condition starts on the view's first visible column instead of the hidden Organization field.
- **Empty checks.** On a column whose type cannot hold an empty value other than null (select, lookup, number, date and the other "null only" types of `@objectstack/spec`'s `expandEmptyOperator`), the operator list offers "Is empty" / "Is not empty" and no longer "Is null" / "Is not null": there they match the same records. Text columns and list-valued columns keep both pairs, and the operator list says how they differ ("Is empty" also matches blank text, or an empty list). A stored "Is null" condition on such a column still loads and shows as "Is null". Every `FilterBuilder` consumer gets this offer; what a row can hold (`operatorsForFieldType`) is unchanged.

`@object-ui/i18n` gains two language-pack keys in all ten packs, `filterBuilder.emptyCheckHint.text` and `filterBuilder.emptyCheckHint.list`, which carry that hint. No export, prop or type member is added.

`@object-ui/components` raises its `@objectstack/spec` floor from `^17.0.0` to `^17.5.0`, because its published entry now imports `expandEmptyOperator`, which the spec first exports in 17.5.0.
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@object-ui/react-runtime": "workspace:*",
"@object-ui/sdui-parser": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@objectstack/spec": "^17.5.0",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* 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#11810 — one empty-check pair per column, unless the column's type
* really tells "empty" from "null"; then both pairs, with a hint saying how.
*
* The per-type table is the spec's own (`expandEmptyOperator`, the function
* every server face expands `$empty` with): on a `null_only` type `is_empty`
* matches null and nothing else, which is exactly what `is_null` matches, so
* the dropdown used to ask an end user to choose between two labels for one
* set of records. `text` types count `''` as empty too and list types count
* `[]`, so there the two pairs are two predicates and both stay.
*
* The narrowing is the dropdown's OFFER only. `operatorsForFieldType` — what a
* row can HOLD, which `app-shell`'s dataset read-back and `plugin-list`'s
* parity pin ask — is unchanged, and a stored `is_null` on a `select` column
* still loads and shows as what it is.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { expandEmptyOperator } from '@objectstack/spec/data';
import { FilterBuilder, operatorsForFieldType, type FilterGroup } from '../custom/filter-builder';

const TEXT_HINT = '"Is empty" also matches blank text; "Is null" matches only a missing value.';
const LIST_HINT = '"Is empty" also matches an empty list; "Is null" matches only a missing value.';

/** One field per type under test, so a row can be pointed at any of them. */
const TYPES = [
'text', 'textarea', 'email',
'select', 'status', 'lookup', 'user', 'master_detail',
'number', 'currency', 'date', 'datetime',
'multiselect', 'tags',
] as const;
const FIELDS = TYPES.map((type) => ({ value: `f_${type}`, label: `F ${type}`, type }));

afterEach(cleanup);

function renderRow(condition: Record<string, unknown>) {
const onChange = vi.fn();
// Hoisted out of the JSX: the builder re-seeds on a new `value` identity.
// Cast: a stored row may carry a spelling the row type does not admit.
const value = { id: 'root', logic: 'and', conditions: [{ id: 'c1', value: '', ...condition }] } as unknown as FilterGroup;
render(<FilterBuilder fields={FIELDS} value={value} onChange={onChange} />);
return { onChange };
}

/** Open the row's operator dropdown (combobox 1) and read what it mounts. */
async function openOperatorList(): Promise<{ options: string[]; hint: string | null }> {
fireEvent.keyDown(screen.getAllByRole('combobox')[1], { key: 'ArrowDown' });
const options = await waitFor(() => {
const found = screen.getAllByRole('option');
expect(found.length).toBeGreaterThan(0);
return found.map((o) => o.textContent ?? '');
});
return { options, hint: screen.queryByTestId('filter-empty-check-hint')?.textContent ?? null };
}

const EMPTY_PAIR = ['Is empty', 'Is not empty'];
const NULL_PAIR = ['Is null', 'Is not null'];

describe('objectui#11810 — the dropdown offers one empty-check pair unless the type distinguishes them', () => {
it('the per-type table this suite expects is the spec’s own (lit control on each arm)', () => {
// Read, not restated: if the spec moves a type between arms, this pin says
// so before the DOM pins below disagree with it for a reason nobody sees.
expect(expandEmptyOperator({ type: 'select' }).arm).toBe('null_only');
expect(expandEmptyOperator({ type: 'number' }).arm).toBe('null_only');
expect(expandEmptyOperator({ type: 'lookup' }).arm).toBe('null_only');
expect(expandEmptyOperator({ type: 'text' }).arm).toBe('text');
expect(expandEmptyOperator({ type: 'tags' }).arm).toBe('multi_value');
});

for (const type of ['select', 'status', 'lookup', 'user', 'master_detail', 'number', 'currency', 'date', 'datetime'] as const) {
it(`a ${type} column offers "Is empty" / "Is not empty" and no null pair, with no hint`, async () => {
renderRow({ field: `f_${type}`, operator: 'equals' });
const { options, hint } = await openOperatorList();
expect(options).toEqual(expect.arrayContaining(EMPTY_PAIR));
for (const label of NULL_PAIR) expect(options).not.toContain(label);
expect(hint).toBeNull();
});
}

for (const type of ['text', 'textarea', 'email'] as const) {
it(`a ${type} column offers both pairs and the blank-text hint`, async () => {
renderRow({ field: `f_${type}`, operator: 'equals' });
const { options, hint } = await openOperatorList();
expect(options).toEqual(expect.arrayContaining([...EMPTY_PAIR, ...NULL_PAIR]));
expect(hint).toBe(TEXT_HINT);
});
}

for (const type of ['multiselect', 'tags'] as const) {
it(`a ${type} column offers both pairs and the empty-list hint`, async () => {
renderRow({ field: `f_${type}`, operator: 'equals' });
const { options, hint } = await openOperatorList();
expect(options).toEqual(expect.arrayContaining([...EMPTY_PAIR, ...NULL_PAIR]));
expect(hint).toBe(LIST_HINT);
});
}
});

describe('objectui#11810 — a stored null check still loads (controls)', () => {
it('a stored `is_null` on a select column shows "Is null" and keeps it mounted', async () => {
renderRow({ field: 'f_select', operator: 'is_null' });
// The trigger matches against MOUNTED items: an unmounted operator draws
// blank (objectui#4768 / #7561), which is the regression this guards.
expect(screen.getAllByRole('combobox')[1].textContent).toBe('Is null');
const { options, hint } = await openOperatorList();
expect(options).toContain('Is null');
// Only the row's own: its partner is still not offered to a fresh choice.
expect(options).not.toContain('Is not null');
// Same records as "Is empty" on this column — nothing to explain.
expect(hint).toBeNull();
});

it('a stored camelCase `isNotNull` on a number column loads as "Is not null"', () => {
// The read boundary folds the deprecated spelling (objectui#9306); the
// row-own rule must compare the FOLDED id, or this draws blank.
renderRow({ field: 'f_number', operator: 'isNotNull' });
expect(screen.getAllByRole('combobox')[1].textContent).toBe('Is not null');
});

it('switching a text row on "Is null" to a select column keeps the row’s operator', async () => {
const { onChange } = renderRow({ field: 'f_text', operator: 'is_null' });
fireEvent.keyDown(screen.getAllByRole('combobox')[0], { key: 'ArrowDown' });
const option = await waitFor(() => {
const found = screen.getAllByRole('option').find((o) => o.textContent === 'F select');
expect(found).toBeTruthy();
return found!;
});
fireEvent.click(option);
const row = onChange.mock.calls[onChange.mock.calls.length - 1][0].conditions[0];
expect(row).toMatchObject({ field: 'f_select', operator: 'is_null' });
expect(screen.getAllByRole('combobox')[1].textContent).toBe('Is null');
});

it('what a row can HOLD is unchanged: `operatorsForFieldType` still carries both pairs', () => {
// The author-facing callers read this, not the dropdown: the dataset
// inspector's read-back (`builderHolds`) keeps a stored `$null` on a
// select column editable in the visual builder only while it does.
for (const type of ['select', 'lookup', 'number', 'date', 'text']) {
const ids = operatorsForFieldType(type).map((op) => op.value);
expect(ids, type).toEqual(expect.arrayContaining(['is_empty', 'is_not_empty', 'is_null', 'is_not_null']));
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,10 @@ describe('objectui#7561 — ⛔ the dropdown still emits its own vocabulary', ()
//
// The order is `defaultOperators`' declaration order filtered by the
// bucket — NOT the bucket array's own order (`operatorsForFieldType`
// filters the declaration list). Recorded from the base tree.
// filters the declaration list). Recorded from the base tree, less the
// `Is null` / `Is not null` pair objectui#11810 stopped offering on a
// number column: there they match exactly what `Is empty` / `Is not empty`
// match (`filter-builder-empty-null-pair-11810.test.tsx`).
renderRow({ field: 'amount', operator: 'gt', value: '5' });
fireEvent.keyDown(screen.getAllByRole('combobox')[1], { key: 'ArrowDown' });
return waitFor(() => {
Expand All @@ -268,8 +271,6 @@ describe('objectui#7561 — ⛔ the dropdown still emits its own vocabulary', ()
'Less than',
'Greater than or equal',
'Less than or equal',
'Is null',
'Is not null',
]);
});
});
Expand Down
114 changes: 110 additions & 4 deletions packages/components/src/custom/filter-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
VIEW_FILTER_PAIR_VALUE_OPERATORS,
normalizeFilterOperator,
} from "@objectstack/spec/ui"
import { expandEmptyOperator } from "@objectstack/spec/data"
import { SchemaRendererContext } from "@object-ui/react"
import type {
FilterBuilderCondition as AuthoredFilterBuilderCondition,
Expand Down Expand Up @@ -1132,6 +1133,11 @@ const useSafeFilterTranslation = createSafeTranslation(
'filterBuilder.operators.is_not_null': 'Is not null',
'filterBuilder.operators.exists': 'Is set',
'filterBuilder.operators.notExists': 'Is not set',
// How the two empty checks differ, on the columns that keep both pairs
// (objectui#11810). The operator labels are interpolated rather than
// restated, so the hint names them exactly as the dropdown above it does.
'filterBuilder.emptyCheckHint.text': '"{{isEmpty}}" also matches blank text; "{{isNull}}" matches only a missing value.',
'filterBuilder.emptyCheckHint.list': '"{{isEmpty}}" also matches an empty list; "{{isNull}}" matches only a missing value.',
// The half-filled range's description, read from the SHARED `validation`
// namespace rather than declared as a new `filterBuilder.*` key
// (objectui#10061). `{{field}} is required` already exists in all ten packs
Expand Down Expand Up @@ -1179,8 +1185,15 @@ const selectLikeTypes = ["select", "status"]
const lookupLikeTypes = ["lookup", "master_detail", "user"]

/**
* The operators the dropdown offers for a field of `fieldType`, given the
* opt-in ids this instance was granted.
* The operators a row on a field of `fieldType` can HOLD, given the opt-in ids
* this instance was granted — every operator the dropdown can draw for it.
*
* What the dropdown OFFERS a fresh choice is this set minus the nullness pair
* on a type that cannot tell "empty" from "null" (objectui#11810, see
* {@link offeredOperatorsForRow}). That narrowing is deliberately NOT applied
* here: callers outside this file (`app-shell`'s dataset read-back,
* `plugin-list`'s parity pin) ask "can the builder hold this row", and a
* stored `is_null` on a `select` column still can.
*
* A pure function rather than a closure so the selection rule — and above all
* the {@link OPT_IN_OPERATORS} gate, whose whole job is to keep an operator OFF
Expand Down Expand Up @@ -1230,6 +1243,61 @@ export function operatorsForFieldType(
)
}

/**
* The nullness pair a column offers only when its type can hold an empty
* value that is not null (objectui#11810).
*/
const NULLNESS_PAIR: ReadonlySet<string> = new Set(["is_null", "is_not_null"])

/**
* Which row of the spec's ruled 「is empty」 table a column of `fieldType`
* takes — read from `@objectstack/spec`'s `expandEmptyOperator`, the one
* function every server face expands `$empty` with, never from a local type
* list (objectui#11810):
*
* - `text` — text-like types: `is_empty` matches null OR `''`;
* - `multi_value` — list-valued types: `is_empty` matches null OR `[]`;
* - `null_only` — every other type: `is_empty` matches null only, which is
* exactly what `is_null` matches.
*
* Keyed on the TYPE alone because that is all a field descriptor here carries:
* a `lookup` / `user` / `select` declared `multiple: true` is `multi_value` on
* the server and is judged `null_only` here. The pair it is then offered,
* `is_empty` / `is_not_empty`, is the one whose server expansion counts `[]`.
*/
function emptyCheckArm(fieldType: string | undefined) {
return expandEmptyOperator({ type: fieldType || "text" }).arm
}

/**
* The operators the dropdown MOUNTS for one row (objectui#11810).
*
* {@link operatorsForFieldType}, minus `is_null` / `is_not_null` on a column
* whose type cannot tell empty from null (`null_only`, see
* {@link emptyCheckArm}). There the two pairs are one predicate under two
* labels — every dialect this builder writes expands `is_empty` on such a
* column to "is null" — so an end user was asked to choose between two words
* for the same records. `is_empty` / `is_not_empty` is the pair that stays: it
* is offered on every bucket that has an empty check, so one label means one
* thing across columns.
*
* The row's OWN operator is always mounted. A stored filter that already
* reads `is_null` on a `select` column (a sharing rule's `$null`, a saved
* view's `is_null` rule) must still load as what it is: an unmounted operator
* draws a BLANK trigger (objectui#4768 / #7561), and nothing here rewrites a
* stored spelling (objectui#9306 folds spellings, never predicates).
*/
function offeredOperatorsForRow(
fieldType: string | undefined,
extraOperators: readonly string[] | undefined,
rowOperator: string,
): ReadonlyArray<{ value: string; label: string }> {
const drawable = operatorsForFieldType(fieldType, extraOperators)
if (emptyCheckArm(fieldType) !== "null_only") return drawable
const held = normalizeFilterBuilderOperator(rowOperator)
return drawable.filter((op) => !NULLNESS_PAIR.has(op.value) || op.value === held)
}

/**
* Does this row have NO value — the ONE reading of "empty" this component makes
* (objectui#4873).
Expand Down Expand Up @@ -1403,6 +1471,43 @@ function FilterBuilder({
return operatorsForFieldType(field?.type, extraOperators)
}

// What the row's operator dropdown mounts — see `offeredOperatorsForRow`.
const getOperatorsForRow = (condition: FilterBuilderCondition) => {
const field = fields.find((f) => f.value === condition.field)
return offeredOperatorsForRow(field?.type, extraOperators, condition.operator)
}

/**
* The line under the operator list that says how "Is empty" and "Is null"
* differ, for a dropdown that offers both (objectui#11810). Only a column
* whose type can hold an empty value that is not null keeps both pairs, and
* the hint names which empty value the extra pair counts. Nothing at all
* when the dropdown offers at most one of them.
*/
const renderEmptyCheckHint = (condition: FilterBuilderCondition): React.ReactNode => {
const ids = new Set(getOperatorsForRow(condition).map((op) => op.value))
if (!ids.has("is_empty") || !ids.has("is_null")) return null
const field = fields.find((f) => f.value === condition.field)
const labels = {
isEmpty: t("filterBuilder.operators.is_empty"),
isNull: t("filterBuilder.operators.is_null"),
}
const arm = emptyCheckArm(field?.type)
// A stored `is_null` held on a `null_only` column mounts both, and on that
// column they match the same records: there is no difference to explain.
if (arm === "null_only") return null
return (
<p
className="mt-1 border-t px-2 pb-1 pt-1.5 text-xs text-muted-foreground"
data-testid="filter-empty-check-hint"
>
{arm === "text"
? t("filterBuilder.emptyCheckHint.text", labels)
: t("filterBuilder.emptyCheckHint.list", labels)}
</p>
)
}

/**
* Change a row's field AND, when the new field's bucket no longer offers the
* row's operator, reset that operator — re-shaping the value for the family
Expand Down Expand Up @@ -1920,7 +2025,7 @@ function FilterBuilder({
// keeps its own spelling; only this comparison is folded.
value={mountedOperatorValue(
condition.operator,
getOperatorsForField(condition.field),
getOperatorsForRow(condition),
)}
// Radix hands back the `value` of a mounted `SelectItem`,
// and every one mounted below is a builder id; the guard is
Expand All @@ -1933,11 +2038,12 @@ function FilterBuilder({
<SelectValue placeholder={t('filterBuilder.operator')} />
</SelectTrigger>
<SelectContent>
{getOperatorsForField(condition.field).map((op) => (
{getOperatorsForRow(condition).map((op) => (
<SelectItem key={op.value} value={op.value}>
{t(`filterBuilder.operators.${op.value}`)}
</SelectItem>
))}
{renderEmptyCheckHint(condition)}
</SelectContent>
</Select>
</div>
Expand Down
Loading
Loading