Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
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
14 changes: 7 additions & 7 deletions apps/web/src/components/kanban-board/column/column-dropzone.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useDroppable } from "@dnd-kit/core";
import { useDndContext, useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
Expand Down Expand Up @@ -32,6 +32,8 @@ export function ColumnDropzone({
}, [isOver, onIsOverChange]);

const reduceMotion = useReducedMotion();
const { active } = useDndContext();
const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 };

return (
<div ref={setNodeRef} className="flex-1 min-h-0">
Expand All @@ -44,15 +46,13 @@ export function ColumnDropzone({
{column.tasks.map((task) => (
<motion.div
key={task.id}
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }
}
// The dragged card only changes columns in the drag preview,
// so it must not fade out of one column and into the next.
initial={task.id === active?.id ? false : hidden}
animate={
reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }
}
exit={
reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }
}
exit={task.id === active?.id ? undefined : hidden}
transition={{ type: "spring", duration: 0.35, bounce: 0.15 }}
>
<TaskCard
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/components/kanban-board/column/column-sort-hint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function ColumnSortHint({ label }: { label: string }) {
return (
<div
role="status"
className="pointer-events-none absolute inset-x-3 bottom-3 z-10 rounded-lg border border-border bg-popover/95 px-3 py-2 text-center text-xs font-medium text-popover-foreground shadow-md backdrop-blur-sm"
>
{label}
</div>
);
}
4 changes: 4 additions & 0 deletions apps/web/src/components/kanban-board/column/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import type { ProjectWithTasks } from "@/types/project";
import { ColumnAddTask } from "./column-add-task";
import { ColumnDropzone } from "./column-dropzone";
import { ColumnHeader } from "./column-header";
import { ColumnSortHint } from "./column-sort-hint";

type ColumnProps = {
column: ProjectWithTasks["columns"][number];
sortHint?: string;
disableDragDrop?: boolean;
disableCollectionActions?: boolean;
};
Expand Down Expand Up @@ -56,6 +58,7 @@ export const columnVariants = cva(

function Column({
column,
sortHint,
disableDragDrop = false,
disableCollectionActions = false,
}: ColumnProps) {
Expand Down Expand Up @@ -84,6 +87,7 @@ function Column({
{/* New work starts in an open column; finished ones only collect. */}
{!column.isFinal && <ColumnAddTask columnId={column.id} />}
</div>
{sortHint && <ColumnSortHint label={sortHint} />}
</div>
);
}
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/kanban-board/drag-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Active, Over } from "@dnd-kit/core";
import type { ProjectWithTasks } from "@/types/project";
import { getVisualTaskPlacement, moveBoardTask } from "./move-task";

type DragItem = Pick<Active | Over, "id">;

function findColumn(board: ProjectWithTasks, id: string) {
return board.columns.find(
(column) => column.id === id || column.tasks.some((task) => task.id === id),
);
}

export function getHoveredOtherColumnId(
board: ProjectWithTasks,
activeId: string,
overId: string,
) {
const from = findColumn(board, activeId);
const to = findColumn(board, overId);
return from && to && from.id !== to.id ? to.id : null;
}

// The card takes the hovered card's slot. Sortable transforms then move it
// above or below as the pointer travels, the same as within one column.
export function moveIntoHoveredColumn(
board: ProjectWithTasks,
active: DragItem,
over: DragItem,
) {
const activeId = active.id.toString();
const overId = over.id.toString();
if (!getHoveredOtherColumnId(board, activeId, overId)) return null;
return moveBoardTask(board, activeId, overId, false, false)?.project ?? null;
}

// Applies the last sortable hover, which only exists as transforms, so the
// drop lands exactly where the card was shown.
export function getPreviewDropPlacement(
board: ProjectWithTasks,
active: DragItem,
over: DragItem,
) {
const activeId = active.id.toString();
const overId = over.id.toString();
const settled =
moveIntoHoveredColumn(board, active, over) ??
(overId === activeId
? board
: (moveBoardTask(board, activeId, overId)?.project ?? board));
return getVisualTaskPlacement(settled, activeId);
}
190 changes: 176 additions & 14 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 { TaskReorder } from "@/fetchers/task/reorder-tasks";
import type { ProjectWithTasks } from "@/types/project";
import KanbanBoard from "./index";
Expand Down Expand Up @@ -39,7 +40,10 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("@kaneo/libs", () => ({ client: {} }));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, options?: { sort?: string }) =>
options?.sort ? `${key}:${options.sort}` : key,
}),
}));
vi.mock("@/store/project", () => ({
default: () => ({ project: mocks.project, setProject: mocks.setProject }),
Expand All @@ -54,27 +58,55 @@ 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,
sortHint,
}: {
column: { id: string; tasks: { id: string }[] };
sortHint?: string;
}) => (
<div
data-testid={`column-${column.id}`}
data-task-ids={column.tasks.map((task) => task.id).join(",")}
data-sort-hint={sortHint}
/>
),
}));
vi.mock("./task-card", () => ({ default: () => null }));
vi.mock("@dnd-kit/core", () => ({
DndContext: ({
children,
onDragStart,
onDragOver,
onDragEnd,
onDragCancel,
}: {
children: ReactNode;
onDragStart: (event: unknown) => void;
onDragOver: (event: unknown) => void;
onDragEnd: (event: unknown) => void;
}) => (
<>
<button onClick={() => onDragStart({ active: { id: "a" } })}>
start
</button>
<button
onClick={() => onDragEnd({ active: { id: "a" }, over: { id: "b" } })}
>
drop
</button>
</>
),
onDragCancel: () => void;
}) => {
const hover = (overId: string) => () =>
onDragOver({ active: { id: "a" }, over: { id: overId } });
const drop = (overId: string) => () =>
onDragEnd({ active: { id: "a" }, over: { id: overId } });
return (
<>
{children}
<button onClick={() => onDragStart({ active: { id: "a" } })}>
start
</button>
<button onClick={hover("c")}>over-c</button>
<button onClick={hover("todo")}>over-todo</button>
<button onClick={drop("b")}>drop</button>
<button onClick={drop("a")}>drop-on-card</button>
<button onClick={drop("c")}>drop-c</button>
<button onClick={() => onDragCancel()}>cancel</button>
</>
);
},
DragOverlay: () => null,
MouseSensor: {},
TouchSensor: {},
Expand Down Expand Up @@ -197,3 +229,133 @@ it("refreshes personal work after a cross-column status change", () => {
queryKey: ["projects"],
});
});

function crossColumnBoard() {
return {
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;
}

function shownIds(view: ReturnType<typeof render>, columnId: string) {
return view.getByTestId(`column-${columnId}`).getAttribute("data-task-ids");
}

function savedIds(columnIndex: number) {
return mocks.setProject.mock.calls[0][0].columns[columnIndex].tasks.map(
(task: { id: string }) => task.id,
);
}

describe("cross-column dragging", () => {
it("previews the card in the hovered card's slot and drops it there", () => {
const project = crossColumnBoard();
mocks.project = project;
const view = render(<KanbanBoard project={project} />);

fireEvent.click(view.getByText("start"));
fireEvent.click(view.getByText("over-c"));
expect(shownIds(view, "todo")).toBe("");
expect(shownIds(view, "doing")).toBe("a,c,d");

fireEvent.click(view.getByText("drop-on-card"));
expect(savedIds(1)).toEqual(["a", "c", "d"]);
expect(mocks.setProject.mock.calls[0][0].columns[1].tasks[0].priority).toBe(
"low",
);
expect(mocks.reorder).toHaveBeenCalledOnce();
expect(mocks.reorder.mock.calls[0][0].tasks).toContainEqual({
id: "a",
position: 0,
status: "doing",
});
expect(shownIds(view, "doing")).toBe("c,d");
});

it("applies a final sortable hover inside the destination column", () => {
const project = crossColumnBoard();
mocks.project = project;
const view = render(<KanbanBoard project={project} />);

fireEvent.click(view.getByText("start"));
fireEvent.click(view.getByText("over-c"));
fireEvent.click(view.getByText("drop-c"));
expect(savedIds(1)).toEqual(["c", "a", "d"]);
});

it("leaves the board unchanged when the card returns to its column", () => {
const project = crossColumnBoard();
mocks.project = project;
const view = render(<KanbanBoard project={project} />);

fireEvent.click(view.getByText("start"));
fireEvent.click(view.getByText("over-c"));
fireEvent.click(view.getByText("over-todo"));
expect(shownIds(view, "todo")).toBe("a");
expect(shownIds(view, "doing")).toBe("c,d");

fireEvent.click(view.getByText("drop-on-card"));
expect(mocks.setProject).not.toHaveBeenCalled();
expect(mocks.reorder).not.toHaveBeenCalled();
});

it("restores the board when the drag is cancelled", () => {
const project = crossColumnBoard();
mocks.project = project;
const view = render(<KanbanBoard project={project} />);

fireEvent.click(view.getByText("start"));
fireEvent.click(view.getByText("over-c"));
fireEvent.click(view.getByText("cancel"));
expect(shownIds(view, "todo")).toBe("a");
expect(shownIds(view, "doing")).toBe("c,d");
expect(mocks.reorder).not.toHaveBeenCalled();
});
});

describe.each([
["sortedByNumber", "tasks:sort.fields.number"],
["sortedByPriority", "tasks:sort.fields.priority"],
])("%s boards", (sortProp, sortLabel) => {
it("hints at the sort and appends the dropped card", () => {
const project = crossColumnBoard();
mocks.project = project;
const view = render(
<KanbanBoard project={project} {...{ [sortProp]: true }} />,
);

fireEvent.click(view.getByText("start"));
fireEvent.click(view.getByText("over-c"));
expect(shownIds(view, "doing")).toBe("c,d");
expect(view.getByTestId("column-doing")).toHaveAttribute(
"data-sort-hint",
`tasks:kanban.automaticallySortedHint:${sortLabel}`,
);
expect(view.getByTestId("column-todo")).not.toHaveAttribute(
"data-sort-hint",
);

fireEvent.click(view.getByText("drop-c"));
expect(savedIds(1)).toEqual(["c", "d", "a"]);
expect(mocks.reorder).toHaveBeenCalledOnce();
expect(view.getByTestId("column-doing")).not.toHaveAttribute(
"data-sort-hint",
);
});
});
Loading
Loading