Skip to content

Commit cac0bda

Browse files
os-zhuangclaude
andcommitted
refactor: remove view-level tabs (schema.tabs); userFilters is sole page filter (ADR-0053)
Validated against Airtable's Interface Designer: "User filters" (None/Tabs/ Dropdown) is the end-user filter control; a tab is just {name, filter} and never switches the view form (that's "Visualizations", a separate axis). ObjectUI's schema.tabs carried a "view" switch, conflating filter and form, so it is removed: - types: drop ListViewSchema.tabs - plugin-list ListView: remove activeTab state, mount tab-filter effect, handleTabChange, the TabBar/TabBarSelect render row; simplify the mutually-exclusive guard to filterElements = userFilters - plugin-view ObjectView: stop passing tabs through - app-shell ObjectView: drop the tabs:undefined suppression - react spec-bridge: stop mapping spec.tabs (clean-build; check rule flags it) - TabBar component kept (still used by plugin-view's view switcher) - tests: remove schema.tabs "tabs rendering" suite; userFilters tabs tests stay Full monorepo type-check 58/58; affected tests 197 pass. Browser: object list shows one switcher row, no legacy tab row, list loads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9934edb commit cac0bda

6 files changed

Lines changed: 2 additions & 163 deletions

File tree

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,11 +1266,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
12661266
?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t),
12671267
),
12681268
aria: viewDef.aria ?? listSchema.aria,
1269-
// ADR-0047 — per-view filter tabs from metadata (ViewTab presets)
1270-
// take precedence over schema-level tabs. Dropping viewDef.tabs
1271-
// here was the one-line gap that kept spec tab metadata from
1272-
// ever reaching the TabBar.
1273-
tabs: undefined, // ADR-0053: see note above (views mode)
12741269
// Propagate filter/sort as default filters/sort for data flow
12751270
...((() => {
12761271
const combined = [

packages/plugin-list/src/ListView.tsx

Lines changed: 2 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import type { SortItem } from '@object-ui/components';
1212
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Rows4, Rows3, Rows2, Share2, Printer, Plus, Trash2, CheckSquare, AlertTriangle, RotateCw, icons, type LucideIcon } from 'lucide-react';
1313
import type { FilterGroup } from '@object-ui/components';
1414
import { ViewSwitcherDropdown, ViewType } from './ViewSwitcher';
15-
import { TabBar, TabBarSelect } from './components/TabBar';
16-
import type { ViewTab } from './components/TabBar';
1715
import { ViewSettingsPopover } from './components/ViewSettingsPopover';
1816
import { UserFilters } from './UserFilters';
1917
import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
@@ -465,61 +463,6 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
465463
conditions: []
466464
});
467465

468-
// Tab State. A URL-restored tab (`userFilterSelections._tab`) wins over
469-
// the author's `isDefault` (ADR-0047 filter persistence).
470-
const [activeTab, setActiveTab] = React.useState<string | undefined>(() => {
471-
if (!schema.tabs || schema.tabs.length === 0) return undefined;
472-
const restored = userFilterSelections?._tab?.[0];
473-
if (typeof restored === 'string' && schema.tabs.some((t: any) => t.name === restored)) {
474-
return restored;
475-
}
476-
const defaultTab = schema.tabs.find((t: any) => t.isDefault);
477-
return defaultTab?.name ?? schema.tabs[0]?.name;
478-
});
479-
480-
// Apply the initially-active tab's filter on mount so a restored tab
481-
// scopes the first fetch the same way a user click would.
482-
React.useEffect(() => {
483-
const tab = schema.tabs?.find((t: any) => t.name === activeTab);
484-
if (tab && Array.isArray(tab.filter) && tab.filter.length > 0) {
485-
const conditions = tab.filter
486-
.filter((r: any) => r && typeof r.field === 'string')
487-
.map((r: any) => ({ field: r.field, operator: r.operator ?? 'equals', value: r.value }));
488-
setCurrentFilters({ id: `tab-filter-${tab.name}`, logic: 'and', conditions });
489-
}
490-
// eslint-disable-next-line react-hooks/exhaustive-deps
491-
}, []);
492-
493-
const handleTabChange = React.useCallback(
494-
(tab: ViewTab) => {
495-
setActiveTab(tab.name);
496-
onUserFilterSelectionsChange?.({ _tab: [tab.name] });
497-
// Apply tab filter if defined. Two shapes are accepted:
498-
// - @objectstack/spec ViewTab.filter: ViewFilterRule[] — an array of
499-
// `{ field, operator, value }` rules (the canonical metadata shape)
500-
// - legacy FilterGroup `{ logic, conditions }`
501-
if (tab.filter) {
502-
const conditions = Array.isArray(tab.filter)
503-
? tab.filter
504-
.filter((r: any) => r && typeof r.field === 'string')
505-
.map((r: any) => ({ field: r.field, operator: r.operator ?? 'equals', value: r.value }))
506-
: (tab.filter.conditions || []);
507-
const tabFilters: FilterGroup = {
508-
id: `tab-filter-${tab.name}`,
509-
logic: (!Array.isArray(tab.filter) && tab.filter.logic) || 'and',
510-
conditions,
511-
};
512-
setCurrentFilters(tabFilters);
513-
onFilterChange?.(tabFilters);
514-
} else {
515-
const emptyFilters: FilterGroup = { id: 'root', logic: 'and', conditions: [] };
516-
setCurrentFilters(emptyFilters);
517-
onFilterChange?.(emptyFilters);
518-
}
519-
},
520-
[onFilterChange, onUserFilterSelectionsChange],
521-
);
522-
523466
// Data State
524467
const dataSource = props.dataSource;
525468
const [data, setData] = React.useState<any[]>([]);
@@ -654,11 +597,8 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
654597
return { ...configured, fields: derivedFields };
655598
}, [schema.userFilters, objectDef, tFieldLabel]);
656599

657-
// Tabs and filter elements are mutually exclusive on the toolbar
658-
// (Airtable parity: the Elements choice is tabs OR dropdowns, never
659-
// both). Metadata tabs win — they are author-curated named presets; a
660-
// userFilters config on the same view only renders when no tabs exist.
661-
const filterElements = schema.tabs && schema.tabs.length > 0 ? undefined : resolvedUserFilters;
600+
// ADR-0053: userFilters (dropdown | tabs) is the sole page filter control.
601+
const filterElements = resolvedUserFilters;
662602

663603
// Hidden Fields State (initialized from schema)
664604
const [hiddenFields, setHiddenFields] = React.useState<Set<string>>(
@@ -1596,29 +1536,6 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
15961536
read as one segmented control rather than a loose bag of icons. */}
15971537
<div className="border-b px-2 sm:px-4 py-1.5 flex items-center justify-between gap-1 sm:gap-2 bg-background">
15981538
<div className="flex items-center gap-2 overflow-x-auto min-w-0">
1599-
{/* View Tabs — split between desktop tab row and a mobile
1600-
dropdown so phones still get a view switcher (compact, one
1601-
button) without burning a full row on chip pills. */}
1602-
{schema.tabs && schema.tabs.length > 0 && (
1603-
<>
1604-
<div className="hidden sm:block shrink-0">
1605-
<TabBar
1606-
tabs={schema.tabs}
1607-
activeTab={activeTab}
1608-
onTabChange={handleTabChange}
1609-
className="!px-0 !py-0"
1610-
/>
1611-
</div>
1612-
<div className="sm:hidden shrink-0">
1613-
<TabBarSelect
1614-
tabs={schema.tabs}
1615-
activeTab={activeTab}
1616-
onTabChange={handleTabChange}
1617-
className="!px-0 !py-0"
1618-
/>
1619-
</div>
1620-
</>
1621-
)}
16221539
{/* User Filters — filter elements (dropdown chips / preset tabs /
16231540
toggles). Mutually exclusive with view tabs above, so at most
16241541
one filter element group ever renders here. On mobile we keep

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 0 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,61 +1355,6 @@ describe('ListView', () => {
13551355
});
13561356
});
13571357

1358-
// ============================
1359-
// tabs rendering
1360-
// ============================
1361-
describe('tabs rendering', () => {
1362-
it('should render view tabs when configured', () => {
1363-
const schema: ListViewSchema = {
1364-
type: 'list-view',
1365-
objectName: 'contacts',
1366-
viewType: 'grid',
1367-
fields: ['name', 'email'],
1368-
tabs: [
1369-
{ name: 'all', label: 'All Records', isDefault: true },
1370-
{ name: 'active', label: 'Active' },
1371-
],
1372-
};
1373-
1374-
renderWithProvider(<ListView schema={schema} />);
1375-
expect(screen.getByTestId('view-tabs')).toBeInTheDocument();
1376-
expect(screen.getByTestId('view-tab-all')).toBeInTheDocument();
1377-
expect(screen.getByTestId('view-tab-active')).toBeInTheDocument();
1378-
expect(screen.getAllByText('All Records').length).toBeGreaterThan(0);
1379-
expect(screen.getAllByText('Active').length).toBeGreaterThan(0);
1380-
});
1381-
1382-
it('should not render tabs when not configured', () => {
1383-
const schema: ListViewSchema = {
1384-
type: 'list-view',
1385-
objectName: 'contacts',
1386-
viewType: 'grid',
1387-
fields: ['name', 'email'],
1388-
};
1389-
1390-
renderWithProvider(<ListView schema={schema} />);
1391-
expect(screen.queryByTestId('view-tabs')).not.toBeInTheDocument();
1392-
});
1393-
1394-
it('should filter out hidden tabs', () => {
1395-
const schema: ListViewSchema = {
1396-
type: 'list-view',
1397-
objectName: 'contacts',
1398-
viewType: 'grid',
1399-
fields: ['name', 'email'],
1400-
tabs: [
1401-
{ name: 'all', label: 'All Records' },
1402-
{ name: 'hidden', label: 'Hidden Tab', visible: 'false' },
1403-
],
1404-
};
1405-
1406-
renderWithProvider(<ListView schema={schema} />);
1407-
expect(screen.getByTestId('view-tabs')).toBeInTheDocument();
1408-
expect(screen.getAllByText('All Records').length).toBeGreaterThan(0);
1409-
expect(screen.queryByText('Hidden Tab')).not.toBeInTheDocument();
1410-
});
1411-
});
1412-
14131358
// ============================
14141359
// userActions toolbar control
14151360
// ============================

packages/plugin-view/src/ObjectView.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -990,7 +990,6 @@ export const ObjectView: React.FC<ObjectViewProps> = ({
990990
virtualScroll: activeView?.virtualScroll ?? (schema as any).virtualScroll,
991991
emptyState: activeView?.emptyState ?? (schema as any).emptyState,
992992
aria: activeView?.aria ?? (schema as any).aria,
993-
tabs: (schema as any).tabs,
994993
// Propagate refresh signal so ListView re-fetches after mutations
995994
refreshTrigger: refreshKey,
996995
},

packages/react/src/spec-bridge/bridges/list-view.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ export const bridgeListView: BridgeFn<ListViewSpec> = (
161161
if (spec.userActions) node.userActions = spec.userActions;
162162
if (spec.appearance) node.appearance = spec.appearance;
163163
if (spec.compactToolbar != null) node.compactToolbar = spec.compactToolbar;
164-
if (spec.tabs) node.tabs = spec.tabs;
165164
if (spec.addRecord) node.addRecord = spec.addRecord;
166165
if (spec.showRecordCount != null) node.showRecordCount = spec.showRecordCount;
167166
if (spec.allowPrinting != null) node.allowPrinting = spec.allowPrinting;

packages/types/src/objectql.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1882,22 +1882,6 @@ export interface ListViewSchema extends BaseSchema {
18821882
allowedVisualizations?: string[];
18831883
};
18841884

1885-
/**
1886-
* View tabs configuration.
1887-
* Aligned with @objectstack/spec ListViewSchema.tabs.
1888-
*/
1889-
tabs?: Array<{
1890-
name: string;
1891-
label: string;
1892-
icon?: string;
1893-
view?: string;
1894-
filter?: any;
1895-
order?: number;
1896-
pinned?: boolean;
1897-
isDefault?: boolean;
1898-
visible?: string;
1899-
}>;
1900-
19011885
/**
19021886
* Add record configuration.
19031887
* Aligned with @objectstack/spec ListViewSchema.addRecord.

0 commit comments

Comments
 (0)