diff --git a/apps/chat-worker/src/index.ts b/apps/chat-worker/src/index.ts index 5eb2a65..6f3adee 100644 --- a/apps/chat-worker/src/index.ts +++ b/apps/chat-worker/src/index.ts @@ -10,12 +10,13 @@ export { ChatRoom }; const app = new Hono<{ Bindings: Env }>(); -// CORS — allow the main app origins +// CORS — allow the web app origins that embed chat app.use("*", cors({ origin: [ "http://localhost:5173", "https://app.xtablo.com", "https://app-staging.xtablo.com", + "https://clients.xtablo.com", ], allowHeaders: ["Authorization", "Content-Type"], allowMethods: ["GET", "POST", "OPTIONS"], diff --git a/apps/clients/.env.production b/apps/clients/.env.production index 7f10812..7c3ba26 100644 --- a/apps/clients/.env.production +++ b/apps/clients/.env.production @@ -1,4 +1,7 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM +VITE_CHAT_WS_URL=wss://chat.xtablo.com +VITE_CHAT_API_URL=https://chat.xtablo.com + VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app diff --git a/apps/clients/src/envProduction.test.ts b/apps/clients/src/envProduction.test.ts index a2481d2..90f3834 100644 --- a/apps/clients/src/envProduction.test.ts +++ b/apps/clients/src/envProduction.test.ts @@ -5,9 +5,11 @@ import { describe, expect, it } from "vitest"; const productionEnv = readFileSync(resolve(process.cwd(), ".env.production"), "utf8"); describe("clients production env", () => { - it("points the API URL to staging while client portal testing is in progress", () => { + it("points the API URL to staging while client portal testing is in progress and keeps chat endpoints configured", () => { expect(productionEnv).toContain( "VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app" ); + expect(productionEnv).toContain("VITE_CHAT_API_URL=https://chat.xtablo.com"); + expect(productionEnv).toContain("VITE_CHAT_WS_URL=wss://chat.xtablo.com"); }); }); diff --git a/apps/clients/src/pages/ClientTabloPage.test.tsx b/apps/clients/src/pages/ClientTabloPage.test.tsx index 4055037..2f0dc18 100644 --- a/apps/clients/src/pages/ClientTabloPage.test.tsx +++ b/apps/clients/src/pages/ClientTabloPage.test.tsx @@ -1,14 +1,114 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../test/testHelpers"; import { ClientTabloPage } from "./ClientTabloPage"; +const { + apiGetMock, + apiPostMock, + apiPutMock, + apiDeleteMock, + updateTaskMock, + insertTaskMock, + deleteTaskMock, + supabaseFromMock, +} = vi.hoisted(() => { + const apiGetMock = vi.fn(async (url: string) => { + if (url.endsWith("/brief.pdf")) { + return { + status: 200, + data: { content: "test file content", contentType: "application/pdf" }, + }; + } + + return { status: 200, data: { folders: [] } }; + }); + const apiPostMock = vi.fn(async () => ({ + status: 200, + data: { + message: "ok", + fileName: "brief.pdf", + tabloId: "tablo-1", + folder: { id: "folder-1", name: "Livrable", description: "" }, + }, + })); + const apiPutMock = vi.fn(async () => ({ + status: 200, + data: { folder: { id: "folder-1", name: "Livrable mis à jour", description: "Desc" } }, + })); + const apiDeleteMock = vi.fn(async () => ({ status: 200, data: { message: "ok" } })); + const createUpdateBuilder = () => { + const builder = { + error: null as null, + eq: vi.fn(() => builder), + select: vi.fn(() => ({ + single: async () => ({ data: { id: "task-1" }, error: null }), + })), + }; + return builder; + }; + const updateTaskMock = vi.fn(() => createUpdateBuilder()); + const insertTaskMock = vi.fn(() => ({ + select: () => ({ + single: async () => ({ data: { id: "task-created" }, error: null }), + }), + })); + const deleteTaskMock = vi.fn(() => ({ + eq: vi.fn(async () => ({ error: null })), + })); + const supabaseFromMock = vi.fn(() => ({ + insert: insertTaskMock, + update: updateTaskMock, + delete: deleteTaskMock, + })); + + return { + apiGetMock, + apiPostMock, + apiPutMock, + apiDeleteMock, + updateTaskMock, + insertTaskMock, + deleteTaskMock, + supabaseFromMock, + }; +}); +let latestTabloTasksSectionProps: Record | null = null; +let latestEtapesSectionProps: Record | null = null; +let latestRoadmapSectionProps: Record | null = null; +let latestTabloFilesSectionProps: Record | null = null; + +vi.mock("@xtablo/shared", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + buildApi: () => ({ + create: () => ({ + get: apiGetMock, + post: apiPostMock, + put: apiPutMock, + delete: apiDeleteMock, + }), + }), + }; +}); + +vi.mock("../lib/supabase", () => ({ + supabase: { + from: supabaseFromMock, + }, +})); + vi.mock("@tanstack/react-query", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useQuery: ({ queryKey }: { queryKey: string[] }) => { + useQuery: ({ queryKey, queryFn }: { queryKey: string[]; queryFn?: () => Promise }) => { + if (queryKey[0] === "client-tablo-folders" && queryFn) { + void queryFn(); + } switch (queryKey[0]) { case "client-tablo": return { @@ -84,16 +184,321 @@ vi.mock("@xtablo/tablo-views", async (importOriginal) => { return { ...actual, - EtapesSection: () =>
Etapes section
, - RoadmapSection: () =>
Roadmap section
, + EtapesSection: (props: Record) => { + latestEtapesSectionProps = props; + return ( +
+
Etapes section
+ + +
+ ); + }, + RoadmapSection: (props: Record) => { + latestRoadmapSectionProps = props; + return ( +
+
Roadmap section
+ +
+ ); + }, TabloDiscussionSection: () =>
Discussion section
, TabloEventsSection: () =>
Events section
, - TabloFilesSection: () =>
Files section
, - TabloTasksSection: () =>
Tasks section
, + TabloFilesSection: (props: Record) => { + latestTabloFilesSectionProps = props; + return ( +
+
Files section
+ + + + + +
+ ); + }, + TabloTasksSection: (props: Record) => { + latestTabloTasksSectionProps = props; + return ( +
+
Tasks section
+ + + + +
+ ); + }, }; }); describe("ClientTabloPage parity shell", () => { + beforeEach(() => { + window.URL.createObjectURL = vi.fn(() => "blob:test"); + window.URL.revokeObjectURL = vi.fn(); + HTMLAnchorElement.prototype.click = vi.fn(); + apiGetMock.mockClear(); + apiPostMock.mockClear(); + apiPutMock.mockClear(); + apiDeleteMock.mockClear(); + updateTaskMock.mockClear(); + insertTaskMock.mockClear(); + deleteTaskMock.mockClear(); + supabaseFromMock.mockClear(); + latestTabloTasksSectionProps = null; + latestEtapesSectionProps = null; + latestRoadmapSectionProps = null; + latestTabloFilesSectionProps = null; + }); + + it("requests folders from the tablo-data API route", () => { + renderWithProviders(, { + route: "/tablo/tablo-1", + path: "/tablo/:tabloId", + }); + + expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders"); + }); + + it("wires real task mutation callbacks throughout the client task surfaces", async () => { + const { user } = renderWithProviders(, { + route: "/tablo/tablo-1", + path: "/tablo/:tabloId", + }); + + await user.click(screen.getByRole("button", { name: "Étapes" })); + + expect(latestEtapesSectionProps?.onCreateTask).toBeTypeOf("function"); + expect(latestEtapesSectionProps?.onTaskStatusChange).toBeTypeOf("function"); + + await user.click(screen.getByRole("button", { name: "Créer tâche d'étape test" })); + await user.click(screen.getByRole("button", { name: "Terminer tâche d'étape test" })); + + await user.click(screen.getByRole("button", { name: "Tâches" })); + + expect(latestTabloTasksSectionProps?.onCreateTask).toBeTypeOf("function"); + expect(latestTabloTasksSectionProps?.onUpdateTask).toBeTypeOf("function"); + expect(latestTabloTasksSectionProps?.onDeleteTask).toBeTypeOf("function"); + expect(latestTabloTasksSectionProps?.onUpdateTaskPositions).toBeTypeOf("function"); + + await user.click(screen.getByRole("button", { name: "Créer tâche test" })); + await user.click(screen.getByRole("button", { name: "Modifier tâche test" })); + await user.click(screen.getByRole("button", { name: "Supprimer tâche test" })); + await user.click(screen.getByRole("button", { name: "Déplacer la tâche test" })); + + await user.click(screen.getByRole("button", { name: "Roadmap" })); + + expect(latestRoadmapSectionProps?.onTaskStatusChange).toBeTypeOf("function"); + + await user.click(screen.getByRole("button", { name: "Changer statut roadmap test" })); + + await waitFor(() => { + expect(supabaseFromMock).toHaveBeenCalledWith("tasks"); + expect(insertTaskMock).toHaveBeenCalledTimes(2); + expect(insertTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + tablo_id: "tablo-1", + title: "Task from etape", + status: "todo", + assignee_id: null, + position: 0, + parent_task_id: "etape-1", + is_parent: false, + description: null, + due_date: null, + }) + ); + expect(updateTaskMock).toHaveBeenCalledWith({ title: "Updated task title" }); + expect(updateTaskMock).toHaveBeenCalledWith({ position: 7, status: "done" }); + expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" }); + expect(deleteTaskMock).toHaveBeenCalledTimes(1); + }); + }); + it("renders the main-route style header metadata and discussion CTA", () => { renderWithProviders(, { route: "/tablo/tablo-1", @@ -143,4 +548,57 @@ describe("ClientTabloPage parity shell", () => { expect(screen.getByText("Informations")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Ajouter" })).not.toBeInTheDocument(); }); + + it("lets the client quickly toggle a task from the overview card", async () => { + const { user } = renderWithProviders(, { + route: "/tablo/tablo-1", + path: "/tablo/:tabloId", + }); + + await user.click(screen.getByRole("button", { name: "Prepare proposal" })); + + await waitFor(() => { + expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" }); + }); + }); + + it("wires file and folder actions in the client files tab while keeping file deletion disabled", async () => { + const { user } = renderWithProviders(, { + route: "/tablo/tablo-1", + path: "/tablo/:tabloId", + }); + + await user.click(screen.getByRole("button", { name: "Fichiers" })); + + expect(latestTabloFilesSectionProps?.isReadOnly).toBe(false); + expect(latestTabloFilesSectionProps?.onCreateFile).toBeTypeOf("function"); + expect(latestTabloFilesSectionProps?.onDownloadFile).toBeTypeOf("function"); + expect(latestTabloFilesSectionProps?.onCreateFolder).toBeTypeOf("function"); + expect(latestTabloFilesSectionProps?.onUpdateFolder).toBeTypeOf("function"); + expect(latestTabloFilesSectionProps?.onDeleteFolder).toBeTypeOf("function"); + expect(latestTabloFilesSectionProps?.onDeleteFile).toBeUndefined(); + + await user.click(screen.getByRole("button", { name: "Créer fichier test" })); + await user.click(screen.getByRole("button", { name: "Télécharger fichier test" })); + await user.click(screen.getByRole("button", { name: "Créer livrable test" })); + await user.click(screen.getByRole("button", { name: "Modifier livrable test" })); + await user.click(screen.getByRole("button", { name: "Supprimer livrable test" })); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledWith( + "/api/v1/tablo-data/tablo-1/file/brief.pdf", + { content: "data:application/pdf;base64,AAAA", contentType: "application/pdf" } + ); + expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/brief.pdf"); + expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders", { + name: "Livrable", + description: "Desc", + }); + expect(apiPutMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1", { + name: "Livrable mis à jour", + description: "Desc", + }); + expect(apiDeleteMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1"); + }); + }); }); diff --git a/apps/clients/src/pages/ClientTabloPage.tsx b/apps/clients/src/pages/ClientTabloPage.tsx index aaeb3eb..4be1e85 100644 --- a/apps/clients/src/pages/ClientTabloPage.tsx +++ b/apps/clients/src/pages/ClientTabloPage.tsx @@ -1,8 +1,15 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { cn } from "@xtablo/shared"; import { buildApi } from "@xtablo/shared"; import { useSession } from "@xtablo/shared/contexts/SessionContext"; -import type { Etape, KanbanTask, TabloFolder, UserTablo } from "@xtablo/shared-types"; +import type { + Etape, + KanbanTask, + KanbanTaskUpdate, + TabloFolder, + TaskStatus, + UserTablo, +} from "@xtablo/shared-types"; import { FolderIcon, } from "lucide-react"; @@ -135,13 +142,254 @@ function useClientTabloFolders(tabloId: string, accessToken: string | undefined) return useQuery({ queryKey: ["client-tablo-folders", tabloId], queryFn: async () => { - const { data } = await api.get<{ folders: TabloFolder[] }>(`/api/v1/tablo-folders/${tabloId}`); + const { data } = await api.get<{ folders: TabloFolder[] }>( + `/api/v1/tablo-data/${tabloId}/folders` + ); return data.folders ?? []; }, enabled: !!tabloId && !!accessToken, }); } +const invalidateClientFileQueries = (queryClient: ReturnType, tabloId: string) => { + queryClient.invalidateQueries({ queryKey: ["client-tablo-files", tabloId] }); + queryClient.invalidateQueries({ queryKey: ["client-tablo-folders", tabloId] }); +}; + +function useClientCreateFile(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + fileName: string; + data: { content: string; contentType: string }; + }) => { + const response = await api.post(`/api/v1/tablo-data/${params.tabloId}/file/${params.fileName}`, params.data); + if (response.status !== 200) { + throw new Error("Failed to create file"); + } + return response.data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +function useClientDownloadFile(accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + + return useMutation({ + mutationFn: async ({ tabloId, fileName }: { tabloId: string; fileName: string }) => { + const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`); + if (response.status !== 200) { + throw new Error("Failed to download file"); + } + + const fileData = response.data as { content: string; contentType?: string }; + let blob: Blob; + + if (fileData.content.startsWith("data:")) { + const fileResponse = await fetch(fileData.content); + blob = await fileResponse.blob(); + } else { + blob = new Blob([fileData.content], { + type: fileData.contentType || "application/octet-stream", + }); + } + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }, + }); +} + +function useClientCreateFolder(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + name: string; + description: string; + createdBy: string; + }) => { + const response = await api.post(`/api/v1/tablo-data/${params.tabloId}/folders`, { + name: params.name, + description: params.description, + }); + if (response.status !== 200) { + throw new Error("Failed to create folder"); + } + return response.data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +function useClientUpdateFolder(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + folderId: string; + name: string; + description: string; + }) => { + const response = await api.put(`/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}`, { + name: params.name, + description: params.description, + }); + if (response.status !== 200) { + throw new Error("Failed to update folder"); + } + return response.data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +function useClientDeleteFolder(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + folderId: string; + folderName: string; + }) => { + const response = await api.delete(`/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}`); + if (response.status !== 200) { + throw new Error("Failed to delete folder"); + } + return response.data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +type ClientTaskCreateInput = { + tablo_id: string; + title: string; + status?: TaskStatus | string; + parent_task_id?: string | null; + is_parent?: boolean; + position?: number; + description?: string | null; + assignee_id?: string | null; + due_date?: string | null; +}; + +const invalidateClientTaskQueries = (queryClient: ReturnType, tabloId: string) => { + queryClient.invalidateQueries({ queryKey: ["client-tasks", tabloId] }); +}; + +function useClientCreateTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (task: ClientTaskCreateInput) => { + const { data, error } = await supabase + .from("tasks") + .insert({ + tablo_id: task.tablo_id, + title: task.title, + status: (task.status as TaskStatus | undefined) ?? "todo", + assignee_id: task.assignee_id ?? null, + position: task.position ?? 0, + parent_task_id: task.parent_task_id ?? null, + is_parent: task.is_parent ?? false, + description: task.description ?? null, + due_date: task.due_date ?? null, + }) + .select() + .single(); + + if (error) throw error; + return data; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +function useClientUpdateTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, tablo_id: _tabloId, ...updates }: KanbanTaskUpdate & { id: string; tablo_id?: string }) => { + const { data, error } = await supabase + .from("tasks") + .update(updates) + .eq("id", id) + .select() + .single(); + + if (error) throw error; + return data; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +function useClientDeleteTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (taskId: string) => { + const { error } = await supabase.from("tasks").delete().eq("id", taskId); + if (error) throw error; + return taskId; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +function useClientUpdateTaskPositions(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + updates: Array<{ + id: string; + position: number; + status?: TaskStatus; + parent_task_id?: string | null; + }> + ) => { + const results = await Promise.all( + updates.map(({ id, position, status, parent_task_id }) => + supabase + .from("tasks") + .update({ + position, + ...(status && { status }), + ...(parent_task_id !== undefined ? { parent_task_id } : {}), + }) + .eq("id", id) + ) + ); + + const errors = results.filter((result) => result.error); + if (errors.length > 0) { + throw new Error("Failed to update some task positions"); + } + + return updates; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + function getStatusConfig(status: string) { switch (status) { case "in_progress": @@ -202,6 +450,15 @@ export function ClientTabloPage() { const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken); const { data: filesData, isLoading: filesLoading, error: filesError } = useClientTabloFiles(tabloId ?? "", accessToken); const { data: folders = [], isLoading: foldersLoading, error: foldersError } = useClientTabloFolders(tabloId ?? "", accessToken); + const { mutate: createTask } = useClientCreateTask(tabloId ?? ""); + const { mutate: updateTask } = useClientUpdateTask(tabloId ?? ""); + const { mutate: deleteTask } = useClientDeleteTask(tabloId ?? ""); + const { mutate: updateTaskPositions } = useClientUpdateTaskPositions(tabloId ?? ""); + const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? "", accessToken); + const { mutateAsync: downloadFile } = useClientDownloadFile(accessToken); + const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? "", accessToken); + const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? "", accessToken); + const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? "", accessToken); const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith(".")); @@ -263,12 +520,36 @@ export function ClientTabloPage() { ) : ( tasks.slice(0, 5).map((task) => ( -
-
-

+ )) )}

@@ -334,8 +615,9 @@ export function ClientTabloPage() { tabloTasks={tasks} tabloId={tablo.id} isAdmin={false} - onCreateTask={() => {}} + onCreateTask={(task) => createTask(task)} onCreateEtape={async () => {}} + onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })} /> )} @@ -347,6 +629,10 @@ export function ClientTabloPage() { members={members} etapes={etapes} currentUser={currentUser} + onCreateTask={(task) => createTask(task)} + onUpdateTask={(task) => updateTask(task)} + onDeleteTask={(taskId) => deleteTask(taskId)} + onUpdateTaskPositions={(updates) => updateTaskPositions(updates)} /> )} @@ -354,7 +640,10 @@ export function ClientTabloPage() { createFile(params)} + onDownloadFile={(params) => downloadFile(params)} + onCreateFolder={(params) => createFolder(params)} + onUpdateFolder={(params) => updateFolder(params)} + onDeleteFolder={(params) => deleteFolder(params)} /> )} @@ -393,7 +687,7 @@ export function ClientTabloPage() { {}} - onTaskStatusChange={() => {}} + onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })} /> )} diff --git a/packages/tablo-views/src/EtapesSection.tsx b/packages/tablo-views/src/EtapesSection.tsx index e1b22e8..4f5f3ee 100644 --- a/packages/tablo-views/src/EtapesSection.tsx +++ b/packages/tablo-views/src/EtapesSection.tsx @@ -1,5 +1,5 @@ import { cn } from "@xtablo/shared"; -import type { Etape, KanbanTask } from "@xtablo/shared-types"; +import type { Etape, KanbanTask, TaskStatus } from "@xtablo/shared-types"; import { Button } from "@xtablo/ui/components/button"; import { Input } from "@xtablo/ui/components/input"; import { @@ -30,6 +30,7 @@ interface EtapesSectionProps { position: number; }) => void; onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise; + onTaskStatusChange?: (taskId: string, status: TaskStatus) => void; onUpdateEtape?: (params: { id: string; tabloId: string; title: string }) => Promise; onDeleteEtape?: (params: { id: string; tabloId: string }) => Promise; isCreatingEtape?: boolean; @@ -44,6 +45,7 @@ export function EtapesSection({ isAdmin, onCreateTask, onCreateEtape, + onTaskStatusChange, onUpdateEtape, onDeleteEtape, isCreatingEtape = false, @@ -381,7 +383,18 @@ export function EtapesSection({ {childTasks.map((task) => (
+ onTaskStatusChange?.( + task.id, + task.status === "done" ? "todo" : "done" + ) + } > {task.status === "done" ? ( diff --git a/packages/tablo-views/src/TabloFilesSection.tsx b/packages/tablo-views/src/TabloFilesSection.tsx index e6b93f2..a96df14 100644 --- a/packages/tablo-views/src/TabloFilesSection.tsx +++ b/packages/tablo-views/src/TabloFilesSection.tsx @@ -270,8 +270,9 @@ const FolderDialog = ({ const FolderSection = ({ folder, files, - isAdmin, - isReadOnly, + canManageFolders, + canUploadFiles, + canDeleteFiles, isOpen, onToggle, onEdit, @@ -285,8 +286,9 @@ const FolderSection = ({ }: { folder: TabloFolder; files: string[]; - isAdmin: boolean; - isReadOnly: boolean; + canManageFolders: boolean; + canUploadFiles: boolean; + canDeleteFiles: boolean; isOpen: boolean; onToggle: () => void; onEdit: () => void; @@ -341,7 +343,7 @@ const FolderSection = ({
- {isAdmin && !isReadOnly && ( + {canManageFolders && ( <> -
+ {canUploadFiles && ( +
+ + +
+ )} {/* Files List */} {files.length > 0 ? ( @@ -427,7 +431,7 @@ const FolderSection = ({ onDownloadFile(fileName)} onDelete={() => onDeleteFile(fileName)} isDownloading={downloadingFile === fileName} @@ -483,6 +487,9 @@ interface TabloFilesSectionProps { isCancellingInvite?: boolean; isCreatingFolder?: boolean; isUpdatingFolder?: boolean; + canUploadFiles?: boolean; + canManageFolders?: boolean; + canDeleteFiles?: boolean; onCreateFile?: (params: { tabloId: string; fileName: string; data: { content: string; contentType: string } }) => Promise; onDeleteFile?: (params: { tabloId: string; fileName: string }) => Promise; onDownloadFile?: (params: { tabloId: string; fileName: string }) => Promise; @@ -512,6 +519,9 @@ export const TabloFilesSection = ({ isCancellingInvite, isCreatingFolder = false, isUpdatingFolder = false, + canUploadFiles, + canManageFolders, + canDeleteFiles, onCreateFile, onDeleteFile, onDownloadFile, @@ -534,6 +544,9 @@ export const TabloFilesSection = ({ const fileInputRef = useRef(null); const folderIds = useMemo(() => new Set(folders.map((folder) => folder.id)), [folders]); + const allowUploadFiles = canUploadFiles ?? !isReadOnly; + const allowManageFolders = canManageFolders ?? (isAdmin && !isReadOnly); + const allowDeleteFiles = canDeleteFiles ?? true; // Organize files by folder const { filesInFolders, unorganizedFiles } = useMemo(() => { @@ -805,7 +818,7 @@ export const TabloFilesSection = ({ )} {/* Create Folder Button - Admin Only */} - {isAdmin && !isReadOnly && ( + {allowManageFolders && (
{/* File Upload Area */} - {!selectedFile ? ( + {allowUploadFiles && !selectedFile ? (
- ) : ( + ) : allowUploadFiles && selectedFile ? (
@@ -979,11 +993,13 @@ export const TabloFilesSection = ({
- )} + ) : null} -

- Taille maximale par fichier: 20MB -

+ {allowUploadFiles && ( +

+ Taille maximale par fichier: 20MB +

+ )} {/* Unorganized Files List */} {unorganizedFiles.length > 0 && ( @@ -992,7 +1008,7 @@ export const TabloFilesSection = ({ handleDownloadFile(fileName)} onDelete={() => handleDeleteFile(fileName)} isDownloading={downloadingFile === fileName}