Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import type { ProjectWithTasks } from "@/types/project";
import TaskCard from "../task-card";

type ColumnDropzoneProps = {
isDragPreview?: boolean;
column: ProjectWithTasks["columns"][number];
disableDragDrop?: boolean;
onIsOverChange?: (isOver: boolean) => void;
};

export function ColumnDropzone({
column,
isDragPreview = false,
disableDragDrop = false,
onIsOverChange,
}: ColumnDropzoneProps) {
Expand Down Expand Up @@ -44,6 +46,7 @@ export function ColumnDropzone({
{column.tasks.map((task) => (
<motion.div
key={task.id}
layout={reduceMotion ? false : "position"}
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }
}
Expand All @@ -55,7 +58,11 @@ export function ColumnDropzone({
}
transition={{ type: "spring", duration: 0.35, bounce: 0.15 }}
>
<TaskCard task={task} disableDragDrop={disableDragDrop} />
<TaskCard
task={task}
disableDragDrop={disableDragDrop}
isDragPreview={isDragPreview}
/>
</motion.div>
))}
</AnimatePresence>
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/components/kanban-board/column/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { cva } from "class-variance-authority";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useBackgroundStore } from "@/store/background";
import type { ProjectWithTasks } from "@/types/project";
import { ColumnDropzone } from "./column-dropzone";
import { ColumnHeader } from "./column-header";

type ColumnProps = {
isDragPreview?: boolean;
column: ProjectWithTasks["columns"][number];
activeTaskId?: string | null;
sourceColumnId?: string;
isPriorityOverlaySuppressed?: boolean;
priorityOverlayColumnId?: string | null;
disableDragDrop?: boolean;
disableCollectionActions?: boolean;
};
Expand Down Expand Up @@ -55,11 +61,23 @@ export const columnVariants = cva(

function Column({
column,
isDragPreview = false,
activeTaskId = null,
sourceColumnId,
isPriorityOverlaySuppressed = false,
priorityOverlayColumnId = null,
disableDragDrop = false,
disableCollectionActions = false,
}: ColumnProps) {
const [isDropzoneOver, setIsDropzoneOver] = useState(false);
const { background } = useBackgroundStore();
const { t } = useTranslation();
const isActiveTaskColumn = column.id === sourceColumnId;
const showPriorityOverlay =
priorityOverlayColumnId === column.id &&
activeTaskId !== null &&
!isActiveTaskColumn &&
!isPriorityOverlaySuppressed;

return (
<div
Expand All @@ -77,10 +95,18 @@ function Column({
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 pt-1 pb-2 [-webkit-overflow-scrolling:touch]">
<ColumnDropzone
column={column}
isDragPreview={isDragPreview}
disableDragDrop={disableDragDrop}
onIsOverChange={setIsDropzoneOver}
/>
</div>
{showPriorityOverlay && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/85 px-4 text-center">
<span className="text-sm font-medium text-neutral-950">
{t("tasks:kanban.priorityOrderedOverlayHint")}
</span>
</div>
)}
</div>
);
}
Expand Down
208 changes: 206 additions & 2 deletions apps/web/src/components/kanban-board/filtered-drag.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
it,
vi,
} from "vite-plus/test";
import type { ReactNode } from "react";
import type { ProjectWithTasks } from "@/types/project";
import KanbanBoard from "./index";

Expand Down Expand Up @@ -45,25 +46,103 @@ vi.mock("@/hooks/use-project-background", () => ({
useProjectBackground: () => null,
}));
vi.mock("../bulk-selection/bulk-toolbar", () => ({ default: () => null }));
vi.mock("./column", () => ({ default: () => null }));
vi.mock("./column", () => ({
default: ({
column,
isPriorityOverlaySuppressed,
priorityOverlayColumnId,
}: {
column: { id: string; tasks: { id: string }[] };
isPriorityOverlaySuppressed: boolean;
priorityOverlayColumnId: string | null;
}) => (
<div
data-testid={`column-${column.id}`}
data-task-ids={column.tasks.map((task) => task.id).join(",")}
data-priority-overlay={
priorityOverlayColumnId === column.id && !isPriorityOverlaySuppressed
? "visible"
: "hidden"
}
/>
),
}));
vi.mock("./task-card", () => ({ default: () => null }));
vi.mock("@dnd-kit/core", () => ({
DndContext: ({
children,
onDragStart,
onDragOver,
onDragEnd,
}: {
children: ReactNode;
onDragStart: (event: unknown) => void;
onDragOver: (event: unknown) => void;
onDragEnd: (event: unknown) => void;
}) => (
<>
<button onClick={() => onDragStart({ active: { id: "a" } })}>
{children}
<button
onClick={() =>
onDragStart({ active: { id: "a" }, activatorEvent: undefined })
}
>
start
</button>
<button
onClick={() =>
onDragOver({
active: {
id: "a",
rect: { current: { translated: { top: 20, height: 80 } } },
},
over: { id: "c", rect: { top: 100, height: 80 } },
})
}
>
over
</button>
<button
onClick={() =>
onDragOver({
active: {
id: "a",
rect: { current: { translated: { top: 110, height: 80 } } },
},
over: { id: "c", rect: { top: 100, height: 80 } },
})
}
>
over-center
</button>
<button
onClick={() =>
onDragOver({
active: {
id: "a",
rect: { current: { translated: { top: 20 } } },
},
over: { id: "a", rect: { top: 20, height: 80 } },
})
}
>
over-active
</button>
<button
onClick={() => onDragEnd({ active: { id: "a" }, over: { id: "b" } })}
>
drop
</button>
<button
onClick={() => onDragEnd({ active: { id: "a" }, over: { id: "a" } })}
>
drop-over
</button>
<button
onClick={() => onDragEnd({ active: { id: "a" }, over: { id: "c" } })}
>
drop-c
</button>
</>
),
DragOverlay: () => null,
Expand Down Expand Up @@ -144,3 +223,128 @@ it("rejects an in-flight Kanban drop after a failed refresh disables dragging",
expect(mocks.setProject).not.toHaveBeenCalled();
expect(mocks.setQueryData).not.toHaveBeenCalled();
});

it("places a cross-column drop at the bottom without Command", () => {
const canonical = {
id: "p",
columns: [
{
id: "todo",
slug: "todo",
tasks: [{ id: "a", status: "todo", position: 0, priority: "low" }],
},
{
id: "doing",
slug: "doing",
tasks: [
{ id: "c", status: "doing", position: 0, priority: "high" },
{ id: "d", status: "doing", position: 1, priority: "medium" },
],
},
],
plannedTasks: [],
archivedTasks: [],
} as unknown as ProjectWithTasks;
mocks.project = canonical;
const view = render(<KanbanBoard project={canonical} />);

fireEvent.click(view.getByText("start"));
fireEvent.click(view.getByText("drop-c"));

expect(
mocks.setProject.mock.calls[0][0].columns[1].tasks.map(
(task: { id: string }) => task.id,
),
).toEqual(["c", "d", "a"]);
expect(mocks.setProject.mock.calls[0][0].columns[1].tasks[2].priority).toBe(
"low",
);
});

it.each(["held", "released", "blurred"])(
"uses the matching drop behavior when Command is %s",
(commandState) => {
const canonical = {
id: "p",
columns: [
{
id: "todo",
slug: "todo",
tasks: [{ id: "a", status: "todo", position: 0, priority: "low" }],
},
{
id: "doing",
slug: "doing",
tasks: [{ id: "c", status: "doing", position: 0, priority: "high" }],
},
],
plannedTasks: [],
archivedTasks: [],
} as unknown as ProjectWithTasks;
mocks.project = canonical;
const view = render(<KanbanBoard project={canonical} />);

fireEvent.click(view.getByText("start"));
fireEvent.click(view.getByText("over"));
expect(view.getByTestId("column-doing")).toHaveAttribute(
"data-priority-overlay",
"visible",
);

fireEvent.keyDown(window, { key: "Meta", metaKey: true });
expect(view.getByTestId("column-doing")).toHaveAttribute(
"data-priority-overlay",
"hidden",
);
expect(view.getByTestId("column-doing")).toHaveAttribute(
"data-task-ids",
"a,c",
);

if (commandState === "held") {
fireEvent.click(view.getByText("over-center"));
expect(view.getByTestId("column-doing")).toHaveAttribute(
"data-task-ids",
"c,a",
);
fireEvent.click(view.getByText("over-active"));
fireEvent.click(view.getByText("drop-over"));
expect(
mocks.setProject.mock.calls[0][0].columns[1].tasks.map(
(task: { id: string }) => task.id,
),
).toEqual(["c", "a"]);
return;
}

if (commandState === "released") {
fireEvent.keyUp(window, { key: "Meta", metaKey: false });
} else {
fireEvent.blur(window);
}
expect(view.getByTestId("column-doing")).toHaveAttribute(
"data-priority-overlay",
"visible",
);
expect(view.getByTestId("column-doing")).toHaveAttribute(
"data-task-ids",
"c",
);

fireEvent.click(view.getByText("over-active"));
expect(view.getByTestId("column-doing")).toHaveAttribute(
"data-task-ids",
"c",
);

fireEvent.click(view.getByText("drop-over"));
expect(
mocks.setProject.mock.calls[0][0].columns[1].tasks.map(
(task: { id: string }) => task.id,
),
).toEqual(["c", "a"]);
expect(mocks.setProject.mock.calls[0][0].columns[1].tasks[1].priority).toBe(
"low",
);
},
);
Loading