feat: add colors for multi-selected tasks (#3906)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { useRouter, useSearchParams } from "@/lib/routing/client-router";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Task } from "./kanban-card";
|
||||
import { TaskCreateDialog } from "./task-create-dialog";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
@@ -451,6 +452,12 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
|
||||
setMoveError={s.setMoveError}
|
||||
handleGoToTask={s.handleGoToTask}
|
||||
/>
|
||||
{s.isMobile && (
|
||||
<MobileSelectionToggle
|
||||
active={s.multiSelect.isMultiSelectMode}
|
||||
onToggle={s.multiSelect.toggleMultiSelect}
|
||||
/>
|
||||
)}
|
||||
<KanbanSwimlanes
|
||||
viewMode={s.kanbanViewMode || ""}
|
||||
workflowFilter={s.workflowsState.activeId}
|
||||
@@ -613,3 +620,20 @@ function ApprovalWarningDialog({
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSelectionToggle({ active, onToggle }: { active: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
aria-pressed={active}
|
||||
onClick={onToggle}
|
||||
data-testid="mobile-select-tasks"
|
||||
>
|
||||
{t(active ? "kanban:cancelTaskSelection" : "kanban:selectTasks")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { TaskMultiSelectToolbar } from "./task-multi-select-toolbar";
|
||||
|
||||
const save = vi.hoisted(() => vi.fn());
|
||||
const responsive = vi.hoisted(() => ({ isMobile: false }));
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({ updateUserSettings: save }));
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => ({ isMobile: responsive.isMobile }),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
save.mockReset();
|
||||
responsive.isMobile = false;
|
||||
});
|
||||
|
||||
function mount(ids: string[]) {
|
||||
const clear = vi.fn();
|
||||
render(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<TaskMultiSelectToolbar
|
||||
selectedIds={new Set(ids)}
|
||||
steps={[]}
|
||||
isProcessing={false}
|
||||
canMove={false}
|
||||
getEligibleSelectedIds={(value) => value}
|
||||
onClearSelection={clear}
|
||||
onBulkDelete={vi.fn()}
|
||||
onBulkArchive={vi.fn()}
|
||||
onBulkMove={vi.fn()}
|
||||
/>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
return clear;
|
||||
}
|
||||
|
||||
it("has no color action for an empty selection", () => {
|
||||
mount([]);
|
||||
expect(screen.queryByTestId("bulk-color-button")).toBeNull();
|
||||
});
|
||||
|
||||
// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.4, AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.5
|
||||
it("colors mixed-workflow selections and exposes pending state without clearing selection", async () => {
|
||||
let resolve: ((value: unknown) => void) | undefined;
|
||||
save.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
const clear = mount(["a", "b"]);
|
||||
const trigger = screen.getByTestId("bulk-color-button");
|
||||
expect(screen.getByRole("status").textContent).toBe("");
|
||||
fireEvent.keyDown(trigger, { key: "Enter" });
|
||||
fireEvent.click(await screen.findByTestId("bulk-color-option-blue"));
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({
|
||||
sidebar_task_color_patch: { colors: { a: "blue", b: "blue" }, if_missing: false },
|
||||
}),
|
||||
);
|
||||
expect((trigger as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(trigger.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(screen.getByRole("status").textContent).toBe("Saving colors...");
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
await act(async () =>
|
||||
resolve?.({
|
||||
settings: {
|
||||
user_id: "default-user",
|
||||
workspace_id: "",
|
||||
repository_ids: [],
|
||||
sidebar_task_colors: { a: "blue", b: "blue" },
|
||||
revision: 2,
|
||||
},
|
||||
shell_options: [],
|
||||
}),
|
||||
);
|
||||
expect((trigger as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the mobile actions sheet when selection becomes empty", async () => {
|
||||
responsive.isMobile = true;
|
||||
const props = {
|
||||
steps: [],
|
||||
isProcessing: false,
|
||||
canMove: false,
|
||||
getEligibleSelectedIds: (value: string[]) => value,
|
||||
onClearSelection: vi.fn(),
|
||||
onBulkDelete: vi.fn(),
|
||||
onBulkArchive: vi.fn(),
|
||||
onBulkMove: vi.fn(),
|
||||
};
|
||||
const view = (ids: string[]) => (
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<TaskMultiSelectToolbar selectedIds={new Set(ids)} {...props} />
|
||||
</ToastProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
const { rerender } = render(view(["a"]));
|
||||
fireEvent.click(screen.getByTestId("bulk-actions-button"));
|
||||
expect(await screen.findByRole("dialog")).not.toBeNull();
|
||||
|
||||
rerender(view([]));
|
||||
await act(async () => {});
|
||||
rerender(view(["b"]));
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { IconTrash, IconArchive, IconChevronRight, IconX } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { BulkTaskColorPicker } from "@/components/task/bulk-task-color-picker";
|
||||
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorkflowStep } from "@/components/kanban-column";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -105,7 +108,7 @@ function BulkArchiveDialog({
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
size="default"
|
||||
variant="outline"
|
||||
className="cursor-pointer gap-1.5"
|
||||
disabled={isProcessing}
|
||||
@@ -159,7 +162,7 @@ function BulkDeleteDialog({
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
size="default"
|
||||
variant="destructive"
|
||||
className="cursor-pointer gap-1.5"
|
||||
disabled={isProcessing}
|
||||
@@ -199,6 +202,11 @@ export function TaskMultiSelectToolbar({
|
||||
}: TaskMultiSelectToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const taskIds = useMemo(() => [...selectedIds], [selectedIds]);
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (selectedIds.size === 0) setActionsOpen(false);
|
||||
}, [selectedIds.size]);
|
||||
|
||||
if (selectedIds.size === 0) return null;
|
||||
|
||||
@@ -207,65 +215,69 @@ export function TaskMultiSelectToolbar({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-[calc(1.5rem+var(--app-status-bar-height))] left-1/2 z-50 -translate-x-1/2",
|
||||
"fixed bottom-[calc(1.5rem+var(--app-status-bar-height)+env(safe-area-inset-bottom))] left-1/2 z-50 -translate-x-1/2",
|
||||
isMobile && "w-[calc(100%_-_1rem)] max-w-sm",
|
||||
"flex items-center gap-2 px-4 py-2 rounded-xl shadow-lg border border-border",
|
||||
"bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/75",
|
||||
)}
|
||||
data-testid="multi-select-toolbar"
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground mr-1">{count} selected</span>
|
||||
|
||||
{steps.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer gap-1.5"
|
||||
disabled={isProcessing || !canMove}
|
||||
title={!canMove ? t("kanban:cannotMoveTasksFromDifferentWorkflows") : undefined}
|
||||
data-testid="bulk-move-button"
|
||||
>
|
||||
{t("kanban:moveTo")}
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" side="top">
|
||||
{steps.map((step) => (
|
||||
<DropdownMenuItem
|
||||
key={step.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onBulkMove(step.id)}
|
||||
data-testid={`bulk-move-step-${step.id}`}
|
||||
<span className="min-w-0 text-sm font-medium text-muted-foreground mr-1">
|
||||
{t("kanban:bulkSelected", { count })}
|
||||
</span>
|
||||
<BulkTaskColorPicker taskIds={taskIds} />
|
||||
<BulkActionsSurface isMobile={isMobile} open={actionsOpen} onOpenChange={setActionsOpen}>
|
||||
{steps.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
className="cursor-pointer gap-1.5"
|
||||
disabled={isProcessing || !canMove}
|
||||
title={!canMove ? t("kanban:cannotMoveTasksFromDifferentWorkflows") : undefined}
|
||||
data-testid="bulk-move-button"
|
||||
>
|
||||
<div className={cn("w-2 h-2 rounded-full mr-2 shrink-0", step.color)} />
|
||||
{step.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{t("kanban:moveTo")}
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" side="top">
|
||||
{steps.map((step) => (
|
||||
<DropdownMenuItem
|
||||
key={step.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onBulkMove(step.id)}
|
||||
data-testid={`bulk-move-step-${step.id}`}
|
||||
>
|
||||
<div className={cn("w-2 h-2 rounded-full mr-2 shrink-0", step.color)} />
|
||||
{step.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<BulkArchiveDialog
|
||||
count={count}
|
||||
taskIds={taskIds}
|
||||
getEligibleSelectedIds={getEligibleSelectedIds}
|
||||
isProcessing={isProcessing}
|
||||
onConfirm={({ cascade }, selection) => onBulkArchive({ cascade }, selection)}
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
count={count}
|
||||
taskIds={taskIds}
|
||||
getEligibleSelectedIds={getEligibleSelectedIds}
|
||||
isProcessing={isProcessing}
|
||||
onConfirm={({ cascade, discardWorktreeChanges }, selection) =>
|
||||
onBulkDelete({ cascade, discardWorktreeChanges }, selection)
|
||||
}
|
||||
/>
|
||||
<BulkArchiveDialog
|
||||
count={count}
|
||||
taskIds={taskIds}
|
||||
getEligibleSelectedIds={getEligibleSelectedIds}
|
||||
isProcessing={isProcessing}
|
||||
onConfirm={({ cascade }, selection) => onBulkArchive({ cascade }, selection)}
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
count={count}
|
||||
taskIds={taskIds}
|
||||
getEligibleSelectedIds={getEligibleSelectedIds}
|
||||
isProcessing={isProcessing}
|
||||
onConfirm={({ cascade, discardWorktreeChanges }, selection) =>
|
||||
onBulkDelete({ cascade, discardWorktreeChanges }, selection)
|
||||
}
|
||||
/>
|
||||
</BulkActionsSurface>
|
||||
<Button
|
||||
size="sm"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="cursor-pointer ml-1"
|
||||
onClick={onClearSelection}
|
||||
@@ -278,3 +290,38 @@ export function TaskMultiSelectToolbar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkActionsSurface({
|
||||
isMobile,
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
isMobile: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!isMobile) return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpenChange(true)}
|
||||
data-testid="bulk-actions-button"
|
||||
>
|
||||
{t("kanban:bulkActions")}
|
||||
</Button>
|
||||
<MobilePickerSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("kanban:bulkActions")}
|
||||
confirmationHost
|
||||
>
|
||||
<div className="flex flex-col gap-2">{children}</div>
|
||||
</MobilePickerSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconCheck, IconPalette, IconX } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useTaskColorSelection } from "@/hooks/use-task-color-selection";
|
||||
import {
|
||||
TASK_COLORS,
|
||||
TASK_COLOR_BAR_CLASS,
|
||||
TASK_COLOR_LABEL_KEYS,
|
||||
type TaskColor,
|
||||
} from "@/lib/task-colors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MobilePickerSheet } from "./mobile/mobile-picker-sheet";
|
||||
|
||||
type ColorOption = {
|
||||
color: TaskColor | null;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export function BulkTaskColorPicker({ taskIds }: { taskIds: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const { ids, commonColor, hasColor, setColors, isPending } = useTaskColorSelection(taskIds);
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const choose = (color: TaskColor | null) => {
|
||||
void setColors(ids, color);
|
||||
setOpen(false);
|
||||
};
|
||||
const trigger = (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
disabled={ids.length === 0}
|
||||
aria-disabled={isPending || undefined}
|
||||
aria-label={t("task:color")}
|
||||
data-testid="bulk-color-button"
|
||||
onClick={isMobile && !isPending ? () => setOpen(true) : undefined}
|
||||
>
|
||||
<IconPalette />
|
||||
<span aria-hidden={isPending}>{isPending ? t("task:bulkColorSaving") : t("task:color")}</span>
|
||||
</Button>
|
||||
);
|
||||
const options = [...TASK_COLORS, null].map((color) => ({
|
||||
color,
|
||||
label: color ? t(TASK_COLOR_LABEL_KEYS[color]) : t("task:groupNone"),
|
||||
selected: color !== null && commonColor === color,
|
||||
disabled: isPending || (color === null && !hasColor),
|
||||
}));
|
||||
const status = (
|
||||
<span className="sr-only" role="status">
|
||||
{isPending ? t("task:bulkColorSaving") : ""}
|
||||
</span>
|
||||
);
|
||||
if (isMobile)
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{status}
|
||||
<MobileBulkColorPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
count={ids.length}
|
||||
options={options}
|
||||
onChoose={choose}
|
||||
returnFocus={() => triggerRef.current?.focus()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<DesktopBulkColorPicker
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen || !isPending) setOpen(nextOpen);
|
||||
}}
|
||||
trigger={trigger}
|
||||
commonColor={commonColor}
|
||||
options={options}
|
||||
onChoose={choose}
|
||||
/>
|
||||
{status}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileBulkColorPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
count,
|
||||
options,
|
||||
onChoose,
|
||||
returnFocus,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
count: number;
|
||||
options: ColorOption[];
|
||||
onChoose: (color: TaskColor | null) => void;
|
||||
returnFocus: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MobilePickerSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("task:bulkColorTitle", { count })}
|
||||
description={t("task:bulkColorAutomaticHint")}
|
||||
contentTestId="bulk-color-picker"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
returnFocus();
|
||||
}}
|
||||
headerAction={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="cursor-pointer"
|
||||
aria-label={t("common:close")}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<IconX />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{options.map(({ color, label, selected, disabled }) => (
|
||||
<Button
|
||||
key={color ?? "none"}
|
||||
variant="ghost"
|
||||
className="w-full cursor-pointer justify-start gap-2"
|
||||
aria-pressed={selected}
|
||||
disabled={disabled}
|
||||
onClick={() => onChoose(color)}
|
||||
data-testid={`bulk-color-option-${color ?? "none"}`}
|
||||
>
|
||||
<ColorSwatch color={color} />
|
||||
{label}
|
||||
{selected && <IconCheck className="ml-auto" />}
|
||||
</Button>
|
||||
))}
|
||||
</MobilePickerSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopBulkColorPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
commonColor,
|
||||
options,
|
||||
onChoose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
trigger: React.ReactNode;
|
||||
commonColor: TaskColor | null;
|
||||
options: ColorOption[];
|
||||
onChoose: (color: TaskColor | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="w-64"
|
||||
data-testid="bulk-color-picker"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("task:bulkColorAutomaticHint")}
|
||||
</div>
|
||||
<DropdownMenuRadioGroup value={commonColor ?? ""}>
|
||||
{options.map(({ color, label, disabled }) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={color ?? "none"}
|
||||
value={color ?? "none"}
|
||||
disabled={disabled}
|
||||
onSelect={() => onChoose(color)}
|
||||
data-testid={`bulk-color-option-${color ?? "none"}`}
|
||||
>
|
||||
<ColorSwatch color={color} />
|
||||
{label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorSwatch({ color }: { color: TaskColor | null }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"mr-2 inline-block h-2 w-2 rounded-full",
|
||||
color ? TASK_COLOR_BAR_CLASS[color] : "border border-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import { TaskItemWithContextMenu } from "./task-switcher-context-menu";
|
||||
|
||||
const save = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({ updateUserSettings: save }));
|
||||
afterEach(cleanup);
|
||||
beforeEach(() => {
|
||||
save.mockReset();
|
||||
save.mockReturnValue(new Promise(() => {}));
|
||||
});
|
||||
|
||||
async function openColors(rowId: string, colors: Record<string, "red" | "blue" | null>) {
|
||||
const clear = vi.fn();
|
||||
render(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
userSettings: {
|
||||
...defaultState.userSettings,
|
||||
sidebarTaskColors: colors,
|
||||
revision: 1,
|
||||
loaded: true,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu
|
||||
task={{ id: rowId, title: "Task", state: "TODO" }}
|
||||
selectedTaskIds={new Set(["a", "b"])}
|
||||
onClearSelection={clear}
|
||||
>
|
||||
<div data-testid="row">Task</div>
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTestId("row"));
|
||||
const trigger = await screen.findByRole("menuitem", { name: "Color" });
|
||||
act(() => trigger.focus());
|
||||
fireEvent.keyDown(trigger, { key: "ArrowRight" });
|
||||
await screen.findByRole("menuitemradio", { name: "Blue" });
|
||||
return clear;
|
||||
}
|
||||
|
||||
// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.1, AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.3
|
||||
it("offers bulk color without pin callbacks and keeps mixed colors unchecked", async () => {
|
||||
const clear = await openColors("a", { a: "red", b: "blue" });
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("menuitemradio")
|
||||
.every((option) => option.getAttribute("aria-checked") === "false"),
|
||||
).toBe(true);
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Blue" }));
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({
|
||||
sidebar_task_color_patch: { colors: { a: "blue", b: "blue" }, if_missing: false },
|
||||
}),
|
||||
);
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("targets an unselected context row individually", async () => {
|
||||
await openColors("c", { a: "red", b: "blue" });
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Blue" }));
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({
|
||||
sidebar_task_color_patch: { colors: { c: "blue" }, if_missing: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("checks a unanimous color and enables clearing", async () => {
|
||||
await openColors("a", { a: "red", b: "red" });
|
||||
expect(screen.getByRole("menuitemradio", { name: "Red" }).getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
const none = screen.getByRole("menuitem", { name: "None" });
|
||||
expect(none.getAttribute("data-disabled")).toBeNull();
|
||||
fireEvent.click(none);
|
||||
await waitFor(() =>
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({
|
||||
sidebar_task_color_patch: { colors: { a: null, b: null }, if_missing: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables None when all manual colors are absent", async () => {
|
||||
await openColors("a", {});
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: "None" }).getAttribute("data-disabled"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { IconCheck, IconPalette } from "@tabler/icons-react";
|
||||
import { IconPalette } from "@tabler/icons-react";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color";
|
||||
import { useTaskColorSelection } from "@/hooks/use-task-color-selection";
|
||||
import {
|
||||
TASK_COLORS,
|
||||
TASK_COLOR_BAR_CLASS,
|
||||
@@ -21,21 +23,29 @@ import type { AutomaticTaskColorSource } from "@/lib/sidebar/task-color-rules";
|
||||
|
||||
export function TaskColorMenu({
|
||||
taskId,
|
||||
taskIds,
|
||||
disabled,
|
||||
automaticColorSource,
|
||||
}: {
|
||||
taskId: string;
|
||||
taskId?: string;
|
||||
taskIds?: string[];
|
||||
disabled?: boolean;
|
||||
automaticColorSource?: AutomaticTaskColorSource;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const currentColor = useTaskColor(taskId);
|
||||
const setColor = useSetTaskColor();
|
||||
const {
|
||||
ids,
|
||||
commonColor: currentColor,
|
||||
hasColor,
|
||||
setColors,
|
||||
isPending,
|
||||
} = useTaskColorSelection(taskIds ?? (taskId ? [taskId] : []));
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={disabled}>
|
||||
<ContextMenuSubTrigger disabled={disabled || isPending || ids.length === 0}>
|
||||
<IconPalette className="mr-2 h-4 w-4" />
|
||||
{t("task:color")}
|
||||
{isPending && <span role="status">{t("task:bulkColorSaving")}</span>}
|
||||
{currentColor && (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -45,7 +55,12 @@ export function TaskColorMenu({
|
||||
/>
|
||||
)}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-40">
|
||||
<ContextMenuSubContent className="w-64">
|
||||
{taskIds && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("task:bulkColorAutomaticHint")}
|
||||
</div>
|
||||
)}
|
||||
{automaticColorSource && (
|
||||
<>
|
||||
<ContextMenuItem disabled data-testid="automatic-task-color-source">
|
||||
@@ -54,16 +69,25 @@ export function TaskColorMenu({
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{TASK_COLORS.map((color) => (
|
||||
<TaskColorMenuItem
|
||||
key={color}
|
||||
color={color}
|
||||
selected={currentColor === color}
|
||||
onSelect={() => setColor(taskId, color)}
|
||||
/>
|
||||
))}
|
||||
<ContextMenuRadioGroup value={currentColor ?? ""}>
|
||||
{TASK_COLORS.map((color) => (
|
||||
<TaskColorMenuItem
|
||||
key={color}
|
||||
color={color}
|
||||
disabled={isPending}
|
||||
onSelect={() => {
|
||||
void setColors(ids, color);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuRadioGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={!currentColor} onSelect={() => setColor(taskId, null)}>
|
||||
<ContextMenuItem
|
||||
disabled={isPending || !hasColor}
|
||||
onSelect={() => {
|
||||
void setColors(ids, null);
|
||||
}}
|
||||
>
|
||||
<span className="mr-2 inline-block h-2 w-2 rounded-full border border-muted-foreground/40" />
|
||||
{t("task:groupNone")}
|
||||
</ContextMenuItem>
|
||||
@@ -74,19 +98,18 @@ export function TaskColorMenu({
|
||||
|
||||
function TaskColorMenuItem({
|
||||
color,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
color: TaskColor;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ContextMenuItem onSelect={onSelect}>
|
||||
<ContextMenuRadioItem value={color} disabled={disabled} onSelect={onSelect}>
|
||||
<span className={cn("mr-2 inline-block h-2 w-2 rounded-full", TASK_COLOR_BAR_CLASS[color])} />
|
||||
{t(TASK_COLOR_LABEL_KEYS[color])}
|
||||
{selected && <IconCheck className="ml-auto h-3.5 w-3.5" />}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuRadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -413,8 +413,13 @@ function BulkSelectionMenuItems(props: BulkSelectionMenuProps) {
|
||||
groups={[
|
||||
{
|
||||
key: "mark",
|
||||
visible: Boolean(props.onBulkPin),
|
||||
content: <BulkPinMenuItem {...props} />,
|
||||
visible: props.actingIds.length > 0,
|
||||
content: (
|
||||
<>
|
||||
<BulkPinMenuItem {...props} />
|
||||
<TaskColorMenu taskIds={props.actingIds} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "move",
|
||||
|
||||
@@ -275,6 +275,7 @@ describe("TaskItemWithContextMenu — grouped bulk actions", () => {
|
||||
|
||||
expect(labels).toEqual([
|
||||
"Pin 2 tasks",
|
||||
"Color",
|
||||
"Move to",
|
||||
"Send to workflow",
|
||||
"Archive 2 tasks",
|
||||
@@ -405,7 +406,7 @@ describe("TaskItemWithContextMenu — pointer containment", () => {
|
||||
fireEvent.pointerMove(screen.getByRole("menuitem", { name: /color/i }), {
|
||||
pointerType: "mouse",
|
||||
});
|
||||
const redSwatch = await screen.findByRole("menuitem", { name: /red/i });
|
||||
const redSwatch = await screen.findByRole("menuitemradio", { name: /red/i });
|
||||
fireEvent.touchStart(redSwatch);
|
||||
expect(onTouchStart).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -417,7 +418,7 @@ describe("TaskItemWithContextMenu — pointer containment", () => {
|
||||
fireEvent.pointerMove(screen.getByRole("menuitem", { name: /color/i }), {
|
||||
pointerType: "mouse",
|
||||
});
|
||||
const redSwatch = await screen.findByRole("menuitem", { name: /red/i });
|
||||
const redSwatch = await screen.findByRole("menuitemradio", { name: /red/i });
|
||||
fireEvent.mouseDown(redSwatch);
|
||||
fireEvent.pointerDown(redSwatch);
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { ApiClient } from "./api-client";
|
||||
import type { SeedData } from "../fixtures/test-base";
|
||||
|
||||
export async function seedColorTasks(api: ApiClient, seed: SeedData) {
|
||||
const tasks = [];
|
||||
for (const title of ["Bulk color A", "Bulk color B", "Unselected color control"]) {
|
||||
tasks.push(
|
||||
await api.createTask(seed.workspaceId, title, {
|
||||
workflow_id: seed.workflowId,
|
||||
workflow_step_id: seed.startStepId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export async function expectStoredColors(api: ApiClient, ids: string[], color: string | null) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { settings } = await api.getUserSettings();
|
||||
const colors = settings.sidebar_task_colors as Record<string, string | null>;
|
||||
return ids.map((id) => colors[id] ?? null);
|
||||
})
|
||||
.toEqual(ids.map(() => color));
|
||||
}
|
||||
|
||||
export async function chooseSidebarColor(page: Page, label: string) {
|
||||
await page.getByRole("menuitem", { name: "Color", exact: true }).hover();
|
||||
const role = label === "None" ? "menuitem" : "menuitemradio";
|
||||
await page.getByRole(role, { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectNoHorizontalOverflow(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import {
|
||||
seedColorTasks,
|
||||
expectStoredColors,
|
||||
expectNoHorizontalOverflow,
|
||||
} from "../../helpers/bulk-task-colors";
|
||||
|
||||
// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.6
|
||||
test("phone selection supports color, dismissal, clearing, and bulk actions", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 393, height: 851 });
|
||||
const [a, b, control] = await seedColorTasks(apiClient, seedData);
|
||||
const board = new KanbanPage(testPage);
|
||||
await board.goto();
|
||||
const select = testPage.getByTestId("mobile-select-tasks");
|
||||
await select.tap();
|
||||
await expect(select).toHaveAttribute("aria-pressed", "true");
|
||||
await select.tap();
|
||||
await expect(select).toHaveAttribute("aria-pressed", "false");
|
||||
await select.tap();
|
||||
await board.taskCard(a.id).tap();
|
||||
await board.taskCard(b.id).tap();
|
||||
await expect(board.multiSelectToolbar).toContainText("2 selected");
|
||||
const color = testPage.getByTestId("bulk-color-button");
|
||||
await color.tap();
|
||||
const picker = testPage.getByTestId("bulk-color-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
const dialog = testPage.getByRole("dialog", { name: "Color for 2 tasks" });
|
||||
await dialog.evaluate(async (el) => {
|
||||
await Promise.all(
|
||||
el
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((a) => Number.isFinite(a.effect?.getComputedTiming().iterations))
|
||||
.map((a) => a.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
const bounds = await dialog.boundingBox();
|
||||
expect(bounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(393);
|
||||
for (const button of await picker.getByRole("button").all()) {
|
||||
expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await expectNoHorizontalOverflow(testPage);
|
||||
await testPage.screenshot({ path: "/tmp/bulk-task-colors-phone.png" });
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).tap();
|
||||
await expect(color).toBeFocused();
|
||||
await expectStoredColors(apiClient, [a.id, b.id], null);
|
||||
await color.tap();
|
||||
await picker.getByTestId("bulk-color-option-green").tap();
|
||||
await expectStoredColors(apiClient, [a.id, b.id], "green");
|
||||
await expectStoredColors(apiClient, [control.id], null);
|
||||
await expect(board.multiSelectToolbar).toContainText("2 selected");
|
||||
await color.tap();
|
||||
await picker.getByTestId("bulk-color-option-none").tap();
|
||||
await expectStoredColors(apiClient, [a.id, b.id], null);
|
||||
await color.tap();
|
||||
await picker.getByTestId("bulk-color-option-purple").tap();
|
||||
await expectStoredColors(apiClient, [a.id, b.id], "purple");
|
||||
await testPage.getByTestId("bulk-actions-button").tap();
|
||||
await expect(testPage.getByTestId("bulk-archive-button")).toBeVisible();
|
||||
await expect(testPage.getByTestId("bulk-delete-button")).toBeVisible();
|
||||
await testPage.keyboard.press("Escape");
|
||||
for (const width of [767, 768]) {
|
||||
await testPage.setViewportSize({ width, height: 851 });
|
||||
await expect(testPage.getByTestId("bulk-actions-button")).toHaveCount(width < 768 ? 1 : 0);
|
||||
await expectNoHorizontalOverflow(testPage);
|
||||
}
|
||||
await testPage.setViewportSize({ width: 393, height: 851 });
|
||||
await testPage.goto(`/t/${control.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await session.mobileSessionMenu.tap();
|
||||
const drawer = testPage.getByTestId("mobile-task-switcher-list");
|
||||
await expect(
|
||||
drawer
|
||||
.getByTestId("sidebar-task-item")
|
||||
.filter({ hasText: a.title })
|
||||
.getByTestId("task-item-color-marker"),
|
||||
).toHaveAttribute("data-color-token", "purple");
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { SidebarTasksPage } from "../../pages/sidebar-tasks-page";
|
||||
import {
|
||||
seedColorTasks,
|
||||
expectStoredColors,
|
||||
chooseSidebarColor,
|
||||
} from "../../helpers/bulk-task-colors";
|
||||
import type { SidebarTaskColorAutomation } from "../../../lib/task-color-automation-settings";
|
||||
|
||||
// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.1 through .4
|
||||
test("sidebar colors exactly selected tasks, preserves selection, and clears after reload", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const [a, b, control] = await seedColorTasks(apiClient, seedData);
|
||||
await testPage.goto(`/t/${control.id}`);
|
||||
await new SessionPage(testPage).waitForLoad();
|
||||
const sidebar = new SidebarTasksPage(testPage);
|
||||
await sidebar.cmdClick(a.id);
|
||||
await sidebar.cmdClick(b.id);
|
||||
await sidebar.rightClick(a.id);
|
||||
await chooseSidebarColor(testPage, "Blue");
|
||||
await expectStoredColors(apiClient, [a.id, b.id], "blue");
|
||||
await expectStoredColors(apiClient, [control.id], null);
|
||||
await sidebar.expectSelected(a.id);
|
||||
await sidebar.expectSelected(b.id);
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${control.id}$`));
|
||||
await testPage.reload();
|
||||
await new SessionPage(testPage).waitForLoad();
|
||||
for (const id of [a.id, b.id]) {
|
||||
await expect(sidebar.row(id).getByTestId("task-item-color-marker")).toHaveAttribute(
|
||||
"data-color-token",
|
||||
"blue",
|
||||
);
|
||||
await sidebar.cmdClick(id);
|
||||
}
|
||||
await sidebar.rightClick(a.id);
|
||||
await chooseSidebarColor(testPage, "None");
|
||||
await expectStoredColors(apiClient, [a.id, b.id], null);
|
||||
await sidebar.expectSelected(a.id);
|
||||
});
|
||||
|
||||
test("board bulk colors persist while automatic rules retain precedence", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const { settings: baseline } = await apiClient.getUserSettings();
|
||||
const [a, b, control] = await seedColorTasks(apiClient, seedData);
|
||||
const automation: SidebarTaskColorAutomation = {
|
||||
enabled: true,
|
||||
rules: [
|
||||
{
|
||||
id: "bulk-color-rule",
|
||||
enabled: true,
|
||||
condition: {
|
||||
dimension: "workflow_step",
|
||||
value: { workspace_id: seedData.workspaceId, step_id: seedData.startStepId },
|
||||
label: "Start",
|
||||
},
|
||||
output: { kind: "fixed", color: "red" },
|
||||
},
|
||||
],
|
||||
};
|
||||
try {
|
||||
await apiClient.saveUserSettings({ sidebar_task_color_automation: automation });
|
||||
const board = new KanbanPage(testPage);
|
||||
await board.goto();
|
||||
await board.selectTask(a.id);
|
||||
await board.selectTask(b.id);
|
||||
await testPage.getByTestId("bulk-color-button").click();
|
||||
await testPage.getByTestId("bulk-color-option-purple").click();
|
||||
await expectStoredColors(apiClient, [a.id, b.id], "purple");
|
||||
await expectStoredColors(apiClient, [control.id], null);
|
||||
await expect(board.multiSelectToolbar).toContainText("2 selected");
|
||||
await testPage.goto(`/t/${control.id}`);
|
||||
await new SessionPage(testPage).waitForLoad();
|
||||
await testPage.reload();
|
||||
await new SessionPage(testPage).waitForLoad();
|
||||
const sidebar = new SidebarTasksPage(testPage);
|
||||
await expect(sidebar.row(a.id).getByTestId("task-item-color-marker")).toHaveAttribute(
|
||||
"data-color-token",
|
||||
"red",
|
||||
);
|
||||
await expectStoredColors(apiClient, [a.id, b.id], "purple");
|
||||
} finally {
|
||||
await apiClient.saveUserSettings({
|
||||
sidebar_task_color_automation:
|
||||
baseline.sidebar_task_color_automation as SidebarTaskColorAutomation,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -97,7 +97,7 @@ test.describe("Sidebar personal task colors", () => {
|
||||
const colorMenu = testPage.getByRole("menuitem", { name: /color/i });
|
||||
await expect(colorMenu).toBeVisible();
|
||||
await colorMenu.hover();
|
||||
const blueMenuItem = testPage.getByRole("menuitem", { name: "Blue", exact: true });
|
||||
const blueMenuItem = testPage.getByRole("menuitemradio", { name: "Blue", exact: true });
|
||||
await expect(blueMenuItem).toBeVisible();
|
||||
await blueMenuItem.click();
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { useAppStoreApi } from "@/components/state-provider";
|
||||
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
|
||||
import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision";
|
||||
import { requestUserSettingsUpdateWithRetry } from "@/lib/user-settings-sync";
|
||||
import type { TaskColor } from "@/lib/task-colors";
|
||||
|
||||
type Store = ReturnType<typeof useAppStoreApi>;
|
||||
type PendingColor = {
|
||||
operation: symbol;
|
||||
color: TaskColor | null;
|
||||
state: "queued" | "sent";
|
||||
};
|
||||
type SyncResult = {
|
||||
response: Awaited<ReturnType<typeof requestUserSettingsUpdateWithRetry>> | null;
|
||||
submittedIds: string[];
|
||||
};
|
||||
type QueuedRequest = {
|
||||
taskIds: string[];
|
||||
operation: symbol;
|
||||
color: TaskColor | null;
|
||||
resolve: (result: SyncResult) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
};
|
||||
|
||||
export type TaskColorResult = { saved: number; total: number };
|
||||
|
||||
function createColorSync(pending: Map<string, PendingColor>) {
|
||||
const requests: QueuedRequest[] = [];
|
||||
let requestRunning = false;
|
||||
|
||||
function runNext() {
|
||||
if (requestRunning) return;
|
||||
let request = requests.shift();
|
||||
while (request) {
|
||||
const activeRequest = request;
|
||||
const submittedIds = activeRequest.taskIds.filter(
|
||||
(id) => pending.get(id)?.operation === activeRequest.operation,
|
||||
);
|
||||
if (submittedIds.length === 0) {
|
||||
activeRequest.resolve({ response: null, submittedIds });
|
||||
request = requests.shift();
|
||||
continue;
|
||||
}
|
||||
submittedIds.forEach((id) => {
|
||||
const entry = pending.get(id);
|
||||
if (entry?.operation === activeRequest.operation) {
|
||||
pending.set(id, { ...entry, state: "sent" });
|
||||
}
|
||||
});
|
||||
requestRunning = true;
|
||||
void requestUserSettingsUpdateWithRetry({
|
||||
sidebar_task_color_patch: {
|
||||
colors: Object.fromEntries(submittedIds.map((id) => [id, activeRequest.color])),
|
||||
if_missing: false,
|
||||
},
|
||||
})
|
||||
.then((response) => activeRequest.resolve({ response, submittedIds }))
|
||||
.catch((error: unknown) => activeRequest.reject(error))
|
||||
.finally(() => {
|
||||
requestRunning = false;
|
||||
runNext();
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (taskIds: string[], operation: symbol, color: TaskColor | null) =>
|
||||
new Promise<SyncResult>((resolve, reject) => {
|
||||
requests.push({ taskIds, operation, color, resolve, reject });
|
||||
runNext();
|
||||
});
|
||||
}
|
||||
|
||||
function createColorMutation(store: Store) {
|
||||
let confirmed = { ...store.getState().userSettings.sidebarTaskColors };
|
||||
let revision = store.getState().userSettings.revision;
|
||||
let publishing = false;
|
||||
const pending = new Map<string, PendingColor>();
|
||||
const sync = createColorSync(pending);
|
||||
|
||||
store.subscribe((state, previous) => {
|
||||
if (publishing || state.userSettings === previous.userSettings) return;
|
||||
const settings = state.userSettings;
|
||||
const order = compareUserSettingsRevisions(settings.revision, revision);
|
||||
if (order === 1 || (pending.size === 0 && order !== -1)) {
|
||||
const previousConfirmed = confirmed;
|
||||
confirmed = { ...settings.sidebarTaskColors };
|
||||
revision = settings.revision;
|
||||
if (order === 1) {
|
||||
pending.forEach((entry, id) => {
|
||||
const colorChanged =
|
||||
(settings.sidebarTaskColors[id] ?? null) !== (previousConfirmed[id] ?? null);
|
||||
if (entry.state === "sent" && colorChanged) pending.delete(id);
|
||||
});
|
||||
if (pending.size > 0) publish();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function publish() {
|
||||
const latest = store.getState().userSettings;
|
||||
const colors = { ...confirmed };
|
||||
pending.forEach(({ color }, id) => {
|
||||
colors[id] = color;
|
||||
});
|
||||
publishing = true;
|
||||
try {
|
||||
store
|
||||
.getState()
|
||||
.setUserSettings({ ...latest, sidebarTaskColors: colors, revision, loaded: true });
|
||||
} finally {
|
||||
publishing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function release(ids: string[], operation: symbol) {
|
||||
ids.forEach((id) => {
|
||||
if (pending.get(id)?.operation === operation) pending.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
return async (taskIds: string[], color: TaskColor | null): Promise<TaskColorResult> => {
|
||||
const ids = [...new Set(taskIds.filter(Boolean))];
|
||||
const operation = Symbol();
|
||||
ids.forEach((id) => pending.set(id, { operation, color, state: "queued" }));
|
||||
if (ids.length === 0) return { saved: 0, total: 0 };
|
||||
publish();
|
||||
let saved = 0;
|
||||
try {
|
||||
for (let offset = 0; offset < ids.length; offset += 500) {
|
||||
const chunk = ids.slice(offset, offset + 500);
|
||||
const { response, submittedIds } = await sync(chunk, operation, color);
|
||||
if (!response) {
|
||||
saved += submittedIds.length;
|
||||
continue;
|
||||
}
|
||||
const mapped = mapUserSettingsResponse(response, store.getState().userSettings);
|
||||
const order = compareUserSettingsRevisions(mapped.revision, revision);
|
||||
if (order === 1 || order === 0) {
|
||||
confirmed = { ...mapped.sidebarTaskColors };
|
||||
revision = mapped.revision;
|
||||
}
|
||||
saved += submittedIds.length;
|
||||
release(submittedIds, operation);
|
||||
publish();
|
||||
}
|
||||
} catch {
|
||||
release(ids, operation);
|
||||
publish();
|
||||
}
|
||||
return { saved, total: ids.length };
|
||||
};
|
||||
}
|
||||
|
||||
const mutations = new WeakMap<Store, ReturnType<typeof createColorMutation>>();
|
||||
|
||||
export function getTaskColorMutation(store: Store) {
|
||||
let mutation = mutations.get(store);
|
||||
if (!mutation) {
|
||||
mutation = createColorMutation(store);
|
||||
mutations.set(store, mutation);
|
||||
}
|
||||
return mutation;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useSetTaskColors } from "./use-task-color";
|
||||
|
||||
export function useTaskColorSelection(taskIds: string[]) {
|
||||
const colors = useAppStore((state) => state.userSettings.sidebarTaskColors);
|
||||
const mutation = useSetTaskColors();
|
||||
const ids = [...new Set(taskIds.filter(Boolean))];
|
||||
const first = colors[ids[0]] ?? null;
|
||||
const commonColor = ids.every((id) => (colors[id] ?? null) === first) ? first : null;
|
||||
const hasColor = ids.some((id) => colors[id] != null);
|
||||
return { ...mutation, ids, commonColor, hasColor };
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ToastProvider } from "@/components/toast-provider";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import { TASK_COLORS_STORAGE_KEY } from "@/lib/task-colors";
|
||||
import type { UserSettingsResponse } from "@/lib/types/http";
|
||||
import { useSetTaskColor, useTaskColor } from "./use-task-color";
|
||||
import { useSetTaskColor, useSetTaskColors, useTaskColor } from "./use-task-color";
|
||||
|
||||
const mockUpdateUserSettings = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -162,3 +162,287 @@ describe("useTaskColor", () => {
|
||||
await waitFor(() => expect(result.current.color).toBe("blue"));
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an in-flight color optimistic across an unrelated newer settings revision", async () => {
|
||||
let rejectUpdate: ((reason: Error) => void) | undefined;
|
||||
mockUpdateUserSettings.mockReturnValue(
|
||||
new Promise<UserSettingsResponse>((_, reject) => {
|
||||
rejectUpdate = reject;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => ({ color: useTaskColor("task-1"), setColor: useSetTaskColor() }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => result.current.setColor("task-1", "blue"));
|
||||
await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1));
|
||||
act(() => {
|
||||
capturedStore?.setState((state) => ({
|
||||
...state,
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
revision: 2,
|
||||
sidebarTaskColors: { "task-1": "red" },
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
expect(result.current.color).toBe("blue");
|
||||
act(() => rejectUpdate?.(new Error("offline")));
|
||||
await waitFor(() => expect(result.current.color).toBe("red"));
|
||||
});
|
||||
|
||||
// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.5
|
||||
it("restores only failed IDs across independent color setters", async () => {
|
||||
let resolveFirst: ((value: UserSettingsResponse) => void) | undefined;
|
||||
mockUpdateUserSettings.mockImplementation(() => {
|
||||
if (mockUpdateUserSettings.mock.calls.length === 1) {
|
||||
return new Promise<UserSettingsResponse>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("offline"));
|
||||
});
|
||||
const { result } = renderHook(() => ({ first: useSetTaskColor(), second: useSetTaskColor() }), {
|
||||
wrapper,
|
||||
});
|
||||
act(() => {
|
||||
result.current.first("task-1", "blue");
|
||||
result.current.second("task-2", "pink");
|
||||
});
|
||||
await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalled());
|
||||
act(() => resolveFirst?.(response({ "task-1": "blue" }, 2)));
|
||||
await waitFor(() =>
|
||||
expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-2"] ?? null).toBeNull(),
|
||||
);
|
||||
expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("blue");
|
||||
});
|
||||
|
||||
it("does not roll back another hook's pending optimistic color", async () => {
|
||||
mockUpdateUserSettings.mockImplementation((payload) => {
|
||||
if (payload.sidebar_task_color_patch.colors["task-1"])
|
||||
return Promise.reject(new Error("offline"));
|
||||
return new Promise(() => {});
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() => ({ first: useSetTaskColor(), second: useSetTaskColor(), color: useTaskColor("task-1") }),
|
||||
{ wrapper },
|
||||
);
|
||||
act(() => {
|
||||
result.current.first("task-1", "blue");
|
||||
result.current.second("task-2", "pink");
|
||||
});
|
||||
await waitFor(() => expect(result.current.color).toBe("red"));
|
||||
expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-2"]).toBe("pink");
|
||||
});
|
||||
|
||||
// @covers AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.2, AC-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006.7
|
||||
describe("bulk colors", () => {
|
||||
it("deduplicates and captures IDs in one patch, including clearing", async () => {
|
||||
mockUpdateUserSettings.mockResolvedValue(response({ "task-1": null, "task-2": null }, 2));
|
||||
const { result } = renderHook(() => useSetTaskColors(), { wrapper });
|
||||
const ids = ["task-1", "task-2", "task-1", ""];
|
||||
let saving: Promise<unknown>;
|
||||
act(() => {
|
||||
saving = result.current.setColors(ids, null);
|
||||
ids.push("unselected");
|
||||
});
|
||||
expect(result.current.isPending).toBe(true);
|
||||
await act(async () => {
|
||||
await saving;
|
||||
});
|
||||
expect(mockUpdateUserSettings).toHaveBeenCalledExactlyOnceWith({
|
||||
sidebar_task_color_patch: { colors: { "task-1": null, "task-2": null }, if_missing: false },
|
||||
});
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing for an empty selection", async () => {
|
||||
const { result } = renderHook(() => useSetTaskColors(), { wrapper });
|
||||
await act(async () => {
|
||||
expect(await result.current.setColors([], "red")).toEqual({ saved: 0, total: 0 });
|
||||
});
|
||||
expect(mockUpdateUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([500, 501])("saves %i tasks in bounded sequential patches", async (count) => {
|
||||
let colors = { "task-1": "red" };
|
||||
let revision = 1;
|
||||
mockUpdateUserSettings.mockImplementation(async (payload) => {
|
||||
colors = { ...colors, ...payload.sidebar_task_color_patch.colors };
|
||||
return response(colors as Parameters<typeof response>[0], ++revision);
|
||||
});
|
||||
const { result } = renderHook(() => useSetTaskColors(), { wrapper });
|
||||
await act(async () => {
|
||||
expect(
|
||||
await result.current.setColors(
|
||||
Array.from({ length: count }, (_, i) => `bulk-${i}`),
|
||||
"purple",
|
||||
),
|
||||
).toEqual({ saved: count, total: count });
|
||||
});
|
||||
expect(mockUpdateUserSettings).toHaveBeenCalledTimes(Math.ceil(count / 500));
|
||||
expect(
|
||||
Object.keys(mockUpdateUserSettings.mock.calls[0][0].sidebar_task_color_patch.colors),
|
||||
).toHaveLength(500);
|
||||
expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("red");
|
||||
});
|
||||
|
||||
it("retains confirmed chunks and restores unsaved entries after partial failure", async () => {
|
||||
const ids = Array.from({ length: 501 }, (_, i) => `bulk-${i}`);
|
||||
mockUpdateUserSettings
|
||||
.mockResolvedValueOnce(
|
||||
response(
|
||||
{
|
||||
"task-1": "red",
|
||||
...Object.fromEntries(ids.slice(0, 500).map((id) => [id, "purple" as const])),
|
||||
},
|
||||
2,
|
||||
),
|
||||
)
|
||||
.mockRejectedValue(new Error("offline"));
|
||||
const { result } = renderHook(() => useSetTaskColors(), { wrapper });
|
||||
await act(async () => {
|
||||
expect(await result.current.setColors(ids, "purple")).toEqual({ saved: 500, total: 501 });
|
||||
});
|
||||
const colors = capturedStore?.getState().userSettings.sidebarTaskColors;
|
||||
expect(colors?.["bulk-499"]).toBe("purple");
|
||||
expect(colors?.["bulk-500"] ?? null).toBeNull();
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
|
||||
it("guards same-tick duplicate submits and preserves a later single-task edit", async () => {
|
||||
let resolve: ((value: UserSettingsResponse) => void) | undefined;
|
||||
mockUpdateUserSettings
|
||||
.mockReturnValueOnce(
|
||||
new Promise<UserSettingsResponse>((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(response({ "task-1": "pink", "task-2": "blue" }, 3));
|
||||
const { result } = renderHook(() => ({ bulk: useSetTaskColors(), single: useSetTaskColor() }), {
|
||||
wrapper,
|
||||
});
|
||||
let saving: Promise<unknown>;
|
||||
act(() => {
|
||||
saving = result.current.bulk.setColors(["task-1", "task-2"], "blue");
|
||||
expect(result.current.bulk.setColors(["task-1"], "green")).toBe(saving);
|
||||
result.current.single("task-1", "pink");
|
||||
});
|
||||
await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
resolve?.(response({ "task-1": "blue", "task-2": "blue" }, 2));
|
||||
await saving;
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("pink"),
|
||||
);
|
||||
expect(mockUpdateUserSettings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite a newer single edit with a later chunk of an older batch", async () => {
|
||||
const ids = Array.from({ length: 501 }, (_, i) => `bulk-${i}`);
|
||||
let release: (() => void) | undefined;
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let confirmed: Parameters<typeof response>[0] = { "task-1": "red" };
|
||||
let revision = 1;
|
||||
mockUpdateUserSettings.mockImplementation(async (payload) => {
|
||||
if (mockUpdateUserSettings.mock.calls.length === 1) await barrier;
|
||||
confirmed = { ...confirmed, ...payload.sidebar_task_color_patch.colors };
|
||||
return response(confirmed, ++revision);
|
||||
});
|
||||
const { result } = renderHook(() => ({ bulk: useSetTaskColors(), single: useSetTaskColor() }), {
|
||||
wrapper,
|
||||
});
|
||||
let saving: Promise<unknown>;
|
||||
act(() => {
|
||||
saving = result.current.bulk.setColors(ids, "blue");
|
||||
});
|
||||
await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1));
|
||||
act(() => {
|
||||
result.current.single("bulk-500", "pink");
|
||||
});
|
||||
await act(async () => {
|
||||
release?.();
|
||||
await saving;
|
||||
});
|
||||
await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(2));
|
||||
expect(confirmed["bulk-500"]).toBe("pink");
|
||||
expect(capturedStore?.getState().userSettings.sidebarTaskColors["bulk-500"]).toBe("pink");
|
||||
});
|
||||
|
||||
it("does not count a superseded task as saved when its newer edit fails", async () => {
|
||||
const ids = Array.from({ length: 501 }, (_, i) => `bulk-${i}`);
|
||||
let resolveFirst: ((value: UserSettingsResponse) => void) | undefined;
|
||||
mockUpdateUserSettings.mockImplementation(() => {
|
||||
if (mockUpdateUserSettings.mock.calls.length === 1) {
|
||||
return new Promise<UserSettingsResponse>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("offline"));
|
||||
});
|
||||
const { result } = renderHook(() => ({ bulk: useSetTaskColors(), single: useSetTaskColor() }), {
|
||||
wrapper,
|
||||
});
|
||||
let saving!: Promise<{ saved: number; total: number }>;
|
||||
act(() => {
|
||||
saving = result.current.bulk.setColors(ids, "blue");
|
||||
});
|
||||
await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1));
|
||||
act(() => {
|
||||
result.current.single("bulk-500", "pink");
|
||||
});
|
||||
|
||||
let outcome: { saved: number; total: number } | undefined;
|
||||
await act(async () => {
|
||||
resolveFirst?.(
|
||||
response(Object.fromEntries(ids.slice(0, 500).map((id) => [id, "blue" as const])), 2),
|
||||
);
|
||||
outcome = await saving;
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ saved: 500, total: 501 });
|
||||
});
|
||||
|
||||
it("keeps a queued newer edit optimistic when an older write emits a settings event", async () => {
|
||||
let resolveFirst: ((value: UserSettingsResponse) => void) | undefined;
|
||||
mockUpdateUserSettings.mockImplementation(() => {
|
||||
if (mockUpdateUserSettings.mock.calls.length === 1) {
|
||||
return new Promise<UserSettingsResponse>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(response({ "task-1": "pink", "task-2": "blue" }, 3));
|
||||
});
|
||||
const { result } = renderHook(() => ({ bulk: useSetTaskColors(), single: useSetTaskColor() }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
void result.current.bulk.setColors(["task-1", "task-2"], "blue");
|
||||
result.current.single("task-1", "pink");
|
||||
});
|
||||
await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(1));
|
||||
act(() => {
|
||||
capturedStore?.setState((state) => ({
|
||||
...state,
|
||||
userSettings: {
|
||||
...state.userSettings,
|
||||
revision: 2,
|
||||
sidebarTaskColors: { "task-1": "blue", "task-2": "blue" },
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("pink");
|
||||
act(() => resolveFirst?.(response({ "task-1": "blue", "task-2": "blue" }, 2)));
|
||||
await waitFor(() => expect(mockUpdateUserSettings).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(capturedStore?.getState().userSettings.sidebarTaskColors["task-1"]).toBe("pink"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,45 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
|
||||
import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision";
|
||||
import { createQueuedUserSettingsSyncWithResponse } from "@/lib/user-settings-sync";
|
||||
import type { UserSettingsUpdatePayload } from "@/lib/types/http-user-settings";
|
||||
import { type TaskColor } from "@/lib/task-colors";
|
||||
|
||||
type TaskColorMap = Record<string, TaskColor | null>;
|
||||
|
||||
function cloneTaskColorMap(colors: TaskColorMap): TaskColorMap {
|
||||
return { ...colors };
|
||||
}
|
||||
|
||||
function optimisticTaskColorSettings(
|
||||
settings: ReturnType<ReturnType<typeof useAppStoreApi>["getState"]>["userSettings"],
|
||||
taskId: string,
|
||||
color: TaskColor | null,
|
||||
) {
|
||||
return {
|
||||
...settings,
|
||||
sidebarTaskColors: {
|
||||
...settings.sidebarTaskColors,
|
||||
[taskId]: color,
|
||||
},
|
||||
loaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
function responseIsCurrent(
|
||||
responseRevision: number | undefined,
|
||||
currentRevision: number | null,
|
||||
settingsAtSubmit: object,
|
||||
currentSettings: object,
|
||||
) {
|
||||
const order = compareUserSettingsRevisions(responseRevision, currentRevision);
|
||||
return order === null ? currentSettings === settingsAtSubmit : order >= 0;
|
||||
}
|
||||
import { getTaskColorMutation, type TaskColorResult } from "./task-color-mutation";
|
||||
|
||||
export function useTaskColor(taskId: string | undefined): TaskColor | null {
|
||||
return useAppStore((state) => {
|
||||
@@ -48,101 +14,52 @@ export function useTaskColor(taskId: string | undefined): TaskColor | null {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetTaskColor(): (taskId: string, color: TaskColor | null) => void {
|
||||
function useColorMutation() {
|
||||
const store = useAppStoreApi();
|
||||
const settings = useAppStore((state) => state.userSettings);
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const confirmedColorsRef = useRef<TaskColorMap>(cloneTaskColorMap(settings.sidebarTaskColors));
|
||||
const confirmedRevisionRef = useRef<number | null>(settings.revision);
|
||||
const operationRef = useRef(0);
|
||||
const pendingRef = useRef(0);
|
||||
const sync = useMemo(
|
||||
() =>
|
||||
createQueuedUserSettingsSyncWithResponse<{
|
||||
taskId: string;
|
||||
color: TaskColor | null;
|
||||
}>(
|
||||
({ taskId, color }): UserSettingsUpdatePayload => ({
|
||||
sidebar_task_color_patch: {
|
||||
colors: { [taskId]: color },
|
||||
if_missing: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const current = store.getState().userSettings;
|
||||
const order = compareUserSettingsRevisions(current.revision, confirmedRevisionRef.current);
|
||||
if (pendingRef.current === 0) {
|
||||
if (order === -1) return;
|
||||
confirmedColorsRef.current = cloneTaskColorMap(current.sidebarTaskColors);
|
||||
confirmedRevisionRef.current = current.revision;
|
||||
return;
|
||||
}
|
||||
if (order === 1) {
|
||||
confirmedColorsRef.current = cloneTaskColorMap(current.sidebarTaskColors);
|
||||
confirmedRevisionRef.current = current.revision;
|
||||
}
|
||||
}, [settings, store]);
|
||||
|
||||
return useCallback(
|
||||
(taskId: string, color: TaskColor | null) => {
|
||||
if (!taskId) return;
|
||||
const operation = ++operationRef.current;
|
||||
pendingRef.current += 1;
|
||||
const settingsAtSubmit = store.getState().userSettings;
|
||||
store
|
||||
.getState()
|
||||
.setUserSettings(optimisticTaskColorSettings(settingsAtSubmit, taskId, color));
|
||||
|
||||
void sync({ taskId, color })
|
||||
.then((response) => {
|
||||
const latest = store.getState().userSettings;
|
||||
const mapped = mapUserSettingsResponse(response, latest);
|
||||
const responseOrder = compareUserSettingsRevisions(
|
||||
mapped.revision,
|
||||
confirmedRevisionRef.current,
|
||||
);
|
||||
if (responseOrder !== 1) return;
|
||||
|
||||
confirmedColorsRef.current = cloneTaskColorMap(mapped.sidebarTaskColors);
|
||||
confirmedRevisionRef.current = mapped.revision;
|
||||
|
||||
if (
|
||||
operation !== operationRef.current ||
|
||||
!responseIsCurrent(
|
||||
response.settings.revision,
|
||||
latest.revision,
|
||||
settingsAtSubmit,
|
||||
latest,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
store.getState().setUserSettings(mapped);
|
||||
})
|
||||
.catch(() => {
|
||||
if (operation !== operationRef.current) return;
|
||||
const latest = store.getState().userSettings;
|
||||
const order = compareUserSettingsRevisions(latest.revision, confirmedRevisionRef.current);
|
||||
if (order === 1) {
|
||||
confirmedColorsRef.current = cloneTaskColorMap(latest.sidebarTaskColors);
|
||||
confirmedRevisionRef.current = latest.revision;
|
||||
}
|
||||
store.getState().setUserSettings({
|
||||
...latest,
|
||||
sidebarTaskColors: cloneTaskColorMap(confirmedColorsRef.current),
|
||||
loaded: true,
|
||||
});
|
||||
toast({ variant: "error", description: t("task:manualColorSaveError") });
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRef.current -= 1;
|
||||
});
|
||||
async (ids: string[], color: TaskColor | null) => {
|
||||
const result = await getTaskColorMutation(store)(ids, color);
|
||||
if (result.saved < result.total) {
|
||||
const description =
|
||||
result.saved === 0
|
||||
? t("task:manualColorSaveError")
|
||||
: t("task:bulkColorPartialError", { saved: result.saved, total: result.total });
|
||||
toast({ variant: "error", description });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[store, sync, t, toast],
|
||||
[store, t, toast],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSetTaskColor(): (taskId: string, color: TaskColor | null) => void {
|
||||
const mutate = useColorMutation();
|
||||
return useCallback(
|
||||
(taskId, color) => {
|
||||
void mutate([taskId], color);
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSetTaskColors() {
|
||||
const mutate = useColorMutation();
|
||||
const [isPending, setPending] = useState(false);
|
||||
const inFlight = useRef<Promise<TaskColorResult> | null>(null);
|
||||
const setColors = useCallback(
|
||||
(ids: string[], color: TaskColor | null) => {
|
||||
if (inFlight.current) return inFlight.current;
|
||||
setPending(true);
|
||||
const promise = mutate([...ids], color).finally(() => {
|
||||
inFlight.current = null;
|
||||
setPending(false);
|
||||
});
|
||||
inFlight.current = promise;
|
||||
return promise;
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
return { setColors, isPending };
|
||||
}
|
||||
|
||||
@@ -129,5 +129,10 @@
|
||||
"boardSort": "Board sort",
|
||||
"boardSortCreatedDesc": "Newest first",
|
||||
"boardSortPriorityDesc": "Priority",
|
||||
"priorityFilter": "Priority filter"
|
||||
"priorityFilter": "Priority filter",
|
||||
"bulkSelected_one": "{{count}} selected",
|
||||
"bulkSelected_other": "{{count}} selected",
|
||||
"bulkActions": "Actions",
|
||||
"selectTasks": "Select tasks",
|
||||
"cancelTaskSelection": "Cancel selection"
|
||||
}
|
||||
|
||||
@@ -2374,5 +2374,10 @@
|
||||
"toolPayloadRemovedAt": "Tool details removed on {{date}}.",
|
||||
"toolPayloadRemoved": "Tool details removed.",
|
||||
"openTaskSwitcherFor": "Open task switcher: {{title}}",
|
||||
"bulkColorPartialError": "Colors saved for {{saved}} of {{total}} tasks. Try again for the rest.",
|
||||
"bulkColorSaving": "Saving colors...",
|
||||
"bulkColorAutomaticHint": "Automatic rules may override the visible color.",
|
||||
"bulkColorTitle_one": "Color for {{count}} task",
|
||||
"bulkColorTitle_other": "Color for {{count}} tasks",
|
||||
"editorActions": "Editor and folder actions"
|
||||
}
|
||||
|
||||
@@ -129,5 +129,10 @@
|
||||
"boardSort": "ボードの並び順",
|
||||
"boardSortCreatedDesc": "新しい順",
|
||||
"boardSortPriorityDesc": "優先度順",
|
||||
"priorityFilter": "優先度フィルタ"
|
||||
"priorityFilter": "優先度フィルタ",
|
||||
"bulkSelected_one": "{{count}}件選択中",
|
||||
"bulkSelected_other": "{{count}}件選択中",
|
||||
"bulkActions": "操作",
|
||||
"selectTasks": "タスクを選択",
|
||||
"cancelTaskSelection": "選択をキャンセル"
|
||||
}
|
||||
|
||||
@@ -2374,5 +2374,10 @@
|
||||
"toolPayloadRemovedAt": "ツール詳細は {{date}} に削除済み。",
|
||||
"toolPayloadRemoved": "ツール詳細は削除済み。",
|
||||
"openTaskSwitcherFor": "タスク切替を開く: {{title}}",
|
||||
"bulkColorPartialError": "全{{total}}件のうち{{saved}}件のタスクの色を保存しました。残りを再試行してください。",
|
||||
"bulkColorSaving": "色を保存中...",
|
||||
"bulkColorAutomaticHint": "自動ルールにより表示色が上書きされる場合があります。",
|
||||
"bulkColorTitle_one": "{{count}}件のタスクの色",
|
||||
"bulkColorTitle_other": "{{count}}件のタスクの色",
|
||||
"editorActions": "エディターとフォルダーの操作"
|
||||
}
|
||||
|
||||
@@ -129,5 +129,10 @@
|
||||
"boardSort": "Ɓōàŕď śōŕţ",
|
||||
"boardSortCreatedDesc": "Ńēŵēśţ ƒĩŕśţ",
|
||||
"boardSortPriorityDesc": "Ƥŕĩōŕĩţŷ",
|
||||
"priorityFilter": "Ƥŕĩōŕĩţŷ ƒĩĺţēŕ"
|
||||
"priorityFilter": "Ƥŕĩōŕĩţŷ ƒĩĺţēŕ",
|
||||
"bulkSelected_one": "{{count}} śēĺēćţēď",
|
||||
"bulkSelected_other": "{{count}} śēĺēćţēď",
|
||||
"bulkActions": "Àćţĩōńś",
|
||||
"selectTasks": "Śēĺēćţ ţàśķś",
|
||||
"cancelTaskSelection": "Ćàńćēĺ śēĺēćţĩōń"
|
||||
}
|
||||
|
||||
@@ -2374,5 +2374,10 @@
|
||||
"toolPayloadRemovedAt": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď ōń {{date}}.",
|
||||
"toolPayloadRemoved": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď.",
|
||||
"openTaskSwitcherFor": "Ōƥēń ţàśķ śŵĩţćĥēŕ: {{title}}",
|
||||
"bulkColorPartialError": "Ćōĺōŕś śàvēď ƒōŕ {{saved}} ōƒ {{total}} ţàśķś. Ţŕŷ àĝàĩń ƒōŕ ţĥē ŕēśţ.",
|
||||
"bulkColorSaving": "Śàvĩńĝ ćōĺōŕś...",
|
||||
"bulkColorAutomaticHint": "Àũţōḿàţĩć ŕũĺēś ḿàŷ ōvēŕŕĩďē ţĥē vĩśĩƀĺē ćōĺōŕ.",
|
||||
"bulkColorTitle_one": "Ćōĺōŕ ƒōŕ {{count}} ţàśķ",
|
||||
"bulkColorTitle_other": "Ćōĺōŕ ƒōŕ {{count}} ţàśķś",
|
||||
"editorActions": "Ēďĩţōŕ àńď ƒōĺďēŕ àćţĩōńś"
|
||||
}
|
||||
|
||||
@@ -129,5 +129,10 @@
|
||||
"boardSort": "Ordenação do quadro",
|
||||
"boardSortCreatedDesc": "Mais recentes primeiro",
|
||||
"boardSortPriorityDesc": "Prioridade",
|
||||
"priorityFilter": "Filtro de prioridade"
|
||||
"priorityFilter": "Filtro de prioridade",
|
||||
"bulkSelected_one": "{{count}} selecionada",
|
||||
"bulkSelected_other": "{{count}} selecionadas",
|
||||
"bulkActions": "Ações",
|
||||
"selectTasks": "Selecionar tarefas",
|
||||
"cancelTaskSelection": "Cancelar seleção"
|
||||
}
|
||||
|
||||
@@ -2374,5 +2374,10 @@
|
||||
"toolPayloadRemovedAt": "Detalhes da ferramenta removidos em {{date}}.",
|
||||
"toolPayloadRemoved": "Detalhes da ferramenta removidos.",
|
||||
"openTaskSwitcherFor": "Abrir seletor de tarefas: {{title}}",
|
||||
"bulkColorPartialError": "Cores guardadas para {{saved}} de {{total}} tarefas. Tente novamente para as restantes.",
|
||||
"bulkColorSaving": "A guardar cores...",
|
||||
"bulkColorAutomaticHint": "As regras automáticas podem substituir a cor visível.",
|
||||
"bulkColorTitle_one": "Cor para {{count}} tarefa",
|
||||
"bulkColorTitle_other": "Cor para {{count}} tarefas",
|
||||
"editorActions": "Ações do editor e da pasta"
|
||||
}
|
||||
|
||||
@@ -129,5 +129,10 @@
|
||||
"boardSort": "看板排序",
|
||||
"boardSortCreatedDesc": "最新优先",
|
||||
"boardSortPriorityDesc": "优先级",
|
||||
"priorityFilter": "优先级筛选"
|
||||
"priorityFilter": "优先级筛选",
|
||||
"bulkSelected_other": "已选择 {{count}} 个",
|
||||
"bulkActions": "操作",
|
||||
"selectTasks": "选择任务",
|
||||
"cancelTaskSelection": "取消选择",
|
||||
"bulkSelected_one": "已选择 {{count}} 个"
|
||||
}
|
||||
|
||||
@@ -2374,5 +2374,10 @@
|
||||
"toolPayloadRemovedAt": "工具详细数据已于 {{date}} 删除。",
|
||||
"toolPayloadRemoved": "工具详细数据已删除。",
|
||||
"openTaskSwitcherFor": "打开任务切换器:{{title}}",
|
||||
"bulkColorPartialError": "已保存 {{total}} 个任务中 {{saved}} 个任务的颜色。请重试其余任务。",
|
||||
"bulkColorSaving": "正在保存颜色...",
|
||||
"bulkColorAutomaticHint": "自动规则可能覆盖显示的颜色。",
|
||||
"bulkColorTitle_other": "为 {{count}} 个任务设置颜色",
|
||||
"bulkColorTitle_one": "为 {{count}} 个任务设置颜色",
|
||||
"editorActions": "编辑器和文件夹操作"
|
||||
}
|
||||
|
||||
@@ -129,5 +129,10 @@
|
||||
"boardSort": "看板排序",
|
||||
"boardSortCreatedDesc": "最新優先",
|
||||
"boardSortPriorityDesc": "優先級",
|
||||
"priorityFilter": "優先級篩選"
|
||||
"priorityFilter": "優先級篩選",
|
||||
"bulkSelected_other": "已選擇 {{count}} 個",
|
||||
"bulkActions": "操作",
|
||||
"selectTasks": "選擇任務",
|
||||
"cancelTaskSelection": "取消選擇",
|
||||
"bulkSelected_one": "已選擇 {{count}} 個"
|
||||
}
|
||||
|
||||
@@ -2374,5 +2374,10 @@
|
||||
"toolPayloadRemovedAt": "工具詳細數據已於 {{date}} 刪除。",
|
||||
"toolPayloadRemoved": "工具詳細數據已刪除。",
|
||||
"openTaskSwitcherFor": "開啟任務切換器:{{title}}",
|
||||
"bulkColorPartialError": "已儲存 {{total}} 個任務中 {{saved}} 個任務的顏色。請重試其餘任務。",
|
||||
"bulkColorSaving": "正在儲存顏色...",
|
||||
"bulkColorAutomaticHint": "自動規則可能覆蓋顯示的顏色。",
|
||||
"bulkColorTitle_other": "為 {{count}} 個任務設定顏色",
|
||||
"bulkColorTitle_one": "為 {{count}} 個任務設定顏色",
|
||||
"editorActions": "編輯器和資料夾操作"
|
||||
}
|
||||
|
||||
@@ -129,5 +129,10 @@
|
||||
"boardSort": "看板排序",
|
||||
"boardSortCreatedDesc": "最新優先",
|
||||
"boardSortPriorityDesc": "優先順序",
|
||||
"priorityFilter": "優先順序篩選"
|
||||
"priorityFilter": "優先順序篩選",
|
||||
"bulkSelected_other": "已選擇 {{count}} 個",
|
||||
"bulkActions": "操作",
|
||||
"selectTasks": "選擇任務",
|
||||
"cancelTaskSelection": "取消選擇",
|
||||
"bulkSelected_one": "已選擇 {{count}} 個"
|
||||
}
|
||||
|
||||
@@ -2374,5 +2374,10 @@
|
||||
"toolPayloadRemovedAt": "工具詳細資料已於 {{date}} 刪除。",
|
||||
"toolPayloadRemoved": "工具詳細資料已刪除。",
|
||||
"openTaskSwitcherFor": "開啟任務切換器:{{title}}",
|
||||
"bulkColorPartialError": "已儲存 {{total}} 個任務中 {{saved}} 個任務的顏色。請重試其餘任務。",
|
||||
"bulkColorSaving": "正在儲存顏色...",
|
||||
"bulkColorAutomaticHint": "自動規則可能覆蓋顯示的顏色。",
|
||||
"bulkColorTitle_other": "為 {{count}} 個任務設定顏色",
|
||||
"bulkColorTitle_one": "為 {{count}} 個任務設定顏色",
|
||||
"editorActions": "編輯器和資料夾操作"
|
||||
}
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
---
|
||||
status: draft
|
||||
created: 2026-09-19
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/bulk-task-colors.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Bulk task colors
|
||||
|
||||
## Outcome and scope
|
||||
|
||||
Add personal Color actions to the sidebar bulk menu and board selection toolbar.
|
||||
Reuse existing palette, persistence, and automatic-rule precedence. Phone users
|
||||
perform the same operation from board selection. This package stops at design;
|
||||
implementation requires a later explicit request. No production code changed.
|
||||
Adds personal Color actions to the sidebar bulk menu and board selection toolbar.
|
||||
Reuses the existing palette, persistence, and automatic-rule precedence. Phone users
|
||||
perform the same operation from board selection. The implementation reuses the existing personal settings patch contract and preserves automatic-rule precedence.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Requirements](../../specs/ui/requirements/bulk-task-colors.md): REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006, draft extension.
|
||||
- [System design](../../specs/ui/system-design/bulk-task-colors.md#bulk-manual-color-editing): draft bulk design extending the current personal-color contract.
|
||||
- [Requirements](../../specs/ui/requirements/bulk-task-colors.md): REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006, active extension.
|
||||
- [System design](../../specs/ui/system-design/bulk-task-colors.md#bulk-manual-color-editing): current bulk design extending the current personal-color contract.
|
||||
- [ADR 0041](../../decisions/0041-backend-owned-portable-user-settings.md): reuse portable personal settings ownership; no new ADR needed.
|
||||
|
||||
## Work package
|
||||
|
||||
| Order | Work order | Status | Dependencies |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [Task 01: Bulk color editing](task-01-bulk-color-editing.md) | pending | None |
|
||||
| Order | Work order | Status | Dependencies |
|
||||
| ----- | ------------------------------------------------------------ | ------ | ------------ |
|
||||
| 1 | [Task 01: Bulk color editing](task-01-bulk-color-editing.md) | done | None |
|
||||
|
||||
One vertical slice, sequential execution. No subagents are authorized.
|
||||
Estimated implementation and targeted checks: 3-5 hours, subject to E2E setup.
|
||||
@@ -107,14 +112,8 @@ Validate phone geometry at 393px and toolbar composition on either side of
|
||||
|
||||
## Documentation
|
||||
|
||||
/docs-maintainer assessment: this turn changes design only, so public docs stay
|
||||
unchanged. Implementation adds a short how-to section to the existing task guide
|
||||
chosen from docs/public after searching current task navigation documentation.
|
||||
Public docs updated: `docs/public/tasks-and-workflows.md` now explains desktop and phone bulk color selection, clearing, automatic-rule precedence, and retry behavior.
|
||||
|
||||
## Results
|
||||
|
||||
Review remediation: add an explicit phone selection trigger and move the new
|
||||
contract into draft artifacts, preserving the shipped personal-color specs.
|
||||
Document catalog validation, all 36 specification-linter tests, full specification
|
||||
lint, and diff whitespace validation passed after these corrections.
|
||||
Implementation and rendered verification remain pending.
|
||||
Implemented the shared mutation coordinator, sidebar and board Color controls, and the phone-native selection and picker flow. Focused unit/component tests passed (50), desktop Playwright passed (24), and mobile Playwright passed (2). Typecheck, focused ESLint, i18n validation, public-doc validation (62 tests and 47 pages), document catalog validation, all 36 specification-linter tests, full specification lint, and diff whitespace validation passed. PR remediation added regressions for in-flight optimistic colors, superseded save counts, focus restoration, the persistent live region, and cleared mobile action state. The 393 px phone render matched the planned inset picker with labeled touch rows, safe containment, and no horizontal overflow.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: "01-bulk-color-editing"
|
||||
title: "Apply personal colors to multi-selected tasks"
|
||||
status: pending
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
@@ -189,4 +189,6 @@ and mobile-sidebar-task-color-sync E2E.
|
||||
|
||||
## Results
|
||||
|
||||
Pending. Record each command result and rendered phone comparison after execution.
|
||||
Implemented bulk manual colors across sidebar and board selection. A shared per-store mutation coordinator deduplicates IDs, sends sequential 500-ID patches, preserves overlapping newer edits, retains confirmed chunks after partial failure, and restores only unsaved owned entries. The desktop and phone pickers preserve selection, expose pending and retry feedback, and keep automatic-rule precedence.
|
||||
|
||||
Verification passed: 50 focused Vitest tests; TypeScript typecheck; focused ESLint; complete i18n validation; 24 desktop Chromium E2E tests; 2 mobile Chrome E2E tests; 62 public-doc validator tests plus all 47 published pages; document catalog validation; 36 specification-linter tests; full specification lint; and `git diff --check`. PR remediation added regressions for in-flight optimistic colors, superseded save counts, focus restoration, the persistent live region, and cleared mobile action state. The inspected 393 px phone screenshot matched the planned inset drawer, labeled 44 px rows, single scrolling surface, focus-return behavior, and viewport containment.
|
||||
|
||||
@@ -627,6 +627,16 @@ On desktop and tablet, drag a card up or down within its column to reorder it re
|
||||
|
||||
On phones, Kanban focuses one workflow and one step at a time. The board navigator always names both; open it to choose either level, or use the previous/next controls and horizontal swipe to move between steps. Choosing a workflow makes it the active workflow for board actions and task creation. Tap a card to open that task directly. Its **More options** menu opens as a touch-sized bottom surface; **Move to** changes the task's workflow or step. **Edit** can still rename a task after work starts, while its original prompt remains locked.
|
||||
|
||||
### Color several tasks
|
||||
|
||||
Use a bulk color when a group of tasks should share the same personal marker:
|
||||
|
||||
1. On desktop, select sidebar rows with `Cmd/Ctrl`-click or select cards on the board. On a phone, choose **Select tasks** above the Kanban board, then tap the cards.
|
||||
2. Open **Color** from the menu for a selected sidebar row or from the board selection bar.
|
||||
3. Choose one of the seven colors. Choose **None** to clear existing manual colors from the selection.
|
||||
|
||||
The selection and active task remain in place, and the colors persist across reloads. Automatic color rules still take precedence over a manual color in the visible sidebar marker; the picker explains this while preserving the saved manual choice. If part of a large selection cannot be saved, Kandev keeps the completed changes, restores the unsaved tasks, and leaves the selection available to retry.
|
||||
|
||||
Regular Kanban does not currently expose label editing or label filters. Do not design a supported Kanban process around labels.
|
||||
|
||||
<details>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: draft
|
||||
status: active
|
||||
system: ui
|
||||
created: 2026-09-19
|
||||
owners:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
status: draft
|
||||
status: current
|
||||
system: ui
|
||||
requirements:
|
||||
- REQ-UI-SIDEBAR-AUTOMATIC-TASK-COLORS-006
|
||||
@@ -15,7 +15,7 @@ requirements:
|
||||
|
||||
## Bulk manual color editing
|
||||
|
||||
This draft extends the [current personal-color design](sidebar-automatic-task-colors.md).
|
||||
This design extends the [current personal-color design](sidebar-automatic-task-colors.md).
|
||||
Delivery is tracked in the
|
||||
[bulk color plan](../../../plans/bulk-task-colors/plan.md). UI remains the owner
|
||||
because colors are personal presentation preferences, not shared task metadata.
|
||||
|
||||
Reference in New Issue
Block a user