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
16 changes: 16 additions & 0 deletions .changeset/27520-list-toolbar-filter-sort-empty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/plugin-list': patch
'@object-ui/components': patch
'@object-ui/i18n': patch
---

fix(plugin-list): a filter row with no value is not a filter, a date sort reads Oldest → Newest, a sort keeps one row per field, and an empty list says how to start

Clause-②: yes (widening: `@object-ui/i18n` gains `list.clearSearch`, `sortBuilder.oldestFirst` and `sortBuilder.newestFirst` in all ten locales, and `SortBuilder`'s `fields` entries take an optional `type`; no accept set moves)
Metadata-protocol: none

- **Unfinished filter rows.** A filter-panel row with no value yet is no longer counted on the Filter badge, no longer turns the list into "No matching records", and is no longer handed to the host's `onFilterChange`, so the console does not store it as the user's list state and it does not come back after a reload. The panel still shows the row so it can be finished.
- **Date sort direction.** The sort picker names a `date` or `datetime` field's direction "Oldest → Newest" / "Newest → Oldest" instead of "A → Z" / "Z → A". The list hands the picker each field's type.
- **One sort row per field.** A field another sort row already orders by is unavailable in the other rows, "Add sort" starts on the first field no row holds, and it is disabled once every field has a row.
- **Clear search.** The "No matching records" a search produced offers "Clear search".
- **Create on an empty list.** An empty list offers Create whenever the host gives a create path and the user may create, without the view having to declare an inline `addRecord` block. An author's `addRecord: { enabled: false }` or `userActions.addRecordForm: false` still withholds it, and a view that declares the inline block keeps its Create as before.
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* 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.
*/

/**
* objectstack#27520 row 7, the sort half: `SortBuilder` names a date's
* direction as time, and keeps one row per field.
*
* Observed in the RC walkthrough on the Tasks list toolbar: the direction of a
* datetime sort read "A → Z", and the same field could be added to the sort
* twice. The second key on a field the first already orders by changes
* nothing, so a duplicate row is a control that does nothing.
*
* Every case reads the real `SortBuilder` and its real Radix `Select`, and
* keeps its control beside it: a text field still reads "A → Z", and a field
* no other row holds is still an ordinary option.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { cleanup, render, screen, fireEvent, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SortBuilder, type SortBuilderProps, type SortItem } from '../custom/sort-builder';

type Fields = NonNullable<SortBuilderProps['fields']>;
type Row = Pick<SortItem, 'field' | 'order'>;

const FIELDS: Fields = [
{ value: 'title', label: 'Title', type: 'text' },
{ value: 'due_date', label: 'Due Date', type: 'date' },
{ value: 'created_at', label: 'Created At', type: 'datetime' },
];

/** Holds the value the way a host does, so a change is rendered back. */
function Harness({ fields, initial, onChange }: { fields: Fields; initial: Row[]; onChange: (rows: Row[]) => void }) {
const [value, setValue] = React.useState<SortItem[]>(() =>
initial.map((row, i) => ({ id: `r${i}`, ...row })),
);
return (
<SortBuilder
fields={fields}
value={value}
onChange={(next) => {
onChange(next.map(({ field, order }) => ({ field, order })));
setValue(next);
}}
/>
);
}

function mount(initial: Row[], fields: Fields = FIELDS) {
const onChange = vi.fn();
render(<Harness fields={fields} initial={initial} onChange={onChange} />);
return onChange;
}

/** Each row is `[field select, direction select]`. */
const fieldTriggers = () => screen.getAllByRole('combobox').filter((_, i) => i % 2 === 0);
const directionTriggers = () => screen.getAllByRole('combobox').filter((_, i) => i % 2 === 1);

/** Open a dropdown and return what it offers, closing it again. */
function optionsOf(trigger: HTMLElement): Array<{ label: string; disabled: boolean }> {
fireEvent.click(trigger);
const listbox = screen.getByRole('listbox');
const options = within(listbox).getAllByRole('option').map((o) => ({
label: o.textContent?.trim() ?? '',
disabled: o.getAttribute('aria-disabled') === 'true',
}));
fireEvent.keyDown(listbox, { key: 'Escape' });
return options;
}

const asc = (field: string): Row => ({ field, order: 'asc' });

afterEach(() => {
cleanup();
});

describe('SortBuilder names a date sort as time (objectstack#27520 row 7)', () => {
it('a date row reads "Oldest → Newest" / "Newest → Oldest"; a datetime row too', () => {
mount([asc('due_date'), { field: 'created_at', order: 'desc' }]);
const [dateDir, datetimeDir] = directionTriggers();
expect(dateDir).toHaveTextContent('Oldest → Newest');
expect(datetimeDir).toHaveTextContent('Newest → Oldest');
expect(optionsOf(dateDir).map((o) => o.label)).toEqual(['Oldest → Newest', 'Newest → Oldest']);
});

it('CONTROL: a text row keeps "A → Z" / "Z → A"', () => {
mount([asc('title')]);
expect(directionTriggers()[0]).toHaveTextContent('A → Z');
expect(optionsOf(directionTriggers()[0]).map((o) => o.label)).toEqual(['A → Z', 'Z → A']);
});

it('the label follows the row when its field changes from text to date', () => {
mount([asc('title')]);
fireEvent.click(fieldTriggers()[0]);
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: 'Due Date' }));
expect(directionTriggers()[0]).toHaveTextContent('Oldest → Newest');
});

it('CONTROL: a field entry with no type keeps "A → Z"', () => {
mount([asc('due_date')], FIELDS.map(({ value, label }) => ({ value, label })));
expect(directionTriggers()[0]).toHaveTextContent('A → Z');
});
});

describe('SortBuilder keeps one row per field (objectstack#27520 row 7)', () => {
it('a field another row sorts by is unavailable in this row; the row\'s own field and the free ones are not', () => {
mount([asc('title'), asc('due_date')]);
expect(optionsOf(fieldTriggers()[1])).toEqual([
{ label: 'Title', disabled: true },
{ label: 'Due Date', disabled: false },
{ label: 'Created At', disabled: false },
]);
// CONTROL: the first row sees the second row's field the same way.
expect(optionsOf(fieldTriggers()[0])).toEqual([
{ label: 'Title', disabled: false },
{ label: 'Due Date', disabled: true },
{ label: 'Created At', disabled: false },
]);
});

it('"Add sort" seeds the first field no row holds, not the first field', () => {
const onChange = mount([asc('title')]);
fireEvent.click(screen.getByRole('button', { name: /add sort/i }));
expect(onChange).toHaveBeenLastCalledWith([asc('title'), asc('due_date')]);
});

it('"Add sort" is disabled once every field has a row', () => {
mount([asc('title'), asc('due_date'), asc('created_at')]);
expect(screen.getByRole('button', { name: /add sort/i })).toBeDisabled();
});

it('CONTROL: with a field still free, "Add sort" stays enabled', () => {
mount([asc('title'), asc('due_date')]);
expect(screen.getByRole('button', { name: /add sort/i })).toBeEnabled();
});
});
54 changes: 46 additions & 8 deletions ui/packages/components/src/custom/sort-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,17 @@ const useSafeSortTranslation = createSafeTranslation(
'sortBuilder.selectField': 'Select field',
'sortBuilder.ascending': 'A → Z',
'sortBuilder.descending': 'Z → A',
'sortBuilder.oldestFirst': 'Oldest → Newest',
'sortBuilder.newestFirst': 'Newest → Oldest',
'sortBuilder.addSort': 'Add sort',
'sortBuilder.removeSort': 'Remove sort',
},
'sortBuilder.sortBy',
)

/** Field types whose order is time: their direction reads "Oldest → Newest". */
const DATE_SORT_TYPES = new Set(['date', 'datetime'])

/**
* One row of the SortBuilder UI: the spec's `SortItem` (`{ field, order }`)
* plus the React key this list needs.
Expand Down Expand Up @@ -56,6 +61,12 @@ export interface SortBuilderProps {
* disabled, and is itself disabled when every entry is.
*/
disabled?: boolean
/**
* The field's type (objectstack#27520). A `date` or `datetime` row names
* its direction as time ("Oldest → Newest") instead of "A → Z". Absent,
* the row reads "A → Z" as before.
*/
type?: string
}>;
value?: SortItem[];
onChange?: (value: SortItem[]) => void;
Expand Down Expand Up @@ -97,9 +108,27 @@ export function SortBuilder({
onChange?.(newItems);
};

// One row per field (objectstack#27520): a second key on a field the sort
// already orders by changes nothing, so a field another row holds is not
// offered to this one. A row's own field stays choosable in its dropdown.
const heldBy = new Map<string, string>();
for (const item of items) if (item.field && !heldBy.has(item.field)) heldBy.set(item.field, item.id);
const takenElsewhere = (field: string, rowId: string) => {
const holder = heldBy.get(field);
return holder !== undefined && holder !== rowId;
};

// A new row starts on the first field the user may choose: a disabled
// entry is only there for the row that already names it.
const firstChoosable = fields.find((f) => !f.disabled);
// entry is only there for the row that already names it, and a field a row
// already sorts by is not offered again.
const firstChoosable = fields.find((f) => !f.disabled && !heldBy.has(f.value));

// A date's direction is time, not the alphabet (objectstack#27520).
const typeOf = new Map(fields.map((f) => [f.value, f.type]));
const directionLabels = (field: string) =>
DATE_SORT_TYPES.has(typeOf.get(field) ?? '')
? { asc: t('sortBuilder.oldestFirst'), desc: t('sortBuilder.newestFirst') }
: { asc: t('sortBuilder.ascending'), desc: t('sortBuilder.descending') };

const addItem = () => {
const newItem: SortItem = {
Expand All @@ -121,7 +150,9 @@ export function SortBuilder({
return (
<div className={cn("space-y-3", className)}>
<div className="space-y-2">
{items.map((item, index) => (
{items.map((item, index) => {
const direction = directionLabels(item.field);
return (
<div key={item.id} className="flex items-center gap-2">
<span className="text-sm font-medium w-16 text-muted-foreground">
{index === 0 ? t('sortBuilder.sortBy') : t('sortBuilder.thenBy')}
Expand All @@ -136,12 +167,18 @@ export function SortBuilder({
</SelectTrigger>
<SelectContent>
{fields.map(f => (
<SelectItem key={f.value} value={f.value} disabled={f.disabled}>{f.label}</SelectItem>
<SelectItem
key={f.value}
value={f.value}
disabled={f.disabled || takenElsewhere(f.value, item.id)}
>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-28">
<div className="w-36">
<Select
value={item.order}
onValueChange={(val) => updateItem(item.id, { order: val as 'asc' | 'desc' })}
Expand All @@ -150,8 +187,8 @@ export function SortBuilder({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc">{t('sortBuilder.ascending')}</SelectItem>
<SelectItem value="desc">{t('sortBuilder.descending')}</SelectItem>
<SelectItem value="asc">{direction.asc}</SelectItem>
<SelectItem value="desc">{direction.desc}</SelectItem>
</SelectContent>
</Select>
</div>
Expand All @@ -165,7 +202,8 @@ export function SortBuilder({
<X className="h-4 w-4" />
</Button>
</div>
))}
);
})}
</div>
<Button
variant="outline"
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,7 @@ const ar = {
firstRunMessage: "أنشئ سجلك الأول للبدء.",
noMatches: "لا توجد سجلات مطابقة",
noMatchesMessage: "لا توجد سجلات تطابق عوامل التصفية أو البحث الحالية. حاول تعديلها أو مسحها.",
clearSearch: "مسح البحث",
viewFilterNoMatchesMessage: "لا توجد سجلات تطابق عامل التصفية لطريقة العرض هذه.",
ownedNoneTitle: "لا يوجد شيء يخصك بعد",
ownedNoneMessage: "تعرض طريقة العرض هذه سجلاتك فقط، وليس لديك أي سجل بعد.",
Expand Down Expand Up @@ -5040,6 +5041,8 @@ const ar = {
selectField: "اختر الحقل",
ascending: "A → Z",
descending: "Z → A",
oldestFirst: "الأقدم → الأحدث",
newestFirst: "الأحدث → الأقدم",
addSort: "إضافة ترتيب",
removeSort: "إزالة الترتيب",
},
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@ const de = {
firstRunMessage: "Legen Sie Ihren ersten Datensatz an, um loszulegen.",
noMatches: "Keine passenden Datensätze",
noMatchesMessage: "Keine Datensätze entsprechen Ihren aktuellen Filtern oder Ihrer Suche. Passen Sie sie an oder setzen Sie sie zurück.",
clearSearch: "Suche löschen",
viewFilterNoMatchesMessage: "Keine Datensätze entsprechen dem Filter dieser Ansicht.",
ownedNoneTitle: "Noch nichts von Ihnen",
ownedNoneMessage: "Diese Ansicht listet nur Ihre eigenen Datensätze auf, und Sie haben noch keine.",
Expand Down Expand Up @@ -4587,6 +4588,8 @@ const de = {
selectField: "Feld auswählen",
ascending: "A → Z",
descending: "Z → A",
oldestFirst: "Älteste → Neueste",
newestFirst: "Neueste → Älteste",
addSort: "Sortierung hinzufügen",
removeSort: "Sortierung entfernen",
},
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,7 @@ const en = {
firstRunMessage: 'Create your first record to get started.',
noMatches: 'No matching records',
noMatchesMessage: 'No records match your current filters or search. Try adjusting or clearing them.',
clearSearch: 'Clear search',
viewFilterNoMatchesMessage: 'No records match this view’s filter.',
ownedNoneTitle: 'Nothing of yours yet',
ownedNoneMessage: 'This view lists only records that are yours, and you don’t have any yet.',
Expand Down Expand Up @@ -5248,6 +5249,8 @@ const en = {
selectField: 'Select field',
ascending: 'A → Z',
descending: 'Z → A',
oldestFirst: 'Oldest → Newest',
newestFirst: 'Newest → Oldest',
addSort: 'Add sort',
removeSort: 'Remove sort',
},
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@ const es = {
firstRunMessage: "Cree su primer registro para empezar.",
noMatches: "No hay registros coincidentes",
noMatchesMessage: "Ningún registro coincide con sus filtros o su búsqueda actuales. Pruebe a ajustarlos o borrarlos.",
clearSearch: "Borrar búsqueda",
viewFilterNoMatchesMessage: "Ningún registro coincide con el filtro de esta vista.",
ownedNoneTitle: "Aún no tiene nada propio",
ownedNoneMessage: "Esta vista solo muestra sus propios registros, y todavía no tiene ninguno.",
Expand Down Expand Up @@ -4701,6 +4702,8 @@ const es = {
selectField: "Seleccionar campo",
ascending: "A → Z",
descending: "Z → A",
oldestFirst: "Más antiguo → Más reciente",
newestFirst: "Más reciente → Más antiguo",
addSort: "Agregar orden",
removeSort: "Quitar orden",
},
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ const fr = {
firstRunMessage: "Créez votre premier enregistrement pour commencer.",
noMatches: "Aucun enregistrement correspondant",
noMatchesMessage: "Aucun enregistrement ne correspond à vos filtres ou à votre recherche. Essayez de les ajuster ou de les effacer.",
clearSearch: "Effacer la recherche",
viewFilterNoMatchesMessage: "Aucun enregistrement ne correspond au filtre de cette vue.",
ownedNoneTitle: "Rien à vous pour l'instant",
ownedNoneMessage: "Cette vue ne liste que vos propres enregistrements, et vous n'en avez encore aucun.",
Expand Down Expand Up @@ -4697,6 +4698,8 @@ const fr = {
selectField: "Sélectionner un champ",
ascending: "A → Z",
descending: "Z → A",
oldestFirst: "Plus ancien → Plus récent",
newestFirst: "Plus récent → Plus ancien",
addSort: "Ajouter un tri",
removeSort: "Supprimer le tri",
},
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@ const ja = {
firstRunMessage: "最初のレコードを作成して始めましょう。",
noMatches: "該当するレコードがありません",
noMatchesMessage: "現在のフィルターや検索条件に一致するレコードはありません。条件を調整するか解除してください。",
clearSearch: "検索をクリア",
viewFilterNoMatchesMessage: "このビューのフィルターに一致するレコードはありません。",
ownedNoneTitle: "あなたのレコードはまだありません",
ownedNoneMessage: "このビューにはあなたのレコードだけが表示されますが、まだ1件もありません。",
Expand Down Expand Up @@ -4589,6 +4590,8 @@ const ja = {
selectField: "フィールドを選択",
ascending: "A → Z",
descending: "Z → A",
oldestFirst: "古い順",
newestFirst: "新しい順",
addSort: "並べ替えを追加",
removeSort: "並べ替えを削除",
},
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@ const ko = {
firstRunMessage: "첫 레코드를 만들어 시작하세요.",
noMatches: "일치하는 레코드가 없습니다",
noMatchesMessage: "현재 필터나 검색어와 일치하는 레코드가 없습니다. 조건을 조정하거나 지워 보세요.",
clearSearch: "검색 지우기",
viewFilterNoMatchesMessage: "이 뷰의 필터와 일치하는 레코드가 없습니다.",
ownedNoneTitle: "아직 내 레코드가 없습니다",
ownedNoneMessage: "이 뷰에는 내 레코드만 표시되며, 아직 하나도 없습니다.",
Expand Down Expand Up @@ -4586,6 +4587,8 @@ const ko = {
selectField: "필드 선택",
ascending: "A → Z",
descending: "Z → A",
oldestFirst: "오래된 순",
newestFirst: "최신 순",
addSort: "정렬 추가",
removeSort: "정렬 삭제",
},
Expand Down
Loading
Loading