diff --git a/apps/main/src/components/TabloOverviewSection.test.tsx b/apps/main/src/components/TabloOverviewSection.test.tsx new file mode 100644 index 0000000..f08ac6b --- /dev/null +++ b/apps/main/src/components/TabloOverviewSection.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderWithProviders } from "../utils/testHelpers"; +import { TabloOverviewSection } from "./TabloOverviewSection"; + +const mockUseTablo = vi.fn(); +const mockUseTabloEtapes = vi.fn(); +const createEtapeMock = { mutateAsync: vi.fn(), isPending: false }; +const updateEtapeMock = { mutateAsync: vi.fn(), isPending: false }; +const deleteEtapeMock = { mutateAsync: vi.fn(), isPending: false }; +const reorderEtapesMock = { mutateAsync: vi.fn(), isPending: false }; + +vi.mock("../hooks/tablos", () => ({ + useTablo: (tabloId: string) => mockUseTablo(tabloId), +})); + +vi.mock("../hooks/tasks", () => ({ + useTabloEtapes: (tabloId: string) => mockUseTabloEtapes(tabloId), + useCreateEtape: () => createEtapeMock, + useUpdateEtape: () => updateEtapeMock, + useDeleteEtape: () => deleteEtapeMock, + useReorderEtapes: () => reorderEtapesMock, +})); + +vi.mock("./TabloFilesSection", () => ({ + TabloFilesSection: () =>
, +})); + +const mockTablo = { + id: "tablo-1", + name: "Projet Alpha", + color: "bg-blue-500", + user_id: "123", + access_level: "admin", + is_admin: true, + created_at: "2024-01-01T00:00:00Z", + deleted_at: null, + position: 0, + status: "active", + image: null, +}; + +const etapeFactory = (overrides = {}) => ({ + id: "etape-1", + tablo_id: mockTablo.id, + title: "Phase 1", + description: null, + status: "todo", + assignee_id: null, + position: 0, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + is_parent: true, + parent_task_id: null, + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockUseTabloEtapes.mockReturnValue({ + data: [etapeFactory()], + isLoading: false, + }); + createEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined); + updateEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined); + deleteEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined); + reorderEtapesMock.mutateAsync = vi.fn().mockResolvedValue(undefined); + mockUseTablo.mockReturnValue({ data: { owner_id: "123" } }); +}); + +describe("TabloOverviewSection", () => { + it("shows the Étape creation input for the tablo owner", () => { + renderWithProviders(); + + expect(screen.getByPlaceholderText("Ajouter une nouvelle Étape")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /ajouter/i })).toBeInTheDocument(); + }); + + it("hides management actions for non owners", () => { + mockUseTablo.mockReturnValue({ data: { owner_id: "another-user" } }); + + renderWithProviders(); + + expect(screen.queryByPlaceholderText("Ajouter une nouvelle Étape")).not.toBeInTheDocument(); + expect( + screen.getByText("Seul le propriétaire du tablo peut modifier les Étapes.", { + exact: false, + }) + ).toBeInTheDocument(); + }); + + it("calls the create mutation when adding a new Étape", async () => { + renderWithProviders(); + + const input = screen.getByPlaceholderText("Ajouter une nouvelle Étape"); + fireEvent.change(input, { target: { value: "Kick-off" } }); + fireEvent.click(screen.getByRole("button", { name: /ajouter/i })); + + await waitFor(() => { + expect(createEtapeMock.mutateAsync).toHaveBeenCalledWith({ + tabloId: mockTablo.id, + title: "Kick-off", + position: 1, + }); + }); + }); +}); + diff --git a/apps/main/src/components/TabloOverviewSection.tsx b/apps/main/src/components/TabloOverviewSection.tsx new file mode 100644 index 0000000..e16069b --- /dev/null +++ b/apps/main/src/components/TabloOverviewSection.tsx @@ -0,0 +1,300 @@ +import { useCallback, useMemo, useState } from "react"; +import { Button } from "@xtablo/ui/components/button"; +import { Input } from "@xtablo/ui/components/input"; +import { + ArrowDown, + ArrowUp, + Check, + Edit2, + Loader2, + Plus, + Trash2, + X, +} from "lucide-react"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { TabloFilesSection } from "./TabloFilesSection"; +import { + useCreateEtape, + useDeleteEtape, + useReorderEtapes, + useTabloEtapes, + useUpdateEtape, +} from "../hooks/tasks"; +import { useTablo } from "../hooks/tablos"; +import { useUser } from "../providers/UserStoreProvider"; + +interface TabloOverviewSectionProps { + tablo: UserTablo; + isAdmin: boolean; +} + +export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionProps) => { + const { data: detailedTablo } = useTablo(tablo.id); + const { id: currentUserId } = useUser(); + + const { data: etapes = [], isLoading: isLoadingEtapes } = useTabloEtapes(tablo.id); + const createEtape = useCreateEtape(); + const updateEtape = useUpdateEtape(); + const deleteEtape = useDeleteEtape(); + const reorderEtapes = useReorderEtapes(); + + const [newEtapeTitle, setNewEtapeTitle] = useState(""); + const [editingEtapeId, setEditingEtapeId] = useState(null); + const [editingTitle, setEditingTitle] = useState(""); + + const isOwner = detailedTablo?.owner_id === currentUserId; + const canManageEtapes = isOwner; + + const sortedEtapes = useMemo( + () => [...etapes].sort((a, b) => a.position - b.position), + [etapes] + ); + + const handleCreateEtape = async () => { + const title = newEtapeTitle.trim(); + if (!title) return; + + await createEtape.mutateAsync({ + tabloId: tablo.id, + title, + position: etapes.length, + }); + setNewEtapeTitle(""); + }; + + const startEditing = useCallback((etapeId: string, currentTitle: string) => { + setEditingEtapeId(etapeId); + setEditingTitle(currentTitle); + }, []); + + const handleUpdateEtape = async () => { + if (!editingEtapeId) return; + const title = editingTitle.trim(); + if (!title) return; + + await updateEtape.mutateAsync({ + id: editingEtapeId, + tabloId: tablo.id, + title, + }); + setEditingEtapeId(null); + setEditingTitle(""); + }; + + const handleCancelEdit = () => { + setEditingEtapeId(null); + setEditingTitle(""); + }; + + const handleDeleteEtape = async (etapeId: string, etapeTitle: string) => { + if ( + !window.confirm( + `Êtes-vous sûr de vouloir supprimer l'Étape "${etapeTitle}" ? Les tâches associées resteront disponibles.` + ) + ) { + return; + } + + await deleteEtape.mutateAsync({ id: etapeId, tabloId: tablo.id }); + }; + + const handleReorder = async (index: number, direction: "up" | "down") => { + const nextIndex = direction === "up" ? index - 1 : index + 1; + if (nextIndex < 0 || nextIndex >= sortedEtapes.length) return; + + const reordered = [...sortedEtapes]; + const [moved] = reordered.splice(index, 1); + reordered.splice(nextIndex, 0, moved); + + const updates = reordered.map((etape, position) => ({ + id: etape.id, + position, + })); + + await reorderEtapes.mutateAsync({ tabloId: tablo.id, updates }); + }; + + const EtapeList = () => { + if (isLoadingEtapes) { + return ( +
+ +
+ ); + } + + if (!sortedEtapes.length) { + return ( +
+

+ Aucune Étape n'a encore été définie pour ce tablo. +

+ {canManageEtapes ? ( +

+ Créez votre première Étape pour structurer les tâches du tablo. +

+ ) : ( +

+ Seul le propriétaire du tablo peut ajouter des Étapes. +

+ )} +
+ ); + } + + return ( +
    + {sortedEtapes.map((etape, index) => { + const isEditing = editingEtapeId === etape.id; + return ( +
  • + {canManageEtapes && ( +
    + + +
    + )} + +
    + {isEditing ? ( +
    + setEditingTitle(event.target.value)} + placeholder="Nom de l'Étape" + autoFocus + /> +
    + + +
    +
    + ) : ( + <> +

    {etape.title}

    +

    + Position {etape.position + 1} +

    + + )} +
    + + {canManageEtapes && !isEditing && ( +
    + + +
    + )} +
  • + ); + })} +
+ ); + }; + + return ( +
+
+
+
+

Vue d'ensemble

+

+ Configurez les Étapes du tablo pour clarifier les grandes phases de votre projet. +

+
+ {canManageEtapes && ( +
+ setNewEtapeTitle(event.target.value)} + placeholder="Ajouter une nouvelle Étape" + className="sm:w-64" + /> + +
+ )} +
+ + {!canManageEtapes && ( +
+ Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si + vous avez besoin d'une nouvelle Étape. +
+ )} + +
+ +
+
+ +
+

Fichiers du tablo

+ +
+
+ ); +}; + diff --git a/apps/main/src/components/TabloTasksSection.tsx b/apps/main/src/components/TabloTasksSection.tsx index 778beab..ed5c2f4 100644 --- a/apps/main/src/components/TabloTasksSection.tsx +++ b/apps/main/src/components/TabloTasksSection.tsx @@ -2,9 +2,9 @@ import { toast } from "@xtablo/shared"; import { UserTablo } from "@xtablo/shared/types/tablos.types"; import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types"; import { ListChecks } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTabloMembers } from "../hooks/tablos"; -import { useCreateTask, useTasksByTablo, useUpdateTaskPositions } from "../hooks/tasks"; +import { useCreateTask, useTabloEtapes, useTasksByTablo, useUpdateTaskPositions } from "../hooks/tasks"; import { KanbanBoard } from "./kanban/KanbanBoard"; import { TaskModal } from "./kanban/TaskModal"; @@ -18,11 +18,22 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => { const [columns, setColumns] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedTask, setSelectedTask] = useState(null); + const [modalStatus, setModalStatus] = useState("todo"); const { data: tasks } = useTasksByTablo(tablo.id); + const { data: etapes = [] } = useTabloEtapes(tablo.id); const { mutate: updateTaskPositions } = useUpdateTaskPositions(); const { mutate: createTask } = useCreateTask(); + const etapeTitleMap = useMemo( + () => + etapes.reduce>((map, etape) => { + map[etape.id] = etape.title; + return map; + }, {}), + [etapes] + ); + // Helper functions defined before use const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => { const defaultColumns: KanbanColumn[] = [ @@ -62,8 +73,9 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => { setColumns(initializeColumns(tasks ?? [])); }, [initializeColumns, tasks]); - const handleAddTask = () => { + const handleAddTask = (status: TaskStatus) => { setSelectedTask(null); + setModalStatus(status); setIsModalOpen(true); }; @@ -72,6 +84,7 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => { description: string; assignee_id?: string; status: TaskStatus; + parent_task_id?: string | null; }) => { const newTask: KanbanTaskInsert = { id: `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, @@ -83,6 +96,7 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), position: 0, + parent_task_id: taskData.parent_task_id ?? null, }; createTask(newTask); @@ -130,6 +144,7 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => { const handleTaskClick = (task: KanbanTask) => { setSelectedTask(task); + setModalStatus(task.status); setIsModalOpen(true); }; @@ -150,6 +165,8 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => { { isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} members={members} - initialStatus={selectedTask?.status ?? "todo"} + initialStatus={modalStatus} + etapes={etapes} />
); diff --git a/apps/main/src/components/kanban/InlineTaskCreate.tsx b/apps/main/src/components/kanban/InlineTaskCreate.tsx index 6e6cceb..8d7f263 100644 --- a/apps/main/src/components/kanban/InlineTaskCreate.tsx +++ b/apps/main/src/components/kanban/InlineTaskCreate.tsx @@ -1,4 +1,4 @@ -import type { TaskStatus } from "@xtablo/shared-types"; +import type { Etape, TaskStatus } from "@xtablo/shared-types"; import { Button } from "@xtablo/ui/components/button"; import { Input } from "@xtablo/ui/components/input"; import { Label } from "@xtablo/ui/components/label"; @@ -17,20 +17,23 @@ import type { TabloMember } from "./types"; interface InlineTaskCreateProps { status: TaskStatus; members: TabloMember[]; + etapes: Etape[]; onSubmit: (task: { title: string; description: string; assignee_id?: string; status: TaskStatus; + parent_task_id?: string | null; }) => void; } -export const InlineTaskCreate = ({ status, members, onSubmit }: InlineTaskCreateProps) => { +export const InlineTaskCreate = ({ status, members, etapes, onSubmit }: InlineTaskCreateProps) => { const [isCreating, setIsCreating] = useState(false); const [title, setTitle] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); const [description, setDescription] = useState(""); const [assigneeId, setAssigneeId] = useState("unassigned"); + const [etapeId, setEtapeId] = useState("none"); const inputRef = useRef(null); useEffect(() => { @@ -48,12 +51,14 @@ export const InlineTaskCreate = ({ status, members, onSubmit }: InlineTaskCreate description: description.trim(), assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined, status, + parent_task_id: etapeId !== "none" ? etapeId : undefined, }); // Reset form setTitle(""); setDescription(""); setAssigneeId("unassigned"); + setEtapeId("none"); setShowAdvanced(false); setIsCreating(false); }; @@ -91,6 +96,27 @@ export const InlineTaskCreate = ({ status, members, onSubmit }: InlineTaskCreate required /> + {etapes.length > 0 && ( +
+ + +
+ )} + {/* Advanced Options */} {showAdvanced && (
diff --git a/apps/main/src/components/kanban/KanbanBoard.tsx b/apps/main/src/components/kanban/KanbanBoard.tsx index c780ac5..a894c35 100644 --- a/apps/main/src/components/kanban/KanbanBoard.tsx +++ b/apps/main/src/components/kanban/KanbanBoard.tsx @@ -1,4 +1,5 @@ import type { + Etape, KanbanColumn as KanbanColumnType, KanbanTask, TaskStatus, @@ -10,6 +11,8 @@ import type { TabloMember } from "./types"; interface KanbanBoardProps { columns: KanbanColumnType[]; members: TabloMember[]; + etapes: Etape[]; + etapeTitles: Record; onTaskClick: (task: KanbanTask) => void; onAddTask: (status: TaskStatus) => void; onAddTaskInline: (task: { @@ -17,6 +20,7 @@ interface KanbanBoardProps { description: string; assignee_id?: string; status: TaskStatus; + parent_task_id?: string | null; }) => void; onTaskMove: (taskId: string, newStatus: TaskStatus) => void; } @@ -24,6 +28,7 @@ interface KanbanBoardProps { export const KanbanBoard = ({ columns, members, + etapes, onTaskClick, onAddTask, onAddTaskInline, @@ -56,6 +61,7 @@ export const KanbanBoard = ({ key={column.id} column={column} members={members} + etapes={etapes} onTaskClick={onTaskClick} onAddTask={onAddTask} onAddTaskInline={onAddTaskInline} diff --git a/apps/main/src/components/kanban/KanbanColumn.tsx b/apps/main/src/components/kanban/KanbanColumn.tsx index 7f7c97b..dcebecc 100644 --- a/apps/main/src/components/kanban/KanbanColumn.tsx +++ b/apps/main/src/components/kanban/KanbanColumn.tsx @@ -1,4 +1,5 @@ import type { + Etape, KanbanColumn as KanbanColumnType, KanbanTask, TaskStatus, @@ -12,6 +13,7 @@ import type { TabloMember } from "./types"; interface KanbanColumnProps { column: KanbanColumnType; members: TabloMember[]; + etapes: Etape[]; onTaskClick: (task: KanbanTask) => void; onAddTask: (status: KanbanColumnType["status"]) => void; onAddTaskInline: (task: { @@ -19,6 +21,7 @@ interface KanbanColumnProps { description: string; assignee_id?: string; status: TaskStatus; + parent_task_id?: string | null; }) => void; onDragStart: (e: React.DragEvent, task: KanbanTask) => void; onDragOver: (e: React.DragEvent) => void; @@ -28,6 +31,7 @@ interface KanbanColumnProps { export const KanbanColumn = ({ column, members, + etapes, onTaskClick, onAddTask, onAddTaskInline, @@ -74,7 +78,13 @@ export const KanbanColumn = ({ onDragStart={(e) => onDragStart(e, task)} className="cursor-move" > - onTaskClick(task)} /> + etape.id === task.parent_task_id)?.title ?? undefined + } + onClick={() => onTaskClick(task)} + />
)) )} @@ -82,7 +92,12 @@ export const KanbanColumn = ({ {/* Inline Task Creation */}
- +
); diff --git a/apps/main/src/components/kanban/KanbanTaskCard.tsx b/apps/main/src/components/kanban/KanbanTaskCard.tsx index 3d19cfe..fdd9303 100644 --- a/apps/main/src/components/kanban/KanbanTaskCard.tsx +++ b/apps/main/src/components/kanban/KanbanTaskCard.tsx @@ -4,10 +4,11 @@ import { User } from "lucide-react"; interface KanbanTaskCardProps { task: KanbanTask; + etapeTitle?: string; onClick: () => void; } -export const KanbanTaskCard = ({ task, onClick }: KanbanTaskCardProps) => { +export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProps) => { return (
{ )} {/* Status Pill */} - {task.status && ( -
- - {task.status === "todo" - ? "À faire" - : task.status === "in_progress" - ? "En cours" - : task.status === "in_review" - ? "En révision" - : task.status === "done" - ? "Terminé" - : task.status} - -
- )} +
+ + {etapeTitle ?? "Sans Étape"} + +
diff --git a/apps/main/src/components/kanban/TaskModal.tsx b/apps/main/src/components/kanban/TaskModal.tsx index 88d692c..48962db 100644 --- a/apps/main/src/components/kanban/TaskModal.tsx +++ b/apps/main/src/components/kanban/TaskModal.tsx @@ -1,4 +1,4 @@ -import type { TaskStatus } from "@xtablo/shared-types"; +import type { Etape, TaskStatus } from "@xtablo/shared-types"; import { Button } from "@xtablo/ui/components/button"; import { Input } from "@xtablo/ui/components/input"; import { Label } from "@xtablo/ui/components/label"; @@ -23,6 +23,7 @@ interface TaskModalProps { onClose: () => void; members: TabloMember[]; initialStatus?: TaskStatus; + etapes: Etape[]; } export const TaskModal = ({ @@ -32,17 +33,20 @@ export const TaskModal = ({ onClose, members, initialStatus = "todo", + etapes, }: TaskModalProps) => { const { data: task = null } = useTask(taskId); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [assigneeId, setAssigneeId] = useState("unassigned"); + const [etapeId, setEtapeId] = useState("none"); useEffect(() => { if (task) { setTitle(task.title ?? ""); setDescription(task.description ?? ""); setAssigneeId(task.assignee_id ?? "unassigned"); + setEtapeId(task.parent_task_id ?? "none"); } }, [task]); @@ -61,6 +65,7 @@ export const TaskModal = ({ description: description.trim(), assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined, status: initialStatus, + parent_task_id: etapeId !== "none" ? etapeId : null, }); } else { createTask({ @@ -69,12 +74,14 @@ export const TaskModal = ({ description: description.trim(), assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined, status: initialStatus, + parent_task_id: etapeId !== "none" ? etapeId : null, }); } // Reset form setTitle(""); setDescription(""); setAssigneeId("unassigned"); + setEtapeId("none"); onClose(); }; @@ -158,6 +165,26 @@ export const TaskModal = ({
+ {/* Étape */} + {etapes.length > 0 && ( +
+ + +
+ )} + {/* Actions */}