PipelineView.tsx14.6 KBView on GitHub import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import {
NewCanvasScreen,
buildCanvasViewConfig,
type CanvasTemplate,
} from '@/modules/home/components/NewCanvasScreen';
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import {
LargeTabs,
LargeTabsContent,
LargeTabsList,
LargeTabsTrigger,
} from '@/components/ui/tabs';
import { CEDAR_COLORS } from '@/components/ui/SexyColourPicker';
import { CanvasRenderer } from '@/modules/canvas/components/CanvasRenderer';
import { ActiveViewDisplay } from '@/components/ui/active-view-display';
import type { Canvas } from '@/modules/canvas/types/canvas-types';
import { useCanvases } from '@/modules/canvas/hooks/use-canvases';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
import { useCallback, useEffect, useState } from 'react';
import { useHasCrmConnected } from '@/modules/integrations/use-has-crm-connected';
import { useTRPC } from '@/providers/query-provider';
import { useMutation } from '@tanstack/react-query';
import { PipelineCanvas } from './PipelineCanvas';
import { useCedarStore } from '@/modules/store';
import { CSS } from '@dnd-kit/utilities';
import { Home } from 'lucide-react';
import { cn } from '@/lib/utils';
const PIPELINE_TAB_ID = '__pipeline__';
const LAST_PIPELINE_TAB_KEY=[redacted];
// ── Sortable tab wrapper ───────────────────────────────────────────────────────
function SortableTabTrigger({
id,
value,
onClose,
isDraggingActive,
colour,
children,
}: {
id: string;
value: string;
onClose?: () => void;
isDraggingActive: boolean;
colour?: string;
children: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
return (
<div
ref={setNodeRef}
className={cn('flex items-end', isDraggingActive && !isDragging && 'transition-transform')}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1,
zIndex: isDragging ? 10 : undefined,
cursor: isDragging ? 'grabbing' : 'grab',
}}
{...attributes}
{...listeners}
>
<LargeTabsTrigger value={value} onClose={onClose}>
<span
className="mr-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ring-1 ring-black/10 dark:ring-white/10"
style={{ backgroundColor: colour ?? '#9aa0a6' }}
/>
{children}
</LargeTabsTrigger>
</div>
);
}
// ── PipelineView ───────────────────────────────────────────────────────────────
interface DynamicTab {
id: string;
title: string;
subtitle: string;
}
export function PipelineView() {
const [dynamicTabs, setDynamicTabs] = useState<DynamicTab[]>([]);
const [activeDynamicTabId, setActiveDynamicTabId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const { isLoading: isLoadingCanvases } = useCanvases();
const homeViewCanvasIds = useCedarStore((state) => state.homeViewCanvasIds);
const canvasesById = useCedarStore((state) => state.canvasesById);
const activeHomeViewCanvasId = useCedarStore((state) => state.activeCanvasId);
const setActiveHomeViewCanvasId = useCedarStore((state) => state.setActiveCanvasId);
const removeCanvas = useCedarStore((state) => state.removeCanvas);
const reorderMyHomeView = useCedarStore((state) => state.reorderMyHomeView);
const upsertCanvas = useCedarStore((state) => state.upsertCanvas);
const setHomeViewCanvasIds = useCedarStore((state) => state.setHomeViewCanvasIds);
const trpc = useTRPC();
const hasCrmConnected = useHasCrmConnected();
const { mutate: createCanvas } = useMutation(trpc.canvas.createCanvas.mutationOptions());
const homeViewCanvases = homeViewCanvasIds
.map((id) => canvasesById[id])
.filter((c): c is Canvas => !!c && c.type !== 'report' && c.type !== 'statistics');
// Restore last active pipeline tab on mount
useEffect(() => {
if (activeHomeViewCanvasId) return;
const savedTabId = localStorage.getItem(LAST_PIPELINE_TAB_KEY);
if (savedTabId && savedTabId !== PIPELINE_TAB_ID && homeViewCanvasIds.includes(savedTabId)) {
setActiveHomeViewCanvasId(savedTabId);
}
}, [homeViewCanvasIds]); // eslint-disable-line react-hooks/exhaustive-deps
const activeTabId = activeDynamicTabId ?? activeHomeViewCanvasId ?? PIPELINE_TAB_ID;
useEffect(() => {
localStorage.setItem(LAST_PIPELINE_TAB_KEY, activeTabId);
}, [activeTabId]);
// ── DnD ────────────────────────────────────────────────────────────────────
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggingId(String(event.active.id));
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setDraggingId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = homeViewCanvasIds.indexOf(String(active.id));
const newIndex = homeViewCanvasIds.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(homeViewCanvasIds, oldIndex, newIndex);
void reorderMyHomeView(reordered);
},
[homeViewCanvasIds, reorderMyHomeView],
);
// ── Tab handlers ───────────────────────────────────────────────────────────
const activeView = useCedarStore((state) => state.getActiveView());
const newEmail = useCedarStore((state) => state.newEmail);
const handleAddTab = useCallback(() => {
setDynamicTabs((prev) => prev.filter((t) => !t.id.startsWith('new-canvas-')));
const newTabId = `new-canvas-${Date.now()}`;
const newTab: DynamicTab = {
id: newTabId,
title: 'New Canvas',
subtitle: 'Choose a template',
};
setDynamicTabs((prev) => [...prev, newTab]);
setActiveHomeViewCanvasId(null);
setActiveDynamicTabId(newTabId);
}, [setActiveHomeViewCanvasId]);
const handleCreateForTemplate = useCallback(
(template: CanvasTemplate) => {
const viewConfig = buildCanvasViewConfig(template.type, { hasCrmConnected });
const colour = CEDAR_COLORS.dark[Math.floor(Math.random() * CEDAR_COLORS.dark.length)];
createCanvas(
{
title: template.title,
type: template.type,
description: template.description,
viewConfig,
data: {},
homeViewOrder: homeViewCanvasIds.length,
colour,
},
{
onSuccess: (created) => {
const canvas: Canvas = {
id: created.id,
type: created.type as Canvas['type'],
title: created.title,
description: created.description ?? undefined,
data: (created.data as Canvas['data']) ?? {},
viewConfig: created.viewConfig as Canvas['viewConfig'],
primaryOwner: created.primaryOwner,
orgId: created.orgId ?? undefined,
homeViewOrder: created.homeViewOrder ?? null,
colour: (created as { colour?: string }).colour ?? undefined,
icon: (created as { icon?: string }).icon ?? undefined,
createdAt:
created.createdAt instanceof Date
? created.createdAt.toISOString()
: String(created.createdAt),
updatedAt:
created.updatedAt instanceof Date
? created.updatedAt.toISOString()
: String(created.updatedAt),
};
upsertCanvas(canvas);
setHomeViewCanvasIds([...homeViewCanvasIds, canvas.id]);
setActiveHomeViewCanvasId(canvas.id);
useCedarStore.getState().switchThread(canvas.id);
},
},
);
},
[
createCanvas,
hasCrmConnected,
homeViewCanvasIds,
setHomeViewCanvasIds,
upsertCanvas,
setActiveHomeViewCanvasId,
],
);
const handleCanvasCreated = useCallback(
(canvas: Canvas, fromTabId: string) => {
setDynamicTabs((prev) => prev.filter((t) => t.id !== fromTabId));
setActiveDynamicTabId(null);
setActiveHomeViewCanvasId(canvas.id);
},
[setActiveHomeViewCanvasId],
);
const handleCloseCanvas = useCallback(
(canvasId: string) => {
if (activeHomeViewCanvasId === canvasId) {
const idx = homeViewCanvasIds.indexOf(canvasId);
const nextId = homeViewCanvasIds[idx - 1] ?? homeViewCanvasIds[idx + 1] ?? null;
setActiveHomeViewCanvasId(nextId);
}
removeCanvas(canvasId);
},
[activeHomeViewCanvasId, homeViewCanvasIds, removeCanvas, setActiveHomeViewCanvasId],
);
const handleCloseDynamicTab = useCallback(
(tabId: string) => {
setDynamicTabs((prev) => prev.filter((t) => t.id !== tabId));
if (activeDynamicTabId === tabId) {
setActiveDynamicTabId(null);
if (homeViewCanvasIds.length > 0) {
setActiveHomeViewCanvasId(homeViewCanvasIds[0]);
}
}
},
[activeDynamicTabId, homeViewCanvasIds, setActiveHomeViewCanvasId],
);
const handleTabChange = useCallback(
(tabId: string) => {
if (tabId === PIPELINE_TAB_ID) {
setActiveHomeViewCanvasId(null);
setActiveDynamicTabId(null);
} else if (canvasesById[tabId]) {
setActiveHomeViewCanvasId(tabId);
setActiveDynamicTabId(null);
} else {
setActiveHomeViewCanvasId(null);
setActiveDynamicTabId(tabId);
}
},
[canvasesById, setActiveHomeViewCanvasId],
);
const renderCanvasTabContent = useCallback(
(canvas: Canvas) => (
<LargeTabsContent key=[redacted] value={canvas.id} className="flex h-full flex-col">
<CanvasRenderer canvas={canvas} className="min-h-0 flex-1" />
</LargeTabsContent>
),
[],
);
if (isLoadingCanvases && homeViewCanvases.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground">Loading...</p>
</div>
);
}
return (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1">
<div className={cn('h-full w-full bg-sunken', (activeView !== null || newEmail) && 'hidden')}>
<div className="flex h-full w-full flex-col overflow-hidden">
<LargeTabs
value={activeTabId}
onValueChange={handleTabChange}
className="flex h-full flex-col bg-surface"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<LargeTabsList onAddTab={handleAddTab}>
{/* Fixed Pipeline home tab */}
<LargeTabsTrigger value={PIPELINE_TAB_ID}>
<Home className="h-4 w-4" />
</LargeTabsTrigger>
<SortableContext
items={homeViewCanvasIds}
strategy={horizontalListSortingStrategy}
>
{homeViewCanvases.map((canvas) => (
<SortableTabTrigger
key=[redacted]
id={canvas.id}
value={canvas.id}
onClose={() => handleCloseCanvas(canvas.id)}
isDraggingActive={draggingId !== null}
colour={canvas.colour}
>
{canvas.title}
</SortableTabTrigger>
))}
</SortableContext>
{dynamicTabs.map((tab) => (
<LargeTabsTrigger
key=[redacted]
value={tab.id}
onClose={() => handleCloseDynamicTab(tab.id)}
>
{tab.title}
</LargeTabsTrigger>
))}
</LargeTabsList>
<DragOverlay dropAnimation={null} modifiers={[restrictToHorizontalAxis]}>
{draggingId &&
canvasesById[draggingId] &&
(() => {
const draggingCanvas = canvasesById[draggingId];
return (
<div className="flex cursor-grabbing items-end px-[6px]">
<div className="flex items-center gap-1.5 rounded-lg bg-raised px-2 py-2 text-[13px] font-medium text-[#202124] shadow-lg ring-1 ring-black/10 dark:text-[#e8eaed] dark:ring-white/10">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full ring-1 ring-black/10 dark:ring-white/10"
style={{ backgroundColor: draggingCanvas.colour ?? '#9aa0a6' }}
/>
{draggingCanvas.title}
</div>
</div>
);
})()}
</DragOverlay>
</DndContext>
{/* Pipeline home tab content */}
<LargeTabsContent value={PIPELINE_TAB_ID} className="flex h-full flex-col">
<PipelineCanvas
onSwitchToCanvas={(canvasId) => handleTabChange(canvasId)}
onCreateForTemplate={handleCreateForTemplate}
/>
</LargeTabsContent>
{homeViewCanvases.map((canvas) => renderCanvasTabContent(canvas))}
{dynamicTabs.map((tab) => (
<LargeTabsContent key=[redacted] value={tab.id} className="flex h-full flex-col">
<NewCanvasScreen
onCanvasCreated={(canvas) => handleCanvasCreated(canvas, tab.id)}
/>
</LargeTabsContent>
))}
</LargeTabs>
</div>
</div>
{(activeView || newEmail) && <ActiveViewDisplay />}
</div>
</div>
);
}