From 7ec848e37e4c85894321d88553f87714f4f64d8c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 22 Nov 2025 17:22:57 +0100 Subject: [PATCH] Ship ship ship the new features (tasks, etapes, notifs) --- apps/api/src/__tests__/helpers/dbSetup.ts | 2 +- .../src/components/CreateTabloModal.test.tsx | 18 +- apps/main/src/components/CreateTabloModal.tsx | 10 +- .../components/TabloOverviewSection.test.tsx | 30 +- .../src/components/TabloOverviewSection.tsx | 85 ++--- .../src/components/TabloSettingsSection.tsx | 21 +- .../main/src/components/TabloTasksSection.tsx | 11 - apps/main/src/components/TabloTutorial.tsx | 6 +- .../src/components/ThreeLoginBackground.tsx | 168 --------- .../src/components/kanban/KanbanBoard.tsx | 4 - .../src/components/kanban/KanbanColumn.tsx | 4 - .../src/components/kanban/KanbanTaskCard.tsx | 12 +- apps/main/src/hooks/tablos.ts | 8 +- apps/main/src/i18n.test.ts | 26 ++ apps/main/src/i18n.ts | 4 + apps/main/src/locales/en/common.json | 8 +- apps/main/src/locales/en/modals.json | 6 +- apps/main/src/locales/en/navigation.json | 2 +- apps/main/src/locales/en/pages.json | 14 +- apps/main/src/locales/en/planning.json | 4 +- apps/main/src/locales/en/tablo.json | 26 ++ apps/main/src/locales/fr/common.json | 8 +- apps/main/src/locales/fr/modals.json | 6 +- apps/main/src/locales/fr/navigation.json | 2 +- apps/main/src/locales/fr/pages.json | 16 +- apps/main/src/locales/fr/planning.json | 4 +- apps/main/src/locales/fr/tablo.json | 26 ++ .../main/src/pages/PublicBookingPage.test.tsx | 6 - apps/main/src/pages/PublicNotePage.test.tsx | 6 - apps/main/src/pages/feedback.test.tsx | 6 - apps/main/src/pages/landing.tsx | 16 +- apps/main/src/pages/login.test.tsx | 32 +- apps/main/src/pages/login.tsx | 5 +- apps/main/src/pages/privacy-policy.tsx | 8 +- apps/main/src/pages/reset-password.test.tsx | 40 +- apps/main/src/pages/signup.test.tsx | 90 ++--- apps/main/src/pages/tablo-details.tsx | 27 +- apps/main/src/pages/tablo.test.tsx | 6 - apps/main/src/pages/tablo.tsx | 121 +----- apps/main/src/utils/etapeColors.ts | 26 +- apps/main/src/utils/testHelpers.tsx | 53 +-- packages/shared-types/src/database.types.ts | 41 +- .../20251122000000_deprecate_tablo_status.sql | 129 +++++++ ...001_fix_tests_after_status_deprecation.sql | 167 +++++++++ .../database/01_schema_structure.test.sql | 3 +- .../database/12_compute_tablo_status.test.sql | 354 ++++++++++++++++++ xtablo-expo/lib/database.types.ts | 10 +- 47 files changed, 1052 insertions(+), 625 deletions(-) delete mode 100644 apps/main/src/components/ThreeLoginBackground.tsx create mode 100644 apps/main/src/locales/en/tablo.json create mode 100644 apps/main/src/locales/fr/tablo.json create mode 100644 supabase/migrations/20251122000000_deprecate_tablo_status.sql create mode 100644 supabase/migrations/20251122000001_fix_tests_after_status_deprecation.sql create mode 100644 supabase/tests/database/12_compute_tablo_status.test.sql diff --git a/apps/api/src/__tests__/helpers/dbSetup.ts b/apps/api/src/__tests__/helpers/dbSetup.ts index 6123173..c2a2215 100644 --- a/apps/api/src/__tests__/helpers/dbSetup.ts +++ b/apps/api/src/__tests__/helpers/dbSetup.ts @@ -149,7 +149,7 @@ export async function setupTestDatabase(): Promise { 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, })); diff --git a/apps/main/src/components/CreateTabloModal.test.tsx b/apps/main/src/components/CreateTabloModal.test.tsx index 0537c1d..4441541 100644 --- a/apps/main/src/components/CreateTabloModal.test.tsx +++ b/apps/main/src/components/CreateTabloModal.test.tsx @@ -27,24 +27,24 @@ describe("CreateTabloModal", () => { it("renders without crashing", () => { render(); - expect(screen.getByText("Create a new project")).toBeInTheDocument(); + expect(screen.getByText("Create a new tablo")).toBeInTheDocument(); }); it("displays name input field", () => { render(); - expect(screen.getByPlaceholderText("Enter project name")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter tablo name")).toBeInTheDocument(); }); it("allows typing in name input", () => { render(); - 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(); - 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(); - expect(screen.getByText("À faire")).toBeInTheDocument(); - }); - it("renders ImageColorPicker component", () => { render(); expect(screen.getByText("Style")).toBeInTheDocument(); @@ -91,7 +85,7 @@ describe("CreateTabloModal", () => { it("resets form after successful creation", () => { render(); - 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(); - 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 diff --git a/apps/main/src/components/CreateTabloModal.tsx b/apps/main/src/components/CreateTabloModal.tsx index 4df485b..834e60b 100644 --- a/apps/main/src/components/CreateTabloModal.tsx +++ b/apps/main/src/components/CreateTabloModal.tsx @@ -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) => void; + onCreate: (tabloData: Pick) => 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("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) = /> - - ({ 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(); + renderWithProviders(, { 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(); + renderWithProviders(, { + 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(); + renderWithProviders(, { 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({ diff --git a/apps/main/src/components/TabloOverviewSection.tsx b/apps/main/src/components/TabloOverviewSection.tsx index c885d40..83a7dc3 100644 --- a/apps/main/src/components/TabloOverviewSection.tsx +++ b/apps/main/src/components/TabloOverviewSection.tsx @@ -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 (
- - Aucune Étape n'a encore été définie pour ce tablo. - + {t("tablo:etape.noEtapes")} {canManageEtapes ? ( - Créez votre première Étape pour structurer les tâches du tablo. + {t("tablo:etape.createFirstEtape")} ) : ( - Seul le propriétaire du tablo peut ajouter des Étapes. + {t("tablo:etape.onlyOwnerCanAdd")} )}
@@ -164,12 +157,8 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
    {sortedEtapes.map((etape, index) => { const isEditing = editingEtapeId === etape.id; - const etapeColor = getEtapeColor(etape.position); return ( -
  • +
  • {canManageEtapes && (
    ) : ( <> - - {etape.title} - - - Étape {etape.position + 1} + {etape.title} + + {t("tablo:etape.stepNumber", { number: etape.position + 1 })} {(() => { const { total, done, ongoing } = getEtapeTaskCounts(etape.id); return ( -
    - - {total}{" "} - {pluralize("tâche", total)} +
    + + {total}{" "} + {t( + `tablo:tasks.task_${total === 0 || total > 1 ? "plural" : "singular"}` + )} {ongoing > 0 && ( - - {ongoing} en cours + + {ongoing}{" "} + {t("tablo:tasks.inProgress")} )} {done > 0 && ( - + {done}{" "} - {pluralize("terminée", done)} + {t(`tablo:tasks.completed_${done > 1 ? "plural" : "singular"}`)} )}
    @@ -291,16 +281,17 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro return (
    - Vue d'ensemble + + {t("tablo:overview.title")} + - Configurez les Étapes du tablo pour clarifier les grandes phases de votre projet. + {t("tablo:overview.description")}
    {!canManageEtapes && (
    - 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")}
    )} @@ -310,12 +301,13 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
    - Progression globale + {t("tablo:overview.overallProgress")} - {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, + })}
    {overallProgress.percentage}%
    @@ -333,11 +325,12 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro setNewEtapeTitle(event.target.value)} - placeholder="Nom de l'Étape" + placeholder={t("tablo:etape.namePlaceholder")} className="h-9 sm:w-64" />
    )} diff --git a/apps/main/src/components/TabloSettingsSection.tsx b/apps/main/src/components/TabloSettingsSection.tsx index 10fa567..273f591 100644 --- a/apps/main/src/components/TabloSettingsSection.tsx +++ b/apps/main/src/components/TabloSettingsSection.tsx @@ -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 />
    - {/* Status Picker */} + {/* Status (Read-only - computed from etapes) */}
    - - setEditData((prev) => (prev ? { ...prev, status } : null)) - } - /> +

    Statut

    +
    +
    + {currentData.status === "todo" && "À faire"} + {currentData.status === "in_progress" && "En cours"} + {currentData.status === "done" && "Terminé"} +
    +
    )} diff --git a/apps/main/src/components/TabloTasksSection.tsx b/apps/main/src/components/TabloTasksSection.tsx index 30154c6..dc11151 100644 --- a/apps/main/src/components/TabloTasksSection.tsx +++ b/apps/main/src/components/TabloTasksSection.tsx @@ -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>>((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} diff --git a/apps/main/src/components/TabloTutorial.tsx b/apps/main/src/components/TabloTutorial.tsx index 4e3f7c3..a3c9fdc 100644 --- a/apps/main/src/components/TabloTutorial.tsx +++ b/apps/main/src/components/TabloTutorial.tsx @@ -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: () => { diff --git a/apps/main/src/components/ThreeLoginBackground.tsx b/apps/main/src/components/ThreeLoginBackground.tsx deleted file mode 100644 index f507677..0000000 --- a/apps/main/src/components/ThreeLoginBackground.tsx +++ /dev/null @@ -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(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 ( - diff --git a/apps/main/src/components/kanban/KanbanTaskCard.tsx b/apps/main/src/components/kanban/KanbanTaskCard.tsx index 195c2e8..fdd9303 100644 --- a/apps/main/src/components/kanban/KanbanTaskCard.tsx +++ b/apps/main/src/components/kanban/KanbanTaskCard.tsx @@ -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; onClick: () => void; } -export const KanbanTaskCard = ({ task, etapeTitle, etapeColor, onClick }: KanbanTaskCardProps) => { +export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProps) => { return (
    )} - {/* Status Pill with Etape Color */} + {/* Status Pill */}
    {etapeTitle ?? "Sans Étape"} diff --git a/apps/main/src/hooks/tablos.ts b/apps/main/src/hooks/tablos.ts index 845f60d..207a3c6 100644 --- a/apps/main/src/hooks/tablos.ts +++ b/apps/main/src/hooks/tablos.ts @@ -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; }, }); }; diff --git a/apps/main/src/i18n.test.ts b/apps/main/src/i18n.test.ts index 9e8ba5e..5254707 100644 --- a/apps/main/src/i18n.test.ts +++ b/apps/main/src/i18n.test.ts @@ -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", diff --git a/apps/main/src/i18n.ts b/apps/main/src/i18n.ts index 8651a92..a9007bc 100644 --- a/apps/main/src/i18n.ts +++ b/apps/main/src/i18n.ts @@ -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", diff --git a/apps/main/src/locales/en/common.json b/apps/main/src/locales/en/common.json index df19b99..1063175 100644 --- a/apps/main/src/locales/en/common.json +++ b/apps/main/src/locales/en/common.json @@ -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", diff --git a/apps/main/src/locales/en/modals.json b/apps/main/src/locales/en/modals.json index 1c80012..5d60675 100644 --- a/apps/main/src/locales/en/modals.json +++ b/apps/main/src/locales/en/modals.json @@ -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" } } diff --git a/apps/main/src/locales/en/navigation.json b/apps/main/src/locales/en/navigation.json index c9841bc..9126912 100644 --- a/apps/main/src/locales/en/navigation.json +++ b/apps/main/src/locales/en/navigation.json @@ -1,5 +1,5 @@ { - "projects": "Projects", + "projects": "Tablos", "myEvents": "My Events", "planning": "Planning", "discussions": "Discussions", diff --git a/apps/main/src/locales/en/pages.json b/apps/main/src/locales/en/pages.json index fc73ecb..69c40f1 100644 --- a/apps/main/src/locales/en/pages.json +++ b/apps/main/src/locales/en/pages.json @@ -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", diff --git a/apps/main/src/locales/en/planning.json b/apps/main/src/locales/en/planning.json index 4a62118..e662292 100644 --- a/apps/main/src/locales/en/planning.json +++ b/apps/main/src/locales/en/planning.json @@ -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", diff --git a/apps/main/src/locales/en/tablo.json b/apps/main/src/locales/en/tablo.json new file mode 100644 index 0000000..57b522a --- /dev/null +++ b/apps/main/src/locales/en/tablo.json @@ -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" + } +} diff --git a/apps/main/src/locales/fr/common.json b/apps/main/src/locales/fr/common.json index 6d816e2..d31dfb8 100644 --- a/apps/main/src/locales/fr/common.json +++ b/apps/main/src/locales/fr/common.json @@ -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", diff --git a/apps/main/src/locales/fr/modals.json b/apps/main/src/locales/fr/modals.json index 06bf548..854baaa 100644 --- a/apps/main/src/locales/fr/modals.json +++ b/apps/main/src/locales/fr/modals.json @@ -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" } } diff --git a/apps/main/src/locales/fr/navigation.json b/apps/main/src/locales/fr/navigation.json index c63ab6d..6e4899d 100644 --- a/apps/main/src/locales/fr/navigation.json +++ b/apps/main/src/locales/fr/navigation.json @@ -1,5 +1,5 @@ { - "projects": "Projets", + "projects": "Tablos", "myEvents": "Mes Événements", "planning": "Planning", "discussions": "Discussions", diff --git a/apps/main/src/locales/fr/pages.json b/apps/main/src/locales/fr/pages.json index 2fe04a9..57fbd97 100644 --- a/apps/main/src/locales/fr/pages.json +++ b/apps/main/src/locales/fr/pages.json @@ -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", diff --git a/apps/main/src/locales/fr/planning.json b/apps/main/src/locales/fr/planning.json index 5a67a92..65a592e 100644 --- a/apps/main/src/locales/fr/planning.json +++ b/apps/main/src/locales/fr/planning.json @@ -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", diff --git a/apps/main/src/locales/fr/tablo.json b/apps/main/src/locales/fr/tablo.json new file mode 100644 index 0000000..c9f0187 --- /dev/null +++ b/apps/main/src/locales/fr/tablo.json @@ -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" + } +} diff --git a/apps/main/src/pages/PublicBookingPage.test.tsx b/apps/main/src/pages/PublicBookingPage.test.tsx index b8fa8bc..cf01146 100644 --- a/apps/main/src/pages/PublicBookingPage.test.tsx +++ b/apps/main/src/pages/PublicBookingPage.test.tsx @@ -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: { diff --git a/apps/main/src/pages/PublicNotePage.test.tsx b/apps/main/src/pages/PublicNotePage.test.tsx index d8d8b93..c9c5446 100644 --- a/apps/main/src/pages/PublicNotePage.test.tsx +++ b/apps/main/src/pages/PublicNotePage.test.tsx @@ -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: { diff --git a/apps/main/src/pages/feedback.test.tsx b/apps/main/src/pages/feedback.test.tsx index 9f1f091..21196a4 100644 --- a/apps/main/src/pages/feedback.test.tsx +++ b/apps/main/src/pages/feedback.test.tsx @@ -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, diff --git a/apps/main/src/pages/landing.tsx b/apps/main/src/pages/landing.tsx index ceb0cc5..3594a4b 100644 --- a/apps/main/src/pages/landing.tsx +++ b/apps/main/src/pages/landing.tsx @@ -40,7 +40,7 @@ export const LandingPage = () => {

    - Un client, un projet, un espace de travail + Un client, un tablo, un espace de travail

    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'hui

    - Vous lancez un projet avec un client ?
    + Vous lancez un tablo avec un client ?
    Vous jonglez entre messages WhatsApp, briefs Notion, liens Calendly, et retours par e-mail ?

    @@ -137,7 +137,7 @@ export const LandingPage = () => {

    Impression de flou

    -

    Confusion dès le départ du projet

    +

    Confusion dès le départ du tablo

    @@ -165,7 +165,7 @@ export const LandingPage = () => { Créer un groupe dédié

    - Un espace unique pour chaque projet + Un espace unique pour chaque tablo

    {

    - Objectif : poser les bases d'un projet aligné, dès le jour 1. + Objectif : poser les bases d'un tablo aligné, dès le jour 1.

    @@ -212,7 +212,7 @@ export const LandingPage = () => { 🚧 Ce qu'on construit avec vous

    - XTablo deviendra bientôt un hub projet tout-en-un : + XTablo deviendra bientôt un hub tablo tout-en-un :

    @@ -318,7 +318,7 @@ export const LandingPage = () => { 🎯 Notre objectif

    - Construire ensemble l'outil parfait pour lancer vos projets sereinement + Construire ensemble l'outil parfait pour lancer vos tablos sereinement

    @@ -368,7 +368,7 @@ export const LandingPage = () => {
    • - Groupes de projet illimités + Groupes de tablo illimités
    • diff --git a/apps/main/src/pages/login.test.tsx b/apps/main/src/pages/login.test.tsx index 6f7172b..a3c0361 100644 --- a/apps/main/src/pages/login.test.tsx +++ b/apps/main/src/pages/login.test.tsx @@ -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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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) diff --git a/apps/main/src/pages/login.tsx b/apps/main/src/pages/login.tsx index ffae521..3a25a4e 100644 --- a/apps/main/src/pages/login.tsx +++ b/apps/main/src/pages/login.tsx @@ -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 (
      - - {/* */} +
        -
      • Informations sur vos projets et tableaux
      • +
      • Informations sur vos tablos et tableaux
      • Événements et rendez-vous créés
      • Messages et communications via la plateforme
      • Fichiers et documents partagés
      • @@ -164,7 +164,7 @@ export function PrivacyPolicyPage() {
      • Fourniture du service : 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.
      • Communication : Vous envoyer des @@ -226,8 +226,8 @@ export function PrivacyPolicyPage() {
        • Membres de votre équipe : 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.
        • Prestataires de services : Nous diff --git a/apps/main/src/pages/reset-password.test.tsx b/apps/main/src/pages/reset-password.test.tsx index e06e96b..beddf74 100644 --- a/apps/main/src/pages/reset-password.test.tsx +++ b/apps/main/src/pages/reset-password.test.tsx @@ -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(); - 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(); - expect(screen.getByText(/resetPassword.description/i)).toBeInTheDocument(); + expect(screen.getByText(/enter your email address/i)).toBeInTheDocument(); }); it("shows link back to login", () => { renderWithProviders(); - 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(); - 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(); - 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(); - 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(); - 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(); - const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i); + const emailInput = screen.getByLabelText(/email/i); expect(emailInput).toHaveAttribute("required"); }); it("requires valid email format", () => { renderWithProviders(); - const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i); + const emailInput = screen.getByLabelText(/email/i); expect(emailInput).toHaveAttribute("type", "email"); }); }); diff --git a/apps/main/src/pages/signup.test.tsx b/apps/main/src/pages/signup.test.tsx index e7210d1..79af006 100644 --- a/apps/main/src/pages/signup.test.tsx +++ b/apps/main/src/pages/signup.test.tsx @@ -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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i }); + const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i }); expect(termsCheckbox).toHaveAttribute("required"); }); }); diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 2dcf18b..c086f34 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -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(null); @@ -216,18 +217,18 @@ export const TabloDetailsPage = () => { {/* Main Content Area */}
          - {activeSection === "overview" && } - {activeSection === "files" && } - {activeSection === "discussion" && ( - - )} - {activeSection === "notes" && } - {activeSection === "events" && } - {activeSection === "tasks" && } - {activeSection === "members" && } - {activeSection === "settings" && ( - - )} + {match(activeSection) + .with("overview", () => ) + .with("files", () => ) + .with("discussion", () => ) + .with("notes", () => ) + .with("events", () => ) + .with("tasks", () => ) + .with("members", () => ) + .with("settings", () => ( + + )) + .exhaustive()}
      diff --git a/apps/main/src/pages/tablo.test.tsx b/apps/main/src/pages/tablo.test.tsx index f3ac032..e3b8939 100644 --- a/apps/main/src/pages/tablo.test.tsx +++ b/apps/main/src/pages/tablo.test.tsx @@ -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: { diff --git a/apps/main/src/pages/tablo.tsx b/apps/main/src/pages/tablo.tsx index f114538..381e92b 100644 --- a/apps/main/src/pages/tablo.tsx +++ b/apps/main/src/pages/tablo.tsx @@ -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 = () => {
      -

      Tablos

      - - Gérez vos projets et collaborations - +

      {t("pages:tablo.title")}

      + {t("pages:tablo.subtitle")}
      ))} - - {/* Status change options - Only for admins */} - {isAdmin && ( - <> -
      -
      - Changer le statut -
      - - - - - )}
      )}
      @@ -596,45 +542,6 @@ export const TabloPage = () => { {item.name} ))} - - {isAdmin && ( - <> -
      -
      - Changer le statut -
      - - - - - )}
      )} diff --git a/apps/main/src/utils/etapeColors.ts b/apps/main/src/utils/etapeColors.ts index 822f506..42a82bc 100644 --- a/apps/main/src/utils/etapeColors.ts +++ b/apps/main/src/utils/etapeColors.ts @@ -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]; }; - - diff --git a/apps/main/src/utils/testHelpers.tsx b/apps/main/src/utils/testHelpers.tsx index 6146cf5..b093879 100644 --- a/apps/main/src/utils/testHelpers.tsx +++ b/apps/main/src/utils/testHelpers.tsx @@ -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( - - - - - {content} - - - - + + + + + + {content} + + + + + ); }; diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index 7a53e32..abcd4e3 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -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(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"], diff --git a/supabase/migrations/20251122000000_deprecate_tablo_status.sql b/supabase/migrations/20251122000000_deprecate_tablo_status.sql new file mode 100644 index 0000000..3e63817 --- /dev/null +++ b/supabase/migrations/20251122000000_deprecate_tablo_status.sql @@ -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'; + diff --git a/supabase/migrations/20251122000001_fix_tests_after_status_deprecation.sql b/supabase/migrations/20251122000001_fix_tests_after_status_deprecation.sql new file mode 100644 index 0000000..85c7f22 --- /dev/null +++ b/supabase/migrations/20251122000001_fix_tests_after_status_deprecation.sql @@ -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"(); + diff --git a/supabase/tests/database/01_schema_structure.test.sql b/supabase/tests/database/01_schema_structure.test.sql index bee7539..a5cf916 100644 --- a/supabase/tests/database/01_schema_structure.test.sql +++ b/supabase/tests/database/01_schema_structure.test.sql @@ -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'); diff --git a/supabase/tests/database/12_compute_tablo_status.test.sql b/supabase/tests/database/12_compute_tablo_status.test.sql new file mode 100644 index 0000000..5a85827 --- /dev/null +++ b/supabase/tests/database/12_compute_tablo_status.test.sql @@ -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; + diff --git a/xtablo-expo/lib/database.types.ts b/xtablo-expo/lib/database.types.ts index c352661..ff9fc4c 100644 --- a/xtablo-expo/lib/database.types.ts +++ b/xtablo-expo/lib/database.types.ts @@ -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