Some work towards initial release

This commit is contained in:
Arthur Belleville 2026-03-07 15:45:49 +01:00
parent 28d0b938fa
commit 354831c82f
No known key found for this signature in database
21 changed files with 372 additions and 124 deletions

View file

@ -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 () => {

View file

@ -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) => {

View file

@ -1,6 +1,7 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
@ -15,10 +16,48 @@ const factory = createFactory<AuthEnv>();
// Metadata file name for folders
const FOLDERS_METADATA_FILE = ".tablo-folders.json";
const CACHE_TTL_MS = 15_000;
type CacheEntry<T> = {
value: T;
expiresAt: number;
};
const fileNamesCache = new Map<string, CacheEntry<string[]>>();
const foldersCache = new Map<string, CacheEntry<TabloFoldersMetadata>>();
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 = <T>(entry: CacheEntry<T> | undefined): T | null => {
if (!entry) return null;
if (Date.now() >= entry.expiresAt) return null;
return entry.value;
};
const setCacheValue = <T>(map: Map<string, CacheEntry<T>>, key: string, value: T) => {
map.set(key, {
value,
expiresAt: Date.now() + CACHE_TTL_MS,
});
};
const getCachedTabloFileNames = async (s3_client: S3Client, tabloId: string): Promise<string[]> => {
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<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManager.
const s3_client = c.get("s3_client");
try {
const { data: access, error: accessError } = await supabase
.from("tablo_access")
.select("is_admin")
.eq("tablo_id", tabloId)
.eq("user_id", user.id)
.eq("is_active", true)
.maybeSingle();
if (accessError) {
return c.json({ error: "Failed to verify access rights" }, 500);
}
if (!access) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
if (!access.is_admin) {
try {
const headResponse = await s3_client.send(
new HeadObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${filePath}`,
})
);
const uploadedBy =
headResponse.Metadata?.["uploaded-by"] ??
headResponse.Metadata?.uploaded_by ??
null;
if (uploadedBy !== user.id) {
return c.json({ error: "You can only delete files you uploaded" }, 403);
}
} catch (error) {
const statusCode = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata
?.httpStatusCode;
if (statusCode === 404) {
return c.json({ error: "You can only delete files you uploaded" }, 403);
}
console.error("Error checking file ownership:", error);
return c.json({ error: "Failed to verify file ownership" }, 500);
}
}
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${filePath}`,
})
);
fileNamesCache.delete(tabloId);
return c.json({
message: "File deleted successfully",
@ -185,6 +277,11 @@ const getFolderMetadata = async (
s3_client: S3Client,
tabloId: string
): Promise<TabloFoldersMetadata> => {
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)

View file

@ -85,7 +85,7 @@ describe("ChannelPreview", () => {
it("highlights active channel", () => {
const { container } = render(<ChannelPreview {...defaultProps} activeChannel={mockChannel} />);
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(<ChannelPreview {...defaultProps} activeChannel={mockChannel} />);
expect(container.querySelector(".bg-blue-500")).toBeInTheDocument();
expect(container.querySelector(".absolute.left-0.top-0.bottom-0.w-1")).toBeInTheDocument();
});
});

View file

@ -27,24 +27,24 @@ describe("CreateTabloModal", () => {
it("renders without crashing", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
expect(screen.getByText("Create a new tablo")).toBeInTheDocument();
expect(screen.getByText("Create a new project")).toBeInTheDocument();
});
it("displays name input field", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
expect(screen.getByPlaceholderText("Enter tablo name")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter project name")).toBeInTheDocument();
});
it("allows typing in name input", () => {
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
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(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
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(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
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(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
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

View file

@ -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();
});
});

View file

@ -40,7 +40,7 @@ describe("EventModal", () => {
it("displays form fields", () => {
renderWithProviders(<EventModal mode="create" />);
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();

View file

@ -65,7 +65,7 @@ describe("ImportICSModal", () => {
it("displays create new tablo checkbox", () => {
renderWithProviders(<ImportICSModal onClose={mockOnClose} />);
expect(screen.getByText("Create a new tablo")).toBeInTheDocument();
expect(screen.getByText("Create a new project")).toBeInTheDocument();
});
it("disables import button initially", () => {

View file

@ -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(<Layout />);
const { container } = renderWithProviders(<Layout />);
// 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(<Layout />);
const { container } = renderWithProviders(<Layout />);
// 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();
});

View file

@ -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 = ({
<DownloadIcon className="w-4 h-4" />
)}
</Button>
{isAdmin && !isReadOnly && (
{canDelete && (
<Button
size="sm"
variant="ghost"
@ -435,8 +433,7 @@ const FolderSection = ({
<FileItem
key={fileName}
displayName={getFileNameWithoutFolder(fileName)}
isAdmin={isAdmin}
isReadOnly={isReadOnly}
canDelete={true}
onDownload={() => onDownloadFile(fileName)}
onDelete={() => onDeleteFile(fileName)}
isDownloading={downloadingFile === fileName}
@ -458,8 +455,12 @@ const FolderSection = ({
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
const currentUser = useUser();
const { data: fileData, isLoading: filesLoading } = useTabloFileNames(tablo.id);
const { data: foldersData, isLoading: foldersLoading } = useTabloFolders(tablo.id);
const { data: fileData, isLoading: filesLoading, error: filesError } = useTabloFileNames(tablo.id);
const {
data: foldersData,
isLoading: foldersLoading,
error: foldersError,
} = useTabloFolders(tablo.id);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
@ -479,6 +480,8 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
const updateFolder = useUpdateTabloFolder();
const deleteFolder = useDeleteTabloFolder();
const isReadOnly = useIsReadOnlyUser();
const folders = foldersData?.folders || [];
const folderIds = useMemo(() => new Set(folders.map((folder) => folder.id)), [folders]);
// Organize files by folder
const { filesInFolders, unorganizedFiles } = useMemo(() => {
@ -494,7 +497,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
if (fileName.startsWith(".")) continue;
const folderId = extractFolderIdFromFileName(fileName);
if (folderId) {
if (folderId && folderIds.has(folderId)) {
const existing = filesInFolders.get(folderId) || [];
existing.push(fileName);
filesInFolders.set(folderId, existing);
@ -504,9 +507,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
}
return { filesInFolders, unorganizedFiles };
}, [fileData?.fileNames]);
const folders = foldersData?.folders || [];
}, [fileData?.fileNames, folderIds]);
const toggleFolder = (folderId: string) => {
setOpenFolders((prev) => {
@ -733,6 +734,13 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
</button>
</div>
)}
{(filesError || foldersError) && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<span className="text-red-700 dark:text-red-300 text-sm">
Impossible de charger les fichiers pour ce tablo.
</span>
</div>
)}
{/* Create Folder Button - Admin Only */}
{isAdmin && !isReadOnly && (
@ -921,9 +929,8 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
{unorganizedFiles.map((fileName) => (
<FileItem
key={fileName}
displayName={fileName}
isAdmin={isAdmin}
isReadOnly={isReadOnly}
displayName={getFileNameWithoutFolder(fileName)}
canDelete={true}
onDownload={() => handleDownloadFile(fileName)}
onDelete={() => handleDeleteFile(fileName)}
isDownloading={downloadingFile === fileName}

View file

@ -25,6 +25,10 @@ vi.mock("../hooks/tablo_invites", () => ({
usePendingTabloInvitesByTablo: () => ({
data: [],
}),
useCancelTabloInvite: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
vi.mock("../hooks/invite", () => ({

View file

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

View file

@ -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
})}
</TypographyMuted>
</div>
<div className="text-2xl font-bold text-primary">{overallProgress.percentage}%</div>
<div className="text-2xl font-bold text-primary">{overallProgress.donePercentage}%</div>
</div>
<div className="relative h-3 w-full overflow-hidden rounded-full bg-muted">
<div
className="absolute inset-y-0 left-0 bg-blue-500/40 transition-all"
style={{ width: `${overallProgress.startedPercentage}%` }}
/>
<div
className="absolute inset-y-0 left-0 bg-green-500 transition-all"
style={{ width: `${overallProgress.donePercentage}%` }}
/>
</div>
<div className="mt-2 flex items-center gap-4 text-xs">
<span className="text-blue-600 dark:text-blue-400">
{t("tablo:overview.inProgressSummary", {
started: overallProgress.started,
total: overallProgress.total,
})}
</span>
<span className="text-green-600 dark:text-green-400">
{t("tablo:overview.progressSummary", {
done: overallProgress.done,
total: overallProgress.total,
})}
</span>
</div>
<Progress value={overallProgress.done} max={overallProgress.total} className="h-3" />
</div>
)}

View file

@ -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<Record<string, string>>((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);

View file

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

View file

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

View file

@ -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",

View file

@ -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",

View file

@ -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 = () => {
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Progression :</span>
<div className="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div className="relative w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${progress}%` }}
className="absolute inset-y-0 left-0 bg-blue-500/40"
style={{ width: `${progress.startedPercentage}%` }}
/>
<div
className="absolute inset-y-0 left-0 bg-green-500"
style={{ width: `${progress.donePercentage}%` }}
/>
</div>
<span className="text-foreground font-medium">{progress}%</span>
<span className="text-foreground font-medium">{progress.donePercentage}%</span>
</div>
</div>
</div>
@ -438,12 +442,12 @@ export const TabloDetailsPage = () => {
</button>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{tabloTasks.length === 0 ? (
{myTabloTasks.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">
Aucune tâche
</div>
) : (
tabloTasks.slice(0, 5).map((task) => (
visibleOverviewTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
@ -467,13 +471,15 @@ export const TabloDetailsPage = () => {
</div>
))
)}
{tabloTasks.length > 5 && (
{myTabloTasks.length > 5 && (
<button
type="button"
onClick={() => setSearchParams({ section: "tasks" })}
onClick={() => setShowAllOverviewTasks((prev) => !prev)}
className="w-full p-3 text-sm text-[#804EEC] hover:underline text-center"
>
Voir les {tabloTasks.length - 5} tâches restantes
{showAllOverviewTasks
? "Voir moins"
: `Voir les ${myTabloTasks.length - 5} tâches restantes`}
</button>
)}
</div>

View file

@ -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", () => ({

View file

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