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 };