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/29052-clear-filters-clears-chips.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-list': patch
---

feat(plugin-list): the list's empty-state "Clear filters" also clears the user-filter chips, so a list emptied by a chip alone offers it

Clause-②: yes (widening)
Metadata-protocol: none

A list emptied by a user-filter chip alone (the quick-filter chips above the grid) said "No records match your current filters or search" and offered only "Add record": the chip kept its selection inside `UserFilters`, out of the list's reach. It now offers "Clear filters" as its primary action, as a list emptied by the filter panel or the "In a queue" toggle does, and one click clears the chips with the rest of the reader's query.

- **`UserFilters` takes an optional `ref`** that receives a `UserFiltersHandle` (exported from `@object-ui/plugin-list`) while a dropdown or toggle bar is mounted. Its `clear()` empties every chip, author defaults included, exactly as pressing each chip's × would: `onFilterChange` receives no conditions, and `onSelectionsChange` reports every chip field with an empty selection, so a host that persists selections per field (the console's `uf_*` URL params) drops each one. The selection stays the bar's own state, and the bar is not remounted. A host that passes no ref gets the bar unchanged.
- **Preset tabs are not cleared.** A tab is one of the view's presets, not a filter the reader added: tabs mode sets no handle, and a list a tab empties still offers no clear action.
- No metadata key, option or copy changes.
4 changes: 2 additions & 2 deletions ui/packages/plugin-list/src/ListEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ export interface ListEmptyStateProps {
/**
* Draws "Clear filters" as the primary action when supplied
* (objectstack#28908): the caller hands it only while a filter the READER
* applied (the filter panel's conditions, the "In a queue" toggle) is part of
* the query that came back empty. It removes the reader's whole query, the
* applied (the filter panel's conditions, the user-filter chips, the "In a
* queue" toggle) is part of the query that came back empty. It removes the reader's whole query, the
* search included, and never the view's declared filter. It is the one clear
* action: supplied, it replaces "Clear search", and the add-record action
* steps down to the secondary style.
Expand Down
33 changes: 23 additions & 10 deletions ui/packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { ViewSwitcherDropdown } from './ViewSwitcher';
import { ListEmptyState, hasDeclaredFilter, filterNamesCurrentUser } from './ListEmptyState';
import { ListViewCompactHeader, ListViewCompactRows, ListViewCappedRows, CAPPED_LIST_DEFAULT_LIMIT } from './ListViewCompact';
import { ViewSettingsPopover, type ViewSettingsRecordOpen } from './components/ViewSettingsPopover';
import { UserFilters } from './UserFilters';
import { UserFilters, type UserFiltersHandle } from './UserFilters';
import {
calendarWindowCovers,
calendarWindowFilter,
Expand Down Expand Up @@ -2173,6 +2173,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({

// User Filters State (Airtable Interfaces-style)
const [userFilterConditions, setUserFilterConditions] = React.useState<any[]>([]);
// The mounted chip bar's handle: the empty state's "Clear filters" clears
// the chips through it (objectstack#29052). The selection stays the bar's.
const userFiltersRef = React.useRef<UserFiltersHandle>(null);

// The user filter this component APPLIES — its fetch, the export, the node of
// a view that queries for itself, the empty-state copy. Empty while a
Expand Down Expand Up @@ -3926,16 +3929,17 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
}, [onSearchChange]);

// objectstack#28908 — the empty state's "Clear filters": removes the
// reader's whole query (the panel's conditions, the "In a queue" toggle and
// the search) and leaves the view's declared filter alone, which is not the
// reader's. The panel's group goes through the same emit as a panel edit, so
// the toolbar badge clears and the host stores the empty group. User-filter
// chips are not reset here: `UserFilters` holds its own selection and each
// chip clears in place.
// reader's whole query (the panel's conditions, the user-filter chips, the
// "In a queue" toggle and the search) and leaves the view's declared filter
// alone, which is not the reader's. The panel's group goes through the same
// emit as a panel edit, so the toolbar badge clears and the host stores the
// empty group. The chips clear through the bar's own handle, which reports
// the cleared selection to the host as a chip's × does (objectstack#29052).
const handleClearReaderQuery = React.useCallback(() => {
const cleared: FilterBuilderRowGroup = { ...currentFilters, conditions: [] };
setCurrentFilters(cleared);
onFilterChange?.(cleared);
userFiltersRef.current?.clear();
setSittingInQueueOnly(false);
if (searchInput) handleSearchChange('');
}, [currentFilters, onFilterChange, searchInput, handleSearchChange]);
Expand Down Expand Up @@ -5680,6 +5684,12 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
const cappedPosture = cappedWindow && surfaceDrawsFetchedRows;
// Either short posture draws none of the object list's chrome.
const listChromeShown = !compactPosture && !cappedPosture;
// The user-filter bar is on screen (the toolbar draws it below). Its chips
// are the reader's filter, so the empty state's "Clear filters" counts them;
// preset tabs are the view's presets and are not cleared.
const userFilterBarShown = listChromeShown && !!filterElements && toolbarFlags.showUserFilters;
const userFilterChipsClearable =
userFilterBarShown && filterElements?.element !== 'tabs' && appliedUserFilterConditions.length > 0;
// The records the query matched, once a load settles: the server's total, or
// the rows in hand when the whole result is in memory. The capped list's
// 「View all N」 and the tab badge (`onTotalChange`) both say this number.
Expand Down Expand Up @@ -5905,9 +5915,10 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
them visible (single line, scrollable) to match the Airtable
Interface pattern. Withheld on a dataset-bound chart, which they
cannot reach (`datasetChartOnScreen`, objectui#10327). */}
{filterElements && toolbarFlags.showUserFilters && (
{filterElements && userFilterBarShown && (
<div className="shrink-0 min-w-0 overflow-x-auto" data-testid="user-filters">
<UserFilters
ref={userFiltersRef}
config={filterElements}
objectDef={objectDef}
objectDefPending={!objectDefLoaded}
Expand Down Expand Up @@ -6821,9 +6832,11 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// emptied: the empty state offers the way out.
onClearSearch={searchTerm && searchTerm.trim() ? () => handleSearchChange('') : undefined}
// objectstack#28908 — a filter the reader applied (the panel's
// finished conditions, the queue toggle) is part of the empty
// query: the one clear action removes it, the search with it.
// finished conditions, the user-filter chips, the queue toggle) is
// part of the empty query: the one clear action removes it, the
// search with it.
onClearFilters={
userFilterChipsClearable ||
appliedQueueSittingConditions.length > 0 ||
(Array.isArray(appliedFilters?.conditions) && appliedFilterGroup(appliedFilters).conditions.length > 0)
? handleClearReaderQuery
Expand Down
58 changes: 53 additions & 5 deletions ui/packages/plugin-list/src/UserFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,26 @@ export interface UserFiltersProps {
* ObjectView/InterfaceListPage mirror them into `uf_*` URL params.
*/
onSelectionsChange?: (selections: Record<string, Array<string | number | boolean>>) => void;
/**
* Receives the bar's {@link UserFiltersHandle} while a dropdown or toggle
* bar is mounted (objectstack#29052). The selection stays the bar's own
* state; the handle only lets a host clear it, e.g. the list's empty-state
* "Clear filters". A host that passes no ref gets today's bar unchanged.
* Tabs mode sets no handle: a preset tab is a choice among the view's
* presets, not a filter the reader added.
*/
ref?: React.Ref<UserFiltersHandle>;
}

/** What a host can ask of a mounted user-filter bar (see `UserFiltersProps.ref`). */
export interface UserFiltersHandle {
/**
* Clears every chip, author defaults included, exactly as pressing each
* chip's × would: `onFilterChange` receives no conditions, and
* `onSelectionsChange` reports every chip field with an empty selection, so
* a host that persists selections (the `uf_*` URL params) drops them all.
*/
clear(): void;
}

/**
Expand Down Expand Up @@ -295,6 +315,7 @@ export function UserFilters({
className,
initialSelections,
onSelectionsChange,
ref,
}: UserFiltersProps) {
// The AUTHORING type (ADR-0053) only admits dropdown/tabs; stored metadata
// still carries the spec-deprecated `toggle`, which must keep rendering
Expand All @@ -312,6 +333,7 @@ export function UserFilters({
className={className}
initialSelections={initialSelections}
onSelectionsChange={onSelectionsChange}
handleRef={ref}
/>
);
case 'tabs':
Expand Down Expand Up @@ -339,6 +361,7 @@ export function UserFilters({
objectDefPending={objectDefPending}
onFilterChange={onFilterChange}
className={className}
handleRef={ref}
/>
);
default:
Expand Down Expand Up @@ -507,6 +530,7 @@ interface DropdownFiltersProps {
className?: string;
initialSelections?: Record<string, Array<string | number | boolean>>;
onSelectionsChange?: (selections: Record<string, Array<string | number | boolean>>) => void;
handleRef?: React.Ref<UserFiltersHandle>;
}

/** Selected values per field name, the dropdown bar's whole filter state. */
Expand Down Expand Up @@ -592,7 +616,7 @@ function typingOf(field: ResolvedField): string {
return JSON.stringify([field.type, field.options.map(o => o.value)]);
}

function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange }: DropdownFiltersProps) {
function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange, handleRef }: DropdownFiltersProps) {
const { fieldLabel, translateOptions } = useSafeFieldLabel();
const moreLabel = useMoreLabel();
const tt = useSafeTranslate();
Expand Down Expand Up @@ -680,14 +704,29 @@ function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterCh
[onFilterChange, controlKinds],
);

const handleChange = (field: string, values: (string | number | boolean)[]) => {
typingRef.current.set(field, null);
const next = { ...selectedValues, [field]: values };
// A selection the reader made: final for typing, applied, and reported to
// the host for persistence.
const commitSelection = (next: DropdownSelection, changed: string[]) => {
changed.forEach(field => typingRef.current.set(field, null));
setSelectedValues(next);
emitFilters(next);
onSelectionsChange?.(next);
};

const handleChange = (field: string, values: (string | number | boolean)[]) => {
commitSelection({ ...selectedValues, [field]: values }, [field]);
};

// `clear()` names every chip field, held or not: a host that persists the
// selection per field drops only the fields a report names.
React.useImperativeHandle(handleRef, () => ({
clear: () => {
const cleared: DropdownSelection = {};
for (const field of [...fields.map(f => f.field), ...Object.keys(selectedValues)]) cleared[field] = [];
commitSelection(cleared, Object.keys(cleared));
},
}));

// Emit default/restored filters on mount, coerced to the option value types
// (see `coerceToOptionTypes`) so the checkbox state and the query condition
// read the same typed value.
Expand Down Expand Up @@ -1288,9 +1327,10 @@ interface ToggleFiltersProps {
objectDefPending: boolean;
onFilterChange: (filters: any[]) => void;
className?: string;
handleRef?: React.Ref<UserFiltersHandle>;
}

function ToggleFilters({ fields, objectDefPending, onFilterChange, className }: ToggleFiltersProps) {
function ToggleFilters({ fields, objectDefPending, onFilterChange, className, handleRef }: ToggleFiltersProps) {
const [activeToggles, setActiveToggles] = React.useState<Set<string>>(() => {
const defaults = new Set<string>();
fields.forEach(f => {
Expand Down Expand Up @@ -1322,6 +1362,14 @@ function ToggleFilters({ fields, objectDefPending, onFilterChange, className }:
});
};

React.useImperativeHandle(handleRef, () => ({
clear: () => {
const none = new Set<string>();
setActiveToggles(none);
emitFilters(none);
},
}));

// Emit default filters on mount
React.useEffect(() => {
if (activeToggles.size > 0) emitFilters(activeToggles);
Expand Down
Loading
Loading