Allow updates deletions on etapes / tasks

This commit is contained in:
Arthur Belleville 2026-04-16 11:54:41 +02:00
parent 37ab73bced
commit e36c536cf2
No known key found for this signature in database
3 changed files with 239 additions and 29 deletions

View file

@ -7,6 +7,8 @@ import { TabloDetailsPage } from "./tablo-details";
const mutateUpdateTablo = vi.fn();
const mutateUpdateTask = vi.fn();
const mutateCreateEtape = vi.fn();
const mutateUpdateEtape = vi.fn();
const mutateDeleteEtape = vi.fn();
const mutateDeleteTask = vi.fn();
const tablosData = [
@ -107,7 +109,22 @@ vi.mock("../hooks/tasks", () => ({
],
}),
useTabloEtapes: () => ({
data: [],
data: [
{
id: "etape-1",
tablo_id: "tablo-1",
title: "Kickoff",
description: null,
status: "todo",
assignee_id: null,
position: 0,
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
is_parent: true,
parent_task_id: null,
due_date: null,
},
],
}),
useUpdateTask: () => ({
mutate: mutateUpdateTask,
@ -122,6 +139,14 @@ vi.mock("../hooks/tasks", () => ({
mutateAsync: mutateCreateEtape,
isPending: false,
}),
useUpdateEtape: () => ({
mutateAsync: mutateUpdateEtape,
isPending: false,
}),
useDeleteEtape: () => ({
mutateAsync: mutateDeleteEtape,
isPending: false,
}),
useCreateTask: () => ({
mutate: vi.fn(),
}),
@ -208,10 +233,54 @@ describe("TabloDetailsPage overview layout", () => {
expect(mutateCreateEtape).toHaveBeenCalledWith({
tabloId: "tablo-1",
title: "Kickoff",
position: 0,
position: 1,
});
});
it("updates an etape from the etapes tab", async () => {
const user = userEvent.setup();
mutateUpdateEtape.mockResolvedValueOnce(undefined);
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",
path: "/tablos/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Étapes" }));
await user.click(screen.getByRole("button", { name: "Modifier l'étape Kickoff" }));
await user.clear(screen.getByDisplayValue("Kickoff"));
await user.type(screen.getByPlaceholderText("Nom de l'étape"), "Planification");
await user.click(screen.getByRole("button", { name: "Enregistrer l'étape" }));
expect(mutateUpdateEtape).toHaveBeenCalledWith({
id: "etape-1",
tabloId: "tablo-1",
title: "Planification",
});
});
it("deletes an etape from the etapes tab", async () => {
const user = userEvent.setup();
mutateDeleteEtape.mockResolvedValueOnce(undefined);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",
path: "/tablos/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Étapes" }));
await user.click(screen.getByRole("button", { name: "Supprimer l'étape Kickoff" }));
expect(confirmSpy).toHaveBeenCalled();
expect(mutateDeleteEtape).toHaveBeenCalledWith({
id: "etape-1",
tabloId: "tablo-1",
});
confirmSpy.mockRestore();
});
it("deletes a task from the task modal", async () => {
const user = userEvent.setup();

View file

@ -64,8 +64,10 @@ import {
useAllTasks,
useCreateEtape,
useCreateTask,
useDeleteEtape,
useDeleteTask,
useTabloEtapes,
useUpdateEtape,
useUpdateTask,
useUpdateTaskPositions,
} from "../hooks/tasks";
@ -127,6 +129,8 @@ export const TabloDetailsPage = () => {
const { mutate: updateTablo, mutateAsync: updateTabloAsync } = useUpdateTablo();
const { mutate: createTask } = useCreateTask();
const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape();
const { mutateAsync: updateEtape, isPending: isUpdatingEtape } = useUpdateEtape();
const { mutateAsync: deleteEtape, isPending: isDeletingEtape } = useDeleteEtape();
const { mutate: updateTaskPositions } = useUpdateTaskPositions();
// Files & folders hooks
@ -473,7 +477,11 @@ export const TabloDetailsPage = () => {
})
}
onCreateEtape={(params) => createEtape(params).then(() => undefined)}
onUpdateEtape={(params) => updateEtape(params).then(() => undefined)}
onDeleteEtape={(params) => deleteEtape(params).then(() => undefined)}
isCreatingEtape={isCreatingEtape}
isUpdatingEtape={isUpdatingEtape}
isDeletingEtape={isDeletingEtape}
/>
)}

View file

@ -4,11 +4,15 @@ import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import {
CalendarIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleCheckIcon,
PencilIcon,
ListChecksIcon,
PlusIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useState } from "react";
@ -26,7 +30,11 @@ interface EtapesSectionProps {
position: number;
}) => void;
onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise<void>;
onUpdateEtape?: (params: { id: string; tabloId: string; title: string }) => Promise<void>;
onDeleteEtape?: (params: { id: string; tabloId: string }) => Promise<void>;
isCreatingEtape?: boolean;
isUpdatingEtape?: boolean;
isDeletingEtape?: boolean;
}
export function EtapesSection({
@ -36,7 +44,11 @@ export function EtapesSection({
isAdmin,
onCreateTask,
onCreateEtape,
onUpdateEtape,
onDeleteEtape,
isCreatingEtape = false,
isUpdatingEtape = false,
isDeletingEtape = false,
}: EtapesSectionProps) {
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
new Set(etapes.map((e) => e.id))
@ -44,6 +56,8 @@ export function EtapesSection({
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
const [newEtapeTitle, setNewEtapeTitle] = useState("");
const [newTaskTitle, setNewTaskTitle] = useState("");
const [editingEtapeId, setEditingEtapeId] = useState<string | null>(null);
const [editingEtapeTitle, setEditingEtapeTitle] = useState("");
const toggleEtape = (id: string) => {
setExpandedEtapes((prev) => {
@ -86,6 +100,46 @@ export function EtapesSection({
setNewEtapeTitle("");
};
const startEditingEtape = (etapeId: string, title: string) => {
setEditingEtapeId(etapeId);
setEditingEtapeTitle(title);
};
const cancelEditingEtape = () => {
setEditingEtapeId(null);
setEditingEtapeTitle("");
};
const handleUpdateEtape = async (etapeId: string) => {
const title = editingEtapeTitle.trim();
if (!title || !tabloId) {
return;
}
await onUpdateEtape?.({
id: etapeId,
tabloId,
title,
});
cancelEditingEtape();
};
const handleDeleteEtape = async (etapeId: string, title: string) => {
if (!tabloId) {
return;
}
if (!window.confirm(`Supprimer l'étape "${title}" ?`)) {
return;
}
await onDeleteEtape?.({
id: etapeId,
tabloId,
});
};
const statusConfig: Record<string, { label: string; color: string }> = {
todo: {
label: "À faire",
@ -159,6 +213,7 @@ export function EtapesSection({
? "in_progress"
: "todo";
const status = statusConfig[derivedStatus] ?? statusConfig.todo;
const isEditing = editingEtapeId === etape.id;
return (
<div
@ -166,33 +221,60 @@ export function EtapesSection({
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
>
{/* Etape header */}
<button
type="button"
onClick={() => toggleEtape(etape.id)}
className="w-full flex items-center gap-3 sm:gap-4 px-3 sm:px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left min-h-[56px]"
>
{isExpanded ? (
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
) : (
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
)}
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm sm:text-base">
{etape.title}
</h3>
{etape.description && (
<p className="text-xs sm:text-sm text-muted-foreground truncate mt-0.5">
{etape.description}
</p>
<div className="flex items-center gap-2 px-3 sm:px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors min-h-[56px]">
<button
type="button"
onClick={() => toggleEtape(etape.id)}
className="flex flex-1 items-center gap-3 sm:gap-4 text-left min-w-0"
>
{isExpanded ? (
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
) : (
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
)}
</div>
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
{isEditing ? (
<Input
value={editingEtapeTitle}
onChange={(event) => setEditingEtapeTitle(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleUpdateEtape(etape.id);
}
if (event.key === "Escape") {
event.preventDefault();
cancelEditingEtape();
}
}}
placeholder="Nom de l'étape"
aria-label="Nom de l'étape"
autoFocus
className="h-9"
/>
) : (
<>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm sm:text-base">
{etape.title}
</h3>
{etape.description && (
<p className="text-xs sm:text-sm text-muted-foreground truncate mt-0.5">
{etape.description}
</p>
)}
</>
)}
</div>
</button>
<div className="flex items-center gap-2 shrink-0">
{etape.due_date && (
@ -237,8 +319,59 @@ export function EtapesSection({
</span>
</div>
)}
{isAdmin && (
<div className="flex items-center gap-1">
{isEditing ? (
<>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Annuler la modification de l'étape"
onClick={() => cancelEditingEtape()}
disabled={isUpdatingEtape}
>
<XIcon className="w-4 h-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="Enregistrer l'étape"
onClick={() => void handleUpdateEtape(etape.id)}
disabled={isUpdatingEtape}
>
<CheckIcon className="w-4 h-4" />
</Button>
</>
) : (
<>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`Modifier l'étape ${etape.title}`}
onClick={() => startEditingEtape(etape.id, etape.title)}
>
<PencilIcon className="w-4 h-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`Supprimer l'étape ${etape.title}`}
onClick={() => void handleDeleteEtape(etape.id, etape.title)}
disabled={isDeletingEtape}
>
<Trash2Icon className="w-4 h-4 text-destructive" />
</Button>
</>
)}
</div>
)}
</div>
</button>
</div>
{/* Child tasks + add task */}
{isExpanded && (