binary-column-popover.tsx10.7 KBView on GitHub /**
* Binary Column Popover Component
*
* Provides sorting and binary filtering functionality for columns with yes/no choices.
* Used for columns like "Tasks" where we want to filter by "Has Todo Tasks" / "No Todo Tasks"
*
* Layout: [sort toggle] <-> [asc/desc button]
* [option 1 badge] [option 2 badge]
*/
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { ColumnPopoverMode, ColumnPopoverPresentation } from './column-popover-presentation';
import type { ColumnFilter, ColumnSort } from '@/modules/crm/store/crmSlice';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import { useColumnFilterOverride } from './column-filter-override';
import { useActivateColumnSortAtTop } from '../hooks/use-activate-column-sort';
import { ArrowUp, Check } from 'lucide-react';
import { useCallback, useState } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useCedarStore } from '@/modules/store';
import { Label } from '@/components/ui/label';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
type BinaryColumnField = 'currentTasks' | 'nextSteps';
interface BinaryOption {
value: 'true' | 'false';
label: string;
color: string; // Tailwind color classes
}
interface BinaryColumnPopoverContentProps {
mode?: ColumnPopoverMode;
crossLink?: React.ReactNode;
field: BinaryColumnField;
options: [BinaryOption, BinaryOption]; // Exactly 2 options
title: string;
canvasId?: string;
headerAction?: React.ReactNode;
}
function BinaryColumnPopoverContent({
field,
options,
title,
canvasId,
headerAction,
mode = 'full',
crossLink,
}: BinaryColumnPopoverContentProps) {
const globalColumns = useCedarStore((state) => state.columns);
const setColumnFilter = useCedarStore((state) => state.setColumnFilter);
const setColumnSort = useCedarStore((state) => state.setColumnSort);
const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
// Canvas mode: subscribe to canvas viewConfig for sort/filter state
const canvasColumnConfig = useCedarStore((state) => {
if (!canvasId) return null;
const c = state.canvasesById[canvasId];
const viewConfig = c?.viewConfig as ConversationViewConfig | null | undefined;
return viewConfig?.filterSortConfiguration?.[field] ?? null;
});
// Draft-local override (inbox editor) wins over both store-backed targets.
const override = useColumnFilterOverride(field);
// Get column state — from the override, canvas, or global store
const columnSort: ColumnSort | undefined = override
? override.entry?.sort
: canvasId
? canvasColumnConfig?.sort
: globalColumns[field]?.sort;
const columnFilter: ColumnFilter | undefined = override
? override.entry?.filter
: canvasId
? (canvasColumnConfig?.filter as ColumnFilter | undefined)
: globalColumns[field]?.filter;
const isActiveSortField = columnSort?.active ?? false;
const sortDirection = columnSort?.direction ?? 'asc';
const currentChoice = columnFilter?.binaryChoice;
// Canvas-aware filter write
const applyFilter = useCallback(
(filter: ColumnFilter | undefined) => {
if (override) {
override.applyFilter(filter);
} else if (canvasId) {
const state = useCedarStore.getState();
const canvas = state.canvasesById[canvasId];
if (!canvas) return;
const currentViewConfig = (canvas.viewConfig ?? {}) as ConversationViewConfig;
const currentFilterSort = currentViewConfig.filterSortConfiguration ?? {};
updateCanvasViewConfig(canvasId, {
...currentViewConfig,
filterSortConfiguration: {
...currentFilterSort,
[field]: { ...currentFilterSort[field], filter },
},
});
void saveCanvasViewConfig(canvasId);
} else {
setColumnFilter(field, filter);
}
},
[override, canvasId, field, setColumnFilter, updateCanvasViewConfig, saveCanvasViewConfig],
);
// Canvas-aware sort write
const applySort = useCallback(
(sort: ColumnSort | undefined) => {
if (canvasId) {
const state = useCedarStore.getState();
const canvas = state.canvasesById[canvasId];
if (!canvas) return;
const currentViewConfig = (canvas.viewConfig ?? {}) as ConversationViewConfig;
const currentFilterSort = currentViewConfig.filterSortConfiguration ?? {};
updateCanvasViewConfig(canvasId, {
...currentViewConfig,
filterSortConfiguration: {
...currentFilterSort,
[field]: { ...currentFilterSort[field], sort },
},
});
void saveCanvasViewConfig(canvasId);
} else {
setColumnSort(field, sort);
}
},
[canvasId, field, setColumnSort, updateCanvasViewConfig, saveCanvasViewConfig],
);
// Turning sorting on here makes this column the #1 sort (see useActivateColumnSortAtTop).
const activateSortAtTop = useActivateColumnSortAtTop(field, canvasId);
const handleToggleSortingActive = useCallback(
(enabled: boolean) => {
if (enabled) {
activateSortAtTop({ active: true, direction: sortDirection });
} else {
applySort(undefined);
}
},
[activateSortAtTop, sortDirection, applySort],
);
const handleToggleDirection = useCallback(() => {
if (!columnSort) return;
applySort({ ...columnSort, direction: sortDirection === 'asc' ? 'desc' : 'asc' });
}, [columnSort, sortDirection, applySort]);
const handleOptionToggle = useCallback(
(value: 'true' | 'false') => {
if (currentChoice === value) {
applyFilter(undefined);
} else {
applyFilter({ binaryChoice: value });
}
},
[currentChoice, applyFilter],
);
const handleClearFilter = useCallback(() => {
applyFilter(undefined);
}, [applyFilter]);
const hasActiveFilter = !!currentChoice;
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">{title}</h4>
<div className="flex items-center gap-1">
{mode !== 'sort' && hasActiveFilter && (
<Button
variant="ghost"
size="sm"
onClick={handleClearFilter}
className="h-6 rounded-sm px-2 text-xs hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
>
Clear Filter
</Button>
)}
{headerAction}
</div>
</div>
{mode !== 'filter' && (
<>
{/* Sorting controls */}
<div className="flex items-center justify-between gap-3">
{mode !== 'sort' && (
<div className="flex items-center gap-2">
<Switch
id={`sort-toggle-${field}`}
checked={isActiveSortField}
onCheckedChange={handleToggleSortingActive}
/>
<Label htmlFor={`sort-toggle-${field}`} className="text-sm">
Sort
</Label>
</div>
)}
{/* Asc/Desc toggle button */}
<button
onClick={handleToggleDirection}
disabled={!isActiveSortField}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-all',
isActiveSortField
? 'bg-muted hover:bg-muted/80 text-foreground'
: 'bg-muted/50 text-muted-foreground cursor-not-allowed opacity-50',
)}
>
<span>{sortDirection === 'asc' ? 'Ascending' : 'Descending'}</span>
<motion.div
animate={{ rotate: sortDirection === 'asc' ? 0 : 180 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<ArrowUp className="h-3.5 w-3.5" />
</motion.div>
</button>
</div>
</>
)}
{mode !== 'sort' && (
<>
{/* Filter Options */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">Filter by</Label>
<div className="space-y-2">
{options.map((option) => (
<button
key=[redacted]
onClick={() => handleOptionToggle(option.value)}
className={cn(
'w-full flex items-center gap-2 rounded-sm px-3 py-2 text-xs font-semibold transition-all',
option.color,
currentChoice === option.value && 'ring-2 ring-primary/20 ring-offset-1',
'hover:scale-[1.02]',
)}
>
<span className="flex-1 text-left">{option.label}</span>
{currentChoice === option.value && <Check className="h-3.5 w-3.5 shrink-0" />}
</button>
))}
</div>
{/* Active filter summary */}
{hasActiveFilter && (
<div className="rounded-md bg-primary/5 px-3 py-2 text-xs text-primary mt-3">
Showing:{' '}
{options.find((opt) => opt.value === currentChoice)?.label || 'Unknown'}
</div>
)}
</div>
</>
)}
{crossLink}
</div>
);
}
interface BinaryColumnPopoverProps extends ColumnPopoverPresentation {
field: BinaryColumnField;
options: [BinaryOption, BinaryOption];
title: string;
children: React.ReactElement;
canvasId?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
headerAction?: React.ReactNode;
}
export function BinaryColumnPopover({
field,
options,
title,
children,
canvasId,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
headerAction,
side,
align = 'start',
mode,
crossLink,
}: BinaryColumnPopoverProps) {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const setIsOpen = controlledOnOpenChange || setInternalOpen;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-80 p-3" side={side} align={align}>
<BinaryColumnPopoverContent
field={field}
options={options}
title={title}
canvasId={canvasId}
headerAction={headerAction}
mode={mode}
crossLink={crossLink}
/>
</PopoverContent>
</Popover>
);
}
// Export types for use in other components
export type { BinaryColumnField, BinaryOption };