Allow updates deletions on etapes / tasks
This commit is contained in:
parent
37ab73bced
commit
e36c536cf2
3 changed files with 239 additions and 29 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue