feat: cmd+click and shift+click multi-select for kanban and sidebar (#1518)
* feat: add anchored range selection to multi-select reducer * feat: cmd+click and shift+click multi-select on kanban board * feat: add cmd/shift multi-select to sidebar task list * test: e2e coverage for cmd/shift multi-select * fix: harden multi-select selection lifecycle from review feedback * test: scope multi-select e2e helpers to active containers * fix: add sidebar multi-select hook tests and tighten bulk-action lifecycle * fix: prune hidden sidebar selections and order bulk moves by view * test: cover visible-order bulk move and anchor realignment * fix: harden sidebar bulk-move scope and keyboard selection parity * fix: order kanban bulk moves by board position * test: cover multi-select helpers and fix stale-id sort fallback * fix: order kanban card context-menu moves by board position * feat: sidebar multi-select shows only bulk-valid actions with bulk pin and delete * fix: harden multi-select bulk ordering, selection clearing, and a11y * fix: gate kanban card move-to-step for mixed-workflow selections * fix: unpin fully pinned sidebar selections * refactor: keep sidebar task components under limits * test: restore sidebar selection console spy safely
This commit is contained in:
@@ -394,6 +394,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
|
||||
selectedRepositoryIds={s.userSettings.repositoryIds}
|
||||
selectedIds={s.multiSelect.selectedIds}
|
||||
onToggleSelect={s.multiSelect.toggleSelect}
|
||||
onSelectRange={s.multiSelect.selectRange}
|
||||
isMultiSelectMode={s.multiSelect.isMultiSelectMode}
|
||||
onToggleMultiSelect={s.multiSelect.toggleMultiSelect}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { dispatchKanbanCardClick, type Task } from "./kanban-card";
|
||||
|
||||
function fakeEvent(mods: Partial<MouseEvent> = {}) {
|
||||
return {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
...mods,
|
||||
} as unknown as React.MouseEvent;
|
||||
}
|
||||
|
||||
const task = { id: "t1", title: "T", workflowStepId: "s1" } as Task;
|
||||
|
||||
function handlers() {
|
||||
return {
|
||||
onToggleSelect: vi.fn(),
|
||||
onRangeSelect: vi.fn(),
|
||||
onClick: vi.fn(),
|
||||
isMultiSelectMode: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("dispatchKanbanCardClick", () => {
|
||||
it("cmd/meta-click toggles and prevents default", () => {
|
||||
const h = handlers();
|
||||
const e = fakeEvent({ metaKey: true });
|
||||
dispatchKanbanCardClick(e, task.id, task, h);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(h.onToggleSelect).toHaveBeenCalledWith("t1");
|
||||
expect(h.onClick).not.toHaveBeenCalled();
|
||||
expect(h.onRangeSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ctrl-click toggles (Windows/Linux)", () => {
|
||||
const h = handlers();
|
||||
dispatchKanbanCardClick(fakeEvent({ ctrlKey: true }), task.id, task, h);
|
||||
expect(h.onToggleSelect).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
|
||||
it("shift-click range-selects and prevents default", () => {
|
||||
const h = handlers();
|
||||
const e = fakeEvent({ shiftKey: true });
|
||||
dispatchKanbanCardClick(e, task.id, task, h);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(h.onRangeSelect).toHaveBeenCalledWith("t1");
|
||||
expect(h.onToggleSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plain click while in multi-select mode toggles", () => {
|
||||
const h = { ...handlers(), isMultiSelectMode: true };
|
||||
dispatchKanbanCardClick(fakeEvent(), task.id, task, h);
|
||||
expect(h.onToggleSelect).toHaveBeenCalledWith("t1");
|
||||
expect(h.onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plain click outside multi-select mode previews/opens", () => {
|
||||
const h = handlers();
|
||||
dispatchKanbanCardClick(fakeEvent(), task.id, task, h);
|
||||
expect(h.onClick).toHaveBeenCalledWith(task);
|
||||
expect(h.onToggleSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export type KanbanCardShellProps = KanbanCardActionProps &
|
||||
isSelected?: boolean;
|
||||
isMultiSelectMode?: boolean;
|
||||
isPreviewed: boolean;
|
||||
onClick: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-d
|
||||
import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog";
|
||||
import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog";
|
||||
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
|
||||
import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select";
|
||||
import { repositorySlug } from "@/lib/repository-slug";
|
||||
import { formatUserHomePath } from "@/lib/utils";
|
||||
import { repositoryId as toRepositoryId, type Repository, type TaskState } from "@/lib/types/http";
|
||||
@@ -82,6 +83,8 @@ interface KanbanCardProps {
|
||||
isSelected?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
/** Shift-click range select within this card's column. */
|
||||
onRangeSelect?: (taskId: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
}
|
||||
|
||||
@@ -111,6 +114,7 @@ function useKanbanCardMenus({
|
||||
>) {
|
||||
const moveTargets = useKanbanCardMoveTargets(task.id, steps);
|
||||
const moveTasks = useTaskWorkflowMove();
|
||||
const { sortByDisplayOrder, getWorkflowIdForTask } = useTaskMultiSelectStore();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
|
||||
const [showPRDialog, setShowPRDialog] = useState(false);
|
||||
@@ -134,13 +138,22 @@ function useKanbanCardMenus({
|
||||
};
|
||||
|
||||
const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id];
|
||||
// Sort into board order lazily (only when a move actually fires) so a backward
|
||||
// range selection isn't scrambled — and we don't pay it on every card render.
|
||||
const orderedSelectedIds = () => sortByDisplayOrder(selectedTaskIds);
|
||||
// A selection spanning workflows can't safely use same-workflow "Move to" (it
|
||||
// would drag other-workflow cards into this card's workflow), so gate it — only
|
||||
// computed for a genuine multi-selection to avoid per-render snapshot scans.
|
||||
const isMixedWorkflowSelection =
|
||||
selectedTaskIds.length > 1 &&
|
||||
new Set(selectedTaskIds.map((id) => getWorkflowIdForTask(id))).size > 1;
|
||||
const moveSelectedToStep = (stepId: string) => {
|
||||
if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) {
|
||||
onMove(task, stepId);
|
||||
return;
|
||||
}
|
||||
if (!moveTargets.currentWorkflowId) return;
|
||||
runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId);
|
||||
runMoveTasks(orderedSelectedIds(), moveTargets.currentWorkflowId, stepId);
|
||||
};
|
||||
|
||||
const menuBase = {
|
||||
@@ -168,9 +181,11 @@ function useKanbanCardMenus({
|
||||
}),
|
||||
contextMenuEntries: buildKanbanCardMenuEntries({
|
||||
...menuBase,
|
||||
onMoveToStep: moveSelectedToStep,
|
||||
// Hide same-workflow "Move to" for a mixed-workflow selection; only the
|
||||
// explicit "Send to workflow" path remains (matches the toolbar guard).
|
||||
onMoveToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep,
|
||||
onSendToWorkflow: (workflowId, stepId) => {
|
||||
runMoveTasks(selectedTaskIds, workflowId, stepId);
|
||||
runMoveTasks(orderedSelectedIds(), workflowId, stepId);
|
||||
},
|
||||
}),
|
||||
showDeleteConfirm,
|
||||
@@ -251,6 +266,42 @@ function KanbanCardDialogs({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl-click toggles a single card; Shift-click range-selects within the
|
||||
* column; either modifier enters multi-select mode without the toggle button.
|
||||
* A plain click toggles while in multi-select mode, otherwise previews/opens.
|
||||
*/
|
||||
/** @internal Exported for unit testing the four-branch click dispatch. */
|
||||
export function dispatchKanbanCardClick(
|
||||
e: React.MouseEvent,
|
||||
taskId: string,
|
||||
task: Task,
|
||||
handlers: {
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onRangeSelect?: (taskId: string) => void;
|
||||
onClick?: (task: Task) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
},
|
||||
): void {
|
||||
// Only intercept a modifier click when the matching handler is wired, so a
|
||||
// card rendered without selection handlers still opens on Cmd/Shift click.
|
||||
if ((e.metaKey || e.ctrlKey) && handlers.onToggleSelect) {
|
||||
e.preventDefault();
|
||||
handlers.onToggleSelect(taskId);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && handlers.onRangeSelect) {
|
||||
e.preventDefault();
|
||||
handlers.onRangeSelect(taskId);
|
||||
return;
|
||||
}
|
||||
if (handlers.isMultiSelectMode && handlers.onToggleSelect) {
|
||||
handlers.onToggleSelect(taskId);
|
||||
return;
|
||||
}
|
||||
handlers.onClick?.(task);
|
||||
}
|
||||
|
||||
function useActiveWorkspaceRepositories() {
|
||||
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
return useAppStore((state) =>
|
||||
@@ -274,6 +325,7 @@ export function KanbanCard({
|
||||
isSelected,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onRangeSelect,
|
||||
isMultiSelectMode,
|
||||
}: KanbanCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
@@ -306,18 +358,13 @@ export function KanbanCard({
|
||||
onMove,
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMultiSelectMode) {
|
||||
onToggleSelect?.(task.id);
|
||||
return;
|
||||
}
|
||||
onClick?.(task);
|
||||
};
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect?.(task.id);
|
||||
};
|
||||
const handleClick = (e: React.MouseEvent) =>
|
||||
dispatchKanbanCardClick(e, task.id, task, {
|
||||
onToggleSelect,
|
||||
onRangeSelect,
|
||||
onClick,
|
||||
isMultiSelectMode,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -338,7 +385,10 @@ export function KanbanCard({
|
||||
isArchiving={isArchiving}
|
||||
menuEntries={dropdownMenuEntries}
|
||||
onClick={handleClick}
|
||||
onCheckboxClick={handleCheckboxClick}
|
||||
onCheckboxClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect?.(task.id);
|
||||
}}
|
||||
onOpenFullPage={onOpenFullPage}
|
||||
/>
|
||||
</KanbanCardContextMenu>
|
||||
|
||||
@@ -34,6 +34,8 @@ interface KanbanColumnProps {
|
||||
hideHeader?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
/** Shift-click range select; receives the clicked id + this column's ordered ids. */
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ export function KanbanColumn({
|
||||
hideHeader = false,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
@@ -66,6 +69,10 @@ export function KanbanColumn({
|
||||
[repositoriesByWorkspace],
|
||||
);
|
||||
|
||||
// Ordered ids of the cards rendered in this column — the source of truth for
|
||||
// shift-click range selection (matches exactly what the user sees).
|
||||
const columnTaskIds = useMemo(() => tasks.map((t) => t.id), [tasks]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -109,6 +116,9 @@ export function KanbanColumn({
|
||||
isSelected={selectedIds?.has(task.id)}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onRangeSelect={
|
||||
onSelectRange ? (taskId) => onSelectRange(taskId, columnTaskIds) : undefined
|
||||
}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -48,6 +48,7 @@ export type SwimlaneContainerProps = {
|
||||
selectedRepositoryIds?: string[];
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
onToggleMultiSelect?: () => void;
|
||||
};
|
||||
@@ -119,6 +120,7 @@ type WorkflowItemProps = {
|
||||
showMaximizeButton?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
onToggleMultiSelect?: () => void;
|
||||
};
|
||||
@@ -263,6 +265,7 @@ export function SwimlaneContainer({
|
||||
selectedRepositoryIds = [],
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
onToggleMultiSelect,
|
||||
}: SwimlaneContainerProps) {
|
||||
@@ -332,6 +335,7 @@ export function SwimlaneContainer({
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onSelectRange={onSelectRange}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
onToggleMultiSelect={index === 0 ? onToggleMultiSelect : undefined}
|
||||
/>
|
||||
|
||||
@@ -41,6 +41,7 @@ export type SwimlaneKanbanContentProps = {
|
||||
showMaximizeButton?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
};
|
||||
|
||||
@@ -185,6 +186,7 @@ function MobileKanbanLayout({
|
||||
archivingTaskId,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
}: {
|
||||
steps: WorkflowStep[];
|
||||
@@ -203,6 +205,7 @@ function MobileKanbanLayout({
|
||||
archivingTaskId?: string | null;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
}) {
|
||||
const taskCounts = useMemo(() => {
|
||||
@@ -239,6 +242,7 @@ function MobileKanbanLayout({
|
||||
archivingTaskId={archivingTaskId}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onSelectRange={onSelectRange}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
/>
|
||||
<MobileDropTargets steps={steps} currentStepId={currentStepId} isDragging={!!activeTask} />
|
||||
@@ -260,6 +264,7 @@ function TabletKanbanLayout({
|
||||
archivingTaskId,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
}: {
|
||||
steps: WorkflowStep[];
|
||||
@@ -275,6 +280,7 @@ function TabletKanbanLayout({
|
||||
archivingTaskId?: string | null;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
}) {
|
||||
const getTasksForStep = useTasksByStep(tasks);
|
||||
@@ -301,6 +307,7 @@ function TabletKanbanLayout({
|
||||
archivingTaskId={archivingTaskId}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onSelectRange={onSelectRange}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -323,6 +330,7 @@ function DesktopKanbanLayout({
|
||||
archivingTaskId,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
isCompactDesktop,
|
||||
}: {
|
||||
@@ -339,6 +347,7 @@ function DesktopKanbanLayout({
|
||||
archivingTaskId?: string | null;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
isCompactDesktop: boolean;
|
||||
}) {
|
||||
@@ -369,6 +378,7 @@ function DesktopKanbanLayout({
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onSelectRange={onSelectRange}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
/>
|
||||
))}
|
||||
@@ -391,6 +401,7 @@ export function SwimlaneKanbanContent({
|
||||
showMaximizeButton,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
}: SwimlaneKanbanContentProps) {
|
||||
const { isMobile, isTablet, isCompactDesktop } = useResponsiveBreakpoint();
|
||||
@@ -416,6 +427,7 @@ export function SwimlaneKanbanContent({
|
||||
archivingTaskId,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
}),
|
||||
[
|
||||
@@ -432,6 +444,7 @@ export function SwimlaneKanbanContent({
|
||||
archivingTaskId,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -22,9 +22,46 @@ type SwipeableColumnsProps = {
|
||||
archivingTaskId?: string | null;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
};
|
||||
|
||||
/** Two-way sync between Embla's carousel position and the external activeIndex. */
|
||||
function useEmblaIndexSync(
|
||||
emblaApi: ReturnType<typeof useEmblaCarousel>[1],
|
||||
activeIndex: number,
|
||||
onIndexChange: (index: number) => void,
|
||||
) {
|
||||
const userInteracting = useRef(false);
|
||||
|
||||
// Sync carousel position with external activeIndex (tab clicks)
|
||||
useEffect(() => {
|
||||
if (emblaApi && emblaApi.selectedScrollSnap() !== activeIndex) {
|
||||
emblaApi.scrollTo(activeIndex, true);
|
||||
}
|
||||
}, [emblaApi, activeIndex]);
|
||||
|
||||
// Sync tab state when user swipes (only on user-initiated interactions)
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
const onPointerDown = () => {
|
||||
userInteracting.current = true;
|
||||
};
|
||||
const onSelect = () => {
|
||||
if (!userInteracting.current) return;
|
||||
userInteracting.current = false;
|
||||
const selectedIndex = emblaApi.selectedScrollSnap();
|
||||
if (selectedIndex !== activeIndex) onIndexChange(selectedIndex);
|
||||
};
|
||||
emblaApi.on("pointerDown", onPointerDown);
|
||||
emblaApi.on("select", onSelect);
|
||||
return () => {
|
||||
emblaApi.off("pointerDown", onPointerDown);
|
||||
emblaApi.off("select", onSelect);
|
||||
};
|
||||
}, [emblaApi, activeIndex, onIndexChange]);
|
||||
}
|
||||
|
||||
export function SwipeableColumns({
|
||||
steps,
|
||||
tasks,
|
||||
@@ -41,6 +78,7 @@ export function SwipeableColumns({
|
||||
archivingTaskId,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onSelectRange,
|
||||
isMultiSelectMode,
|
||||
}: SwipeableColumnsProps) {
|
||||
// Stable options to avoid Embla reinitializing on every activeIndex change
|
||||
@@ -55,7 +93,6 @@ export function SwipeableColumns({
|
||||
[initialIndex],
|
||||
);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(options);
|
||||
const userInteracting = useRef(false);
|
||||
|
||||
const getTasksForStep = useCallback(
|
||||
(stepId: string) => {
|
||||
@@ -67,36 +104,7 @@ export function SwipeableColumns({
|
||||
[tasks],
|
||||
);
|
||||
|
||||
// Sync carousel position with external activeIndex (tab clicks)
|
||||
useEffect(() => {
|
||||
if (emblaApi && emblaApi.selectedScrollSnap() !== activeIndex) {
|
||||
emblaApi.scrollTo(activeIndex, true);
|
||||
}
|
||||
}, [emblaApi, activeIndex]);
|
||||
|
||||
// Sync tab state when user swipes (only on user-initiated interactions)
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
const onPointerDown = () => {
|
||||
userInteracting.current = true;
|
||||
};
|
||||
const onSelect = () => {
|
||||
if (!userInteracting.current) return;
|
||||
userInteracting.current = false;
|
||||
const selectedIndex = emblaApi.selectedScrollSnap();
|
||||
if (selectedIndex !== activeIndex) {
|
||||
onIndexChange(selectedIndex);
|
||||
}
|
||||
};
|
||||
|
||||
emblaApi.on("pointerDown", onPointerDown);
|
||||
emblaApi.on("select", onSelect);
|
||||
return () => {
|
||||
emblaApi.off("pointerDown", onPointerDown);
|
||||
emblaApi.off("select", onSelect);
|
||||
};
|
||||
}, [emblaApi, activeIndex, onIndexChange]);
|
||||
useEmblaIndexSync(emblaApi, activeIndex, onIndexChange);
|
||||
|
||||
// Use explicit height calculation for mobile since flex height inheritance doesn't work reliably
|
||||
// 100dvh - header (~56px) - tabs (~44px) - safe area
|
||||
@@ -127,6 +135,7 @@ export function SwipeableColumns({
|
||||
archivingTaskId={archivingTaskId}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onSelectRange={onSelectRange}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
hideHeader
|
||||
/>
|
||||
|
||||
@@ -39,7 +39,15 @@ type TaskItemProps = {
|
||||
sessionState?: TaskSessionState;
|
||||
isArchived?: boolean;
|
||||
isSelected?: boolean;
|
||||
/** Whether this row is part of an active multi-selection (distinct from the active-task highlight). */
|
||||
isMultiSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* Modifier-aware activation handler. When provided, both mouse clicks and
|
||||
* keyboard Enter/Space delegate here (cmd/shift/plain dispatch lives in the
|
||||
* parent); `onClick` is the fallback when no selection handler is wired.
|
||||
*/
|
||||
onSelect?: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
diffStats?: DiffStats;
|
||||
isRemoteExecutor?: boolean;
|
||||
remoteExecutorType?: string;
|
||||
@@ -102,10 +110,53 @@ function computeIsPreparing(state?: TaskState, sessionState?: TaskSessionState):
|
||||
return sessionState === "STARTING" && classifyTask(sessionState, state) !== "review";
|
||||
}
|
||||
|
||||
function handleTaskItemKeyDown(e: React.KeyboardEvent<HTMLDivElement>, onClick?: () => void): void {
|
||||
function handleTaskItemKeyDown(
|
||||
e: React.KeyboardEvent<HTMLDivElement>,
|
||||
onSelect: ((e: React.KeyboardEvent) => void) | undefined,
|
||||
onClick: (() => void) | undefined,
|
||||
): void {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
onClick?.();
|
||||
// Keyboard activation mirrors mouse: when a selection-aware handler is wired,
|
||||
// Enter/Space toggles/extends the selection just like a click would.
|
||||
if (onSelect) onSelect(e);
|
||||
else onClick?.();
|
||||
}
|
||||
|
||||
/** State attributes for the row: active-task (`aria-current`) + multi-selected. */
|
||||
function taskItemStateAttrs(isSelected: boolean, isMultiSelected: boolean) {
|
||||
return {
|
||||
"data-active": isSelected ? "true" : "false",
|
||||
"data-multiselected": isMultiSelected ? "true" : undefined,
|
||||
"aria-current": isSelected ? ("true" as const) : undefined,
|
||||
// Surface the multi-selected state to assistive tech.
|
||||
"aria-selected": isMultiSelected ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function taskItemRowClassName(
|
||||
isSelected: boolean,
|
||||
isMultiSelected: boolean,
|
||||
isRoot: boolean,
|
||||
): string {
|
||||
return cn(
|
||||
"group relative flex w-full items-start gap-2 py-2 pr-3 text-left text-sm outline-none cursor-pointer",
|
||||
"transition-colors duration-75 hover:bg-foreground/[0.05]",
|
||||
isSelected && "bg-primary/10",
|
||||
// When a row is both the active task and multi-selected, keep the stronger
|
||||
// active background and just add the selection ring on top.
|
||||
isMultiSelected && !isSelected && "bg-primary/5",
|
||||
isMultiSelected && "ring-1 ring-inset ring-primary/40",
|
||||
isRoot && "pl-3",
|
||||
);
|
||||
}
|
||||
|
||||
/** Mouse uses the modifier-aware `onSelect`; falls back to the plain `onClick`. */
|
||||
function taskItemRowClick(
|
||||
onSelect: ((e: React.MouseEvent | React.KeyboardEvent) => void) | undefined,
|
||||
onClick: (() => void) | undefined,
|
||||
): (e: React.MouseEvent) => void {
|
||||
return (e) => (onSelect ? onSelect(e) : onClick?.());
|
||||
}
|
||||
|
||||
function TaskStateIcon({
|
||||
@@ -347,7 +398,9 @@ export const TaskItem = memo(function TaskItem({
|
||||
sessionState,
|
||||
isArchived,
|
||||
isSelected = false,
|
||||
isMultiSelected = false,
|
||||
onClick,
|
||||
onSelect,
|
||||
diffStats,
|
||||
isRemoteExecutor,
|
||||
remoteExecutorType,
|
||||
@@ -382,17 +435,11 @@ export const TaskItem = memo(function TaskItem({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="sidebar-task-item"
|
||||
data-active={isSelected ? "true" : "false"}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => handleTaskItemKeyDown(e, onClick)}
|
||||
{...taskItemStateAttrs(isSelected, isMultiSelected)}
|
||||
onClick={taskItemRowClick(onSelect, onClick)}
|
||||
onKeyDown={(e) => handleTaskItemKeyDown(e, onSelect, onClick)}
|
||||
style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-2 py-2 pr-3 text-left text-sm outline-none cursor-pointer",
|
||||
"transition-colors duration-75 hover:bg-foreground/[0.05]",
|
||||
isSelected && "bg-primary/10",
|
||||
indent.depth === 0 && "pl-3",
|
||||
)}
|
||||
className={taskItemRowClassName(isSelected, isMultiSelected, indent.depth === 0)}
|
||||
>
|
||||
<SelectionBar isSelected={isSelected} color={taskColor} />
|
||||
<RowConnector depth={indent.depth} leftPx={indent.connectorLeftPx} />
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TaskSwitcherItem } from "./task-switcher";
|
||||
import type { useArchivedTaskState } from "./task-archived-context";
|
||||
|
||||
export function buildArchivedSidebarItem(
|
||||
s: ReturnType<typeof useArchivedTaskState>,
|
||||
): TaskSwitcherItem {
|
||||
return {
|
||||
id: s.archivedTaskId!,
|
||||
title: s.archivedTaskTitle ?? "Archived task",
|
||||
state: undefined,
|
||||
sessionState: undefined,
|
||||
description: undefined,
|
||||
workflowId: undefined,
|
||||
workflowName: undefined,
|
||||
workflowStepId: undefined,
|
||||
workflowStepTitle: undefined,
|
||||
repositoryPath: s.archivedTaskRepositoryPath,
|
||||
diffStats: undefined,
|
||||
isRemoteExecutor: false,
|
||||
remoteExecutorType: undefined,
|
||||
remoteExecutorName: undefined,
|
||||
primarySessionId: null,
|
||||
hasPendingClarification: false,
|
||||
hasPendingPermission: false,
|
||||
updatedAt: s.archivedTaskUpdatedAt,
|
||||
createdAt: undefined,
|
||||
isArchived: true,
|
||||
parentTaskTitle: undefined,
|
||||
parentTaskId: undefined,
|
||||
prInfo: undefined,
|
||||
isPRReview: false,
|
||||
isIssueWatch: false,
|
||||
issueInfo: undefined,
|
||||
agentErrorMessage: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useBulkConfirmDialog } from "./task-session-sidebar-selection";
|
||||
|
||||
const tasks = [
|
||||
{ id: "a", remoteExecutorType: "docker" },
|
||||
{ id: "b", remoteExecutorType: null },
|
||||
{ id: "c", remoteExecutorType: "sprites" },
|
||||
];
|
||||
|
||||
describe("useBulkConfirmDialog", () => {
|
||||
it("open() captures the ids and their executor types", () => {
|
||||
const bulkArchive = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive));
|
||||
|
||||
act(() => result.current.open(["a", "c"]));
|
||||
expect(result.current.state).toEqual({ ids: ["a", "c"], executorTypes: ["docker", "sprites"] });
|
||||
});
|
||||
|
||||
it("confirm() archives the captured ids and clears the dialog", async () => {
|
||||
const bulkArchive = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive));
|
||||
|
||||
act(() => result.current.open(["a", "b"]));
|
||||
await act(async () => {
|
||||
await result.current.confirm({ cascade: true });
|
||||
});
|
||||
expect(bulkArchive).toHaveBeenCalledWith(["a", "b"], { cascade: true });
|
||||
expect(result.current.state).toBeNull();
|
||||
});
|
||||
|
||||
it("confirm() is a no-op when no dialog is open", async () => {
|
||||
const bulkArchive = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.confirm({ cascade: false });
|
||||
});
|
||||
expect(bulkArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the dialog even when archiving rejects", async () => {
|
||||
const bulkArchive = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const { result } = renderHook(() => useBulkConfirmDialog(tasks, bulkArchive));
|
||||
act(() => result.current.open(["a"]));
|
||||
await act(async () => {
|
||||
await result.current.confirm({ cascade: false });
|
||||
});
|
||||
expect(result.current.state).toBeNull();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useSidebarMultiSelect } from "@/hooks/use-sidebar-multi-select";
|
||||
import {
|
||||
computeMixedWorkflowSelection,
|
||||
flattenVisibleTaskIds,
|
||||
sortIdsByVisibleOrder,
|
||||
type GroupedSidebarList,
|
||||
} from "@/lib/sidebar/apply-view";
|
||||
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
|
||||
type BulkConfirmState = { ids: string[]; executorTypes: Array<string | null | undefined> };
|
||||
|
||||
/**
|
||||
* Owns a bulk confirm-dialog's state and runs `run` (archive or delete) on
|
||||
* confirm. `run` already surfaces its own failures, so the catch here is
|
||||
* defensive; `finally` guarantees the dialog closes.
|
||||
* @internal Exported for unit testing.
|
||||
*/
|
||||
export function useBulkConfirmDialog(
|
||||
displayTasks: Array<{ id: string; remoteExecutorType?: string | null }>,
|
||||
run: (ids: string[], opts?: { cascade?: boolean }) => Promise<void>,
|
||||
) {
|
||||
const [state, setState] = useState<BulkConfirmState | null>(null);
|
||||
|
||||
const open = useCallback(
|
||||
(ids: string[]) => {
|
||||
const byId = new Map(displayTasks.map((t) => [t.id, t.remoteExecutorType]));
|
||||
setState({ ids, executorTypes: ids.map((id) => byId.get(id) ?? null) });
|
||||
},
|
||||
[displayTasks],
|
||||
);
|
||||
|
||||
const confirm = useCallback(
|
||||
async ({ cascade }: { cascade: boolean }) => {
|
||||
if (!state) return;
|
||||
try {
|
||||
await run(state.ids, { cascade });
|
||||
} catch (error) {
|
||||
console.error("Bulk action failed:", error);
|
||||
} finally {
|
||||
setState(null);
|
||||
}
|
||||
},
|
||||
[state, run],
|
||||
);
|
||||
|
||||
return { state, setState, open, confirm };
|
||||
}
|
||||
|
||||
type Multi = ReturnType<typeof useSidebarMultiSelect>;
|
||||
|
||||
/**
|
||||
* The selection-lifecycle effects (Escape-to-clear, prune-hidden) and the
|
||||
* memoized range/move/pin callbacks threaded into `TaskSwitcher`.
|
||||
*/
|
||||
function useSelectionHandlers(args: {
|
||||
multiSelect: Multi;
|
||||
pinTasks: (ids: string[]) => void;
|
||||
unpinTasks: (ids: string[]) => void;
|
||||
pinnedTaskIds: string[];
|
||||
visibleTaskIds: string[];
|
||||
movableSelectedIds: Set<string>;
|
||||
}) {
|
||||
const { multiSelect, pinTasks, unpinTasks, pinnedTaskIds, visibleTaskIds, movableSelectedIds } =
|
||||
args;
|
||||
const { isSelecting, clearSelection, selectRange, pruneToVisible } = multiSelect;
|
||||
|
||||
// Escape clears an active selection.
|
||||
useEffect(() => {
|
||||
if (!isSelecting) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") clearSelection();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [isSelecting, clearSelection]);
|
||||
|
||||
// Prune selections that scroll out of view (collapsed group / filter change)
|
||||
// so plain clicks on visible rows stop behaving as selection-mode.
|
||||
useEffect(() => {
|
||||
pruneToVisible(visibleTaskIds);
|
||||
}, [visibleTaskIds, pruneToVisible]);
|
||||
|
||||
// Stable ref so TaskSwitcher's React.memo isn't defeated by a fresh closure.
|
||||
const onSelectTaskRange = useCallback(
|
||||
(taskId: string) => selectRange(taskId, visibleTaskIds),
|
||||
[selectRange, visibleTaskIds],
|
||||
);
|
||||
|
||||
// Bulk move in rendered order so a backward range selection isn't scrambled at
|
||||
// the destination; drop workflow-less rows that can't be moved.
|
||||
const onBulkMove = useCallback(
|
||||
(ids: string[], targetWorkflowId: string, targetStepId: string) =>
|
||||
multiSelect.bulkMove(
|
||||
sortIdsByVisibleOrder(
|
||||
ids.filter((id) => movableSelectedIds.has(id)),
|
||||
visibleTaskIds,
|
||||
),
|
||||
targetWorkflowId,
|
||||
targetStepId,
|
||||
),
|
||||
[multiSelect, visibleTaskIds, movableSelectedIds],
|
||||
);
|
||||
|
||||
const onBulkPin = useCallback(
|
||||
(ids: string[]) => {
|
||||
const orderedIds = sortIdsByVisibleOrder(ids, visibleTaskIds);
|
||||
const pinnedSet = new Set(pinnedTaskIds);
|
||||
const allPinned = orderedIds.length > 0 && orderedIds.every((id) => pinnedSet.has(id));
|
||||
// Pin in visible order so pinned rows keep the order the user saw.
|
||||
if (allPinned) unpinTasks(orderedIds);
|
||||
else pinTasks(orderedIds);
|
||||
clearSelection();
|
||||
},
|
||||
[pinTasks, unpinTasks, pinnedTaskIds, clearSelection, visibleTaskIds],
|
||||
);
|
||||
|
||||
return { onSelectTaskRange, onBulkMove, onBulkPin };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar multi-select wiring: selection state, the visible-order list shift
|
||||
* range-select walks, mixed-workflow detection (gates bulk "Move to step"), and
|
||||
* the bulk archive/delete confirm dialogs. Returns the props bundle for
|
||||
* `TaskSwitcher` plus the dialog state the panel renders via `SidebarBulkDialogs`.
|
||||
*/
|
||||
export function useSidebarSelection({
|
||||
workspaceId,
|
||||
grouped,
|
||||
collapsedGroups,
|
||||
collapsedSubtaskParents,
|
||||
displayTasks,
|
||||
}: {
|
||||
workspaceId: string | null;
|
||||
grouped: GroupedSidebarList;
|
||||
collapsedGroups: string[];
|
||||
collapsedSubtaskParents: string[];
|
||||
displayTasks: Array<{ id: string; workflowId?: string; remoteExecutorType?: string | null }>;
|
||||
}) {
|
||||
const multiSelect = useSidebarMultiSelect(workspaceId);
|
||||
const { selectedIds, clearSelection, toggleSelect } = multiSelect;
|
||||
const pinTasks = useAppStore((s) => s.pinTasks);
|
||||
const unpinTasks = useAppStore((s) => s.unpinTasks);
|
||||
const pinnedTaskIds = useAppStore((s) => s.sidebarTaskPrefs.pinnedTaskIds);
|
||||
const archiveDialog = useBulkConfirmDialog(displayTasks, multiSelect.bulkArchive);
|
||||
const deleteDialog = useBulkConfirmDialog(displayTasks, multiSelect.bulkDelete);
|
||||
|
||||
const visibleTaskIds = useMemo(
|
||||
() => flattenVisibleTaskIds(grouped, collapsedGroups, collapsedSubtaskParents),
|
||||
[grouped, collapsedGroups, collapsedSubtaskParents],
|
||||
);
|
||||
|
||||
// A workflow-less selected row (e.g. the archived placeholder) can't be moved,
|
||||
// so treat its presence as a mixed selection (disables "Move to step") and
|
||||
// filter such ids out of the actual move.
|
||||
const { isMixedWorkflowSelection, movableSelectedIds } = useMemo(
|
||||
() => computeMixedWorkflowSelection(displayTasks, selectedIds),
|
||||
[displayTasks, selectedIds],
|
||||
);
|
||||
|
||||
const { onSelectTaskRange, onBulkMove, onBulkPin } = useSelectionHandlers({
|
||||
multiSelect,
|
||||
pinTasks,
|
||||
unpinTasks,
|
||||
pinnedTaskIds,
|
||||
visibleTaskIds,
|
||||
movableSelectedIds,
|
||||
});
|
||||
|
||||
const switcherProps = {
|
||||
selectedTaskIds: selectedIds,
|
||||
onToggleSelectTask: toggleSelect,
|
||||
onSelectTaskRange,
|
||||
onBulkArchive: archiveDialog.open,
|
||||
onBulkDelete: deleteDialog.open,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection: clearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
};
|
||||
|
||||
return {
|
||||
switcherProps,
|
||||
archiveDialog,
|
||||
deleteDialog,
|
||||
isArchiving: multiSelect.isArchiving,
|
||||
isDeleting: multiSelect.isDeleting,
|
||||
};
|
||||
}
|
||||
|
||||
export function SidebarBulkDialogs({
|
||||
selection,
|
||||
}: {
|
||||
selection: ReturnType<typeof useSidebarSelection>;
|
||||
}) {
|
||||
const { archiveDialog, deleteDialog } = selection;
|
||||
return (
|
||||
<>
|
||||
{archiveDialog.state && (
|
||||
<TaskArchiveConfirmDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && archiveDialog.setState(null)}
|
||||
isBulkOperation
|
||||
count={archiveDialog.state.ids.length}
|
||||
taskIds={archiveDialog.state.ids}
|
||||
executorTypes={archiveDialog.state.executorTypes}
|
||||
isArchiving={selection.isArchiving}
|
||||
onConfirm={archiveDialog.confirm}
|
||||
confirmTestId="sidebar-bulk-archive-confirm"
|
||||
/>
|
||||
)}
|
||||
{deleteDialog.state && (
|
||||
<TaskDeleteConfirmDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && deleteDialog.setState(null)}
|
||||
isBulkOperation
|
||||
count={deleteDialog.state.ids.length}
|
||||
taskIds={deleteDialog.state.ids}
|
||||
executorTypes={deleteDialog.state.executorTypes}
|
||||
isDeleting={selection.isDeleting}
|
||||
onConfirm={deleteDialog.confirm}
|
||||
confirmTestId="sidebar-bulk-delete-confirm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { Repository, TaskSession, TaskSessionState, TaskState } from "@/lib
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
|
||||
import { TaskSwitcher } from "./task-switcher";
|
||||
import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher";
|
||||
import { SidebarFilterBar } from "./sidebar-filter/sidebar-filter-bar";
|
||||
import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data";
|
||||
import { SidebarDialogs } from "./task-session-sidebar-dialogs";
|
||||
@@ -15,6 +15,7 @@ import { PanelRoot, PanelBody } from "./panel-primitives";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useSidebarSelection, SidebarBulkDialogs } from "./task-session-sidebar-selection";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
import { repositorySlug } from "@/lib/repository-slug";
|
||||
@@ -27,6 +28,7 @@ import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
|
||||
import { buildPendingFlags, readPendingFlags } from "./task-session-sidebar-aggregate";
|
||||
import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view";
|
||||
import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
|
||||
import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { type AgentErrorOptions, agentErrorMessageForTask } from "@/lib/task-agent-error";
|
||||
import {
|
||||
@@ -160,40 +162,6 @@ type TaskSessionSidebarProps = {
|
||||
hideFilterBar?: boolean;
|
||||
};
|
||||
|
||||
type SidebarItem = Omit<ReturnType<typeof toSidebarItem>, "workflowId"> & { workflowId?: string };
|
||||
|
||||
function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarItem {
|
||||
return {
|
||||
id: s.archivedTaskId!,
|
||||
title: s.archivedTaskTitle ?? "Archived task",
|
||||
state: undefined,
|
||||
sessionState: undefined,
|
||||
description: undefined,
|
||||
workflowId: undefined,
|
||||
workflowName: undefined,
|
||||
workflowStepId: undefined,
|
||||
workflowStepTitle: undefined,
|
||||
repositoryPath: s.archivedTaskRepositoryPath,
|
||||
diffStats: undefined,
|
||||
isRemoteExecutor: false,
|
||||
remoteExecutorType: undefined,
|
||||
remoteExecutorName: undefined,
|
||||
primarySessionId: null,
|
||||
hasPendingClarification: false,
|
||||
hasPendingPermission: false,
|
||||
updatedAt: s.archivedTaskUpdatedAt,
|
||||
createdAt: undefined,
|
||||
isArchived: true,
|
||||
parentTaskTitle: undefined,
|
||||
parentTaskId: undefined,
|
||||
prInfo: undefined,
|
||||
isPRReview: false,
|
||||
isIssueWatch: false,
|
||||
issueInfo: undefined,
|
||||
agentErrorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
function useSidebarData(workspaceId: string | null) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
@@ -262,13 +230,13 @@ function useSidebarData(workspaceId: string | null) {
|
||||
acknowledgedAgentErrors,
|
||||
messagesBySession,
|
||||
};
|
||||
const items: SidebarItem[] = allTasks.map((task) => toSidebarItem(task, mapCtx));
|
||||
const items: TaskSwitcherItem[] = allTasks.map((task) => toSidebarItem(task, mapCtx));
|
||||
if (
|
||||
archivedState.isArchived &&
|
||||
archivedState.archivedTaskId &&
|
||||
!items.some((t) => t.id === archivedState.archivedTaskId)
|
||||
) {
|
||||
items.unshift(buildArchivedItem(archivedState));
|
||||
items.unshift(buildArchivedSidebarItem(archivedState));
|
||||
}
|
||||
return items;
|
||||
}, [
|
||||
@@ -629,6 +597,13 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
const toggleSubtaskCollapsed = useAppStore((state) => state.toggleSubtaskCollapsed);
|
||||
const { grouped, effectiveView, prefs } = useGroupedSidebarView(displayTasks);
|
||||
const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs;
|
||||
const selection = useSidebarSelection({
|
||||
workspaceId,
|
||||
grouped,
|
||||
collapsedGroups: effectiveView.collapsedGroups,
|
||||
collapsedSubtaskParents,
|
||||
displayTasks,
|
||||
});
|
||||
const handleToggleGroup = useCallback(
|
||||
(groupKey: string) => toggleSidebarGroupCollapsed(effectiveView.id, groupKey),
|
||||
[toggleSidebarGroupCollapsed, effectiveView.id],
|
||||
@@ -661,9 +636,11 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
deletingTaskId={deletingTaskId}
|
||||
isLoading={isLoadingWorkflow}
|
||||
totalTaskCount={displayTasks.length}
|
||||
{...selection.switcherProps}
|
||||
/>
|
||||
</PanelBody>
|
||||
<SidebarDialogs actions={sidebarActions} repositories={repositories} />
|
||||
<SidebarBulkDialogs selection={selection} />
|
||||
</PanelRoot>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { dispatchSidebarRowClick } from "./task-switcher";
|
||||
|
||||
function fakeEvent(mods: Partial<MouseEvent> = {}) {
|
||||
return {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
...mods,
|
||||
} as unknown as React.MouseEvent;
|
||||
}
|
||||
|
||||
function fakeKeyEvent(mods: Partial<KeyboardEvent> = {}) {
|
||||
return {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
...mods,
|
||||
} as unknown as React.KeyboardEvent;
|
||||
}
|
||||
|
||||
function handlers() {
|
||||
return {
|
||||
onSelectTask: vi.fn(),
|
||||
onToggleSelectTask: vi.fn(),
|
||||
onSelectTaskRange: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("dispatchSidebarRowClick", () => {
|
||||
it("cmd/meta-click toggles and prevents default", () => {
|
||||
const h = handlers();
|
||||
const e = fakeEvent({ metaKey: true });
|
||||
dispatchSidebarRowClick(e, "t1", false, h);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(h.onToggleSelectTask).toHaveBeenCalledWith("t1");
|
||||
expect(h.onSelectTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shift-click range-selects and prevents default", () => {
|
||||
const h = handlers();
|
||||
const e = fakeEvent({ shiftKey: true });
|
||||
dispatchSidebarRowClick(e, "t1", false, h);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(h.onSelectTaskRange).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
|
||||
it("plain click toggles while a selection is active", () => {
|
||||
const h = handlers();
|
||||
dispatchSidebarRowClick(fakeEvent(), "t1", true, h);
|
||||
expect(h.onToggleSelectTask).toHaveBeenCalledWith("t1");
|
||||
expect(h.onSelectTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plain click navigates when nothing is selected", () => {
|
||||
const h = handlers();
|
||||
dispatchSidebarRowClick(fakeEvent(), "t1", false, h);
|
||||
expect(h.onSelectTask).toHaveBeenCalledWith("t1");
|
||||
expect(h.onToggleSelectTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Keyboard activation (Enter/Space) routes through the same dispatcher.
|
||||
it("cmd/meta keypress toggles", () => {
|
||||
const h = handlers();
|
||||
const e = fakeKeyEvent({ metaKey: true });
|
||||
dispatchSidebarRowClick(e, "t1", false, h);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(h.onToggleSelectTask).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
|
||||
it("shift keypress range-selects", () => {
|
||||
const h = handlers();
|
||||
dispatchSidebarRowClick(fakeKeyEvent({ shiftKey: true }), "t1", false, h);
|
||||
expect(h.onSelectTaskRange).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
|
||||
it("plain keypress toggles while a selection is active", () => {
|
||||
const h = handlers();
|
||||
dispatchSidebarRowClick(fakeKeyEvent(), "t1", true, h);
|
||||
expect(h.onToggleSelectTask).toHaveBeenCalledWith("t1");
|
||||
expect(h.onSelectTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plain keypress navigates when nothing is selected", () => {
|
||||
const h = handlers();
|
||||
dispatchSidebarRowClick(fakeKeyEvent(), "t1", false, h);
|
||||
expect(h.onSelectTask).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color";
|
||||
import {
|
||||
TASK_COLORS,
|
||||
TASK_COLOR_BAR_CLASS,
|
||||
TASK_COLOR_LABEL,
|
||||
type TaskColor,
|
||||
} from "@/lib/task-colors";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) {
|
||||
const currentColor = useTaskColor(taskId);
|
||||
const setColor = useSetTaskColor();
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={disabled}>
|
||||
<IconPalette className="mr-2 h-4 w-4" />
|
||||
Color
|
||||
{currentColor && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 inline-block h-2 w-2 rounded-full",
|
||||
TASK_COLOR_BAR_CLASS[currentColor],
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-40">
|
||||
{TASK_COLORS.map((color) => (
|
||||
<TaskColorMenuItem
|
||||
key={color}
|
||||
color={color}
|
||||
selected={currentColor === color}
|
||||
onSelect={() => setColor(taskId, color)}
|
||||
/>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={!currentColor} onSelect={() => setColor(taskId, null)}>
|
||||
<span className="mr-2 inline-block h-2 w-2 rounded-full border border-muted-foreground/40" />
|
||||
None
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskColorMenuItem({
|
||||
color,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
color: TaskColor;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuItem onSelect={onSelect}>
|
||||
<span className={cn("mr-2 inline-block h-2 w-2 rounded-full", TASK_COLOR_BAR_CLASS[color])} />
|
||||
{TASK_COLOR_LABEL[color]}
|
||||
{selected && <IconCheck className="ml-auto h-3.5 w-3.5" />}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,11 @@
|
||||
import { cloneElement, isValidElement, useState } from "react";
|
||||
import {
|
||||
IconArchive,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconCircleDot,
|
||||
IconGitPullRequest,
|
||||
IconLink,
|
||||
IconLoader,
|
||||
IconPalette,
|
||||
IconPencil,
|
||||
IconPin,
|
||||
IconPinFilled,
|
||||
@@ -30,14 +28,7 @@ import {
|
||||
type TaskMoveWorkflow,
|
||||
} from "@/components/task/task-move-context-menu";
|
||||
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
|
||||
import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color";
|
||||
import {
|
||||
TASK_COLORS,
|
||||
TASK_COLOR_BAR_CLASS,
|
||||
TASK_COLOR_LABEL,
|
||||
type TaskColor,
|
||||
} from "@/lib/task-colors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskColorMenu } from "./task-switcher-color-menu";
|
||||
import type { TaskSwitcherItem } from "./task-switcher";
|
||||
|
||||
export type StepDef = {
|
||||
@@ -61,7 +52,17 @@ type ContextMenuProps = {
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
isPinned?: boolean;
|
||||
pinnedTaskIds?: string[];
|
||||
isDeleting?: boolean;
|
||||
/** Active multi-selection; when this task is part of it, actions apply to the whole set. */
|
||||
selectedTaskIds?: Set<string>;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
onBulkDelete?: (taskIds: string[]) => void;
|
||||
onBulkPin?: (taskIds: string[]) => void;
|
||||
onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void;
|
||||
onClearSelection?: () => void;
|
||||
/** True when the selection spans more than one workflow (disables bulk "Move to step"). */
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
};
|
||||
|
||||
export function TaskItemWithContextMenu({
|
||||
@@ -78,7 +79,15 @@ export function TaskItemWithContextMenu({
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
pinnedTaskIds,
|
||||
isDeleting,
|
||||
selectedTaskIds,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
}: ContextMenuProps) {
|
||||
const [contextOpen, setContextOpen] = useState(false);
|
||||
const [menuKey, setMenuKey] = useState(0);
|
||||
@@ -108,7 +117,15 @@ export function TaskItemWithContextMenu({
|
||||
onMoveToStep={onMoveToStep}
|
||||
onTogglePin={onTogglePin}
|
||||
isPinned={isPinned}
|
||||
pinnedTaskIds={pinnedTaskIds}
|
||||
isDeleting={isDeleting}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onBulkArchive={onBulkArchive}
|
||||
onBulkDelete={onBulkDelete}
|
||||
onBulkPin={onBulkPin}
|
||||
onBulkMove={onBulkMove}
|
||||
onClearSelection={onClearSelection}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
closeMenu={closeMenu}
|
||||
moveTasks={moveTasks}
|
||||
/>
|
||||
@@ -135,24 +152,78 @@ function TaskContextMenuItems({
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
pinnedTaskIds,
|
||||
isDeleting,
|
||||
selectedTaskIds,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
}: TaskContextMenuItemsProps) {
|
||||
// Right-clicking any row that's part of the active selection acts on the whole
|
||||
// selection (even a one-row selection, so the action clears it); right-clicking
|
||||
// a non-selected row acts on just that task and leaves the selection intact.
|
||||
const actingOnSelection = !!selectedTaskIds?.has(task.id);
|
||||
const actingIds = actingOnSelection ? [...selectedTaskIds!] : [task.id];
|
||||
|
||||
// With several tasks selected, only actions that make sense for all of them
|
||||
// are offered (Pin / Move / Archive / Delete) — the single-task actions
|
||||
// (Rename, Color, Link, Duplicate) are hidden.
|
||||
if (actingOnSelection && actingIds.length > 1) {
|
||||
return (
|
||||
<BulkSelectionMenuItems
|
||||
task={task}
|
||||
actingIds={actingIds}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
steps={steps}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
pinnedTaskIds={pinnedTaskIds}
|
||||
onBulkPin={onBulkPin}
|
||||
onBulkArchive={onBulkArchive}
|
||||
onBulkDelete={onBulkDelete}
|
||||
onBulkMove={onBulkMove}
|
||||
closeMenu={closeMenu}
|
||||
moveTasks={moveTasks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Acting on a lone selected row (Pin / Delete) must drop it from the selection
|
||||
// so later plain clicks navigate instead of toggling.
|
||||
const withClear = (handler?: (id: string) => void) =>
|
||||
actingOnSelection && onClearSelection && handler
|
||||
? (id: string) => {
|
||||
onClearSelection();
|
||||
handler(id);
|
||||
}
|
||||
: handler;
|
||||
const onDelete = withClear(onDeleteTask);
|
||||
return (
|
||||
<>
|
||||
<TaskPinItem
|
||||
taskId={task.id}
|
||||
isPinned={isPinned}
|
||||
disabled={isDeleting}
|
||||
onTogglePin={onTogglePin}
|
||||
onTogglePin={withClear(onTogglePin)}
|
||||
/>
|
||||
<TaskRenameItem task={task} disabled={isDeleting} onRenameTask={onRenameTask} />
|
||||
<ContextMenuItem disabled>
|
||||
<IconCopy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<TaskArchiveItem taskId={task.id} disabled={isDeleting} onArchiveTask={onArchiveTask} />
|
||||
<TaskArchiveItem
|
||||
taskId={task.id}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection={actingOnSelection}
|
||||
disabled={isDeleting}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onBulkArchive={onBulkArchive}
|
||||
/>
|
||||
<TaskColorMenu taskId={task.id} disabled={isDeleting} />
|
||||
<TaskLinkMenu
|
||||
disabled={isDeleting}
|
||||
@@ -166,10 +237,93 @@ function TaskContextMenuItems({
|
||||
steps={steps}
|
||||
isDeleting={isDeleting}
|
||||
onMoveToStep={onMoveToStep}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection={actingOnSelection}
|
||||
onBulkMove={onBulkMove}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
closeMenu={closeMenu}
|
||||
moveTasks={moveTasks}
|
||||
/>
|
||||
<TaskDeleteItem taskId={task.id} isDeleting={isDeleting} onDeleteTask={onDeleteTask} />
|
||||
<TaskDeleteItem taskId={task.id} isDeleting={isDeleting} onDeleteTask={onDelete} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reduced menu shown when 2+ tasks are selected — only bulk-valid actions. */
|
||||
function BulkSelectionMenuItems({
|
||||
task,
|
||||
actingIds,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
steps,
|
||||
isMixedWorkflowSelection,
|
||||
pinnedTaskIds,
|
||||
onBulkPin,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkMove,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
}: {
|
||||
task: TaskSwitcherItem;
|
||||
actingIds: string[];
|
||||
workflows?: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
steps?: StepDef[];
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
pinnedTaskIds?: string[];
|
||||
onBulkPin?: (taskIds: string[]) => void;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
onBulkDelete?: (taskIds: string[]) => void;
|
||||
onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void;
|
||||
closeMenu: () => void;
|
||||
moveTasks: ReturnType<typeof useTaskWorkflowMove>;
|
||||
}) {
|
||||
const n = actingIds.length;
|
||||
const allPinned =
|
||||
actingIds.length > 0 && actingIds.every((id) => pinnedTaskIds?.includes(id) ?? false);
|
||||
const pinLabel = `${allPinned ? "Unpin" : "Pin"} ${n} ${n === 1 ? "task" : "tasks"}`;
|
||||
return (
|
||||
<>
|
||||
{onBulkPin && (
|
||||
<ContextMenuItem onSelect={() => onBulkPin(actingIds)}>
|
||||
{allPinned ? (
|
||||
<IconPinFilled className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<IconPin className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{pinLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<TaskArchiveItem
|
||||
taskId={task.id}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection
|
||||
onArchiveTask={undefined}
|
||||
onBulkArchive={onBulkArchive}
|
||||
/>
|
||||
<TaskMoveItems
|
||||
task={task}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
steps={steps}
|
||||
onMoveToStep={undefined}
|
||||
actingIds={actingIds}
|
||||
actingOnSelection
|
||||
onBulkMove={onBulkMove}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
closeMenu={closeMenu}
|
||||
moveTasks={moveTasks}
|
||||
/>
|
||||
{onBulkDelete && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive" onSelect={() => onBulkDelete(actingIds)}>
|
||||
<IconTrash className="mr-2 h-4 w-4" />
|
||||
Delete {n} tasks
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -234,13 +388,30 @@ function TaskRenameItem({
|
||||
|
||||
function TaskArchiveItem({
|
||||
taskId,
|
||||
actingIds,
|
||||
actingOnSelection,
|
||||
disabled,
|
||||
onArchiveTask,
|
||||
onBulkArchive,
|
||||
}: {
|
||||
taskId: string;
|
||||
actingIds: string[];
|
||||
actingOnSelection: boolean;
|
||||
disabled?: boolean;
|
||||
onArchiveTask?: (taskId: string) => void;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
}) {
|
||||
// Acting on the selection routes through the bulk path (which clears the
|
||||
// selection afterwards) even for a single selected row.
|
||||
if (actingOnSelection && onBulkArchive) {
|
||||
const n = actingIds.length;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onBulkArchive(actingIds)}>
|
||||
<IconArchive className="mr-2 h-4 w-4" />
|
||||
{n > 1 ? `Archive ${n} tasks` : "Archive"}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
if (!onArchiveTask) return null;
|
||||
return (
|
||||
<ContextMenuItem disabled={disabled} onSelect={() => onArchiveTask(taskId)}>
|
||||
@@ -257,21 +428,62 @@ function TaskMoveItems({
|
||||
steps,
|
||||
isDeleting,
|
||||
onMoveToStep,
|
||||
actingIds,
|
||||
actingOnSelection,
|
||||
onBulkMove,
|
||||
isMixedWorkflowSelection,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask">) {
|
||||
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask"> & {
|
||||
actingIds: string[];
|
||||
actingOnSelection: boolean;
|
||||
}) {
|
||||
if (!task.workflowId) return null;
|
||||
const workflowId = task.workflowId;
|
||||
// Moving a selection routes through the sidebar hook's bulkMove, which clears
|
||||
// the selection afterwards. Fall back to a raw move when no bulk handler is
|
||||
// wired (e.g. the kanban-less callers that don't manage a selection).
|
||||
const runSelectionMove = (targetWorkflowId: string, stepId: string) => {
|
||||
closeMenu();
|
||||
if (onBulkMove) {
|
||||
onBulkMove(actingIds, targetWorkflowId, stepId);
|
||||
return;
|
||||
}
|
||||
void moveTasks(actingIds, targetWorkflowId, stepId).catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
};
|
||||
|
||||
// Single-task right-click keeps the optimistic same-workflow move. A selection
|
||||
// spanning workflows makes "Move to step" of one workflow ambiguous, so disable
|
||||
// it there (Send to workflow remains the explicit path).
|
||||
let moveToStep: ((stepId: string) => void) | undefined;
|
||||
if (actingOnSelection) {
|
||||
moveToStep = isMixedWorkflowSelection
|
||||
? undefined
|
||||
: (stepId) => runSelectionMove(workflowId, stepId);
|
||||
} else {
|
||||
moveToStep = selectMoveAction(task.id, workflowId, onMoveToStep, closeMenu);
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskMoveContextMenuItems
|
||||
currentWorkflowId={task.workflowId}
|
||||
currentStepId={task.workflowStepId}
|
||||
currentWorkflowId={workflowId}
|
||||
// For a selection spanning several steps, don't disable the clicked row's
|
||||
// step — the backend bulk move skips tasks already there, and the other
|
||||
// selected rows still need it as a target.
|
||||
currentStepId={actingOnSelection ? undefined : task.workflowStepId}
|
||||
workflows={workflows ?? []}
|
||||
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})}
|
||||
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [workflowId]: steps } : {})}
|
||||
disabled={isDeleting || task.isArchived}
|
||||
onMoveToStep={selectMoveAction(task.id, task.workflowId, onMoveToStep, closeMenu)}
|
||||
onSendToWorkflow={(workflowId, stepId) => {
|
||||
onMoveToStep={moveToStep}
|
||||
onSendToWorkflow={(targetWorkflowId, stepId) => {
|
||||
if (actingOnSelection) {
|
||||
runSelectionMove(targetWorkflowId, stepId);
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
void moveTasks([task.id], workflowId, stepId).catch(() => {
|
||||
void moveTasks([task.id], targetWorkflowId, stepId).catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
}}
|
||||
@@ -350,57 +562,3 @@ function TaskLinkMenu({
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskColorMenu({ taskId, disabled }: { taskId: string; disabled?: boolean }) {
|
||||
const currentColor = useTaskColor(taskId);
|
||||
const setColor = useSetTaskColor();
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={disabled}>
|
||||
<IconPalette className="mr-2 h-4 w-4" />
|
||||
Color
|
||||
{currentColor && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 inline-block h-2 w-2 rounded-full",
|
||||
TASK_COLOR_BAR_CLASS[currentColor],
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-40">
|
||||
{TASK_COLORS.map((color) => (
|
||||
<TaskColorMenuItem
|
||||
key={color}
|
||||
color={color}
|
||||
selected={currentColor === color}
|
||||
onSelect={() => setColor(taskId, color)}
|
||||
/>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={!currentColor} onSelect={() => setColor(taskId, null)}>
|
||||
<span className="mr-2 inline-block h-2 w-2 rounded-full border border-muted-foreground/40" />
|
||||
None
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskColorMenuItem({
|
||||
color,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
color: TaskColor;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuItem onSelect={onSelect}>
|
||||
<span className={cn("mr-2 inline-block h-2 w-2 rounded-full", TASK_COLOR_BAR_CLASS[color])} />
|
||||
{TASK_COLOR_LABEL[color]}
|
||||
{selected && <IconCheck className="ml-auto h-3.5 w-3.5" />}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TaskSwitcherSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-10 bg-foreground/5" />
|
||||
<div className="h-10 bg-foreground/5 mt-px" />
|
||||
<div className="h-10 bg-foreground/5 mt-px" />
|
||||
<div className="h-10 bg-foreground/5 mt-px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupHeader({
|
||||
label,
|
||||
groupKey,
|
||||
count,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
groupKey: string;
|
||||
count: number;
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
data-testid="sidebar-group-header"
|
||||
data-group-key={groupKey}
|
||||
data-group-label={label}
|
||||
className="flex w-full items-center gap-2 bg-background px-3 py-1.5 cursor-pointer hover:bg-foreground/[0.03]"
|
||||
>
|
||||
<span className="flex-1 truncate text-left text-[12px] font-medium text-foreground/80">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/50">{count}</span>
|
||||
<IconChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 text-muted-foreground/40 transition-transform",
|
||||
isCollapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher";
|
||||
@@ -23,6 +23,8 @@ function item(id: string, parentTaskId?: string): TaskSwitcherItem {
|
||||
const ROOT = item("Root");
|
||||
const CHILD = item("Child", "Root");
|
||||
const GRANDCHILD = item("Grandchild", "Child");
|
||||
const TASK_A = item("Task A");
|
||||
const TASK_B = item("Task B");
|
||||
|
||||
function grouped(): GroupedSidebarList {
|
||||
return {
|
||||
@@ -136,3 +138,28 @@ describe("TaskSwitcher — nested subtasks beyond depth 1", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskSwitcher — bulk pin menu", () => {
|
||||
it("offers bulk unpin when every selected root task is pinned", () => {
|
||||
render(
|
||||
<Providers>
|
||||
<TaskSwitcher
|
||||
grouped={{
|
||||
groups: [{ key: "__all__", label: "All", tasks: [TASK_A, TASK_B] }],
|
||||
subTasksByParentId: new Map(),
|
||||
}}
|
||||
activeTaskId={null}
|
||||
selectedTaskId={null}
|
||||
onSelectTask={vi.fn()}
|
||||
onBulkPin={vi.fn()}
|
||||
pinnedTaskIds={[TASK_A.id, TASK_B.id]}
|
||||
selectedTaskIds={new Set([TASK_A.id, TASK_B.id])}
|
||||
/>
|
||||
</Providers>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText(TASK_A.title));
|
||||
|
||||
expect(screen.getByText("Unpin 2 tasks")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import type { TaskState, TaskSessionState } from "@/lib/types/http";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskItem } from "./task-item";
|
||||
import { TaskItemWithContextMenu, type StepDef } from "./task-switcher-context-menu";
|
||||
import {
|
||||
@@ -13,6 +11,7 @@ import {
|
||||
} from "@/lib/sidebar/apply-view";
|
||||
import { type TaskMoveWorkflow } from "@/components/task/task-move-context-menu";
|
||||
import { SortableTaskLevel, SortableTaskNode } from "./task-switcher-subtask-dnd";
|
||||
import { GroupHeader, TaskSwitcherSkeleton } from "./task-switcher-group";
|
||||
|
||||
export type TaskSwitcherItem = {
|
||||
id: string;
|
||||
@@ -69,61 +68,61 @@ type TaskSwitcherProps = {
|
||||
deletingTaskId?: string | null;
|
||||
isLoading?: boolean;
|
||||
totalTaskCount?: number;
|
||||
// Multi-select (cmd/shift click). When the selection is non-empty, plain
|
||||
// clicks toggle instead of navigating; the context menu acts on the selection.
|
||||
selectedTaskIds?: Set<string>;
|
||||
onToggleSelectTask?: (taskId: string) => void;
|
||||
onSelectTaskRange?: (taskId: string) => void;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
onBulkDelete?: (taskIds: string[]) => void;
|
||||
onBulkPin?: (taskIds: string[]) => void;
|
||||
onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void;
|
||||
onClearSelection?: () => void;
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Modifier-aware sidebar row click: cmd/ctrl toggles one task, shift extends a
|
||||
* range, a plain click toggles while a selection is active and otherwise
|
||||
* navigates to the task.
|
||||
*/
|
||||
/** @internal Exported for unit testing the modifier-aware click dispatch. */
|
||||
export function dispatchSidebarRowClick(
|
||||
e: React.MouseEvent | React.KeyboardEvent,
|
||||
taskId: string,
|
||||
isSelecting: boolean,
|
||||
handlers: {
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onToggleSelectTask?: (taskId: string) => void;
|
||||
onSelectTaskRange?: (taskId: string) => void;
|
||||
},
|
||||
): void {
|
||||
// Only intercept a modifier click when the matching handler is wired (the
|
||||
// mobile switcher renders without selection handlers — there a Cmd/Shift click
|
||||
// must still navigate rather than become a no-op).
|
||||
if ((e.metaKey || e.ctrlKey) && handlers.onToggleSelectTask) {
|
||||
e.preventDefault();
|
||||
handlers.onToggleSelectTask(taskId);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && handlers.onSelectTaskRange) {
|
||||
e.preventDefault();
|
||||
handlers.onSelectTaskRange(taskId);
|
||||
return;
|
||||
}
|
||||
if (isSelecting && handlers.onToggleSelectTask) {
|
||||
handlers.onToggleSelectTask(taskId);
|
||||
return;
|
||||
}
|
||||
handlers.onSelectTask(taskId);
|
||||
}
|
||||
|
||||
type SubtaskToggleInfo = {
|
||||
subtaskCount: number;
|
||||
subtasksCollapsed: boolean;
|
||||
onToggleSubtasks: () => void;
|
||||
};
|
||||
|
||||
function TaskSwitcherSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-10 bg-foreground/5" />
|
||||
<div className="h-10 bg-foreground/5 mt-px" />
|
||||
<div className="h-10 bg-foreground/5 mt-px" />
|
||||
<div className="h-10 bg-foreground/5 mt-px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupHeader({
|
||||
label,
|
||||
groupKey,
|
||||
count,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
groupKey: string;
|
||||
count: number;
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
data-testid="sidebar-group-header"
|
||||
data-group-key={groupKey}
|
||||
data-group-label={label}
|
||||
className="flex w-full items-center gap-2 bg-background px-3 py-1.5 cursor-pointer hover:bg-foreground/[0.03]"
|
||||
>
|
||||
<span className="flex-1 truncate text-left text-[12px] font-medium text-foreground/80">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/50">{count}</span>
|
||||
<IconChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 text-muted-foreground/40 transition-transform",
|
||||
isCollapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type TaskRowProps = {
|
||||
task: TaskSwitcherItem;
|
||||
isSubTask?: boolean;
|
||||
@@ -142,7 +141,17 @@ type TaskRowProps = {
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
isPinned?: boolean;
|
||||
pinnedTaskIds?: string[];
|
||||
deletingTaskId?: string | null;
|
||||
selectedTaskIds?: Set<string>;
|
||||
onToggleSelectTask?: (taskId: string) => void;
|
||||
onSelectTaskRange?: (taskId: string) => void;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
onBulkDelete?: (taskIds: string[]) => void;
|
||||
onBulkPin?: (taskIds: string[]) => void;
|
||||
onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void;
|
||||
onClearSelection?: () => void;
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
};
|
||||
|
||||
function TaskRow({
|
||||
@@ -163,9 +172,21 @@ function TaskRow({
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
pinnedTaskIds,
|
||||
deletingTaskId,
|
||||
selectedTaskIds,
|
||||
onToggleSelectTask,
|
||||
onSelectTaskRange,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
}: TaskRowProps) {
|
||||
const isSelected = task.id === selectedTaskId || task.id === activeTaskId;
|
||||
const isMultiSelected = selectedTaskIds?.has(task.id) ?? false;
|
||||
const isSelecting = (selectedTaskIds?.size ?? 0) > 0;
|
||||
const taskSteps = task.workflowId ? stepsByWorkflowId?.[task.workflowId] : undefined;
|
||||
return (
|
||||
<TaskItemWithContextMenu
|
||||
@@ -181,9 +202,25 @@ function TaskRow({
|
||||
onMoveToStep={onMoveToStep}
|
||||
onTogglePin={onTogglePin}
|
||||
isPinned={isPinned}
|
||||
pinnedTaskIds={pinnedTaskIds}
|
||||
isDeleting={deletingTaskId === task.id}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onBulkArchive={onBulkArchive}
|
||||
onBulkDelete={onBulkDelete}
|
||||
onBulkPin={onBulkPin}
|
||||
onBulkMove={onBulkMove}
|
||||
onClearSelection={onClearSelection}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
>
|
||||
<TaskItem
|
||||
isMultiSelected={isMultiSelected}
|
||||
onSelect={(e) =>
|
||||
dispatchSidebarRowClick(e, task.id, isSelecting, {
|
||||
onSelectTask,
|
||||
onToggleSelectTask,
|
||||
onSelectTaskRange,
|
||||
})
|
||||
}
|
||||
title={task.title}
|
||||
state={task.state}
|
||||
sessionState={task.sessionState}
|
||||
@@ -341,8 +378,18 @@ type GroupSectionProps = {
|
||||
onTogglePin?: (taskId: string) => void;
|
||||
onReorderGroup?: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
pinnedTaskIds?: string[];
|
||||
pinnedSet: Set<string>;
|
||||
deletingTaskId?: string | null;
|
||||
selectedTaskIds?: Set<string>;
|
||||
onToggleSelectTask?: (taskId: string) => void;
|
||||
onSelectTaskRange?: (taskId: string) => void;
|
||||
onBulkArchive?: (taskIds: string[]) => void;
|
||||
onBulkDelete?: (taskIds: string[]) => void;
|
||||
onBulkPin?: (taskIds: string[]) => void;
|
||||
onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void;
|
||||
onClearSelection?: () => void;
|
||||
isMixedWorkflowSelection?: boolean;
|
||||
};
|
||||
|
||||
function GroupSection({
|
||||
@@ -367,8 +414,18 @@ function GroupSection({
|
||||
onTogglePin,
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
pinnedTaskIds,
|
||||
pinnedSet,
|
||||
deletingTaskId,
|
||||
selectedTaskIds,
|
||||
onToggleSelectTask,
|
||||
onSelectTaskRange,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
}: GroupSectionProps) {
|
||||
const totalCount = countGroupTasks(group.tasks, subTasksByParentId);
|
||||
const ctx: TaskTreeContext = {
|
||||
@@ -389,7 +446,17 @@ function GroupSection({
|
||||
onLinkIssue,
|
||||
onMoveToStep,
|
||||
onTogglePin,
|
||||
pinnedTaskIds,
|
||||
deletingTaskId,
|
||||
selectedTaskIds,
|
||||
onToggleSelectTask,
|
||||
onSelectTaskRange,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
},
|
||||
onReorderGroup,
|
||||
onReorderSubtasks,
|
||||
@@ -437,6 +504,15 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
deletingTaskId,
|
||||
isLoading = false,
|
||||
totalTaskCount,
|
||||
selectedTaskIds,
|
||||
onToggleSelectTask,
|
||||
onSelectTaskRange,
|
||||
onBulkArchive,
|
||||
onBulkDelete,
|
||||
onBulkPin,
|
||||
onBulkMove,
|
||||
onClearSelection,
|
||||
isMixedWorkflowSelection,
|
||||
}: TaskSwitcherProps) {
|
||||
const pinnedSet = useMemo(() => new Set(pinnedTaskIds ?? []), [pinnedTaskIds]);
|
||||
if (isLoading) return <TaskSwitcherSkeleton />;
|
||||
@@ -476,8 +552,18 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
onTogglePin={onTogglePin}
|
||||
onReorderGroup={onReorderGroup}
|
||||
onReorderSubtasks={onReorderSubtasks}
|
||||
pinnedTaskIds={pinnedTaskIds}
|
||||
pinnedSet={pinnedSet}
|
||||
deletingTaskId={deletingTaskId}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onToggleSelectTask={onToggleSelectTask}
|
||||
onSelectTaskRange={onSelectTaskRange}
|
||||
onBulkArchive={onBulkArchive}
|
||||
onBulkDelete={onBulkDelete}
|
||||
onBulkPin={onBulkPin}
|
||||
onBulkMove={onBulkMove}
|
||||
onClearSelection={onClearSelection}
|
||||
isMixedWorkflowSelection={isMixedWorkflowSelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -192,6 +192,37 @@ export class KanbanPage {
|
||||
await this.taskSelectCheckbox(taskId).click();
|
||||
}
|
||||
|
||||
/** Cmd/Ctrl-click a card body — toggles it into the selection without the toggle button. */
|
||||
async cmdClickCard(taskId: string) {
|
||||
const card = this.taskCard(taskId);
|
||||
await card.waitFor({ state: "visible" });
|
||||
await card.click({ modifiers: ["ControlOrMeta"] });
|
||||
}
|
||||
|
||||
/** Shift-click a card body — range-selects within the column. */
|
||||
async shiftClickCard(taskId: string) {
|
||||
const card = this.taskCard(taskId);
|
||||
await card.waitFor({ state: "visible" });
|
||||
await card.click({ modifiers: ["Shift"] });
|
||||
}
|
||||
|
||||
/** Plain click a card body (no modifier). */
|
||||
async plainClickCard(taskId: string) {
|
||||
const card = this.taskCard(taskId);
|
||||
await card.waitFor({ state: "visible" });
|
||||
await card.click();
|
||||
}
|
||||
|
||||
/** A card is part of the selection when it renders the primary ring. */
|
||||
async expectCardSelected(taskId: string, selected = true) {
|
||||
const card = this.taskCard(taskId);
|
||||
if (selected) {
|
||||
await expect(card).toHaveClass(/ring-primary/, { timeout: 5_000 });
|
||||
} else {
|
||||
await expect(card).not.toHaveClass(/ring-primary/, { timeout: 5_000 });
|
||||
}
|
||||
}
|
||||
|
||||
pipelineTask(taskId: string): Locator {
|
||||
return this.page.getByTestId(`pipeline-task-${taskId}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const plural = (count: number) => (count === 1 ? "task" : "tasks");
|
||||
|
||||
/**
|
||||
* Page object for the task list inside the unified AppSidebar (TaskSessionSidebar).
|
||||
* Covers cmd/shift-click multi-select and the selection-aware right-click menu.
|
||||
*/
|
||||
export class SidebarTasksPage {
|
||||
readonly root: Locator;
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
// Anchor to a single active sidebar — dock/mobile layouts can mount more
|
||||
// than one `task-sidebar` and a stale one must not satisfy these reads.
|
||||
this.root = page.getByTestId("task-sidebar").first();
|
||||
}
|
||||
|
||||
row(taskId: string): Locator {
|
||||
// The per-task id lives on the sortable-task-block wrapper; the selection
|
||||
// state (data-multiselected) lives on the sidebar-task-item row inside it.
|
||||
return this.root
|
||||
.locator(`[data-testid='sortable-task-block'][data-task-id='${taskId}']`)
|
||||
.getByTestId("sidebar-task-item")
|
||||
.first();
|
||||
}
|
||||
|
||||
rows(): Locator {
|
||||
return this.root.locator("[data-testid='sidebar-task-item']");
|
||||
}
|
||||
|
||||
async cmdClick(taskId: string) {
|
||||
await this.row(taskId).click({ modifiers: ["ControlOrMeta"] });
|
||||
}
|
||||
|
||||
async shiftClick(taskId: string) {
|
||||
await this.row(taskId).click({ modifiers: ["Shift"] });
|
||||
}
|
||||
|
||||
async plainClick(taskId: string) {
|
||||
await this.row(taskId).click();
|
||||
}
|
||||
|
||||
async rightClick(taskId: string) {
|
||||
await this.row(taskId).click({ button: "right" });
|
||||
}
|
||||
|
||||
async expectSelected(taskId: string, selected = true) {
|
||||
const row = this.row(taskId);
|
||||
if (selected) {
|
||||
await expect(row).toHaveAttribute("data-multiselected", "true", { timeout: 5_000 });
|
||||
} else {
|
||||
await expect(row).not.toHaveAttribute("data-multiselected", "true", { timeout: 5_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async selectedCount(): Promise<number> {
|
||||
return this.root
|
||||
.locator("[data-testid='sidebar-task-item'][data-multiselected='true']")
|
||||
.count();
|
||||
}
|
||||
|
||||
// --- right-click bulk menu ---
|
||||
bulkArchiveMenuItem(count: number): Locator {
|
||||
return this.page.getByRole("menuitem", { name: `Archive ${count} ${plural(count)}` });
|
||||
}
|
||||
|
||||
singleArchiveMenuItem(): Locator {
|
||||
return this.page.getByRole("menuitem", { name: "Archive", exact: true });
|
||||
}
|
||||
|
||||
bulkArchiveConfirm(): Locator {
|
||||
return this.page.getByTestId("sidebar-bulk-archive-confirm");
|
||||
}
|
||||
|
||||
bulkPinMenuItem(count: number): Locator {
|
||||
return this.page.getByRole("menuitem", { name: `Pin ${count} ${plural(count)}` });
|
||||
}
|
||||
|
||||
bulkUnpinMenuItem(count: number): Locator {
|
||||
return this.page.getByRole("menuitem", { name: `Unpin ${count} ${plural(count)}` });
|
||||
}
|
||||
|
||||
bulkDeleteMenuItem(count: number): Locator {
|
||||
return this.page.getByRole("menuitem", { name: `Delete ${count} ${plural(count)}` });
|
||||
}
|
||||
|
||||
bulkDeleteConfirm(): Locator {
|
||||
return this.page.getByTestId("sidebar-bulk-delete-confirm");
|
||||
}
|
||||
|
||||
menuItem(name: string): Locator {
|
||||
return this.page.getByRole("menuitem", { name, exact: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* E2E: cmd/ctrl-click and shift-click multi-select on the Kanban board.
|
||||
*
|
||||
* Covers the modifier-driven entry into multi-select mode (no toggle button
|
||||
* needed), per-card toggle, same-column shift range, the cross-column fallback,
|
||||
* and plain-click toggle behaviour while in multi-select mode.
|
||||
*/
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
// Scope DOM reads to the active board so a hidden/stale mounted layout can't
|
||||
// satisfy these selectors (dock/mobile layouts can coexist in the DOM).
|
||||
function activeColumn(page: Page, stepId: string) {
|
||||
return page.getByTestId("kanban-board").getByTestId(`kanban-column-${stepId}`);
|
||||
}
|
||||
|
||||
/** Rendered (display-order) task ids inside a column. */
|
||||
async function columnTaskIds(page: Page, stepId: string): Promise<string[]> {
|
||||
return activeColumn(page, stepId)
|
||||
.locator("[data-testid^='task-card-']:not([data-testid='task-card-title'])")
|
||||
.evaluateAll((els: Element[]) =>
|
||||
els.map((e) => (e.getAttribute("data-testid") ?? "").replace("task-card-", "")),
|
||||
);
|
||||
}
|
||||
|
||||
/** Task ids of the selected (primary-ring) cards in a column. */
|
||||
async function columnSelectedIds(page: Page, stepId: string): Promise<string[]> {
|
||||
return activeColumn(page, stepId)
|
||||
.locator("[data-testid^='task-card-']:not([data-testid='task-card-title'])")
|
||||
.evaluateAll((els: Element[]) =>
|
||||
els
|
||||
.filter((e) => e.className.includes("ring-primary"))
|
||||
.map((e) => (e.getAttribute("data-testid") ?? "").replace("task-card-", "")),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Kanban cmd/shift multi-select", () => {
|
||||
test("cmd-click enters multi-select and shows the toolbar without the toggle", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Cmd Enter Task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await expect(kanban.multiSelectToolbar).not.toBeVisible();
|
||||
|
||||
await kanban.cmdClickCard(task.id);
|
||||
|
||||
await expect(kanban.multiSelectToolbar).toBeVisible();
|
||||
await expect(kanban.multiSelectToolbar).toContainText("1 selected");
|
||||
await kanban.expectCardSelected(task.id);
|
||||
});
|
||||
|
||||
test("cmd-click toggles individual cards in and out of the selection", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const t1 = await apiClient.createTask(seedData.workspaceId, "Cmd Toggle 1", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const t2 = await apiClient.createTask(seedData.workspaceId, "Cmd Toggle 2", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await kanban.cmdClickCard(t1.id);
|
||||
await kanban.cmdClickCard(t2.id);
|
||||
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
|
||||
|
||||
// Cmd-click an already-selected card removes it from the selection.
|
||||
await kanban.cmdClickCard(t1.id);
|
||||
await expect(kanban.multiSelectToolbar).toContainText("1 selected");
|
||||
await kanban.expectCardSelected(t1.id, false);
|
||||
await kanban.expectCardSelected(t2.id, true);
|
||||
});
|
||||
|
||||
test("shift-click selects a contiguous range within the column", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
// Four tasks in the same column; create sequentially for a stable order.
|
||||
for (const title of ["Range A", "Range B", "Range C", "Range D"]) {
|
||||
await apiClient.createTask(seedData.workspaceId, title, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
}
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCardByTitle("Range A")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const order = await columnTaskIds(testPage, seedData.startStepId);
|
||||
expect(order.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Anchor on the first card, shift-click the third → first three selected.
|
||||
await kanban.cmdClickCard(order[0]);
|
||||
await kanban.shiftClickCard(order[2]);
|
||||
|
||||
await expect(kanban.multiSelectToolbar).toContainText("3 selected");
|
||||
const selected = await columnSelectedIds(testPage, seedData.startStepId);
|
||||
expect(selected.sort()).toEqual(order.slice(0, 3).sort());
|
||||
await kanban.expectCardSelected(order[3], false);
|
||||
});
|
||||
|
||||
test("shift-click does not extend the range across columns", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const otherStep = seedData.steps.find((s) => s.id !== seedData.startStepId);
|
||||
test.skip(!otherStep, "Workflow needs at least two steps");
|
||||
|
||||
const a1 = await apiClient.createTask(seedData.workspaceId, "Col A One", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await apiClient.createTask(seedData.workspaceId, "Col A Two", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const b1 = await apiClient.createTask(seedData.workspaceId, "Col B One", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: otherStep!.id,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCard(b1.id)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Anchor in column A, shift-click into column B. The anchor isn't in B's
|
||||
// column order, so the range falls back to union-with-previous and re-anchors
|
||||
// to the clicked card — A stays selected, B is added, A's column isn't spanned.
|
||||
await kanban.cmdClickCard(a1.id);
|
||||
await kanban.shiftClickCard(b1.id);
|
||||
|
||||
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
|
||||
await kanban.expectCardSelected(a1.id, true);
|
||||
await kanban.expectCardSelected(b1.id, true);
|
||||
});
|
||||
|
||||
test("plain click toggles selection while in multi-select mode", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const t1 = await apiClient.createTask(seedData.workspaceId, "Plain Toggle 1", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const t2 = await apiClient.createTask(seedData.workspaceId, "Plain Toggle 2", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
// Enter mode via cmd-click, then plain-click another card to add it.
|
||||
await kanban.cmdClickCard(t1.id);
|
||||
await kanban.plainClickCard(t2.id);
|
||||
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
|
||||
// Still on the board — a plain in-mode click must not navigate away.
|
||||
await expect(kanban.board).toBeVisible();
|
||||
|
||||
// Plain-click the same card again toggles it back out.
|
||||
await kanban.plainClickCard(t2.id);
|
||||
await expect(kanban.multiSelectToolbar).toContainText("1 selected");
|
||||
});
|
||||
|
||||
test("the multi-select toggle button still works alongside cmd-click", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Toggle Coexist", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Enter multi-select via the toggle button, then a Cmd-click must still
|
||||
// select a card (the two entry paths coexist).
|
||||
await kanban.multiSelectToggle.first().click();
|
||||
await expect(testPage.locator('[data-multi-select-active="true"]').first()).toBeVisible();
|
||||
|
||||
await kanban.cmdClickCard(task.id);
|
||||
await kanban.expectCardSelected(task.id, true);
|
||||
await expect(kanban.multiSelectToolbar).toContainText("1 selected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* E2E: cmd/ctrl-click and shift-click multi-select in the task sidebar, plus the
|
||||
* selection-aware right-click context menu (bulk archive / single fallback).
|
||||
*/
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { SidebarTasksPage } from "../../pages/sidebar-tasks-page";
|
||||
|
||||
type Created = { id: string; title: string };
|
||||
|
||||
async function seedTasks(
|
||||
apiClient: { createTask: (ws: string, title: string, opts: object) => Promise<{ id: string }> },
|
||||
seedData: { workspaceId: string; workflowId: string; startStepId: string },
|
||||
titles: string[],
|
||||
): Promise<Created[]> {
|
||||
const out: Created[] = [];
|
||||
for (const title of titles) {
|
||||
const t = await apiClient.createTask(seedData.workspaceId, title, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
out.push({ id: t.id, title });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rendered top-to-bottom order of task ids in the sidebar. */
|
||||
async function sidebarOrder(page: Page): Promise<string[]> {
|
||||
return page
|
||||
.getByTestId("task-sidebar")
|
||||
.first()
|
||||
.locator("[data-testid='sortable-task-block']")
|
||||
.evaluateAll((els: Element[]) => els.map((e) => e.getAttribute("data-task-id") ?? ""));
|
||||
}
|
||||
|
||||
async function openSidebarOn(page: Page, taskId: string): Promise<SidebarTasksPage> {
|
||||
await page.goto(`/t/${taskId}`);
|
||||
const session = new SessionPage(page);
|
||||
await session.waitForLoad();
|
||||
await expect(session.sidebar).toBeVisible({ timeout: 10_000 });
|
||||
return new SidebarTasksPage(page);
|
||||
}
|
||||
|
||||
test.describe("Sidebar cmd/shift multi-select", () => {
|
||||
test("cmd-click selects a row without navigating", async ({ testPage, apiClient, seedData }) => {
|
||||
const [nav, target] = await seedTasks(apiClient, seedData, ["SB Nav", "SB Target"]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(target.id);
|
||||
|
||||
await sidebar.expectSelected(target.id, true);
|
||||
// URL must not change — cmd-click selects, it doesn't open the task.
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${nav.id}`));
|
||||
});
|
||||
|
||||
test("cmd-click toggles multiple rows in and out", async ({ testPage, apiClient, seedData }) => {
|
||||
const [nav, a, b] = await seedTasks(apiClient, seedData, ["SB MultiNav", "SB A", "SB B"]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.cmdClick(b.id);
|
||||
expect(await sidebar.selectedCount()).toBe(2);
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.expectSelected(a.id, false);
|
||||
await sidebar.expectSelected(b.id, true);
|
||||
expect(await sidebar.selectedCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("shift-click selects a contiguous range of visible rows", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const [nav] = await seedTasks(apiClient, seedData, [
|
||||
"SB Range Nav",
|
||||
"SB Range 1",
|
||||
"SB Range 2",
|
||||
"SB Range 3",
|
||||
]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
await expect(sidebar.rows().first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const order = await sidebarOrder(testPage);
|
||||
expect(order.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
await sidebar.cmdClick(order[0]);
|
||||
await sidebar.shiftClick(order[2]);
|
||||
|
||||
await sidebar.expectSelected(order[0], true);
|
||||
await sidebar.expectSelected(order[1], true);
|
||||
await sidebar.expectSelected(order[2], true);
|
||||
await sidebar.expectSelected(order[3], false);
|
||||
});
|
||||
|
||||
test("plain click navigates when nothing is selected", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const [nav, target] = await seedTasks(apiClient, seedData, ["SB PlainNav", "SB PlainTarget"]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.plainClick(target.id);
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${target.id}`));
|
||||
});
|
||||
|
||||
test("plain click toggles while a selection is active", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const [nav, a, b] = await seedTasks(apiClient, seedData, ["SB PT Nav", "SB PT A", "SB PT B"]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(a.id); // enter selection mode
|
||||
await sidebar.plainClick(b.id); // plain click now toggles, not navigates
|
||||
await sidebar.expectSelected(b.id, true);
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${nav.id}`));
|
||||
expect(await sidebar.selectedCount()).toBe(2);
|
||||
});
|
||||
|
||||
test("Escape clears the selection", async ({ testPage, apiClient, seedData }) => {
|
||||
const [nav, a] = await seedTasks(apiClient, seedData, ["SB Esc Nav", "SB Esc A"]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.expectSelected(a.id, true);
|
||||
|
||||
await testPage.keyboard.press("Escape");
|
||||
await sidebar.expectSelected(a.id, false);
|
||||
expect(await sidebar.selectedCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar selection-aware context menu", () => {
|
||||
test("right-clicking a selected row archives the whole selection", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const [nav, a, b] = await seedTasks(apiClient, seedData, [
|
||||
"SB Arc Nav",
|
||||
"SB Arc A",
|
||||
"SB Arc B",
|
||||
]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.cmdClick(b.id);
|
||||
expect(await sidebar.selectedCount()).toBe(2);
|
||||
|
||||
await sidebar.rightClick(a.id);
|
||||
const bulkItem = sidebar.bulkArchiveMenuItem(2);
|
||||
await expect(bulkItem).toBeVisible({ timeout: 5_000 });
|
||||
await bulkItem.click();
|
||||
|
||||
const confirm = sidebar.bulkArchiveConfirm();
|
||||
await expect(confirm).toBeVisible({ timeout: 5_000 });
|
||||
await confirm.click();
|
||||
|
||||
await expect(sidebar.row(a.id)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(sidebar.row(b.id)).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("multi-selection menu offers only bulk-valid actions", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const [nav, a, b] = await seedTasks(apiClient, seedData, [
|
||||
"SB Menu Nav",
|
||||
"SB Menu A",
|
||||
"SB Menu B",
|
||||
]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.cmdClick(b.id);
|
||||
await sidebar.rightClick(a.id);
|
||||
|
||||
// Bulk-valid actions are present…
|
||||
await expect(sidebar.bulkPinMenuItem(2)).toBeVisible({ timeout: 5_000 });
|
||||
await expect(sidebar.bulkArchiveMenuItem(2)).toBeVisible();
|
||||
await expect(sidebar.bulkDeleteMenuItem(2)).toBeVisible();
|
||||
// …and the single-task-only actions are hidden.
|
||||
await expect(sidebar.menuItem("Rename")).not.toBeVisible();
|
||||
await expect(sidebar.menuItem("Duplicate")).not.toBeVisible();
|
||||
await testPage.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("bulk Delete prompts for the selection and removes the rows", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const [nav, a, b] = await seedTasks(apiClient, seedData, [
|
||||
"SB Del Nav",
|
||||
"SB Del A",
|
||||
"SB Del B",
|
||||
]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.cmdClick(b.id);
|
||||
await sidebar.rightClick(a.id);
|
||||
|
||||
const del = sidebar.bulkDeleteMenuItem(2);
|
||||
await expect(del).toBeVisible({ timeout: 5_000 });
|
||||
await del.click();
|
||||
|
||||
const confirm = sidebar.bulkDeleteConfirm();
|
||||
await expect(confirm).toBeVisible({ timeout: 5_000 });
|
||||
await confirm.click();
|
||||
|
||||
await expect(sidebar.row(a.id)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(sidebar.row(b.id)).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("bulk Pin pins all selected rows", async ({ testPage, apiClient, seedData }) => {
|
||||
const [nav, a, b] = await seedTasks(apiClient, seedData, [
|
||||
"SB Pin Nav",
|
||||
"SB Pin A",
|
||||
"SB Pin B",
|
||||
]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.cmdClick(b.id);
|
||||
await sidebar.rightClick(a.id);
|
||||
|
||||
const pin = sidebar.bulkPinMenuItem(2);
|
||||
await expect(pin).toBeVisible({ timeout: 5_000 });
|
||||
await pin.click();
|
||||
|
||||
// Both rows now render the pinned icon.
|
||||
await expect(sidebar.row(a.id).getByTestId("task-pinned-icon")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(sidebar.row(b.id).getByTestId("task-pinned-icon")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.cmdClick(b.id);
|
||||
await sidebar.rightClick(a.id);
|
||||
|
||||
const unpin = sidebar.bulkUnpinMenuItem(2);
|
||||
await expect(unpin).toBeVisible({ timeout: 5_000 });
|
||||
await unpin.click();
|
||||
|
||||
await expect(sidebar.row(a.id).getByTestId("task-pinned-icon")).toBeHidden({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(sidebar.row(b.id).getByTestId("task-pinned-icon")).toBeHidden({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("right-clicking an unselected row acts on that row only", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const [nav, a, b, c] = await seedTasks(apiClient, seedData, [
|
||||
"SB Solo Nav",
|
||||
"SB Solo A",
|
||||
"SB Solo B",
|
||||
"SB Solo C",
|
||||
]);
|
||||
const sidebar = await openSidebarOn(testPage, nav.id);
|
||||
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.cmdClick(b.id);
|
||||
expect(await sidebar.selectedCount()).toBe(2);
|
||||
|
||||
// Right-click a row that is NOT in the selection.
|
||||
await sidebar.rightClick(c.id);
|
||||
|
||||
// The menu must offer the single-task action, not the bulk one.
|
||||
await expect(sidebar.singleArchiveMenuItem()).toBeVisible({ timeout: 5_000 });
|
||||
await expect(sidebar.bulkArchiveMenuItem(2)).not.toBeVisible();
|
||||
|
||||
// Opening the menu on an unselected row leaves the existing selection intact
|
||||
// (asserted while the menu is open — Escape both closes the menu and clears
|
||||
// the selection, which is the documented dismiss behaviour).
|
||||
await sidebar.expectSelected(a.id, true);
|
||||
await sidebar.expectSelected(b.id, true);
|
||||
|
||||
await testPage.keyboard.press("Escape");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const archiveTaskById = vi.fn();
|
||||
const deleteTaskById = vi.fn();
|
||||
const archiveAndSwitch = vi.fn();
|
||||
const removeTaskFromBoard = vi.fn();
|
||||
const removeTasksFromStore = vi.fn();
|
||||
const moveTasks = vi.fn();
|
||||
const toast = vi.fn();
|
||||
let activeTaskId: string | null = null;
|
||||
|
||||
vi.mock("./use-task-actions", () => ({
|
||||
useTaskActions: () => ({ archiveTaskById, deleteTaskById }),
|
||||
useArchiveAndSwitchTask: () => archiveAndSwitch,
|
||||
}));
|
||||
vi.mock("./use-task-removal", () => ({ useTaskRemoval: () => ({ removeTaskFromBoard }) }));
|
||||
vi.mock("./use-task-workflow-move", () => ({ useTaskWorkflowMove: () => moveTasks }));
|
||||
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast }) }));
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStoreApi: () => ({
|
||||
getState: () => ({ tasks: { activeTaskId, activeSessionId: null } }),
|
||||
}),
|
||||
}));
|
||||
vi.mock("./use-task-multi-select", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./use-task-multi-select")>();
|
||||
return { ...actual, useTaskMultiSelectStore: () => ({ removeTasksFromStore }) };
|
||||
});
|
||||
|
||||
import { useSidebarMultiSelect } from "./use-sidebar-multi-select";
|
||||
|
||||
beforeEach(() => {
|
||||
activeTaskId = null;
|
||||
archiveTaskById.mockReset().mockResolvedValue(undefined);
|
||||
deleteTaskById.mockReset().mockResolvedValue(undefined);
|
||||
archiveAndSwitch.mockReset().mockResolvedValue(undefined);
|
||||
removeTaskFromBoard.mockReset().mockResolvedValue(undefined);
|
||||
removeTasksFromStore.mockReset();
|
||||
moveTasks.mockReset().mockResolvedValue(undefined);
|
||||
toast.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("useSidebarMultiSelect", () => {
|
||||
it("toggles, ranges, and clears the selection", () => {
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
expect(result.current.isSelecting).toBe(false);
|
||||
|
||||
act(() => result.current.toggleSelect("a"));
|
||||
act(() => result.current.toggleSelect("b"));
|
||||
expect(result.current.selectedIds).toEqual(new Set(["a", "b"]));
|
||||
expect(result.current.isSelecting).toBe(true);
|
||||
|
||||
act(() => result.current.clearSelection());
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("pruneToVisible drops selected ids that are no longer visible", () => {
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
act(() => result.current.toggleSelect("a"));
|
||||
act(() => result.current.toggleSelect("b"));
|
||||
|
||||
act(() => result.current.pruneToVisible(["a"]));
|
||||
expect(result.current.selectedIds).toEqual(new Set(["a"]));
|
||||
|
||||
// No-op when everything is still visible.
|
||||
act(() => result.current.pruneToVisible(["a", "b"]));
|
||||
expect(result.current.selectedIds).toEqual(new Set(["a"]));
|
||||
});
|
||||
|
||||
it("pruneToVisible realigns the anchor so a later range starts from a visible id", () => {
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
act(() => result.current.toggleSelect("a"));
|
||||
act(() => result.current.toggleSelect("b")); // anchor is now "b"
|
||||
act(() => result.current.pruneToVisible(["a"])); // drops "b", anchor must realign to "a"
|
||||
|
||||
// Range from the realigned anchor "a" to "c" includes the in-between "x".
|
||||
// A stale "b" anchor (absent from orderedIds) would fall back to just {a, c}.
|
||||
act(() => result.current.selectRange("c", ["a", "x", "c"]));
|
||||
expect(result.current.selectedIds).toEqual(new Set(["a", "x", "c"]));
|
||||
});
|
||||
|
||||
it("resets the selection when the workspace changes", () => {
|
||||
const { result, rerender } = renderHook(({ ws }) => useSidebarMultiSelect(ws), {
|
||||
initialProps: { ws: "ws1" },
|
||||
});
|
||||
act(() => result.current.toggleSelect("a"));
|
||||
expect(result.current.selectedIds.size).toBe(1);
|
||||
|
||||
rerender({ ws: "ws2" });
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSidebarMultiSelect — bulk actions", () => {
|
||||
it("bulkArchive removes all on full success and clears the selection", async () => {
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
await act(async () => {
|
||||
await result.current.bulkArchive(["a", "b"]);
|
||||
});
|
||||
expect(archiveTaskById).toHaveBeenCalledTimes(2);
|
||||
expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a", "b"]));
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bulkArchive keeps the failed ids selected and toasts on partial failure", async () => {
|
||||
archiveTaskById.mockImplementation((id) =>
|
||||
id === "b" ? Promise.reject(new Error("nope")) : Promise.resolve(),
|
||||
);
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
await act(async () => {
|
||||
await result.current.bulkArchive(["a", "b"]);
|
||||
});
|
||||
expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a"]));
|
||||
expect(result.current.selectedIds).toEqual(new Set(["b"]));
|
||||
expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
|
||||
});
|
||||
|
||||
it("bulkArchive routes the active task through the switch-aware path", async () => {
|
||||
activeTaskId = "a";
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
await act(async () => {
|
||||
await result.current.bulkArchive(["a", "b"]);
|
||||
});
|
||||
expect(archiveAndSwitch).toHaveBeenCalledWith("a", undefined);
|
||||
expect(archiveTaskById).toHaveBeenCalledTimes(1);
|
||||
expect(archiveTaskById).toHaveBeenCalledWith("b", undefined);
|
||||
expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["b"]));
|
||||
});
|
||||
|
||||
it("bulkArchive ignores an empty id list", async () => {
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
await act(async () => {
|
||||
await result.current.bulkArchive([]);
|
||||
});
|
||||
expect(archiveTaskById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bulkDelete removes all on full success and clears the selection", async () => {
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
await act(async () => {
|
||||
await result.current.bulkDelete(["a", "b"]);
|
||||
});
|
||||
expect(deleteTaskById).toHaveBeenCalledTimes(2);
|
||||
expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a", "b"]));
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bulkDelete keeps failed ids selected and toasts on partial failure", async () => {
|
||||
deleteTaskById.mockImplementation((id: string) =>
|
||||
id === "b" ? Promise.reject(new Error("nope")) : Promise.resolve(),
|
||||
);
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
await act(async () => {
|
||||
await result.current.bulkDelete(["a", "b"]);
|
||||
});
|
||||
expect(result.current.selectedIds).toEqual(new Set(["b"]));
|
||||
expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
|
||||
});
|
||||
|
||||
it("bulkDelete routes the active task through the switch-aware removal", async () => {
|
||||
activeTaskId = "a";
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
await act(async () => {
|
||||
await result.current.bulkDelete(["a", "b"]);
|
||||
});
|
||||
// 'b' deleted directly; 'a' (active) deleted then removed-from-board to switch.
|
||||
expect(deleteTaskById).toHaveBeenCalledWith("b", undefined);
|
||||
expect(deleteTaskById).toHaveBeenCalledWith("a", undefined);
|
||||
expect(removeTaskFromBoard).toHaveBeenCalledWith(
|
||||
"a",
|
||||
expect.objectContaining({ wasActiveTaskId: "a" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("bulkMove clears the selection on success", async () => {
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
act(() => result.current.toggleSelect("a"));
|
||||
await act(async () => {
|
||||
await result.current.bulkMove(["a"], "wf1", "s1");
|
||||
});
|
||||
expect(moveTasks).toHaveBeenCalledWith(["a"], "wf1", "s1");
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("bulkMove keeps the selection and swallows the rejection on failure", async () => {
|
||||
moveTasks.mockRejectedValue(new Error("locked"));
|
||||
const { result } = renderHook(() => useSidebarMultiSelect("ws1"));
|
||||
act(() => result.current.toggleSelect("a"));
|
||||
await act(async () => {
|
||||
await result.current.bulkMove(["a"], "wf1", "s1");
|
||||
});
|
||||
expect(result.current.selectedIds).toEqual(new Set(["a"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useReducer, useState, type Dispatch } from "react";
|
||||
import {
|
||||
INITIAL_STATE,
|
||||
multiSelectReducer,
|
||||
useTaskMultiSelectStore,
|
||||
} from "./use-task-multi-select";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "./use-task-actions";
|
||||
import { useTaskRemoval } from "./use-task-removal";
|
||||
import { useTaskWorkflowMove } from "./use-task-workflow-move";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
|
||||
type BulkOpts = { cascade?: boolean };
|
||||
|
||||
/**
|
||||
* Bulk archive/delete/move that act on an explicit id list. Archive and delete
|
||||
* share one flow: remove each task, routing the currently-open task through the
|
||||
* switch-aware path last so the view doesn't strand on removed content; keep any
|
||||
* failed ids selected for retry and surface the failure via toast.
|
||||
*/
|
||||
function useSidebarBulkActions(
|
||||
dispatch: Dispatch<{ type: "set_selected"; ids: Set<string> }>,
|
||||
clearSelection: () => void,
|
||||
) {
|
||||
const store = useAppStoreApi();
|
||||
const { archiveTaskById, deleteTaskById } = useTaskActions();
|
||||
const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true });
|
||||
const { removeTaskFromBoard } = useTaskRemoval({ store, useLayoutSwitch: true });
|
||||
const { removeTasksFromStore } = useTaskMultiSelectStore();
|
||||
const moveTasks = useTaskWorkflowMove();
|
||||
const { toast } = useToast();
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const runBulkRemoval = useCallback(
|
||||
async (
|
||||
ids: string[],
|
||||
per: (id: string) => Promise<void>,
|
||||
handleActive: (id: string) => Promise<void>,
|
||||
setBusy: (v: boolean) => void,
|
||||
noun: string,
|
||||
) => {
|
||||
if (ids.length === 0) return;
|
||||
const activeId = store.getState().tasks.activeTaskId;
|
||||
const activeInSet = activeId != null && ids.includes(activeId);
|
||||
const restIds = activeInSet ? ids.filter((id) => id !== activeId) : ids;
|
||||
setBusy(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(restIds.map((id) => per(id)));
|
||||
const failed = restIds.filter((_, i) => results[i].status === "rejected");
|
||||
const succeeded = restIds.filter((_, i) => results[i].status === "fulfilled");
|
||||
if (succeeded.length > 0) removeTasksFromStore(new Set(succeeded));
|
||||
if (activeInSet) {
|
||||
try {
|
||||
await handleActive(activeId!);
|
||||
} catch {
|
||||
failed.push(activeId!);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
// Keep the failed ids selected so the user can retry, and surface it.
|
||||
dispatch({ type: "set_selected", ids: new Set(failed) });
|
||||
toast({
|
||||
title: `Failed to ${noun} ${failed.length} task${failed.length === 1 ? "" : "s"}`,
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
clearSelection();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[store, removeTasksFromStore, dispatch, toast, clearSelection],
|
||||
);
|
||||
|
||||
const bulkArchive = useCallback(
|
||||
(ids: string[], opts?: BulkOpts) =>
|
||||
runBulkRemoval(
|
||||
ids,
|
||||
(id) => archiveTaskById(id, opts),
|
||||
(id) => archiveAndSwitch(id, opts),
|
||||
setIsArchiving,
|
||||
"archive",
|
||||
),
|
||||
[runBulkRemoval, archiveTaskById, archiveAndSwitch],
|
||||
);
|
||||
|
||||
const bulkDelete = useCallback(
|
||||
(ids: string[], opts?: BulkOpts) =>
|
||||
runBulkRemoval(
|
||||
ids,
|
||||
(id) => deleteTaskById(id, opts),
|
||||
async (id) => {
|
||||
const { activeTaskId, activeSessionId } = store.getState().tasks;
|
||||
await deleteTaskById(id, opts);
|
||||
await removeTaskFromBoard(id, {
|
||||
wasActiveTaskId: activeTaskId,
|
||||
wasActiveSessionId: activeSessionId,
|
||||
});
|
||||
},
|
||||
setIsDeleting,
|
||||
"delete",
|
||||
),
|
||||
[runBulkRemoval, deleteTaskById, removeTaskFromBoard, store],
|
||||
);
|
||||
|
||||
const bulkMove = useCallback(
|
||||
async (ids: string[], targetWorkflowId: string, targetStepId: string) => {
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await moveTasks(ids, targetWorkflowId, targetStepId);
|
||||
clearSelection();
|
||||
} catch {
|
||||
// useTaskWorkflowMove already toasts the failure; keep the rows selected
|
||||
// for retry and swallow the rejection so it isn't unhandled at the
|
||||
// fire-and-forget call site.
|
||||
}
|
||||
},
|
||||
[moveTasks, clearSelection],
|
||||
);
|
||||
|
||||
return { bulkArchive, bulkDelete, bulkMove, isArchiving, isDeleting };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar task multi-select. Reuses the shared selection reducer (toggle +
|
||||
* shift-range with an anchor) from `use-task-multi-select`, and exposes the
|
||||
* selection state plus bulk archive/delete/move used by the right-click menu.
|
||||
*
|
||||
* Unlike the kanban hook this spans all workflows in a workspace, so it resets
|
||||
* on workspace change and the caller passes the target workflow for moves.
|
||||
*/
|
||||
export function useSidebarMultiSelect(workspaceId: string | null) {
|
||||
const [state, dispatch] = useReducer(multiSelectReducer, INITIAL_STATE);
|
||||
const { selectedIds } = state;
|
||||
|
||||
// Selection is ephemeral per workspace; drop it when the workspace changes.
|
||||
useEffect(() => {
|
||||
dispatch({ type: "reset" });
|
||||
}, [workspaceId]);
|
||||
|
||||
const toggleSelect = useCallback(
|
||||
(taskId: string) => dispatch({ type: "toggle_select", taskId }),
|
||||
[],
|
||||
);
|
||||
|
||||
const selectRange = useCallback(
|
||||
(taskId: string, orderedIds: string[]) =>
|
||||
dispatch({ type: "select_range", taskId, orderedIds }),
|
||||
[],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback(() => dispatch({ type: "set_selected", ids: new Set() }), []);
|
||||
|
||||
// Drop any selected ids that are no longer visible (collapsed group / filtered
|
||||
// out) so `isSelecting` doesn't stay latched on hidden rows. No-ops (and avoids
|
||||
// a render loop) when every selected id is still visible.
|
||||
const pruneToVisible = useCallback(
|
||||
(visibleIds: string[]) => {
|
||||
const visible = new Set(visibleIds);
|
||||
const next = new Set([...selectedIds].filter((id) => visible.has(id)));
|
||||
if (next.size !== selectedIds.size) dispatch({ type: "set_selected", ids: next });
|
||||
},
|
||||
[selectedIds],
|
||||
);
|
||||
|
||||
const bulk = useSidebarBulkActions(dispatch, clearSelection);
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
isSelecting: selectedIds.size > 0,
|
||||
isArchiving: bulk.isArchiving,
|
||||
isDeleting: bulk.isDeleting,
|
||||
toggleSelect,
|
||||
selectRange,
|
||||
clearSelection,
|
||||
pruneToVisible,
|
||||
bulkArchive: bulk.bulkArchive,
|
||||
bulkDelete: bulk.bulkDelete,
|
||||
bulkMove: bulk.bulkMove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { multiSelectReducer, INITIAL_STATE } from "./use-task-multi-select";
|
||||
|
||||
describe("multiSelectReducer — anchor + range", () => {
|
||||
const ordered = ["a", "b", "c", "d", "e"];
|
||||
|
||||
it("toggle_select sets the range anchor to the toggled task", () => {
|
||||
const next = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "t1" });
|
||||
expect(next.anchorId).toBe("t1");
|
||||
});
|
||||
|
||||
it("select_range with no anchor selects only the clicked task and anchors it", () => {
|
||||
const next = multiSelectReducer(INITIAL_STATE, {
|
||||
type: "select_range",
|
||||
taskId: "c",
|
||||
orderedIds: ordered,
|
||||
});
|
||||
expect(next.selectedIds).toEqual(new Set(["c"]));
|
||||
expect(next.anchorId).toBe("c");
|
||||
});
|
||||
|
||||
it("selects the inclusive forward range from the anchor", () => {
|
||||
const anchored = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "b" });
|
||||
const next = multiSelectReducer(anchored, {
|
||||
type: "select_range",
|
||||
taskId: "d",
|
||||
orderedIds: ordered,
|
||||
});
|
||||
expect(next.selectedIds).toEqual(new Set(["b", "c", "d"]));
|
||||
expect(next.anchorId).toBe("b");
|
||||
});
|
||||
|
||||
it("selects the inclusive backward range from the anchor", () => {
|
||||
const anchored = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "d" });
|
||||
const next = multiSelectReducer(anchored, {
|
||||
type: "select_range",
|
||||
taskId: "b",
|
||||
orderedIds: ordered,
|
||||
});
|
||||
expect(next.selectedIds).toEqual(new Set(["b", "c", "d"]));
|
||||
});
|
||||
|
||||
it("unions the range with the existing selection", () => {
|
||||
const withPrev = { ...INITIAL_STATE, selectedIds: new Set(["x"]), anchorId: "a" };
|
||||
const next = multiSelectReducer(withPrev, {
|
||||
type: "select_range",
|
||||
taskId: "c",
|
||||
orderedIds: ordered,
|
||||
});
|
||||
expect(next.selectedIds).toEqual(new Set(["x", "a", "b", "c"]));
|
||||
});
|
||||
|
||||
it("unions with the existing selection and re-anchors when the anchor is in another column", () => {
|
||||
const anchored = { ...INITIAL_STATE, selectedIds: new Set(["z"]), anchorId: "z" };
|
||||
const next = multiSelectReducer(anchored, {
|
||||
type: "select_range",
|
||||
taskId: "c",
|
||||
orderedIds: ordered,
|
||||
});
|
||||
expect(next.selectedIds).toEqual(new Set(["z", "c"]));
|
||||
expect(next.anchorId).toBe("c");
|
||||
});
|
||||
|
||||
it("toggling off the only selected task clears the anchor", () => {
|
||||
const one = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "a" });
|
||||
expect(one.anchorId).toBe("a");
|
||||
const empty = multiSelectReducer(one, { type: "toggle_select", taskId: "a" });
|
||||
expect(empty.selectedIds.size).toBe(0);
|
||||
expect(empty.anchorId).toBeNull();
|
||||
});
|
||||
|
||||
it("toggling off the anchor realigns it to a remaining selected task", () => {
|
||||
let s = multiSelectReducer(INITIAL_STATE, { type: "toggle_select", taskId: "a" });
|
||||
s = multiSelectReducer(s, { type: "toggle_select", taskId: "b" });
|
||||
// anchor is now 'b'; remove it and the anchor must move to the surviving 'a'.
|
||||
const next = multiSelectReducer(s, { type: "toggle_select", taskId: "b" });
|
||||
expect(next.selectedIds).toEqual(new Set(["a"]));
|
||||
expect(next.anchorId).toBe("a");
|
||||
});
|
||||
|
||||
it("clearing the selection (set_selected empty) drops the anchor", () => {
|
||||
const dirty = { ...INITIAL_STATE, selectedIds: new Set(["a"]), anchorId: "a" };
|
||||
const next = multiSelectReducer(dirty, { type: "set_selected", ids: new Set<string>() });
|
||||
expect(next.anchorId).toBeNull();
|
||||
});
|
||||
|
||||
it("set_selected keeps the anchor when it survives the replacement", () => {
|
||||
const state = { ...INITIAL_STATE, selectedIds: new Set(["a", "b", "c"]), anchorId: "b" };
|
||||
const next = multiSelectReducer(state, { type: "set_selected", ids: new Set(["b", "c"]) });
|
||||
expect(next.anchorId).toBe("b");
|
||||
});
|
||||
|
||||
it("set_selected realigns the anchor when it is dropped (partial bulk failure)", () => {
|
||||
const state = { ...INITIAL_STATE, selectedIds: new Set(["a", "b", "c"]), anchorId: "a" };
|
||||
// 'a' archived away; only the failed ids remain — anchor must point at one.
|
||||
const next = multiSelectReducer(state, { type: "set_selected", ids: new Set(["b", "c"]) });
|
||||
expect(next.anchorId).not.toBeNull();
|
||||
expect(next.selectedIds.has(next.anchorId!)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ describe("multiSelectReducer", () => {
|
||||
isMultiSelectEnabled: true,
|
||||
isDeleting: true,
|
||||
isArchiving: false,
|
||||
anchorId: "a",
|
||||
};
|
||||
expect(multiSelectReducer(dirty, { type: "reset" })).toBe(INITIAL_STATE);
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, type RefOb
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import { sortIdsByCreatedDesc } from "@/lib/kanban/task-order";
|
||||
|
||||
function useTaskMultiSelectStore() {
|
||||
/** @internal Exported for reuse by the sidebar multi-select hook. */
|
||||
export function useTaskMultiSelectStore() {
|
||||
const store = useAppStoreApi();
|
||||
|
||||
const removeTasksFromStore = useCallback(
|
||||
@@ -77,7 +79,23 @@ function useTaskMultiSelectStore() {
|
||||
[store],
|
||||
);
|
||||
|
||||
return { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask };
|
||||
// Sort ids into the board's visible (created-desc) order. A backward range
|
||||
// selection leaves `selectedIds` in anchor-first Set order, which would land
|
||||
// scrambled when the move assigns sequential positions.
|
||||
const sortByDisplayOrder = useCallback(
|
||||
(ids: string[]): string[] => {
|
||||
const state = store.getState();
|
||||
const taskById = new Map<string, { createdAt?: string }>();
|
||||
for (const snap of Object.values(state.kanbanMulti.snapshots)) {
|
||||
for (const t of snap.tasks) taskById.set(t.id, t);
|
||||
}
|
||||
for (const t of state.kanban.tasks) if (!taskById.has(t.id)) taskById.set(t.id, t);
|
||||
return sortIdsByCreatedDesc(ids, taskById);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
return { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, sortByDisplayOrder };
|
||||
}
|
||||
|
||||
function useBulkOperations({
|
||||
@@ -93,6 +111,7 @@ function useBulkOperations({
|
||||
removeTasksFromStore,
|
||||
applyMoveInStore,
|
||||
getWorkflowIdForTask,
|
||||
sortByDisplayOrder,
|
||||
}: {
|
||||
workflowId: string | null;
|
||||
selectedIdsRef: RefObject<Set<string>>;
|
||||
@@ -106,6 +125,7 @@ function useBulkOperations({
|
||||
removeTasksFromStore: (ids: Set<string>) => void;
|
||||
applyMoveInStore: (ids: Set<string>, stepId: string) => void;
|
||||
getWorkflowIdForTask: (id: string) => string | null;
|
||||
sortByDisplayOrder: (ids: string[]) => string[];
|
||||
}) {
|
||||
const runBulk = useCallback(
|
||||
async (
|
||||
@@ -143,7 +163,9 @@ function useBulkOperations({
|
||||
|
||||
const bulkMove = useCallback(
|
||||
async (targetStepId: string) => {
|
||||
const idList = [...(selectedIdsRef.current ?? [])];
|
||||
// Move in board order so a backward range selection isn't reordered when
|
||||
// sequential positions are assigned below.
|
||||
const idList = sortByDisplayOrder([...(selectedIdsRef.current ?? [])]);
|
||||
if (idList.length === 0) return;
|
||||
const results = await Promise.allSettled(
|
||||
idList.map((id, i) => {
|
||||
@@ -159,7 +181,14 @@ function useBulkOperations({
|
||||
const succeeded = new Set(idList.filter((_, i) => results[i].status === "fulfilled"));
|
||||
applyMoveInStore(succeeded, targetStepId);
|
||||
},
|
||||
[workflowId, moveTaskById, applyMoveInStore, getWorkflowIdForTask, selectedIdsRef],
|
||||
[
|
||||
workflowId,
|
||||
moveTaskById,
|
||||
applyMoveInStore,
|
||||
getWorkflowIdForTask,
|
||||
sortByDisplayOrder,
|
||||
selectedIdsRef,
|
||||
],
|
||||
);
|
||||
|
||||
return { bulkDelete, bulkArchive, bulkMove };
|
||||
@@ -170,11 +199,17 @@ type MultiSelectState = {
|
||||
isMultiSelectEnabled: boolean;
|
||||
isDeleting: boolean;
|
||||
isArchiving: boolean;
|
||||
/**
|
||||
* The task that anchors a shift-click range selection — the last task the
|
||||
* user toggled/range-selected. `null` when there is no active anchor.
|
||||
*/
|
||||
anchorId: string | null;
|
||||
};
|
||||
|
||||
type MultiSelectAction =
|
||||
| { type: "reset" }
|
||||
| { type: "toggle_select"; taskId: string }
|
||||
| { type: "select_range"; taskId: string; orderedIds: string[] }
|
||||
| { type: "set_selected"; ids: Set<string> }
|
||||
| { type: "set_enabled"; value: boolean }
|
||||
| { type: "set_deleting"; value: boolean }
|
||||
@@ -186,8 +221,46 @@ export const INITIAL_STATE: MultiSelectState = {
|
||||
isMultiSelectEnabled: false,
|
||||
isDeleting: false,
|
||||
isArchiving: false,
|
||||
anchorId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick a valid range anchor after the selection set is replaced wholesale: keep
|
||||
* the existing anchor if it survived, otherwise fall back to any remaining id
|
||||
* (or null when the selection is now empty).
|
||||
*/
|
||||
function realignAnchor(state: MultiSelectState, ids: Set<string>): string | null {
|
||||
if (ids.size === 0) return null;
|
||||
if (state.anchorId && ids.has(state.anchorId)) return state.anchorId;
|
||||
return ids.values().next().value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union-select every id from the anchor to `taskId` (inclusive) within
|
||||
* `orderedIds`. When there is no valid anchor in `orderedIds` (first shift
|
||||
* click, or anchor lives in a different column), fall back to union-selecting
|
||||
* just `taskId` — the previous selection is preserved — and make it the new
|
||||
* anchor.
|
||||
*/
|
||||
function applyRangeSelect(
|
||||
state: MultiSelectState,
|
||||
taskId: string,
|
||||
orderedIds: string[],
|
||||
): MultiSelectState {
|
||||
const anchor = state.anchorId;
|
||||
const anchorIdx = anchor ? orderedIds.indexOf(anchor) : -1;
|
||||
const targetIdx = orderedIds.indexOf(taskId);
|
||||
if (anchorIdx === -1 || targetIdx === -1) {
|
||||
const next = new Set(state.selectedIds);
|
||||
next.add(taskId);
|
||||
return { ...state, selectedIds: next, anchorId: taskId };
|
||||
}
|
||||
const [lo, hi] = anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx];
|
||||
const next = new Set(state.selectedIds);
|
||||
for (let i = lo; i <= hi; i++) next.add(orderedIds[i]);
|
||||
return { ...state, selectedIds: next };
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function multiSelectReducer(
|
||||
state: MultiSelectState,
|
||||
@@ -198,12 +271,26 @@ export function multiSelectReducer(
|
||||
return INITIAL_STATE;
|
||||
case "toggle_select": {
|
||||
const next = new Set(state.selectedIds);
|
||||
if (next.has(action.taskId)) next.delete(action.taskId);
|
||||
else next.add(action.taskId);
|
||||
return { ...state, selectedIds: next };
|
||||
const added = !next.has(action.taskId);
|
||||
if (added) next.add(action.taskId);
|
||||
else next.delete(action.taskId);
|
||||
// Adding anchors to the toggled task; removing realigns to a surviving id
|
||||
// (or clears the anchor when the selection is now empty) so a later
|
||||
// Shift+click can't range from a stale anchor.
|
||||
return {
|
||||
...state,
|
||||
selectedIds: next,
|
||||
anchorId: added ? action.taskId : realignAnchor(state, next),
|
||||
};
|
||||
}
|
||||
case "select_range":
|
||||
return applyRangeSelect(state, action.taskId, action.orderedIds);
|
||||
case "set_selected":
|
||||
return { ...state, selectedIds: action.ids };
|
||||
// Keep the range anchor pointing at a still-selected task. After a partial
|
||||
// bulk failure (selection replaced with the failed ids) the old anchor may
|
||||
// be gone, so realign to a remaining id rather than stranding the next
|
||||
// Shift+click on an invalid anchor.
|
||||
return { ...state, selectedIds: action.ids, anchorId: realignAnchor(state, action.ids) };
|
||||
case "set_enabled":
|
||||
return { ...state, isMultiSelectEnabled: action.value };
|
||||
case "set_deleting":
|
||||
@@ -244,7 +331,7 @@ export function useTaskMultiSelect(workflowId: string | null) {
|
||||
}, [workflowId]);
|
||||
|
||||
const { moveTaskById, deleteTaskById, archiveTaskById } = useTaskActions();
|
||||
const { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask } =
|
||||
const { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, sortByDisplayOrder } =
|
||||
useTaskMultiSelectStore();
|
||||
|
||||
const toggleSelect = useCallback(
|
||||
@@ -252,6 +339,12 @@ export function useTaskMultiSelect(workflowId: string | null) {
|
||||
[],
|
||||
);
|
||||
|
||||
const selectRange = useCallback(
|
||||
(taskId: string, orderedIds: string[]) =>
|
||||
dispatch({ type: "select_range", taskId, orderedIds }),
|
||||
[],
|
||||
);
|
||||
|
||||
const enableMultiSelect = useCallback(
|
||||
() => setIsMultiSelectEnabled(true),
|
||||
[setIsMultiSelectEnabled],
|
||||
@@ -284,6 +377,7 @@ export function useTaskMultiSelect(workflowId: string | null) {
|
||||
removeTasksFromStore,
|
||||
applyMoveInStore,
|
||||
getWorkflowIdForTask,
|
||||
sortByDisplayOrder,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -293,6 +387,7 @@ export function useTaskMultiSelect(workflowId: string | null) {
|
||||
enableMultiSelect,
|
||||
toggleMultiSelect,
|
||||
toggleSelect,
|
||||
selectRange,
|
||||
clearSelection,
|
||||
bulkDelete,
|
||||
bulkArchive,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareTasksByCreatedDesc } from "./task-order";
|
||||
import { compareTasksByCreatedDesc, sortIdsByCreatedDesc } from "./task-order";
|
||||
|
||||
describe("compareTasksByCreatedDesc", () => {
|
||||
it("sorts newer created tasks first", () => {
|
||||
@@ -53,3 +53,27 @@ describe("compareTasksByCreatedDesc", () => {
|
||||
expect(compareTasksByCreatedDesc({}, {})).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortIdsByCreatedDesc", () => {
|
||||
// d newest … a oldest → board order is d, c, b, a.
|
||||
const taskById = new Map<string, { createdAt?: string }>([
|
||||
["a", { createdAt: "2026-01-01T00:00:00Z" }],
|
||||
["b", { createdAt: "2026-01-02T00:00:00Z" }],
|
||||
["c", { createdAt: "2026-01-03T00:00:00Z" }],
|
||||
["d", { createdAt: "2026-01-04T00:00:00Z" }],
|
||||
]);
|
||||
|
||||
it("reorders a backward range selection into board (created-desc) order", () => {
|
||||
// Anchor on the oldest then shift up leaves the Set as [a, c, b] (insertion).
|
||||
expect(sortIdsByCreatedDesc(["a", "c", "b"], taskById)).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
it("sorts ids without a known task last (transitive fallback)", () => {
|
||||
expect(sortIdsByCreatedDesc(["zzz", "a"], taskById)).toEqual(["a", "zzz"]);
|
||||
});
|
||||
|
||||
it("stays transitive when only some ids are missing", () => {
|
||||
// c (newest present) < a (older present) < missing → deterministic order.
|
||||
expect(sortIdsByCreatedDesc(["a", "missing", "c"], taskById)).toEqual(["c", "a", "missing"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,3 +15,18 @@ export function compareTasksByCreatedDesc(a: CreatedTask, b: CreatedTask): numbe
|
||||
if (bTime < aTime) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort `ids` into the board's visible created-desc order using `taskById` for
|
||||
* lookups. Ids without a known task keep their relative order. Used before a
|
||||
* kanban bulk move so a backward range selection doesn't land scrambled when
|
||||
* sequential positions are assigned.
|
||||
*/
|
||||
export function sortIdsByCreatedDesc(ids: string[], taskById: Map<string, CreatedTask>): string[] {
|
||||
// Missing ids fall back to `{}`, which `compareTasksByCreatedDesc` treats as
|
||||
// the oldest (sorts last) — keeping the comparator transitive rather than
|
||||
// returning 0 whenever either side is unknown.
|
||||
return [...ids].sort((a, b) =>
|
||||
compareTasksByCreatedDesc(taskById.get(a) ?? {}, taskById.get(b) ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ViewContentProps = {
|
||||
showMaximizeButton?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (taskId: string) => void;
|
||||
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
|
||||
import {
|
||||
computeMixedWorkflowSelection,
|
||||
flattenVisibleTaskIds,
|
||||
sortIdsByVisibleOrder,
|
||||
type GroupedSidebarList,
|
||||
} from "./apply-view";
|
||||
|
||||
function task(id: string): TaskSwitcherItem {
|
||||
return { id, title: id };
|
||||
}
|
||||
|
||||
function grouped(
|
||||
groups: GroupedSidebarList["groups"],
|
||||
subs: Record<string, TaskSwitcherItem[]> = {},
|
||||
): GroupedSidebarList {
|
||||
return { groups, subTasksByParentId: new Map(Object.entries(subs)) };
|
||||
}
|
||||
|
||||
describe("flattenVisibleTaskIds", () => {
|
||||
it("walks groups then tasks in render order", () => {
|
||||
const g = grouped([
|
||||
{ key: "g1", label: "G1", tasks: [task("a"), task("b")] },
|
||||
{ key: "g2", label: "G2", tasks: [task("c")] },
|
||||
]);
|
||||
expect(flattenVisibleTaskIds(g, [], [])).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("expands subtasks depth-first under their parent", () => {
|
||||
const g = grouped([{ key: "g1", label: "G1", tasks: [task("a"), task("d")] }], {
|
||||
a: [task("a1"), task("a2")],
|
||||
});
|
||||
expect(flattenVisibleTaskIds(g, [], [])).toEqual(["a", "a1", "a2", "d"]);
|
||||
});
|
||||
|
||||
it("skips tasks in collapsed groups", () => {
|
||||
const g = grouped([
|
||||
{ key: "g1", label: "G1", tasks: [task("a")] },
|
||||
{ key: "g2", label: "G2", tasks: [task("b")] },
|
||||
]);
|
||||
expect(flattenVisibleTaskIds(g, ["g2"], [])).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("hides subtasks of a collapsed parent but keeps the parent", () => {
|
||||
const g = grouped([{ key: "g1", label: "G1", tasks: [task("a")] }], { a: [task("a1")] });
|
||||
expect(flattenVisibleTaskIds(g, [], ["a"])).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortIdsByVisibleOrder", () => {
|
||||
const visible = ["a", "b", "c", "d"];
|
||||
|
||||
it("reorders a backward range selection into visible order", () => {
|
||||
// Anchor 'd' then shift to 'b' leaves the Set as [d, b, c] (insertion order).
|
||||
expect(sortIdsByVisibleOrder(["d", "b", "c"], visible)).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("leaves an already-ordered list unchanged", () => {
|
||||
expect(sortIdsByVisibleOrder(["a", "c"], visible)).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("sorts ids missing from the visible list to the back", () => {
|
||||
expect(sortIdsByVisibleOrder(["c", "zzz", "a"], visible)).toEqual(["a", "c", "zzz"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeMixedWorkflowSelection", () => {
|
||||
const tasks = [
|
||||
{ id: "a", workflowId: "wf1" },
|
||||
{ id: "b", workflowId: "wf1" },
|
||||
{ id: "c", workflowId: "wf2" },
|
||||
{ id: "arch" }, // archived placeholder — no workflow
|
||||
];
|
||||
|
||||
it("treats a single-workflow selection as not mixed and all movable", () => {
|
||||
const r = computeMixedWorkflowSelection(tasks, new Set(["a", "b"]));
|
||||
expect(r.isMixedWorkflowSelection).toBe(false);
|
||||
expect(r.movableSelectedIds).toEqual(new Set(["a", "b"]));
|
||||
});
|
||||
|
||||
it("flags a selection spanning two workflows as mixed", () => {
|
||||
const r = computeMixedWorkflowSelection(tasks, new Set(["a", "c"]));
|
||||
expect(r.isMixedWorkflowSelection).toBe(true);
|
||||
expect(r.movableSelectedIds).toEqual(new Set(["a", "c"]));
|
||||
});
|
||||
|
||||
it("flags a workflow-less selected row as mixed and excludes it from movable", () => {
|
||||
const r = computeMixedWorkflowSelection(tasks, new Set(["a", "arch"]));
|
||||
expect(r.isMixedWorkflowSelection).toBe(true);
|
||||
expect(r.movableSelectedIds).toEqual(new Set(["a"]));
|
||||
});
|
||||
});
|
||||
@@ -458,6 +458,74 @@ export function countGroupTasks(
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the grouped sidebar tree into the exact top-to-bottom order of the
|
||||
* task rows the user currently sees, honoring collapsed groups and collapsed
|
||||
* subtask parents. This is the ordered list shift-click range selection walks.
|
||||
*/
|
||||
export function flattenVisibleTaskIds(
|
||||
grouped: GroupedSidebarList,
|
||||
collapsedGroupKeys: string[],
|
||||
collapsedSubtaskParentIds: string[],
|
||||
): string[] {
|
||||
const collapsedGroups = new Set(collapsedGroupKeys);
|
||||
const collapsedSubs = new Set(collapsedSubtaskParentIds);
|
||||
const out: string[] = [];
|
||||
const visit = (task: TaskSwitcherItem) => {
|
||||
out.push(task.id);
|
||||
if (collapsedSubs.has(task.id)) return;
|
||||
const subs = grouped.subTasksByParentId.get(task.id);
|
||||
if (subs) for (const sub of subs) visit(sub);
|
||||
};
|
||||
for (const group of grouped.groups) {
|
||||
if (collapsedGroups.has(group.key)) continue;
|
||||
for (const task of group.tasks) visit(task);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order `ids` by their position in the rendered `visibleTaskIds` list. Used
|
||||
* before bulk moves so a backward range selection (anchor after target, which
|
||||
* leaves the selection Set in insertion rather than visible order) still lands
|
||||
* top-to-bottom at the destination. Ids absent from the visible list sort last
|
||||
* so they never displace the visible-ordered ones.
|
||||
*/
|
||||
export function sortIdsByVisibleOrder(ids: string[], visibleTaskIds: string[]): string[] {
|
||||
const order = new Map(visibleTaskIds.map((id, i) => [id, i]));
|
||||
return [...ids].sort(
|
||||
(a, b) =>
|
||||
(order.get(a) ?? Number.POSITIVE_INFINITY) - (order.get(b) ?? Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a selection can be bulk-moved to a single step. A selected row
|
||||
* with no workflow (e.g. the archived placeholder) can't move, so flag the
|
||||
* selection as "mixed" (disables "Move to step") and report the movable subset.
|
||||
*/
|
||||
export function computeMixedWorkflowSelection(
|
||||
displayTasks: Array<{ id: string; workflowId?: string }>,
|
||||
selectedIds: Set<string>,
|
||||
): { isMixedWorkflowSelection: boolean; movableSelectedIds: Set<string> } {
|
||||
const wfIds = new Set<string>();
|
||||
const movable = new Set<string>();
|
||||
let hasWorkflowless = false;
|
||||
for (const t of displayTasks) {
|
||||
if (!selectedIds.has(t.id)) continue;
|
||||
if (t.workflowId) {
|
||||
wfIds.add(t.workflowId);
|
||||
movable.add(t.id);
|
||||
} else {
|
||||
hasWorkflowless = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
isMixedWorkflowSelection: hasWorkflowless || wfIds.size > 1,
|
||||
movableSelectedIds: movable,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyView(
|
||||
tasks: TaskSwitcherItem[],
|
||||
view: SidebarView,
|
||||
|
||||
@@ -63,6 +63,39 @@ export function buildSidebarTaskPrefsActions(set: ImmerSet, get: () => UISlice)
|
||||
});
|
||||
syncSidebarTaskPrefs(get().sidebarTaskPrefs, set);
|
||||
},
|
||||
// Pin every id that isn't already pinned (bulk "Pin", not a per-id toggle).
|
||||
pinTasks: (taskIds: string[]) => {
|
||||
let changed = false;
|
||||
set((draft) => {
|
||||
const list = draft.sidebarTaskPrefs.pinnedTaskIds;
|
||||
for (const id of taskIds) {
|
||||
if (!list.includes(id)) {
|
||||
list.push(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
draft.sidebarTaskPrefs.syncPending = true;
|
||||
setStoredPinnedTaskIds(list);
|
||||
}
|
||||
});
|
||||
if (changed) syncSidebarTaskPrefs(get().sidebarTaskPrefs, set);
|
||||
},
|
||||
// Unpin every matching id in one state update (bulk "Unpin").
|
||||
unpinTasks: (taskIds: string[]) => {
|
||||
const toRemove = new Set(taskIds);
|
||||
let changed = false;
|
||||
set((draft) => {
|
||||
const list = draft.sidebarTaskPrefs.pinnedTaskIds;
|
||||
const next = list.filter((id) => !toRemove.has(id));
|
||||
if (next.length === list.length) return;
|
||||
changed = true;
|
||||
draft.sidebarTaskPrefs.syncPending = true;
|
||||
draft.sidebarTaskPrefs.pinnedTaskIds = next;
|
||||
setStoredPinnedTaskIds(next);
|
||||
});
|
||||
if (changed) syncSidebarTaskPrefs(get().sidebarTaskPrefs, set);
|
||||
},
|
||||
setSidebarTaskOrder: (orderedTaskIds: string[]) => {
|
||||
set((draft) => {
|
||||
draft.sidebarTaskPrefs.syncPending = true;
|
||||
|
||||
@@ -234,6 +234,10 @@ export type UISliceActions = {
|
||||
migrateLocalViewsToBackend: () => void;
|
||||
setKanbanPreviewedTaskId: (taskId: string | null) => void;
|
||||
togglePinnedTask: (taskId: string) => void;
|
||||
/** Pin every id that isn't already pinned (bulk "Pin" for multi-select). */
|
||||
pinTasks: (taskIds: string[]) => void;
|
||||
/** Unpin every id that is currently pinned (bulk "Unpin" for multi-select). */
|
||||
unpinTasks: (taskIds: string[]) => void;
|
||||
setSidebarTaskOrder: (orderedTaskIds: string[]) => void;
|
||||
/** Replace the stored subtask order for a parent task. Empty array clears it. */
|
||||
setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
|
||||
@@ -32,6 +32,8 @@ const SIDEBAR_VIEWS_KEY = "kandev.sidebar.views";
|
||||
const SIDEBAR_ACTIVE_VIEW_KEY = "kandev.sidebar.activeViewId";
|
||||
const SIDEBAR_DRAFT_KEY = "kandev.sidebar.draft";
|
||||
const BACKEND_DOWN = "backend down";
|
||||
const PINNED_KEY = "kandev.sidebar.pinnedTaskIds";
|
||||
const ORDER_KEY = "kandev.sidebar.orderedTaskIds";
|
||||
|
||||
function makeSidebarView(id: string, name: string) {
|
||||
return {
|
||||
@@ -85,9 +87,6 @@ describe("toggleSubtaskCollapsed", () => {
|
||||
});
|
||||
|
||||
describe("sidebar task prefs (pin + manual order)", () => {
|
||||
const PINNED_KEY = "kandev.sidebar.pinnedTaskIds";
|
||||
const ORDER_KEY = "kandev.sidebar.orderedTaskIds";
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
@@ -117,6 +116,30 @@ describe("sidebar task prefs (pin + manual order)", () => {
|
||||
expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2"]);
|
||||
});
|
||||
|
||||
it("pinTasks pins all given ids without unpinning existing ones", () => {
|
||||
const store = makeStore();
|
||||
store.getState().togglePinnedTask("t1");
|
||||
|
||||
store.getState().pinTasks(["t1", "t2", "t3"]);
|
||||
// t1 already pinned stays pinned; t2/t3 added.
|
||||
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1", "t2", "t3"]);
|
||||
expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual([
|
||||
"t1",
|
||||
"t2",
|
||||
"t3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("unpinTasks removes all given ids and leaves other pinned tasks alone", () => {
|
||||
const store = makeStore();
|
||||
store.getState().pinTasks(["t1", "t2", "t3"]);
|
||||
|
||||
store.getState().unpinTasks(["t1", "t3"]);
|
||||
|
||||
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t2"]);
|
||||
expect(JSON.parse(window.localStorage.getItem(PINNED_KEY) ?? "null")).toEqual(["t2"]);
|
||||
});
|
||||
|
||||
it("setSidebarTaskOrder replaces and persists", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setSidebarTaskOrder(["a", "b", "c"]);
|
||||
@@ -155,6 +178,15 @@ describe("sidebar task prefs (pin + manual order)", () => {
|
||||
expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]);
|
||||
expect(window.localStorage.getItem(PINNED_KEY)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar task prefs sync", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
|
||||
it("records sync errors and clears them after a later successful sync", async () => {
|
||||
const store = makeStore();
|
||||
|
||||
@@ -526,6 +526,8 @@ export type AppState = {
|
||||
migrateLocalViewsToBackend: UIA["migrateLocalViewsToBackend"];
|
||||
setKanbanPreviewedTaskId: UIA["setKanbanPreviewedTaskId"];
|
||||
togglePinnedTask: UIA["togglePinnedTask"];
|
||||
pinTasks: UIA["pinTasks"];
|
||||
unpinTasks: UIA["unpinTasks"];
|
||||
setSidebarTaskOrder: UIA["setSidebarTaskOrder"];
|
||||
setSubtaskOrder: UIA["setSubtaskOrder"];
|
||||
removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"];
|
||||
|
||||
Reference in New Issue
Block a user