Ship ship ship the new features (tasks, etapes, notifs)
This commit is contained in:
parent
2e16353f5e
commit
7ec848e37e
47 changed files with 1052 additions and 625 deletions
|
|
@ -149,7 +149,7 @@ export async function setupTestDatabase(): Promise<TestDatabaseData> {
|
|||
owner_id: users[tablo.owner_key].userId,
|
||||
name: tablo.name,
|
||||
color: tablo.color,
|
||||
status: tablo.status,
|
||||
// status is now computed from etapes, not set directly
|
||||
position: tablo.position,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -27,24 +27,24 @@ describe("CreateTabloModal", () => {
|
|||
|
||||
it("renders without crashing", () => {
|
||||
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
|
||||
expect(screen.getByText("Create a new project")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create a new tablo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays name input field", () => {
|
||||
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
|
||||
expect(screen.getByPlaceholderText("Enter project name")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Enter tablo name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows typing in name input", () => {
|
||||
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
|
||||
const input = screen.getByPlaceholderText("Enter project name") as HTMLInputElement;
|
||||
const input = screen.getByPlaceholderText("Enter tablo 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 project name");
|
||||
const input = screen.getByPlaceholderText("Enter tablo name");
|
||||
fireEvent.change(input, { target: { value: "New Tablo" } });
|
||||
|
||||
const createButton = screen.getByText("Create");
|
||||
|
|
@ -52,7 +52,6 @@ describe("CreateTabloModal", () => {
|
|||
|
||||
expect(mockOnCreate).toHaveBeenCalledWith({
|
||||
name: "New Tablo",
|
||||
status: "todo",
|
||||
image: null,
|
||||
color: "bg-blue-500",
|
||||
});
|
||||
|
|
@ -79,11 +78,6 @@ describe("CreateTabloModal", () => {
|
|||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders StatusPicker component", () => {
|
||||
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
|
||||
expect(screen.getByText("À faire")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ImageColorPicker component", () => {
|
||||
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
|
||||
expect(screen.getByText("Style")).toBeInTheDocument();
|
||||
|
|
@ -91,7 +85,7 @@ describe("CreateTabloModal", () => {
|
|||
|
||||
it("resets form after successful creation", () => {
|
||||
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
|
||||
const input = screen.getByPlaceholderText("Enter project name") as HTMLInputElement;
|
||||
const input = screen.getByPlaceholderText("Enter tablo name") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "New Tablo" } });
|
||||
|
||||
const createButton = screen.getByText("Create");
|
||||
|
|
@ -102,7 +96,7 @@ describe("CreateTabloModal", () => {
|
|||
|
||||
it("disables create button when in image mode", () => {
|
||||
render(<CreateTabloModal onClose={mockOnClose} onCreate={mockOnCreate} />);
|
||||
const input = screen.getByPlaceholderText("Enter project name");
|
||||
const input = screen.getByPlaceholderText("Enter tablo name");
|
||||
fireEvent.change(input, { target: { value: "New Tablo" } });
|
||||
|
||||
// Switch to image mode
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@ import { useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { StatusPicker } from "./StatusPicker";
|
||||
|
||||
type Tablo = Database["public"]["Tables"]["tablos"]["Row"];
|
||||
type StatusType = "todo" | "in_progress" | "done";
|
||||
|
||||
interface CreateTabloModalProps {
|
||||
onClose: () => void;
|
||||
onCreate: (tabloData: Pick<Tablo, "name" | "color" | "image" | "status">) => void;
|
||||
onCreate: (tabloData: Pick<Tablo, "name" | "color" | "image">) => void;
|
||||
}
|
||||
|
||||
export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) => {
|
||||
|
|
@ -21,7 +19,6 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
|
|||
"https://images.unsplash.com/photo-1553877522-43269d4ea984?w=400&h=250&fit=crop&crop=center"
|
||||
);
|
||||
const [selectedColor, setSelectedColor] = useState("bg-blue-500");
|
||||
const [selectedStatus, setSelectedStatus] = useState<StatusType>("todo");
|
||||
|
||||
const resetForm = () => {
|
||||
setNewTabloName("");
|
||||
|
|
@ -30,7 +27,6 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
|
|||
"https://images.unsplash.com/photo-1553877522-43269d4ea984?w=400&h=250&fit=crop&crop=center"
|
||||
);
|
||||
setSelectedColor("bg-blue-500");
|
||||
setSelectedStatus("todo");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -42,7 +38,7 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
|
|||
if (newTabloName.trim()) {
|
||||
const tabloData = {
|
||||
name: newTabloName.trim(),
|
||||
status: selectedStatus,
|
||||
// Note: status is now computed from etapes and will default to 'todo' until etapes are created
|
||||
...(creationMode === "image"
|
||||
? { image: selectedImage, color: null }
|
||||
: { image: null, color: selectedColor }),
|
||||
|
|
@ -76,8 +72,6 @@ export const CreateTabloModal = ({ onClose, onCreate }: CreateTabloModalProps) =
|
|||
/>
|
||||
</div>
|
||||
|
||||
<StatusPicker selectedStatus={selectedStatus} setSelectedStatus={setSelectedStatus} />
|
||||
|
||||
<ImageColorPicker
|
||||
creationMode={creationMode}
|
||||
setCreationMode={setCreationMode}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { TabloOverviewSection } from "./TabloOverviewSection";
|
|||
|
||||
const mockUseTablo = vi.fn();
|
||||
const mockUseTabloEtapes = vi.fn();
|
||||
const mockUseTasksByTablo = vi.fn();
|
||||
const createEtapeMock = { mutateAsync: vi.fn(), isPending: false };
|
||||
const updateEtapeMock = { mutateAsync: vi.fn(), isPending: false };
|
||||
const deleteEtapeMock = { mutateAsync: vi.fn(), isPending: false };
|
||||
|
|
@ -16,6 +17,7 @@ vi.mock("../hooks/tablos", () => ({
|
|||
|
||||
vi.mock("../hooks/tasks", () => ({
|
||||
useTabloEtapes: (tabloId: string) => mockUseTabloEtapes(tabloId),
|
||||
useTasksByTablo: (tabloId: string) => mockUseTasksByTablo(tabloId),
|
||||
useCreateEtape: () => createEtapeMock,
|
||||
useUpdateEtape: () => updateEtapeMock,
|
||||
useDeleteEtape: () => deleteEtapeMock,
|
||||
|
|
@ -61,6 +63,10 @@ beforeEach(() => {
|
|||
data: [etapeFactory()],
|
||||
isLoading: false,
|
||||
});
|
||||
mockUseTasksByTablo.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
createEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
updateEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
deleteEtapeMock.mutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -70,31 +76,33 @@ beforeEach(() => {
|
|||
|
||||
describe("TabloOverviewSection", () => {
|
||||
it("shows the Étape creation input for the tablo owner", () => {
|
||||
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin />);
|
||||
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin />, { language: "fr" });
|
||||
|
||||
expect(screen.getByPlaceholderText("Ajouter une nouvelle Étape")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /ajouter/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Nom de l'Étape")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ajouter l'Étape")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides management actions for non owners", () => {
|
||||
mockUseTablo.mockReturnValue({ data: { owner_id: "another-user" } });
|
||||
|
||||
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin={false} />);
|
||||
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin={false} />, {
|
||||
language: "fr",
|
||||
});
|
||||
|
||||
expect(screen.queryByPlaceholderText("Ajouter une nouvelle Étape")).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Nom de l'Étape")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Seul le propriétaire du tablo peut modifier les Étapes.", {
|
||||
exact: false,
|
||||
})
|
||||
screen.getByText(
|
||||
"Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape."
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls the create mutation when adding a new Étape", async () => {
|
||||
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin />);
|
||||
renderWithProviders(<TabloOverviewSection tablo={mockTablo} isAdmin />, { language: "fr" });
|
||||
|
||||
const input = screen.getByPlaceholderText("Ajouter une nouvelle Étape");
|
||||
const input = screen.getByPlaceholderText("Nom de l'Étape");
|
||||
fireEvent.change(input, { target: { value: "Kick-off" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /ajouter/i }));
|
||||
fireEvent.click(screen.getByText("Ajouter l'Étape"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEtapeMock.mutateAsync).toHaveBeenCalledWith({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { pluralize, toast } from "@xtablo/shared";
|
||||
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, Trash2, X } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Check, Edit2, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
|
|
@ -15,7 +15,6 @@ import {
|
|||
useTasksByTablo,
|
||||
useUpdateEtape,
|
||||
} from "../hooks/tasks";
|
||||
import { getEtapeColor } from "../utils/etapeColors";
|
||||
|
||||
interface TabloOverviewSectionProps {
|
||||
tablo: UserTablo;
|
||||
|
|
@ -65,8 +64,8 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
const title = newEtapeTitle.trim();
|
||||
if (!title) {
|
||||
toast.add({
|
||||
title: "Erreur",
|
||||
description: "Le nom de l'Étape est requis",
|
||||
title: t("common:errors.error"),
|
||||
description: t("tablo:etape.nameRequired"),
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
|
|
@ -105,11 +104,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
};
|
||||
|
||||
const handleDeleteEtape = async (etapeId: string, etapeTitle: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer l'Étape "${etapeTitle}" ? Les tâches associées resteront disponibles.`
|
||||
)
|
||||
) {
|
||||
if (!window.confirm(t("tablo:etape.deleteConfirm", { name: etapeTitle }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -144,16 +139,14 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
if (!sortedEtapes.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-center">
|
||||
<TypographyP className="text-muted-foreground">
|
||||
Aucune Étape n'a encore été définie pour ce tablo.
|
||||
</TypographyP>
|
||||
<TypographyP className="text-muted-foreground">{t("tablo:etape.noEtapes")}</TypographyP>
|
||||
{canManageEtapes ? (
|
||||
<TypographyP className="text-sm text-muted-foreground mt-2">
|
||||
Créez votre première Étape pour structurer les tâches du tablo.
|
||||
{t("tablo:etape.createFirstEtape")}
|
||||
</TypographyP>
|
||||
) : (
|
||||
<TypographyP className="text-sm text-muted-foreground mt-2">
|
||||
Seul le propriétaire du tablo peut ajouter des Étapes.
|
||||
{t("tablo:etape.onlyOwnerCanAdd")}
|
||||
</TypographyP>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -164,12 +157,8 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
<ul className="space-y-3">
|
||||
{sortedEtapes.map((etape, index) => {
|
||||
const isEditing = editingEtapeId === etape.id;
|
||||
const etapeColor = getEtapeColor(etape.position);
|
||||
return (
|
||||
<li
|
||||
key={etape.id}
|
||||
className={`flex items-start gap-3 rounded-lg border px-4 py-3 ${etapeColor.bg} ${etapeColor.border}`}
|
||||
>
|
||||
<li key={etape.id} className="flex items-start gap-3 rounded-lg border px-4 py-3">
|
||||
{canManageEtapes && (
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
<Button
|
||||
|
|
@ -197,7 +186,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
<Input
|
||||
value={editingTitle}
|
||||
onChange={(event) => setEditingTitle(event.target.value)}
|
||||
placeholder="Nom de l'Étape"
|
||||
placeholder={t("tablo:etape.namePlaceholder")}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -208,7 +197,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
disabled={updateEtape.isPending}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Annuler
|
||||
{t("common:actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -220,35 +209,36 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
) : (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Enregistrer
|
||||
{t("common:actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TypographyP className={`text-base font-medium ${etapeColor.text}`}>
|
||||
{etape.title}
|
||||
</TypographyP>
|
||||
<TypographyMuted className={`text-xs ${etapeColor.text} opacity-70`}>
|
||||
Étape {etape.position + 1}
|
||||
<TypographyP className="text-base font-medium">{etape.title}</TypographyP>
|
||||
<TypographyMuted className="text-xs opacity-70">
|
||||
{t("tablo:etape.stepNumber", { number: etape.position + 1 })}
|
||||
</TypographyMuted>
|
||||
{(() => {
|
||||
const { total, done, ongoing } = getEtapeTaskCounts(etape.id);
|
||||
return (
|
||||
<div className={`flex gap-3 mt-2 text-xs ${etapeColor.text}`}>
|
||||
<span className="opacity-70">
|
||||
<span className="font-medium opacity-100">{total}</span>{" "}
|
||||
{pluralize("tâche", total)}
|
||||
<div className="flex gap-3 mt-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{total}</span>{" "}
|
||||
{t(
|
||||
`tablo:tasks.task_${total === 0 || total > 1 ? "plural" : "singular"}`
|
||||
)}
|
||||
</span>
|
||||
{ongoing > 0 && (
|
||||
<span className="opacity-90">
|
||||
<span className="font-medium">{ongoing}</span> en cours
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
<span className="font-medium">{ongoing}</span>{" "}
|
||||
{t("tablo:tasks.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
{done > 0 && (
|
||||
<span className="opacity-90">
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
<span className="font-medium">{done}</span>{" "}
|
||||
{pluralize("terminée", done)}
|
||||
{t(`tablo:tasks.completed_${done > 1 ? "plural" : "singular"}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -291,16 +281,17 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<TypographyH3 className="text-3xl font-bold text-foreground">Vue d'ensemble</TypographyH3>
|
||||
<TypographyH3 className="text-3xl font-bold text-foreground">
|
||||
{t("tablo:overview.title")}
|
||||
</TypographyH3>
|
||||
<TypographyMuted className="text-muted-foreground mt-1">
|
||||
Configurez les Étapes du tablo pour clarifier les grandes phases de votre projet.
|
||||
{t("tablo:overview.description")}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
||||
{!canManageEtapes && (
|
||||
<div className="mt-4 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous
|
||||
avez besoin d'une nouvelle Étape.
|
||||
{t("tablo:etape.onlyOwnerCanModify")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -310,12 +301,13 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<TypographyP className="text-sm font-medium text-foreground">
|
||||
Progression globale
|
||||
{t("tablo:overview.overallProgress")}
|
||||
</TypographyP>
|
||||
<TypographyMuted className="text-xs">
|
||||
{overallProgress.done} sur {overallProgress.total}{" "}
|
||||
{pluralize("tâche", overallProgress.total)}{" "}
|
||||
{pluralize("terminée", overallProgress.done)}
|
||||
{t("tablo:overview.progressSummary", {
|
||||
done: overallProgress.done,
|
||||
total: overallProgress.total,
|
||||
})}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-primary">{overallProgress.percentage}%</div>
|
||||
|
|
@ -333,11 +325,12 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
|
|||
<Input
|
||||
value={newEtapeTitle}
|
||||
onChange={(event) => setNewEtapeTitle(event.target.value)}
|
||||
placeholder="Nom de l'Étape"
|
||||
placeholder={t("tablo:etape.namePlaceholder")}
|
||||
className="h-9 sm:w-64"
|
||||
/>
|
||||
<Button onClick={handleCreateEtape} disabled={createEtape.isPending}>
|
||||
{t("common:actions.add", "Nouvelle Étape")}
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("tablo:etape.addNew")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import { Input } from "@xtablo/ui/components/input";
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { StatusPicker } from "./StatusPicker";
|
||||
|
||||
type StatusType = "todo" | "in_progress" | "done";
|
||||
|
||||
interface TabloSettingsSectionProps {
|
||||
tablo: UserTablo;
|
||||
|
|
@ -41,7 +38,7 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
|
|||
id: editData.id,
|
||||
name: editData.name,
|
||||
color: creationMode === "color" ? selectedColor : null,
|
||||
status: editData.status,
|
||||
// Note: status is now computed from etapes and cannot be changed directly
|
||||
};
|
||||
onEdit(updatedTablo);
|
||||
}
|
||||
|
|
@ -156,14 +153,16 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Picker */}
|
||||
{/* Status (Read-only - computed from etapes) */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<StatusPicker
|
||||
selectedStatus={currentData.status as StatusType}
|
||||
setSelectedStatus={(status) =>
|
||||
setEditData((prev) => (prev ? { ...prev, status } : null))
|
||||
}
|
||||
/>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Statut</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-foreground">
|
||||
{currentData.status === "todo" && "À faire"}
|
||||
{currentData.status === "in_progress" && "En cours"}
|
||||
{currentData.status === "done" && "Terminé"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
useTasksByTablo,
|
||||
useUpdateTaskPositions,
|
||||
} from "../hooks/tasks";
|
||||
import { getEtapeColor } from "../utils/etapeColors";
|
||||
import { KanbanBoard } from "./kanban/KanbanBoard";
|
||||
import { TaskModal } from "./kanban/TaskModal";
|
||||
|
||||
|
|
@ -40,15 +39,6 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
|
|||
[etapes]
|
||||
);
|
||||
|
||||
const etapeColorMap = useMemo(
|
||||
() =>
|
||||
etapes.reduce<Record<string, ReturnType<typeof getEtapeColor>>>((map, etape) => {
|
||||
map[etape.id] = getEtapeColor(etape.position);
|
||||
return map;
|
||||
}, {}),
|
||||
[etapes]
|
||||
);
|
||||
|
||||
// Check for tasks without parent (orphaned tasks)
|
||||
const orphanedTasks = useMemo(() => {
|
||||
return tasks?.filter((task) => !task.parent_task_id) || [];
|
||||
|
|
@ -206,7 +196,6 @@ export const TabloTasksSection = ({ tablo }: TabloTasksSectionProps) => {
|
|||
members={members}
|
||||
etapes={etapes}
|
||||
etapeTitles={etapeTitleMap}
|
||||
etapeColors={etapeColorMap}
|
||||
onTaskClick={handleTaskClick}
|
||||
onAddTask={handleAddTask}
|
||||
onAddTaskInline={handleCreateTask}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const tutorialSteps: TutorialStep[] = [
|
|||
id: "welcome",
|
||||
title: "Bienvenue sur XTablo ! 🎉",
|
||||
description:
|
||||
"Découvrez comment XTablo peut révolutionner votre façon de gérer vos projets, équipes et collaborations.",
|
||||
"Découvrez comment XTablo peut révolutionner votre façon de gérer vos tablos, équipes et collaborations.",
|
||||
},
|
||||
{
|
||||
id: "what-is-tablo",
|
||||
|
|
@ -42,7 +42,7 @@ const tutorialSteps: TutorialStep[] = [
|
|||
id: "tablo-status",
|
||||
title: "Gérer le statut des Tablos",
|
||||
description:
|
||||
"Organisez vos projets avec les statuts :\n• 📋 À faire : Projets en attente\n• 🔄 En cours : Projets actifs\n• ✅ Terminé : Projets complétés",
|
||||
"Organisez vos tablos avec les statuts :\n• 📋 À faire : Tablos en attente\n• 🔄 En cours : Tablos actifs\n• ✅ Terminé : Tablos complétés",
|
||||
},
|
||||
{
|
||||
id: "collaboration",
|
||||
|
|
@ -60,7 +60,7 @@ const tutorialSteps: TutorialStep[] = [
|
|||
id: "create-first-tablo",
|
||||
title: "Créer votre premier Tablo",
|
||||
description:
|
||||
"Vous êtes maintenant prêt à utiliser XTablo ! Créez votre premier Tablo pour démarrer votre projet.",
|
||||
"Vous êtes maintenant prêt à utiliser XTablo ! Créez votre premier Tablo pour commencer.",
|
||||
target: "create-tablo-button",
|
||||
position: "bottom",
|
||||
action: () => {
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
const usePrefersReducedMotion = () => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
};
|
||||
|
||||
export const ThreeLoginBackground = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (usePrefersReducedMotion()) return;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
containerRef.current.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x030014, 0.035);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
45,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
100
|
||||
);
|
||||
camera.position.set(0, 0, 14);
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0x6d28d9, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const pointLight = new THREE.PointLight(0x8b5cf6, 40, 40);
|
||||
pointLight.position.set(5, 5, 6);
|
||||
scene.add(pointLight);
|
||||
|
||||
const rimLight = new THREE.PointLight(0x38bdf8, 25, 30);
|
||||
rimLight.position.set(-4, -3, -6);
|
||||
scene.add(rimLight);
|
||||
|
||||
const coreGeometry = new THREE.IcosahedronGeometry(3, 1);
|
||||
const coreMaterial = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color("#8b5cf6"),
|
||||
emissive: new THREE.Color("#6366f1"),
|
||||
metalness: 0.85,
|
||||
roughness: 0.2,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
wireframe: true,
|
||||
});
|
||||
const core = new THREE.Mesh(coreGeometry, coreMaterial);
|
||||
scene.add(core);
|
||||
|
||||
const haloGeometry = new THREE.RingGeometry(4.2, 5.6, 128);
|
||||
const haloMaterial = new THREE.MeshBasicMaterial({
|
||||
color: 0x38bdf8,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
});
|
||||
const halo = new THREE.Mesh(haloGeometry, haloMaterial);
|
||||
halo.rotation.x = Math.PI / 2.4;
|
||||
scene.add(halo);
|
||||
|
||||
const orbitCount = 1200;
|
||||
const orbitPositions = new Float32Array(orbitCount * 3);
|
||||
for (let i = 0; i < orbitCount; i += 1) {
|
||||
const radius = 7 + Math.random() * 8;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
orbitPositions[i * 3] = Math.cos(angle) * radius;
|
||||
orbitPositions[i * 3 + 1] = (Math.random() - 0.5) * 6;
|
||||
orbitPositions[i * 3 + 2] = Math.sin(angle) * radius;
|
||||
}
|
||||
const orbitGeometry = new THREE.BufferGeometry();
|
||||
orbitGeometry.setAttribute("position", new THREE.BufferAttribute(orbitPositions, 3));
|
||||
const orbitMaterial = new THREE.PointsMaterial({
|
||||
color: 0x818cf8,
|
||||
size: 0.07,
|
||||
transparent: true,
|
||||
opacity: 0.65,
|
||||
});
|
||||
const orbitField = new THREE.Points(orbitGeometry, orbitMaterial);
|
||||
scene.add(orbitField);
|
||||
|
||||
const floatingGeometry = new THREE.TetrahedronGeometry(0.35);
|
||||
const floatingMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
emissive: 0x7c3aed,
|
||||
emissiveIntensity: 0.4,
|
||||
metalness: 0.4,
|
||||
roughness: 0.3,
|
||||
});
|
||||
const floatingMeshes: THREE.Mesh[] = [];
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
const mesh = new THREE.Mesh(floatingGeometry, floatingMaterial.clone());
|
||||
mesh.position.set(
|
||||
(Math.random() - 0.5) * 20,
|
||||
(Math.random() - 0.5) * 10,
|
||||
(Math.random() - 0.5) * 20
|
||||
);
|
||||
mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
|
||||
mesh.scale.setScalar(0.6 + Math.random() * 0.9);
|
||||
floatingMeshes.push(mesh);
|
||||
scene.add(mesh);
|
||||
}
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let frameId: number;
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = clock.getElapsedTime();
|
||||
core.rotation.x = elapsed * 0.2;
|
||||
core.rotation.y = elapsed * 0.3;
|
||||
halo.rotation.z = elapsed * 0.1;
|
||||
|
||||
orbitField.rotation.y = elapsed * 0.05;
|
||||
orbitField.rotation.x = Math.sin(elapsed * 0.1) * 0.05;
|
||||
|
||||
floatingMeshes.forEach((mesh, index) => {
|
||||
mesh.position.y += Math.sin(elapsed + index) * 0.002;
|
||||
mesh.rotation.x += 0.005;
|
||||
mesh.rotation.y += 0.008;
|
||||
});
|
||||
|
||||
camera.position.x = Math.sin(elapsed * 0.2) * 1.5;
|
||||
camera.position.y = Math.cos(elapsed * 0.15) * 0.8;
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
frameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
const handleResize = () => {
|
||||
const { innerWidth, innerHeight } = window;
|
||||
camera.aspect = innerWidth / innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
renderer.dispose();
|
||||
coreGeometry.dispose();
|
||||
haloGeometry.dispose();
|
||||
orbitGeometry.dispose();
|
||||
floatingGeometry.dispose();
|
||||
containerRef.current?.removeChild(renderer.domElement);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 -z-10 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
TaskStatus,
|
||||
} from "@xtablo/shared-types";
|
||||
import { useState } from "react";
|
||||
import type { getEtapeColor } from "../../utils/etapeColors";
|
||||
import { KanbanColumn } from "./KanbanColumn";
|
||||
import type { TabloMember } from "./types";
|
||||
|
||||
|
|
@ -14,7 +13,6 @@ interface KanbanBoardProps {
|
|||
members: TabloMember[];
|
||||
etapes: Etape[];
|
||||
etapeTitles: Record<string, string>;
|
||||
etapeColors: Record<string, ReturnType<typeof getEtapeColor>>;
|
||||
onTaskClick: (task: KanbanTask) => void;
|
||||
onAddTask: (status: TaskStatus) => void;
|
||||
onAddTaskInline: (task: {
|
||||
|
|
@ -31,7 +29,6 @@ export const KanbanBoard = ({
|
|||
columns,
|
||||
members,
|
||||
etapes,
|
||||
etapeColors,
|
||||
onTaskClick,
|
||||
onAddTask,
|
||||
onAddTaskInline,
|
||||
|
|
@ -65,7 +62,6 @@ export const KanbanBoard = ({
|
|||
column={column}
|
||||
members={members}
|
||||
etapes={etapes}
|
||||
etapeColors={etapeColors}
|
||||
onTaskClick={onTaskClick}
|
||||
onAddTask={onAddTask}
|
||||
onAddTaskInline={onAddTaskInline}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type {
|
|||
} from "@xtablo/shared-types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { getEtapeColor } from "../../utils/etapeColors";
|
||||
import { InlineTaskCreate } from "./InlineTaskCreate";
|
||||
import { KanbanTaskCard } from "./KanbanTaskCard";
|
||||
import type { TabloMember } from "./types";
|
||||
|
|
@ -15,7 +14,6 @@ interface KanbanColumnProps {
|
|||
column: KanbanColumnType;
|
||||
members: TabloMember[];
|
||||
etapes: Etape[];
|
||||
etapeColors: Record<string, ReturnType<typeof getEtapeColor>>;
|
||||
onTaskClick: (task: KanbanTask) => void;
|
||||
onAddTask: (status: KanbanColumnType["status"]) => void;
|
||||
onAddTaskInline: (task: {
|
||||
|
|
@ -34,7 +32,6 @@ export const KanbanColumn = ({
|
|||
column,
|
||||
members,
|
||||
etapes,
|
||||
etapeColors,
|
||||
onTaskClick,
|
||||
onAddTask,
|
||||
onAddTaskInline,
|
||||
|
|
@ -86,7 +83,6 @@ export const KanbanColumn = ({
|
|||
<KanbanTaskCard
|
||||
task={task}
|
||||
etapeTitle={etape?.title}
|
||||
etapeColor={task.parent_task_id ? etapeColors[task.parent_task_id] : undefined}
|
||||
onClick={() => onTaskClick(task)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import type { KanbanTask } from "@xtablo/shared-types";
|
||||
import { TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { User } from "lucide-react";
|
||||
import type { getEtapeColor } from "../../utils/etapeColors";
|
||||
|
||||
interface KanbanTaskCardProps {
|
||||
task: KanbanTask;
|
||||
etapeTitle?: string;
|
||||
etapeColor?: ReturnType<typeof getEtapeColor>;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const KanbanTaskCard = ({ task, etapeTitle, etapeColor, onClick }: KanbanTaskCardProps) => {
|
||||
export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProps) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
|
|
@ -26,13 +24,11 @@ export const KanbanTaskCard = ({ task, etapeTitle, etapeColor, onClick }: Kanban
|
|||
</TypographyMuted>
|
||||
)}
|
||||
|
||||
{/* Status Pill with Etape Color */}
|
||||
{/* Status Pill */}
|
||||
<div className="mb-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium border ${
|
||||
etapeColor
|
||||
? `${etapeColor.bg} ${etapeColor.text} ${etapeColor.border}`
|
||||
: "bg-muted text-muted-foreground border-muted"
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
etapeTitle ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{etapeTitle ?? "Sans Étape"}
|
||||
|
|
|
|||
|
|
@ -29,16 +29,18 @@ export const useTablosList = () => {
|
|||
|
||||
// Fetch single tablo
|
||||
export const useTablo = (id: string) => {
|
||||
const user = useUser();
|
||||
return useQuery({
|
||||
queryKey: ["tablos", id],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("tablos")
|
||||
.from("user_tablos")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.is("deleted_at", null);
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data[0];
|
||||
return data as UserTablo;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,9 +10,34 @@ import notesEn from "./locales/en/notes.json";
|
|||
import pagesEn from "./locales/en/pages.json";
|
||||
import planningEn from "./locales/en/planning.json";
|
||||
import settingsEn from "./locales/en/settings.json";
|
||||
import tabloEn from "./locales/en/tablo.json";
|
||||
import authFr from "./locales/fr/auth.json";
|
||||
import availabilitiesFr from "./locales/fr/availabilities.json";
|
||||
import commonFr from "./locales/fr/common.json";
|
||||
import componentsFr from "./locales/fr/components.json";
|
||||
import modalsFr from "./locales/fr/modals.json";
|
||||
import navigationFr from "./locales/fr/navigation.json";
|
||||
import notesFr from "./locales/fr/notes.json";
|
||||
import pagesFr from "./locales/fr/pages.json";
|
||||
import planningFr from "./locales/fr/planning.json";
|
||||
import settingsFr from "./locales/fr/settings.json";
|
||||
import tabloFr from "./locales/fr/tablo.json";
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
fr: {
|
||||
common: commonFr,
|
||||
navigation: navigationFr,
|
||||
pages: pagesFr,
|
||||
settings: settingsFr,
|
||||
availabilities: availabilitiesFr,
|
||||
auth: authFr,
|
||||
planning: planningFr,
|
||||
modals: modalsFr,
|
||||
components: componentsFr,
|
||||
notes: notesFr,
|
||||
tablo: tabloFr,
|
||||
},
|
||||
en: {
|
||||
common: commonEn,
|
||||
navigation: navigationEn,
|
||||
|
|
@ -24,6 +49,7 @@ i18n.use(initReactI18next).init({
|
|||
modals: modalsEn,
|
||||
components: componentsEn,
|
||||
notes: notesEn,
|
||||
tablo: tabloEn,
|
||||
},
|
||||
},
|
||||
lng: "en",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import notesEn from "./locales/en/notes.json";
|
|||
import pagesEn from "./locales/en/pages.json";
|
||||
import planningEn from "./locales/en/planning.json";
|
||||
import settingsEn from "./locales/en/settings.json";
|
||||
import tabloEn from "./locales/en/tablo.json";
|
||||
import authFr from "./locales/fr/auth.json";
|
||||
import availabilitiesFr from "./locales/fr/availabilities.json";
|
||||
// Import translation files
|
||||
|
|
@ -22,6 +23,7 @@ import notesFr from "./locales/fr/notes.json";
|
|||
import pagesFr from "./locales/fr/pages.json";
|
||||
import planningFr from "./locales/fr/planning.json";
|
||||
import settingsFr from "./locales/fr/settings.json";
|
||||
import tabloFr from "./locales/fr/tablo.json";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
|
|
@ -39,6 +41,7 @@ i18n
|
|||
modals: modalsFr,
|
||||
components: componentsFr,
|
||||
notes: notesFr,
|
||||
tablo: tabloFr,
|
||||
},
|
||||
en: {
|
||||
common: commonEn,
|
||||
|
|
@ -51,6 +54,7 @@ i18n
|
|||
modals: modalsEn,
|
||||
components: componentsEn,
|
||||
notes: notesEn,
|
||||
tablo: tabloEn,
|
||||
},
|
||||
},
|
||||
lng: "fr",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@
|
|||
"sort": "Sort",
|
||||
"loading": "Loading...",
|
||||
"saving": "Saving...",
|
||||
"deleting": "Deleting..."
|
||||
"deleting": "Deleting...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"add": "Add"
|
||||
},
|
||||
"messages": {
|
||||
"success": "Success",
|
||||
|
|
@ -36,6 +39,9 @@
|
|||
"info": "Information",
|
||||
"confirm_delete": "Are you sure you want to delete this item?"
|
||||
},
|
||||
"errors": {
|
||||
"error": "Error"
|
||||
},
|
||||
"labels": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"createTablo": {
|
||||
"title": "Create a new project",
|
||||
"nameLabel": "Project name",
|
||||
"namePlaceholder": "Enter project name"
|
||||
"title": "Create a new tablo",
|
||||
"nameLabel": "Tablo name",
|
||||
"namePlaceholder": "Enter tablo name"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"projects": "Projects",
|
||||
"projects": "Tablos",
|
||||
"myEvents": "My Events",
|
||||
"planning": "Planning",
|
||||
"discussions": "Discussions",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"tablo": {
|
||||
"title": "Projects",
|
||||
"subtitle": "Manage your projects and collaborations",
|
||||
"createButton": "New project",
|
||||
"title": "Tablos",
|
||||
"subtitle": "Manage your tablos and collaborations",
|
||||
"createButton": "New tablo",
|
||||
"emptyState": {
|
||||
"title": "No projects found",
|
||||
"description": "Create your first project to start organizing your work",
|
||||
"button": "Create your first project"
|
||||
"title": "No tablos found",
|
||||
"description": "Create your first tablo to start organizing your work",
|
||||
"button": "Create your first tablo"
|
||||
},
|
||||
"filter": {
|
||||
"all": "All",
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"contextMenu": {
|
||||
"openDiscussions": "Open discussions",
|
||||
"openPlanning": "Open planning",
|
||||
"delete": "Delete project"
|
||||
"delete": "Delete tablo"
|
||||
},
|
||||
"kpis": {
|
||||
"total": "Total",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"title": "Planning",
|
||||
"allEvents": "All events",
|
||||
"allTablos": "All projects",
|
||||
"selectTablo": "Select a project",
|
||||
"allTablos": "All tablos",
|
||||
"selectTablo": "Select a tablo",
|
||||
"createEvent": "Create event",
|
||||
"importPlanning": "Import planning",
|
||||
"today": "Today",
|
||||
|
|
|
|||
26
apps/main/src/locales/en/tablo.json
Normal file
26
apps/main/src/locales/en/tablo.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"overview": {
|
||||
"title": "Overview",
|
||||
"description": "Configure the Stages of the tablo to clarify the major phases of your tablo.",
|
||||
"overallProgress": "Overall Progress",
|
||||
"progressSummary": "{{done}} of {{total}} task(s) completed"
|
||||
},
|
||||
"etape": {
|
||||
"nameRequired": "The Stage name is required",
|
||||
"namePlaceholder": "Stage Name",
|
||||
"deleteConfirm": "Are you sure you want to delete the Stage \"{{name}}\"? Associated tasks will remain available.",
|
||||
"noEtapes": "No Stages have been defined for this tablo yet.",
|
||||
"createFirstEtape": "Create your first Stage to structure the tablo tasks.",
|
||||
"onlyOwnerCanAdd": "Only the tablo owner can add Stages.",
|
||||
"onlyOwnerCanModify": "Only the tablo owner can modify Stages. Contact the administrator if you need a new Stage.",
|
||||
"stepNumber": "Stage {{number}}",
|
||||
"addNew": "New Stage"
|
||||
},
|
||||
"tasks": {
|
||||
"task_singular": "task",
|
||||
"task_plural": "tasks",
|
||||
"inProgress": "in progress",
|
||||
"completed_singular": "completed",
|
||||
"completed_plural": "completed"
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,10 @@
|
|||
"sort": "Trier",
|
||||
"loading": "Chargement...",
|
||||
"saving": "Enregistrement...",
|
||||
"deleting": "Suppression..."
|
||||
"deleting": "Suppression...",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"add": "Ajouter"
|
||||
},
|
||||
"messages": {
|
||||
"success": "Succès",
|
||||
|
|
@ -36,6 +39,9 @@
|
|||
"info": "Information",
|
||||
"confirm_delete": "Êtes-vous sûr de vouloir supprimer cet élément ?"
|
||||
},
|
||||
"errors": {
|
||||
"error": "Erreur"
|
||||
},
|
||||
"labels": {
|
||||
"name": "Nom",
|
||||
"description": "Description",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"createTablo": {
|
||||
"title": "Créer un nouveau projet",
|
||||
"nameLabel": "Nom du projet",
|
||||
"namePlaceholder": "Entrez le nom du projet"
|
||||
"title": "Créer un nouveau tablo",
|
||||
"nameLabel": "Nom du tablo",
|
||||
"namePlaceholder": "Entrez le nom du tablo"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"projects": "Projets",
|
||||
"projects": "Tablos",
|
||||
"myEvents": "Mes Événements",
|
||||
"planning": "Planning",
|
||||
"discussions": "Discussions",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"tablo": {
|
||||
"title": "Projets",
|
||||
"subtitle": "Gérez vos projets et collaborations",
|
||||
"createButton": "Nouveau projet",
|
||||
"title": "Tablos",
|
||||
"subtitle": "Gérez vos tablos et collaborations",
|
||||
"createButton": "Nouveau tablo",
|
||||
"emptyState": {
|
||||
"title": "Aucun projet trouvé",
|
||||
"description": "Créez votre premier projet pour commencer à organiser votre travail",
|
||||
"button": "Créer votre premier projet"
|
||||
"title": "Aucun tablo trouvé",
|
||||
"description": "Créez votre premier tablo pour commencer à organiser votre travail",
|
||||
"button": "Créer votre premier tablo"
|
||||
},
|
||||
"filter": {
|
||||
"all": "Tous",
|
||||
"todo": "À faire",
|
||||
"todo": "Pas commencé",
|
||||
"inProgress": "En cours",
|
||||
"done": "Terminé"
|
||||
},
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"contextMenu": {
|
||||
"openDiscussions": "Ouvrir les discussions",
|
||||
"openPlanning": "Ouvrir le planning",
|
||||
"delete": "Supprimer le projet"
|
||||
"delete": "Supprimer le tablo"
|
||||
},
|
||||
"kpis": {
|
||||
"total": "Total",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"title": "Planning",
|
||||
"allEvents": "Tous les événements",
|
||||
"allTablos": "Tous les projets",
|
||||
"selectTablo": "Sélectionner un projet",
|
||||
"allTablos": "Tous les tablos",
|
||||
"selectTablo": "Sélectionner un tablo",
|
||||
"createEvent": "Créer un événement",
|
||||
"importPlanning": "Importer un planning",
|
||||
"today": "Aujourd'hui",
|
||||
|
|
|
|||
26
apps/main/src/locales/fr/tablo.json
Normal file
26
apps/main/src/locales/fr/tablo.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"overview": {
|
||||
"title": "Vue d'ensemble",
|
||||
"description": "Configurez les Étapes du tablo pour clarifier les grandes phases de votre tablo.",
|
||||
"overallProgress": "Progression globale",
|
||||
"progressSummary": "{{done}} sur {{total}} tâche(s) terminée(s)"
|
||||
},
|
||||
"etape": {
|
||||
"nameRequired": "Le nom de l'Étape est requis",
|
||||
"namePlaceholder": "Nom de l'Étape",
|
||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer l'Étape \"{{name}}\" ? Les tâches associées resteront disponibles.",
|
||||
"noEtapes": "Aucune Étape n'a encore été définie pour ce tablo.",
|
||||
"createFirstEtape": "Créez votre première Étape pour structurer les tâches du tablo.",
|
||||
"onlyOwnerCanAdd": "Seul le propriétaire du tablo peut ajouter des Étapes.",
|
||||
"onlyOwnerCanModify": "Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape.",
|
||||
"stepNumber": "Étape {{number}}",
|
||||
"addNew": "Ajouter l'Étape"
|
||||
},
|
||||
"tasks": {
|
||||
"task_singular": "tâche",
|
||||
"task_plural": "tâches",
|
||||
"inProgress": "en cours",
|
||||
"completed_singular": "terminée",
|
||||
"completed_plural": "terminées"
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { renderWithProviders } from "../utils/testHelpers";
|
||||
import { PublicBookingPage } from "./PublicBookingPage";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/eventTypes", () => ({
|
||||
usePublicEventType: () => ({
|
||||
eventType: {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { renderWithProviders } from "../utils/testHelpers";
|
||||
import { PublicNotePage } from "./PublicNotePage";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/notes", () => ({
|
||||
usePublicNote: () => ({
|
||||
note: {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,6 @@ import { FeedbackPage } from "./feedback";
|
|||
|
||||
const mockCreateFeedback = vi.fn();
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/feedback", () => ({
|
||||
useCreateFeedback: () => ({
|
||||
createFeedback: mockCreateFeedback,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const LandingPage = () => {
|
|||
</div>
|
||||
|
||||
<h1 className="text-6xl font-bold text-slate-900 dark:text-white mb-6 relative">
|
||||
Un client, un projet, un espace de travail
|
||||
Un client, un tablo, un espace de travail
|
||||
</h1>
|
||||
<p className="text-xl text-slate-700 dark:text-white mb-12 max-w-3xl mx-auto relative">
|
||||
Avec XTablo, créez un groupe et discutez avec vos clients et collaborateurs sans email,
|
||||
|
|
@ -92,7 +92,7 @@ export const LandingPage = () => {
|
|||
🤔 Le problème aujourd'hui
|
||||
</h2>
|
||||
<p className="text-xl text-slate-700 dark:text-white max-w-2xl mx-auto mb-8">
|
||||
Vous lancez un projet avec un client ?<br />
|
||||
Vous lancez un tablo avec un client ?<br />
|
||||
Vous jonglez entre messages WhatsApp, briefs Notion, liens Calendly, et retours par
|
||||
e-mail ?
|
||||
</p>
|
||||
|
|
@ -137,7 +137,7 @@ export const LandingPage = () => {
|
|||
<h3 className="text-xl font-semibold text-red-800 dark:text-red-300 mb-2">
|
||||
Impression de flou
|
||||
</h3>
|
||||
<p className="text-red-700 dark:text-red-200">Confusion dès le départ du projet</p>
|
||||
<p className="text-red-700 dark:text-red-200">Confusion dès le départ du tablo</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -165,7 +165,7 @@ export const LandingPage = () => {
|
|||
Créer un groupe dédié
|
||||
</h3>
|
||||
<p className="text-green-700 dark:text-green-200">
|
||||
Un espace unique pour chaque projet
|
||||
Un espace unique pour chaque tablo
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -200,7 +200,7 @@ export const LandingPage = () => {
|
|||
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium text-green-800 dark:text-green-300">
|
||||
Objectif : poser les bases d'un projet aligné, dès le jour 1.
|
||||
Objectif : poser les bases d'un tablo aligné, dès le jour 1.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -212,7 +212,7 @@ export const LandingPage = () => {
|
|||
🚧 Ce qu'on construit avec vous
|
||||
</h2>
|
||||
<p className="text-xl text-slate-700 dark:text-white max-w-2xl mx-auto mb-8">
|
||||
XTablo deviendra bientôt un hub projet tout-en-un :
|
||||
XTablo deviendra bientôt un hub tablo tout-en-un :
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -318,7 +318,7 @@ export const LandingPage = () => {
|
|||
🎯 Notre objectif
|
||||
</p>
|
||||
<p className="text-slate-700 dark:text-white">
|
||||
Construire ensemble l'outil parfait pour lancer vos projets sereinement
|
||||
Construire ensemble l'outil parfait pour lancer vos tablos sereinement
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -368,7 +368,7 @@ export const LandingPage = () => {
|
|||
<ul className="space-y-3">
|
||||
<li className="flex items-center text-slate-700 dark:text-white">
|
||||
<CheckIcon />
|
||||
Groupes de projet illimités
|
||||
Groupes de tablo illimités
|
||||
</li>
|
||||
<li className="flex items-center text-slate-700 dark:text-white">
|
||||
<CheckIcon />
|
||||
|
|
|
|||
|
|
@ -5,12 +5,6 @@ import { LoginPage } from "./login";
|
|||
|
||||
const mockLogin = vi.fn();
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/auth", () => ({
|
||||
useLoginEmail: () => ({
|
||||
mutate: mockLogin,
|
||||
|
|
@ -40,31 +34,29 @@ describe("LoginPage", () => {
|
|||
it("renders all form elements", () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
expect(screen.getByLabelText(/common:labels.email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/common:labels.password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /auth:login.loginButton/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /log in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays theme toggle button", () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const themeButton = screen.getByRole("button", { name: /auth:common.themeToggle/i });
|
||||
const themeButton = screen.getByRole("button", { name: /change theme/i });
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows link to signup page", () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const signupLink = screen.getByText(/auth:login.signupLink/i);
|
||||
const signupLink = screen.getByText(/sign up/i);
|
||||
expect(signupLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates email input on change", () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(
|
||||
/auth:login.emailPlaceholder/i
|
||||
) as HTMLInputElement;
|
||||
const emailInput = screen.getByPlaceholderText(/your email/i) as HTMLInputElement;
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
|
||||
expect(emailInput.value).toBe("test@example.com");
|
||||
|
|
@ -73,9 +65,7 @@ describe("LoginPage", () => {
|
|||
it("updates password input on change", () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(
|
||||
/auth:login.passwordPlaceholder/i
|
||||
) as HTMLInputElement;
|
||||
const passwordInput = screen.getByPlaceholderText(/your password/i) as HTMLInputElement;
|
||||
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||
|
||||
expect(passwordInput.value).toBe("password123");
|
||||
|
|
@ -84,9 +74,9 @@ describe("LoginPage", () => {
|
|||
it("submits form with email and password", async () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/auth:login.emailPlaceholder/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/auth:login.passwordPlaceholder/i);
|
||||
const submitButton = screen.getByRole("button", { name: /auth:login.loginButton/i });
|
||||
const emailInput = screen.getByPlaceholderText(/your email/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/your password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /log in/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||
|
|
@ -111,7 +101,7 @@ describe("LoginPage", () => {
|
|||
it("prevents form submission when fields are empty", () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /auth:login.loginButton/i });
|
||||
const submitButton = screen.getByRole("button", { name: /log in/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// Form should not submit due to HTML5 validation (required fields)
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
|
|||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { AnimatedBackground } from "src/components/AnimatedBackground";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useLoginEmail } from "../hooks/auth";
|
||||
import { ThreeLoginBackground } from "../components/ThreeLoginBackground";
|
||||
|
||||
export function LoginPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
|
@ -96,8 +96,7 @@ export function LoginPage() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden">
|
||||
<ThreeLoginBackground />
|
||||
{/* <AnimatedBackground /> */}
|
||||
<AnimatedBackground />
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={twMerge(
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export function PrivacyPolicyPage() {
|
|||
2.3. Données d'utilisation
|
||||
</h3>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>Informations sur vos projets et tableaux</li>
|
||||
<li>Informations sur vos tablos et tableaux</li>
|
||||
<li>Événements et rendez-vous créés</li>
|
||||
<li>Messages et communications via la plateforme</li>
|
||||
<li>Fichiers et documents partagés</li>
|
||||
|
|
@ -164,7 +164,7 @@ export function PrivacyPolicyPage() {
|
|||
<li>
|
||||
<strong className="text-foreground">Fourniture du service :</strong> Créer et
|
||||
gérer votre compte, vous permettre d'accéder à nos fonctionnalités de
|
||||
planification, de collaboration et de gestion de projets.
|
||||
planification, de collaboration et de gestion de tablos.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Communication :</strong> Vous envoyer des
|
||||
|
|
@ -226,8 +226,8 @@ export function PrivacyPolicyPage() {
|
|||
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong className="text-foreground">Membres de votre équipe :</strong> Les
|
||||
utilisateurs avec lesquels vous collaborez sur des projets partagés peuvent
|
||||
accéder aux informations que vous partagez volontairement.
|
||||
utilisateurs avec lesquels vous collaborez sur des tablos partagés peuvent accéder
|
||||
aux informations que vous partagez volontairement.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Prestataires de services :</strong> Nous
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { renderWithProviders } from "../utils/testHelpers";
|
||||
import { ResetPasswordPage } from "./reset-password";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ResetPasswordPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -22,28 +16,28 @@ describe("ResetPasswordPage", () => {
|
|||
it("renders form with email input", () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
expect(screen.getByText(/resetPassword.title/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/resetPassword.emailLabel/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /resetPassword.submit/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/forgot your password/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /send reset link/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays help text", () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
expect(screen.getByText(/resetPassword.description/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/enter your email address/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows link back to login", () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
const loginLink = screen.getByText(/resetPassword.backToLogin/i);
|
||||
const loginLink = screen.getByText(/back to login/i);
|
||||
expect(loginLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates email input on change", () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i) as HTMLInputElement;
|
||||
const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement;
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
|
||||
expect(emailInput.value).toBe("test@example.com");
|
||||
|
|
@ -52,22 +46,22 @@ describe("ResetPasswordPage", () => {
|
|||
it.skip("submits form and shows success message", async () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
|
||||
const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i });
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const submitButton = screen.getByRole("button", { name: /send reset link/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/resetPassword.emailSent/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/email sent/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("displays email in success message", async () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
|
||||
const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i });
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const submitButton = screen.getByRole("button", { name: /send reset link/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
|
@ -80,30 +74,28 @@ describe("ResetPasswordPage", () => {
|
|||
it.skip("shows return to login button in success state", async () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
|
||||
const submitButton = screen.getByRole("button", { name: /resetPassword.submit/i });
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const submitButton = screen.getByRole("button", { name: /send reset link/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /resetPassword.backToLogin/i })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /back to login/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("requires email field to be filled", () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
expect(emailInput).toHaveAttribute("required");
|
||||
});
|
||||
|
||||
it("requires valid email format", () => {
|
||||
renderWithProviders(<ResetPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByLabelText(/resetPassword.emailLabel/i);
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
expect(emailInput).toHaveAttribute("type", "email");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,12 +5,6 @@ import { SignUpPage } from "./signup";
|
|||
|
||||
const mockSignUp = vi.fn();
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/auth", () => ({
|
||||
useSignUp: () => ({
|
||||
mutate: mockSignUp,
|
||||
|
|
@ -39,38 +33,30 @@ describe("SignUpPage", () => {
|
|||
it("renders all form fields", () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
expect(screen.getByLabelText(/auth:signup.firstName/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/auth:signup.lastName/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/auth:signup.email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/common:labels.password/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/auth:signup.confirmPassword/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /auth:signup.signupButton/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/first name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/last name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/professional email/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText(/password/i)[0]).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /create my account/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows link to login page", () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
const loginLink = screen.getByText(/auth:signup.loginLink/i);
|
||||
const loginLink = screen.getByText(/log in/i);
|
||||
expect(loginLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates form fields on change", () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
const firstNameInput = screen.getByPlaceholderText(
|
||||
/auth:signup.firstNamePlaceholder/i
|
||||
) as HTMLInputElement;
|
||||
const lastNameInput = screen.getByPlaceholderText(
|
||||
/auth:signup.lastNamePlaceholder/i
|
||||
) as HTMLInputElement;
|
||||
const emailInput = screen.getByPlaceholderText(
|
||||
/auth:signup.emailPlaceholder/i
|
||||
) as HTMLInputElement;
|
||||
const passwordInput = screen.getByPlaceholderText(
|
||||
/auth:signup.passwordPlaceholder/i
|
||||
) as HTMLInputElement;
|
||||
const firstNameInput = screen.getByPlaceholderText(/your first name/i) as HTMLInputElement;
|
||||
const lastNameInput = screen.getByPlaceholderText(/your last name/i) as HTMLInputElement;
|
||||
const emailInput = screen.getByPlaceholderText(/your email/i) as HTMLInputElement;
|
||||
const passwordInput = screen.getAllByPlaceholderText(/your password/i)[0] as HTMLInputElement;
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/auth:signup.confirmPasswordPlaceholder/i
|
||||
/confirm your password/i
|
||||
) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(firstNameInput, { target: { value: "John" } });
|
||||
|
|
@ -89,20 +75,18 @@ describe("SignUpPage", () => {
|
|||
it("shows error when password is too short", async () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/auth:signup.confirmPasswordPlaceholder/i
|
||||
);
|
||||
const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
|
||||
const passwordInput = screen.getAllByPlaceholderText(/your password/i)[0];
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm your password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /create my account/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
|
||||
target: { value: "John" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
|
||||
target: { value: "Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(passwordInput, { target: { value: "short" } });
|
||||
|
|
@ -111,7 +95,7 @@ describe("SignUpPage", () => {
|
|||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/auth:signup.errors.passwordLength/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockSignUp).not.toHaveBeenCalled();
|
||||
|
|
@ -120,20 +104,18 @@ describe("SignUpPage", () => {
|
|||
it("shows error when passwords don't match", async () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/auth:signup.confirmPasswordPlaceholder/i
|
||||
);
|
||||
const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
|
||||
const passwordInput = screen.getAllByPlaceholderText(/your password/i)[0];
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(/confirm your password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /create my account/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
|
||||
target: { value: "John" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
|
||||
target: { value: "Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||
|
|
@ -142,7 +124,7 @@ describe("SignUpPage", () => {
|
|||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/auth:signup.errors.passwordMatch/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockSignUp).not.toHaveBeenCalled();
|
||||
|
|
@ -182,22 +164,22 @@ describe("SignUpPage", () => {
|
|||
it("submits form with valid data", async () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
|
||||
const submitButton = screen.getByRole("button", { name: /create my account/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
|
||||
target: { value: "John" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
|
||||
target: { value: "Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i), {
|
||||
fireEvent.change(screen.getAllByPlaceholderText(/your password/i)[0], {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/auth:signup.confirmPasswordPlaceholder/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/confirm your password/i), {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.click(termsCheckbox);
|
||||
|
|
@ -218,7 +200,7 @@ describe("SignUpPage", () => {
|
|||
it("requires terms checkbox to be checked", () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
|
||||
expect(termsCheckbox).toHaveAttribute("required");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { match } from "ts-pattern";
|
||||
import { LoadingSpinner } from "../components/LoadingSpinner";
|
||||
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
|
||||
import { TabloEventsSection } from "../components/TabloEventsSection";
|
||||
|
|
@ -42,7 +43,7 @@ export const TabloDetailsPage = () => {
|
|||
const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeSection = (searchParams.get("section") as TabSection) || "files";
|
||||
const activeSection = (searchParams.get("section") as TabSection) || "overview";
|
||||
|
||||
const [tablo, setTablo] = useState<UserTablo | null>(null);
|
||||
|
||||
|
|
@ -216,18 +217,18 @@ export const TabloDetailsPage = () => {
|
|||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto p-6 h-full">
|
||||
{activeSection === "overview" && <TabloOverviewSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "discussion" && (
|
||||
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeSection === "notes" && <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "members" && <TabloMembersSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "settings" && (
|
||||
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
|
||||
)}
|
||||
{match(activeSection)
|
||||
.with("overview", () => <TabloOverviewSection tablo={tablo} isAdmin={isAdmin} />)
|
||||
.with("files", () => <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />)
|
||||
.with("discussion", () => <TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />)
|
||||
.with("notes", () => <TabloNotesSection tablo={tablo} isAdmin={isAdmin} />)
|
||||
.with("events", () => <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />)
|
||||
.with("tasks", () => <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />)
|
||||
.with("members", () => <TabloMembersSection tablo={tablo} isAdmin={isAdmin} />)
|
||||
.with("settings", () => (
|
||||
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { renderWithProviders } from "../utils/testHelpers";
|
||||
import { TabloPage } from "./tablo";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/tablos", () => ({
|
||||
useTablo: () => ({
|
||||
tablo: {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import {
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useCreateTablo, useDeleteTablo, useTablosList, useUpdateTablo } from "../hooks/tablos";
|
||||
import { useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
|
||||
type FilterOption = {
|
||||
|
|
@ -77,7 +77,7 @@ export const TabloPage = () => {
|
|||
|
||||
const { data: tablos, isLoading, error } = useTablosList();
|
||||
const createTabloMutation = useCreateTablo();
|
||||
const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
// const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
const { mutateAsync: deleteTablo } = useDeleteTablo();
|
||||
|
||||
// Filter tablos based on status
|
||||
|
|
@ -146,7 +146,7 @@ export const TabloPage = () => {
|
|||
switch (status) {
|
||||
case "todo":
|
||||
return t("pages:tablo.status.todo");
|
||||
case "inProgress":
|
||||
case "in_progress":
|
||||
return t("pages:tablo.status.inProgress");
|
||||
case "done":
|
||||
return t("pages:tablo.status.done");
|
||||
|
|
@ -158,28 +158,18 @@ export const TabloPage = () => {
|
|||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "todo":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "inProgress":
|
||||
return "bg-primary/10 text-primary";
|
||||
return "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600";
|
||||
case "in_progress":
|
||||
return "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 border border-blue-300 dark:border-blue-700";
|
||||
case "done":
|
||||
return "bg-secondary text-secondary-foreground";
|
||||
return "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 border border-green-300 dark:border-green-700";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
return "bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600";
|
||||
}
|
||||
};
|
||||
|
||||
const changeTabloStatus = async (tabloId: string, newStatus: string) => {
|
||||
try {
|
||||
await updateTablo({
|
||||
id: tabloId,
|
||||
status: newStatus,
|
||||
});
|
||||
setContextMenuTablo(null);
|
||||
setContextMenuPosition(null);
|
||||
} catch (error) {
|
||||
console.error("Error updating tablo status:", error);
|
||||
}
|
||||
};
|
||||
// NOTE: Tablo status is now computed from etapes and cannot be changed directly
|
||||
// The status change UI has been removed from the context menu
|
||||
|
||||
const handleDeleteTablo = (tabloId: string) => {
|
||||
if (!tablos) return;
|
||||
|
|
@ -247,10 +237,8 @@ export const TabloPage = () => {
|
|||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Tablos</h1>
|
||||
<Text className="text-muted-foreground mt-1">
|
||||
Gérez vos projets et collaborations
|
||||
</Text>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
|
||||
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
|
||||
</div>
|
||||
<Button onClick={openCreateModal} disabled={isReadOnly}>
|
||||
<Plus /> Nouveau tablo
|
||||
|
|
@ -275,10 +263,8 @@ export const TabloPage = () => {
|
|||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Tablos</h1>
|
||||
<Text className="text-muted-foreground mt-1">
|
||||
Gérez vos projets et collaborations
|
||||
</Text>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
|
||||
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
|
||||
</div>
|
||||
<Button onClick={openCreateModal} disabled={isReadOnly}>
|
||||
<Plus /> Nouveau tablo
|
||||
|
|
@ -408,46 +394,6 @@ export const TabloPage = () => {
|
|||
<span>{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Status change options - Only for admins */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="border-t border-border my-1"></div>
|
||||
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
Changer le statut
|
||||
</div>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
changeTabloStatus(tablo.id, "todo");
|
||||
}}
|
||||
>
|
||||
<span>À faire</span>
|
||||
{tablo.status === "todo" && <span className="text-primary">•</span>}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
changeTabloStatus(tablo.id, "inProgress");
|
||||
}}
|
||||
>
|
||||
<span>En cours</span>
|
||||
{tablo.status === "inProgress" && <span className="text-primary">•</span>}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
changeTabloStatus(tablo.id, "done");
|
||||
}}
|
||||
>
|
||||
<span>Terminé</span>
|
||||
{tablo.status === "done" && <span className="text-primary">•</span>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -596,45 +542,6 @@ export const TabloPage = () => {
|
|||
<span>{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="border-t border-border my-1"></div>
|
||||
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
Changer le statut
|
||||
</div>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
changeTabloStatus(tablo.id, "todo");
|
||||
}}
|
||||
>
|
||||
<span>À faire</span>
|
||||
{tablo.status === "todo" && <span className="text-primary">•</span>}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
changeTabloStatus(tablo.id, "inProgress");
|
||||
}}
|
||||
>
|
||||
<span>En cours</span>
|
||||
{tablo.status === "inProgress" && <span className="text-primary">•</span>}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted flex items-center justify-between"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
changeTabloStatus(tablo.id, "done");
|
||||
}}
|
||||
>
|
||||
<span>Terminé</span>
|
||||
{tablo.status === "done" && <span className="text-primary">•</span>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,35 +6,33 @@ export const getEtapeColor = (position: number) => {
|
|||
const colors = [
|
||||
{
|
||||
// Light/Yellow
|
||||
bg: "bg-yellow-100 dark:bg-yellow-950/30",
|
||||
text: "text-yellow-700 dark:text-yellow-400",
|
||||
border: "border-yellow-200 dark:border-yellow-900/50",
|
||||
bg: "bg-yellow-50 dark:bg-yellow-950/20",
|
||||
text: "text-yellow-600 dark:text-yellow-300",
|
||||
border: "border-yellow-100 dark:border-yellow-900/30",
|
||||
indicator: "bg-yellow-400 dark:bg-yellow-500",
|
||||
},
|
||||
{
|
||||
// Green
|
||||
bg: "bg-green-100 dark:bg-green-950/30",
|
||||
text: "text-green-700 dark:text-green-400",
|
||||
border: "border-green-200 dark:border-green-900/50",
|
||||
bg: "bg-green-50 dark:bg-green-950/20",
|
||||
text: "text-green-600 dark:text-green-300",
|
||||
border: "border-green-100 dark:border-green-900/30",
|
||||
indicator: "bg-green-400 dark:bg-green-500",
|
||||
},
|
||||
{
|
||||
// Blue
|
||||
bg: "bg-blue-100 dark:bg-blue-950/30",
|
||||
text: "text-blue-700 dark:text-blue-400",
|
||||
border: "border-blue-200 dark:border-blue-900/50",
|
||||
bg: "bg-blue-50 dark:bg-blue-950/20",
|
||||
text: "text-blue-600 dark:text-blue-300",
|
||||
border: "border-blue-100 dark:border-blue-900/30",
|
||||
indicator: "bg-blue-400 dark:bg-blue-500",
|
||||
},
|
||||
{
|
||||
// Gray
|
||||
bg: "bg-gray-100 dark:bg-gray-800/30",
|
||||
text: "text-gray-700 dark:text-gray-400",
|
||||
border: "border-gray-200 dark:border-gray-700/50",
|
||||
bg: "bg-gray-50 dark:bg-gray-800/20",
|
||||
text: "text-gray-600 dark:text-gray-300",
|
||||
border: "border-gray-100 dark:border-gray-700/30",
|
||||
indicator: "bg-gray-400 dark:bg-gray-500",
|
||||
},
|
||||
];
|
||||
|
||||
return colors[position % colors.length];
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import userEvent from "@testing-library/user-event";
|
|||
import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext";
|
||||
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
|
||||
import React from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { BrowserRouter, MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import testI18n from "../i18n.test";
|
||||
import { TestUserStoreProvider } from "../providers/UserStoreProvider";
|
||||
|
||||
const defaultUser = {
|
||||
|
|
@ -33,12 +35,15 @@ export const renderWithRouter = (ui: React.ReactNode, { route = "/" } = {}) => {
|
|||
interface RenderWithProvidersOptions {
|
||||
route?: string;
|
||||
path?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const renderWithProviders = (
|
||||
ui: React.ReactNode,
|
||||
{ route, path }: RenderWithProvidersOptions = {}
|
||||
{ route, path, language = "en" }: RenderWithProvidersOptions = {}
|
||||
): RenderResult => {
|
||||
// Set the language for this test
|
||||
testI18n.changeLanguage(language);
|
||||
// Create a new QueryClient instance for each test to avoid state pollution
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -62,28 +67,30 @@ export const renderWithProviders = (
|
|||
);
|
||||
|
||||
return render(
|
||||
<RouterWrapper {...routerProps}>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<SessionTestProvider
|
||||
testUser={{
|
||||
id: defaultUser.id,
|
||||
app_metadata: {},
|
||||
aud: "test",
|
||||
created_at: "2021-01-01",
|
||||
user_metadata: {
|
||||
first_name: defaultUser.first_name,
|
||||
last_name: defaultUser.last_name,
|
||||
avatar_url: defaultUser.avatar_url,
|
||||
full_name: defaultUser.name,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TestUserStoreProvider user={defaultUser}>{content}</TestUserStoreProvider>
|
||||
</SessionTestProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</RouterWrapper>
|
||||
<I18nextProvider i18n={testI18n}>
|
||||
<RouterWrapper {...routerProps}>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<SessionTestProvider
|
||||
testUser={{
|
||||
id: defaultUser.id,
|
||||
app_metadata: {},
|
||||
aud: "test",
|
||||
created_at: "2021-01-01",
|
||||
user_metadata: {
|
||||
first_name: defaultUser.first_name,
|
||||
last_name: defaultUser.last_name,
|
||||
avatar_url: defaultUser.avatar_url,
|
||||
full_name: defaultUser.name,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TestUserStoreProvider user={defaultUser}>{content}</TestUserStoreProvider>
|
||||
</SessionTestProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</RouterWrapper>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,30 @@
|
|||
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
|
||||
|
||||
export type Database = {
|
||||
// Allows to automatically instantiate createClient with right options
|
||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||
__InternalSupabase: {
|
||||
PostgrestVersion: "13.0.4";
|
||||
graphql_public: {
|
||||
Tables: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
graphql: {
|
||||
Args: {
|
||||
extensions?: Json;
|
||||
operationName?: string;
|
||||
query?: string;
|
||||
variables?: Json;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
};
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
public: {
|
||||
Tables: {
|
||||
|
|
@ -570,7 +590,7 @@ export type Database = {
|
|||
name: string;
|
||||
owner_id: string;
|
||||
position: number;
|
||||
status: string;
|
||||
status: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
Insert: {
|
||||
|
|
@ -582,7 +602,7 @@ export type Database = {
|
|||
name: string;
|
||||
owner_id: string;
|
||||
position?: number;
|
||||
status?: string;
|
||||
status?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
Update: {
|
||||
|
|
@ -594,7 +614,7 @@ export type Database = {
|
|||
name?: string;
|
||||
owner_id?: string;
|
||||
position?: number;
|
||||
status?: string;
|
||||
status?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
|
|
@ -795,6 +815,10 @@ export type Database = {
|
|||
};
|
||||
};
|
||||
Functions: {
|
||||
compute_tablo_status: {
|
||||
Args: { tablo_id_param: string };
|
||||
Returns: string;
|
||||
};
|
||||
generate_random_string: { Args: { length?: number }; Returns: string };
|
||||
get_my_active_subscription: {
|
||||
Args: never;
|
||||
|
|
@ -1017,6 +1041,9 @@ export type CompositeTypes<
|
|||
: never;
|
||||
|
||||
export const Constants = {
|
||||
graphql_public: {
|
||||
Enums: {},
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
|
||||
|
|
|
|||
129
supabase/migrations/20251122000000_deprecate_tablo_status.sql
Normal file
129
supabase/migrations/20251122000000_deprecate_tablo_status.sql
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
-- Migration: Deprecate tablo status and compute it from etapes
|
||||
-- This migration creates a function to compute tablo status from etapes,
|
||||
-- updates the user_tablos view to use the computed status, and makes
|
||||
-- the status column nullable for backwards compatibility.
|
||||
|
||||
-- Create function to compute tablo status from etapes (parent tasks)
|
||||
CREATE OR REPLACE FUNCTION "public"."compute_tablo_status"("tablo_id_param" "text")
|
||||
RETURNS "text"
|
||||
LANGUAGE "plpgsql"
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
etape_count INTEGER;
|
||||
total_tasks INTEGER;
|
||||
done_tasks INTEGER;
|
||||
in_progress_tasks INTEGER;
|
||||
computed_status TEXT;
|
||||
BEGIN
|
||||
-- Count total etapes for this tablo
|
||||
SELECT COUNT(*)
|
||||
INTO etape_count
|
||||
FROM "public"."tasks"
|
||||
WHERE "tablo_id" = tablo_id_param
|
||||
AND "is_parent" = true;
|
||||
|
||||
-- If no etapes exist, return 'todo'
|
||||
IF etape_count = 0 THEN
|
||||
RETURN 'todo';
|
||||
END IF;
|
||||
|
||||
-- Count tasks across all etapes (excluding parent tasks)
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COUNT(CASE WHEN "status" = 'done' THEN 1 END),
|
||||
COUNT(CASE WHEN "status" IN ('in_progress', 'in_review') THEN 1 END)
|
||||
INTO total_tasks, done_tasks, in_progress_tasks
|
||||
FROM "public"."tasks"
|
||||
WHERE "tablo_id" = tablo_id_param
|
||||
AND "is_parent" = false;
|
||||
|
||||
-- If no child tasks exist, consider all etapes as done (empty etapes)
|
||||
IF total_tasks = 0 THEN
|
||||
RETURN 'done';
|
||||
END IF;
|
||||
|
||||
-- Determine status based on task counts
|
||||
-- Priority order: done > in_progress > todo
|
||||
IF done_tasks = total_tasks THEN
|
||||
-- All tasks are done
|
||||
computed_status := 'done';
|
||||
ELSIF in_progress_tasks > 0 THEN
|
||||
-- At least one task is actively in progress or in review
|
||||
computed_status := 'in_progress';
|
||||
ELSIF done_tasks > 0 THEN
|
||||
-- Some tasks are done but none are in progress (showing progress)
|
||||
computed_status := 'in_progress';
|
||||
ELSE
|
||||
-- All tasks are todo (no progress has been made)
|
||||
computed_status := 'todo';
|
||||
END IF;
|
||||
|
||||
RETURN computed_status;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION "public"."compute_tablo_status"("text") OWNER TO "postgres";
|
||||
|
||||
COMMENT ON FUNCTION "public"."compute_tablo_status"("text") IS 'Computes the status of a tablo based on its etapes (parent tasks). Returns todo, in_progress, or done.';
|
||||
|
||||
-- Update the user_tablos view to use computed status
|
||||
CREATE OR REPLACE VIEW "public"."user_tablos" WITH ("security_invoker"='true') AS
|
||||
SELECT DISTINCT
|
||||
"t"."id",
|
||||
"ta"."user_id",
|
||||
"t"."name",
|
||||
"t"."image",
|
||||
"t"."color",
|
||||
CAST("public"."compute_tablo_status"("t"."id") AS character varying(20)) AS "status",
|
||||
"t"."position",
|
||||
"t"."created_at",
|
||||
"t"."deleted_at",
|
||||
CASE
|
||||
WHEN ("ta"."is_admin" = true) THEN 'admin'::"text"
|
||||
ELSE 'member'::"text"
|
||||
END AS "access_level",
|
||||
"ta"."is_admin"
|
||||
FROM ("public"."tablos" "t"
|
||||
LEFT JOIN "public"."tablo_access" "ta" ON (("t"."id" = "ta"."tablo_id")))
|
||||
WHERE (("ta"."is_active" = true) AND ("t"."deleted_at" IS NULL))
|
||||
ORDER BY "t"."position", "t"."created_at" DESC;
|
||||
|
||||
ALTER TABLE "public"."user_tablos" OWNER TO "postgres";
|
||||
|
||||
COMMENT ON VIEW "public"."user_tablos" IS 'View that returns all tablos accessible to the current authenticated user, with status computed from etapes';
|
||||
|
||||
-- Make the status column nullable in tablos table (for backwards compatibility)
|
||||
-- Remove the NOT NULL constraint
|
||||
ALTER TABLE "public"."tablos"
|
||||
ALTER COLUMN "status" DROP NOT NULL;
|
||||
|
||||
-- Remove the CHECK constraint on status
|
||||
ALTER TABLE "public"."tablos"
|
||||
DROP CONSTRAINT IF EXISTS "tablos_status_check";
|
||||
|
||||
-- Add a comment to indicate the column is deprecated
|
||||
COMMENT ON COLUMN "public"."tablos"."status" IS 'DEPRECATED: Status is now computed from etapes. This column is kept for backwards compatibility but should not be used.';
|
||||
|
||||
-- Update events_and_tablos view to use computed status as well
|
||||
CREATE OR REPLACE VIEW "public"."events_and_tablos" WITH ("security_invoker"='true') AS
|
||||
SELECT DISTINCT
|
||||
"e"."id" AS "event_id",
|
||||
"e"."title",
|
||||
"e"."start_date",
|
||||
"e"."start_time",
|
||||
"e"."end_time",
|
||||
"e"."description",
|
||||
"t"."id" AS "tablo_id",
|
||||
"t"."name" AS "tablo_name",
|
||||
"t"."color" AS "tablo_color",
|
||||
CAST("public"."compute_tablo_status"("t"."id") AS character varying(20)) AS "tablo_status"
|
||||
FROM "public"."events" "e"
|
||||
LEFT JOIN "public"."tablos" "t" ON ("e"."tablo_id" = "t"."id")
|
||||
WHERE ("e"."deleted_at" IS NULL) AND ("t"."deleted_at" IS NULL)
|
||||
ORDER BY "e"."start_date", "e"."start_time";
|
||||
|
||||
ALTER TABLE "public"."events_and_tablos" OWNER TO "postgres";
|
||||
|
||||
COMMENT ON VIEW "public"."events_and_tablos" IS 'View combining events with their associated tablo information, with status computed from etapes';
|
||||
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
-- Fix test failures after deprecating tablo status
|
||||
-- This migration addresses:
|
||||
-- 1. Re-add check constraint on status to allow NULL or valid values
|
||||
-- 2. Fix RLS policies for etape creation/update to properly block non-owners
|
||||
|
||||
-- ============================================================================
|
||||
-- Fix 1: Add back status check constraint (but allow NULL)
|
||||
-- ============================================================================
|
||||
|
||||
-- Add check constraint that allows NULL or the valid status values
|
||||
ALTER TABLE "public"."tablos"
|
||||
ADD CONSTRAINT "tablos_status_check"
|
||||
CHECK (
|
||||
"status" IS NULL
|
||||
OR ("status")::"text" = ANY (ARRAY[
|
||||
('todo'::character varying)::"text",
|
||||
('in_progress'::character varying)::"text",
|
||||
('done'::character varying)::"text"
|
||||
])
|
||||
);
|
||||
|
||||
COMMENT ON CONSTRAINT "tablos_status_check" ON "public"."tablos"
|
||||
IS 'Allows NULL or valid status values (todo, in_progress, done). Status is deprecated and computed from etapes.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Fix 2: Ensure RLS policies properly prevent non-owners from managing etapes
|
||||
-- ============================================================================
|
||||
|
||||
-- The issue is that the RLS policies need to explicitly check that the user
|
||||
-- is the tablo owner when dealing with etapes. Let's verify the policies
|
||||
-- are correctly preventing non-owner access to parent tasks.
|
||||
|
||||
-- Drop and recreate the INSERT policy with more explicit checks
|
||||
DROP POLICY IF EXISTS "Users can create tasks in their tablos" ON "public"."tasks";
|
||||
|
||||
CREATE POLICY "Users can create tasks in their tablos"
|
||||
ON "public"."tasks"
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
-- User must have access to the tablo
|
||||
EXISTS (
|
||||
SELECT 1 FROM "public"."tablo_access" "ta"
|
||||
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
|
||||
AND "ta"."user_id" = auth.uid()
|
||||
AND "ta"."is_active" = true
|
||||
-- If creating an etape (is_parent = true), user must be admin
|
||||
AND (
|
||||
(NOT "tasks"."is_parent")
|
||||
OR "ta"."is_admin" = true
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Drop and recreate the UPDATE policy with more explicit checks
|
||||
DROP POLICY IF EXISTS "Users can update tasks in their tablos" ON "public"."tasks";
|
||||
|
||||
CREATE POLICY "Users can update tasks in their tablos"
|
||||
ON "public"."tasks"
|
||||
FOR UPDATE
|
||||
USING (
|
||||
-- User must have access to the tablo
|
||||
EXISTS (
|
||||
SELECT 1 FROM "public"."tablo_access" "ta"
|
||||
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
|
||||
AND "ta"."user_id" = auth.uid()
|
||||
AND "ta"."is_active" = true
|
||||
-- If updating an etape (is_parent = true), user must be admin
|
||||
AND (
|
||||
(NOT "tasks"."is_parent")
|
||||
OR "ta"."is_admin" = true
|
||||
)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
-- Same check for the new values
|
||||
EXISTS (
|
||||
SELECT 1 FROM "public"."tablo_access" "ta"
|
||||
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
|
||||
AND "ta"."user_id" = auth.uid()
|
||||
AND "ta"."is_active" = true
|
||||
-- If the result would be an etape, user must be admin
|
||||
AND (
|
||||
(NOT "tasks"."is_parent")
|
||||
OR "ta"."is_admin" = true
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Drop and recreate the DELETE policy for consistency
|
||||
DROP POLICY IF EXISTS "Users can delete tasks in their tablos" ON "public"."tasks";
|
||||
|
||||
CREATE POLICY "Users can delete tasks in their tablos"
|
||||
ON "public"."tasks"
|
||||
FOR DELETE
|
||||
USING (
|
||||
-- User must have access to the tablo
|
||||
EXISTS (
|
||||
SELECT 1 FROM "public"."tablo_access" "ta"
|
||||
WHERE "ta"."tablo_id" = "tasks"."tablo_id"
|
||||
AND "ta"."user_id" = auth.uid()
|
||||
AND "ta"."is_active" = true
|
||||
-- If deleting an etape (is_parent = true), user must be admin
|
||||
AND (
|
||||
(NOT "tasks"."is_parent")
|
||||
OR "ta"."is_admin" = true
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON POLICY "Users can create tasks in their tablos" ON "public"."tasks"
|
||||
IS 'Users can create regular tasks in tablos they have access to. Only tablo admins can create etapes (is_parent=true).';
|
||||
|
||||
COMMENT ON POLICY "Users can update tasks in their tablos" ON "public"."tasks"
|
||||
IS 'Users can update regular tasks in tablos they have access to. Only tablo admins can update etapes (is_parent=true).';
|
||||
|
||||
COMMENT ON POLICY "Users can delete tasks in their tablos" ON "public"."tasks"
|
||||
IS 'Users can delete regular tasks in tablos they have access to. Only tablo admins can delete etapes (is_parent=true).';
|
||||
|
||||
-- ============================================================================
|
||||
-- Fix 3: Add trigger to explicitly validate etape permissions and raise error
|
||||
-- ============================================================================
|
||||
|
||||
-- Create function that validates etape permissions before INSERT/UPDATE
|
||||
CREATE OR REPLACE FUNCTION "public"."validate_etape_permissions"()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE "plpgsql"
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
user_is_admin boolean;
|
||||
BEGIN
|
||||
-- Only validate if this is an etape (is_parent = true)
|
||||
IF NEW.is_parent = true THEN
|
||||
-- Check if the current user is an admin of this tablo
|
||||
SELECT "ta"."is_admin" INTO user_is_admin
|
||||
FROM "public"."tablo_access" "ta"
|
||||
WHERE "ta"."tablo_id" = NEW.tablo_id
|
||||
AND "ta"."user_id" = auth.uid()
|
||||
AND "ta"."is_active" = true
|
||||
LIMIT 1;
|
||||
|
||||
-- If user is not found or not an admin, raise an error with proper SQLSTATE
|
||||
IF user_is_admin IS NULL OR user_is_admin = false THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE = '42501', -- insufficient_privilege
|
||||
MESSAGE = 'Only tablo admins can create or modify etapes',
|
||||
HINT = 'Contact the tablo owner to request admin access';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION "public"."validate_etape_permissions"() OWNER TO "postgres";
|
||||
|
||||
COMMENT ON FUNCTION "public"."validate_etape_permissions"()
|
||||
IS 'Validates that only tablo admins can create or update etapes (tasks with is_parent=true)';
|
||||
|
||||
-- Create trigger that runs before INSERT/UPDATE on tasks
|
||||
DROP TRIGGER IF EXISTS "validate_etape_permissions_trigger" ON "public"."tasks";
|
||||
|
||||
CREATE TRIGGER "validate_etape_permissions_trigger"
|
||||
BEFORE INSERT OR UPDATE ON "public"."tasks"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "public"."validate_etape_permissions"();
|
||||
|
||||
|
|
@ -50,7 +50,8 @@ SELECT col_type_is('public', 'tablos', 'position', 'integer', 'tablos.position s
|
|||
|
||||
SELECT col_not_null('public', 'tablos', 'owner_id', 'tablos.owner_id should be NOT NULL');
|
||||
SELECT col_not_null('public', 'tablos', 'name', 'tablos.name should be NOT NULL');
|
||||
SELECT col_not_null('public', 'tablos', 'status', 'tablos.status should be NOT NULL');
|
||||
-- Note: status is now nullable and computed from etapes
|
||||
SELECT col_is_null('public', 'tablos', 'status', 'tablos.status is now nullable (deprecated)');
|
||||
|
||||
SELECT col_has_default('public', 'tablos', 'status', 'tablos.status should have default');
|
||||
SELECT col_has_default('public', 'tablos', 'position', 'tablos.position should have default');
|
||||
|
|
|
|||
354
supabase/tests/database/12_compute_tablo_status.test.sql
Normal file
354
supabase/tests/database/12_compute_tablo_status.test.sql
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
BEGIN;
|
||||
SELECT plan(15);
|
||||
|
||||
-- ============================================================================
|
||||
-- Setup Test Data
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
test_user_id uuid := gen_random_uuid();
|
||||
tablo_no_etapes text;
|
||||
tablo_all_done text;
|
||||
tablo_in_progress text;
|
||||
tablo_mixed text;
|
||||
tablo_empty_etapes text;
|
||||
etape1_id text;
|
||||
etape2_id text;
|
||||
etape3_id text;
|
||||
etape4_id text;
|
||||
etape5_id text;
|
||||
BEGIN
|
||||
-- Insert test user
|
||||
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
|
||||
VALUES
|
||||
(test_user_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'status_test_' || test_user_id::text || '@test.com', 'encrypted', now(), now(), now())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert test profile
|
||||
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
|
||||
VALUES
|
||||
(test_user_id, 'status_test_' || test_user_id::text || '@test.com', 'Status', 'Test', substring(test_user_id::text from 1 for 8))
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Set JWT context
|
||||
PERFORM set_config('request.jwt.claims', json_build_object('sub', test_user_id::text)::text, true);
|
||||
|
||||
-- Create test tablos
|
||||
-- Tablo 1: No etapes
|
||||
INSERT INTO public.tablos (owner_id, name, position)
|
||||
VALUES (test_user_id, 'Tablo No Etapes', 0)
|
||||
RETURNING id INTO tablo_no_etapes;
|
||||
|
||||
-- Tablo 2: All etapes done
|
||||
INSERT INTO public.tablos (owner_id, name, position)
|
||||
VALUES (test_user_id, 'Tablo All Done', 1)
|
||||
RETURNING id INTO tablo_all_done;
|
||||
|
||||
-- Tablo 3: Has in_progress tasks
|
||||
INSERT INTO public.tablos (owner_id, name, position)
|
||||
VALUES (test_user_id, 'Tablo In Progress', 2)
|
||||
RETURNING id INTO tablo_in_progress;
|
||||
|
||||
-- Tablo 4: Mixed statuses
|
||||
INSERT INTO public.tablos (owner_id, name, position)
|
||||
VALUES (test_user_id, 'Tablo Mixed', 3)
|
||||
RETURNING id INTO tablo_mixed;
|
||||
|
||||
-- Tablo 5: Etapes without child tasks
|
||||
INSERT INTO public.tablos (owner_id, name, position)
|
||||
VALUES (test_user_id, 'Tablo Empty Etapes', 4)
|
||||
RETURNING id INTO tablo_empty_etapes;
|
||||
|
||||
-- Setup Tablo 2: All etapes done
|
||||
-- Etape 1 with all tasks done
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES (tablo_all_done, 'Etape 1', 'done', 0, true)
|
||||
RETURNING id INTO etape1_id;
|
||||
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
|
||||
VALUES
|
||||
(tablo_all_done, 'Task 1.1', 'done', 0, false, etape1_id),
|
||||
(tablo_all_done, 'Task 1.2', 'done', 1, false, etape1_id);
|
||||
|
||||
-- Etape 2 with all tasks done
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES (tablo_all_done, 'Etape 2', 'done', 1, true)
|
||||
RETURNING id INTO etape2_id;
|
||||
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
|
||||
VALUES
|
||||
(tablo_all_done, 'Task 2.1', 'done', 0, false, etape2_id);
|
||||
|
||||
-- Setup Tablo 3: Has in_progress tasks
|
||||
-- Etape 1 with in_progress task
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES (tablo_in_progress, 'Etape 1', 'in_progress', 0, true)
|
||||
RETURNING id INTO etape3_id;
|
||||
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
|
||||
VALUES
|
||||
(tablo_in_progress, 'Task 1.1', 'in_progress', 0, false, etape3_id),
|
||||
(tablo_in_progress, 'Task 1.2', 'done', 1, false, etape3_id);
|
||||
|
||||
-- Setup Tablo 4: Mixed statuses
|
||||
-- Etape 1: all done
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES (tablo_mixed, 'Etape 1', 'done', 0, true)
|
||||
RETURNING id INTO etape4_id;
|
||||
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
|
||||
VALUES
|
||||
(tablo_mixed, 'Task 1.1', 'done', 0, false, etape4_id);
|
||||
|
||||
-- Etape 2: has todo tasks (not started)
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES (tablo_mixed, 'Etape 2', 'todo', 1, true)
|
||||
RETURNING id INTO etape5_id;
|
||||
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
|
||||
VALUES
|
||||
(tablo_mixed, 'Task 2.1', 'todo', 0, false, etape5_id);
|
||||
|
||||
-- Setup Tablo 5: Empty etapes (no child tasks)
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES
|
||||
(tablo_empty_etapes, 'Empty Etape 1', 'todo', 0, true),
|
||||
(tablo_empty_etapes, 'Empty Etape 2', 'todo', 1, true);
|
||||
|
||||
-- Store test IDs
|
||||
PERFORM set_config('test.user_id', test_user_id::text, true);
|
||||
PERFORM set_config('test.tablo_no_etapes', tablo_no_etapes, true);
|
||||
PERFORM set_config('test.tablo_all_done', tablo_all_done, true);
|
||||
PERFORM set_config('test.tablo_in_progress', tablo_in_progress, true);
|
||||
PERFORM set_config('test.tablo_mixed', tablo_mixed, true);
|
||||
PERFORM set_config('test.tablo_empty_etapes', tablo_empty_etapes, true);
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 1: Function exists and is accessible
|
||||
-- ============================================================================
|
||||
|
||||
SELECT has_function(
|
||||
'public',
|
||||
'compute_tablo_status',
|
||||
ARRAY['text'],
|
||||
'compute_tablo_status function should exist'
|
||||
);
|
||||
|
||||
SELECT function_returns(
|
||||
'public',
|
||||
'compute_tablo_status',
|
||||
ARRAY['text'],
|
||||
'text',
|
||||
'compute_tablo_status should return text'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 2: Tablo with no etapes should return 'todo'
|
||||
-- ============================================================================
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.tablo_no_etapes')),
|
||||
'todo',
|
||||
'Tablo with no etapes should have status "todo"'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 3: Tablo with all etapes done should return 'done'
|
||||
-- ============================================================================
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.tablo_all_done')),
|
||||
'done',
|
||||
'Tablo with all etapes completed should have status "done"'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 4: Tablo with in_progress tasks should return 'in_progress'
|
||||
-- ============================================================================
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.tablo_in_progress')),
|
||||
'in_progress',
|
||||
'Tablo with tasks in progress should have status "in_progress"'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 5: Tablo with mixed etapes (some done, some todo) should return 'in_progress'
|
||||
-- ============================================================================
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.tablo_mixed')),
|
||||
'in_progress',
|
||||
'Tablo with mixed etapes (some done, some todo) should have status "in_progress"'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 6: Tablo with empty etapes (no child tasks) should return 'done'
|
||||
-- ============================================================================
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.tablo_empty_etapes')),
|
||||
'done',
|
||||
'Tablo with etapes that have no child tasks should have status "done"'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 6.5: Tablo with partial progress (some tasks done, some todo) should return 'in_progress'
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
partial_tablo_id text;
|
||||
partial_etape_id text;
|
||||
BEGIN
|
||||
-- Create a tablo with partial progress
|
||||
INSERT INTO public.tablos (owner_id, name, position)
|
||||
VALUES (current_setting('test.user_id')::uuid, 'Partial Progress Tablo', 7)
|
||||
RETURNING id INTO partial_tablo_id;
|
||||
|
||||
-- Create an etape
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES (partial_tablo_id, 'Partial Etape', 'in_progress', 0, true)
|
||||
RETURNING id INTO partial_etape_id;
|
||||
|
||||
-- Create multiple tasks with mixed statuses
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
|
||||
VALUES
|
||||
(partial_tablo_id, 'Done Task 1', 'done', 0, false, partial_etape_id),
|
||||
(partial_tablo_id, 'Done Task 2', 'done', 1, false, partial_etape_id),
|
||||
(partial_tablo_id, 'Todo Task 1', 'todo', 2, false, partial_etape_id),
|
||||
(partial_tablo_id, 'Todo Task 2', 'todo', 3, false, partial_etape_id);
|
||||
|
||||
PERFORM set_config('test.partial_tablo', partial_tablo_id, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.partial_tablo')),
|
||||
'in_progress',
|
||||
'Tablo with some tasks done and some todo should have status "in_progress"'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 7: Verify status computation in user_tablos view
|
||||
-- ============================================================================
|
||||
|
||||
SELECT is(
|
||||
(SELECT status FROM public.user_tablos WHERE id = current_setting('test.tablo_no_etapes')),
|
||||
'todo',
|
||||
'user_tablos view should show computed status for tablo with no etapes'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT status FROM public.user_tablos WHERE id = current_setting('test.tablo_all_done')),
|
||||
'done',
|
||||
'user_tablos view should show computed status for tablo with all done'
|
||||
);
|
||||
|
||||
SELECT is(
|
||||
(SELECT status FROM public.user_tablos WHERE id = current_setting('test.tablo_in_progress')),
|
||||
'in_progress',
|
||||
'user_tablos view should show computed status for tablo in progress'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 8: Test status changes when tasks are updated
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
test_tablo_id text;
|
||||
test_etape_id text;
|
||||
test_task_id text;
|
||||
BEGIN
|
||||
-- Create a new tablo for dynamic testing
|
||||
INSERT INTO public.tablos (owner_id, name, position)
|
||||
VALUES (current_setting('test.user_id')::uuid, 'Dynamic Test Tablo', 5)
|
||||
RETURNING id INTO test_tablo_id;
|
||||
|
||||
-- Create an etape
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES (test_tablo_id, 'Dynamic Etape', 'todo', 0, true)
|
||||
RETURNING id INTO test_etape_id;
|
||||
|
||||
-- Create a todo task
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
|
||||
VALUES (test_tablo_id, 'Dynamic Task', 'todo', 0, false, test_etape_id)
|
||||
RETURNING id INTO test_task_id;
|
||||
|
||||
-- Store IDs for testing
|
||||
PERFORM set_config('test.dynamic_tablo', test_tablo_id, true);
|
||||
PERFORM set_config('test.dynamic_task', test_task_id, true);
|
||||
END $$;
|
||||
|
||||
-- Initially should be 'todo'
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.dynamic_tablo')),
|
||||
'todo',
|
||||
'New tablo with todo task should have status "todo"'
|
||||
);
|
||||
|
||||
-- Update task to in_progress
|
||||
DO $$
|
||||
BEGIN
|
||||
UPDATE public.tasks
|
||||
SET status = 'in_progress'
|
||||
WHERE id = current_setting('test.dynamic_task');
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.dynamic_tablo')),
|
||||
'in_progress',
|
||||
'Tablo should change to "in_progress" when task is updated'
|
||||
);
|
||||
|
||||
-- Update task to done
|
||||
DO $$
|
||||
BEGIN
|
||||
UPDATE public.tasks
|
||||
SET status = 'done'
|
||||
WHERE id = current_setting('test.dynamic_task');
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.dynamic_tablo')),
|
||||
'done',
|
||||
'Tablo should change to "done" when all tasks are completed'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Test 9: Test with tasks in 'in_review' status (should count as in_progress)
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
review_tablo_id text;
|
||||
review_etape_id text;
|
||||
BEGIN
|
||||
-- Create a tablo with in_review tasks
|
||||
INSERT INTO public.tablos (owner_id, name, position)
|
||||
VALUES (current_setting('test.user_id')::uuid, 'Review Test Tablo', 6)
|
||||
RETURNING id INTO review_tablo_id;
|
||||
|
||||
-- Create an etape
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent)
|
||||
VALUES (review_tablo_id, 'Review Etape', 'in_review', 0, true)
|
||||
RETURNING id INTO review_etape_id;
|
||||
|
||||
-- Create an in_review task
|
||||
INSERT INTO public.tasks (tablo_id, title, status, position, is_parent, parent_task_id)
|
||||
VALUES (review_tablo_id, 'Review Task', 'in_review', 0, false, review_etape_id);
|
||||
|
||||
PERFORM set_config('test.review_tablo', review_tablo_id, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
public.compute_tablo_status(current_setting('test.review_tablo')),
|
||||
'in_progress',
|
||||
'Tablo with in_review tasks should have status "in_progress"'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
|
||||
|
|
@ -576,7 +576,7 @@ export type Database = {
|
|||
name: string
|
||||
owner_id: string
|
||||
position: number
|
||||
status: string
|
||||
status: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
|
|
@ -588,7 +588,7 @@ export type Database = {
|
|||
name: string
|
||||
owner_id: string
|
||||
position?: number
|
||||
status?: string
|
||||
status?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
|
|
@ -600,7 +600,7 @@ export type Database = {
|
|||
name?: string
|
||||
owner_id?: string
|
||||
position?: number
|
||||
status?: string
|
||||
status?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
|
|
@ -801,6 +801,10 @@ export type Database = {
|
|||
}
|
||||
}
|
||||
Functions: {
|
||||
compute_tablo_status: {
|
||||
Args: { tablo_id_param: string }
|
||||
Returns: string
|
||||
}
|
||||
generate_random_string: { Args: { length?: number }; Returns: string }
|
||||
get_my_active_subscription: {
|
||||
Args: never
|
||||
|
|
|
|||
Loading…
Reference in a new issue