Introduced 1 production defect in 180 days, median 106 days to fix.
/**
* ColumnConfigurationItem Component
*
* Displays column headers matching the ConversationItem layout.
* - Clicking on the Company/Settings area opens a popover to configure visible columns.
* - Clicking on individual column headers opens the appropriate sorting/filtering popover:
* - Enum fields (status, priority, etc.) → UnifiedColumnPopover
* - Date fields (lastContactedAt, nextSteps) → DateColumnPopover
* - Number fields (dealValue) → NumberColumnPopover
* - Columns (except Company) can be dragged to reorder them.
*/
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { NumberColumnPopover, type NumberColumnField } from '../number-column-popover';
import { TaskColumnPopover } from '../task-column-popover';
import { CustomFieldColumnPopover } from '../custom-field-column-popover';
import { DateColumnPopover, type DateColumnField } from '../date-column-popover';
import { useCanvasConfiguration } from '../../hooks/use-canvas-configuration';
import { useGlobalCanvasPresets } from '../../hooks/use-global-canvas-presets';
import { UnifiedColumnPopover } from '../unified-column-popover';
import Container3DButton from '@/modules/cedar-os/src/cedar-os-components/containers/Container3DButton';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeftRight, GripVertical, Star } from 'lucide-react';
import type { CRMColumn } from '../../store/crmSlice';
import { useCedarStore } from '@/modules/store';
import type {
CanvasColumnConfiguration,
CanvasPreset,
ConversationViewConfig,
} from '@/modules/canvas/types/canvas-types';
import { CSS } from '@dnd-kit/utilities';
import { createPortal } from 'react-dom';
import { getExpandedColumnWidthPx, getDefaultColumnWidthPx } from '../../utils/canvas-columns';
import { cn } from '@/lib/utils';
/**
* Built-in column presets for quick configuration
*/
export interface ColumnPreset {
id: string;
name: string;
description: string;
columnIds: string[];
}
export const COLUMN_PRESETS: ColumnPreset[] = [
{
id: 'current-action',
name: 'Suggested Action',
description: 'Suggested action items',
columnIds: ['currentAction'],
},
{
id: 'status',
name: 'Stage',
description: 'Stage badge, overview and deal size',
columnIds: ['status', 'statusOverview', 'dealValue'],
},
{
id: 'next-steps',
name: 'Next Steps',
description: 'Next steps and last contacted',
columnIds: ['nextSteps', 'nextStepDate', 'lastContactedAt'],
},
{
id: 'timeline-overview',
name: 'Timeline Overview',
description: 'Status badge, activity timeline, and last contacted',
columnIds: ['status', 'statusOverview', 'activityOverview', 'lastContactedAt'],
},
];
/** Built-in default presets shown when no global user presets are configured. */
export const BUILTIN_CANVAS_PRESETS: CanvasPreset[] = COLUMN_PRESETS.map((p) => ({
id: p.id,
name: p.name,
columnIds: p.columnIds,
}));
// Field type sets (matching SortableColumnHeader pattern)
const ENUM_FIELDS = new Set<string>([
'type',
'status',
'priority',
'crmSynced',
'history',
'hasFutureCalendar',
]);
const DATE_FIELDS = new Set<string>(['lastContactedAt', 'nextSteps', 'nextStepDate', 'lastMeetingTime', 'lastOutboundEmailTime', 'lastInboundEmailTime']);
const NUMBER_FIELDS = new Set<string>(['dealValue']);
const ACTION_FIELDS = new Set<string>(['currentAction']);
/**
* Props for the sortable column header
*/
interface SortableColumnProps {
column: CRMColumn;
isExpanded: boolean;
columnWidth?: number;
getColumnLabel: (column: CRMColumn) => string;
getPlaceholderText: (column: CRMColumn) => string;
getPopoverType: (columnId: string) => 'enum' | 'date' | 'number' | 'action' | 'customField' | null;
renderColumnWithPopover: (
column: CRMColumn,
columnContent: React.ReactElement,
) => React.ReactNode;
onResizePointerDown: (e: React.PointerEvent, column: CRMColumn) => void;
}
/**
* Sortable column header component that can be dragged to reorder
*/
function SortableColumn({
column,
isExpanded,
columnWidth,
getColumnLabel,
getPlaceholderText,
getPopoverType,
renderColumnWithPopover,
onResizePointerDown,
}: SortableColumnProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
animateLayoutChanges: () => true,
});
const shouldGrow =
column.type === 'text' ||
column.type === 'current-action' ||
column.type === 'activity-overview';
const hasCustomWidth = columnWidth != null;
// Compute numeric width for inline style
const widthPx = isExpanded
? (columnWidth ?? getExpandedColumnWidthPx(column.type, column.id))
: hasCustomWidth
? columnWidth
: shouldGrow
? undefined
: getDefaultColumnWidthPx(column.type, column.id);
const label = getColumnLabel(column);
const placeholder = getPlaceholderText(column);
const supportsPopover = getPopoverType(column.id) !== null;
const style = {
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 200ms ease',
...(widthPx != null ? { width: widthPx } : {}),
};
const columnContent = (
<div
ref={setNodeRef}
style={style}
className={cn(
'group/col relative flex items-center gap-1.5',
isExpanded || !shouldGrow || hasCustomWidth ? 'shrink-0' : 'min-w-0 flex-1',
isDragging && 'opacity-30',
)}
>
{/* Drag handle - positioned absolutely to not take up space */}
<div
{...attributes}
{...listeners}
className="absolute -left-4 top-1/2 -translate-y-1/2 cursor-grab opacity-0 group-hover/col:opacity-50 hover:!opacity-100 transition-opacity active:cursor-grabbing"
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
</div>
{/* Column header badge */}
<span
className={cn(
'shrink-0 rounded-sm bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground',
supportsPopover &&
'cursor-pointer hover:bg-muted/80 hover:text-foreground transition-colors',
)}
>
{label}
</span>
{/* Placeholder text for combined/text types */}
{placeholder && (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground/60">
{placeholder}
</span>
)}
{/* Resize handle */}
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize opacity-0 group-hover/col:opacity-100 flex items-center justify-end pr-0.5"
onPointerDown={(e) => {
e.stopPropagation(); // prevent dnd-kit sensor
onResizePointerDown(e, column);
}}
onClick={(e) => e.stopPropagation()}
>
<div className="w-0.5 h-4 rounded-full bg-border" />
</div>
</div>
);
// Wrap in appropriate popover based on column type
return <>{renderColumnWithPopover(column, columnContent)}</>;
}
/**
* Overlay component shown during drag
*/
function DragOverlayColumn({
column,
getColumnLabel,
}: {
column: CRMColumn;
getColumnLabel: (column: CRMColumn) => string;
}) {
const label = getColumnLabel(column);
return (
<div className="flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 shadow-lg">
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0 rounded-sm bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{label}
</span>
</div>
);
}
export const ColumnConfigurationItem = memo(function ColumnConfigurationItem({
canvasId,
}: {
canvasId?: string;
}) {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragOverlayContainer, setDragOverlayContainer] = useState<HTMLElement | null>(null);
const { columns: visibleColumns, allColumns, selectedAopIds, setSelectedAopIds } = useCanvasConfiguration(canvasId);
const { syncActivePresetColumns } = useGlobalCanvasPresets();
// Canvas view config subscription — needed to build merged updates
const canvasViewConfig = useCedarStore((state) => {
if (!canvasId) return null;
return state.canvasesById[canvasId]?.viewConfig as ConversationViewConfig | null | undefined;
});
const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
// Derive expanded state from the per-canvas view config
const isExpanded = canvasViewConfig?.expanded ?? false;
// Ref mirror so resize effects don't re-register on every drag pixel
const canvasViewConfigRef = useRef(canvasViewConfig);
useEffect(() => { canvasViewConfigRef.current = canvasViewConfig; }, [canvasViewConfig]);
const showConversationName = canvasViewConfig?.showConversationName ?? false;
// Company column resize state
const defaultCompanyWidth = showConversationName ? 224 : 144;
const companyColumnWidth = canvasViewConfig?.companyColumnWidth ?? defaultCompanyWidth;
const isDraggingResize = useRef(false);
const resizeStartX = useRef(0);
const resizeStartWidth = useRef(0);
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isDraggingResize.current = true;
resizeStartX.current = e.clientX;
resizeStartWidth.current = canvasViewConfig?.companyColumnWidth ?? defaultCompanyWidth;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[canvasViewConfig, defaultCompanyWidth],
);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingResize.current || !canvasId) return;
const cfg = canvasViewConfigRef.current;
if (!cfg) return;
const delta = e.clientX - resizeStartX.current;
const newWidth = Math.max(80, Math.min(480, resizeStartWidth.current + delta));
updateCanvasViewConfig(canvasId, { ...cfg, companyColumnWidth: newWidth });
};
const handleMouseUp = (e: MouseEvent) => {
if (!isDraggingResize.current) return;
isDraggingResize.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (!canvasId) return;
const cfg = canvasViewConfigRef.current;
if (!cfg) return;
const delta = e.clientX - resizeStartX.current;
const newWidth = Math.max(80, Math.min(480, resizeStartWidth.current + delta));
updateCanvasViewConfig(canvasId, { ...cfg, companyColumnWidth: newWidth });
saveCanvasViewConfig(canvasId);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [canvasId, updateCanvasViewConfig, saveCanvasViewConfig]);
// Column resize state
const resizingColumn = useRef<CRMColumn | null>(null);
const colResizeStartX = useRef(0);
const colResizeStartWidth = useRef(0);
const handleColumnResizePointerDown = useCallback(
(e: React.PointerEvent, col: CRMColumn) => {
e.preventDefault();
resizingColumn.current = col;
colResizeStartX.current = e.clientX;
const existing = canvasViewConfig?.columnConfiguration?.[col.id]?.width;
colResizeStartWidth.current =
existing ??
(isExpanded
? getExpandedColumnWidthPx(col.type, col.id)
: getDefaultColumnWidthPx(col.type, col.id));
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[canvasViewConfig, isExpanded],
);
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
if (!resizingColumn.current || !canvasId) return;
const cfg = canvasViewConfigRef.current;
if (!cfg) return;
const delta = e.clientX - colResizeStartX.current;
const newWidth = Math.max(40, Math.min(600, colResizeStartWidth.current + delta));
const currentConfig = cfg.columnConfiguration ?? {};
updateCanvasViewConfig(canvasId, {
...cfg,
columnConfiguration: {
...currentConfig,
[resizingColumn.current.id]: { ...currentConfig[resizingColumn.current.id], width: newWidth },
},
});
};
const handlePointerUp = (e: PointerEvent) => {
if (!resizingColumn.current) return;
const col = resizingColumn.current;
resizingColumn.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (!canvasId) return;
const cfg = canvasViewConfigRef.current;
if (!cfg) return;
const delta = e.clientX - colResizeStartX.current;
const newWidth = Math.max(40, Math.min(600, colResizeStartWidth.current + delta));
const currentConfig = cfg.columnConfiguration ?? {};
updateCanvasViewConfig(canvasId, {
...cfg,
columnConfiguration: {
...currentConfig,
[col.id]: { ...currentConfig[col.id], width: newWidth },
},
});
saveCanvasViewConfig(canvasId);
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
return () => {
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
};
}, [canvasId, updateCanvasViewConfig, saveCanvasViewConfig]);
const toggleCompanyNameMode = useCallback(() => {
if (!canvasId || !canvasViewConfig) return;
updateCanvasViewConfig(canvasId, { ...canvasViewConfig, showConversationName: !showConversationName });
saveCanvasViewConfig(canvasId);
}, [canvasId, canvasViewConfig, showConversationName, updateCanvasViewConfig, saveCanvasViewConfig]);
const reorderColumns = useCallback(
(newOrder: string[]) => {
if (!canvasId || !canvasViewConfig) return;
const currentConfig = canvasViewConfig.columnConfiguration ?? {};
const newColumnConfig: CanvasColumnConfiguration = {
...currentConfig,
primaryCompany: { ...currentConfig['primaryCompany'], order: 0 },
};
newOrder.forEach((colId, idx) => {
newColumnConfig[colId] = { ...newColumnConfig[colId], order: idx + 1 };
});
updateCanvasViewConfig(canvasId, { ...canvasViewConfig, columnConfiguration: newColumnConfig });
saveCanvasViewConfig(canvasId);
// Sync reordered column IDs to the active preset
void syncActivePresetColumns(canvasViewConfig.presetId, newOrder);
},
[canvasId, canvasViewConfig, updateCanvasViewConfig, saveCanvasViewConfig, syncActivePresetColumns],
);
// Create a container for the drag overlay
useEffect(() => {
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.top = '0';
container.style.left = '0';
container.style.zIndex = '10001';
container.style.pointerEvents = 'none';
document.body.appendChild(container);
setDragOverlayContainer(container);
return () => {
document.body.removeChild(container);
};
}, []);
// Setup drag sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Require 8px drag before starting
},
}),
);
// Filter out columns we don't want to show in the list view (same as ConversationItem)
const displayColumns = visibleColumns.filter(
(col) =>
col.type !== 'timeline' && col.type !== 'working-memory' && col.id !== 'wm_custom',
);
// Get draggable columns (all except primaryCompany which is always first)
const draggableColumns = displayColumns.filter((col) => col.id !== 'primaryCompany');
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
// Handle drag end - reorder columns in store
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) return;
// Get current column order (excluding primaryCompany)
const currentOrder = draggableColumns.map((col) => col.id);
const oldIndex = currentOrder.indexOf(active.id as string);
const newIndex = currentOrder.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
// Create new order array
const newOrder = [...currentOrder];
const [movedItem] = newOrder.splice(oldIndex, 1);
newOrder.splice(newIndex, 0, movedItem);
// Update store (reorderColumns will ensure primaryCompany stays first)
reorderColumns(newOrder);
},
[draggableColumns, reorderColumns],
);
// Get the active column for the drag overlay
const activeColumn = activeId ? draggableColumns.find((col) => col.id === activeId) : null;
// Get column label based on type
const getColumnLabel = (column: (typeof displayColumns)[0]) => {
switch (column.type) {
case 'company-combined':
return 'Company';
case 'current-action':
return 'Suggested Action';
default:
return column.name || column.id;
}
};
// Get placeholder text for combined types
const getPlaceholderText = (column: (typeof displayColumns)[0]) => {
switch (column.type) {
case 'current-action':
return 'Overdue tasks or activity...';
case 'text':
return `${column.name || 'Text'}...`;
default:
return '';
}
};
// Determine what type of popover a column should have
const getPopoverType = (columnId: string) => {
// Score sub-columns have no popover
const col = allColumns.find((c) => c.id === columnId);
if (col?.type === 'score') return null;
if (columnId.startsWith('wm_')) return 'customField';
if (ENUM_FIELDS.has(columnId)) return 'enum';
if (DATE_FIELDS.has(columnId)) return 'date';
if (NUMBER_FIELDS.has(columnId)) return 'number';
if (ACTION_FIELDS.has(columnId)) return 'action';
return null;
};
// Render column with appropriate popover wrapper
const renderColumnWithPopover = (
column: (typeof displayColumns)[0],
columnContent: React.ReactElement,
) => {
const popoverType = getPopoverType(column.id);
switch (popoverType) {
case 'enum': {
const enumField = column.id as
| 'status'
| 'priority'
| 'type'
| 'crmSynced'
| 'history'
| 'hasFutureCalendar';
return (
<UnifiedColumnPopover
key=[redacted]
field={enumField}
enumOptions={column.enumOptions}
canvasId={canvasId}
canvasSelectedAopIds={selectedAopIds}
canvasSetSelectedAopIds={setSelectedAopIds}
>
{columnContent}
</UnifiedColumnPopover>
);
}
case 'date': {
const dateField = (column.id === 'nextSteps' ? 'nextStepDate' : column.id) as DateColumnField;
return (
<DateColumnPopover key=[redacted] field={dateField} canvasId={canvasId}>
{columnContent}
</DateColumnPopover>
);
}
case 'number':
return (
<NumberColumnPopover
key=[redacted]
field={column.id as NumberColumnField}
title={column.name}
prefix={column.id === 'dealValue' ? '$' : undefined}
canvasId={canvasId}
>
{columnContent}
</NumberColumnPopover>
);
case 'action':
return (
<TaskColumnPopover
key=[redacted]
columnId={column.id as 'currentAction' | 'futureAction'}
canvasId={canvasId}
>
{columnContent}
</TaskColumnPopover>
);
case 'customField':
return (
<CustomFieldColumnPopover
key=[redacted]
columnId={column.id}
title={column.name}
fieldType={column.type}
valueSortable={column.valueSortable ?? true}
hasSignal={column.hasSignal ?? false}
enumOptions={column.enumOptions}
canvasId={canvasId}
>
{columnContent}
</CustomFieldColumnPopover>
);
default:
return columnContent;
}
};
return (
<div
className={cn(
'border-border/30 relative flex select-none items-center gap-3 border-b px-3 py-2 overflow-hidden',
'bg-muted/30',
isExpanded && 'w-fit min-w-full',
)}
>
{/* Company Section (Resizable) - matches ConversationItem */}
<div className="group/company relative shrink-0" style={{ width: companyColumnWidth }}>
<div className="flex w-full items-center justify-between gap-2 text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<Container3DButton
shadowSize="s"
bgColor="rgba(59, 130, 246, 0.15)"
className="rounded-full h-6 w-6 !p-0 shrink-0"
childClassName="!p-0 flex items-center justify-center h-6 w-6"
onClick={toggleCompanyNameMode}
>
<ArrowLeftRight className="h-3.5 w-3.5 text-blue-500" />
</Container3DButton>
<span className="text-xs font-medium uppercase tracking-wide truncate">
{showConversationName ? 'Name' : 'Company'}
</span>
</div>
<span className="shrink-0" title="Press X to star">
<Star className="h-3.5 w-3.5 text-muted-foreground/40" />
</span>
</div>
{/* Resize handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize opacity-0 group-hover/company:opacity-100 hover:!opacity-100 transition-opacity flex items-center justify-center"
onMouseDown={handleResizeMouseDown}
>
<div className="w-0.5 h-4 rounded-full bg-border" />
</div>
</div>
{/* Columns Section (Flexible) - matches ConversationItem */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={draggableColumns.map((col) => col.id)}
strategy={horizontalListSortingStrategy}
>
<div className={cn('flex items-center gap-3 overflow-visible', isExpanded ? 'shrink-0' : 'min-w-0 flex-1')}>
{draggableColumns.map((column) => (
<SortableColumn
key=[redacted]
column={column}
isExpanded={isExpanded}
columnWidth={canvasViewConfig?.columnConfiguration?.[column.id]?.width}
getColumnLabel={getColumnLabel}
getPlaceholderText={getPlaceholderText}
getPopoverType={getPopoverType}
renderColumnWithPopover={renderColumnWithPopover}
onResizePointerDown={handleColumnResizePointerDown}
/>
))}
</div>
</SortableContext>
{/* Drag overlay for visual feedback */}
{dragOverlayContainer &&
createPortal(
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeColumn && (
<DragOverlayColumn column={activeColumn} getColumnLabel={getColumnLabel} />
)}
</DragOverlay>,
dragOverlayContainer,
)}
</DndContext>
</div>
);
});