From 545d5db158f9ccb6ccdcc27093d81c86ddc66473 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 12:13:03 +0000
Subject: [PATCH 1/3] Initial plan
From 71397f89411abc91128292ffa7ba1d923d66f026 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 12:20:35 +0000
Subject: [PATCH 2/3] feat: add ViewTabBar component with context menu,
overflow, indicators, and save-as-view
- Add ViewTabBarConfig type to @object-ui/types
- Create reusable ViewTabBar component in packages/plugin-view
- Integrate ViewTabBar into console ObjectView
- Add 26 tests covering all Phase 1 features
- All 130 plugin-view tests pass
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
---
apps/console/src/components/ObjectView.tsx | 56 ++-
packages/plugin-view/src/ViewTabBar.tsx | 376 ++++++++++++++++
.../src/__tests__/ViewTabBar.test.tsx | 363 +++++++++++++++
packages/plugin-view/src/index.tsx | 2 +
packages/types/src/index.ts | 1 +
packages/types/src/objectql.ts | 26 ++
pnpm-lock.yaml | 422 +-----------------
7 files changed, 824 insertions(+), 422 deletions(-)
create mode 100644 packages/plugin-view/src/ViewTabBar.tsx
create mode 100644 packages/plugin-view/src/__tests__/ViewTabBar.test.tsx
diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx
index 596a167fdd..0c76fb007a 100644
--- a/apps/console/src/components/ObjectView.tsx
+++ b/apps/console/src/components/ObjectView.tsx
@@ -14,7 +14,8 @@ import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { ObjectChart } from '@object-ui/plugin-charts';
import { ListView } from '@object-ui/plugin-list';
import { DetailView } from '@object-ui/plugin-detail';
-import { ObjectView as PluginObjectView } from '@object-ui/plugin-view';
+import { ObjectView as PluginObjectView, ViewTabBar } from '@object-ui/plugin-view';
+import type { ViewTabItem } from '@object-ui/plugin-view';
// Import plugins for side-effects (registration)
import '@object-ui/plugin-grid';
import '@object-ui/plugin-kanban';
@@ -358,30 +359,39 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) {
- {/* View Tabs — Airtable-style named-view switcher */}
+ {/* View Tabs — Airtable-style named-view switcher with management UX */}
{views.length > 1 && (
-
- {views.map((view: { id: string; label: string; type: string }) => {
- const isActive = view.id === activeViewId;
- const ViewIcon = VIEW_TYPE_ICONS[view.type] || TableIcon;
- return (
-
- );
- })}
-
+
({
+ id: view.id,
+ label: view.label,
+ type: view.type,
+ hasActiveFilters: Array.isArray((view as any).filter) && (view as any).filter.length > 0,
+ hasActiveSort: Array.isArray((view as any).sort) && (view as any).sort.length > 0,
+ } as ViewTabItem))}
+ activeViewId={activeViewId}
+ onViewChange={handleViewChange}
+ viewTypeIcons={VIEW_TYPE_ICONS}
+ config={objectDef.viewTabBar}
+ onAddView={() => navigate(viewId ? '../../views/new' : 'views/new')}
+ onRenameView={(id, newName) => {
+ // Rename is wired for future backend integration
+ console.info('[ViewTabBar] Rename view:', id, newName);
+ }}
+ onDuplicateView={(id) => {
+ console.info('[ViewTabBar] Duplicate view:', id);
+ }}
+ onDeleteView={(id) => {
+ console.info('[ViewTabBar] Delete view:', id);
+ }}
+ onSetDefaultView={(id) => {
+ console.info('[ViewTabBar] Set default view:', id);
+ }}
+ onShareView={(id) => {
+ console.info('[ViewTabBar] Share view:', id);
+ }}
+ />
)}
diff --git a/packages/plugin-view/src/ViewTabBar.tsx b/packages/plugin-view/src/ViewTabBar.tsx
new file mode 100644
index 0000000000..ce2ffc6e56
--- /dev/null
+++ b/packages/plugin-view/src/ViewTabBar.tsx
@@ -0,0 +1,376 @@
+/**
+ * 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.
+ */
+
+/**
+ * ViewTabBar Component
+ *
+ * A reusable view tab bar with:
+ * - Inline "+" Add View button
+ * - Right-click context menu (rename, duplicate, delete, set as default, share)
+ * - Overflow → "More" dropdown when maxVisibleTabs exceeded
+ * - Filter/sort indicator badges on tabs
+ * - "Save as View" button when user filters differ from saved state
+ * - Double-click to rename view tab inline
+ */
+
+import React, { useState, useRef, useEffect, useCallback, type ComponentType } from 'react';
+import {
+ cn,
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ Input,
+ Button,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+ TooltipProvider,
+} from '@object-ui/components';
+import {
+ Plus,
+ MoreHorizontal,
+ Pencil,
+ Copy,
+ Trash2,
+ Star,
+ Share2,
+ Save,
+ Table as TableIcon,
+} from 'lucide-react';
+import type { ViewTabBarConfig } from '@object-ui/types';
+
+/** A single view definition for the tab bar */
+export interface ViewTabItem {
+ /** Unique view identifier */
+ id: string;
+ /** Display label */
+ label: string;
+ /** View type (grid, kanban, calendar, etc.) */
+ type: string;
+ /** Whether this view has active filters */
+ hasActiveFilters?: boolean;
+ /** Whether this view has active sort */
+ hasActiveSort?: boolean;
+ /** Whether this is the default view */
+ isDefault?: boolean;
+}
+
+export interface ViewTabBarProps {
+ /** Views to render as tabs */
+ views: ViewTabItem[];
+ /** Currently active view ID */
+ activeViewId: string;
+ /** Callback when a view tab is clicked */
+ onViewChange: (viewId: string) => void;
+ /** Icon map: view type → React component */
+ viewTypeIcons?: Record>;
+ /** Configuration for the tab bar UX */
+ config?: ViewTabBarConfig;
+
+ // --- Action callbacks ---
+ /** Called when "+" button is clicked to add a new view */
+ onAddView?: () => void;
+ /** Called when a view is renamed via context menu or double-click */
+ onRenameView?: (viewId: string, newName: string) => void;
+ /** Called when a view is duplicated */
+ onDuplicateView?: (viewId: string) => void;
+ /** Called when a view is deleted */
+ onDeleteView?: (viewId: string) => void;
+ /** Called when a view is set as default */
+ onSetDefaultView?: (viewId: string) => void;
+ /** Called when a view is shared */
+ onShareView?: (viewId: string) => void;
+ /** Called when "Save as View" is clicked */
+ onSaveAsView?: () => void;
+
+ /** Whether user has unsaved filter/sort changes (shows "Save as View" indicator) */
+ hasUnsavedChanges?: boolean;
+ /** Called when user clicks "Reset" to discard changes */
+ onResetChanges?: () => void;
+ /** Additional CSS class */
+ className?: string;
+}
+
+/**
+ * ViewTabBar — Airtable/Salesforce-style view tab bar with management UX.
+ */
+export const ViewTabBar: React.FC = ({
+ views,
+ activeViewId,
+ onViewChange,
+ viewTypeIcons = {},
+ config = {},
+ onAddView,
+ onRenameView,
+ onDuplicateView,
+ onDeleteView,
+ onSetDefaultView,
+ onShareView,
+ onSaveAsView,
+ hasUnsavedChanges = false,
+ onResetChanges,
+ className,
+}) => {
+ const {
+ showAddButton = true,
+ inlineRename = true,
+ contextMenu: enableContextMenu = true,
+ maxVisibleTabs = 6,
+ showIndicators = true,
+ showSaveAsView = true,
+ } = config;
+
+ // --- Inline rename state ---
+ const [renamingViewId, setRenamingViewId] = useState(null);
+ const [renameValue, setRenameValue] = useState('');
+ const renameInputRef = useRef(null);
+
+ useEffect(() => {
+ if (renamingViewId && renameInputRef.current) {
+ renameInputRef.current.focus();
+ renameInputRef.current.select();
+ }
+ }, [renamingViewId]);
+
+ const startRename = useCallback((viewId: string) => {
+ if (!inlineRename || !onRenameView) return;
+ const view = views.find(v => v.id === viewId);
+ if (!view) return;
+ setRenamingViewId(viewId);
+ setRenameValue(view.label);
+ }, [inlineRename, onRenameView, views]);
+
+ const commitRename = useCallback(() => {
+ if (renamingViewId && renameValue.trim() && onRenameView) {
+ onRenameView(renamingViewId, renameValue.trim());
+ }
+ setRenamingViewId(null);
+ setRenameValue('');
+ }, [renamingViewId, renameValue, onRenameView]);
+
+ const cancelRename = useCallback(() => {
+ setRenamingViewId(null);
+ setRenameValue('');
+ }, []);
+
+ // --- Overflow ---
+ const visibleViews = views.slice(0, maxVisibleTabs);
+ const overflowViews = views.slice(maxVisibleTabs);
+
+ const DefaultIcon = TableIcon;
+
+ // --- Render a single tab ---
+ const renderTab = (view: ViewTabItem) => {
+ const isActive = view.id === activeViewId;
+ const ViewIcon = viewTypeIcons[view.type] || DefaultIcon;
+ const isRenaming = renamingViewId === view.id;
+ const hasIndicator = showIndicators && (view.hasActiveFilters || view.hasActiveSort);
+
+ const tabContent = (
+
+ );
+
+ // Wrap with context menu if enabled
+ if (enableContextMenu && !isRenaming) {
+ return (
+
+
+ {tabContent}
+
+
+ {onRenameView && (
+ startRename(view.id)}
+ >
+ Rename
+
+ )}
+ {onDuplicateView && (
+ onDuplicateView(view.id)}
+ >
+ Duplicate View
+
+ )}
+ {onShareView && (
+ onShareView(view.id)}
+ >
+ Share View
+
+ )}
+ {onSetDefaultView && (
+ onSetDefaultView(view.id)}
+ >
+ Set as Default
+
+ )}
+ {onDeleteView && (
+ <>
+
+ onDeleteView(view.id)}
+ className="text-destructive focus:text-destructive"
+ >
+ Delete View
+
+ >
+ )}
+
+
+ );
+ }
+
+ return {tabContent};
+ };
+
+ return (
+
+
+ {/* Visible tabs */}
+ {visibleViews.map(renderTab)}
+
+ {/* Overflow "More" dropdown */}
+ {overflowViews.length > 0 && (
+
+
+
+
+
+ {overflowViews.map((view) => {
+ const ViewIcon = viewTypeIcons[view.type] || DefaultIcon;
+ return (
+ onViewChange(view.id)}
+ >
+
+ {view.label}
+ {showIndicators && (view.hasActiveFilters || view.hasActiveSort) && (
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Inline "+" Add View button */}
+ {showAddButton && onAddView && (
+
+
+
+
+ Add View
+
+ )}
+
+ {/* "Save as View" indicator */}
+ {showSaveAsView && hasUnsavedChanges && (
+
+
+ Unsaved changes
+ {onSaveAsView && (
+
+ )}
+ {onResetChanges && (
+
+ )}
+
+ )}
+
+
+ );
+};
diff --git a/packages/plugin-view/src/__tests__/ViewTabBar.test.tsx b/packages/plugin-view/src/__tests__/ViewTabBar.test.tsx
new file mode 100644
index 0000000000..9bf17a4671
--- /dev/null
+++ b/packages/plugin-view/src/__tests__/ViewTabBar.test.tsx
@@ -0,0 +1,363 @@
+/**
+ * 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, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ViewTabBar, type ViewTabItem, type ViewTabBarProps } from '../ViewTabBar';
+
+const createViews = (count: number): ViewTabItem[] =>
+ Array.from({ length: count }, (_, i) => ({
+ id: `view-${i}`,
+ label: `View ${i}`,
+ type: i === 0 ? 'grid' : i === 1 ? 'kanban' : 'calendar',
+ }));
+
+const defaultProps: ViewTabBarProps = {
+ views: createViews(3),
+ activeViewId: 'view-0',
+ onViewChange: vi.fn(),
+};
+
+describe('ViewTabBar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ============================
+ // Basic Rendering
+ // ============================
+ describe('Basic Rendering', () => {
+ it('should render all view tabs', () => {
+ render();
+ expect(screen.getByTestId('view-tab-bar')).toBeDefined();
+ expect(screen.getByTestId('view-tab-view-0')).toBeDefined();
+ expect(screen.getByTestId('view-tab-view-1')).toBeDefined();
+ expect(screen.getByTestId('view-tab-view-2')).toBeDefined();
+ });
+
+ it('should render view labels', () => {
+ render();
+ expect(screen.getByText('View 0')).toBeDefined();
+ expect(screen.getByText('View 1')).toBeDefined();
+ expect(screen.getByText('View 2')).toBeDefined();
+ });
+
+ it('should highlight active tab', () => {
+ render();
+ const activeTab = screen.getByTestId('view-tab-view-0');
+ expect(activeTab.className).toContain('border-primary');
+ });
+
+ it('should call onViewChange when tab is clicked', () => {
+ const onViewChange = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId('view-tab-view-1'));
+ expect(onViewChange).toHaveBeenCalledWith('view-1');
+ });
+ });
+
+ // ============================
+ // Inline "+" Add View Button
+ // ============================
+ describe('Add View Button', () => {
+ it('should render "+" button when onAddView is provided', () => {
+ const onAddView = vi.fn();
+ render();
+ expect(screen.getByTestId('view-tab-add')).toBeDefined();
+ });
+
+ it('should not render "+" button when showAddButton is false', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('view-tab-add')).toBeNull();
+ });
+
+ it('should not render "+" button when onAddView is not provided', () => {
+ render();
+ expect(screen.queryByTestId('view-tab-add')).toBeNull();
+ });
+
+ it('should call onAddView when "+" button is clicked', () => {
+ const onAddView = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId('view-tab-add'));
+ expect(onAddView).toHaveBeenCalledOnce();
+ });
+ });
+
+ // ============================
+ // Tab Overflow ("More" Dropdown)
+ // ============================
+ describe('Tab Overflow', () => {
+ it('should show overflow button when views exceed maxVisibleTabs', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('view-tab-overflow')).toBeDefined();
+ expect(screen.getByText('2 more')).toBeDefined();
+ });
+
+ it('should not show overflow when all views fit', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('view-tab-overflow')).toBeNull();
+ });
+
+ it('should only render maxVisibleTabs tabs directly', () => {
+ render(
+
+ );
+ // First 4 should be visible as direct tabs
+ expect(screen.getByTestId('view-tab-view-0')).toBeDefined();
+ expect(screen.getByTestId('view-tab-view-3')).toBeDefined();
+ // 5th should NOT be a direct tab
+ expect(screen.queryByTestId('view-tab-view-4')).toBeNull();
+ // But should appear in overflow
+ expect(screen.getByText('4 more')).toBeDefined();
+ });
+ });
+
+ // ============================
+ // Filter/Sort Indicator Badges
+ // ============================
+ describe('Filter/Sort Indicators', () => {
+ it('should show indicator dot when view has active filters', () => {
+ const views: ViewTabItem[] = [
+ { id: 'v1', label: 'Active', type: 'grid', hasActiveFilters: true },
+ { id: 'v2', label: 'All', type: 'grid' },
+ ];
+ render();
+ expect(screen.getByTestId('view-tab-indicator-v1')).toBeDefined();
+ expect(screen.queryByTestId('view-tab-indicator-v2')).toBeNull();
+ });
+
+ it('should show indicator dot when view has active sort', () => {
+ const views: ViewTabItem[] = [
+ { id: 'v1', label: 'Sorted', type: 'grid', hasActiveSort: true },
+ { id: 'v2', label: 'All', type: 'grid' },
+ ];
+ render();
+ expect(screen.getByTestId('view-tab-indicator-v1')).toBeDefined();
+ });
+
+ it('should not show indicators when showIndicators is false', () => {
+ const views: ViewTabItem[] = [
+ { id: 'v1', label: 'Active', type: 'grid', hasActiveFilters: true },
+ ];
+ render(
+
+ );
+ expect(screen.queryByTestId('view-tab-indicator-v1')).toBeNull();
+ });
+ });
+
+ // ============================
+ // Context Menu
+ // ============================
+ describe('Context Menu', () => {
+ it('should show context menu items when right-clicking a tab', () => {
+ render(
+
+ );
+ // Context menu is rendered but hidden by default via Radix
+ // Verify the tab is rendered as a trigger
+ const tab = screen.getByTestId('view-tab-view-0');
+ expect(tab).toBeDefined();
+ });
+
+ it('should not wrap with context menu when contextMenu is false', () => {
+ const { container } = render(
+
+ );
+ // When contextMenu is disabled, no context menu wrapper
+ expect(container.querySelector('[data-testid="context-menu-rename-view-0"]')).toBeNull();
+ });
+ });
+
+ // ============================
+ // Save as View
+ // ============================
+ describe('Save as View', () => {
+ it('should show save-as-view indicator when hasUnsavedChanges is true', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('view-tab-save-as')).toBeDefined();
+ });
+
+ it('should not show save-as-view indicator when hasUnsavedChanges is false', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('view-tab-save-as')).toBeNull();
+ });
+
+ it('should call onSaveAsView when Save button is clicked', () => {
+ const onSaveAsView = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByTestId('view-tab-save-as-btn'));
+ expect(onSaveAsView).toHaveBeenCalledOnce();
+ });
+
+ it('should call onResetChanges when Reset button is clicked', () => {
+ const onResetChanges = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByTestId('view-tab-reset-btn'));
+ expect(onResetChanges).toHaveBeenCalledOnce();
+ });
+
+ it('should not show save-as-view when showSaveAsView is false', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('view-tab-save-as')).toBeNull();
+ });
+ });
+
+ // ============================
+ // Inline Rename
+ // ============================
+ describe('Inline Rename', () => {
+ it('should enter rename mode on double-click', () => {
+ render(
+
+ );
+ const tab = screen.getByTestId('view-tab-view-0');
+ fireEvent.doubleClick(tab);
+ expect(screen.getByTestId('view-tab-rename-input-view-0')).toBeDefined();
+ });
+
+ it('should call onRenameView on Enter key', () => {
+ const onRenameView = vi.fn();
+ render(
+
+ );
+ const tab = screen.getByTestId('view-tab-view-0');
+ fireEvent.doubleClick(tab);
+
+ const input = screen.getByTestId('view-tab-rename-input-view-0');
+ fireEvent.change(input, { target: { value: 'New Name' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(onRenameView).toHaveBeenCalledWith('view-0', 'New Name');
+ });
+
+ it('should cancel rename on Escape key', () => {
+ render(
+
+ );
+ const tab = screen.getByTestId('view-tab-view-0');
+ fireEvent.doubleClick(tab);
+
+ const input = screen.getByTestId('view-tab-rename-input-view-0');
+ fireEvent.keyDown(input, { key: 'Escape' });
+
+ // Should exit rename mode
+ expect(screen.queryByTestId('view-tab-rename-input-view-0')).toBeNull();
+ });
+
+ it('should not enter rename mode when inlineRename is false', () => {
+ render(
+
+ );
+ const tab = screen.getByTestId('view-tab-view-0');
+ fireEvent.doubleClick(tab);
+ expect(screen.queryByTestId('view-tab-rename-input-view-0')).toBeNull();
+ });
+ });
+
+ // ============================
+ // Default View Indicator
+ // ============================
+ describe('Default View Indicator', () => {
+ it('should show star icon for default view', () => {
+ const views: ViewTabItem[] = [
+ { id: 'v1', label: 'Default', type: 'grid', isDefault: true },
+ { id: 'v2', label: 'Other', type: 'grid' },
+ ];
+ const { container } = render();
+ // The default view tab should contain a star icon
+ const defaultTab = screen.getByTestId('view-tab-v1');
+ // Star icon rendered via lucide-react SVG
+ expect(defaultTab.querySelector('svg.text-amber-500')).toBeDefined();
+ });
+ });
+});
diff --git a/packages/plugin-view/src/index.tsx b/packages/plugin-view/src/index.tsx
index da899e49b3..ba166d773a 100644
--- a/packages/plugin-view/src/index.tsx
+++ b/packages/plugin-view/src/index.tsx
@@ -15,8 +15,10 @@ import { SortUI } from './SortUI';
import { SharedViewLink } from './SharedViewLink';
export { ObjectView, ViewSwitcher, FilterUI, SortUI, SharedViewLink };
+export { ViewTabBar } from './ViewTabBar';
export type { ObjectViewProps } from './ObjectView';
export type { ViewSwitcherProps } from './ViewSwitcher';
+export type { ViewTabBarProps, ViewTabItem } from './ViewTabBar';
export type { FilterUIProps } from './FilterUI';
export type { SortUIProps } from './SortUI';
export type { SharedViewLinkProps } from './SharedViewLink';
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index f39e4ed9b4..0bb103832f 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -302,6 +302,7 @@ export type {
ObjectViewSchema,
NamedListView,
ViewNavigationConfig,
+ ViewTabBarConfig,
ObjectQLComponentSchema,
} from './objectql';
diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts
index 1f5b468d86..06b73a9eb8 100644
--- a/packages/types/src/objectql.ts
+++ b/packages/types/src/objectql.ts
@@ -896,6 +896,32 @@ export interface ObjectViewSchema extends BaseSchema {
* Custom CSS class
*/
className?: string;
+
+ /**
+ * View tab bar UX configuration (inline add, context menu, overflow, indicators).
+ */
+ viewTabBar?: ViewTabBarConfig;
+}
+
+/**
+ * View Tab Bar Configuration
+ * Controls the UX of the view tab bar (inline add, context menu, overflow, indicators).
+ */
+export interface ViewTabBarConfig {
+ /** Show inline "+" button to create new views @default true */
+ showAddButton?: boolean;
+ /** Allow inline renaming by double-clicking tab @default true */
+ inlineRename?: boolean;
+ /** Show context menu on right-click @default true */
+ contextMenu?: boolean;
+ /** Allow drag-reorder of view tabs @default false */
+ reorderable?: boolean;
+ /** Max visible tabs before overflow → "More" dropdown @default 6 */
+ maxVisibleTabs?: number;
+ /** Show filter/sort indicator badges on tabs @default true */
+ showIndicators?: boolean;
+ /** Show "Save as View" when filters differ from saved @default true */
+ showSaveAsView?: boolean;
}
/**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 96305f4217..2c6c0762ea 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -573,273 +573,6 @@ importers:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
- examples/hotcrm/packages/ai:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- axios:
- specifier: ^1.13.5
- version: 1.13.5
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/analytics:
- dependencies:
- '@hotcrm/ai':
- specifier: workspace:*
- version: link:../ai
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/community:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/core:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.2
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
-
- examples/hotcrm/packages/crm:
- dependencies:
- '@hotcrm/ai':
- specifier: workspace:*
- version: link:../ai
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/education:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/finance:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/financial-services:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/healthcare:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/hr:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/integration:
- dependencies:
- '@hotcrm/ai':
- specifier: workspace:*
- version: link:../ai
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/marketing:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/products:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/real-estate:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- zod:
- specifier: ^4.3.6
- version: 4.3.6
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/server:
- dependencies:
- '@objectstack/cli':
- specifier: ^3.0.2
- version: 3.0.7(@objectstack/core@3.0.7(pino@10.3.1))(esbuild@0.27.3)(pino@10.3.1)
- '@objectstack/core':
- specifier: ^3.0.2
- version: 3.0.7(pino@10.3.1)
- '@objectstack/metadata':
- specifier: ^3.0.2
- version: 3.0.7(pino@10.3.1)
- '@objectstack/objectql':
- specifier: ^3.0.2
- version: 3.0.7(pino@10.3.1)
- '@objectstack/plugin-hono-server':
- specifier: ^3.0.2
- version: 3.0.7(pino@10.3.1)
- '@objectstack/runtime':
- specifier: ^3.0.2
- version: 3.0.7(pino@10.3.1)
- '@objectstack/spec':
- specifier: ^3.0.2
- version: 3.0.7
- pino:
- specifier: ^10.3.1
- version: 10.3.1
- devDependencies:
- '@types/js-yaml':
- specifier: ^4.0.9
- version: 4.0.9
- ts-node:
- specifier: ^10.9.2
- version: 10.9.2(@swc/core@1.15.11)(@types/node@25.2.3)(typescript@5.9.3)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
- examples/hotcrm/packages/support:
- dependencies:
- '@objectstack/spec':
- specifier: ^3.0.3
- version: 3.0.7
- devDependencies:
- '@objectstack/cli':
- specifier: ^3.0.3
- version: 3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
examples/kitchen-sink:
dependencies:
'@objectstack/spec':
@@ -4287,9 +4020,6 @@ packages:
resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==}
engines: {node: '>= 20.0.0'}
- '@pinojs/redact@0.4.0':
- resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
-
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -9920,13 +9650,6 @@ packages:
pino-std-serializers@6.2.2:
resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==}
- pino-std-serializers@7.1.0:
- resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
-
- pino@10.3.1:
- resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
- hasBin: true
-
pino@8.21.0:
resolution: {integrity: sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==}
hasBin: true
@@ -10060,9 +9783,6 @@ packages:
process-warning@3.0.0:
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
- process-warning@5.0.0:
- resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
-
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -11022,10 +10742,6 @@ packages:
thread-stream@2.7.0:
resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==}
- thread-stream@4.0.0:
- resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
- engines: {node: '>=20'}
-
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -12499,6 +12215,7 @@ snapshots:
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
+ optional: true
'@csstools/color-helpers@6.0.1': {}
@@ -13311,6 +13028,7 @@ snapshots:
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ optional: true
'@manypkg/find-root@1.1.0':
dependencies:
@@ -13590,24 +13308,6 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
- '@objectstack/cli@3.0.7(@objectstack/core@3.0.7(pino@10.3.1))(esbuild@0.27.3)(pino@10.3.1)':
- dependencies:
- '@objectstack/core': 3.0.7(pino@10.3.1)
- '@objectstack/driver-memory': 3.0.7(pino@10.3.1)
- '@objectstack/objectql': 3.0.7(pino@10.3.1)
- '@objectstack/plugin-hono-server': 3.0.7(pino@10.3.1)
- '@objectstack/rest': 3.0.7(pino@10.3.1)
- '@objectstack/runtime': 3.0.7(pino@10.3.1)
- '@objectstack/spec': 3.0.7
- '@oclif/core': 4.8.0
- bundle-require: 5.1.0(esbuild@0.27.3)
- chalk: 5.6.2
- tsx: 4.21.0
- zod: 4.3.6
- transitivePeerDependencies:
- - esbuild
- - pino
-
'@objectstack/cli@3.0.7(@objectstack/core@3.0.7(pino@8.21.0))(esbuild@0.27.3)(pino@8.21.0)':
dependencies:
'@objectstack/core': 3.0.7(pino@8.21.0)
@@ -13642,14 +13342,6 @@ snapshots:
transitivePeerDependencies:
- pino
- '@objectstack/core@3.0.7(pino@10.3.1)':
- dependencies:
- '@objectstack/spec': 3.0.7
- pino-pretty: 13.1.3
- zod: 4.3.6
- optionalDependencies:
- pino: 10.3.1
-
'@objectstack/core@3.0.7(pino@8.21.0)':
dependencies:
'@objectstack/spec': 3.0.7
@@ -13658,14 +13350,6 @@ snapshots:
optionalDependencies:
pino: 8.21.0
- '@objectstack/driver-memory@3.0.7(pino@10.3.1)':
- dependencies:
- '@objectstack/core': 3.0.7(pino@10.3.1)
- '@objectstack/spec': 3.0.7
- mingo: 7.2.0
- transitivePeerDependencies:
- - pino
-
'@objectstack/driver-memory@3.0.7(pino@8.21.0)':
dependencies:
'@objectstack/core': 3.0.7(pino@8.21.0)
@@ -13674,18 +13358,6 @@ snapshots:
transitivePeerDependencies:
- pino
- '@objectstack/metadata@3.0.7(pino@10.3.1)':
- dependencies:
- '@objectstack/core': 3.0.7(pino@10.3.1)
- '@objectstack/spec': 3.0.7
- '@objectstack/types': 3.0.7
- chokidar: 5.0.0
- glob: 13.0.5
- js-yaml: 4.1.1
- zod: 4.3.6
- transitivePeerDependencies:
- - pino
-
'@objectstack/metadata@3.0.7(pino@8.21.0)':
dependencies:
'@objectstack/core': 3.0.7(pino@8.21.0)
@@ -13698,14 +13370,6 @@ snapshots:
transitivePeerDependencies:
- pino
- '@objectstack/objectql@3.0.7(pino@10.3.1)':
- dependencies:
- '@objectstack/core': 3.0.7(pino@10.3.1)
- '@objectstack/spec': 3.0.7
- '@objectstack/types': 3.0.7
- transitivePeerDependencies:
- - pino
-
'@objectstack/objectql@3.0.7(pino@8.21.0)':
dependencies:
'@objectstack/core': 3.0.7(pino@8.21.0)
@@ -13741,15 +13405,6 @@ snapshots:
- vitest
- vue
- '@objectstack/plugin-hono-server@3.0.7(pino@10.3.1)':
- dependencies:
- '@hono/node-server': 1.19.9(hono@4.11.9)
- '@objectstack/core': 3.0.7(pino@10.3.1)
- '@objectstack/spec': 3.0.7
- hono: 4.11.9
- transitivePeerDependencies:
- - pino
-
'@objectstack/plugin-hono-server@3.0.7(pino@8.21.0)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.11.9)
@@ -13772,14 +13427,6 @@ snapshots:
- pino
- typescript
- '@objectstack/rest@3.0.7(pino@10.3.1)':
- dependencies:
- '@objectstack/core': 3.0.7(pino@10.3.1)
- '@objectstack/spec': 3.0.7
- zod: 4.3.6
- transitivePeerDependencies:
- - pino
-
'@objectstack/rest@3.0.7(pino@8.21.0)':
dependencies:
'@objectstack/core': 3.0.7(pino@8.21.0)
@@ -13788,16 +13435,6 @@ snapshots:
transitivePeerDependencies:
- pino
- '@objectstack/runtime@3.0.7(pino@10.3.1)':
- dependencies:
- '@objectstack/core': 3.0.7(pino@10.3.1)
- '@objectstack/rest': 3.0.7(pino@10.3.1)
- '@objectstack/spec': 3.0.7
- '@objectstack/types': 3.0.7
- zod: 4.3.6
- transitivePeerDependencies:
- - pino
-
'@objectstack/runtime@3.0.7(pino@8.21.0)':
dependencies:
'@objectstack/core': 3.0.7(pino@8.21.0)
@@ -13887,8 +13524,6 @@ snapshots:
'@orama/orama@3.1.18': {}
- '@pinojs/redact@0.4.0': {}
-
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -15411,13 +15046,17 @@ snapshots:
minimatch: 10.2.1
path-browserify: 1.0.1
- '@tsconfig/node10@1.0.12': {}
+ '@tsconfig/node10@1.0.12':
+ optional: true
- '@tsconfig/node12@1.0.11': {}
+ '@tsconfig/node12@1.0.11':
+ optional: true
- '@tsconfig/node14@1.0.3': {}
+ '@tsconfig/node14@1.0.3':
+ optional: true
- '@tsconfig/node16@1.0.4': {}
+ '@tsconfig/node16@1.0.4':
+ optional: true
'@tybys/wasm-util@0.10.1':
dependencies:
@@ -16279,7 +15918,8 @@ snapshots:
archy@1.0.0: {}
- arg@4.1.3: {}
+ arg@4.1.3:
+ optional: true
argparse@1.0.10:
dependencies:
@@ -16858,7 +16498,8 @@ snapshots:
coverage-v8@0.0.1-security: {}
- create-require@1.1.1: {}
+ create-require@1.1.1:
+ optional: true
cross-spawn@7.0.6:
dependencies:
@@ -17039,7 +16680,8 @@ snapshots:
diff-sequences@29.6.3: {}
- diff@4.0.4: {}
+ diff@4.0.4:
+ optional: true
diff@8.0.3: {}
@@ -19321,7 +18963,8 @@ snapshots:
dependencies:
semver: 7.7.4
- make-error@1.3.6: {}
+ make-error@1.3.6:
+ optional: true
makeerror@1.0.12:
dependencies:
@@ -20373,22 +20016,6 @@ snapshots:
pino-std-serializers@6.2.2: {}
- pino-std-serializers@7.1.0: {}
-
- pino@10.3.1:
- dependencies:
- '@pinojs/redact': 0.4.0
- atomic-sleep: 1.0.0
- on-exit-leak-free: 2.1.2
- pino-abstract-transport: 3.0.0
- pino-std-serializers: 7.1.0
- process-warning: 5.0.0
- quick-format-unescaped: 4.0.4
- real-require: 0.2.0
- safe-stable-stringify: 2.5.0
- sonic-boom: 4.2.1
- thread-stream: 4.0.0
-
pino@8.21.0:
dependencies:
atomic-sleep: 1.0.0
@@ -20525,8 +20152,6 @@ snapshots:
process-warning@3.0.0: {}
- process-warning@5.0.0: {}
-
process@0.11.10: {}
prompts@2.4.2:
@@ -21736,10 +21361,6 @@ snapshots:
dependencies:
real-require: 0.2.0
- thread-stream@4.0.0:
- dependencies:
- real-require: 0.2.0
-
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -21829,6 +21450,7 @@ snapshots:
yn: 3.1.1
optionalDependencies:
'@swc/core': 1.15.11
+ optional: true
tsconfig-paths@4.2.0:
dependencies:
@@ -22131,7 +21753,8 @@ snapshots:
uuid@9.0.1: {}
- v8-compile-cache-lib@3.0.1: {}
+ v8-compile-cache-lib@3.0.1:
+ optional: true
v8-to-istanbul@9.3.0:
dependencies:
@@ -22621,7 +22244,8 @@ snapshots:
dependencies:
buffer-crc32: 0.2.13
- yn@3.1.1: {}
+ yn@3.1.1:
+ optional: true
yocto-queue@0.1.0: {}
From 2b4d3b64975a1c4d01f2a4a6c7881ffe2a6bfb5e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 12:24:20 +0000
Subject: [PATCH 3/3] docs: update ROADMAP.md with P1.4 View Tab Management UX
section
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
---
ROADMAP.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/ROADMAP.md b/ROADMAP.md
index 3d456a89da..164659b8d9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -184,6 +184,20 @@ All 4 phases complete across 5 designers (Page, View, DataModel, Process, Report
- [ ] Column reorder/resize persistence for grid
- [ ] Drag-to-reschedule calendar events (move between dates)
+#### P1.4 View Tab Management UX (Airtable/Salesforce-style)
+- [x] `ViewTabBarConfig` type in `@object-ui/types` — configures tab bar UX (showAddButton, contextMenu, overflow, indicators)
+- [x] `ViewTabBar` reusable component in `plugin-view` — extracted from console inline tabs
+- [x] Inline "+" Add View button on tab bar (configurable via `showAddButton`)
+- [x] Right-click context menu on view tabs: Rename, Duplicate, Delete, Set as Default, Share
+- [x] Tab overflow → "More" dropdown when `maxVisibleTabs` exceeded
+- [x] Filter/sort indicator badge (dot) on view tabs (`showIndicators`)
+- [x] "Save as View" indicator + button when user has unsaved filter/sort changes
+- [x] Double-click inline rename on view tabs (`inlineRename`)
+- [ ] Drag-reorder view tabs (`reorderable` — needs `@dnd-kit/sortable`)
+- [ ] Pin/favorite views
+- [ ] View type quick-switch palette (Notion-style layout menu)
+- [ ] Personal vs. shared views grouping in tab bar
+
### P2. Component & Plugin Excellence 🧩 (Completed Foundation)
**Goal:** Every component is polished, consistent, well-tested, and delightful to use.