Improve task management

This commit is contained in:
Arthur Belleville 2026-04-16 22:21:16 +02:00
parent 916dba496a
commit 740bedaf50
No known key found for this signature in database
4 changed files with 112 additions and 14 deletions

View file

@ -1,11 +1,24 @@
import { screen } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloDetailsPage } from "./tablo-details";
if (!HTMLElement.prototype.hasPointerCapture) {
HTMLElement.prototype.hasPointerCapture = () => false;
}
if (!HTMLElement.prototype.setPointerCapture) {
HTMLElement.prototype.setPointerCapture = () => undefined;
}
if (!HTMLElement.prototype.releasePointerCapture) {
HTMLElement.prototype.releasePointerCapture = () => undefined;
}
const mutateUpdateTablo = vi.fn();
const mutateUpdateTask = vi.fn();
const mutateCreateTask = vi.fn();
const mutateCreateEtape = vi.fn();
const mutateUpdateEtape = vi.fn();
const mutateDeleteEtape = vi.fn();
@ -47,7 +60,15 @@ vi.mock("../hooks/tablos", () => ({
isLoading: false,
}),
useTabloMembers: () => ({
data: [],
data: [
{
id: "user-1",
name: "Test User",
email: "john@example.com",
avatar_url: null,
is_admin: true,
},
],
}),
useTabloOverviewLayout: () => ({
data: layoutData,
@ -105,6 +126,7 @@ vi.mock("../hooks/tasks", () => ({
assignee_id: "user-1",
title: "Task A",
status: "todo",
parent_task_id: "etape-1",
},
],
}),
@ -148,7 +170,7 @@ vi.mock("../hooks/tasks", () => ({
isPending: false,
}),
useCreateTask: () => ({
mutate: vi.fn(),
mutate: mutateCreateTask,
}),
useUpdateTaskPositions: () => ({
mutate: vi.fn(),
@ -295,6 +317,50 @@ describe("TabloDetailsPage overview layout", () => {
expect(mutateDeleteTask).toHaveBeenCalledWith("task-1");
});
it("restores the task etape when reopening the same edit modal", async () => {
const user = userEvent.setup();
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1?section=tasks",
path: "/tablos/:tabloId",
});
await user.click(screen.getByText("Task A"));
await user.click(screen.getByRole("combobox", { name: "Étape" }));
await user.click(screen.getByRole("option", { name: "Aucune" }));
await user.click(screen.getByRole("button", { name: "Annuler" }));
await user.click(screen.getByText("Task A"));
expect(screen.getByRole("combobox", { name: "Étape" })).toHaveTextContent("Kickoff");
});
it("assigns the current user when creating from mes tâches in overview", async () => {
const user = userEvent.setup();
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",
path: "/tablos/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Ajouter" }));
await waitFor(() => {
expect(screen.getByRole("combobox", { name: "Assigné à" })).toHaveTextContent("Test User");
});
await user.type(screen.getByLabelText("Titre *"), "Nouvelle tâche");
await user.click(screen.getByRole("button", { name: "Créer" }));
expect(mutateCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
tablo_id: "tablo-1",
title: "Nouvelle tâche",
assignee_id: "user-1",
})
);
});
it("renders overview cards in persisted left-zone order", () => {
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",

View file

@ -103,6 +103,9 @@ export const TabloDetailsPage = () => {
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<Date | undefined>(
undefined
);
const [taskModalDefaultAssigneeId, setTaskModalDefaultAssigneeId] = useState<string | undefined>(
undefined
);
const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
@ -169,14 +172,16 @@ export const TabloDetailsPage = () => {
setInviteEmail("");
};
const openTaskModal = (dueDate?: Date) => {
const openTaskModal = (dueDate?: Date, defaultAssigneeId?: string) => {
setTaskModalInitialDueDate(dueDate ? new Date(dueDate) : undefined);
setTaskModalDefaultAssigneeId(defaultAssigneeId);
setIsTaskModalOpen(true);
};
const closeTaskModal = () => {
setIsTaskModalOpen(false);
setTaskModalInitialDueDate(undefined);
setTaskModalDefaultAssigneeId(undefined);
};
const sectionParam = searchParams.get("section") as TabSection | null;
@ -357,7 +362,7 @@ export const TabloDetailsPage = () => {
showFileMenu={true}
onOpenTasks={() => setSearchParams({ section: "tasks" })}
onOpenFiles={() => setSearchParams({ section: "files" })}
onCreateTask={() => openTaskModal()}
onCreateTask={() => openTaskModal(undefined, currentUser.id)}
onToggleTaskDone={(taskId) => updateTask({ id: taskId, status: "done" })}
showAllTasks={showAllOverviewTasks}
onToggleShowAllTasks={() => setShowAllOverviewTasks((prev) => !prev)}
@ -500,8 +505,11 @@ export const TabloDetailsPage = () => {
tabloId={tabloId}
isOpen={isTaskModalOpen}
onClose={closeTaskModal}
members={members}
etapes={etapes}
initialStatus="todo"
initialDueDate={taskModalInitialDueDate}
defaultAssigneeId={taskModalDefaultAssigneeId}
onCreateTask={(task) => createTask(task)}
onUpdateTask={(task) => updateTask(task)}
onDeleteTask={(taskId) => deleteTask(taskId)}

View file

@ -245,9 +245,7 @@ export const TabloTasksSection = ({
{orphanedTasks.length} {pluralize("tâche", orphanedTasks.length)} sans Étape
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
{orphanedTasks.length === 1
? "Cette tâche n'est associée à aucune Étape. Modifiez-la pour l'associer à une Étape."
: "Ces tâches ne sont associées à aucune Étape. Modifiez-les pour les associer à une Étape."}
Associez {orphanedTasks.length > 1 ? "ces" : "cette"} {pluralize("tâche", orphanedTasks.length)} à une étape.
</p>
</div>
</div>

View file

@ -36,6 +36,7 @@ interface TaskModalProps {
tablos?: MinimalTablo[];
allowTabloSelection?: boolean;
initialDueDate?: Date;
defaultAssigneeId?: string;
/** Called when creating a new task. */
onCreateTask?: (task: KanbanTaskInsert) => void;
/** Called when updating an existing task. */
@ -56,6 +57,7 @@ export const TaskModal = ({
tablos,
allowTabloSelection = false,
initialDueDate,
defaultAssigneeId,
onCreateTask,
onUpdateTask,
}: TaskModalProps) => {
@ -69,8 +71,20 @@ export const TaskModal = ({
);
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
const selectedAssigneeLabel =
assigneeId === "unassigned"
? "Non assigné"
: providedMembers.find((member) => member.id === assigneeId)?.name ?? "Non assigné";
const selectedEtapeLabel =
etapeId === "none"
? "Aucune Étape"
: providedEtapes.find((etape) => etape.id === etapeId)?.title ?? "Aucune Étape";
useEffect(() => {
if (!isOpen) {
return;
}
if (task) {
setTitle(task.title ?? "");
setDescription(task.description ?? "");
@ -83,14 +97,14 @@ export const TaskModal = ({
} else {
setTitle("");
setDescription("");
setAssigneeId("unassigned");
setAssigneeId(defaultAssigneeId ?? "unassigned");
setEtapeId("none");
setDueDate(initialDueDate ? new Date(initialDueDate) : undefined);
if (allowTabloSelection && tablos && tablos.length > 0) {
setSelectedTabloId(tablos[0].id);
}
}
}, [task, initialTabloId, allowTabloSelection, tablos, initialDueDate]);
}, [isOpen, task, initialTabloId, allowTabloSelection, tablos, initialDueDate, defaultAssigneeId]);
// Format Date to YYYY-MM-DD string for database storage
const formatDateForDb = (date: Date | undefined): string | null => {
@ -217,9 +231,15 @@ export const TaskModal = ({
{/* Assignee */}
<div className="space-y-2">
<Label htmlFor="assignee">Assigné à</Label>
<Select value={assigneeId} onValueChange={setAssigneeId}>
<Select
value={assigneeId}
onValueChange={(value) => {
if (!value) return;
setAssigneeId(value);
}}
>
<SelectTrigger id="assignee" className="w-full">
<SelectValue placeholder="Non assigné" />
<span className="line-clamp-1">{selectedAssigneeLabel}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Non assigné</SelectItem>
@ -236,9 +256,15 @@ export const TaskModal = ({
{providedEtapes.length > 0 && (
<div className="space-y-2">
<Label htmlFor="etape">Étape</Label>
<Select value={etapeId} onValueChange={setEtapeId}>
<Select
value={etapeId}
onValueChange={(value) => {
if (!value) return;
setEtapeId(value);
}}
>
<SelectTrigger id="etape" className="w-full">
<SelectValue placeholder="Aucune Étape" />
<span className="line-clamp-1">{selectedEtapeLabel}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune</SelectItem>