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 numberDiff line numberDiff line change
Expand Up@@ -118,6 +118,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind

- [x] Inline ViewConfigPanel for all view types (Airtable-style right sidebar)
- [x] Column visibility toggle from config panel
- [x] Column reorder (move up/down) from config panel with real-time preview
- [x] Sort/filter/group config from right sidebar
- [x] Type-specific options in config panel (kanban/calendar/map/gallery/timeline/gantt)
- [x] Unified create/edit mode (`mode="create"|"edit"`) — single panel entry point
Expand Down
176 changes: 176 additions & 0 deletions apps/console/src/__tests__/ViewConfigPanel.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1284,4 +1284,180 @@ describe('ViewConfigPanel', () => {
fireEvent.click(screen.getByText('console.objectView.sortBy'));
expect(screen.queryByTestId('inline-sort-builder')).not.toBeInTheDocument();
});

// ── Column selector sub-panel tests ──

it('shows selected columns in order with move up/down buttons', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
/>
);

// Expand the Fields sub-section
fireEvent.click(screen.getByText('console.objectView.fields'));

// Selected columns section should appear
expect(screen.getByTestId('selected-columns')).toBeInTheDocument();

// Move buttons should exist for selected columns
expect(screen.getByTestId('col-move-up-name')).toBeInTheDocument();
expect(screen.getByTestId('col-move-down-name')).toBeInTheDocument();
expect(screen.getByTestId('col-move-up-stage')).toBeInTheDocument();
expect(screen.getByTestId('col-move-down-stage')).toBeInTheDocument();
expect(screen.getByTestId('col-move-up-amount')).toBeInTheDocument();
expect(screen.getByTestId('col-move-down-amount')).toBeInTheDocument();
});

it('disables move-up for first column and move-down for last column', () => {
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
/>
);

fireEvent.click(screen.getByText('console.objectView.fields'));

// First column (name) — move up disabled
expect(screen.getByTestId('col-move-up-name')).toBeDisabled();
expect(screen.getByTestId('col-move-down-name')).not.toBeDisabled();

// Last column (amount) — move down disabled
expect(screen.getByTestId('col-move-up-amount')).not.toBeDisabled();
expect(screen.getByTestId('col-move-down-amount')).toBeDisabled();
});

it('moves column down and updates draft.columns order', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

fireEvent.click(screen.getByText('console.objectView.fields'));

// Move "name" down — should swap with "stage"
fireEvent.click(screen.getByTestId('col-move-down-name'));
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['stage', 'name', 'amount']);
});

it('moves column up and updates draft.columns order', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

fireEvent.click(screen.getByText('console.objectView.fields'));

// Move "amount" up — should swap with "stage"
fireEvent.click(screen.getByTestId('col-move-up-amount'));
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['name', 'amount', 'stage']);
});

it('saves reordered columns via onSave', () => {
const onSave = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onSave={onSave}
/>
);

fireEvent.click(screen.getByText('console.objectView.fields'));

// Move "name" down
fireEvent.click(screen.getByTestId('col-move-down-name'));

// Footer should appear
expect(screen.getByTestId('view-config-footer')).toBeInTheDocument();

// Save
fireEvent.click(screen.getByTestId('view-config-save'));
expect(onSave).toHaveBeenCalledOnce();
expect(onSave.mock.calls[0][0].columns).toEqual(['stage', 'name', 'amount']);
});

it('shows unselected fields below selected columns', () => {
// Only 'name' and 'stage' are selected, 'amount' is not
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, columns: ['name', 'stage'] }}
objectDef={mockObjectDef}
/>
);

fireEvent.click(screen.getByText('console.objectView.fields'));

// 'amount' should have a checkbox but no move buttons
expect(screen.getByTestId('col-checkbox-amount')).toBeInTheDocument();
expect(screen.getByTestId('col-checkbox-amount')).not.toBeChecked();
expect(screen.queryByTestId('col-move-up-amount')).not.toBeInTheDocument();
expect(screen.queryByTestId('col-move-down-amount')).not.toBeInTheDocument();
});

it('adding unselected field appends to columns and shows move buttons', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={{ ...mockActiveView, columns: ['name', 'stage'] }}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

fireEvent.click(screen.getByText('console.objectView.fields'));

// Click checkbox for 'amount' to add it
fireEvent.click(screen.getByTestId('col-checkbox-amount'));
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['name', 'stage', 'amount']);
});

it('reorder triggers onViewUpdate for real-time preview', () => {
const onViewUpdate = vi.fn();
render(
<ViewConfigPanel
open={true}
onClose={vi.fn()}
activeView={mockActiveView}
objectDef={mockObjectDef}
onViewUpdate={onViewUpdate}
/>
);

fireEvent.click(screen.getByText('console.objectView.fields'));

// Move "stage" up
fireEvent.click(screen.getByTestId('col-move-up-stage'));
expect(onViewUpdate).toHaveBeenCalledWith('columns', ['stage', 'name', 'amount']);

// Move "stage" down (now at index 0)
fireEvent.click(screen.getByTestId('col-move-down-stage'));
// After the first move, state has stage at index 0
// The second move should operate on the updated state
expect(onViewUpdate).toHaveBeenCalledTimes(2);
});
});
72 changes: 63 additions & 9 deletions apps/console/src/components/ViewConfigPanel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,7 @@
import { useMemo, useEffect, useRef, useState, useCallback } from 'react';
import { Button, Switch, Input, Checkbox, FilterBuilder, SortBuilder } from '@object-ui/components';
import type { FilterGroup, SortItem } from '@object-ui/components';
import { X, Save, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import { X, Save, RotateCcw, ChevronDown, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';

// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -190,6 +190,8 @@ const ROW_HEIGHT_OPTIONS = [
];

/** Editor panel types that can be opened from clickable rows */
export type EditorPanelType = 'columns' | 'filter' | 'sort';

export interface ViewConfigPanelProps {
/** Whether the panel is open */
open: boolean;
Expand DownExpand Up@@ -427,6 +429,18 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje
updateDraft('columns', currentCols);
}, [draft.columns, updateDraft]);

/** Move a column up or down in the columns array */
const handleColumnMove = useCallback((fieldName: string, direction: 'up' | 'down') => {
const currentCols: string[] = Array.isArray(draft.columns) ? [...draft.columns] : [];
const idx = currentCols.indexOf(fieldName);
if (idx < 0) return;
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= currentCols.length) return;
// Swap
[currentCols[idx], currentCols[targetIdx]] = [currentCols[targetIdx], currentCols[idx]];
updateDraft('columns', currentCols);
}, [draft.columns, updateDraft]);

/** Handle type-specific option change (e.g., kanban.groupByField, calendar.startDateField) */
const handleTypeOptionChange = useCallback((typeKey: string, optionKey: string, value: any) => {
const current = draft[typeKey] || {};
Expand DownExpand Up@@ -584,21 +598,61 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje
onClick={() => toggleDataSub('fields')}
/>
{expandedDataSubs.fields && (
<div data-testid="column-selector" className="pb-2 space-y-1 max-h-36 overflow-auto">
{fieldOptions.map((f) => {
const checked = Array.isArray(draft.columns) ? draft.columns.includes(f.value) : false;
return (
<div data-testid="column-selector" className="pb-2 space-y-0.5 max-h-48 overflow-auto">
{/* Selected columns — shown in draft order with reorder buttons */}
{Array.isArray(draft.columns) && draft.columns.length > 0 && (
<div data-testid="selected-columns" className="space-y-0.5 pb-1 mb-1 border-b border-border/50">
{draft.columns.map((colName: string, idx: number) => {
const field = fieldOptions.find(f => f.value === colName);
return (
<div key={colName} className="flex items-center gap-1 text-xs hover:bg-accent/50 rounded-sm py-0.5 px-1 -mx-1">
<Checkbox
data-testid={`col-checkbox-${colName}`}
checked={true}
onCheckedChange={() => handleColumnToggle(colName, false)}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="truncate flex-1">{field?.label || colName}</span>
<button
type="button"
data-testid={`col-move-up-${colName}`}
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0"
disabled={idx === 0}
onClick={() => handleColumnMove(colName, 'up')}
aria-label={`Move ${field?.label || colName} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
data-testid={`col-move-down-${colName}`}
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent disabled:opacity-30 shrink-0"
disabled={idx === draft.columns.length - 1}
onClick={() => handleColumnMove(colName, 'down')}
aria-label={`Move ${field?.label || colName} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
);
})}
</div>
)}
{/* Unselected fields — available to add */}
{fieldOptions
.filter(f => !Array.isArray(draft.columns) || !draft.columns.includes(f.value))
.map((f) => (
<label key={f.value} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/50 rounded-sm py-0.5 px-1 -mx-1">
<Checkbox
data-testid={`col-checkbox-${f.value}`}
checked={checked}
onCheckedChange={(c) => handleColumnToggle(f.value, c === true)}
checked={false}
onCheckedChange={() => handleColumnToggle(f.value, true)}
className="h-3.5 w-3.5"
/>
<span className="truncate">{f.label}</span>
</label>
);
})}
))
}
</div>
)}

Expand Down