Skip to content

🎯 Feature: Airtable Interfaces 风格 User Filters — 列表视图顶部过滤器(Dropdown / Tabs / Toggle 三模式) #637

@hotlong

Description

@hotlong

参考 Airtable Interfaces 的 User Filters 设计,为 ListView 工具栏新增 userFilters 配置,支持三种 Element 呈现模式:

  • Dropdown — 每个可过滤字段显示为独立的下拉选择器 badge(如 状态 ∨
  • Tabs — 预设过滤组合显示为选项卡(如 Tab | my customers | All records
  • Toggle — 过滤字段显示为开关切换按钮

Airtable 参考截图

Dropdown 模式 — 工具栏左侧显示字段级过滤 badge:
image1

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

当前状态

  • ListView.tsx 已有 Airtable 风格工具栏(Hide fields / Filter / Group / Sort / Color / Density / Search)
  • quickFilters 仅支持 toggle button 模式,无 dropdown 和 tabs 模式
  • 工具栏左侧无字段级过滤 badge,右侧工具按钮无法自动收纳

涉及的包和文件

文件 变更类型
@object-ui/types packages/types/src/objectql.ts Schema 扩展 — 新增 userFilters 属性
@object-ui/types packages/types/src/zod/objectql.zod.ts Zod schema 验证
@object-ui/plugin-list packages/plugin-list/src/UserFilters.tsx 新文件 — 三模式渲染组件
@object-ui/plugin-list packages/plugin-list/src/ListView.tsx 工具栏集成 + 状态管理
@object-ui/plugin-list packages/plugin-list/src/__tests__/UserFilters.test.tsx 新文件 — 单元测试

Schema 设计

// ListViewSchema extension
userFilters?: {
  /** UI element type: 'dropdown' | 'tabs' | 'toggle' */
  element: 'dropdown' | 'tabs' | 'toggle';

  /** Field-level filters (for dropdown & toggle modes) */
  fields?: Array<{
    field: string;
    label?: string;
    type?: 'select' | 'multi-select' | 'boolean' | 'date-range' | 'text';
    options?: Array<{ label: string; value: string | number | boolean; color?: string }>;
    showCount?: boolean;
    defaultValues?: (string | number | boolean)[];
  }>;

  /** Named filter presets (for tabs mode) */
  tabs?: Array<{
    id: string;
    label: string;
    filters: Array<any[] | string>;
    icon?: string;
    default?: boolean;
  }>;

  allowAddTab?: boolean;
  showAllRecords?: boolean;
};

使用示例

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"]] }
    ]
  }
}

验收标准

  • Schema: userFilters 类型定义完整,Zod 验证通过
  • Dropdown 模式: 渲染字段级 badge,点击展开选项列表,支持多选,显示计数
  • Tabs 模式: 渲染选项卡栏,切换 tab 应用对应过滤条件,支持 "All records" tab
  • Toggle 模式: 渲染开关按钮,点击切换激活状态
  • 数据联动: 选择过滤器后自动重新获取数据
  • 自动推导: 从 objectDef 自动推导字段选项(当未提供 options 时)
  • 向后兼容: 现有 quickFilters 继续工作
  • 测试: 覆盖三种模式的渲染、交互、数据联动
  • Roadmap: 更新 ROADMAP.md 标记此功能为 v1.0 UI Essentials 进行中

子任务

  • Sub-issue 1: Schema 扩展 + Zod 验证 (@object-ui/types)
  • Sub-issue 2: UserFilters 三模式渲染组件 (@object-ui/plugin-list)
  • Sub-issue 3: ListView 工具栏集成 + 测试 + Roadmap 更新

优先级

🔴 P0 — v1.0 UI Essentials,直接影响列表视图的用户体验,对标 Airtable Interfaces 核心交互模式

🔍 截图关键信息解读

从右侧配置面板可以看到:

User Filters 配置区域

配置项 含义
Elements 三个图标按钮(👁 / 📺 / 👁‍🗨) 过滤器的 展示模式:隐藏 / 下拉框(Dropdown) / 选项卡(Tab)
Tabs Tab, my customer... ⚙️ Tab 模式下的具体标签页配置

User Actions 配置区域

配置项 状态
Sort ✅ 开启
Search ✅ 开启
Filter ✅ 开启
Group ✅ 开启
Row height ✅ 开启

左侧实际效果

工具栏显示为:Tab | my customers | All records ➕ — 这就是 Tab 模式 的过滤器,而不是下拉框模式。


📐 Airtable 的两种 User Filter 模式

从截图分析,Airtable 的 Display Filter 支持三种 Element 模式:

  1. Hidden (👁) — 不显示过滤器
  2. Dropdown (📺) — 以下拉选择框形式展示(即之前截图中的 状态 ∨ 重要性标签 ∨
  3. Tab (👁‍🗨) — 以选项卡形式展示(即当前截图中的 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 双模式解析

从截图 image2 右侧的设置面板可以清楚看到 Airtable 的 User Filters 配置系统:

设置项 截图中的值 含义
Elements 三个图标切换(👁️ 下拉框 / 📺 选项卡 / 🔘 切换) 过滤器的 UI 呈现模式
Tabs Tab, my customer... ⚙️ 选项卡的具体配置(名称、过滤条件)

对照截图 image1 工具栏左侧的 状态 ∨ 重要性标签 ∨

  • 模式1:Dropdown — 过滤字段显示为下拉选择器(如 状态 ∨),用户从选项列表中选择筛选值
  • 模式2:Tabs — 过滤条件显示为选项卡(如截图2中 Tab my customers All records ⊕),用户点击标签切换不同的预设筛选视图
  • 模式3:Toggle — 过滤字段显示为开关按钮,适合布尔型过滤

两种模式的关键区别

模式1 Dropdown(下拉框模式):
┌──────────────────────────────────────────────────┐
│ [状态 ∨]  [重要性标签 ∨]       Group Filter Sort │  ← 每个字段独立过滤
└──────────────────────────────────────────────────┘

模式2 Tabs(选项卡模式):
┌──────────────────────────────────────────────────┐
│ Tab  my customers  All records [+]  Group Filter │  ← 预设组合过滤
└──────────────────────────────────────────────────┘

📊 ObjectUI 当前能力评估

查看现有代码,ObjectUI 已有部分基础设施可以复用:

✅ 已有可复用的能力

{/* Quick Filters Row */}
{schema.quickFilters && schema.quickFilters.length > 0 && (
  <div className="border-b px-2 sm:px-4 py-1 flex items-center gap-1 flex-wrap bg-background" data-testid="quick-filters">
    {schema.quickFilters.map(qf => {
      // ... renders toggle buttons
    })}
  </div>
)}
quickFilters?: Array<{
  id: string;
  label: string;
  filters: Array<any[] | string>;
  icon?: string;
  defaultActive?: boolean;
}>;

⚠️ 现有问题

能力 Airtable ObjectUI 现状 差距
Dropdown 模式 (字段级下拉) 状态 ∨ 重要性标签 ∨ ❌ 不存在 需要新增 FilterFieldBadge
Tabs 模式 (预设筛选标签) Tab my customers All records ⚠️ 部分 — quickFilters 只支持 toggle button,不支持 tabs 形态 需要升级为 tabs/buttons 双模式
Toggle 模式 (开关切换) 第三个图标 ⚠️ 部分 — quickFilters 就是 toggle 按钮 基本满足,需视觉优化
设置面板配置 (Elements 类型切换) 右侧设置面板 ❌ 不存在 需要在 ViewDesigner 中添加
Tabs 可配置 (添加/删除/重命名) 按钮,⚙️ 设置 ❌ 不存在 需要扩展 schema

🏗️ 完整优化方案

Phase 1: Schema 扩展 — userFilters 配置

ListViewSchema 中新增统一的 userFilters 配置,支持三种 Element 类型:

// packages/types/src/objectql.ts — ListViewSchema extension

/**
 * User Filters Configuration (Airtable Interfaces-style)
 * 
 * Supports three display modes configured by `element`:
 * - 'dropdown': Each field renders as a dropdown selector badge (e.g., "状态 ∨")
 * - 'tabs': Named filter presets rendered as tab bar (e.g., "Tab | my customers | All records")
 * - 'toggle': Each filter renders as an on/off toggle button
 */
userFilters?: {
  /** UI element type for displaying filters */
  element: 'dropdown' | 'tabs' | 'toggle';

  /** 
   * Field-level filter definitions (used by 'dropdown' and 'toggle' modes).
   * Each field appears as an independent filter control in the toolbar.
   */
  fields?: Array<{
    /** Field name to filter on */
    field: string;
    /** Display label (defaults to field label from objectDef) */
    label?: string;
    /** Filter input type */
    type?: 'select' | 'multi-select' | 'boolean' | 'date-range' | 'text';
    /** Static options (overrides auto-derived from objectDef) */
    options?: Array<{
      label: string;
      value: string | number | boolean;
      color?: string;
    }>;
    /** Show record count per option */
    showCount?: boolean;
    /** Default selected values */
    defaultValues?: (string | number | boolean)[];
  }>;

  /**
   * Named filter presets (used by 'tabs' mode).
   * Each tab represents a pre-configured filter combination.
   */
  tabs?: Array<{
    /** Unique tab identifier */
    id: string;
    /** Tab display label */
    label: string;
    /** Filter conditions to apply when this tab is active */
    filters: Array<any[] | string>;
    /** Icon name (Lucide icon identifier) */
    icon?: string;
    /** Whether this is the default active tab */
    default?: boolean;
  }>;

  /** Allow users to add new filter tabs at runtime */
  allowAddTab?: boolean;
  /** Show "All records" tab */
  showAllRecords?: boolean;
};

Phase 2: 三种 Element 渲染器

新增 UserFilters 组件,根据 element 类型渲染不同 UI:

// packages/plugin-list/src/UserFilters.tsx

import * as React from 'react';
import { cn, Button, Popover, PopoverContent, PopoverTrigger,
  Tabs, TabsList, TabsTrigger } from '@object-ui/components';
import { ChevronDown, X, Plus } from 'lucide-react';

interface UserFiltersProps {
  config: NonNullable<ListViewSchema['userFilters']>;
  /** Object definition for auto-deriving field options */
  objectDef?: any;
  /** Current data for computing counts */
  data?: any[];
  /** Callback when filter state changes */
  onFilterChange: (filters: any[]) => void;
  className?: string;
}

export function UserFilters({
  config,
  objectDef,
  data = [],
  onFilterChange,
  className,
}: UserFiltersProps) {
  switch (config.element) {
    case 'dropdown':
      return (
        <DropdownFilters
          fields={config.fields || []}
          objectDef={objectDef}
          data={data}
          onFilterChange={onFilterChange}
          className={className}
        />
      );
    case 'tabs':
      return (
        <TabFilters
          tabs={config.tabs || []}
          showAllRecords={config.showAllRecords !== false}
          allowAddTab={config.allowAddTab}
          onFilterChange={onFilterChange}
          className={className}
        />
      );
    case 'toggle':
      return (
        <ToggleFilters
          fields={config.fields || []}
          objectDef={objectDef}
          data={data}
          onFilterChange={onFilterChange}
          className={className}
        />
      );
    default:
      return null;
  }
}

// ============================================
// Dropdown Mode — Airtable "状态 ∨" style
// ============================================
function DropdownFilters({ fields, objectDef, data, onFilterChange, className }) {
  const [selectedValues, setSelectedValues] = React.useState<
    Record<string, (string | number | boolean)[]>
  >({});

  // Resolve options from objectDef if not provided statically
  const resolvedFields = React.useMemo(() => {
    return fields.map(f => {
      let options = f.options || [];
      if (options.length === 0 && objectDef?.fields?.[f.field]) {
        const fieldDef = objectDef.fields[f.field];
        if (fieldDef.options) {
          options = Object.entries(fieldDef.options).map(([value, meta]) => ({
            label: (meta as any)?.label || value,
            value,
            color: (meta as any)?.color,
          }));
        }
      }
      if (f.showCount && data.length > 0) {
        options = options.map(opt => ({
          ...opt,
          count: data.filter(row => row[f.field] === opt.value).length,
        }));
      }
      return { ...f, options };
    });
  }, [fields, objectDef, data]);

  const handleChange = (field: string, values: (string | number | boolean)[]) => {
    const next = { ...selectedValues, [field]: values };
    setSelectedValues(next);
    // Convert to filter AST
    const conditions = Object.entries(next)
      .filter(([, v]) => v.length > 0)
      .map(([field, values]) => [field, 'in', values]);
    onFilterChange(conditions);
  };

  return (
    <div className={cn('flex items-center gap-1 overflow-x-auto scrollbar-none', className)}>
      {resolvedFields.map(f => {
        const selected = selectedValues[f.field] || [];
        const hasSelection = selected.length > 0;

        return (
          <Popover key={f.field}>
            <PopoverTrigger asChild>
              <button
                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'
                )}
              >
                <span className="truncate max-w-[100px]">
                  {f.label || f.field}
                </span>
                {hasSelection && (
                  <span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px]">
                    {selected.length}
                  </span>
                )}
                {hasSelection ? (
                  <X className="h-3 w-3 opacity-60" onClick={(e) => { e.stopPropagation(); handleChange(f.field, []); }} />
                ) : (
                  <ChevronDown className="h-3 w-3 opacity-60" />
                )}
              </button>
            </PopoverTrigger>
            <PopoverContent align="start" className="w-56 p-2">
              <div className="max-h-60 overflow-y-auto space-y-0.5">
                {f.options.map(opt => (
                  <label
                    key={String(opt.value)}
                    className={cn(
                      'flex items-center gap-2 text-sm py-1.5 px-2 rounded cursor-pointer',
                      selected.includes(opt.value) ? 'bg-primary/5 text-primary' : 'hover:bg-muted'
                    )}
                  >
                    <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"
                    />
                    {opt.color && <span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: opt.color }} />}
                    <span className="truncate flex-1">{opt.label}</span>
                    {opt.count !== undefined && <span className="text-xs text-muted-foreground">{opt.count}</span>}
                  </label>
                ))}
              </div>
            </PopoverContent>
          </Popover>
        );
      })}
    </div>
  );
}

// ============================================
// Tabs Mode — Airtable "Tab | my customers | All records [+]"
// ============================================
function TabFilters({ tabs, showAllRecords, allowAddTab, onFilterChange, className }) {
  const [activeTab, setActiveTab] = React.useState<string>(() => {
    const defaultTab = tabs.find(t => t.default);
    return defaultTab?.id || (showAllRecords ? '__all__' : tabs[0]?.id || '');
  });

  const handleTabChange = (tabId: string) => {
    setActiveTab(tabId);
    if (tabId === '__all__') {
      onFilterChange([]);
    } else {
      const tab = tabs.find(t => t.id === tabId);
      onFilterChange(tab?.filters || []);
    }
  };

  const allTabs = React.useMemo(() => {
    const result = [...tabs];
    if (showAllRecords) {
      result.push({ id: '__all__', label: 'All records', filters: [], default: false });
    }
    return result;
  }, [tabs, showAllRecords]);

  return (
    <div className={cn('flex items-center gap-0.5 overflow-x-auto scrollbar-none', className)}>
      {allTabs.map(tab => (
        <button
          key={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
          className="inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted shrink-0"
          title="Add filter tab"
        >
          <Plus className="h-3.5 w-3.5" />
        </button>
      )}
    </div>
  );
}

// ============================================
// Toggle Mode — Quick on/off filter buttons
// ============================================
function ToggleFilters({ fields, objectDef, data, onFilterChange, className }) {
  const [activeToggles, setActiveToggles] = React.useState<Set<string>>(new Set());

  const handleToggle = (field: string) => {
    setActiveToggles(prev => {
      const next = new Set(prev);
      if (next.has(field)) next.delete(field);
      else next.add(field);
      // Build filters from active toggles
      const conditions = Array.from(next).map(f => {
        const fieldDef = fields.find(fd => fd.field === f);
        return fieldDef?.defaultValues
          ? [f, 'in', fieldDef.defaultValues]
          : [f, '!=', null];
      });
      onFilterChange(conditions);
      return next;
    });
  };

  return (
    <div className={cn('flex items-center gap-1 overflow-x-auto scrollbar-none', className)}>
      {fields.map(f => {
        const isActive = activeToggles.has(f.field);
        return (
          <Button
            key={f.field}
            variant={isActive ? 'default' : 'outline'}
            size="sm"
            className="h-7 px-3 text-xs shrink-0"
            onClick={() => handleToggle(f.field)}
          >
            {f.label || f.field}
          </Button>
        );
      })}
    </div>
  );
}

Phase 3: ListView Toolbar 集成

修改 ListView.tsx 工具栏,在左侧区域插入 UserFilters

{/* Airtable-style Toolbar — Row 2: Tool buttons */}
<div className="border-b px-2 sm:px-4 py-1 flex items-center justify-between gap-1 sm:gap-2 bg-background">
  {/* LEFT ZONE: User Filters */}
  <div className="flex items-center gap-1 overflow-hidden flex-1 min-w-0">
    {schema.userFilters ? (
      <UserFilters
        config={schema.userFilters}
        objectDef={objectDef}
        data={data}
        onFilterChange={handleUserFilterChange}
      />
    ) : (
      /* Fallback: Existing quick filters inline */
      schema.quickFilters?.map(qf => (/* ... existing toggle buttons ... */))
    )}
  </div>

  {/* RIGHT ZONE: Tool Actions (unchanged) */}
  <div className="flex items-center gap-0.5 shrink-0">
    {/* Group, Filter, Sort, Density, Search, ... */}
  </div>
</div>

Phase 4: 使用示例

Dropdown 模式 (对应截图1):

{
  "type": "list-view",
  "objectName": "accounts",
  "viewType": "grid",
  "fields": ["name", "industry", "region", "owner", "created_at"],
  "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" },
          { "label": "Startup", "value": "startup", "color": "#d97706" }
        ]
      }
    ]
  }
}

Tabs 模式 (对应截图2):

{
  "type": "list-view",
  "objectName": "accounts",
  "viewType": "grid",
  "fields": ["name", "industry", "region", "owner"],
  "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"]]
      }
    ]
  }
}

📦 向后兼容策略

现有属性 新方案 兼容策略
quickFilters userFilters.element: 'toggle' 保留 quickFilters,ListView 内部映射为 toggle 模式
displayFilters (上轮建议) userFilters.element: 'dropdown' 统一到 userFilters

🧪 测试计划

测试用例 目标
Dropdown mode renders field badges with chevron UserFilters 渲染
Dropdown mode toggles filter and updates data 数据联动
Tabs mode renders tab bar with active state TabFilters 渲染
Tabs mode switches filters on tab click 过滤切换
Toggle mode toggles button active state ToggleFilters 交互
All records tab clears all filters 全量显示
Auto-derives options from objectDef 自动推导选项
showCount displays correct counts 计数准确
Backward compat: quickFilters still works 向后兼容

📋 推进方式建议

这个功能复杂度较高,建议拆分执行:

  1. 创建 GitHub Issue — 将 userFilters 方案拆为 3 个子任务 (Schema 扩展 / ��种渲染器 / ListView 集成)
  2. 创建 PR — 直接在一个 PR 中实现完整功能(包括测试)
  3. 更新 ROADMAP.md — 将此任务标记为 v1.0 UI Essentials 下的工作项

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions