From 354831c82fe34a8cfa527fa59ae15c6ca563a3e3 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 7 Mar 2026 15:45:49 +0100 Subject: [PATCH] Some work towards initial release --- .../src/__tests__/routes/tablo_data.test.ts | 60 +++++---- apps/api/src/helpers/helpers.ts | 15 ++- apps/api/src/routers/tablo_data.ts | 117 ++++++++++++++++-- .../src/components/ChannelPreview.test.tsx | 4 +- .../src/components/CreateTabloModal.test.tsx | 12 +- .../src/components/DeleteTabloModal.test.tsx | 4 +- apps/main/src/components/EventModal.test.tsx | 2 +- .../src/components/ImportICSModal.test.tsx | 2 +- apps/main/src/components/Layout.test.tsx | 16 ++- .../main/src/components/TabloFilesSection.tsx | 39 +++--- .../components/TabloMembersSection.test.tsx | 4 + .../components/TabloOverviewSection.test.tsx | 14 +-- .../src/components/TabloOverviewSection.tsx | 42 +++++-- .../main/src/components/TabloTasksSection.tsx | 34 ++++- apps/main/src/hooks/tablo_data.ts | 4 +- apps/main/src/hooks/tasks.ts | 17 ++- apps/main/src/locales/en/tablo.json | 3 +- apps/main/src/locales/fr/tablo.json | 3 +- apps/main/src/pages/tablo-details.tsx | 40 +++--- apps/main/src/pages/tablo.test.tsx | 29 +++-- apps/main/src/utils/etapeProgress.ts | 35 ++++++ 21 files changed, 372 insertions(+), 124 deletions(-) create mode 100644 apps/main/src/utils/etapeProgress.ts diff --git a/apps/api/src/__tests__/routes/tablo_data.test.ts b/apps/api/src/__tests__/routes/tablo_data.test.ts index 572fcb8..780755d 100644 --- a/apps/api/src/__tests__/routes/tablo_data.test.ts +++ b/apps/api/src/__tests__/routes/tablo_data.test.ts @@ -1,6 +1,7 @@ import { DeleteObjectCommand, GetObjectCommand, + HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, @@ -14,6 +15,7 @@ import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { createConfig } from "../../config.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; +import { clearTabloDataCachesForTests } from "../../routers/tablo_data.js"; import type { TestUserData } from "../helpers/dbSetup.js"; import { getTestUser } from "../helpers/dbSetup.js"; @@ -103,11 +105,21 @@ describe("TabloData Endpoint", () => { // Mock DeleteObjectCommand (used by deleteTabloFile) s3Mock.on(DeleteObjectCommand).resolves({}); + + // Mock HeadObjectCommand (used by deleteTabloFile ownership checks) + s3Mock.on(HeadObjectCommand).callsFake((input) => { + const key = input.Key ?? ""; + if (key.includes("temp-uploaded")) { + return Promise.resolve({ Metadata: { "uploaded-by": temporaryUser.userId } }); + } + return Promise.resolve({ Metadata: { "uploaded-by": ownerUser.userId } }); + }); }); beforeEach(() => { // Reset folder metadata before each test mockFolderMetadata = { folders: [], version: 1 }; + clearTabloDataCachesForTests(); }); describe("GET /tablo-data/:tabloId/filenames - Owner Access", () => { @@ -374,7 +386,7 @@ describe("TabloData Endpoint", () => { }); }); - describe("DELETE /tablo-data/:tabloId/:fileName - Delete File (Admin Only)", () => { + describe("DELETE /tablo-data/:tabloId/:fileName - Delete File (Admin or Uploader)", () => { // Helper function to delete file const deleteTabloFileRequest = async ( user: TestUserData, @@ -420,9 +432,7 @@ describe("TabloData Endpoint", () => { expect(data.message).toBe("File deleted successfully"); }); - it("should deny owner from deleting file from temp's tablo (regularUserCheck blocks temporary owner)", async () => { - // Owner has admin access to test_tablo_temp_shared_admin - // BUT regularUserCheck blocks access to tablos owned by temporary users + it("should allow owner to delete file from temp's shared tablo when owner is admin member", async () => { const res = await deleteTabloFileRequest( ownerUser, client, @@ -430,14 +440,12 @@ describe("TabloData Endpoint", () => { "test-file.pdf" ); - expect(res.status).toBe(403); - const data = await res.json(); - expect(data.error).toBe("You are not an admin of this tablo"); + expect(res.status).toBe(200); }); }); - describe("Temp User - Blocked by regularUserCheck", () => { - it("should deny temp user from deleting file from their own tablo (regularUserCheck)", async () => { + describe("Temp User Access (Member/Uploader Rules)", () => { + it("should allow temp user to delete file from their own tablo", async () => { const res = await deleteTabloFileRequest( temporaryUser, client, @@ -445,12 +453,21 @@ describe("TabloData Endpoint", () => { "test-file.pdf" ); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(200); }); - it("should deny temp user from deleting file from owner's shared tablo (regularUserCheck)", async () => { - // Even though temp has access, regularUserCheck blocks temporary users + it("should allow temp user to delete their own uploaded file in shared tablo", async () => { + const res = await deleteTabloFileRequest( + temporaryUser, + client, + "test_tablo_owner_shared", + "temp-uploaded.pdf" + ); + + expect(res.status).toBe(200); + }); + + it("should deny temp user from deleting another user's file in shared tablo", async () => { const res = await deleteTabloFileRequest( temporaryUser, client, @@ -458,8 +475,7 @@ describe("TabloData Endpoint", () => { "test-file.pdf" ); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); }); @@ -1094,7 +1110,9 @@ describe("TabloData Endpoint", () => { }); }); - describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin Only)", () => { + describe( + "DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)", + () => { it("should allow admin to delete file with nested path", async () => { const res = await deleteNestedFileRequest( ownerUser, @@ -1120,7 +1138,7 @@ describe("TabloData Endpoint", () => { expect(res.status).toBe(200); }); - it("should deny temp user from deleting nested file (regularUserCheck)", async () => { + it("should allow temp user to delete nested file from their own tablo", async () => { const res = await deleteNestedFileRequest( temporaryUser, client, @@ -1128,12 +1146,10 @@ describe("TabloData Endpoint", () => { "folder-123/file.pdf" ); - expect(res.status).toBe(401); + expect(res.status).toBe(200); }); - it("should deny non-admin from deleting nested file", async () => { - // Owner has admin access to test_tablo_temp_shared_admin - // BUT regularUserCheck blocks access to tablos owned by temporary users + it("should allow admin member to delete nested file in shared tablo", async () => { const res = await deleteNestedFileRequest( ownerUser, client, @@ -1141,7 +1157,7 @@ describe("TabloData Endpoint", () => { "folder/file.pdf" ); - expect(res.status).toBe(403); + expect(res.status).toBe(200); }); it("should deny unauthenticated nested file deletion", async () => { diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 3a71f4b..0f9f636 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -174,32 +174,35 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) => const isTabloMember = async (supabase: SupabaseClient, tabloId: string, userId: string) => { const { data: tabloAccess, error: isMemberError } = await supabase .from("tablo_access") - .select("*") + .select("id") .eq("tablo_id", tabloId) .eq("user_id", userId) - .eq("is_active", true); + .eq("is_active", true) + .maybeSingle(); if (isMemberError) { return false; } - return tabloAccess?.length > 0; + return !!tabloAccess; }; const isTabloAdmin = async (supabase: SupabaseClient, tabloId: string, userId: string) => { const { data: tabloAccess, error: isAdminError } = await supabase .from("tablo_access") - .select("*") + .select("id") .eq("tablo_id", tabloId) .eq("user_id", userId) .eq("is_active", true) - .eq("is_admin", true); + .eq("is_admin", true) + .maybeSingle(); + // unique_tablo_access ensures at most one row if (isAdminError) { return false; } - return tabloAccess?.length > 0; + return !!tabloAccess; }; export const checkTabloMember = async (c: Context, next: Next) => { diff --git a/apps/api/src/routers/tablo_data.ts b/apps/api/src/routers/tablo_data.ts index 2e18619..a356010 100644 --- a/apps/api/src/routers/tablo_data.ts +++ b/apps/api/src/routers/tablo_data.ts @@ -1,6 +1,7 @@ import { DeleteObjectCommand, GetObjectCommand, + HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; @@ -15,10 +16,48 @@ const factory = createFactory(); // Metadata file name for folders const FOLDERS_METADATA_FILE = ".tablo-folders.json"; +const CACHE_TTL_MS = 15_000; + +type CacheEntry = { + value: T; + expiresAt: number; +}; + +const fileNamesCache = new Map>(); +const foldersCache = new Map>(); + +export const clearTabloDataCachesForTests = () => { + fileNamesCache.clear(); + foldersCache.clear(); +}; // Helper to generate unique folder IDs const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +const getCachedValue = (entry: CacheEntry | undefined): T | null => { + if (!entry) return null; + if (Date.now() >= entry.expiresAt) return null; + return entry.value; +}; + +const setCacheValue = (map: Map>, key: string, value: T) => { + map.set(key, { + value, + expiresAt: Date.now() + CACHE_TTL_MS, + }); +}; + +const getCachedTabloFileNames = async (s3_client: S3Client, tabloId: string): Promise => { + const cached = getCachedValue(fileNamesCache.get(tabloId)); + if (cached) { + return cached; + } + + const fileNames = (await getTabloFileNames(s3_client, tabloId)) || []; + setCacheValue(fileNamesCache, tabloId, fileNames); + return fileNames; +}; + // ============================================ // FILE ENDPOINTS // ============================================ @@ -28,8 +67,8 @@ const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => const s3_client = c.get("s3_client"); try { - const fileNames = await getTabloFileNames(s3_client, tabloId); - return c.json({ fileNames: fileNames || [] }); + const fileNames = await getCachedTabloFileNames(s3_client, tabloId); + return c.json({ fileNames }); } catch (error) { console.error("Error fetching tablo files:", error); return c.json({ error: "Failed to fetch tablo files" }, 500); @@ -57,8 +96,8 @@ const getAllTablosFilenames = factory.createHandlers(async (c) => { const results = await Promise.all( tabloIds.map(async (tabloId: string) => { - const fileNames = await getTabloFileNames(s3_client, tabloId); - return { tabloId, fileNames: fileNames ?? [] }; + const fileNames = await getCachedTabloFileNames(s3_client, tabloId); + return { tabloId, fileNames }; }) ); @@ -108,6 +147,7 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => { const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { const tabloId = c.req.param("tabloId"); + const user = c.get("user"); // Get the file path - supports both wildcard (*) and named parameter (:fileName) const filePath = c.req.param("path") || c.req.param("fileName"); @@ -131,8 +171,12 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { Key: `${tabloId}/${filePath}`, Body: content, ContentType: contentType, + Metadata: { + "uploaded-by": user.id, + }, }) ); + fileNamesCache.delete(tabloId); return c.json({ message: "File uploaded successfully", @@ -146,8 +190,10 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { }); const deleteTabloFile = (middlewareManager: ReturnType) => - factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + factory.createHandlers(checkTabloMember, async (c) => { const tabloId = c.req.param("tabloId"); + const user = c.get("user"); + const supabase = c.get("supabase"); // Get the file path - supports both wildcard (*) and named parameter (:fileName) const filePath = c.req.param("path") || c.req.param("fileName"); @@ -158,12 +204,58 @@ const deleteTabloFile = (middlewareManager: ReturnType => { + const cached = getCachedValue(foldersCache.get(tabloId)); + if (cached) { + return cached; + } + try { const response = await s3_client.send( new GetObjectCommand({ @@ -195,12 +292,16 @@ const getFolderMetadata = async ( if (response.Body) { const content = await response.Body.transformToString(); - return JSON.parse(content); + const metadata = JSON.parse(content) as TabloFoldersMetadata; + setCacheValue(foldersCache, tabloId, metadata); + return metadata; } } catch { // File doesn't exist, return default } - return { folders: [], version: 1 }; + const emptyMetadata = { folders: [], version: 1 }; + setCacheValue(foldersCache, tabloId, emptyMetadata); + return emptyMetadata; }; // Helper to save folder metadata @@ -217,6 +318,7 @@ const saveFolderMetadata = async ( ContentType: "application/json", }) ); + setCacheValue(foldersCache, tabloId, metadata); }; // GET /tablo-data/:tabloId/folders - Get all folders for a tablo @@ -368,7 +470,6 @@ export const getTabloDataRouter = () => { const middlewareManager = MiddlewareManager.getInstance(); tabloDataRouter.use(middlewareManager.auth); - tabloDataRouter.use(middlewareManager.streamChat); tabloDataRouter.use(middlewareManager.r2); // All-tablos file listing (must be before /:tabloId routes) diff --git a/apps/main/src/components/ChannelPreview.test.tsx b/apps/main/src/components/ChannelPreview.test.tsx index 5a456a0..db1b493 100644 --- a/apps/main/src/components/ChannelPreview.test.tsx +++ b/apps/main/src/components/ChannelPreview.test.tsx @@ -85,7 +85,7 @@ describe("ChannelPreview", () => { it("highlights active channel", () => { const { container } = render(); - expect(container.querySelector(".bg-blue-50")).toBeInTheDocument(); + expect(container.querySelector(".bg-purple-50")).toBeInTheDocument(); }); it("displays latest message preview", () => { @@ -100,6 +100,6 @@ describe("ChannelPreview", () => { it("shows active indicator for active channel", () => { const { container } = render(); - expect(container.querySelector(".bg-blue-500")).toBeInTheDocument(); + expect(container.querySelector(".absolute.left-0.top-0.bottom-0.w-1")).toBeInTheDocument(); }); }); diff --git a/apps/main/src/components/CreateTabloModal.test.tsx b/apps/main/src/components/CreateTabloModal.test.tsx index 4441541..2882d4b 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 tablo")).toBeInTheDocument(); + expect(screen.getByText("Create a new project")).toBeInTheDocument(); }); it("displays name input field", () => { render(); - expect(screen.getByPlaceholderText("Enter tablo name")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter project name")).toBeInTheDocument(); }); it("allows typing in name input", () => { render(); - const input = screen.getByPlaceholderText("Enter tablo name") as HTMLInputElement; + const input = screen.getByPlaceholderText("Enter project 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 tablo name"); + const input = screen.getByPlaceholderText("Enter project name"); fireEvent.change(input, { target: { value: "New Tablo" } }); const createButton = screen.getByText("Create"); @@ -85,7 +85,7 @@ describe("CreateTabloModal", () => { it("resets form after successful creation", () => { render(); - const input = screen.getByPlaceholderText("Enter tablo name") as HTMLInputElement; + const input = screen.getByPlaceholderText("Enter project name") as HTMLInputElement; fireEvent.change(input, { target: { value: "New Tablo" } }); const createButton = screen.getByText("Create"); @@ -96,7 +96,7 @@ describe("CreateTabloModal", () => { it("disables create button when in image mode", () => { render(); - const input = screen.getByPlaceholderText("Enter tablo name"); + const input = screen.getByPlaceholderText("Enter project name"); fireEvent.change(input, { target: { value: "New Tablo" } }); // Switch to image mode diff --git a/apps/main/src/components/DeleteTabloModal.test.tsx b/apps/main/src/components/DeleteTabloModal.test.tsx index 93ae915..c3b841e 100644 --- a/apps/main/src/components/DeleteTabloModal.test.tsx +++ b/apps/main/src/components/DeleteTabloModal.test.tsx @@ -38,7 +38,7 @@ describe("DeleteTabloModal", () => { isDeleting={false} /> ); - expect(screen.getByText("Delete tablo")).toBeInTheDocument(); + expect(screen.getByText("Delete project")).toBeInTheDocument(); }); it("returns null when tablo is null", () => { @@ -142,7 +142,7 @@ describe("DeleteTabloModal", () => { /> ); expect( - screen.getByText("All data associated with this tablo will be permanently lost.") + screen.getByText("All data associated with this project will be permanently lost.") ).toBeInTheDocument(); }); }); diff --git a/apps/main/src/components/EventModal.test.tsx b/apps/main/src/components/EventModal.test.tsx index 748f48c..be36c5d 100644 --- a/apps/main/src/components/EventModal.test.tsx +++ b/apps/main/src/components/EventModal.test.tsx @@ -40,7 +40,7 @@ describe("EventModal", () => { it("displays form fields", () => { renderWithProviders(); expect(screen.getByText("Title *")).toBeInTheDocument(); - expect(screen.getByText("Tablo *")).toBeInTheDocument(); + expect(screen.getByText("Project *")).toBeInTheDocument(); expect(screen.getByText("Date *")).toBeInTheDocument(); expect(screen.getByText("Start *")).toBeInTheDocument(); expect(screen.getByText("End")).toBeInTheDocument(); diff --git a/apps/main/src/components/ImportICSModal.test.tsx b/apps/main/src/components/ImportICSModal.test.tsx index b2896e4..bb0ec9b 100644 --- a/apps/main/src/components/ImportICSModal.test.tsx +++ b/apps/main/src/components/ImportICSModal.test.tsx @@ -65,7 +65,7 @@ describe("ImportICSModal", () => { it("displays create new tablo checkbox", () => { renderWithProviders(); - expect(screen.getByText("Create a new tablo")).toBeInTheDocument(); + expect(screen.getByText("Create a new project")).toBeInTheDocument(); }); it("disables import button initially", () => { diff --git a/apps/main/src/components/Layout.test.tsx b/apps/main/src/components/Layout.test.tsx index c21c216..e4449f8 100644 --- a/apps/main/src/components/Layout.test.tsx +++ b/apps/main/src/components/Layout.test.tsx @@ -1,23 +1,29 @@ import { fireEvent, screen } from "@testing-library/react"; import { Layout } from "@ui/components/Layout"; +import { beforeEach } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; describe("Layout", () => { + beforeEach(() => { + localStorage.setItem("xtablo-onboarding-completed", "true"); + }); + it("renders the layout with children", () => { - renderWithProviders(); + const { container } = renderWithProviders(); // Check if the mobile menu button is present - expect(screen.getByRole("button", { name: /menu/i })).toBeInTheDocument(); + expect(container.querySelector("button.md\\:hidden")).toBeInTheDocument(); }); it("has a menu button that can be clicked", () => { - renderWithProviders(); + const { container } = renderWithProviders(); // Get the menu button - const menuButton = screen.getByRole("button", { name: /menu/i }); + const menuButton = container.querySelector("button.md\\:hidden"); + expect(menuButton).toBeInTheDocument(); // Click the menu button - should not throw - fireEvent.click(menuButton); + fireEvent.click(menuButton!); expect(menuButton).toBeInTheDocument(); }); diff --git a/apps/main/src/components/TabloFilesSection.tsx b/apps/main/src/components/TabloFilesSection.tsx index 748ee64..14744f1 100644 --- a/apps/main/src/components/TabloFilesSection.tsx +++ b/apps/main/src/components/TabloFilesSection.tsx @@ -104,16 +104,14 @@ const FileIcon = ({ type }: { type: "image" | "pdf" | "text" | "other" }) => { // File Item Component const FileItem = ({ displayName, - isAdmin, - isReadOnly, + canDelete, onDownload, onDelete, isDownloading, isDeleting, }: { displayName: string; - isAdmin: boolean; - isReadOnly: boolean; + canDelete: boolean; onDownload: () => void; onDelete: () => void; isDownloading: boolean; @@ -159,7 +157,7 @@ const FileItem = ({ )} - {isAdmin && !isReadOnly && ( + {canDelete && ( )} + {(filesError || foldersError) && ( +
+ + Impossible de charger les fichiers pour ce tablo. + +
+ )} {/* Create Folder Button - Admin Only */} {isAdmin && !isReadOnly && ( @@ -921,9 +929,8 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {unorganizedFiles.map((fileName) => ( handleDownloadFile(fileName)} onDelete={() => handleDeleteFile(fileName)} isDownloading={downloadingFile === fileName} diff --git a/apps/main/src/components/TabloMembersSection.test.tsx b/apps/main/src/components/TabloMembersSection.test.tsx index 010b499..62ddced 100644 --- a/apps/main/src/components/TabloMembersSection.test.tsx +++ b/apps/main/src/components/TabloMembersSection.test.tsx @@ -25,6 +25,10 @@ vi.mock("../hooks/tablo_invites", () => ({ usePendingTabloInvitesByTablo: () => ({ data: [], }), + useCancelTabloInvite: () => ({ + mutate: vi.fn(), + isPending: false, + }), })); vi.mock("../hooks/invite", () => ({ diff --git a/apps/main/src/components/TabloOverviewSection.test.tsx b/apps/main/src/components/TabloOverviewSection.test.tsx index 89cd9a5..12ebf66 100644 --- a/apps/main/src/components/TabloOverviewSection.test.tsx +++ b/apps/main/src/components/TabloOverviewSection.test.tsx @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { TabloOverviewSection } from "./TabloOverviewSection"; -const mockUseTablo = vi.fn(); const mockUseTabloEtapes = vi.fn(); const mockUseTasksByTablo = vi.fn(); const createEtapeMock = { mutateAsync: vi.fn(), isPending: false }; @@ -11,10 +10,6 @@ const updateEtapeMock = { mutateAsync: vi.fn(), isPending: false }; const deleteEtapeMock = { mutateAsync: vi.fn(), isPending: false }; const reorderEtapesMock = { mutateAsync: vi.fn(), isPending: false }; -vi.mock("../hooks/tablos", () => ({ - useTablo: (tabloId: string) => mockUseTablo(tabloId), -})); - vi.mock("../hooks/tasks", () => ({ useTabloEtapes: (tabloId: string) => mockUseTabloEtapes(tabloId), useTasksByTablo: (tabloId: string) => mockUseTasksByTablo(tabloId), @@ -28,6 +23,10 @@ vi.mock("./TabloFilesSection", () => ({ TabloFilesSection: () =>
, })); +vi.mock("./TabloHeaderActions", () => ({ + TabloHeaderActions: () =>
, +})); + const mockTablo = { id: "tablo-1", name: "Projet Alpha", @@ -71,7 +70,6 @@ beforeEach(() => { updateEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined); deleteEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined); reorderEtapesMock.mutateAsync = vi.fn().mockResolvedValue(undefined); - mockUseTablo.mockReturnValue({ data: { owner_id: "123" } }); }); describe("TabloOverviewSection", () => { @@ -83,8 +81,6 @@ describe("TabloOverviewSection", () => { }); it("hides management actions for non owners", () => { - mockUseTablo.mockReturnValue({ data: { owner_id: "another-user" } }); - renderWithProviders(, { language: "fr", }); @@ -92,7 +88,7 @@ describe("TabloOverviewSection", () => { expect(screen.queryByPlaceholderText("Nom de l'Étape")).not.toBeInTheDocument(); expect( screen.getByText( - "Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape." + "Seul le propriétaire du projet peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape." ) ).toBeInTheDocument(); }); diff --git a/apps/main/src/components/TabloOverviewSection.tsx b/apps/main/src/components/TabloOverviewSection.tsx index d6ff614..f9c9e5a 100644 --- a/apps/main/src/components/TabloOverviewSection.tsx +++ b/apps/main/src/components/TabloOverviewSection.tsx @@ -2,7 +2,6 @@ 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, Plus, Trash2, X } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; @@ -15,6 +14,8 @@ import { useTasksByTablo, useUpdateEtape, } from "../hooks/tasks"; +import { useUser } from "../providers/UserStoreProvider"; +import { getEtapeProgressStats } from "../utils/etapeProgress"; import { TabloHeaderActions } from "./TabloHeaderActions"; interface TabloOverviewSectionProps { @@ -24,8 +25,9 @@ interface TabloOverviewSectionProps { export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionProps) => { const { t } = useTranslation(); + const currentUser = useUser(); const { data: etapes = [], isLoading: isLoadingEtapes } = useTabloEtapes(tablo.id); - const { data: tasks = [] } = useTasksByTablo(tablo.id); + const { data: tasks = [] } = useTasksByTablo(tablo.id, { assigneeId: currentUser.id }); const createEtape = useCreateEtape(); const updateEtape = useUpdateEtape(); const deleteEtape = useDeleteEtape(); @@ -39,13 +41,10 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro const sortedEtapes = useMemo(() => [...etapes].sort((a, b) => a.position - b.position), [etapes]); - // Calculate overall tablo progress + // Calculate overall tablo progress from etape statuses const overallProgress = useMemo(() => { - const totalTasks = tasks.length; - const doneTasks = tasks.filter((task) => task.status === "done").length; - const percentage = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0; - return { total: totalTasks, done: doneTasks, percentage }; - }, [tasks]); + return getEtapeProgressStats(etapes); + }, [etapes]); // Calculate task counts per etape const getEtapeTaskCounts = useCallback( @@ -314,9 +313,32 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro })}
-
{overallProgress.percentage}%
+
{overallProgress.donePercentage}%
+
+
+
+
+
+
+ + {t("tablo:overview.inProgressSummary", { + started: overallProgress.started, + total: overallProgress.total, + })} + + + {t("tablo:overview.progressSummary", { + done: overallProgress.done, + total: overallProgress.total, + })} +
-
)} diff --git a/apps/main/src/components/TabloTasksSection.tsx b/apps/main/src/components/TabloTasksSection.tsx index 12c92f7..759b404 100644 --- a/apps/main/src/components/TabloTasksSection.tsx +++ b/apps/main/src/components/TabloTasksSection.tsx @@ -32,6 +32,32 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => const { mutate: updateTaskPositions } = useUpdateTaskPositions(); const { mutate: createTask } = useCreateTask(); + const memberById = useMemo( + () => new Map(members.map((member) => [member.id, member])), + [members] + ); + + const tasksWithAssigneeFallback = useMemo( + () => + (tasks ?? []).map((task) => { + if (!task.assignee_id) { + return task; + } + + const assignee = memberById.get(task.assignee_id); + if (!assignee) { + return task; + } + + return { + ...task, + assignee_name: task.assignee_name ?? assignee.name, + assignee_avatar: task.assignee_avatar ?? assignee.avatar_url, + } satisfies KanbanTask; + }), + [memberById, tasks] + ); + const etapeTitleMap = useMemo( () => etapes.reduce>((map, etape) => { @@ -43,8 +69,8 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => // Check for tasks without parent (orphaned tasks) const orphanedTasks = useMemo(() => { - return tasks?.filter((task) => !task.parent_task_id) || []; - }, [tasks]); + return tasksWithAssigneeFallback.filter((task) => !task.parent_task_id); + }, [tasksWithAssigneeFallback]); // Helper functions defined before use const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => { @@ -82,8 +108,8 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => }, []); useEffect(() => { - setColumns(initializeColumns(tasks ?? [])); - }, [initializeColumns, tasks]); + setColumns(initializeColumns(tasksWithAssigneeFallback)); + }, [initializeColumns, tasksWithAssigneeFallback]); const handleAddTask = (status: TaskStatus) => { setSelectedTask(null); diff --git a/apps/main/src/hooks/tablo_data.ts b/apps/main/src/hooks/tablo_data.ts index 0aa8578..483401b 100644 --- a/apps/main/src/hooks/tablo_data.ts +++ b/apps/main/src/hooks/tablo_data.ts @@ -247,7 +247,9 @@ export function useDeleteTabloFile() { mutationFn: async ({ tabloId, fileName }) => { const response = await api.delete(`/api/v1/tablo-data/${tabloId}/file/${fileName}`); if (response.status !== 200) { - throw new Error("Failed to delete file"); + const errorMessage = + typeof response.data?.error === "string" ? response.data.error : "Failed to delete file"; + throw new Error(errorMessage); } return response.data; }, diff --git a/apps/main/src/hooks/tasks.ts b/apps/main/src/hooks/tasks.ts index 5f7bf6e..3440612 100644 --- a/apps/main/src/hooks/tasks.ts +++ b/apps/main/src/hooks/tasks.ts @@ -92,17 +92,28 @@ export const useAllTasks = () => { }; // Fetch all tasks for a specific tablo -export const useTasksByTablo = (tabloId: string | undefined) => { +export const useTasksByTablo = ( + tabloId: string | undefined, + options?: { assigneeId?: string } +) => { + const assigneeId = options?.assigneeId; + return useQuery({ - queryKey: ["tasks", "tablo", tabloId], + queryKey: ["tasks", "tablo", tabloId, assigneeId ?? "all-assignees"], queryFn: async () => { - const { data, error } = await supabase + let query = supabase .from("tasks_with_assignee") .select("*") .eq("tablo_id", tabloId) .eq("is_parent", false) .order("position", { ascending: true }); + if (assigneeId) { + query = query.eq("assignee_id", assigneeId); + } + + const { data, error } = await query; + if (error) throw error; return data as KanbanTask[]; }, diff --git a/apps/main/src/locales/en/tablo.json b/apps/main/src/locales/en/tablo.json index 43debbb..915e309 100644 --- a/apps/main/src/locales/en/tablo.json +++ b/apps/main/src/locales/en/tablo.json @@ -3,7 +3,8 @@ "title": "Overview", "description": "Configure the Stages of the project to clarify the major phases of your project.", "overallProgress": "Overall Progress", - "progressSummary": "{{done}} of {{total}} task(s) completed" + "progressSummary": "{{done}} of {{total}} stage(s) completed", + "inProgressSummary": "{{started}} of {{total}} stage(s) at least in progress" }, "etape": { "nameRequired": "The Stage name is required", diff --git a/apps/main/src/locales/fr/tablo.json b/apps/main/src/locales/fr/tablo.json index 635bc15..21a680c 100644 --- a/apps/main/src/locales/fr/tablo.json +++ b/apps/main/src/locales/fr/tablo.json @@ -3,7 +3,8 @@ "title": "Vue d'ensemble", "description": "Configurez les Étapes du projet pour clarifier les grandes phases de votre projet.", "overallProgress": "Progression globale", - "progressSummary": "{{done}} sur {{total}} tâche(s) terminée(s)" + "progressSummary": "{{done}} sur {{total}} étape(s) terminée(s)", + "inProgressSummary": "{{started}} sur {{total}} étape(s) au moins en cours" }, "etape": { "nameRequired": "Le nom de l'Étape est requis", diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 7a4183c..7486ad4 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -70,6 +70,7 @@ import { useUpdateTask, } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; +import { getEtapeProgressStats } from "../utils/etapeProgress"; // ─── Icon helpers ───────────────────────────────────────────────────────────── @@ -119,21 +120,18 @@ function getStatusConfig(status: string) { label: "En cours", badgeClass: "bg-yellow-50 text-yellow-700 border border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-800", - progress: 50, }; case "done": return { label: "Terminé", badgeClass: "bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800", - progress: 100, }; default: return { label: "À faire", badgeClass: "bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800", - progress: 0, }; } } @@ -179,6 +177,7 @@ export const TabloDetailsPage = () => { const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState< Date | undefined >(undefined); + const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); @@ -249,6 +248,10 @@ export const TabloDetailsPage = () => { const tabloTasks = (allTasks as KanbanTask[]).filter( (t) => t.tablo_id === tabloId, ); + const myTabloTasks = tabloTasks.filter((task) => task.assignee_id === currentUser.id); + const visibleOverviewTasks = showAllOverviewTasks + ? myTabloTasks + : myTabloTasks.slice(0, 5); // Etapes (parent tasks) for this tablo const { data: etapes = [] } = useTabloEtapes(tabloId); @@ -269,11 +272,8 @@ export const TabloDetailsPage = () => { if (!tablo) return null; - const { - label: statusLabel, - badgeClass, - progress, - } = getStatusConfig(tablo.status); + const { label: statusLabel, badgeClass } = getStatusConfig(tablo.status); + const progress = getEtapeProgressStats(etapes); const isAdmin = tablo.is_admin; const TabloIcon = getTabloIcon(tablo.color); const iconColor = getTabloIconColor(tablo.color); @@ -357,13 +357,17 @@ export const TabloDetailsPage = () => {
Progression : -
+
+
- {progress}% + {progress.donePercentage}%
@@ -438,12 +442,12 @@ export const TabloDetailsPage = () => {
- {tabloTasks.length === 0 ? ( + {myTabloTasks.length === 0 ? (
Aucune tâche
) : ( - tabloTasks.slice(0, 5).map((task) => ( + visibleOverviewTasks.map((task) => (
{
)) )} - {tabloTasks.length > 5 && ( + {myTabloTasks.length > 5 && ( )}
diff --git a/apps/main/src/pages/tablo.test.tsx b/apps/main/src/pages/tablo.test.tsx index 25d9cf3..e2fcbf9 100644 --- a/apps/main/src/pages/tablo.test.tsx +++ b/apps/main/src/pages/tablo.test.tsx @@ -4,18 +4,25 @@ import { renderWithProviders } from "../utils/testHelpers"; import { TabloPage } from "./tablo"; vi.mock("../hooks/tablos", () => ({ - useTablo: () => ({ - tablo: { - id: "test-tablo-id", - name: "Test Tablo", - owner_id: "test-owner-id", - }, + useTablosList: () => ({ + data: [ + { + id: "test-tablo-id", + name: "Test Tablo", + color: "bg-blue-500", + image: null, + created_at: "2024-01-01T00:00:00Z", + deleted_at: null, + position: 0, + status: "todo", + user_id: "test-user-id", + is_admin: true, + access_level: "admin", + }, + ], isLoading: false, error: null, }), - useTablosList: () => ({ - data: [{ id: "test-tablo-id", name: "Test Tablo" }], - }), useCreateTablo: () => ({ mutate: vi.fn(), }), @@ -26,6 +33,10 @@ vi.mock("../hooks/tablos", () => ({ mutate: vi.fn(), }), useCanCreateTablo: () => true, + useTabloMembers: () => ({ + data: [], + isLoading: false, + }), })); vi.mock("../hooks/tabloData", () => ({ diff --git a/apps/main/src/utils/etapeProgress.ts b/apps/main/src/utils/etapeProgress.ts new file mode 100644 index 0000000..1ce7539 --- /dev/null +++ b/apps/main/src/utils/etapeProgress.ts @@ -0,0 +1,35 @@ +import type { Etape } from "@xtablo/shared-types"; + +const STARTED_ETAPE_STATUSES = new Set(["in_progress", "in_review", "done"]); + +export interface EtapeProgressStats { + total: number; + started: number; + done: number; + startedPercentage: number; + donePercentage: number; +} + +export function getEtapeProgressStats(etapes: Etape[]): EtapeProgressStats { + const total = etapes.length; + const done = etapes.filter((etape) => etape.status === "done").length; + const started = etapes.filter((etape) => STARTED_ETAPE_STATUSES.has(etape.status ?? "todo")).length; + + if (total === 0) { + return { + total: 0, + started: 0, + done: 0, + startedPercentage: 0, + donePercentage: 0, + }; + } + + return { + total, + started, + done, + startedPercentage: Math.round((started / total) * 100), + donePercentage: Math.round((done / total) * 100), + }; +}