Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- Add userFilters type definition to ListViewSchema in @object-ui/types - Add Zod validation schema for userFilters configuration - Create UserFilters component with 3 rendering modes (dropdown/tabs/toggle) - Integrate UserFilters into ListView toolbar with data refetch - Export UserFilters from plugin-list - Add 25 unit tests for UserFilters component - Add 5 Zod validation tests for userFilters schema - Update ROADMAP.md to mark userFilters as completed Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds Airtable Interfaces-style user filters to ListView, supporting three distinct filter presentation modes: dropdown (field-level badges), tabs (named filter presets), and toggle (on/off buttons). The implementation spans schema definitions, component logic, and comprehensive test coverage.
Changes:
- Extends
ListViewSchemawith newuserFiltersproperty supporting dropdown/tabs/toggle modes - Implements
UserFilterscomponent with mode-specific renderers and auto-derivation of field options from objectDef - Integrates user filters into ListView's data fetching pipeline alongside existing quickFilters and FilterBuilder
- Adds 25 component tests and 5 Zod validation tests covering all modes and edge cases
- Updates ROADMAP to mark P0.4 ListView spec property as complete
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/types/src/objectql.ts | Adds userFilters TypeScript interface with detailed JSDoc for three element modes (dropdown/tabs/toggle) |
| packages/types/src/zod/objectql.zod.ts | Defines Zod validators for UserFilterOption, UserFilterField, UserFilterTab, and UserFilters schemas |
| packages/types/src/tests/phase2-schemas.test.ts | Adds 5 validation tests covering dropdown, tabs, toggle modes, invalid input, and backward compatibility |
| packages/plugin-list/src/UserFilters.tsx | Implements main UserFilters component with three mode-specific renderers (DropdownFilters, TabFilters, ToggleFilters) and shared option resolution logic |
| packages/plugin-list/src/tests/UserFilters.test.tsx | Comprehensive test suite with 25 tests covering rendering, interaction, multi-select, counts, colors, defaults, and edge cases |
| packages/plugin-list/src/index.tsx | Exports UserFilters component and UserFiltersProps type for public API |
| packages/plugin-list/src/ListView.tsx | Integrates userFilters state into data fetching pipeline, renders UserFilters component below quickFilters row, passes objectDef and data props |
| ROADMAP.md | Marks userFilters spec property implementation as complete in P0.4 section |
| if (f.showCount && data.length > 0) { | ||
| options = options.map(opt => ({ | ||
| ...opt, | ||
| count: data.filter(row => row[f.field] === opt.value).length, |
There was a problem hiding this comment.
The record count calculation on line 126 uses the filtered data array, which means counts will reflect already-filtered results rather than the full dataset. This creates potentially confusing UX where:
- User sees "Active: 10" in the filter dropdown
- User selects "Active"
- Counts update to reflect only active records (e.g., "Active: 10, Inactive: 0")
This differs from Airtable's behavior where counts typically show the total available records for each option before filtering. Consider either:
- Passing unfiltered data separately for count computation
- Computing counts server-side
- Documenting this behavior clearly if intentional
| <input | ||
| type="checkbox" | ||
| checked={selected.includes(opt.value)} | ||
| onChange={() => { | ||
| const next = selected.includes(opt.value) | ||
| ? selected.filter(v => v !== opt.value) | ||
| : [...selected, opt.value]; | ||
| handleChange(f.field, next); | ||
| }} | ||
| className="rounded border-input" |
There was a problem hiding this comment.
The checkbox input should have an aria-label or aria-labelledby attribute for better accessibility. While the wrapping <label> provides some context visually, screen readers may not properly associate the checkbox with its label text since the label content includes multiple elements (color dot, text, count).
Consider adding:
<input
type="checkbox"
aria-label={opt.label}
checked={selected.includes(opt.value)}
onChange={() => { /* ... */ }}
className="rounded border-input"
/>This ensures screen readers announce the option name when focusing on the checkbox.
| return ( | ||
| <div className={cn('flex items-center gap-0.5 overflow-x-auto', className)} data-testid="user-filters-tabs"> | ||
| {allTabs.map(tab => ( | ||
| <button | ||
| key={tab.id} | ||
| data-testid={`filter-tab-${tab.id}`} | ||
| onClick={() => handleTabChange(tab.id)} | ||
| className={cn( | ||
| 'inline-flex items-center h-7 px-3 text-xs font-medium rounded-md transition-colors shrink-0', | ||
| activeTab === tab.id | ||
| ? 'bg-primary text-primary-foreground' | ||
| : 'text-muted-foreground hover:text-foreground hover:bg-muted', | ||
| )} | ||
| > | ||
| {tab.label} | ||
| </button> | ||
| ))} | ||
| {allowAddTab && ( | ||
| <button |
There was a problem hiding this comment.
The tab buttons should use proper ARIA tab semantics for better accessibility. Currently they're plain buttons, but they should follow the WAI-ARIA tabs pattern:
- Wrap in a
<div role="tablist"> - Add
role="tab",aria-selected, andaria-controlsto each button - Support keyboard navigation (Arrow keys, Home, End)
Alternatively, if these are meant to be toggle buttons rather than true tabs, consider using aria-pressed to indicate the active state.
This ensures screen reader users understand the component's purpose and can navigate it efficiently.
| return ( | |
| <div className={cn('flex items-center gap-0.5 overflow-x-auto', className)} data-testid="user-filters-tabs"> | |
| {allTabs.map(tab => ( | |
| <button | |
| key={tab.id} | |
| data-testid={`filter-tab-${tab.id}`} | |
| onClick={() => handleTabChange(tab.id)} | |
| className={cn( | |
| 'inline-flex items-center h-7 px-3 text-xs font-medium rounded-md transition-colors shrink-0', | |
| activeTab === tab.id | |
| ? 'bg-primary text-primary-foreground' | |
| : 'text-muted-foreground hover:text-foreground hover:bg-muted', | |
| )} | |
| > | |
| {tab.label} | |
| </button> | |
| ))} | |
| {allowAddTab && ( | |
| <button | |
| const handleKeyDown = React.useCallback( | |
| (event: React.KeyboardEvent<HTMLDivElement>) => { | |
| if (!allTabs.length) return; | |
| const currentIndex = allTabs.findIndex(tab => tab.id === activeTab); | |
| const lastIndex = allTabs.length - 1; | |
| let nextIndex = currentIndex === -1 ? 0 : currentIndex; | |
| switch (event.key) { | |
| case 'ArrowRight': | |
| case 'ArrowDown': { | |
| event.preventDefault(); | |
| nextIndex = currentIndex === -1 || currentIndex === lastIndex ? 0 : currentIndex + 1; | |
| break; | |
| } | |
| case 'ArrowLeft': | |
| case 'ArrowUp': { | |
| event.preventDefault(); | |
| nextIndex = currentIndex <= 0 ? lastIndex : currentIndex - 1; | |
| break; | |
| } | |
| case 'Home': { | |
| event.preventDefault(); | |
| nextIndex = 0; | |
| break; | |
| } | |
| case 'End': { | |
| event.preventDefault(); | |
| nextIndex = lastIndex; | |
| break; | |
| } | |
| default: | |
| return; | |
| } | |
| const nextTab = allTabs[nextIndex]; | |
| if (!nextTab) return; | |
| handleTabChange(nextTab.id); | |
| const nextButton = document.getElementById(`filter-tab-${nextTab.id}`); | |
| if (nextButton && typeof (nextButton as HTMLElement).focus === 'function') { | |
| (nextButton as HTMLElement).focus(); | |
| } | |
| }, | |
| [allTabs, activeTab, handleTabChange], | |
| ); | |
| return ( | |
| <div className={cn('flex items-center gap-0.5 overflow-x-auto', className)} data-testid="user-filters-tabs"> | |
| <div role="tablist" aria-label="Filter tabs" onKeyDown={handleKeyDown} className="flex gap-0.5"> | |
| {allTabs.map(tab => ( | |
| <button | |
| key={tab.id} | |
| id={`filter-tab-${tab.id}`} | |
| type="button" | |
| role="tab" | |
| aria-selected={activeTab === tab.id} | |
| aria-controls={`${tab.id}-panel`} | |
| tabIndex={activeTab === tab.id ? 0 : -1} | |
| data-testid={`filter-tab-${tab.id}`} | |
| onClick={() => handleTabChange(tab.id)} | |
| className={cn( | |
| 'inline-flex items-center h-7 px-3 text-xs font-medium rounded-md transition-colors shrink-0', | |
| activeTab === tab.id | |
| ? 'bg-primary text-primary-foreground' | |
| : 'text-muted-foreground hover:text-foreground hover:bg-muted', | |
| )} | |
| > | |
| {tab.label} | |
| </button> | |
| ))} | |
| </div> | |
| {allowAddTab && ( | |
| <button | |
| type="button" |
| <button | ||
| className="inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted shrink-0" | ||
| data-testid="filter-tab-add" | ||
| title="Add filter tab" |
There was a problem hiding this comment.
The add tab button should have an aria-label in addition to the title attribute. Screen readers often don't announce title attributes consistently. Add:
<button
aria-label="Add filter tab"
title="Add filter tab"
className="..."
data-testid="filter-tab-add"
>
<Plus className="h-3.5 w-3.5" />
</button>Also note that this button currently has no onClick handler, so it appears to be a placeholder for future functionality.
| title="Add filter tab" | |
| title="Add filter tab" | |
| aria-label="Add filter tab" |
| // Merge base filters, user filters, quick filters, and user filter bar conditions | ||
| const allFilters = [ | ||
| ...(baseFilter.length > 0 ? [baseFilter] : []), | ||
| ...(userFilter.length > 0 ? [userFilter] : []), | ||
| ...quickFilterConditions, | ||
| ...userFilterConditions, | ||
| ]; |
There was a problem hiding this comment.
The comment on line 321 could be more descriptive about what each filter source represents:
baseFilter(schema.filters): Static filters defined in the schemauserFilter(currentFilters): Filters from the FilterBuilder UI (advanced filter panel)quickFilterConditions: Filters from toggle-style quick filter buttonsuserFilterConditions: NEW - Filters from Airtable-style user filter bar (dropdown/tabs/toggle)
Consider updating the comment to clarify these distinctions, especially since userFilters is a new addition. For example:
// Merge all filter sources:
// 1. Base filters (schema.filters) - static schema-level filters
// 2. FilterBuilder (currentFilters) - advanced filter UI
// 3. Quick filters (schema.quickFilters) - toggle buttons
// 4. User filters (schema.userFilters) - Airtable-style dropdown/tabs/toggleThis helps future developers understand the complete filter pipeline.
| field: string; | ||
| /** Display label (defaults to field label from objectDef) */ | ||
| label?: string; | ||
| /** Filter input type */ |
There was a problem hiding this comment.
The type field defined in the schema (line 1228 in objectql.ts and line 211 in objectql.zod.ts) is not used in the UserFilters implementation. The field accepts values like 'select', 'multi-select', 'boolean', 'date-range', 'text', but the current implementation always treats filters as multi-select (using the 'in' operator).
If this field is reserved for future use, consider adding a comment explaining this. Otherwise, the implementation should use this field to determine:
- Filter operator ('=' for single select vs 'in' for multi-select)
- Input UI (checkbox vs radio buttons)
- Value validation
| /** Filter input type */ | |
| /** | |
| * Filter input type (UI and behavior hint). | |
| * | |
| * This value is intended to guide: | |
| * - Which operator is used (e.g. '=' for 'select' / 'boolean', 'in' for 'multi-select') | |
| * - Which input widget is rendered (e.g. radios vs checkboxes vs text input) | |
| * - Basic value validation (e.g. date range vs free text) | |
| * | |
| * Note: Current implementations of UserFilters MAY still treat all filters as | |
| * multi-select (using the 'in' operator) and ignore this field. It is kept in | |
| * the schema for forward compatibility so renderers/engines can progressively | |
| * adopt richer, type-aware behavior without breaking existing views. | |
| */ |
| <button | ||
| data-testid={`filter-badge-${f.field}`} | ||
| className={cn( | ||
| 'inline-flex items-center gap-1 rounded-md border h-7 px-2.5 text-xs font-medium transition-colors shrink-0', | ||
| hasSelection | ||
| ? 'border-primary/30 bg-primary/5 text-primary' | ||
| : 'border-border bg-background hover:bg-accent text-foreground', | ||
| )} | ||
| > |
There was a problem hiding this comment.
The filter badge button lacks proper accessibility attributes. Screen reader users won't know what the button does or what field it filters. Add an aria-label that describes the button's purpose, for example:
<button
aria-label={`Filter by ${f.label || f.field}`}
data-testid={`filter-badge-${f.field}`}
// ... rest of props
>This ensures the button is properly announced to assistive technologies.
| <X | ||
| className="h-3 w-3 opacity-60" | ||
| data-testid={`filter-clear-${f.field}`} | ||
| onClick={e => { | ||
| e.stopPropagation(); | ||
| handleChange(f.field, []); | ||
| }} | ||
| /> |
There was a problem hiding this comment.
The clear filter X icon lacks an aria-label, making it inaccessible to screen reader users. They won't know what clicking this icon does. Add an aria-label:
<X
className="h-3 w-3 opacity-60"
aria-label={`Clear ${f.label || f.field} filter`}
data-testid={`filter-clear-${f.field}`}
onClick={e => {
e.stopPropagation();
handleChange(f.field, []);
}}
/>Note: Since the X is rendered inside a button, you may also need to ensure the click handler doesn't interfere with the parent button's Popover trigger behavior.
| {/* User Filters Row (Airtable Interfaces-style) */} | ||
| {schema.userFilters && ( | ||
| <div className="border-b px-2 sm:px-4 py-1 bg-background" data-testid="user-filters"> | ||
| <UserFilters | ||
| config={schema.userFilters} | ||
| objectDef={objectDef} | ||
| data={data} | ||
| onFilterChange={setUserFilterConditions} | ||
| /> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
The userFilters feature lacks integration tests with ListView. While UserFilters.test.tsx comprehensively tests the component in isolation, there are no tests verifying:
- The filters are properly rendered in the ListView toolbar
- Filter changes trigger data refetching with correct filter conditions
- userFilters work alongside existing quickFilters and FilterBuilder
- The data dependency (passing filtered data for counts) works correctly
Consider adding integration tests in ListView.test.tsx similar to the existing quickFilters tests (line 265-282), such as:
it('should render user filters when configured', () => {
const schema: ListViewSchema = {
type: 'list-view',
objectName: 'contacts',
userFilters: {
element: 'dropdown',
fields: [{ field: 'status', label: 'Status' }]
}
};
renderWithProvider(<ListView schema={schema} />);
expect(screen.getByTestId('user-filters')).toBeInTheDocument();
});
Adds
userFiltersconfiguration toListViewSchema, supporting three Airtable Interfaces-style filter display modes: dropdown badges, tab presets, and toggle buttons.Schema (
@object-ui/types)userFiltersproperty onListViewSchemawithelement: 'dropdown' | 'tabs' | 'toggle'fields[]) for dropdown/toggle modes with options, colors, counts, defaultstabs[]) for tabs mode with "All records" and add-tab supportUserFilterOptionSchema,UserFilterFieldSchema,UserFilterTabSchema,UserFiltersSchemaComponent (
@object-ui/plugin-list)UserFilters.tsx— dispatches toDropdownFilters,TabFilters, orToggleFiltersbased onconfig.elementdefaultValuesor!= nullconditionsobjectDefwhenoptionsnot provided staticallyListView integration
userFilterConditionsstate merged into the data fetch filter pipeline alongside base/quick/builder filtersquickFiltersunaffectedUsage
{ "type": "list-view", "objectName": "accounts", "userFilters": { "element": "dropdown", "fields": [ { "field": "status", "label": "Status", "type": "multi-select", "showCount": true }, { "field": "priority", "label": "Priority", "options": [ { "label": "High", "value": "high", "color": "#dc2626" } ]} ] } }Tests
Original prompt
This section details on the original issue you should resolve
<issue_title>🎯 Feature: Airtable Interfaces 风格 User Filters — 列表视图顶部过滤器(Dropdown / Tabs / Toggle 三模式)</issue_title>
<issue_description>参考 Airtable Interfaces 的 User Filters 设计,为 ListView 工具栏新增
userFilters配置,支持三种 Element 呈现模式:状态 ∨)Tab | my customers | All records)Airtable 参考截图
Dropdown 模式 — 工具栏左侧显示字段级过滤 badge:

Tabs 模式 + 设置面板 — 右侧面板中 Elements 和 Tabs 配置:

当前状态
ListView.tsx已有 Airtable 风格工具栏(Hide fields / Filter / Group / Sort / Color / Density / Search)quickFilters仅支持 toggle button 模式,无 dropdown 和 tabs 模式涉及的包和文件
@object-ui/typespackages/types/src/objectql.tsuserFilters属性@object-ui/typespackages/types/src/zod/objectql.zod.ts@object-ui/plugin-listpackages/plugin-list/src/UserFilters.tsx@object-ui/plugin-listpackages/plugin-list/src/ListView.tsx@object-ui/plugin-listpackages/plugin-list/src/__tests__/UserFilters.test.tsxSchema 设计
使用示例
Dropdown 模式:
{ "type": "list-view", "objectName": "accounts", "userFilters": { "element": "dropdown", "fields": [ { "field": "status", "label": "状态", "type": "multi-select", "showCount": true }, { "field": "priority_tag", "label": "重要性标签", "type": "multi-select", "options": [ { "label": "Enterprise", "value": "enterprise", "color": "#dc2626" }, { "label": "SMB", "value": "smb", "color": "#2563eb" } ] } ] } }Tabs 模式:
{ "type": "list-view", "objectName": "accounts", "userFilters": { "element": "tabs", "showAllRecords": true, "allowAddTab": true, "tabs": [ { "id": "tab-1", "label": "Tab", "filters": [["status", "=", "active"]], "default": true }, { "id": "tab-2", "label": "my customers", "filters": [["owner", "=", "$currentUser"]] } ] } }验收标准
userFilters类型定义完整,Zod 验证通过quickFilters继续工作子任务
@object-ui/types)@object-ui/plugin-list)优先级
🔴 P0 — v1.0 UI Essentials,直接影响列表视图的用户体验,对标 Airtable Interfaces 核心交互模式
🔍 截图关键信息解读
从右侧配置面板可以看到:
User Filters 配置区域
Tab, my customer...⚙️User Actions 配置区域
左侧实际效果
工具栏显示为:
Tab|my customers|All records ➕— 这就是 Tab 模式 的过滤器,而不是下拉框模式。📐 Airtable 的两种 User Filter 模式
从截图分析,Airtable 的 Display Filter 支持三种 Element 模式:
状态 ∨重要性标签 ∨)Tab|my customers|All records)🏗️ ObjectUI 中的实现方案
让我先检查现有的相关实现基础:Now let me look at how the ObjectView handles the right-side settings panel pattern shown in the screenshot:根据你提供的两张截图的深入分析,我现在清晰理解了 Airtable Interfaces 的 User Filters 双模式设计。让我做详细的对比和方案��计。
🔍 Airtable Interfaces User Filters 双模式解析
从截图
右侧的设置面板可以清楚看到 Airtable 的 User Filters 配置系统:
Tab, my customer...⚙️对照截图
工具栏左侧的
状态 ∨重要性标签 ∨:💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.