First version of parent tasks
This commit is contained in:
parent
d4afb0e9bb
commit
cebafbdb2e
14 changed files with 1109 additions and 42 deletions
108
apps/main/src/components/TabloOverviewSection.test.tsx
Normal file
108
apps/main/src/components/TabloOverviewSection.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
300
apps/main/src/components/TabloOverviewSection.tsx
Normal file
300
apps/main/src/components/TabloOverviewSection.tsx
Normal 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 été 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
139
supabase/migrations/20251117213349_add_parent_tasks.sql
Normal file
139
supabase/migrations/20251117213349_add_parent_tasks.sql
Normal 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()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
150
supabase/tests/database/11_parent_tasks.test.sql
Normal file
150
supabase/tests/database/11_parent_tasks.test.sql
Normal 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;
|
||||
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue