Some work towards initial release
This commit is contained in:
parent
28d0b938fa
commit
354831c82f
21 changed files with 372 additions and 124 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ vi.mock("../hooks/tablo_invites", () => ({
|
|||
usePendingTabloInvitesByTablo: () => ({
|
||||
data: [],
|
||||
}),
|
||||
useCancelTabloInvite: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/invite", () => ({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
35
apps/main/src/utils/etapeProgress.ts
Normal file
35
apps/main/src/utils/etapeProgress.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue