First version of parent tasks

This commit is contained in:
Arthur Belleville 2025-11-18 09:53:29 +01:00
parent d4afb0e9bb
commit cebafbdb2e
No known key found for this signature in database
14 changed files with 1109 additions and 42 deletions

View file

@ -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: () => <div data-testid="tablo-files-section" />,
}));
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(<TabloOverviewSection tablo={mockTablo} isAdmin />);
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(<TabloOverviewSection tablo={mockTablo} isAdmin={false} />);
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(<TabloOverviewSection tablo={mockTablo} isAdmin />);
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,
});
});
});
});

View file

@ -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<string | null>(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 (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!sortedEtapes.length) {
return (
<div className="rounded-lg border border-dashed border-border p-6 text-center">
<p className="text-muted-foreground">
Aucune Étape n'a encore é définie pour ce tablo.
</p>
{canManageEtapes ? (
<p className="text-sm text-muted-foreground mt-2">
Créez votre première Étape pour structurer les tâches du tablo.
</p>
) : (
<p className="text-sm text-muted-foreground mt-2">
Seul le propriétaire du tablo peut ajouter des Étapes.
</p>
)}
</div>
);
}
return (
<ul className="space-y-3">
{sortedEtapes.map((etape, index) => {
const isEditing = editingEtapeId === etape.id;
return (
<li
key={etape.id}
className="flex items-start gap-3 rounded-lg border border-border bg-card/40 px-4 py-3"
>
{canManageEtapes && (
<div className="flex flex-col gap-1 pt-1">
<Button
size="icon"
variant="ghost"
disabled={index === 0 || reorderEtapes.isPending}
onClick={() => handleReorder(index, "up")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
disabled={index === sortedEtapes.length - 1 || reorderEtapes.isPending}
onClick={() => handleReorder(index, "down")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex-1">
{isEditing ? (
<div className="flex flex-col gap-2">
<Input
value={editingTitle}
onChange={(event) => setEditingTitle(event.target.value)}
placeholder="Nom de l'Étape"
autoFocus
/>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleCancelEdit}
disabled={updateEtape.isPending}
>
<X className="mr-2 h-4 w-4" />
Annuler
</Button>
<Button
size="sm"
onClick={handleUpdateEtape}
disabled={updateEtape.isPending}
>
{updateEtape.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Enregistrer
</Button>
</div>
</div>
) : (
<>
<p className="text-base font-medium text-foreground">{etape.title}</p>
<p className="text-xs text-muted-foreground">
Position {etape.position + 1}
</p>
</>
)}
</div>
{canManageEtapes && !isEditing && (
<div className="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
onClick={() => startEditing(etape.id, etape.title)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => handleDeleteEtape(etape.id, etape.title)}
disabled={deleteEtape.isPending}
>
{deleteEtape.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4 text-destructive" />
)}
</Button>
</div>
)}
</li>
);
})}
</ul>
);
};
return (
<div className="space-y-8">
<div className="bg-card border border-border rounded-lg p-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">Vue d'ensemble</h1>
<p className="text-muted-foreground mt-1">
Configurez les Étapes du tablo pour clarifier les grandes phases de votre projet.
</p>
</div>
{canManageEtapes && (
<div className="flex gap-2 w-full sm:w-auto">
<Input
value={newEtapeTitle}
onChange={(event) => setNewEtapeTitle(event.target.value)}
placeholder="Ajouter une nouvelle Étape"
className="sm:w-64"
/>
<Button
onClick={handleCreateEtape}
disabled={!newEtapeTitle.trim() || createEtape.isPending}
>
{createEtape.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Ajouter
</Button>
</div>
)}
</div>
{!canManageEtapes && (
<div className="mt-4 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si
vous avez besoin d'une nouvelle Étape.
</div>
)}
<div className="mt-6">
<EtapeList />
</div>
</div>
<div className="bg-card border border-border rounded-lg p-6">
<h2 className="text-2xl font-semibold text-foreground mb-4">Fichiers du tablo</h2>
<TabloFilesSection tablo={tablo} isAdmin={isAdmin} />
</div>
</div>
);
};

View file

@ -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<KanbanColumn[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<KanbanTask | null>(null);
const [modalStatus, setModalStatus] = useState<TaskStatus>("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<Record<string, string>>((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) => {
<KanbanBoard
columns={columns}
members={members}
etapes={etapes}
etapeTitles={etapeTitleMap}
onTaskClick={handleTaskClick}
onAddTask={handleAddTask}
onAddTaskInline={handleCreateTask}
@ -164,7 +181,8 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
members={members}
initialStatus={selectedTask?.status ?? "todo"}
initialStatus={modalStatus}
etapes={etapes}
/>
</div>
);

View file

@ -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<string>("unassigned");
const [etapeId, setEtapeId] = useState<string>("none");
const inputRef = useRef<HTMLInputElement>(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 && (
<div className="space-y-1">
<Label htmlFor={`etape-${status}`} className="text-xs text-muted-foreground">
Étape
</Label>
<Select value={etapeId} onValueChange={setEtapeId}>
<SelectTrigger id={`etape-${status}`} size="sm" className="w-full text-sm h-8">
<SelectValue placeholder="Aucune Étape" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune</SelectItem>
{etapes.map((etape) => (
<SelectItem key={etape.id} value={etape.id}>
{etape.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Advanced Options */}
{showAdvanced && (
<div className="space-y-2 pt-2 border-t border-border">

View file

@ -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<string, string>;
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}

View file

@ -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"
>
<KanbanTaskCard task={task} onClick={() => onTaskClick(task)} />
<KanbanTaskCard
task={task}
etapeTitle={
etapes.find((etape) => etape.id === task.parent_task_id)?.title ?? undefined
}
onClick={() => onTaskClick(task)}
/>
</div>
))
)}
@ -82,7 +92,12 @@ export const KanbanColumn = ({
{/* Inline Task Creation */}
<div className="mt-2">
<InlineTaskCreate status={column.status} members={members} onSubmit={onAddTaskInline} />
<InlineTaskCreate
status={column.status}
members={members}
etapes={etapes}
onSubmit={onAddTaskInline}
/>
</div>
</div>
);

View file

@ -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 (
<div
onClick={onClick}
@ -24,33 +25,15 @@ export const KanbanTaskCard = ({ task, onClick }: KanbanTaskCardProps) => {
)}
{/* Status Pill */}
{task.status && (
<div className="mb-2">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
task.status === "todo"
? "bg-gray-100 text-gray-800"
: task.status === "in_progress"
? "bg-blue-100 text-blue-800"
: task.status === "in_review"
? "bg-yellow-100 text-yellow-800"
: task.status === "done"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}
>
{task.status === "todo"
? "À faire"
: task.status === "in_progress"
? "En cours"
: task.status === "in_review"
? "En révision"
: task.status === "done"
? "Terminé"
: task.status}
</span>
</div>
)}
<div className="mb-2">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
etapeTitle ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
}`}
>
{etapeTitle ?? "Sans Étape"}
</span>
</div>
<div className="flex items-center justify-end mt-2">
<div className="flex items-center gap-2">

View file

@ -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<string>("unassigned");
const [etapeId, setEtapeId] = useState<string>("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 = ({
</Select>
</div>
{/* Étape */}
{etapes.length > 0 && (
<div className="space-y-2">
<Label htmlFor="etape">Étape</Label>
<Select value={etapeId} onValueChange={setEtapeId}>
<SelectTrigger id="etape" className="w-full">
<SelectValue placeholder="Aucune Étape" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune</SelectItem>
{etapes.map((etape) => (
<SelectItem key={etape.id} value={etape.id}>
{etape.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="ghost" onClick={onClose}>

View file

@ -1,6 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { toast } from "@xtablo/shared";
import type {
Etape,
KanbanTask,
KanbanTaskInsert,
KanbanTaskUpdate,
@ -8,6 +10,40 @@ import type {
} from "@xtablo/shared-types";
import { supabase } from "../lib/supabase";
type CreateEtapeInput = {
tabloId: string;
title: string;
description?: string | null;
position?: number;
};
type UpdateEtapeInput = {
id: string;
tabloId: string;
title?: string;
description?: string | null;
position?: number;
};
type DeleteEtapeInput = {
id: string;
tabloId: string;
};
type ReorderEtapesInput = {
tabloId: string;
updates: Array<{ id: string; position: number }>;
};
const invalidateEtapeCaches = (queryClient: QueryClient, tabloId?: string) => {
queryClient.invalidateQueries({ queryKey: ["tablo-etapes"] });
if (tabloId) {
queryClient.invalidateQueries({ queryKey: ["tablo-etapes", tabloId] });
queryClient.invalidateQueries({ queryKey: ["tasks", "tablo", tabloId] });
}
queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
// Fetch all tasks for a specific tablo
export const useTasksByTablo = (tabloId: string | undefined) => {
return useQuery({
@ -17,6 +53,7 @@ export const useTasksByTablo = (tabloId: string | undefined) => {
.from("tasks_with_assignee")
.select("*")
.eq("tablo_id", tabloId)
.eq("is_parent", false)
.order("position", { ascending: true });
if (error) throw error;
@ -85,6 +122,8 @@ export const useCreateTask = () => {
status: task.status || "todo",
assignee_id: task.assignee_id,
position: task.position || 0,
parent_task_id: task.parent_task_id ?? null,
is_parent: task.is_parent ?? false,
})
.select()
.single();
@ -205,11 +244,17 @@ export const useUpdateTaskPositions = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updates: Array<{ id: string; position: number; status?: TaskStatus }>) => {
const promises = updates.map(({ id, position, status }) =>
mutationFn: async (
updates: Array<{ id: string; position: number; status?: TaskStatus; parent_task_id?: string | null }>
) => {
const promises = updates.map(({ id, position, status, parent_task_id }) =>
supabase
.from("tasks")
.update({ position, ...(status && { status }) })
.update({
position,
...(status && { status }),
...(parent_task_id !== undefined ? { parent_task_id } : {}),
})
.eq("id", id)
);
@ -239,3 +284,199 @@ export const useUpdateTaskPositions = () => {
},
});
};
// Fetch Etapes (parent tasks) for a tablo
export const useTabloEtapes = (tabloId: string | undefined) => {
return useQuery({
queryKey: ["tablo-etapes", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("tasks")
.select("*")
.eq("tablo_id", tabloId)
.eq("is_parent", true)
.order("position", { ascending: true });
if (error) throw error;
return data as Etape[];
},
enabled: !!tabloId,
});
};
export const useCreateEtape = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tabloId, title, description, position }: CreateEtapeInput) => {
const { data, error } = await supabase
.from("tasks")
.insert({
tablo_id: tabloId,
title,
description: description ?? null,
status: "todo",
position: position ?? 0,
is_parent: true,
})
.select()
.single();
if (error) throw error;
return data as Etape;
},
onSuccess: (_data, variables) => {
invalidateEtapeCaches(queryClient, variables.tabloId);
toast.add(
{
title: "Étape créée",
description: "La nouvelle Étape a été ajoutée avec succès",
type: "success",
},
{ timeout: 3000 }
);
},
onError: (error) => {
toast.add(
{
title: "Erreur",
description: "Impossible de créer l'Étape",
type: "error",
},
{ timeout: 5000 }
);
console.error("Error creating Etape:", error);
},
});
};
export const useUpdateEtape = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, tabloId, title, description, position }: UpdateEtapeInput) => {
const updates: Record<string, unknown> = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (position !== undefined) updates.position = position;
if (Object.keys(updates).length === 0) {
throw new Error("Aucune modification fournie pour l'Étape");
}
const { data, error } = await supabase
.from("tasks")
.update(updates)
.eq("id", id)
.eq("tablo_id", tabloId)
.eq("is_parent", true)
.select()
.single();
if (error) throw error;
return data as Etape;
},
onSuccess: (_data, variables) => {
invalidateEtapeCaches(queryClient, variables.tabloId);
toast.add(
{
title: "Étape mise à jour",
description: "Les modifications de l'Étape ont été enregistrées",
type: "success",
},
{ timeout: 3000 }
);
},
onError: (error) => {
toast.add(
{
title: "Erreur",
description: "Impossible de mettre à jour l'Étape",
type: "error",
},
{ timeout: 5000 }
);
console.error("Error updating Etape:", error);
},
});
};
export const useDeleteEtape = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, tabloId }: DeleteEtapeInput) => {
const { error } = await supabase
.from("tasks")
.delete()
.eq("id", id)
.eq("tablo_id", tabloId)
.eq("is_parent", true);
if (error) throw error;
return id;
},
onSuccess: (_data, variables) => {
invalidateEtapeCaches(queryClient, variables.tabloId);
toast.add(
{
title: "Étape supprimée",
description: "L'Étape a été supprimée avec succès",
type: "success",
},
{ timeout: 3000 }
);
},
onError: (error) => {
toast.add(
{
title: "Erreur",
description: "Impossible de supprimer l'Étape",
type: "error",
},
{ timeout: 5000 }
);
console.error("Error deleting Etape:", error);
},
});
};
export const useReorderEtapes = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tabloId, updates }: ReorderEtapesInput) => {
const results = await Promise.all(
updates.map(({ id, position }) =>
supabase
.from("tasks")
.update({ position })
.eq("id", id)
.eq("tablo_id", tabloId)
.eq("is_parent", true)
)
);
const errors = results.filter((r) => r.error);
if (errors.length > 0) {
throw new Error("Failed to reorder Étapes");
}
return updates;
},
onSuccess: (_data, variables) => {
invalidateEtapeCaches(queryClient, variables.tabloId);
},
onError: (error) => {
toast.add(
{
title: "Erreur",
description: "Impossible de réorganiser les Étapes",
type: "error",
},
{ timeout: 5000 }
);
console.error("Error reordering Etapes:", error);
},
});
};

View file

@ -6,6 +6,7 @@ import {
BookOpen,
Calendar,
FileText,
LayoutDashboard,
ListChecks,
MessageSquare,
Settings,
@ -20,10 +21,19 @@ import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloMembersSection } from "../components/TabloMembersSection";
import { TabloNotesSection } from "../components/TabloNotesSection";
import { TabloSettingsSection } from "../components/TabloSettingsSection";
import { TabloOverviewSection } from "../components/TabloOverviewSection";
import { TabloTasksSection } from "../components/TabloTasksSection";
import { useTablosList, useUpdateTablo } from "../hooks/tablos";
type TabSection = "files" | "discussion" | "notes" | "events" | "tasks" | "members" | "settings";
type TabSection =
| "overview"
| "files"
| "discussion"
| "notes"
| "events"
| "tasks"
| "members"
| "settings";
export const TabloDetailsPage = () => {
const { tabloId } = useParams<{ tabloId: string }>();
@ -98,6 +108,11 @@ export const TabloDetailsPage = () => {
label: string;
icon: React.ReactNode;
}> = [
{
id: "overview",
label: "Vue d'ensemble",
icon: <LayoutDashboard className="w-5 h-5" />,
},
{
id: "files",
label: "Fichiers",
@ -201,6 +216,9 @@ export const TabloDetailsPage = () => {
{/* Main Content Area */}
<main className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto p-6 h-full">
{activeSection === "overview" && (
<TabloOverviewSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "discussion" && (
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,139 @@
-- Add columns to support parent tasks (Etapes)
ALTER TABLE "public"."tasks"
ADD COLUMN "is_parent" boolean DEFAULT false NOT NULL;
ALTER TABLE "public"."tasks"
ADD COLUMN "parent_task_id" text;
ALTER TABLE "public"."tasks"
ADD CONSTRAINT "tasks_parent_task_id_fkey"
FOREIGN KEY ("parent_task_id") REFERENCES "public"."tasks"("id") ON DELETE SET NULL;
ALTER TABLE "public"."tasks"
ADD CONSTRAINT "tasks_parent_self_reference_check"
CHECK (NOT "is_parent" OR "parent_task_id" IS NULL);
COMMENT ON COLUMN "public"."tasks"."is_parent" IS 'When TRUE, the task represents an Etape (parent task) that can group other tasks';
COMMENT ON COLUMN "public"."tasks"."parent_task_id" IS 'Optional reference to the Etape (parent task) this task belongs to';
-- Index to quickly fetch Etapes per tablo and their ordering, plus children by parent
CREATE INDEX "tasks_parent_scope_idx" ON "public"."tasks" USING btree ("tablo_id", "is_parent", "position");
CREATE INDEX "tasks_parent_task_id_idx" ON "public"."tasks" USING btree ("parent_task_id");
-- Update tasks_with_assignee view to expose the new fields
CREATE OR REPLACE VIEW "public"."tasks_with_assignee" WITH ("security_invoker"='true') AS
SELECT
t.id,
t.tablo_id,
t.title,
t.description,
t.status,
t.assignee_id,
t.position,
t.created_at,
t.updated_at,
p.name AS assignee_name,
p.avatar_url AS assignee_avatar,
t.is_parent,
t.parent_task_id
FROM "public"."tasks" t
LEFT JOIN "public"."profiles" p ON t.assignee_id = p.id;
ALTER TABLE "public"."tasks_with_assignee" OWNER TO "postgres";
COMMENT ON VIEW "public"."tasks_with_assignee" IS 'View that returns tasks with assignee information from profiles';
-- Update RLS policies so only tablo owners can manipulate Etapes
DROP POLICY IF EXISTS "Users can view tasks from their tablos" ON "public"."tasks";
DROP POLICY IF EXISTS "Users can create tasks in their tablos" ON "public"."tasks";
DROP POLICY IF EXISTS "Users can update tasks in their tablos" ON "public"."tasks";
DROP POLICY IF EXISTS "Users can delete tasks in their tablos" ON "public"."tasks";
CREATE POLICY "Users can view tasks from their tablos"
ON "public"."tasks"
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM "public"."tablo_access" "ta"
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
AND "ta"."user_id" = auth.uid()
AND "ta"."is_active" = true
)
);
CREATE POLICY "Users can create tasks in their tablos"
ON "public"."tasks"
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM "public"."tablo_access" "ta"
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
AND "ta"."user_id" = auth.uid()
AND "ta"."is_active" = true
)
AND (
NOT "tasks"."is_parent"
OR EXISTS (
SELECT 1 FROM "public"."tablos" "t"
WHERE "t"."id" = "tasks"."tablo_id"
AND "t"."owner_id" = auth.uid()
)
)
);
CREATE POLICY "Users can update tasks in their tablos"
ON "public"."tasks"
FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM "public"."tablo_access" "ta"
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
AND "ta"."user_id" = auth.uid()
AND "ta"."is_active" = true
)
AND (
NOT "tasks"."is_parent"
OR EXISTS (
SELECT 1 FROM "public"."tablos" "t"
WHERE "t"."id" = "tasks"."tablo_id"
AND "t"."owner_id" = auth.uid()
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM "public"."tablo_access" "ta"
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
AND "ta"."user_id" = auth.uid()
AND "ta"."is_active" = true
)
AND (
NOT "tasks"."is_parent"
OR EXISTS (
SELECT 1 FROM "public"."tablos" "t"
WHERE "t"."id" = "tasks"."tablo_id"
AND "t"."owner_id" = auth.uid()
)
)
);
CREATE POLICY "Users can delete tasks in their tablos"
ON "public"."tasks"
FOR DELETE
USING (
EXISTS (
SELECT 1 FROM "public"."tablo_access" "ta"
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
AND "ta"."user_id" = auth.uid()
AND "ta"."is_active" = true
)
AND (
NOT "tasks"."is_parent"
OR EXISTS (
SELECT 1 FROM "public"."tablos" "t"
WHERE "t"."id" = "tasks"."tablo_id"
AND "t"."owner_id" = auth.uid()
)
)
);

View file

@ -0,0 +1,150 @@
BEGIN;
SELECT plan(6);
DO $$
DECLARE
owner_id uuid := gen_random_uuid();
member_id uuid := gen_random_uuid();
tablo_id text;
parent_task_id text;
BEGIN
-- Insert auth users
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
VALUES
(owner_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'etape_owner_' || owner_id::text || '@test.com', 'encrypted', now(), now(), now()),
(member_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'etape_member_' || member_id::text || '@test.com', 'encrypted', now(), now(), now())
ON CONFLICT DO NOTHING;
-- Insert public profiles
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
VALUES
(owner_id, 'etape_owner_' || owner_id::text || '@test.com', 'Etape', 'Owner', substring(owner_id::text from 1 for 8)),
(member_id, 'etape_member_' || member_id::text || '@test.com', 'Etape', 'Member', substring(member_id::text from 1 for 8))
ON CONFLICT DO NOTHING;
-- Create tablo as owner
PERFORM set_config('request.jwt.claims', json_build_object('sub', owner_id::text)::text, true);
INSERT INTO public.tablos (owner_id, name, status, position)
VALUES (owner_id, 'Parent Tasks Test Tablo', 'todo', 0)
RETURNING id INTO tablo_id;
-- Grant access to member
INSERT INTO public.tablo_access (tablo_id, user_id, granted_by, is_active, is_admin)
VALUES (tablo_id, member_id, owner_id, true, false);
-- Create an Etape as owner
INSERT INTO public.tasks (tablo_id, title, description, status, position, is_parent)
VALUES (tablo_id, 'Initial Étape', 'Owner created etape', 'todo', 0, true)
RETURNING id INTO parent_task_id;
-- Persist identifiers for tests
PERFORM set_config('test.owner_id', owner_id::text, true);
PERFORM set_config('test.member_id', member_id::text, true);
PERFORM set_config('test.tablo_id', tablo_id, true);
PERFORM set_config('test.parent_task_id', parent_task_id, true);
END $$;
SELECT is(
(SELECT is_parent FROM public.tasks WHERE id = current_setting('test.parent_task_id')),
true,
'Owner-created task is flagged as an Etape'
);
SELECT is(
(SELECT parent_task_id FROM public.tasks WHERE id = current_setting('test.parent_task_id')),
NULL,
'Etape cannot reference another parent task'
);
DO $$
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.owner_id'))::text, true);
UPDATE public.tasks
SET title = 'Renamed Étape'
WHERE id = current_setting('test.parent_task_id');
END $$;
SELECT is(
(SELECT title FROM public.tasks WHERE id = current_setting('test.parent_task_id')),
'Renamed Étape',
'Tablo owner can update an Etape'
);
DO $$
DECLARE
failed boolean := false;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.member_id'))::text, true);
BEGIN
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (current_setting('test.tablo_id'), 'Member Étape Attempt', 'todo', 1, true);
EXCEPTION
WHEN insufficient_privilege THEN
failed := true;
WHEN others THEN
RAISE;
END;
PERFORM set_config('test.member_etape_insert_blocked', failed::text, true);
END $$;
SELECT is(
current_setting('test.member_etape_insert_blocked', true)::boolean,
true,
'Non-owner cannot create an Etape'
);
DO $$
DECLARE
failed boolean := false;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.member_id'))::text, true);
BEGIN
UPDATE public.tasks
SET title = 'Member Update Attempt'
WHERE id = current_setting('test.parent_task_id');
EXCEPTION
WHEN insufficient_privilege THEN
failed := true;
WHEN others THEN
RAISE;
END;
PERFORM set_config('test.member_etape_update_blocked', failed::text, true);
END $$;
SELECT is(
current_setting('test.member_etape_update_blocked', true)::boolean,
true,
'Non-owner cannot update an Etape'
);
DO $$
DECLARE
child_task_id text;
BEGIN
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.member_id'))::text, true);
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES (
current_setting('test.tablo_id'),
'Child Task',
'todo',
2,
false,
current_setting('test.parent_task_id')
)
RETURNING id INTO child_task_id;
PERFORM set_config('test.child_task_id', child_task_id, true);
END $$;
SELECT ok(
(
SELECT EXISTS (
SELECT 1 FROM public.tasks WHERE id = current_setting('test.child_task_id')
)
),
'Members can still create regular tasks assigned to an Etape'
);
SELECT * FROM finish();
ROLLBACK;

View file

@ -611,6 +611,8 @@ export type Database = {
created_at: string
description: string | null
id: string
is_parent: boolean
parent_task_id: string | null
position: number
status: Database["public"]["Enums"]["task_status"]
tablo_id: string
@ -622,6 +624,8 @@ export type Database = {
created_at?: string
description?: string | null
id?: string
is_parent?: boolean
parent_task_id?: string | null
position?: number
status?: Database["public"]["Enums"]["task_status"]
tablo_id: string
@ -633,6 +637,8 @@ export type Database = {
created_at?: string
description?: string | null
id?: string
is_parent?: boolean
parent_task_id?: string | null
position?: number
status?: Database["public"]["Enums"]["task_status"]
tablo_id?: string
@ -640,6 +646,20 @@ export type Database = {
updated_at?: string
}
Relationships: [
{
foreignKeyName: "tasks_parent_task_id_fkey"
columns: ["parent_task_id"]
isOneToOne: false
referencedRelation: "tasks"
referencedColumns: ["id"]
},
{
foreignKeyName: "tasks_parent_task_id_fkey"
columns: ["parent_task_id"]
isOneToOne: false
referencedRelation: "tasks_with_assignee"
referencedColumns: ["id"]
},
{
foreignKeyName: "tasks_tablo_id_fkey"
columns: ["tablo_id"]
@ -709,6 +729,8 @@ export type Database = {
created_at: string | null
description: string | null
id: string | null
is_parent: boolean | null
parent_task_id: string | null
position: number | null
status: Database["public"]["Enums"]["task_status"] | null
tablo_id: string | null
@ -716,6 +738,20 @@ export type Database = {
updated_at: string | null
}
Relationships: [
{
foreignKeyName: "tasks_parent_task_id_fkey"
columns: ["parent_task_id"]
isOneToOne: false
referencedRelation: "tasks"
referencedColumns: ["id"]
},
{
foreignKeyName: "tasks_parent_task_id_fkey"
columns: ["parent_task_id"]
isOneToOne: false
referencedRelation: "tasks_with_assignee"
referencedColumns: ["id"]
},
{
foreignKeyName: "tasks_tablo_id_fkey"
columns: ["tablo_id"]