-
Notifications
You must be signed in to change notification settings - Fork 2
feat(plugin-list): TabBar component for ListView multi-tab view switching with console integration #799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(plugin-list): TabBar component for ListView multi-tab view switching with console integration #799
Changes from all commits
427b6e9
c5779fc
2eabfc8
4347f4f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<string, string> = { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '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<ListViewProps> = ({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| conditions: [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Tab State | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [activeTab, setActiveTab] = React.useState<string | undefined>(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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-${tab.name}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Apply default/active tab filter on mount and when tabs change | |
| React.useEffect(() => { | |
| if (!schema.tabs || schema.tabs.length === 0) return; | |
| const activeTabConfig = | |
| schema.tabs.find(t => t.name === activeTab) ?? | |
| schema.tabs.find(t => t.isDefault) ?? | |
| schema.tabs[0]; | |
| if (!activeTabConfig) return; | |
| if (activeTabConfig.filter) { | |
| const tabFilters: FilterGroup = { | |
| id: `tab-filter-${activeTabConfig.name}`, | |
| logic: activeTabConfig.filter.logic || 'and', | |
| conditions: activeTabConfig.filter.conditions || [], | |
| }; | |
| setCurrentFilters(tabFilters); | |
| onFilterChange?.(tabFilters); | |
| } else { | |
| const emptyFilters: FilterGroup = { id: 'root', logic: 'and', conditions: [] }; | |
| setCurrentFilters(emptyFilters); | |
| onFilterChange?.(emptyFilters); | |
| } | |
| }, [activeTab, schema.tabs, onFilterChange]); |
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default tab's filter is not applied on initial mount. The activeTab state is initialized correctly, but the corresponding filter from that tab is never applied to currentFilters. This means that if a default tab has a filter configuration, that filter won't be active until the user clicks on the tab.
Consider adding a useEffect that applies the default tab's filter on mount, or initialize currentFilters with the default tab's filter in the useState initializer.
| // Apply default tab filter on initial mount | |
| React.useEffect(() => { | |
| // Only run if there are tabs and the filters are still at their initial empty state | |
| if (!schema.tabs || schema.tabs.length === 0) return; | |
| if (currentFilters.id !== 'root' || currentFilters.conditions.length > 0) return; | |
| const initialTab = | |
| schema.tabs.find((t) => t.name === activeTab) ?? | |
| schema.tabs.find((t) => t.isDefault) ?? | |
| schema.tabs[0]; | |
| if (!initialTab) return; | |
| if (initialTab.filter) { | |
| const tabFilters: FilterGroup = { | |
| id: `tab-filter-${initialTab.name}`, | |
| logic: initialTab.filter.logic || 'and', | |
| conditions: initialTab.filter.conditions || [], | |
| }; | |
| setCurrentFilters(tabFilters); | |
| onFilterChange?.(tabFilters); | |
| } else { | |
| const emptyFilters: FilterGroup = { id: 'root', logic: 'and', conditions: [] }; | |
| // currentFilters is already empty, but notify listeners of the initial state | |
| onFilterChange?.(emptyFilters); | |
| } | |
| }, []); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<TabBar tabs={baseTabs} />); | ||
| 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(<TabBar tabs={baseTabs} />); | ||
| 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(<TabBar tabs={[]} />); | ||
| 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(<TabBar tabs={baseTabs} />); | ||
| 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(<TabBar tabs={tabs} />); | ||
| 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(<TabBar tabs={baseTabs} />); | ||
| 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(<TabBar tabs={baseTabs} onTabChange={onTabChange} />); | ||
| 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(<TabBar tabs={tabs} />); | ||
| 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(<TabBar tabs={tabs} />); | ||
| 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(<TabBar tabs={tabs} />); | ||
| 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(<TabBar tabs={tabs} />); | ||
| 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(<TabBar tabs={tabs} />); | ||
| 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(<TabBar tabs={tabs} />); | ||
| 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(<TabBar tabs={tabs} />); | ||
| const tab = screen.getByTestId('view-tab-noicon'); | ||
| const svg = tab.querySelector('svg'); | ||
| expect(svg).toBeFalsy(); | ||
| }); | ||
|
|
||
| // Controlled activeTab | ||
| it('should respect controlled activeTab prop', () => { | ||
| render(<TabBar tabs={baseTabs} activeTab="archived" />); | ||
| 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(<TabBar tabs={baseTabs} />); | ||
| 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(<TabBar tabs={tabs} onTabChange={onTabChange} />); | ||
| 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' }), | ||
| ]), | ||
| }), | ||
| }), | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The i18n keys 'list.tabs' and 'list.allRecords' are added to all locale files and the default translations object (line 208-209), but they are never actually used in the code. The TabBar component doesn't use any i18n translation functions, and there's no code that calls t('list.tabs') or t('list.allRecords').
Either remove these unused i18n keys from all locale files, or integrate them into the TabBar component if they're intended for future use.