Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions apps/console/src/components/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ const ar = {
recordCount: '{{count}} سجلات',
recordCountOne: '{{count}} سجل',
addRecord: 'إضافة سجل',
tabs: 'علامات التبويب',
allRecords: 'جميع السجلات',
},
kanban: {
addCard: 'إضافة بطاقة',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const en = {
recordCount: '{{count}} records',
recordCountOne: '{{count}} record',
addRecord: 'Add record',
tabs: 'Tabs',
allRecords: 'All Records',
},
kanban: {
addCard: 'Add card',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const ja = {
recordCount: '{{count}} 件のレコード',
recordCountOne: '{{count}} 件のレコード',
addRecord: 'レコードを追加',
tabs: 'タブ',
allRecords: 'すべてのレコード',
},
kanban: {
addCard: 'カードを追加',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const ko = {
recordCount: '{{count}}개 레코드',
recordCountOne: '{{count}}개 레코드',
addRecord: '레코드 추가',
tabs: '탭',
allRecords: '전체 레코드',
},
kanban: {
addCard: '카드 추가',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const ru = {
recordCount: '{{count}} записей',
recordCountOne: '{{count}} запись',
addRecord: 'Добавить запись',
tabs: 'Вкладки',
allRecords: 'Все записи',
},
kanban: {
addCard: 'Добавить карточку',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const zh = {
recordCount: '{{count}} 条记录',
recordCountOne: '{{count}} 条记录',
addRecord: '添加记录',
tabs: '标签页',
allRecords: '全部记录',
},
kanban: {
addCard: '添加卡片',
Expand Down
62 changes: 37 additions & 25 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Comment on lines +208 to +209
Copy link

Copilot AI Feb 23, 2026

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.

Suggested change
'list.tabs': 'Tabs',
'list.allRecords': 'All Records',

Copilot uses AI. Check for mistakes.
};

/**
Expand Down Expand Up @@ -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],
);

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the default tab filter application on mount. While TabBar tests verify that the default tab is selected (line 43-49), there are no tests verifying that when a default tab has a filter configuration, that filter is applied to the ListView on initial render and triggers data fetching with those filters.

Add an integration test in ListView.test.tsx that verifies the default tab's filter is applied on mount and affects the data fetching behavior.

Suggested change
// 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 uses AI. Check for mistakes.
Copy link

Copilot AI Feb 23, 2026

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.

Suggested change
// 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);
}
}, []);

Copilot uses AI. Check for mistakes.
// Data State
const dataSource = props.dataSource;
const [data, setData] = React.useState<any[]>([]);
Expand Down Expand Up @@ -965,31 +997,11 @@ export const ListView: React.FC<ListViewProps> = ({

{/* View Tabs */}
{schema.tabs && schema.tabs.length > 0 && (
<div className="border-b px-2 sm:px-4 py-1 flex items-center gap-1 bg-background" data-testid="view-tabs">
{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<string, LucideIcon>)[
tab.icon.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
] ?? null)
: null;
return (
<Button
key={tab.name}
variant={tab.isDefault ? 'default' : 'ghost'}
size="sm"
className="h-7 px-3 text-xs"
data-testid={`view-tab-${tab.name}`}
>
{TabIcon && <TabIcon className="h-3 w-3 mr-1.5" />}
{tab.label}
</Button>
);
})}
</div>
<TabBar
tabs={schema.tabs}
activeTab={activeTab}
onTabChange={handleTabChange}
/>
)}

{/* View Description */}
Expand Down
199 changes: 199 additions & 0 deletions packages/plugin-list/src/__tests__/TabBar.test.tsx
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' }),
]),
}),
}),
);
});
});
Loading