TasksToolbar.tsx13.0 KBView on GitHub
'use client';

import {
  ALL_DISPLAY_PROPS,
  ORDER_BY_LABELS,
  useTaskListViewOptions,
  type TaskColumnBy,
  type TaskDisplayProp,
  type TaskGroupBy,
  type TaskOrderBy,
} from '@/modules/userTasks/hooks/use-task-list-view-options';
import {
  Field,
  FieldChips,
  FieldPanelFooter,
  FieldPopover,
  FieldSection,
  FieldSelectTrigger,
  SelectField,
} from '@/components/ui/field';
import { Columns3, Filter, ListTodo, NotebookText, SlidersHorizontal } from 'lucide-react';
import { rememberTasksLayout, type TasksLayout } from './TasksLayoutToggle';
import { TaskFilterMenu, useTaskFilterCount } from './TaskFilterMenu';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { TaskGroupOrderPopover } from './TaskGroupOrderPopover';
import { useLocation, useNavigate } from 'react-router';
import { useTRPC } from '@/providers/query-provider';
import { Switch } from '@/components/ui/switch';
import { Fragment, useCallback } from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';

/** The three view form-factors — the left cluster of the toolbar. */
const VIEWS: { layout: TasksLayout; label: string; href: string; icon: typeof ListTodo }[] = [
  { layout: 'kanban', label: 'Kanban', href: '/tasks/kanban', icon: Columns3 },
  { layout: 'list', label: 'List', href: '/tasks/list', icon: ListTodo },
  { layout: 'agenda', label: 'Agenda', href: '/tasks/agenda', icon: NotebookText },
];

const GROUP_BY_OPTIONS: { value: TaskGroupBy; label: string }[] = [
  { value: 'none', label: 'No grouping' },
  { value: 'group', label: 'Task group' },
  { value: 'due', label: 'Due date' },
  { value: 'status', label: 'Deal stage' },
];

/**
 * Picking one of these RE-SEEDS the board's stored order; it does not switch a comparator. Every
 * surface renders `sortOrder`, and a card dragged afterwards holds wherever it was dropped.
 *
 * There is no 'Manual' entry, deliberately. It existed, it was the only mode a drag could change,
 * and it lived behind this popover — so the ordinary experience of the feature was dragging a card
 * and watching nothing happen. See task-order.ts.
 */
const ORDER_BY_OPTIONS: { value: TaskOrderBy; label: string }[] = (
  Object.keys(ORDER_BY_LABELS) as TaskOrderBy[]
).map((value) => ({ value, label: ORDER_BY_LABELS[value] }));

/** How the Board splits its columns. */
const COLUMN_BY_OPTIONS: { value: TaskColumnBy; label: string }[] = [
  { value: 'group', label: 'Task group' },
  { value: 'due', label: 'Due date' },
  { value: 'channel', label: 'Channel' },
];

const DISPLAY_PROP_LABELS: Record<TaskDisplayProp, string> = {
  dueDate: 'Due date',
  conversation: 'Conversation',
  action: 'Action',
};

/** The shared toolbar pill — a rounded-full bordered chip. */
const PILL =
  'flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border bg-background px-2.5 text-xs transition-colors ' +
  // A trigger whose panel is open stays filled. Otherwise the panel appears to belong to nothing,
  // and on a second glance you cannot tell which of the two pills you opened.
  'hover:bg-hover aria-expanded:bg-hover aria-expanded:text-foreground';

/** One segment of the view switcher — same metrics as PILL, minus its own border/background. */
const SEGMENT =
  'relative flex h-full shrink-0 cursor-pointer items-center gap-1.5 px-2.5 text-xs transition-colors';

function currentLayout(pathname: string): TasksLayout {
  if (pathname.startsWith('/tasks/kanban')) return 'kanban';
  if (pathname.startsWith('/tasks/list')) return 'list';
  return 'agenda';
}

/**
 * The tasks toolbar — a Linear-style row under the greeting. Left: the view switcher (Notes ·
 * Board · List). Right: a **Filters** popover (task group + channel) and a **Display** popover
 * (grouping, ordering, completed, display properties), each styled like Linear's view controls.
 * The controls drive the List view via URL params (see useTaskListViewOptions).
 */
export function TasksToolbar() {
  const { pathname } = useLocation();
  const navigate = useNavigate();
  const active = currentLayout(pathname);

  const {
    groupBy,
    setGroupBy,
    columnBy,
    setColumnBy,
    orderBy,
    setOrderBy,
    showCompleted,
    setShowCompleted,
    collapseEmpty,
    setCollapseEmpty,
    showDone,
    setShowDone,
    visibleProps,
    toggleProp,
    resetDisplay,
  } = useTaskListViewOptions();

  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const restampSortOrder = useMutation(trpc.userTasks.restampSortOrder.mutationOptions());

  /**
   * Re-seed the board's stored order from the ordering just picked, and clear every pin.
   *
   * This is the ONLY thing Ordering does — the comparator never changes. Pins are cleared because
   * a pinned card is one a human placed under the previous arrangement, and asking for a fresh
   * sort is asking for that arrangement to be replaced.
   *
   * The URL param flips first so the control responds instantly; the re-stamp lands behind it and
   * re-hydrates the slice. If it fails the board keeps the order it already had, which is a stale
   * answer rather than a wrong one.
   */
  const handleOrderByChange = useCallback(
    (next: TaskOrderBy) => {
      setOrderBy(next);
      void restampSortOrder
        .mutateAsync({ fromOrderBy: next })
        .then(() => queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] }))
        .catch(() => {
          toast.error('Could not re-sort the board');
        });
    },
    [setOrderBy, restampSortOrder, queryClient],
  );

  // The future-tasks filter only counts on the List (the Board keeps its Upcoming column).
  const filterCount = useTaskFilterCount(active === 'list');
  // The Agenda is a server-reconciled document with its own group sections, so the client-side
  // filter/display controls only drive the List and Board (both render from the task slice).
  const showControls = active === 'list' || active === 'kanban';

  return (
    <div className="flex items-center justify-between gap-2">
      {/* View switcher — ONE pill split into segments, like a Tabs list rendered as a pill: a
          single bordered capsule, hairline dividers between the views, and a sliding fill that
          animates to whichever segment is active (the layoutId indicator Tabs uses). The divider
          on either side of the active segment is dropped so the fill reads as a clean capsule. */}
      <div className="flex h-7 shrink-0 items-center overflow-hidden rounded-full border border-border bg-background">
        {VIEWS.map((v, i) => {
          const Icon = v.icon;
          const isActive = v.layout === active;
          const prevActive = i > 0 && VIEWS[i - 1]?.layout === active;
          return (
            <Fragment key=[redacted]
              {i > 0 && (
                <span
                  aria-hidden
                  className={cn(
                    'h-3.5 w-px shrink-0 bg-border transition-opacity',
                    (isActive || prevActive) && 'opacity-0',
                  )}
                />
              )}
              <button
                type="button"
                onClick={() => {
                  rememberTasksLayout(v.layout);
                  void navigate(v.href);
                }}
                className={cn(
                  SEGMENT,
                  isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
                )}
              >
                {isActive && (
                  <motion.span
                    layoutId="tasks-view-segment"
                    className="absolute inset-0 rounded-full bg-muted/60"
                    transition={{ type: 'spring', stiffness: 500, damping: 35 }}
                  />
                )}
                <Icon className="relative z-10 h-3 w-3 shrink-0" />
                <span className="relative z-10">{v.label}</span>
              </button>
            </Fragment>
          );
        })}
      </div>

      {/* Filters + Display — Linear-style view controls, wired for List + Board (both render from
          the task slice). The Agenda renders a server-reconciled document, so its own controls are
          a separate concern and it doesn't show these. */}
      <div className={cn('flex items-center gap-1.5', !showControls && 'hidden')}>
        <TaskFilterMenu
          align="end"
          includeHideFuture={active === 'list'}
          trigger={
            <button className={cn(PILL, 'text-muted-foreground hover:bg-muted/60')}>
              <Filter className="h-3 w-3 shrink-0" />
              <span>Filter</span>
              {filterCount > 0 && (
                <span className="bg-muted-foreground/20 flex h-4 min-w-[1rem] items-center justify-center rounded-full px-1 text-[10px] font-medium">
                  {filterCount}
                </span>
              )}
            </button>
          }
        />

        <FieldPopover
          trigger={
            <button className={cn(PILL, 'text-muted-foreground')}>
              <SlidersHorizontal className="h-3 w-3 shrink-0" />
              <span>Display</span>
            </button>
          }
        >
          <FieldSection>
            {active === 'kanban' ? (
              // Board: what each column represents, and how the cards sort inside one.
              <>
                <SelectField
                  label="Columns"
                  value={columnBy}
                  onValueChange={setColumnBy}
                  options={COLUMN_BY_OPTIONS}
                />
                <SelectField
                  label="Ordering"
                  value={orderBy}
                  onValueChange={handleOrderByChange}
                  options={ORDER_BY_OPTIONS}
                />
              </>
            ) : (
              <>
                <SelectField
                  label="Grouping"
                  value={groupBy}
                  onValueChange={setGroupBy}
                  options={GROUP_BY_OPTIONS}
                />
                <SelectField
                  label="Ordering"
                  value={orderBy}
                  onValueChange={handleOrderByChange}
                  options={ORDER_BY_OPTIONS}
                />
              </>
            )}

            {/* Group order — the sections' order on the list, the columns' order on the board.
                Only offered where the surface is actually split by task group.

                The control looks like the selects above it and opens the drag-reorderable list,
                because that IS the ordering: groups carry an explicit `position` a person
                arranges, not a sort key derived from a field. A select of axes here would have to
                re-seed those positions to mean anything, which is a different feature wearing
                this one's clothes. */}
            {(active === 'kanban' ? columnBy === 'group' : groupBy === 'group') && (
              <Field label="Group order">
                <TaskGroupOrderPopover trigger={<FieldSelectTrigger>Reorder</FieldSelectTrigger>} />
              </Field>
            )}
          </FieldSection>

          {/* The switches are their own block. Three selects and three toggles running together
              as one column of six is a list you have to read; split by a seam, it is two things
              you can each take in at a glance. */}
          <FieldSection title={active === 'kanban' ? 'Board options' : 'List options'}>
            {active === 'kanban' ? (
              <>
                <Field label="Show done">
                  <Switch checked={showDone} onCheckedChange={setShowDone} />
                </Field>
                <Field label="Collapse empty">
                  <Switch checked={collapseEmpty} onCheckedChange={setCollapseEmpty} />
                </Field>
              </>
            ) : (
              <Field label="Show completed">
                <Switch checked={showCompleted} onCheckedChange={setShowCompleted} />
              </Field>
            )}
          </FieldSection>

          {/* Three short options, all worth seeing at once — so chips, not a menu. A menu here
              would hide behind a trigger reading "2 shown", which is a number you have to open
              the menu to understand. */}
          {active === 'list' && (
            <FieldSection>
              <p className="text-muted-foreground mb-2 mt-2 text-xs font-medium leading-5">
                Display properties
              </p>
              <FieldChips
                options={ALL_DISPLAY_PROPS.map((prop) => ({
                  value: prop,
                  label: DISPLAY_PROP_LABELS[prop],
                }))}
                selected={visibleProps}
                onToggle={toggleProp}
              />
            </FieldSection>
          )}

          <FieldPanelFooter>
            <button
              type="button"
              onClick={resetDisplay}
              className="hover:bg-hover h-6 cursor-pointer rounded-full px-2 text-xs font-medium transition-colors"
            >
              Reset
            </button>
          </FieldPanelFooter>
        </FieldPopover>
      </div>
    </div>
  );
}