Fix react rendering issue

This commit is contained in:
Arthur Belleville 2026-04-22 21:35:42 +02:00
parent 515a875e88
commit fd5137e91e
No known key found for this signature in database
3 changed files with 433 additions and 229 deletions

View file

@ -88,3 +88,54 @@ See `.env.example` for required environment variables.
## Deployment
The API is deployed to Google Cloud Run. See `cloudbuild.yaml` for deployment configuration.
## Dokploy Deployment
Prefer a Dokploy `Application` instead of a Compose service.
Dokploy supports Dockerfile-based applications directly, which fits this API better than maintaining a dedicated compose wrapper. Configure the application like this:
- Build Type: `Dockerfile`
- Dockerfile Path: `apps/api/Dockerfile`
- Docker Context Path: `.`
- Docker Build Stage: `final`
- Port: `8080`
- Run Command: leave empty and use the image `CMD`
Set these Dokploy environment variables:
- `NODE_ENV`
- `DD_SERVICE=xtablo-api`
- `DD_ENV`
- `DD_VERSION`
- `DD_LOGS_INJECTION=true`
- `SUPABASE_URL`
- `SUPABASE_SERVICE_ROLE_KEY`
- `SUPABASE_CONNECTION_STRING`
- `SUPABASE_CA_CERT`
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `STRIPE_SOLO_PRICE_ID`
- `STRIPE_TEAM_PRICE_ID`
- `STRIPE_FOUNDER_PRICE_ID`
- `EMAIL_USER`
- `EMAIL_CLIENT_ID`
- `EMAIL_CLIENT_SECRET`
- `EMAIL_REFRESH_TOKEN`
- `R2_ACCOUNT_ID`
- `R2_ACCESS_KEY_ID`
- `R2_SECRET_ACCESS_KEY`
- `TASKS_SECRET`
For Datadog logs, the Dokploy host should already run a Datadog Agent with Docker log collection enabled. Then add these Docker Swarm labels in Dokploy `Advanced Settings -> Swarm Settings -> Labels`:
- `com.datadoghq.tags.service=xtablo-api`
- `com.datadoghq.tags.env=${DD_ENV}`
- `com.datadoghq.tags.version=${DD_VERSION}`
- `com.datadoghq.ad.logs=[{"source":"nodejs","service":"xtablo-api"}]`
This gives you:
- container logs in Datadog with `source: nodejs`
- service tagging as `xtablo-api`
- APM/log correlation via `dd-trace` plus `DD_LOGS_INJECTION=true`

View file

@ -0,0 +1,145 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render, screen, waitFor } from "@testing-library/react";
import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { I18nextProvider } from "react-i18next";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import testI18n from "../i18n.test";
import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider";
import { PlanningPage } from "./planning";
vi.mock("../hooks/events", () => ({
useDeleteEvent: () => ({ mutate: vi.fn(), isPending: false }),
useEventsByTablo: () => ({
data: [
{
event_id: "event-1",
tablo_id: "tablo-1",
tablo_name: "Tablo Alpha",
tablo_color: "bg-blue-500",
start_date: "2026-04-22",
start_time: "09:00:00",
end_time: "10:00:00",
title: "Kickoff",
description: "Planning event",
},
],
isLoading: false,
}),
}));
vi.mock("../hooks/tablos", () => ({
useGetAllTabloAccess: () => ({
data: [{ tablo_id: "tablo-1", is_admin: true }],
}),
useTablosList: () => ({
data: [{ id: "tablo-1", name: "Tablo Alpha" }],
isLoading: false,
}),
}));
vi.mock("../providers/UserStoreProvider", async (importOriginal) => {
const actual = await importOriginal<typeof import("../providers/UserStoreProvider")>();
return {
...actual,
useIsReadOnlyUser: () => false,
};
});
vi.mock("../components/EventModal", () => ({
EventModal: () => <div data-testid="event-modal" />,
}));
const testUser: User = {
id: "user-1",
short_user_id: "u1",
name: "John Doe",
first_name: "John",
last_name: "Doe",
email: "john@example.com",
avatar_url: null,
is_temporary: false,
is_client: false,
client_onboarded_at: null,
last_signed_in: null,
plan: "none",
created_at: new Date("2026-01-01").toISOString(),
};
const createTestRouter = (initialEntry: string) =>
createMemoryRouter(
[
{
path: "/planning",
element: <PlanningPage />,
},
{
path: "/tasks",
element: <div>Tasks page</div>,
},
],
{ initialEntries: [initialEntry] }
);
const renderPlanningRouter = (initialEntry: string) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
},
},
});
const router = createTestRouter(initialEntry);
return {
router,
...render(
<I18nextProvider i18n={testI18n}>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<SessionTestProvider
testUser={{
id: testUser.id,
app_metadata: {},
aud: "test",
created_at: "2021-01-01",
user_metadata: {
first_name: testUser.first_name,
last_name: testUser.last_name,
avatar_url: testUser.avatar_url,
full_name: testUser.name,
},
}}
>
<TestUserStoreProvider user={testUser}>
<RouterProvider router={router} />
</TestUserStoreProvider>
</SessionTestProvider>
</QueryClientProvider>
</ThemeProvider>
</I18nextProvider>
),
};
};
describe("PlanningPage", () => {
beforeEach(() => {
testI18n.changeLanguage("fr");
});
it("does not throw when leaving the planning page from the events tab", async () => {
const { router } = renderPlanningRouter("/planning?tab=events");
expect(screen.getByText("Événements")).toBeInTheDocument();
await act(async () => {
await expect(router.navigate("/tasks")).resolves.toBeUndefined();
});
await waitFor(() => {
expect(screen.getByText("Tasks page")).toBeInTheDocument();
});
});
});

View file

@ -1055,254 +1055,262 @@ export const PlanningPage = () => {
);
};
if (currentTab === "events") {
return (
<div className="min-h-screen bg-background px-4">
{renderEventsView()}
<EventModal
mode="create"
isOpen={isCreateEventOpen}
onClose={() => setIsCreateEventOpen(false)}
defaultTabloId={selectedTabloId !== "all" ? selectedTabloId : undefined}
defaultDate={currentDate}
/>
<Outlet />
</div>
);
}
return (
<div className="min-h-screen bg-background">
<div className="flex">
{/* Sidebar */}
<div className="w-64 bg-card border-r border-border min-h-screen">
<div className="p-4">
{/* Tablo Selector */}
<div className="mb-4">
<Select
value={selectedTabloId}
onValueChange={(value) => setSelectedTabloId(value)}
disabled={tablosLoading}
{currentTab === "events" ? (
<div className="px-4">{renderEventsView()}</div>
) : (
<div className="flex">
{/* Sidebar */}
<div className="w-64 bg-card border-r border-border min-h-screen">
<div className="p-4">
{/* Tablo Selector */}
<div className="mb-4">
<Select
value={selectedTabloId}
onValueChange={(value) => setSelectedTabloId(value)}
disabled={tablosLoading}
>
<SelectTrigger className="w-full" aria-label={t("planning:selectTablo")}>
<SelectValue
placeholder={
tablosLoading ? t("common:actions.loading") : t("planning:selectTablo")
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("planning:allTablos")}</SelectItem>
{tablos?.map((tablo) => (
<SelectItem key={tablo.id} value={tablo.id}>
{tablo.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={() => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
if (selectedTabloId === "all") {
navigate(`/planning/create?date=${currentDate.toISOString()}`);
} else {
navigate(
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`
);
}
}}
className="w-full bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
disabled={isReadOnly}
>
<SelectTrigger className="w-full" aria-label={t("planning:selectTablo")}>
<SelectValue
placeholder={
tablosLoading ? t("common:actions.loading") : t("planning:selectTablo")
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("planning:allTablos")}</SelectItem>
{tablos?.map((tablo) => (
<SelectItem key={tablo.id} value={tablo.id}>
{tablo.name}
</SelectItem>
))}
</SelectContent>
</Select>
<PlusIcon className="w-5 h-5 mr-2" />
{t("planning:createEvent")}
</Button>
<Button
onClick={() => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
setIsImportModalOpen(true);
}}
variant="secondary"
className="w-full mt-2"
disabled={isReadOnly}
>
<FolderInputIcon className="w-5 h-5 mr-2" />
{t("planning:importPlanning")}
</Button>
<Button
onClick={handleExportICS}
disabled={!tabloEvents || tabloEvents.length === 0}
variant="outline"
className="w-full mt-2"
title={t("planning:exportICS")}
>
<Download className="w-4 h-4 mr-2" />
{t("planning:export")}
</Button>
</div>
<Button
onClick={() => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
if (selectedTabloId === "all") {
navigate(`/planning/create?date=${currentDate.toISOString()}`);
} else {
navigate(
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`
);
}
}}
className="w-full bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
disabled={isReadOnly}
>
<PlusIcon className="w-5 h-5 mr-2" />
{t("planning:createEvent")}
</Button>
<Button
onClick={() => {
if (isReadOnly) {
toast.add(
{
title: t("common:error"),
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
type: "error",
},
{ timeout: 5000 }
);
return;
}
setIsImportModalOpen(true);
}}
variant="secondary"
className="w-full mt-2"
disabled={isReadOnly}
>
<FolderInputIcon className="w-5 h-5 mr-2" />
{t("planning:importPlanning")}
</Button>
<Button
onClick={handleExportICS}
disabled={!tabloEvents || tabloEvents.length === 0}
variant="outline"
className="w-full mt-2"
title={t("planning:exportICS")}
>
<Download className="w-4 h-4 mr-2" />
{t("planning:export")}
</Button>
</div>
{/* Mini Calendar */}
<div className="p-4 border-t border-border">
<div className="text-sm font-medium text-foreground mb-3">
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</div>
<div className="grid grid-cols-7 gap-1 text-xs">
{dayNamesShort.map((day) => (
<div key={day} className="text-center text-muted-foreground p-1">
{day.slice(0, 1)}
</div>
))}
{getDaysInMonth(currentDate).map((day, index) => (
<div
key={index}
className={`text-center p-1 cursor-pointer rounded ${
day ? "hover:bg-muted" : ""
} ${
day && formatDate(day) === formatDate(new Date())
? "bg-[#804EEC] text-white"
: day
? "text-foreground"
: ""
}`}
onClick={() => {
if (day) {
navigateToCreateEvent(day, selectedTabloId);
}
}}
>
{day ? day.getDate() : ""}
</div>
))}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="bg-card border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<TypographyH3>{t("planning:title")}</TypographyH3>
<Button
onClick={goToToday}
variant="outline"
size="sm"
className="border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10"
>
{t("planning:today")}
</Button>
<div className="flex items-center space-x-2">
<button onClick={() => navigateDate(-1)} className="p-2 hover:bg-muted rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<button onClick={() => navigateDate(1)} className="p-2 hover:bg-muted rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
<TypographyH4>{getViewTitle()}</TypographyH4>
{/* Mini Calendar */}
<div className="p-4 border-t border-border">
<div className="text-sm font-medium text-foreground mb-3">
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</div>
<div className="grid grid-cols-7 gap-1 text-xs">
{dayNamesShort.map((day) => (
<div key={day} className="text-center text-muted-foreground p-1">
{day.slice(0, 1)}
</div>
))}
{getDaysInMonth(currentDate).map((day, index) => (
<div
key={index}
className={`text-center p-1 cursor-pointer rounded ${
day ? "hover:bg-muted" : ""
} ${
day && formatDate(day) === formatDate(new Date())
? "bg-[#804EEC] text-white"
: day
? "text-foreground"
: ""
}`}
onClick={() => {
if (day) {
navigateToCreateEvent(day, selectedTabloId);
}
}}
>
{day ? day.getDate() : ""}
</div>
))}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Button
onClick={() => setIsWebcalModalOpen(true)}
variant="outline"
size="sm"
title={t("planning:syncCalendar")}
>
<RefreshCcw className="w-4 h-4 mr-1" />
{t("planning:sync")}
</Button>
<div className="flex bg-muted rounded-lg p-1">
{(["month", "week", "day"] as ViewType[]).map((view) => (
<button
key={view}
onClick={() => changeView(view)}
title={t(`planning:views.${view}Title`)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors capitalize ${
currentView === view
? "bg-white dark:bg-gray-800 text-[#804EEC] shadow-sm font-semibold"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`planning:views.${view}`)}
<span className="ml-1 text-xs opacity-60">
{t(`planning:views.${view}Short`)}
</span>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="bg-card border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<TypographyH3>{t("planning:title")}</TypographyH3>
<Button
onClick={goToToday}
variant="outline"
size="sm"
className="border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10"
>
{t("planning:today")}
</Button>
<div className="flex items-center space-x-2">
<button onClick={() => navigateDate(-1)} className="p-2 hover:bg-muted rounded">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
))}
<button onClick={() => navigateDate(1)} className="p-2 hover:bg-muted rounded">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
<TypographyH4>{getViewTitle()}</TypographyH4>
</div>
<div className="flex items-center space-x-2">
<Button
onClick={() => setIsWebcalModalOpen(true)}
variant="outline"
size="sm"
title={t("planning:syncCalendar")}
>
<RefreshCcw className="w-4 h-4 mr-1" />
{t("planning:sync")}
</Button>
<div className="flex bg-muted rounded-lg p-1">
{(["month", "week", "day"] as ViewType[]).map((view) => (
<button
key={view}
onClick={() => changeView(view)}
title={t(`planning:views.${view}Title`)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors capitalize ${
currentView === view
? "bg-white dark:bg-gray-800 text-[#804EEC] shadow-sm font-semibold"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`planning:views.${view}`)}
<span className="ml-1 text-xs opacity-60">
{t(`planning:views.${view}Short`)}
</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
{/* Calendar Views */}
<div className="flex-1 p-4">
{tabloEventsLoading ? (
<div className="flex items-center justify-center h-64">
<img
src="/icon.jpg"
alt="Loading..."
className="animate-spin rounded-full h-8 w-8 object-cover"
/>
<span className="ml-2 text-muted-foreground">{t("planning:loadingEvents")}</span>
</div>
) : (
<>
{currentView === "month" && renderMonthView()}
{currentView === "week" && renderWeekView()}
{currentView === "day" && renderDayView()}
</>
)}
{/* Calendar Views */}
<div className="flex-1 p-4">
{tabloEventsLoading ? (
<div className="flex items-center justify-center h-64">
<img
src="/icon.jpg"
alt="Loading..."
className="animate-spin rounded-full h-8 w-8 object-cover"
/>
<span className="ml-2 text-muted-foreground">{t("planning:loadingEvents")}</span>
</div>
) : (
<>
{currentView === "month" && renderMonthView()}
{currentView === "week" && renderWeekView()}
{currentView === "day" && renderDayView()}
</>
)}
</div>
</div>
</div>
</div>
)}
<EventModal
mode="create"
isOpen={isCreateEventOpen}
onClose={() => setIsCreateEventOpen(false)}
defaultTabloId={selectedTabloId !== "all" ? selectedTabloId : undefined}
defaultDate={currentDate}
/>
<Outlet />
{isImportModalOpen && <ImportICSModal onClose={() => setIsImportModalOpen(false)} />}
<WebcalModal open={isWebcalModalOpen} onOpenChange={setIsWebcalModalOpen} />
{isWebcalModalOpen && (
<WebcalModal open={isWebcalModalOpen} onOpenChange={setIsWebcalModalOpen} />
)}
</div>
);
};