Ship ship ship the new features (tasks, etapes, notifs)

This commit is contained in:
Arthur Belleville 2025-11-22 17:22:57 +01:00
parent 2e16353f5e
commit 7ec848e37e
No known key found for this signature in database
47 changed files with 1052 additions and 625 deletions

View file

@ -149,7 +149,7 @@ export async function setupTestDatabase(): Promise<TestDatabaseData> {
owner_id: users[tablo.owner_key].userId,
name: tablo.name,
color: tablo.color,
status: tablo.status,
// status is now computed from etapes, not set directly
position: tablo.position,
}));

View file

@ -27,24 +27,24 @@ describe("CreateTabloModal", () => {
it("renders without crashing", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
expect(screen.getByText("Create a new project")).toBeInTheDocument();
expect(screen.getByText("Create a new tablo")).toBeInTheDocument();
});
it("displays name input field", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
expect(screen.getByPlaceholderText("Enter project name")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter tablo name")).toBeInTheDocument();
});
it("allows typing in name input", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
const input = screen.getByPlaceholderText("Enter project name") as HTMLInputElement;
const input = screen.getByPlaceholderText("Enter tablo name") as HTMLInputElement;
fireEvent.change(input, { target: { value: "New Tablo" } });
expect(input.value).toBe("New Tablo");
});
it("calls onCreate when create button is clicked with valid name", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
const input = screen.getByPlaceholderText("Enter project name");
const input = screen.getByPlaceholderText("Enter tablo name");
fireEvent.change(input, { target: { value: "New Tablo" } });
const createButton = screen.getByText("Create");
@ -52,7 +52,6 @@ describe("CreateTabloModal", () => {
expect(mockOnCreate).toHaveBeenCalledWith({
name: "New Tablo",
status: "todo",
image: null,
color: "bg-blue-500",
});
@ -79,11 +78,6 @@ describe("CreateTabloModal", () => {
expect(mockOnClose).toHaveBeenCalled();
});
it("renders StatusPicker component", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
expect(screen.getByText("À faire")).toBeInTheDocument();
});
it("renders ImageColorPicker component", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
expect(screen.getByText("Style")).toBeInTheDocument();
@ -91,7 +85,7 @@ describe("CreateTabloModal", () => {
it("resets form after successful creation", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
const input = screen.getByPlaceholderText("Enter project name") as HTMLInputElement;
const input = screen.getByPlaceholderText("Enter tablo name") as HTMLInputElement;
fireEvent.change(input, { target: { value: "New Tablo" } });
const createButton = screen.getByText("Create");
@ -102,7 +96,7 @@ describe("CreateTabloModal", () => {
it("disables create button when in image mode", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
const input = screen.getByPlaceholderText("Enter project name");
const input = screen.getByPlaceholderText("Enter tablo name");
fireEvent.change(input, { target: { value: "New Tablo" } });
// Switch to image mode

View file

@ -3,14 +3,12 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
type Tablo = Database["public"]["Tables"]["tablos"]["Row"];
type StatusType = "todo" | "in_progress" | "done";
interface CreateTabloModalProps {
onClose: () => void;
onCreate: (tabloData: Pick<Tablo, "name" | "color" | "image" | "status">) => void;
onCreate: (tabloData: Pick<Tablo, "name" | "color" | "image">) => void;
}
export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) => {
@ -21,7 +19,6 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
"https://images.unsplash.com/photo-1553877522-43269d4ea984?w=400&h=250&fit=crop&crop=center"
);
const [selectedColor, setSelectedColor] = useState("bg-blue-500");
const [selectedStatus, setSelectedStatus] = useState<StatusType>("todo");
const resetForm = () => {
setNewTabloName("");
@ -30,7 +27,6 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
"https://images.unsplash.com/photo-1553877522-43269d4ea984?w=400&h=250&fit=crop&crop=center"
);
setSelectedColor("bg-blue-500");
setSelectedStatus("todo");
};
const handleClose = () => {
@ -42,7 +38,7 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
if (newTabloName.trim()) {
const tabloData = {
name: newTabloName.trim(),
status: selectedStatus,
// Note: status is now computed from etapes and will default to 'todo' until etapes are created
...(creationMode === "image"
? { image: selectedImage, color: null }
: { image: null, color: selectedColor }),
@ -76,8 +72,6 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
/>
</div>
<StatusPicker selectedStatus={selectedStatus} setSelectedStatus={setSelectedStatus} />
<ImageColorPicker
creationMode={creationMode}
setCreationMode={setCreationMode}

View file

@ -5,6 +5,7 @@ import { TabloOverviewSection } from "./TabloOverviewSection";
const mockUseTablo = vi.fn();
const mockUseTabloEtapes = vi.fn();
const mockUseTasksByTablo = vi.fn();
const createEtapeMock = { mutateAsync: vi.fn(), isPending: false };
const updateEtapeMock = { mutateAsync: vi.fn(), isPending: false };
const deleteEtapeMock = { mutateAsync: vi.fn(), isPending: false };
@ -16,6 +17,7 @@ vi.mock("../hooks/tablos", () => ({
vi.mock("../hooks/tasks", () => ({
useTabloEtapes: (tabloId: string) => mockUseTabloEtapes(tabloId),
useTasksByTablo: (tabloId: string) => mockUseTasksByTablo(tabloId),
useCreateEtape: () => createEtapeMock,
useUpdateEtape: () => updateEtapeMock,
useDeleteEtape: () => deleteEtapeMock,
@ -61,6 +63,10 @@ beforeEach(() => {
data: [etapeFactory()],
isLoading: false,
});
mockUseTasksByTablo.mockReturnValue({
data: [],
isLoading: false,
});
createEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
updateEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
deleteEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
@ -70,31 +76,33 @@ beforeEach(() => {
describe("TabloOverviewSection", () => {
it("shows the Étape creation input for the tablo owner", () => {
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin />);
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin />, { language: "fr" });
expect(screen.getByPlaceholderText("Ajouter une nouvelle Étape")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /ajouter/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Nom de l'Étape")).toBeInTheDocument();
expect(screen.getByText("Ajouter l'Étape")).toBeInTheDocument();
});
it("hides management actions for non owners", () => {
mockUseTablo.mockReturnValue({ data: { owner_id: "another-user" } });
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin={false} />);
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin={false} />, {
language: "fr",
});
expect(screen.queryByPlaceholderText("Ajouter une nouvelle Étape")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Nom de l'Étape")).not.toBeInTheDocument();
expect(
screen.getByText("Seul le propriétaire du tablo peut modifier les Étapes.", {
exact: false,
})
screen.getByText(
"Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape."
)
).toBeInTheDocument();
});
it("calls the create mutation when adding a new Étape", async () => {
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin />);
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin />, { language: "fr" });
const input = screen.getByPlaceholderText("Ajouter une nouvelle Étape");
const input = screen.getByPlaceholderText("Nom de l'Étape");
fireEvent.change(input, { target: { value: "Kick-off" } });
fireEvent.click(screen.getByRole("button", { name: /ajouter/i }));
fireEvent.click(screen.getByText("Ajouter l'Étape"));
await waitFor(() => {
expect(createEtapeMock.mutateAsync).toHaveBeenCalledWith({

View file

@ -1,10 +1,10 @@
import { pluralize, toast } from "@xtablo/shared";
import { toast } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import { Progress } from "@xtablo/ui/components/progress";
import { TypographyH3, TypographyMuted, TypographyP } from "@xtablo/ui/components/typography";
import { ArrowDown, ArrowUp, Check, Edit2, Loader2, Trash2, X } from "lucide-react";
import { ArrowDown, ArrowUp, Check, Edit2, Loader2, Plus, Trash2, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@ -15,7 +15,6 @@ import {
useTasksByTablo,
useUpdateEtape,
} from "../hooks/tasks";
import { getEtapeColor } from "../utils/etapeColors";
interface TabloOverviewSectionProps {
tablo: UserTablo;
@ -65,8 +64,8 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
const title = newEtapeTitle.trim();
if (!title) {
toast.add({
title: "Erreur",
description: "Le nom de l'Étape est requis",
title: t("common:errors.error"),
description: t("tablo:etape.nameRequired"),
type: "error",
});
return;
@ -105,11 +104,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
};
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.`
)
) {
if (!window.confirm(t("tablo:etape.deleteConfirm", { name: etapeTitle }))) {
return;
}
@ -144,16 +139,14 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
if (!sortedEtapes.length) {
return (
<div className="rounded-lg border border-dashed border-border p-6 text-center">
<TypographyP className="text-muted-foreground">
Aucune Étape n'a encore é définie pour ce tablo.
</TypographyP>
<TypographyP className="text-muted-foreground">{t("tablo:etape.noEtapes")}</TypographyP>
{canManageEtapes ? (
<TypographyP className="text-sm text-muted-foreground mt-2">
Créez votre première Étape pour structurer les tâches du tablo.
{t("tablo:etape.createFirstEtape")}
</TypographyP>
) : (
<TypographyP className="text-sm text-muted-foreground mt-2">
Seul le propriétaire du tablo peut ajouter des Étapes.
{t("tablo:etape.onlyOwnerCanAdd")}
</TypographyP>
)}
</div>
@ -164,12 +157,8 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
<ul className="space-y-3">
{sortedEtapes.map((etape, index) => {
const isEditing = editingEtapeId === etape.id;
const etapeColor = getEtapeColor(etape.position);
return (
<li
key={etape.id}
className={`flex items-start gap-3 rounded-lg border px-4 py-3 ${etapeColor.bg} ${etapeColor.border}`}
>
<li key={etape.id} className="flex items-start gap-3 rounded-lg border px-4 py-3">
{canManageEtapes && (
<div className="flex flex-col gap-1 pt-1">
<Button
@ -197,7 +186,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
<Input
value={editingTitle}
onChange={(event) => setEditingTitle(event.target.value)}
placeholder="Nom de l'Étape"
placeholder={t("tablo:etape.namePlaceholder")}
autoFocus
/>
<div className="flex items-center gap-2">
@ -208,7 +197,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
disabled={updateEtape.isPending}
>
<X className="mr-2 h-4 w-4" />
Annuler
{t("common:actions.cancel")}
</Button>
<Button
size="sm"
@ -220,35 +209,36 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
) : (
<Check className="mr-2 h-4 w-4" />
)}
Enregistrer
{t("common:actions.save")}
</Button>
</div>
</div>
) : (
<>
<TypographyP className={`text-base font-medium ${etapeColor.text}`}>
{etape.title}
</TypographyP>
<TypographyMuted className={`text-xs ${etapeColor.text} opacity-70`}>
Étape {etape.position + 1}
<TypographyP className="text-base font-medium">{etape.title}</TypographyP>
<TypographyMuted className="text-xs opacity-70">
{t("tablo:etape.stepNumber", { number: etape.position + 1 })}
</TypographyMuted>
{(() => {
const { total, done, ongoing } = getEtapeTaskCounts(etape.id);
return (
<div className={`flex gap-3 mt-2 text-xs ${etapeColor.text}`}>
<span className="opacity-70">
<span className="font-medium opacity-100">{total}</span>{" "}
{pluralize("tâche", total)}
<div className="flex gap-3 mt-2 text-xs">
<span className="text-muted-foreground">
<span className="font-medium text-foreground">{total}</span>{" "}
{t(
`tablo:tasks.task_${total === 0 || total > 1 ? "plural" : "singular"}`
)}
</span>
{ongoing > 0 && (
<span className="opacity-90">
<span className="font-medium">{ongoing}</span> en cours
<span className="text-blue-600 dark:text-blue-400">
<span className="font-medium">{ongoing}</span>{" "}
{t("tablo:tasks.inProgress")}
</span>
)}
{done > 0 && (
<span className="opacity-90">
<span className="text-green-600 dark:text-green-400">
<span className="font-medium">{done}</span>{" "}
{pluralize("terminée", done)}
{t(`tablo:tasks.completed_${done > 1 ? "plural" : "singular"}`)}
</span>
)}
</div>
@ -291,16 +281,17 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
return (
<div className="space-y-6">
<div>
<TypographyH3 className="text-3xl font-bold text-foreground">Vue d'ensemble</TypographyH3>
<TypographyH3 className="text-3xl font-bold text-foreground">
{t("tablo:overview.title")}
</TypographyH3>
<TypographyMuted className="text-muted-foreground mt-1">
Configurez les Étapes du tablo pour clarifier les grandes phases de votre projet.
{t("tablo:overview.description")}
</TypographyMuted>
</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.
{t("tablo:etape.onlyOwnerCanModify")}
</div>
)}
@ -310,12 +301,13 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
<div className="flex items-center justify-between mb-3">
<div>
<TypographyP className="text-sm font-medium text-foreground">
Progression globale
{t("tablo:overview.overallProgress")}
</TypographyP>
<TypographyMuted className="text-xs">
{overallProgress.done} sur {overallProgress.total}{" "}
{pluralize("tâche", overallProgress.total)}{" "}
{pluralize("terminée", overallProgress.done)}
{t("tablo:overview.progressSummary", {
done: overallProgress.done,
total: overallProgress.total,
})}
</TypographyMuted>
</div>
<div className="text-2xl font-bold text-primary">{overallProgress.percentage}%</div>
@ -333,11 +325,12 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
<Input
value={newEtapeTitle}
onChange={(event) => setNewEtapeTitle(event.target.value)}
placeholder="Nom de l'Étape"
placeholder={t("tablo:etape.namePlaceholder")}
className="h-9 sm:w-64"
/>
<Button onClick={handleCreateEtape} disabled={createEtape.isPending}>
{t("common:actions.add", "Nouvelle Étape")}
<Plus className="h-4 w-4" />
{t("tablo:etape.addNew")}
</Button>
</div>
)}

View file

@ -4,9 +4,6 @@ import { Input } from "@xtablo/ui/components/input";
import { useEffect, useRef, useState } from "react";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
type StatusType = "todo" | "in_progress" | "done";
interface TabloSettingsSectionProps {
tablo: UserTablo;
@ -41,7 +38,7 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
id: editData.id,
name: editData.name,
color: creationMode === "color" ? selectedColor : null,
status: editData.status,
// Note: status is now computed from etapes and cannot be changed directly
};
onEdit(updatedTablo);
}
@ -156,14 +153,16 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
/>
</div>
{/* Status Picker */}
{/* Status (Read-only - computed from etapes) */}
<div className="bg-card rounded-lg border border-border p-6">
<StatusPicker
selectedStatus={currentData.status as StatusType}
setSelectedStatus={(status) =>
setEditData((prev) => (prev ? { ...prev, status } : null))
}
/>
<h3 className="text-lg font-semibold text-foreground mb-4">Statut</h3>
<div className="flex items-center gap-3">
<div className="text-foreground">
{currentData.status === "todo" && "À faire"}
{currentData.status === "in_progress" && "En cours"}
{currentData.status === "done" && "Terminé"}
</div>
</div>
</div>
</div>
)}

View file

@ -10,7 +10,6 @@ import {
useTasksByTablo,
useUpdateTaskPositions,
} from "../hooks/tasks";
import { getEtapeColor } from "../utils/etapeColors";
import { KanbanBoard } from "./kanban/KanbanBoard";
import { TaskModal } from "./kanban/TaskModal";
@ -40,15 +39,6 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
[etapes]
);
const etapeColorMap = useMemo(
() =>
etapes.reduce<Record<string, ReturnType<typeof getEtapeColor>>>((map, etape) => {
map[etape.id] = getEtapeColor(etape.position);
return map;
}, {}),
[etapes]
);
// Check for tasks without parent (orphaned tasks)
const orphanedTasks = useMemo(() => {
return tasks?.filter((task) => !task.parent_task_id) || [];
@ -206,7 +196,6 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
members={members}
etapes={etapes}
etapeTitles={etapeTitleMap}
etapeColors={etapeColorMap}
onTaskClick={handleTaskClick}
onAddTask={handleAddTask}
onAddTaskInline={handleCreateTask}

View file

@ -23,7 +23,7 @@ const tutorialSteps: TutorialStep[] = [
id: "welcome",
title: "Bienvenue sur XTablo ! 🎉",
description:
"Découvrez comment XTablo peut révolutionner votre façon de gérer vos projets, équipes et collaborations.",
"Découvrez comment XTablo peut révolutionner votre façon de gérer vos tablos, équipes et collaborations.",
},
{
id: "what-is-tablo",
@ -42,7 +42,7 @@ const tutorialSteps: TutorialStep[] = [
id: "tablo-status",
title: "Gérer le statut des Tablos",
description:
"Organisez vos projets avec les statuts :\n• 📋 À faire : Projets en attente\n• 🔄 En cours : Projets actifs\n• ✅ Terminé : Projets complétés",
"Organisez vos tablos avec les statuts :\n• 📋 À faire : Tablos en attente\n• 🔄 En cours : Tablos actifs\n• ✅ Terminé : Tablos complétés",
},
{
id: "collaboration",
@ -60,7 +60,7 @@ const tutorialSteps: TutorialStep[] = [
id: "create-first-tablo",
title: "Créer votre premier Tablo",
description:
"Vous êtes maintenant prêt à utiliser XTablo ! Créez votre premier Tablo pour démarrer votre projet.",
"Vous êtes maintenant prêt à utiliser XTablo ! Créez votre premier Tablo pour commencer.",
target: "create-tablo-button",
position: "bottom",
action: () => {

View file

@ -1,168 +0,0 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
const usePrefersReducedMotion = () => {
if (typeof window === "undefined") return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
};
export const ThreeLoginBackground = () => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
if (usePrefersReducedMotion()) return;
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
containerRef.current.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x030014, 0.035);
const camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
100
);
camera.position.set(0, 0, 14);
const ambientLight = new THREE.AmbientLight(0x6d28d9, 0.6);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(0x8b5cf6, 40, 40);
pointLight.position.set(5, 5, 6);
scene.add(pointLight);
const rimLight = new THREE.PointLight(0x38bdf8, 25, 30);
rimLight.position.set(-4, -3, -6);
scene.add(rimLight);
const coreGeometry = new THREE.IcosahedronGeometry(3, 1);
const coreMaterial = new THREE.MeshStandardMaterial({
color: new THREE.Color("#8b5cf6"),
emissive: new THREE.Color("#6366f1"),
metalness: 0.85,
roughness: 0.2,
transparent: true,
opacity: 0.8,
wireframe: true,
});
const core = new THREE.Mesh(coreGeometry, coreMaterial);
scene.add(core);
const haloGeometry = new THREE.RingGeometry(4.2, 5.6, 128);
const haloMaterial = new THREE.MeshBasicMaterial({
color: 0x38bdf8,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.25,
});
const halo = new THREE.Mesh(haloGeometry, haloMaterial);
halo.rotation.x = Math.PI / 2.4;
scene.add(halo);
const orbitCount = 1200;
const orbitPositions = new Float32Array(orbitCount * 3);
for (let i = 0; i < orbitCount; i += 1) {
const radius = 7 + Math.random() * 8;
const angle = Math.random() * Math.PI * 2;
orbitPositions[i * 3] = Math.cos(angle) * radius;
orbitPositions[i * 3 + 1] = (Math.random() - 0.5) * 6;
orbitPositions[i * 3 + 2] = Math.sin(angle) * radius;
}
const orbitGeometry = new THREE.BufferGeometry();
orbitGeometry.setAttribute("position", new THREE.BufferAttribute(orbitPositions, 3));
const orbitMaterial = new THREE.PointsMaterial({
color: 0x818cf8,
size: 0.07,
transparent: true,
opacity: 0.65,
});
const orbitField = new THREE.Points(orbitGeometry, orbitMaterial);
scene.add(orbitField);
const floatingGeometry = new THREE.TetrahedronGeometry(0.35);
const floatingMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: 0x7c3aed,
emissiveIntensity: 0.4,
metalness: 0.4,
roughness: 0.3,
});
const floatingMeshes: THREE.Mesh[] = [];
for (let i = 0; i < 25; i += 1) {
const mesh = new THREE.Mesh(floatingGeometry, floatingMaterial.clone());
mesh.position.set(
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 20
);
mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
mesh.scale.setScalar(0.6 + Math.random() * 0.9);
floatingMeshes.push(mesh);
scene.add(mesh);
}
const clock = new THREE.Clock();
let frameId: number;
const animate = () => {
const elapsed = clock.getElapsedTime();
core.rotation.x = elapsed * 0.2;
core.rotation.y = elapsed * 0.3;
halo.rotation.z = elapsed * 0.1;
orbitField.rotation.y = elapsed * 0.05;
orbitField.rotation.x = Math.sin(elapsed * 0.1) * 0.05;
floatingMeshes.forEach((mesh, index) => {
mesh.position.y += Math.sin(elapsed + index) * 0.002;
mesh.rotation.x += 0.005;
mesh.rotation.y += 0.008;
});
camera.position.x = Math.sin(elapsed * 0.2) * 1.5;
camera.position.y = Math.cos(elapsed * 0.15) * 0.8;
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
frameId = requestAnimationFrame(animate);
};
animate();
const handleResize = () => {
const { innerWidth, innerHeight } = window;
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener("resize", handleResize);
renderer.dispose();
coreGeometry.dispose();
haloGeometry.dispose();
orbitGeometry.dispose();
floatingGeometry.dispose();
containerRef.current?.removeChild(renderer.domElement);
};
}, []);
return (
<div
ref={containerRef}
className="absolute inset-0 -z-10 pointer-events-none"
aria-hidden="true"
/>
);
};

View file

@ -5,7 +5,6 @@ import type {
TaskStatus,
} from "@xtablo/shared-types";
import { useState } from "react";
import type { getEtapeColor } from "../../utils/etapeColors";
import { KanbanColumn } from "./KanbanColumn";
import type { TabloMember } from "./types";
@ -14,7 +13,6 @@ interface KanbanBoardProps {
members: TabloMember[];
etapes: Etape[];
etapeTitles: Record<string, string>;
etapeColors: Record<string, ReturnType<typeof getEtapeColor>>;
onTaskClick: (task: KanbanTask) => void;
onAddTask: (status: TaskStatus) => void;
onAddTaskInline: (task: {
@ -31,7 +29,6 @@ export const KanbanBoard = ({
columns,
members,
etapes,
etapeColors,
onTaskClick,
onAddTask,
onAddTaskInline,
@ -65,7 +62,6 @@ export const KanbanBoard = ({
column={column}
members={members}
etapes={etapes}
etapeColors={etapeColors}
onTaskClick={onTaskClick}
onAddTask={onAddTask}
onAddTaskInline={onAddTaskInline}

View file

@ -6,7 +6,6 @@ import type {
} from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import { Plus } from "lucide-react";
import type { getEtapeColor } from "../../utils/etapeColors";
import { InlineTaskCreate } from "./InlineTaskCreate";
import { KanbanTaskCard } from "./KanbanTaskCard";
import type { TabloMember } from "./types";
@ -15,7 +14,6 @@ interface KanbanColumnProps {
column: KanbanColumnType;
members: TabloMember[];
etapes: Etape[];
etapeColors: Record<string, ReturnType<typeof getEtapeColor>>;
onTaskClick: (task: KanbanTask) => void;
onAddTask: (status: KanbanColumnType["status"]) => void;
onAddTaskInline: (task: {
@ -34,7 +32,6 @@ export const KanbanColumn = ({
column,
members,
etapes,
etapeColors,
onTaskClick,
onAddTask,
onAddTaskInline,
@ -86,7 +83,6 @@ export const KanbanColumn = ({
<KanbanTaskCard
task={task}
etapeTitle={etape?.title}
etapeColor={task.parent_task_id ? etapeColors[task.parent_task_id] : undefined}
onClick={() => onTaskClick(task)}
/>
</div>

View file

@ -1,16 +1,14 @@
import type { KanbanTask } from "@xtablo/shared-types";
import { TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography";
import { User } from "lucide-react";
import type { getEtapeColor } from "../../utils/etapeColors";
interface KanbanTaskCardProps {
task: KanbanTask;
etapeTitle?: string;
etapeColor?: ReturnType<typeof getEtapeColor>;
onClick: () => void;
}
export const KanbanTaskCard = ({ task, etapeTitle, etapeColor, onClick }: KanbanTaskCardProps) => {
export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProps) => {
return (
<div
onClick={onClick}
@ -26,13 +24,11 @@ export const KanbanTaskCard = ({ task, etapeTitle, etapeColor, onClick }: Kanban
</TypographyMuted>
)}
{/* Status Pill with Etape Color */}
{/* Status Pill */}
<div className="mb-2">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium border ${
etapeColor
? `${etapeColor.bg} ${etapeColor.text} ${etapeColor.border}`
: "bg-muted text-muted-foreground border-muted"
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"}

View file

@ -29,16 +29,18 @@ export const useTablosList = () => {
// Fetch single tablo
export const useTablo = (id: string) => {
const user = useUser();
return useQuery({
queryKey: ["tablos", id],
queryFn: async () => {
const { data, error } = await supabase
.from("tablos")
.from("user_tablos")
.select("*")
.eq("id", id)
.is("deleted_at", null);
.eq("user_id", user.id)
.single();
if (error) throw error;
return data[0];
return data as UserTablo;
},
});
};

View file

@ -10,9 +10,34 @@ import notesEn from "./locales/en/notes.json";
import pagesEn from "./locales/en/pages.json";
import planningEn from "./locales/en/planning.json";
import settingsEn from "./locales/en/settings.json";
import tabloEn from "./locales/en/tablo.json";
import authFr from "./locales/fr/auth.json";
import availabilitiesFr from "./locales/fr/availabilities.json";
import commonFr from "./locales/fr/common.json";
import componentsFr from "./locales/fr/components.json";
import modalsFr from "./locales/fr/modals.json";
import navigationFr from "./locales/fr/navigation.json";
import notesFr from "./locales/fr/notes.json";
import pagesFr from "./locales/fr/pages.json";
import planningFr from "./locales/fr/planning.json";
import settingsFr from "./locales/fr/settings.json";
import tabloFr from "./locales/fr/tablo.json";
i18n.use(initReactI18next).init({
resources: {
fr: {
common: commonFr,
navigation: navigationFr,
pages: pagesFr,
settings: settingsFr,
availabilities: availabilitiesFr,
auth: authFr,
planning: planningFr,
modals: modalsFr,
components: componentsFr,
notes: notesFr,
tablo: tabloFr,
},
en: {
common: commonEn,
navigation: navigationEn,
@ -24,6 +49,7 @@ i18n.use(initReactI18next).init({
modals: modalsEn,
components: componentsEn,
notes: notesEn,
tablo: tabloEn,
},
},
lng: "en",

View file

@ -11,6 +11,7 @@ import notesEn from "./locales/en/notes.json";
import pagesEn from "./locales/en/pages.json";
import planningEn from "./locales/en/planning.json";
import settingsEn from "./locales/en/settings.json";
import tabloEn from "./locales/en/tablo.json";
import authFr from "./locales/fr/auth.json";
import availabilitiesFr from "./locales/fr/availabilities.json";
// Import translation files
@ -22,6 +23,7 @@ import notesFr from "./locales/fr/notes.json";
import pagesFr from "./locales/fr/pages.json";
import planningFr from "./locales/fr/planning.json";
import settingsFr from "./locales/fr/settings.json";
import tabloFr from "./locales/fr/tablo.json";
i18n
.use(LanguageDetector)
@ -39,6 +41,7 @@ i18n
modals: modalsFr,
components: componentsFr,
notes: notesFr,
tablo: tabloFr,
},
en: {
common: commonEn,
@ -51,6 +54,7 @@ i18n
modals: modalsEn,
components: componentsEn,
notes: notesEn,
tablo: tabloEn,
},
},
lng: "fr",

View file

@ -27,7 +27,10 @@
"sort": "Sort",
"loading": "Loading...",
"saving": "Saving...",
"deleting": "Deleting..."
"deleting": "Deleting...",
"save": "Save",
"cancel": "Cancel",
"add": "Add"
},
"messages": {
"success": "Success",
@ -36,6 +39,9 @@
"info": "Information",
"confirm_delete": "Are you sure you want to delete this item?"
},
"errors": {
"error": "Error"
},
"labels": {
"name": "Name",
"description": "Description",

View file

@ -1,7 +1,7 @@
{
"createTablo": {
"title": "Create a new project",
"nameLabel": "Project name",
"namePlaceholder": "Enter project name"
"title": "Create a new tablo",
"nameLabel": "Tablo name",
"namePlaceholder": "Enter tablo name"
}
}

View file

@ -1,5 +1,5 @@
{
"projects": "Projects",
"projects": "Tablos",
"myEvents": "My Events",
"planning": "Planning",
"discussions": "Discussions",

View file

@ -1,12 +1,12 @@
{
"tablo": {
"title": "Projects",
"subtitle": "Manage your projects and collaborations",
"createButton": "New project",
"title": "Tablos",
"subtitle": "Manage your tablos and collaborations",
"createButton": "New tablo",
"emptyState": {
"title": "No projects found",
"description": "Create your first project to start organizing your work",
"button": "Create your first project"
"title": "No tablos found",
"description": "Create your first tablo to start organizing your work",
"button": "Create your first tablo"
},
"filter": {
"all": "All",
@ -30,7 +30,7 @@
"contextMenu": {
"openDiscussions": "Open discussions",
"openPlanning": "Open planning",
"delete": "Delete project"
"delete": "Delete tablo"
},
"kpis": {
"total": "Total",

View file

@ -1,8 +1,8 @@
{
"title": "Planning",
"allEvents": "All events",
"allTablos": "All projects",
"selectTablo": "Select a project",
"allTablos": "All tablos",
"selectTablo": "Select a tablo",
"createEvent": "Create event",
"importPlanning": "Import planning",
"today": "Today",

View file

@ -0,0 +1,26 @@
{
"overview": {
"title": "Overview",
"description": "Configure the Stages of the tablo to clarify the major phases of your tablo.",
"overallProgress": "Overall Progress",
"progressSummary": "{{done}} of {{total}} task(s) completed"
},
"etape": {
"nameRequired": "The Stage name is required",
"namePlaceholder": "Stage Name",
"deleteConfirm": "Are you sure you want to delete the Stage \"{{name}}\"? Associated tasks will remain available.",
"noEtapes": "No Stages have been defined for this tablo yet.",
"createFirstEtape": "Create your first Stage to structure the tablo tasks.",
"onlyOwnerCanAdd": "Only the tablo owner can add Stages.",
"onlyOwnerCanModify": "Only the tablo owner can modify Stages. Contact the administrator if you need a new Stage.",
"stepNumber": "Stage {{number}}",
"addNew": "New Stage"
},
"tasks": {
"task_singular": "task",
"task_plural": "tasks",
"inProgress": "in progress",
"completed_singular": "completed",
"completed_plural": "completed"
}
}

View file

@ -27,7 +27,10 @@
"sort": "Trier",
"loading": "Chargement...",
"saving": "Enregistrement...",
"deleting": "Suppression..."
"deleting": "Suppression...",
"save": "Enregistrer",
"cancel": "Annuler",
"add": "Ajouter"
},
"messages": {
"success": "Succès",
@ -36,6 +39,9 @@
"info": "Information",
"confirm_delete": "Êtes-vous sûr de vouloir supprimer cet élément ?"
},
"errors": {
"error": "Erreur"
},
"labels": {
"name": "Nom",
"description": "Description",

View file

@ -1,7 +1,7 @@
{
"createTablo": {
"title": "Créer un nouveau projet",
"nameLabel": "Nom du projet",
"namePlaceholder": "Entrez le nom du projet"
"title": "Créer un nouveau tablo",
"nameLabel": "Nom du tablo",
"namePlaceholder": "Entrez le nom du tablo"
}
}

View file

@ -1,5 +1,5 @@
{
"projects": "Projets",
"projects": "Tablos",
"myEvents": "Mes Événements",
"planning": "Planning",
"discussions": "Discussions",

View file

@ -1,16 +1,16 @@
{
"tablo": {
"title": "Projets",
"subtitle": "Gérez vos projets et collaborations",
"createButton": "Nouveau projet",
"title": "Tablos",
"subtitle": "Gérez vos tablos et collaborations",
"createButton": "Nouveau tablo",
"emptyState": {
"title": "Aucun projet trouvé",
"description": "Créez votre premier projet pour commencer à organiser votre travail",
"button": "Créer votre premier projet"
"title": "Aucun tablo trouvé",
"description": "Créez votre premier tablo pour commencer à organiser votre travail",
"button": "Créer votre premier tablo"
},
"filter": {
"all": "Tous",
"todo": "À faire",
"todo": "Pas commencé",
"inProgress": "En cours",
"done": "Terminé"
},
@ -30,7 +30,7 @@
"contextMenu": {
"openDiscussions": "Ouvrir les discussions",
"openPlanning": "Ouvrir le planning",
"delete": "Supprimer le projet"
"delete": "Supprimer le tablo"
},
"kpis": {
"total": "Total",

View file

@ -1,8 +1,8 @@
{
"title": "Planning",
"allEvents": "Tous les événements",
"allTablos": "Tous les projets",
"selectTablo": "Sélectionner un projet",
"allTablos": "Tous les tablos",
"selectTablo": "Sélectionner un tablo",
"createEvent": "Créer un événement",
"importPlanning": "Importer un planning",
"today": "Aujourd'hui",

View file

@ -0,0 +1,26 @@
{
"overview": {
"title": "Vue d'ensemble",
"description": "Configurez les Étapes du tablo pour clarifier les grandes phases de votre tablo.",
"overallProgress": "Progression globale",
"progressSummary": "{{done}} sur {{total}} tâche(s) terminée(s)"
},
"etape": {
"nameRequired": "Le nom de l'Étape est requis",
"namePlaceholder": "Nom de l'Étape",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer l'Étape \"{{name}}\" ? Les tâches associées resteront disponibles.",
"noEtapes": "Aucune Étape n'a encore été définie pour ce tablo.",
"createFirstEtape": "Créez votre première Étape pour structurer les tâches du tablo.",
"onlyOwnerCanAdd": "Seul le propriétaire du tablo peut ajouter des Étapes.",
"onlyOwnerCanModify": "Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape.",
"stepNumber": "Étape {{number}}",
"addNew": "Ajouter l'Étape"
},
"tasks": {
"task_singular": "tâche",
"task_plural": "tâches",
"inProgress": "en cours",
"completed_singular": "terminée",
"completed_plural": "terminées"
}
}

View file

@ -2,12 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { PublicBookingPage } from "./PublicBookingPage";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../hooks/eventTypes", () => ({
usePublicEventType: () => ({
eventType: {

View file

@ -3,12 +3,6 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { PublicNotePage } from "./PublicNotePage";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../hooks/notes", () => ({
usePublicNote: () => ({
note: {

View file

@ -4,12 +4,6 @@ import { FeedbackPage } from "./feedback";
const mockCreateFeedback = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../hooks/feedback", () => ({
useCreateFeedback: () => ({
createFeedback: mockCreateFeedback,

View file

@ -40,7 +40,7 @@ export const LandingPage = () => {
</div>
<h1 className="text-6xl font-bold text-slate-900 dark:text-white mb-6 relative">
Un client, un projet, un espace de travail
Un client, un tablo, un espace de travail
</h1>
<p className="text-xl text-slate-700 dark:text-white mb-12 max-w-3xl mx-auto relative">
Avec XTablo, créez un groupe et discutez avec vos clients et collaborateurs sans email,
@ -92,7 +92,7 @@ export const LandingPage = () => {
🤔 Le problème aujourd&apos;hui
</h2>
<p className="text-xl text-slate-700 dark:text-white max-w-2xl mx-auto mb-8">
Vous lancez un projet avec un client ?<br />
Vous lancez un tablo avec un client ?<br />
Vous jonglez entre messages WhatsApp, briefs Notion, liens Calendly, et retours par
e-mail ?
</p>
@ -137,7 +137,7 @@ export const LandingPage = () => {
<h3 className="text-xl font-semibold text-red-800 dark:text-red-300 mb-2">
Impression de flou
</h3>
<p className="text-red-700 dark:text-red-200">Confusion dès le départ du projet</p>
<p className="text-red-700 dark:text-red-200">Confusion dès le départ du tablo</p>
</div>
</div>
</section>
@ -165,7 +165,7 @@ export const LandingPage = () => {
Créer un groupe dédié
</h3>
<p className="text-green-700 dark:text-green-200">
Un espace unique pour chaque projet
Un espace unique pour chaque tablo
</p>
</div>
<div
@ -200,7 +200,7 @@ export const LandingPage = () => {
<div className="text-center">
<p className="text-lg font-medium text-green-800 dark:text-green-300">
Objectif : poser les bases d&apos;un projet aligné, dès le jour 1.
Objectif : poser les bases d&apos;un tablo aligné, dès le jour 1.
</p>
</div>
</div>
@ -212,7 +212,7 @@ export const LandingPage = () => {
🚧 Ce qu&apos;on construit avec vous
</h2>
<p className="text-xl text-slate-700 dark:text-white max-w-2xl mx-auto mb-8">
XTablo deviendra bientôt un hub projet tout-en-un :
XTablo deviendra bientôt un hub tablo tout-en-un :
</p>
</div>
@ -318,7 +318,7 @@ export const LandingPage = () => {
🎯 Notre objectif
</p>
<p className="text-slate-700 dark:text-white">
Construire ensemble l&apos;outil parfait pour lancer vos projets sereinement
Construire ensemble l&apos;outil parfait pour lancer vos tablos sereinement
</p>
</div>
</div>
@ -368,7 +368,7 @@ export const LandingPage = () => {
<ul className="space-y-3">
<li className="flex items-center text-slate-700 dark:text-white">
<CheckIcon />
Groupes de projet illimités
Groupes de tablo illimités
</li>
<li className="flex items-center text-slate-700 dark:text-white">
<CheckIcon />

View file

@ -5,12 +5,6 @@ import { LoginPage } from "./login";
const mockLogin = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../hooks/auth", () => ({
useLoginEmail: () => ({
mutate: mockLogin,
@ -40,31 +34,29 @@ describe("LoginPage", () => {
it("renders all form elements", () => {
renderWithProviders(<LoginPage />);
expect(screen.getByLabelText(/common:labels.email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/common:labels.password/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /auth:login.loginButton/i })).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /log in/i })).toBeInTheDocument();
});
it("displays theme toggle button", () => {
renderWithProviders(<LoginPage />);
const themeButton = screen.getByRole("button", { name: /auth:common.themeToggle/i });
const themeButton = screen.getByRole("button", { name: /change theme/i });
expect(themeButton).toBeInTheDocument();
});
it("shows link to signup page", () => {
renderWithProviders(<LoginPage />);
const signupLink = screen.getByText(/auth:login.signupLink/i);
const signupLink = screen.getByText(/sign up/i);
expect(signupLink).toBeInTheDocument();
});
it("updates email input on change", () => {
renderWithProviders(<LoginPage />);
const emailInput = screen.getByPlaceholderText(
/auth:login.emailPlaceholder/i
) as HTMLInputElement;
const emailInput = screen.getByPlaceholderText(/your email/i) as HTMLInputElement;
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
expect(emailInput.value).toBe("test@example.com");
@ -73,9 +65,7 @@ describe("LoginPage", () => {
it("updates password input on change", () => {
renderWithProviders(<LoginPage />);
const passwordInput = screen.getByPlaceholderText(
/auth:login.passwordPlaceholder/i
) as HTMLInputElement;
const passwordInput = screen.getByPlaceholderText(/your password/i) as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "password123" } });
expect(passwordInput.value).toBe("password123");
@ -84,9 +74,9 @@ describe("LoginPage", () => {
it("submits form with email and password", async () => {
renderWithProviders(<LoginPage />);
const emailInput = screen.getByPlaceholderText(/auth:login.emailPlaceholder/i);
const passwordInput = screen.getByPlaceholderText(/auth:login.passwordPlaceholder/i);
const submitButton = screen.getByRole("button", { name: /auth:login.loginButton/i });
const emailInput = screen.getByPlaceholderText(/your email/i);
const passwordInput = screen.getByPlaceholderText(/your password/i);
const submitButton = screen.getByRole("button", { name: /log in/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "password123" } });
@ -111,7 +101,7 @@ describe("LoginPage", () => {
it("prevents form submission when fields are empty", () => {
renderWithProviders(<LoginPage />);
const submitButton = screen.getByRole("button", { name: /auth:login.loginButton/i });
const submitButton = screen.getByRole("button", { name: /log in/i });
fireEvent.click(submitButton);
// Form should not submit due to HTML5 validation (required fields)

View file

@ -8,9 +8,9 @@ import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useSearchParams } from "react-router-dom";
import { AnimatedBackground } from "src/components/AnimatedBackground";
import { twMerge } from "tailwind-merge";
import { useLoginEmail } from "../hooks/auth";
import { ThreeLoginBackground } from "../components/ThreeLoginBackground";
export function LoginPage() {
const [searchParams] = useSearchParams();
@ -96,8 +96,7 @@ export function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden">
<ThreeLoginBackground />
{/* <AnimatedBackground /> */}
<AnimatedBackground />
<div
ref={cardRef}
className={twMerge(

View file

@ -143,7 +143,7 @@ export function PrivacyPolicyPage() {
2.3. Données d'utilisation
</h3>
<ul className="list-disc pl-6 space-y-1">
<li>Informations sur vos projets et tableaux</li>
<li>Informations sur vos tablos et tableaux</li>
<li>Événements et rendez-vous créés</li>
<li>Messages et communications via la plateforme</li>
<li>Fichiers et documents partagés</li>
@ -164,7 +164,7 @@ export function PrivacyPolicyPage() {
<li>
<strong className="text-foreground">Fourniture du service :</strong> Créer et
gérer votre compte, vous permettre d'accéder à nos fonctionnalités de
planification, de collaboration et de gestion de projets.
planification, de collaboration et de gestion de tablos.
</li>
<li>
<strong className="text-foreground">Communication :</strong> Vous envoyer des
@ -226,8 +226,8 @@ export function PrivacyPolicyPage() {
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
<li>
<strong className="text-foreground">Membres de votre équipe :</strong> Les
utilisateurs avec lesquels vous collaborez sur des projets partagés peuvent
accéder aux informations que vous partagez volontairement.
utilisateurs avec lesquels vous collaborez sur des tablos partagés peuvent accéder
aux informations que vous partagez volontairement.
</li>
<li>
<strong className="text-foreground">Prestataires de services :</strong> Nous

View file

@ -3,12 +3,6 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { ResetPasswordPage } from "./reset-password";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("ResetPasswordPage", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -22,28 +16,28 @@ describe("ResetPasswordPage", () => {
it("renders form with email input", () => {
renderWithProviders(<ResetPasswordPage />);
expect(screen.getByText(/resetPassword.title/i)).toBeInTheDocument();
expect(screen.getByLabelText(/resetPassword.emailLabel/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /resetPassword.submit/i })).toBeInTheDocument();
expect(screen.getByText(/forgot your password/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /send reset link/i })).toBeInTheDocument();
});
it("displays help text", () => {
renderWithProviders(<ResetPasswordPage />);
expect(screen.getByText(/resetPassword.description/i)).toBeInTheDocument();
expect(screen.getByText(/enter your email address/i)).toBeInTheDocument();
});
it("shows link back to login", () => {
renderWithProviders(<ResetPasswordPage />);
const loginLink = screen.getByText(/resetPassword.backToLogin/i);
const loginLink = screen.getByText(/back to login/i);
expect(loginLink).toBeInTheDocument();
});
it("updates email input on change", () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i) as HTMLInputElement;
const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement;
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
expect(emailInput.value).toBe("test@example.com");
@ -52,22 +46,22 @@ describe("ResetPasswordPage", () => {
it.skip("submits form and shows success message", async () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i });
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole("button", { name: /send reset link/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/resetPassword.emailSent/i)).toBeInTheDocument();
expect(screen.getByText(/email sent/i)).toBeInTheDocument();
});
});
it.skip("displays email in success message", async () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i });
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole("button", { name: /send reset link/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.click(submitButton);
@ -80,30 +74,28 @@ describe("ResetPasswordPage", () => {
it.skip("shows return to login button in success state", async () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i });
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole("button", { name: /send reset link/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /resetPassword.backToLogin/i })
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /back to login/i })).toBeInTheDocument();
});
});
it("requires email field to be filled", () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toHaveAttribute("required");
});
it("requires valid email format", () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toHaveAttribute("type", "email");
});
});

View file

@ -5,12 +5,6 @@ import { SignUpPage } from "./signup";
const mockSignUp = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../hooks/auth", () => ({
useSignUp: () => ({
mutate: mockSignUp,
@ -39,38 +33,30 @@ describe("SignUpPage", () => {
it("renders all form fields", () => {
renderWithProviders(<SignUpPage />);
expect(screen.getByLabelText(/auth:signup.firstName/i)).toBeInTheDocument();
expect(screen.getByLabelText(/auth:signup.lastName/i)).toBeInTheDocument();
expect(screen.getByLabelText(/auth:signup.email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/common:labels.password/i)).toBeInTheDocument();
expect(screen.getByLabelText(/auth:signup.confirmPassword/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /auth:signup.signupButton/i })).toBeInTheDocument();
expect(screen.getByLabelText(/first name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/last name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/professional email/i)).toBeInTheDocument();
expect(screen.getAllByLabelText(/password/i)[0]).toBeInTheDocument();
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /create my account/i })).toBeInTheDocument();
});
it("shows link to login page", () => {
renderWithProviders(<SignUpPage />);
const loginLink = screen.getByText(/auth:signup.loginLink/i);
const loginLink = screen.getByText(/log in/i);
expect(loginLink).toBeInTheDocument();
});
it("updates form fields on change", () => {
renderWithProviders(<SignUpPage />);
const firstNameInput = screen.getByPlaceholderText(
/auth:signup.firstNamePlaceholder/i
) as HTMLInputElement;
const lastNameInput = screen.getByPlaceholderText(
/auth:signup.lastNamePlaceholder/i
) as HTMLInputElement;
const emailInput = screen.getByPlaceholderText(
/auth:signup.emailPlaceholder/i
) as HTMLInputElement;
const passwordInput = screen.getByPlaceholderText(
/auth:signup.passwordPlaceholder/i
) as HTMLInputElement;
const firstNameInput = screen.getByPlaceholderText(/your first name/i) as HTMLInputElement;
const lastNameInput = screen.getByPlaceholderText(/your last name/i) as HTMLInputElement;
const emailInput = screen.getByPlaceholderText(/your email/i) as HTMLInputElement;
const passwordInput = screen.getAllByPlaceholderText(/your password/i)[0] as HTMLInputElement;
const confirmPasswordInput = screen.getByPlaceholderText(
/auth:signup.confirmPasswordPlaceholder/i
/confirm your password/i
) as HTMLInputElement;
fireEvent.change(firstNameInput, { target: { value: "John" } });
@ -89,20 +75,18 @@ describe("SignUpPage", () => {
it("shows error when password is too short", async () => {
renderWithProviders(<SignUpPage />);
const passwordInput = screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/auth:signup.confirmPasswordPlaceholder/i
);
const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i });
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
const passwordInput = screen.getAllByPlaceholderText(/your password/i)[0];
const confirmPasswordInput = screen.getByPlaceholderText(/confirm your password/i);
const submitButton = screen.getByRole("button", { name: /create my account/i });
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
target: { value: "John" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
target: { value: "Doe" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
target: { value: "john@example.com" },
});
fireEvent.change(passwordInput, { target: { value: "short" } });
@ -111,7 +95,7 @@ describe("SignUpPage", () => {
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/auth:signup.errors.passwordLength/i)).toBeInTheDocument();
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
});
expect(mockSignUp).not.toHaveBeenCalled();
@ -120,20 +104,18 @@ describe("SignUpPage", () => {
it("shows error when passwords don't match", async () => {
renderWithProviders(<SignUpPage />);
const passwordInput = screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/auth:signup.confirmPasswordPlaceholder/i
);
const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i });
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
const passwordInput = screen.getAllByPlaceholderText(/your password/i)[0];
const confirmPasswordInput = screen.getByPlaceholderText(/confirm your password/i);
const submitButton = screen.getByRole("button", { name: /create my account/i });
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
target: { value: "John" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
target: { value: "Doe" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
target: { value: "john@example.com" },
});
fireEvent.change(passwordInput, { target: { value: "password123" } });
@ -142,7 +124,7 @@ describe("SignUpPage", () => {
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/auth:signup.errors.passwordMatch/i)).toBeInTheDocument();
expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument();
});
expect(mockSignUp).not.toHaveBeenCalled();
@ -182,22 +164,22 @@ describe("SignUpPage", () => {
it("submits form with valid data", async () => {
renderWithProviders(<SignUpPage />);
const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i });
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
const submitButton = screen.getByRole("button", { name: /create my account/i });
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
target: { value: "John" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
target: { value: "Doe" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
target: { value: "john@example.com" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i), {
fireEvent.change(screen.getAllByPlaceholderText(/your password/i)[0], {
target: { value: "password123" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.confirmPasswordPlaceholder/i), {
fireEvent.change(screen.getByPlaceholderText(/confirm your password/i), {
target: { value: "password123" },
});
fireEvent.click(termsCheckbox);
@ -218,7 +200,7 @@ describe("SignUpPage", () => {
it("requires terms checkbox to be checked", () => {
renderWithProviders(<SignUpPage />);
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
expect(termsCheckbox).toHaveAttribute("required");
});
});

View file

@ -14,6 +14,7 @@ import {
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { match } from "ts-pattern";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
@ -42,7 +43,7 @@ export const TabloDetailsPage = () => {
const { mutateAsync: updateTablo } = useUpdateTablo();
const [searchParams, setSearchParams] = useSearchParams();
const activeSection = (searchParams.get("section") as TabSection) || "files";
const activeSection = (searchParams.get("section") as TabSection) || "overview";
const [tablo, setTablo] = useState<UserTablo | null>(null);
@ -216,18 +217,18 @@ 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} />
)}
{activeSection === "notes" && <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "members" && <TabloMembersSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "settings" && (
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
)}
{match(activeSection)
.with("overview", () => <TabloOverviewSection tablo={tablo} isAdmin={isAdmin} />)
.with("files", () => <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />)
.with("discussion", () => <TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />)
.with("notes", () => <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />)
.with("events", () => <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />)
.with("tasks", () => <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />)
.with("members", () => <TabloMembersSection tablo={tablo} isAdmin={isAdmin} />)
.with("settings", () => (
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
))
.exhaustive()}
</div>
</main>
</div>

View file

@ -3,12 +3,6 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloPage } from "./tablo";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../hooks/tablos", () => ({
useTablo: () => ({
tablo: {

View file

@ -35,7 +35,7 @@ import {
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCreateTablo, useDeleteTablo, useTablosList, useUpdateTablo } from "../hooks/tablos";
import { useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
type FilterOption = {
@ -77,7 +77,7 @@ export const TabloPage = () => {
const { data: tablos, isLoading, error } = useTablosList();
const createTabloMutation = useCreateTablo();
const { mutateAsync: updateTablo } = useUpdateTablo();
// const { mutateAsync: updateTablo } = useUpdateTablo();
const { mutateAsync: deleteTablo } = useDeleteTablo();
// Filter tablos based on status
@ -146,7 +146,7 @@ export const TabloPage = () => {
switch (status) {
case "todo":
return t("pages:tablo.status.todo");
case "inProgress":
case "in_progress":
return t("pages:tablo.status.inProgress");
case "done":
return t("pages:tablo.status.done");
@ -158,28 +158,18 @@ export const TabloPage = () => {
const getStatusBadgeColor = (status: string) => {
switch (status) {
case "todo":
return "bg-muted text-muted-foreground";
case "inProgress":
return "bg-primary/10 text-primary";
return "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600";
case "in_progress":
return "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 border border-blue-300 dark:border-blue-700";
case "done":
return "bg-secondary text-secondary-foreground";
return "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border border-green-300 dark:border-green-700";
default:
return "bg-muted text-muted-foreground";
return "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600";
}
};
const changeTabloStatus = async (tabloId: string, newStatus: string) => {
try {
await updateTablo({
id: tabloId,
status: newStatus,
});
setContextMenuTablo(null);
setContextMenuPosition(null);
} catch (error) {
console.error("Error updating tablo status:", error);
}
};
// NOTE: Tablo status is now computed from etapes and cannot be changed directly
// The status change UI has been removed from the context menu
const handleDeleteTablo = (tabloId: string) => {
if (!tablos) return;
@ -247,10 +237,8 @@ export const TabloPage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">Tablos</h1>
<Text className="text-muted-foreground mt-1">
Gérez vos projets et collaborations
</Text>
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
</div>
<Button onClick={openCreateModal} disabled={isReadOnly}>
<Plus /> Nouveau tablo
@ -275,10 +263,8 @@ export const TabloPage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">Tablos</h1>
<Text className="text-muted-foreground mt-1">
Gérez vos projets et collaborations
</Text>
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
</div>
<Button onClick={openCreateModal} disabled={isReadOnly}>
<Plus /> Nouveau tablo
@ -408,46 +394,6 @@ export const TabloPage = () => {
<span>{item.name}</span>
</button>
))}
{/* Status change options - Only for admins */}
{isAdmin && (
<>
<div className="border-t border-border my-1"></div>
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
Changer le statut
</div>
<button
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
onClick={(e) => {
e.stopPropagation();
changeTabloStatus(tablo.id, "todo");
}}
>
<span>À faire</span>
{tablo.status === "todo" && <span className="text-primary"></span>}
</button>
<button
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
onClick={(e) => {
e.stopPropagation();
changeTabloStatus(tablo.id, "inProgress");
}}
>
<span>En cours</span>
{tablo.status === "inProgress" && <span className="text-primary"></span>}
</button>
<button
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
onClick={(e) => {
e.stopPropagation();
changeTabloStatus(tablo.id, "done");
}}
>
<span>Terminé</span>
{tablo.status === "done" && <span className="text-primary"></span>}
</button>
</>
)}
</div>
)}
</div>
@ -596,45 +542,6 @@ export const TabloPage = () => {
<span>{item.name}</span>
</button>
))}
{isAdmin && (
<>
<div className="border-t border-border my-1"></div>
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
Changer le statut
</div>
<button
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
onClick={(e) => {
e.stopPropagation();
changeTabloStatus(tablo.id, "todo");
}}
>
<span>À faire</span>
{tablo.status === "todo" && <span className="text-primary"></span>}
</button>
<button
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
onClick={(e) => {
e.stopPropagation();
changeTabloStatus(tablo.id, "inProgress");
}}
>
<span>En cours</span>
{tablo.status === "inProgress" && <span className="text-primary"></span>}
</button>
<button
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
onClick={(e) => {
e.stopPropagation();
changeTabloStatus(tablo.id, "done");
}}
>
<span>Terminé</span>
{tablo.status === "done" && <span className="text-primary"></span>}
</button>
</>
)}
</div>
)}
</div>

View file

@ -6,35 +6,33 @@ export const getEtapeColor = (position: number) => {
const colors = [
{
// Light/Yellow
bg: "bg-yellow-100 dark:bg-yellow-950/30",
text: "text-yellow-700 dark:text-yellow-400",
border: "border-yellow-200 dark:border-yellow-900/50",
bg: "bg-yellow-50 dark:bg-yellow-950/20",
text: "text-yellow-600 dark:text-yellow-300",
border: "border-yellow-100 dark:border-yellow-900/30",
indicator: "bg-yellow-400 dark:bg-yellow-500",
},
{
// Green
bg: "bg-green-100 dark:bg-green-950/30",
text: "text-green-700 dark:text-green-400",
border: "border-green-200 dark:border-green-900/50",
bg: "bg-green-50 dark:bg-green-950/20",
text: "text-green-600 dark:text-green-300",
border: "border-green-100 dark:border-green-900/30",
indicator: "bg-green-400 dark:bg-green-500",
},
{
// Blue
bg: "bg-blue-100 dark:bg-blue-950/30",
text: "text-blue-700 dark:text-blue-400",
border: "border-blue-200 dark:border-blue-900/50",
bg: "bg-blue-50 dark:bg-blue-950/20",
text: "text-blue-600 dark:text-blue-300",
border: "border-blue-100 dark:border-blue-900/30",
indicator: "bg-blue-400 dark:bg-blue-500",
},
{
// Gray
bg: "bg-gray-100 dark:bg-gray-800/30",
text: "text-gray-700 dark:text-gray-400",
border: "border-gray-200 dark:border-gray-700/50",
bg: "bg-gray-50 dark:bg-gray-800/20",
text: "text-gray-600 dark:text-gray-300",
border: "border-gray-100 dark:border-gray-700/30",
indicator: "bg-gray-400 dark:bg-gray-500",
},
];
return colors[position % colors.length];
};

View file

@ -4,7 +4,9 @@ import userEvent from "@testing-library/user-event";
import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import React from "react";
import { I18nextProvider } from "react-i18next";
import { BrowserRouter, MemoryRouter, Route, Routes } from "react-router-dom";
import testI18n from "../i18n.test";
import { TestUserStoreProvider } from "../providers/UserStoreProvider";
const defaultUser = {
@ -33,12 +35,15 @@ export const renderWithRouter = (ui: React.ReactNode, { route = "/" } = {}) => {
interface RenderWithProvidersOptions {
route?: string;
path?: string;
language?: string;
}
export const renderWithProviders = (
ui: React.ReactNode,
{ route, path }: RenderWithProvidersOptions = {}
{ route, path, language = "en" }: RenderWithProvidersOptions = {}
): RenderResult => {
// Set the language for this test
testI18n.changeLanguage(language);
// Create a new QueryClient instance for each test to avoid state pollution
const testQueryClient = new QueryClient({
defaultOptions: {
@ -62,28 +67,30 @@ export const renderWithProviders = (
);
return render(
<RouterWrapper {...routerProps}>
<ThemeProvider>
<QueryClientProvider client={testQueryClient}>
<SessionTestProvider
testUser={{
id: defaultUser.id,
app_metadata: {},
aud: "test",
created_at: "2021-01-01",
user_metadata: {
first_name: defaultUser.first_name,
last_name: defaultUser.last_name,
avatar_url: defaultUser.avatar_url,
full_name: defaultUser.name,
},
}}
>
<TestUserStoreProvider user={defaultUser}>{content}</TestUserStoreProvider>
</SessionTestProvider>
</QueryClientProvider>
</ThemeProvider>
</RouterWrapper>
<I18nextProvider i18n={testI18n}>
<RouterWrapper {...routerProps}>
<ThemeProvider>
<QueryClientProvider client={testQueryClient}>
<SessionTestProvider
testUser={{
id: defaultUser.id,
app_metadata: {},
aud: "test",
created_at: "2021-01-01",
user_metadata: {
first_name: defaultUser.first_name,
last_name: defaultUser.last_name,
avatar_url: defaultUser.avatar_url,
full_name: defaultUser.name,
},
}}
>
<TestUserStoreProvider user={defaultUser}>{content}</TestUserStoreProvider>
</SessionTestProvider>
</QueryClientProvider>
</ThemeProvider>
</RouterWrapper>
</I18nextProvider>
);
};

View file

@ -1,10 +1,30 @@
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "13.0.4";
graphql_public: {
Tables: {
[_ in never]: never;
};
Views: {
[_ in never]: never;
};
Functions: {
graphql: {
Args: {
extensions?: Json;
operationName?: string;
query?: string;
variables?: Json;
};
Returns: Json;
};
};
Enums: {
[_ in never]: never;
};
CompositeTypes: {
[_ in never]: never;
};
};
public: {
Tables: {
@ -570,7 +590,7 @@ export type Database = {
name: string;
owner_id: string;
position: number;
status: string;
status: string | null;
updated_at: string | null;
};
Insert: {
@ -582,7 +602,7 @@ export type Database = {
name: string;
owner_id: string;
position?: number;
status?: string;
status?: string | null;
updated_at?: string | null;
};
Update: {
@ -594,7 +614,7 @@ export type Database = {
name?: string;
owner_id?: string;
position?: number;
status?: string;
status?: string | null;
updated_at?: string | null;
};
Relationships: [];
@ -795,6 +815,10 @@ export type Database = {
};
};
Functions: {
compute_tablo_status: {
Args: { tablo_id_param: string };
Returns: string;
};
generate_random_string: { Args: { length?: number }; Returns: string };
get_my_active_subscription: {
Args: never;
@ -1017,6 +1041,9 @@ export type CompositeTypes<
: never;
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],

View file

@ -0,0 +1,129 @@
-- Migration: Deprecate tablo status and compute it from etapes
-- This migration creates a function to compute tablo status from etapes,
-- updates the user_tablos view to use the computed status, and makes
-- the status column nullable for backwards compatibility.
-- Create function to compute tablo status from etapes (parent tasks)
CREATE OR REPLACE FUNCTION "public"."compute_tablo_status"("tablo_id_param" "text")
RETURNS "text"
LANGUAGE "plpgsql"
STABLE
AS $$
DECLARE
etape_count INTEGER;
total_tasks INTEGER;
done_tasks INTEGER;
in_progress_tasks INTEGER;
computed_status TEXT;
BEGIN
-- Count total etapes for this tablo
SELECT COUNT(*)
INTO etape_count
FROM "public"."tasks"
WHERE "tablo_id" = tablo_id_param
AND "is_parent" = true;
-- If no etapes exist, return 'todo'
IF etape_count = 0 THEN
RETURN 'todo';
END IF;
-- Count tasks across all etapes (excluding parent tasks)
SELECT
COUNT(*),
COUNT(CASE WHEN "status" = 'done' THEN 1 END),
COUNT(CASE WHEN "status" IN ('in_progress', 'in_review') THEN 1 END)
INTO total_tasks, done_tasks, in_progress_tasks
FROM "public"."tasks"
WHERE "tablo_id" = tablo_id_param
AND "is_parent" = false;
-- If no child tasks exist, consider all etapes as done (empty etapes)
IF total_tasks = 0 THEN
RETURN 'done';
END IF;
-- Determine status based on task counts
-- Priority order: done > in_progress > todo
IF done_tasks = total_tasks THEN
-- All tasks are done
computed_status := 'done';
ELSIF in_progress_tasks > 0 THEN
-- At least one task is actively in progress or in review
computed_status := 'in_progress';
ELSIF done_tasks > 0 THEN
-- Some tasks are done but none are in progress (showing progress)
computed_status := 'in_progress';
ELSE
-- All tasks are todo (no progress has been made)
computed_status := 'todo';
END IF;
RETURN computed_status;
END;
$$;
ALTER FUNCTION "public"."compute_tablo_status"("text") OWNER TO "postgres";
COMMENT ON FUNCTION "public"."compute_tablo_status"("text") IS 'Computes the status of a tablo based on its etapes (parent tasks). Returns todo, in_progress, or done.';
-- Update the user_tablos view to use computed status
CREATE OR REPLACE VIEW "public"."user_tablos" WITH ("security_invoker"='true') AS
SELECT DISTINCT
"t"."id",
"ta"."user_id",
"t"."name",
"t"."image",
"t"."color",
CAST("public"."compute_tablo_status"("t"."id") AS character varying(20)) AS "status",
"t"."position",
"t"."created_at",
"t"."deleted_at",
CASE
WHEN ("ta"."is_admin" = true) THEN 'admin'::"text"
ELSE 'member'::"text"
END AS "access_level",
"ta"."is_admin"
FROM ("public"."tablos" "t"
LEFT JOIN "public"."tablo_access" "ta" ON (("t"."id" = "ta"."tablo_id")))
WHERE (("ta"."is_active" = true) AND ("t"."deleted_at" IS NULL))
ORDER BY "t"."position", "t"."created_at" DESC;
ALTER TABLE "public"."user_tablos" OWNER TO "postgres";
COMMENT ON VIEW "public"."user_tablos" IS 'View that returns all tablos accessible to the current authenticated user, with status computed from etapes';
-- Make the status column nullable in tablos table (for backwards compatibility)
-- Remove the NOT NULL constraint
ALTER TABLE "public"."tablos"
ALTER COLUMN "status" DROP NOT NULL;
-- Remove the CHECK constraint on status
ALTER TABLE "public"."tablos"
DROP CONSTRAINT IF EXISTS "tablos_status_check";
-- Add a comment to indicate the column is deprecated
COMMENT ON COLUMN "public"."tablos"."status" IS 'DEPRECATED: Status is now computed from etapes. This column is kept for backwards compatibility but should not be used.';
-- Update events_and_tablos view to use computed status as well
CREATE OR REPLACE VIEW "public"."events_and_tablos" WITH ("security_invoker"='true') AS
SELECT DISTINCT
"e"."id" AS "event_id",
"e"."title",
"e"."start_date",
"e"."start_time",
"e"."end_time",
"e"."description",
"t"."id" AS "tablo_id",
"t"."name" AS "tablo_name",
"t"."color" AS "tablo_color",
CAST("public"."compute_tablo_status"("t"."id") AS character varying(20)) AS "tablo_status"
FROM "public"."events" "e"
LEFT JOIN "public"."tablos" "t" ON ("e"."tablo_id" = "t"."id")
WHERE ("e"."deleted_at" IS NULL) AND ("t"."deleted_at" IS NULL)
ORDER BY "e"."start_date", "e"."start_time";
ALTER TABLE "public"."events_and_tablos" OWNER TO "postgres";
COMMENT ON VIEW "public"."events_and_tablos" IS 'View combining events with their associated tablo information, with status computed from etapes';

View file

@ -0,0 +1,167 @@
-- Fix test failures after deprecating tablo status
-- This migration addresses:
-- 1. Re-add check constraint on status to allow NULL or valid values
-- 2. Fix RLS policies for etape creation/update to properly block non-owners
-- ============================================================================
-- Fix 1: Add back status check constraint (but allow NULL)
-- ============================================================================
-- Add check constraint that allows NULL or the valid status values
ALTER TABLE "public"."tablos"
ADD CONSTRAINT "tablos_status_check"
CHECK (
"status" IS NULL
OR ("status")::"text" = ANY (ARRAY[
('todo'::character varying)::"text",
('in_progress'::character varying)::"text",
('done'::character varying)::"text"
])
);
COMMENT ON CONSTRAINT "tablos_status_check" ON "public"."tablos"
IS 'Allows NULL or valid status values (todo, in_progress, done). Status is deprecated and computed from etapes.';
-- ============================================================================
-- Fix 2: Ensure RLS policies properly prevent non-owners from managing etapes
-- ============================================================================
-- The issue is that the RLS policies need to explicitly check that the user
-- is the tablo owner when dealing with etapes. Let's verify the policies
-- are correctly preventing non-owner access to parent tasks.
-- Drop and recreate the INSERT policy with more explicit checks
DROP POLICY IF EXISTS "Users can create tasks in their tablos" ON "public"."tasks";
CREATE POLICY "Users can create tasks in their tablos"
ON "public"."tasks"
FOR INSERT
WITH CHECK (
-- User must have access to the tablo
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
-- If creating an etape (is_parent = true), user must be admin
AND (
(NOT "tasks"."is_parent")
OR "ta"."is_admin" = true
)
)
);
-- Drop and recreate the UPDATE policy with more explicit checks
DROP POLICY IF EXISTS "Users can update tasks in their tablos" ON "public"."tasks";
CREATE POLICY "Users can update tasks in their tablos"
ON "public"."tasks"
FOR UPDATE
USING (
-- User must have access to the tablo
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
-- If updating an etape (is_parent = true), user must be admin
AND (
(NOT "tasks"."is_parent")
OR "ta"."is_admin" = true
)
)
)
WITH CHECK (
-- Same check for the new values
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
-- If the result would be an etape, user must be admin
AND (
(NOT "tasks"."is_parent")
OR "ta"."is_admin" = true
)
)
);
-- Drop and recreate the DELETE policy for consistency
DROP POLICY IF EXISTS "Users can delete tasks in their tablos" ON "public"."tasks";
CREATE POLICY "Users can delete tasks in their tablos"
ON "public"."tasks"
FOR DELETE
USING (
-- User must have access to the tablo
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
-- If deleting an etape (is_parent = true), user must be admin
AND (
(NOT "tasks"."is_parent")
OR "ta"."is_admin" = true
)
)
);
COMMENT ON POLICY "Users can create tasks in their tablos" ON "public"."tasks"
IS 'Users can create regular tasks in tablos they have access to. Only tablo admins can create etapes (is_parent=true).';
COMMENT ON POLICY "Users can update tasks in their tablos" ON "public"."tasks"
IS 'Users can update regular tasks in tablos they have access to. Only tablo admins can update etapes (is_parent=true).';
COMMENT ON POLICY "Users can delete tasks in their tablos" ON "public"."tasks"
IS 'Users can delete regular tasks in tablos they have access to. Only tablo admins can delete etapes (is_parent=true).';
-- ============================================================================
-- Fix 3: Add trigger to explicitly validate etape permissions and raise error
-- ============================================================================
-- Create function that validates etape permissions before INSERT/UPDATE
CREATE OR REPLACE FUNCTION "public"."validate_etape_permissions"()
RETURNS TRIGGER
LANGUAGE "plpgsql"
SECURITY DEFINER
AS $$
DECLARE
user_is_admin boolean;
BEGIN
-- Only validate if this is an etape (is_parent = true)
IF NEW.is_parent = true THEN
-- Check if the current user is an admin of this tablo
SELECT "ta"."is_admin" INTO user_is_admin
FROM "public"."tablo_access" "ta"
WHERE "ta"."tablo_id" = NEW.tablo_id
AND "ta"."user_id" = auth.uid()
AND "ta"."is_active" = true
LIMIT 1;
-- If user is not found or not an admin, raise an error with proper SQLSTATE
IF user_is_admin IS NULL OR user_is_admin = false THEN
RAISE EXCEPTION USING
ERRCODE = '42501', -- insufficient_privilege
MESSAGE = 'Only tablo admins can create or modify etapes',
HINT = 'Contact the tablo owner to request admin access';
END IF;
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION "public"."validate_etape_permissions"() OWNER TO "postgres";
COMMENT ON FUNCTION "public"."validate_etape_permissions"()
IS 'Validates that only tablo admins can create or update etapes (tasks with is_parent=true)';
-- Create trigger that runs before INSERT/UPDATE on tasks
DROP TRIGGER IF EXISTS "validate_etape_permissions_trigger" ON "public"."tasks";
CREATE TRIGGER "validate_etape_permissions_trigger"
BEFORE INSERT OR UPDATE ON "public"."tasks"
FOR EACH ROW
EXECUTE FUNCTION "public"."validate_etape_permissions"();

View file

@ -50,7 +50,8 @@ SELECT col_type_is('public', 'tablos', 'position', 'integer', 'tablos.position s
SELECT col_not_null('public', 'tablos', 'owner_id', 'tablos.owner_id should be NOT NULL');
SELECT col_not_null('public', 'tablos', 'name', 'tablos.name should be NOT NULL');
SELECT col_not_null('public', 'tablos', 'status', 'tablos.status should be NOT NULL');
-- Note: status is now nullable and computed from etapes
SELECT col_is_null('public', 'tablos', 'status', 'tablos.status is now nullable (deprecated)');
SELECT col_has_default('public', 'tablos', 'status', 'tablos.status should have default');
SELECT col_has_default('public', 'tablos', 'position', 'tablos.position should have default');

View file

@ -0,0 +1,354 @@
BEGIN;
SELECT plan(15);
-- ============================================================================
-- Setup Test Data
-- ============================================================================
DO $$
DECLARE
test_user_id uuid := gen_random_uuid();
tablo_no_etapes text;
tablo_all_done text;
tablo_in_progress text;
tablo_mixed text;
tablo_empty_etapes text;
etape1_id text;
etape2_id text;
etape3_id text;
etape4_id text;
etape5_id text;
BEGIN
-- Insert test user
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
VALUES
(test_user_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'status_test_' || test_user_id::text || '@test.com', 'encrypted', now(), now(), now())
ON CONFLICT DO NOTHING;
-- Insert test profile
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
VALUES
(test_user_id, 'status_test_' || test_user_id::text || '@test.com', 'Status', 'Test', substring(test_user_id::text from 1 for 8))
ON CONFLICT DO NOTHING;
-- Set JWT context
PERFORM set_config('request.jwt.claims', json_build_object('sub', test_user_id::text)::text, true);
-- Create test tablos
-- Tablo 1: No etapes
INSERT INTO public.tablos (owner_id, name, position)
VALUES (test_user_id, 'Tablo No Etapes', 0)
RETURNING id INTO tablo_no_etapes;
-- Tablo 2: All etapes done
INSERT INTO public.tablos (owner_id, name, position)
VALUES (test_user_id, 'Tablo All Done', 1)
RETURNING id INTO tablo_all_done;
-- Tablo 3: Has in_progress tasks
INSERT INTO public.tablos (owner_id, name, position)
VALUES (test_user_id, 'Tablo In Progress', 2)
RETURNING id INTO tablo_in_progress;
-- Tablo 4: Mixed statuses
INSERT INTO public.tablos (owner_id, name, position)
VALUES (test_user_id, 'Tablo Mixed', 3)
RETURNING id INTO tablo_mixed;
-- Tablo 5: Etapes without child tasks
INSERT INTO public.tablos (owner_id, name, position)
VALUES (test_user_id, 'Tablo Empty Etapes', 4)
RETURNING id INTO tablo_empty_etapes;
-- Setup Tablo 2: All etapes done
-- Etape 1 with all tasks done
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (tablo_all_done, 'Etape 1', 'done', 0, true)
RETURNING id INTO etape1_id;
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES
(tablo_all_done, 'Task 1.1', 'done', 0, false, etape1_id),
(tablo_all_done, 'Task 1.2', 'done', 1, false, etape1_id);
-- Etape 2 with all tasks done
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (tablo_all_done, 'Etape 2', 'done', 1, true)
RETURNING id INTO etape2_id;
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES
(tablo_all_done, 'Task 2.1', 'done', 0, false, etape2_id);
-- Setup Tablo 3: Has in_progress tasks
-- Etape 1 with in_progress task
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (tablo_in_progress, 'Etape 1', 'in_progress', 0, true)
RETURNING id INTO etape3_id;
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES
(tablo_in_progress, 'Task 1.1', 'in_progress', 0, false, etape3_id),
(tablo_in_progress, 'Task 1.2', 'done', 1, false, etape3_id);
-- Setup Tablo 4: Mixed statuses
-- Etape 1: all done
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (tablo_mixed, 'Etape 1', 'done', 0, true)
RETURNING id INTO etape4_id;
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES
(tablo_mixed, 'Task 1.1', 'done', 0, false, etape4_id);
-- Etape 2: has todo tasks (not started)
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (tablo_mixed, 'Etape 2', 'todo', 1, true)
RETURNING id INTO etape5_id;
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES
(tablo_mixed, 'Task 2.1', 'todo', 0, false, etape5_id);
-- Setup Tablo 5: Empty etapes (no child tasks)
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES
(tablo_empty_etapes, 'Empty Etape 1', 'todo', 0, true),
(tablo_empty_etapes, 'Empty Etape 2', 'todo', 1, true);
-- Store test IDs
PERFORM set_config('test.user_id', test_user_id::text, true);
PERFORM set_config('test.tablo_no_etapes', tablo_no_etapes, true);
PERFORM set_config('test.tablo_all_done', tablo_all_done, true);
PERFORM set_config('test.tablo_in_progress', tablo_in_progress, true);
PERFORM set_config('test.tablo_mixed', tablo_mixed, true);
PERFORM set_config('test.tablo_empty_etapes', tablo_empty_etapes, true);
END $$;
-- ============================================================================
-- Test 1: Function exists and is accessible
-- ============================================================================
SELECT has_function(
'public',
'compute_tablo_status',
ARRAY['text'],
'compute_tablo_status function should exist'
);
SELECT function_returns(
'public',
'compute_tablo_status',
ARRAY['text'],
'text',
'compute_tablo_status should return text'
);
-- ============================================================================
-- Test 2: Tablo with no etapes should return 'todo'
-- ============================================================================
SELECT is(
public.compute_tablo_status(current_setting('test.tablo_no_etapes')),
'todo',
'Tablo with no etapes should have status "todo"'
);
-- ============================================================================
-- Test 3: Tablo with all etapes done should return 'done'
-- ============================================================================
SELECT is(
public.compute_tablo_status(current_setting('test.tablo_all_done')),
'done',
'Tablo with all etapes completed should have status "done"'
);
-- ============================================================================
-- Test 4: Tablo with in_progress tasks should return 'in_progress'
-- ============================================================================
SELECT is(
public.compute_tablo_status(current_setting('test.tablo_in_progress')),
'in_progress',
'Tablo with tasks in progress should have status "in_progress"'
);
-- ============================================================================
-- Test 5: Tablo with mixed etapes (some done, some todo) should return 'in_progress'
-- ============================================================================
SELECT is(
public.compute_tablo_status(current_setting('test.tablo_mixed')),
'in_progress',
'Tablo with mixed etapes (some done, some todo) should have status "in_progress"'
);
-- ============================================================================
-- Test 6: Tablo with empty etapes (no child tasks) should return 'done'
-- ============================================================================
SELECT is(
public.compute_tablo_status(current_setting('test.tablo_empty_etapes')),
'done',
'Tablo with etapes that have no child tasks should have status "done"'
);
-- ============================================================================
-- Test 6.5: Tablo with partial progress (some tasks done, some todo) should return 'in_progress'
-- ============================================================================
DO $$
DECLARE
partial_tablo_id text;
partial_etape_id text;
BEGIN
-- Create a tablo with partial progress
INSERT INTO public.tablos (owner_id, name, position)
VALUES (current_setting('test.user_id')::uuid, 'Partial Progress Tablo', 7)
RETURNING id INTO partial_tablo_id;
-- Create an etape
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (partial_tablo_id, 'Partial Etape', 'in_progress', 0, true)
RETURNING id INTO partial_etape_id;
-- Create multiple tasks with mixed statuses
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES
(partial_tablo_id, 'Done Task 1', 'done', 0, false, partial_etape_id),
(partial_tablo_id, 'Done Task 2', 'done', 1, false, partial_etape_id),
(partial_tablo_id, 'Todo Task 1', 'todo', 2, false, partial_etape_id),
(partial_tablo_id, 'Todo Task 2', 'todo', 3, false, partial_etape_id);
PERFORM set_config('test.partial_tablo', partial_tablo_id, true);
END $$;
SELECT is(
public.compute_tablo_status(current_setting('test.partial_tablo')),
'in_progress',
'Tablo with some tasks done and some todo should have status "in_progress"'
);
-- ============================================================================
-- Test 7: Verify status computation in user_tablos view
-- ============================================================================
SELECT is(
(SELECT status FROM public.user_tablos WHERE id = current_setting('test.tablo_no_etapes')),
'todo',
'user_tablos view should show computed status for tablo with no etapes'
);
SELECT is(
(SELECT status FROM public.user_tablos WHERE id = current_setting('test.tablo_all_done')),
'done',
'user_tablos view should show computed status for tablo with all done'
);
SELECT is(
(SELECT status FROM public.user_tablos WHERE id = current_setting('test.tablo_in_progress')),
'in_progress',
'user_tablos view should show computed status for tablo in progress'
);
-- ============================================================================
-- Test 8: Test status changes when tasks are updated
-- ============================================================================
DO $$
DECLARE
test_tablo_id text;
test_etape_id text;
test_task_id text;
BEGIN
-- Create a new tablo for dynamic testing
INSERT INTO public.tablos (owner_id, name, position)
VALUES (current_setting('test.user_id')::uuid, 'Dynamic Test Tablo', 5)
RETURNING id INTO test_tablo_id;
-- Create an etape
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (test_tablo_id, 'Dynamic Etape', 'todo', 0, true)
RETURNING id INTO test_etape_id;
-- Create a todo task
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES (test_tablo_id, 'Dynamic Task', 'todo', 0, false, test_etape_id)
RETURNING id INTO test_task_id;
-- Store IDs for testing
PERFORM set_config('test.dynamic_tablo', test_tablo_id, true);
PERFORM set_config('test.dynamic_task', test_task_id, true);
END $$;
-- Initially should be 'todo'
SELECT is(
public.compute_tablo_status(current_setting('test.dynamic_tablo')),
'todo',
'New tablo with todo task should have status "todo"'
);
-- Update task to in_progress
DO $$
BEGIN
UPDATE public.tasks
SET status = 'in_progress'
WHERE id = current_setting('test.dynamic_task');
END $$;
SELECT is(
public.compute_tablo_status(current_setting('test.dynamic_tablo')),
'in_progress',
'Tablo should change to "in_progress" when task is updated'
);
-- Update task to done
DO $$
BEGIN
UPDATE public.tasks
SET status = 'done'
WHERE id = current_setting('test.dynamic_task');
END $$;
SELECT is(
public.compute_tablo_status(current_setting('test.dynamic_tablo')),
'done',
'Tablo should change to "done" when all tasks are completed'
);
-- ============================================================================
-- Test 9: Test with tasks in 'in_review' status (should count as in_progress)
-- ============================================================================
DO $$
DECLARE
review_tablo_id text;
review_etape_id text;
BEGIN
-- Create a tablo with in_review tasks
INSERT INTO public.tablos (owner_id, name, position)
VALUES (current_setting('test.user_id')::uuid, 'Review Test Tablo', 6)
RETURNING id INTO review_tablo_id;
-- Create an etape
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
VALUES (review_tablo_id, 'Review Etape', 'in_review', 0, true)
RETURNING id INTO review_etape_id;
-- Create an in_review task
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
VALUES (review_tablo_id, 'Review Task', 'in_review', 0, false, review_etape_id);
PERFORM set_config('test.review_tablo', review_tablo_id, true);
END $$;
SELECT is(
public.compute_tablo_status(current_setting('test.review_tablo')),
'in_progress',
'Tablo with in_review tasks should have status "in_progress"'
);
SELECT * FROM finish();
ROLLBACK;

View file

@ -576,7 +576,7 @@ export type Database = {
name: string
owner_id: string
position: number
status: string
status: string | null
updated_at: string | null
}
Insert: {
@ -588,7 +588,7 @@ export type Database = {
name: string
owner_id: string
position?: number
status?: string
status?: string | null
updated_at?: string | null
}
Update: {
@ -600,7 +600,7 @@ export type Database = {
name?: string
owner_id?: string
position?: number
status?: string
status?: string | null
updated_at?: string | null
}
Relationships: []
@ -801,6 +801,10 @@ export type Database = {
}
}
Functions: {
compute_tablo_status: {
Args: { tablo_id_param: string }
Returns: string
}
generate_random_string: { Args: { length?: number }; Returns: string }
get_my_active_subscription: {
Args: never