From 427b6e9deac59032e6c939f2fb9a28bab9bbba47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:12:09 +0000 Subject: [PATCH 1/4] Initial plan From c5779fcb698e21a3cdf2ae18439ffb668a77ba8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:19:58 +0000 Subject: [PATCH 2/4] feat(plugin-list): add TabBar component with active tab state, filter switching, and i18n Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 1 + packages/i18n/src/locales/ar.ts | 2 + packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + packages/i18n/src/locales/es.ts | 2 + packages/i18n/src/locales/fr.ts | 2 + packages/i18n/src/locales/ja.ts | 2 + packages/i18n/src/locales/ko.ts | 2 + packages/i18n/src/locales/pt.ts | 2 + packages/i18n/src/locales/ru.ts | 2 + packages/i18n/src/locales/zh.ts | 2 + packages/plugin-list/src/ListView.tsx | 62 +++--- .../plugin-list/src/__tests__/TabBar.test.tsx | 199 ++++++++++++++++++ .../plugin-list/src/components/TabBar.tsx | 115 ++++++++++ packages/plugin-list/src/index.tsx | 2 + 15 files changed, 374 insertions(+), 25 deletions(-) create mode 100644 packages/plugin-list/src/__tests__/TabBar.test.tsx create mode 100644 packages/plugin-list/src/components/TabBar.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 5dc8a37d6..66d8d82ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -704,6 +704,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [x] Column `summary`: `summary` property added to ListViewSchema column type. Bridge passes through for aggregation rendering. ObjectGrid renders summary footer with count/sum/avg/min/max aggregations via `useColumnSummary` hook. Zod schema updated with `summary` field. - [x] Column `link`: ObjectGrid renders click-to-navigate buttons on link-type columns with `navigation.handleClick`. Primary field auto-linked. - [x] Column `action`: ObjectGrid renders action dispatch buttons via `executeAction` on action-type columns. +- [x] `tabs` (ViewTabSchema): TabBar component renders view tabs above the ListView toolbar. Supports icon (Lucide), pinned (always visible), isDefault (auto-selected), visible filtering, order sorting, and active tab state. Tab switch applies filter config. Extracted as reusable `TabBar` component in `packages/plugin-list/src/components/TabBar.tsx`. i18n keys added for all 10 locales. **P2 — Advanced Features:** - [x] `rowActions`: Row-level dropdown action menu per row in ObjectGrid. `schema.rowActions` string array items rendered as dropdown menu items, dispatched via `executeAction`. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 78c574f0f..44ea0667c 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -102,6 +102,8 @@ const ar = { recordCount: '{{count}} سجلات', recordCountOne: '{{count}} سجل', addRecord: 'إضافة سجل', + tabs: 'علامات التبويب', + allRecords: 'جميع السجلات', }, kanban: { addCard: 'إضافة بطاقة', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d79de08a2..dfd515352 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -101,6 +101,8 @@ const de = { recordCount: '{{count}} Datensätze', recordCountOne: '{{count}} Datensatz', addRecord: 'Datensatz hinzufügen', + tabs: 'Tabs', + allRecords: 'Alle Datensätze', }, kanban: { addCard: 'Karte hinzufügen', diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8522c898a..378bf6d1b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -101,6 +101,8 @@ const en = { recordCount: '{{count}} records', recordCountOne: '{{count}} record', addRecord: 'Add record', + tabs: 'Tabs', + allRecords: 'All Records', }, kanban: { addCard: 'Add card', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 3cd1ed9bf..2efdeb91d 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -101,6 +101,8 @@ const es = { recordCount: '{{count}} registros', recordCountOne: '{{count}} registro', addRecord: 'Agregar registro', + tabs: 'Pestañas', + allRecords: 'Todos los registros', }, kanban: { addCard: 'Añadir tarjeta', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 2cd91d6dc..777939ff6 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -101,6 +101,8 @@ const fr = { recordCount: '{{count}} enregistrements', recordCountOne: '{{count}} enregistrement', addRecord: 'Ajouter un enregistrement', + tabs: 'Onglets', + allRecords: 'Tous les enregistrements', }, kanban: { addCard: 'Ajouter une carte', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 7b5cdc943..d95a71ee8 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -101,6 +101,8 @@ const ja = { recordCount: '{{count}} 件のレコード', recordCountOne: '{{count}} 件のレコード', addRecord: 'レコードを追加', + tabs: 'タブ', + allRecords: 'すべてのレコード', }, kanban: { addCard: 'カードを追加', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 4ef44aec3..fcbec2c9d 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -101,6 +101,8 @@ const ko = { recordCount: '{{count}}개 레코드', recordCountOne: '{{count}}개 레코드', addRecord: '레코드 추가', + tabs: '탭', + allRecords: '전체 레코드', }, kanban: { addCard: '카드 추가', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 347350d53..576f8fefc 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -101,6 +101,8 @@ const pt = { recordCount: '{{count}} registros', recordCountOne: '{{count}} registro', addRecord: 'Adicionar registro', + tabs: 'Abas', + allRecords: 'Todos os registros', }, kanban: { addCard: 'Adicionar cartão', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 48502c540..bc7211021 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -101,6 +101,8 @@ const ru = { recordCount: '{{count}} записей', recordCountOne: '{{count}} запись', addRecord: 'Добавить запись', + tabs: 'Вкладки', + allRecords: 'Все записи', }, kanban: { addCard: 'Добавить карточку', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 5382241ca..0c700a1b5 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -101,6 +101,8 @@ const zh = { recordCount: '{{count}} 条记录', recordCountOne: '{{count}} 条记录', addRecord: '添加记录', + tabs: '标签页', + allRecords: '全部记录', }, kanban: { addCard: '添加卡片', diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 980b4acce..5aa4451bd 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -12,6 +12,8 @@ import type { SortItem } from '@object-ui/components'; import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Share2, Printer, Plus, icons, type LucideIcon } from 'lucide-react'; import type { FilterGroup } from '@object-ui/components'; import { ViewSwitcher, ViewType } from './ViewSwitcher'; +import { TabBar } from './components/TabBar'; +import type { ViewTab } from './components/TabBar'; import { UserFilters } from './UserFilters'; import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react'; import { useDensityMode } from '@object-ui/react'; @@ -203,6 +205,8 @@ const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.refreshing': 'Refreshing…', 'list.dataLimitReached': 'Showing first {{limit}} records. More data may be available.', 'list.addRecord': 'Add record', + 'list.tabs': 'Tabs', + 'list.allRecords': 'All Records', }; /** @@ -317,6 +321,34 @@ export const ListView: React.FC = ({ conditions: [] }); + // Tab State + const [activeTab, setActiveTab] = React.useState(() => { + if (!schema.tabs || schema.tabs.length === 0) return undefined; + const defaultTab = schema.tabs.find(t => t.isDefault); + return defaultTab?.name ?? schema.tabs[0]?.name; + }); + + const handleTabChange = React.useCallback( + (tab: ViewTab) => { + setActiveTab(tab.name); + // Apply tab filter if defined + if (tab.filter) { + const tabFilters: FilterGroup = { + id: 'tab-filter', + logic: tab.filter.logic || 'and', + conditions: tab.filter.conditions || [], + }; + setCurrentFilters(tabFilters); + onFilterChange?.(tabFilters); + } else { + const emptyFilters: FilterGroup = { id: 'root', logic: 'and', conditions: [] }; + setCurrentFilters(emptyFilters); + onFilterChange?.(emptyFilters); + } + }, + [onFilterChange], + ); + // Data State const dataSource = props.dataSource; const [data, setData] = React.useState([]); @@ -965,31 +997,11 @@ export const ListView: React.FC = ({ {/* View Tabs */} {schema.tabs && schema.tabs.length > 0 && ( -
- {schema.tabs - // Spec defines visible as string (expression), but also handle boolean false for convenience - .filter(tab => tab.visible !== 'false' && tab.visible !== (false as any)) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map(tab => { - const TabIcon: LucideIcon | null = tab.icon - ? ((icons as Record)[ - tab.icon.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('') - ] ?? null) - : null; - return ( - - ); - })} -
+ )} {/* View Description */} diff --git a/packages/plugin-list/src/__tests__/TabBar.test.tsx b/packages/plugin-list/src/__tests__/TabBar.test.tsx new file mode 100644 index 000000000..68cf8eeeb --- /dev/null +++ b/packages/plugin-list/src/__tests__/TabBar.test.tsx @@ -0,0 +1,199 @@ +/** + * 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. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TabBar } from '../components/TabBar'; +import type { ViewTab } from '../components/TabBar'; + +describe('TabBar', () => { + const baseTabs: ViewTab[] = [ + { name: 'all', label: 'All Records', isDefault: true }, + { name: 'active', label: 'Active' }, + { name: 'archived', label: 'Archived' }, + ]; + + it('should render tab bar with tabs', () => { + render(); + expect(screen.getByTestId('view-tabs')).toBeInTheDocument(); + expect(screen.getByTestId('view-tab-all')).toBeInTheDocument(); + expect(screen.getByTestId('view-tab-active')).toBeInTheDocument(); + expect(screen.getByTestId('view-tab-archived')).toBeInTheDocument(); + }); + + it('should render tab labels', () => { + render(); + expect(screen.getByText('All Records')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Archived')).toBeInTheDocument(); + }); + + it('should not render when tabs array is empty', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + expect(screen.queryByTestId('view-tabs')).not.toBeInTheDocument(); + }); + + // isDefault tab should be selected by default + it('should select isDefault tab initially', () => { + render(); + const defaultTab = screen.getByTestId('view-tab-all'); + expect(defaultTab).toHaveAttribute('aria-selected', 'true'); + const otherTab = screen.getByTestId('view-tab-active'); + expect(otherTab).toHaveAttribute('aria-selected', 'false'); + }); + + it('should select first tab when no isDefault is set', () => { + const tabs: ViewTab[] = [ + { name: 'alpha', label: 'Alpha' }, + { name: 'beta', label: 'Beta' }, + ]; + render(); + expect(screen.getByTestId('view-tab-alpha')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('view-tab-beta')).toHaveAttribute('aria-selected', 'false'); + }); + + // Tab click changes active tab + it('should switch active tab on click', () => { + render(); + const activeTab = screen.getByTestId('view-tab-active'); + fireEvent.click(activeTab); + expect(activeTab).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('view-tab-all')).toHaveAttribute('aria-selected', 'false'); + }); + + it('should call onTabChange callback on click', () => { + const onTabChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('view-tab-active')); + expect(onTabChange).toHaveBeenCalledTimes(1); + expect(onTabChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'active', label: 'Active' })); + }); + + // Pinned tabs always visible + it('should always show pinned tabs even if visible is false', () => { + const tabs: ViewTab[] = [ + { name: 'all', label: 'All', isDefault: true }, + { name: 'pinned-tab', label: 'Pinned', pinned: true, visible: 'false' }, + { name: 'hidden', label: 'Hidden', visible: 'false' }, + ]; + render(); + expect(screen.getByTestId('view-tab-pinned-tab')).toBeInTheDocument(); + expect(screen.queryByTestId('view-tab-hidden')).not.toBeInTheDocument(); + }); + + // Hidden tabs filtered out + it('should filter out hidden tabs (visible: "false")', () => { + const tabs: ViewTab[] = [ + { name: 'all', label: 'All Records' }, + { name: 'hidden', label: 'Hidden Tab', visible: 'false' }, + ]; + render(); + expect(screen.getByText('All Records')).toBeInTheDocument(); + expect(screen.queryByText('Hidden Tab')).not.toBeInTheDocument(); + }); + + it('should filter out tabs with visible: boolean false', () => { + const tabs: ViewTab[] = [ + { name: 'all', label: 'All Records' }, + { name: 'hidden', label: 'Hidden Tab', visible: false as any }, + ]; + render(); + expect(screen.getByText('All Records')).toBeInTheDocument(); + expect(screen.queryByText('Hidden Tab')).not.toBeInTheDocument(); + }); + + // Order sorting + it('should sort tabs by order property', () => { + const tabs: ViewTab[] = [ + { name: 'c', label: 'Third', order: 3 }, + { name: 'a', label: 'First', order: 1 }, + { name: 'b', label: 'Second', order: 2 }, + ]; + render(); + const tabContainer = screen.getByTestId('view-tabs'); + const buttons = tabContainer.querySelectorAll('button'); + expect(buttons[0]).toHaveTextContent('First'); + expect(buttons[1]).toHaveTextContent('Second'); + expect(buttons[2]).toHaveTextContent('Third'); + }); + + // Icon rendering + it('should render Lucide icon when icon prop is provided', () => { + const tabs: ViewTab[] = [ + { name: 'starred', label: 'Starred', icon: 'star' }, + ]; + render(); + const tab = screen.getByTestId('view-tab-starred'); + // Lucide icons render as SVG elements + const svg = tab.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + + it('should handle kebab-case icon names', () => { + const tabs: ViewTab[] = [ + { name: 'test', label: 'Test', icon: 'arrow-right' }, + ]; + render(); + const tab = screen.getByTestId('view-tab-test'); + const svg = tab.querySelector('svg'); + expect(svg).toBeTruthy(); + }); + + it('should not render icon when icon is not provided', () => { + const tabs: ViewTab[] = [ + { name: 'noicon', label: 'No Icon' }, + ]; + render(); + const tab = screen.getByTestId('view-tab-noicon'); + const svg = tab.querySelector('svg'); + expect(svg).toBeFalsy(); + }); + + // Controlled activeTab + it('should respect controlled activeTab prop', () => { + render(); + expect(screen.getByTestId('view-tab-archived')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('view-tab-all')).toHaveAttribute('aria-selected', 'false'); + }); + + // Tab role attributes + it('should have proper ARIA attributes', () => { + render(); + const tabBar = screen.getByTestId('view-tabs'); + expect(tabBar).toHaveAttribute('role', 'tablist'); + const tab = screen.getByTestId('view-tab-all'); + expect(tab).toHaveAttribute('role', 'tab'); + }); + + // Tab with filter config + it('should pass tab with filter to onTabChange', () => { + const onTabChange = vi.fn(); + const tabs: ViewTab[] = [ + { name: 'all', label: 'All', isDefault: true }, + { + name: 'active', + label: 'Active', + filter: { logic: 'and', conditions: [{ field: 'status', operator: 'eq', value: 'active' }] }, + }, + ]; + render(); + fireEvent.click(screen.getByTestId('view-tab-active')); + expect(onTabChange).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'active', + filter: expect.objectContaining({ + logic: 'and', + conditions: expect.arrayContaining([ + expect.objectContaining({ field: 'status' }), + ]), + }), + }), + ); + }); +}); diff --git a/packages/plugin-list/src/components/TabBar.tsx b/packages/plugin-list/src/components/TabBar.tsx new file mode 100644 index 000000000..3326e63c5 --- /dev/null +++ b/packages/plugin-list/src/components/TabBar.tsx @@ -0,0 +1,115 @@ +/** + * 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. + */ + +import * as React from 'react'; +import { cn, Button } from '@object-ui/components'; +import { icons, type LucideIcon } from 'lucide-react'; + +export interface ViewTab { + name: string; + label: string; + icon?: string; + view?: string; + filter?: any; + order?: number; + pinned?: boolean; + isDefault?: boolean; + visible?: string; +} + +export interface TabBarProps { + tabs: ViewTab[]; + activeTab?: string; + onTabChange?: (tab: ViewTab) => void; + className?: string; +} + +/** + * Resolve a kebab-case or lowercase Lucide icon name to a LucideIcon component. + * E.g. "arrow-right" → ArrowRight, "star" → Star + */ +function resolveIcon(iconName?: string): LucideIcon | null { + if (!iconName) return null; + const pascalCase = iconName + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(''); + return (icons as Record)[pascalCase] ?? null; +} + +/** + * Filter visible tabs: exclude tabs where visible is 'false' or boolean false. + * Pinned tabs are always included regardless of other filtering. + */ +function getVisibleTabs(tabs: ViewTab[]): ViewTab[] { + return tabs + .filter(tab => tab.pinned || (tab.visible !== 'false' && tab.visible !== (false as any))) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +/** + * TabBar renders a row of view tabs above the ListView toolbar. + * Supports icons (resolved via Lucide), pinned tabs, isDefault selection, + * and emits tab changes with filter/sort configuration. + */ +export const TabBar: React.FC = ({ + tabs, + activeTab: controlledActiveTab, + onTabChange, + className, +}) => { + const visibleTabs = React.useMemo(() => getVisibleTabs(tabs), [tabs]); + + // Determine the default tab: first isDefault tab, or first tab + const defaultTab = React.useMemo(() => { + const def = visibleTabs.find(t => t.isDefault); + return def?.name ?? visibleTabs[0]?.name; + }, [visibleTabs]); + + const [internalActiveTab, setInternalActiveTab] = React.useState(defaultTab); + + const activeTabName = controlledActiveTab ?? internalActiveTab; + + const handleTabClick = React.useCallback( + (tab: ViewTab) => { + setInternalActiveTab(tab.name); + onTabChange?.(tab); + }, + [onTabChange], + ); + + if (visibleTabs.length === 0) return null; + + return ( +
+ {visibleTabs.map(tab => { + const TabIcon = resolveIcon(tab.icon); + const isActive = activeTabName === tab.name; + return ( + + ); + })} +
+ ); +}; diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index b1b9d0250..503af83f4 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -12,6 +12,8 @@ import { ViewSwitcher } from './ViewSwitcher'; import { ObjectGallery } from './ObjectGallery'; export { ListView, ViewSwitcher, ObjectGallery }; +export { TabBar } from './components/TabBar'; +export type { TabBarProps, ViewTab } from './components/TabBar'; export { UserFilters } from './UserFilters'; export type { UserFiltersProps } from './UserFilters'; export { evaluateConditionalFormatting, normalizeFilterCondition, normalizeFilters } from './ListView'; From 2eabfc8f7dd1ead7fc6b47fca77062e587628344 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:20:53 +0000 Subject: [PATCH 3/4] fix: address code review - type-safe visible prop, unique filter IDs Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugin-list/src/ListView.tsx | 2 +- packages/plugin-list/src/components/TabBar.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 5aa4451bd..ec408b716 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -334,7 +334,7 @@ export const ListView: React.FC = ({ // Apply tab filter if defined if (tab.filter) { const tabFilters: FilterGroup = { - id: 'tab-filter', + id: `tab-filter-${tab.name}`, logic: tab.filter.logic || 'and', conditions: tab.filter.conditions || [], }; diff --git a/packages/plugin-list/src/components/TabBar.tsx b/packages/plugin-list/src/components/TabBar.tsx index 3326e63c5..efc224ac5 100644 --- a/packages/plugin-list/src/components/TabBar.tsx +++ b/packages/plugin-list/src/components/TabBar.tsx @@ -19,7 +19,7 @@ export interface ViewTab { order?: number; pinned?: boolean; isDefault?: boolean; - visible?: string; + visible?: string | boolean; } export interface TabBarProps { @@ -48,7 +48,7 @@ function resolveIcon(iconName?: string): LucideIcon | null { */ function getVisibleTabs(tabs: ViewTab[]): ViewTab[] { return tabs - .filter(tab => tab.pinned || (tab.visible !== 'false' && tab.visible !== (false as any))) + .filter(tab => tab.pinned || (tab.visible !== 'false' && tab.visible !== false)) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } From 4347f4fa5918665b82e2e4f4df4c5c4c5bdf7201 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:43:15 +0000 Subject: [PATCH 4/4] feat: integrate TabBar tabs propagation into console and plugin-view Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/components/ObjectView.tsx | 1 + packages/plugin-view/src/ObjectView.tsx | 1 + .../config-sync-integration.test.tsx | 35 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx index 702c56c61..88d71275a 100644 --- a/apps/console/src/components/ObjectView.tsx +++ b/apps/console/src/components/ObjectView.tsx @@ -344,6 +344,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { virtualScroll: viewDef.virtualScroll ?? listSchema.virtualScroll, emptyState: viewDef.emptyState ?? listSchema.emptyState, aria: viewDef.aria ?? listSchema.aria, + tabs: listSchema.tabs, // Propagate filter/sort as default filters/sort for data flow ...(viewDef.filter?.length ? { filters: viewDef.filter } : {}), ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}), diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 6276e32e6..7738f25be 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -855,6 +855,7 @@ export const ObjectView: React.FC = ({ virtualScroll: activeView?.virtualScroll ?? (schema as any).virtualScroll, emptyState: activeView?.emptyState ?? (schema as any).emptyState, aria: activeView?.aria ?? (schema as any).aria, + tabs: (schema as any).tabs, }, dataSource, onEdit: handleEdit, diff --git a/packages/plugin-view/src/__tests__/config-sync-integration.test.tsx b/packages/plugin-view/src/__tests__/config-sync-integration.test.tsx index 0b81f46b0..8dadd58d5 100644 --- a/packages/plugin-view/src/__tests__/config-sync-integration.test.tsx +++ b/packages/plugin-view/src/__tests__/config-sync-integration.test.tsx @@ -550,4 +550,39 @@ describe('Config Sync Integration — Per View Type', () => { expect(s?.emptyState).toEqual({ title: 'No data', message: 'Add records', icon: 'inbox' }); expect(s?.aria).toEqual({ label: 'Contacts', describedBy: 'desc', live: 'polite' }); }); + + it('propagates schema-level tabs through renderListView', () => { + const tabsConfig = [ + { name: 'all', label: 'All Records', isDefault: true }, + { name: 'active', label: 'Active', icon: 'circle-check', filter: { logic: 'and', conditions: [{ field: 'status', operator: 'eq', value: 'active' }] } }, + { name: 'vip', label: 'VIP', pinned: true }, + ]; + + const schema: ObjectViewSchema = { + type: 'object-view', + objectName: 'contacts', + tabs: tabsConfig, + } as any; + + const renderListViewSpy = vi.fn(({ schema: listSchema }: any) => ( +
Grid
+ )); + + render( + , + ); + + expect(renderListViewSpy).toHaveBeenCalled(); + const callSchema = renderListViewSpy.mock.calls[0]?.[0]?.schema; + expect(callSchema?.tabs).toEqual(tabsConfig); + expect(callSchema?.tabs).toHaveLength(3); + expect(callSchema?.tabs[0]?.isDefault).toBe(true); + expect(callSchema?.tabs[2]?.pinned).toBe(true); + }); });