Merge pull request #31 from artslidd/develop

Develop
This commit is contained in:
Arthur Belleville 2025-10-28 18:10:19 +01:00 committed by GitHub
commit 474ca7d823
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2244 additions and 82 deletions

View file

@ -13,7 +13,7 @@ R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"
SYNC_CALS_SECRET="hello"
TASKS_SECRET="hello"
EMAIL_USER="baptiste@xtablo.com"
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"

View file

@ -11,7 +11,7 @@ R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"
SYNC_CALS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0="
TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0="
EMAIL_USER="baptiste@xtablo.com"
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"

View file

@ -18,7 +18,7 @@ export interface AppConfig {
R2_SECRET_ACCESS_KEY: string;
CORS_ORIGIN: string;
LOG_LEVEL: "debug" | "info" | "warn" | "error";
SYNC_CALS_SECRET: string;
TASKS_SECRET: string;
}
function validateEnvVar(name: string, value: string | undefined): string {
@ -61,7 +61,7 @@ function createConfig(): AppConfig {
R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID),
R2_ACCESS_KEY_ID: validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID),
R2_SECRET_ACCESS_KEY: validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY),
SYNC_CALS_SECRET: process.env.SYNC_CALS_SECRET || "",
TASKS_SECRET: process.env.TASKS_SECRET || "",
LOG_LEVEL: "info",
};

View file

@ -353,6 +353,7 @@ export type Database = {
id: string
is_temporary: boolean
last_name: string | null
last_signed_in: string | null
name: string | null
short_user_id: string
}
@ -363,6 +364,7 @@ export type Database = {
id: string
is_temporary?: boolean
last_name?: string | null
last_signed_in?: string | null
name?: string | null
short_user_id: string
}
@ -373,6 +375,7 @@ export type Database = {
id?: string
is_temporary?: boolean
last_name?: string | null
last_signed_in?: string | null
name?: string | null
short_user_id?: string
}
@ -471,24 +474,30 @@ export type Database = {
}
tablo_invites: {
Row: {
created_at: string
id: number
invite_token: string
invited_by: string
invited_email: string
is_pending: boolean
tablo_id: string
}
Insert: {
created_at?: string
id?: number
invite_token: string
invited_by: string
invited_email: string
is_pending?: boolean
tablo_id: string
}
Update: {
created_at?: string
id?: number
invite_token?: string
invited_by?: string
invited_email?: string
is_pending?: boolean
tablo_id?: string
}
Relationships: [
@ -526,6 +535,7 @@ export type Database = {
owner_id: string
position: number
status: string
updated_at: string | null
}
Insert: {
color?: string | null
@ -537,6 +547,7 @@ export type Database = {
owner_id: string
position?: number
status?: string
updated_at?: string | null
}
Update: {
color?: string | null
@ -548,6 +559,7 @@ export type Database = {
owner_id?: string
position?: number
status?: string
updated_at?: string | null
}
Relationships: []
}

View file

@ -165,7 +165,7 @@ export const checkTabloMember = async (c: Context, next: Next) => {
export const checkTabloAdmin = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const tabloId = c.req.param("tabloId");
const tabloId = c.req.param("tabloId") || c.req.query("tablo_id");
const isAdmin = await isTabloAdmin(supabase, tabloId, user.id);
if (!isAdmin) {
return c.json({ error: "You are not an admin of this tablo" }, 403);

View file

@ -5,14 +5,14 @@ import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import { config } from "./config.js";
import type { Tables } from "./database.types.ts";
import { writeCalendarFileToR2 } from "./helpers.js";
import { checkTabloAdmin, writeCalendarFileToR2 } from "./helpers.js";
import {
authMiddleware,
r2Middleware,
regularUserCheckMiddleware,
streamChatMiddleware,
} from "./middleware.js";
import { generateToken } from "./token.js";
import { generatePassword, generateToken } from "./token.js";
import { transporter } from "./transporter.js";
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
@ -361,32 +361,28 @@ tabloRouter.delete("/delete", async (c) => {
return c.json({ message: "Tablo deleted successfully" });
});
tabloRouter.post("/invite", regularUserCheckMiddleware, async (c) => {
tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin, async (c) => {
const sender = c.get("user");
const supabase = c.get("supabase");
const { email: recipientmail, tablo_id } = await c.req.json();
const { tabloId } = c.req.param();
const { email: recipientmail } = await c.req.json();
const token = generateToken();
const { data, error: tabloError } = await supabase
.from("tablos")
.select("*")
.eq("id", tablo_id)
.single();
const tablo = data as Tables<"tablos">;
if (tabloError) {
return c.json({ error: tabloError.message }, 500);
if (sender.email === recipientmail) {
return c.json({ error: "You cannot invite yourself" }, 400);
}
if (!tablo) {
// Get tablo name
const { data: tablo, error: tabloError } = await supabase
.from("tablos")
.select("name")
.eq("id", tabloId)
.single();
if (tabloError || !tablo) {
return c.json({ error: "Tablo not found" }, 404);
}
if (tablo.owner_id !== sender.id) {
return c.json({ error: "You are not allowed to invite users to this tablo" }, 400);
}
const token = generateToken();
const { data: introConfigData, error: introError } = await supabase
.from("user_introductions")
@ -401,28 +397,133 @@ tabloRouter.post("/invite", regularUserCheckMiddleware, async (c) => {
const { error } = await supabase.from("tablo_invites").insert({
invited_email: recipientmail,
tablo_id: tablo_id,
tablo_id: tabloId,
invited_by: sender.id,
invite_token: token,
is_pending: true,
});
if (error) {
return c.json({ error: error.message }, 500);
}
// Get user from recipient email
const { data: recipientUser, error: recipientError } = await supabase
.from("profiles")
.select("id")
.eq("email", recipientmail)
.maybeSingle();
if (recipientError) {
return c.json({ error: recipientError.message }, 500);
}
if (!recipientUser) {
// Create a new invited user and add them to the tablo
// Create a new user account for the invited email
const password = generatePassword();
const { data: newUser, error: createUserError } = await supabase.auth.admin.createUser({
email: recipientmail,
password: password,
email_confirm: true,
user_metadata: {
name: recipientmail.split("@")[0],
first_name: recipientmail,
last_name: "",
role: "invited_user",
},
app_metadata: {
// Can't do that: https://github.com/supabase/auth/issues/1280
// role: "invited_user",
},
});
if (createUserError) {
return c.json({ error: createUserError.message }, 500);
}
// Add the new user to the tablo
const { error: accessError } = await supabase.from("tablo_access").insert({
tablo_id: tabloId,
user_id: newUser.user.id,
granted_by: sender.id,
is_active: true,
// ** IMPORTANT **
is_admin: false,
// -------------
});
if (accessError) {
return c.json({ error: accessError.message }, 500);
}
// Send welcome email to the new user
await transporter.sendMail({
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
to: recipientmail,
subject: "Vous avez été invité sur XTablo",
html: `
<p>Bonjour !</p>
<p>${sender.email} vous a invité à rejoindre XTablo.</p>
<p>Un nouveau compte a é créé pour vous avec les identifiants suivants :</p>
<div style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin: 16px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<p style="margin: 0; font-family: monospace;"><strong>Email :</strong> ${recipientmail}</p>
<p style="margin: 8px 0 0 0; font-family: monospace;"><strong>Mot de passe :</strong> ${password}</p>
</div>
<p>Veuillez cliquer sur le lien ci-dessous pour accepter l'invitation et configurer votre mot de passe.</p>
<p><a href="${config.XTABLO_URL}/login" style="display: inline-block; padding: 12px 24px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; font-weight: bold;">Accepter et se connecter</a></p>
<p style="color: #d9534f; margin-bottom: 20px;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Cordialement,<br>
L'équipe XTablo
</p>
`,
});
return c.json({
message: "User created and invite sent successfully",
});
}
// Check if the user already has access to the tablo
const { data: existingAccess, error: existingAccessError } = await supabase
.from("tablo_access")
.select("id")
.eq("tablo_id", tabloId)
.eq("user_id", recipientUser.id)
.single();
if (existingAccessError) {
return c.json({ error: existingAccessError.message }, 500);
}
if (existingAccess) {
return c.json({ message: "User already has access to this tablo" }, 400);
}
// Let the user know that they have been invited to the tablo
await transporter.sendMail({
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
to: recipientmail,
subject: "Vous avez été invité à un tablo",
html: `
${introEmail ? `<p>${introEmail}</p>` : ""}
<p>Cliquez sur <a href="${
config.XTABLO_URL
}/join-tablo?tablo_name=${encodeURIComponent(tablo.name)}&token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p>Cordialement.</p>
${introEmail ? `<p>${introEmail}</p>` : ""}
<p>Cliquez sur <a href="${
config.XTABLO_URL
}/join-tablo?tablo_name=${encodeURIComponent(tablo.name)}&token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Cordialement,<br>
L'équipe XTablo
</p>
`,
});
@ -443,6 +544,7 @@ tabloRouter.post("/join", async (c) => {
.select("id, tablo_id, invited_by")
.eq("invite_token", token)
.eq("invited_email", joiner.email)
.eq("is_pending", true)
.maybeSingle();
if (error) {
@ -468,10 +570,17 @@ tabloRouter.post("/join", async (c) => {
if (tabloAccessError) {
console.error("tabloAccessError", tabloAccessError);
// Check if it's a conflict error (user already has access)
if (tabloAccessError.code === "23505") {
return c.json({ error: "User already has access to this tablo" }, 409);
}
return c.json({ error: tabloAccessError.message }, 500);
}
await supabase.from("tablo_invites").delete().eq("id", invite_id);
// Mark invite as accepted instead of deleting (maintains audit trail)
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id);
try {
const channel = streamServerClient.channel("messaging", tablo_id);
@ -504,7 +613,7 @@ tabloRouter.get("/members/:tablo_id", async (c) => {
const { data, error } = await supabase
.from("tablo_access")
.select("is_admin, profiles(id, name)")
.select("is_admin, profiles(id, name, email)")
.eq("tablo_id", tablo_id)
.eq("is_active", true);
@ -513,6 +622,7 @@ tabloRouter.get("/members/:tablo_id", async (c) => {
profiles: {
id: string;
name: string;
email: string;
};
}[];
@ -524,6 +634,7 @@ tabloRouter.get("/members/:tablo_id", async (c) => {
members: rows.map((member) => ({
...member.profiles,
is_admin: member.is_admin,
email: member.profiles.email,
})),
});
});

View file

@ -1,8 +1,10 @@
import { S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient } from "@supabase/supabase-js";
import { Hono } from "hono";
import { Hono, type Context } from "hono";
import { config } from "./config.js";
import { writeCalendarFileToR2 } from "./helpers.js";
import { streamChatMiddleware } from "./middleware.js";
import type { StreamChat } from "stream-chat";
export const taskRouter = new Hono<{
Variables: { supabase: SupabaseClient };
@ -10,7 +12,7 @@ export const taskRouter = new Hono<{
taskRouter.post("/sync-calendars", async (c) => {
const supabase = c.get("supabase");
if (c.req.header("Authorization") !== `Basic ${config.SYNC_CALS_SECRET}`) {
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
@ -48,3 +50,45 @@ taskRouter.post("/sync-calendars", async (c) => {
return c.json({ message: "Synced calendars" });
});
taskRouter.post(
"/sync-tablo-names",
streamChatMiddleware,
async (
c: Context<{ Variables: { supabase: SupabaseClient; streamServerClient: StreamChat } }>
) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
if (c.req.header("Authorization") !== `Basic ${config.TASKS_SECRET}`) {
return c.json({ error: "Unauthorized" }, 401);
}
const fifteenMinutesInMilliseconds = 1000 * 60 * 15;
const { data, error } = await supabase
.from("tablos")
.select("id, name")
.gt("updated_at", new Date(Date.now() - fifteenMinutesInMilliseconds).toISOString());
if (error) {
return c.json({ error: error.message }, 500);
}
const tablosData = data as { id: string; name: string }[];
tablosData.forEach(async (tablo) => {
const channel = streamServerClient.channel("messaging", tablo.id);
try {
await channel.update({
// @ts-ignore
name: tablo.name,
});
} catch (error) {
console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`);
}
});
return c.json({ message: `Synced ${tablosData.length} tablo names` });
}
);

View file

@ -5,3 +5,9 @@ export const generateToken = (): string => {
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(36).padStart(2, "0")).join("");
};
export const generatePassword = (): string => {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(36).padStart(2, "0")).join("");
};

View file

@ -125,7 +125,7 @@ L'équipe XTablo`,
<p style="color: #d9534f; margin-bottom: 20px;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
<p>
<a href="${process.env.FRONTEND_URL || "https://app.tablo.fr"}"
<a href="${process.env.FRONTEND_URL || "https://app.tablo.com"}"
style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
Se connecter à XTablo
</a>

View file

@ -26,4 +26,29 @@ describe("AnimatedBackground", () => {
expect(wrapper).toHaveClass("absolute");
expect(wrapper).toHaveClass("inset-0");
});
it("has overflow hidden to clip content", () => {
const { container } = render(<AnimatedBackground />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass("overflow-hidden");
});
it("has full width and height", () => {
const { container } = render(<AnimatedBackground />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass("w-full", "h-full");
});
it("renders images with varying sizes and positions", () => {
render(<AnimatedBackground />);
const images = screen.getAllByAltText("Xtablo");
// Check that images have different styles/classes for animation variety
const hasVariation = images.some((img, index) => {
const otherImg = images[(index + 1) % images.length];
return img.className !== otherImg.className || img.style.cssText !== otherImg.style.cssText;
});
expect(hasVariation).toBe(true);
});
});

View file

@ -1,8 +1,12 @@
import { screen } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { EventModal } from "./EventModal";
const mockNavigate = vi.fn();
const mockCreateEvent = vi.fn();
const mockUpdateEvent = vi.fn();
// Mock hooks and dependencies
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
@ -10,19 +14,22 @@ vi.mock("react-router-dom", async () => {
...actual,
useParams: () => ({ event_id: undefined }),
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
useNavigate: () => mockNavigate,
};
});
vi.mock("../hooks/events", () => ({
useEvent: () => ({ data: null }),
useCreateEvents: () => vi.fn(),
useUpdateEvent: () => ({ mutate: vi.fn() }),
useCreateEvents: () => mockCreateEvent,
useUpdateEvent: () => ({ mutate: mockUpdateEvent }),
}));
vi.mock("../hooks/tablos", () => ({
useTablosList: () => ({
data: [{ id: "tablo-1", name: "Test Tablo" }],
data: [
{ id: "tablo-1", name: "Test Tablo 1" },
{ id: "tablo-2", name: "Test Tablo 2" },
],
isLoading: false,
}),
}));
@ -41,6 +48,10 @@ vi.mock("react-i18next", () => ({
}));
describe("EventModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders in create mode", () => {
renderWithProviders(<EventModal mode="create" />);
expect(screen.getByText("eventModal.title.create")).toBeInTheDocument();
@ -71,4 +82,74 @@ describe("EventModal", () => {
renderWithProviders(<EventModal mode="edit" />);
expect(screen.getByText("eventModal.buttons.edit")).toBeInTheDocument();
});
it("shows tablo selection dropdown with available tablos", () => {
renderWithProviders(<EventModal mode="create" />);
// Should have a combobox for tablo selection
const tabloSelect = screen.getByRole("combobox");
expect(tabloSelect).toBeInTheDocument();
});
it("allows entering event title", () => {
renderWithProviders(<EventModal mode="create" />);
const titleInput = screen.getByPlaceholderText(/eventModal.placeholders.title/i);
fireEvent.change(titleInput, { target: { value: "New Event" } });
expect((titleInput as HTMLInputElement).value).toBe("New Event");
});
it("allows entering event description", () => {
renderWithProviders(<EventModal mode="create" />);
const descriptionTextarea = screen.getByPlaceholderText(/eventModal.placeholders.description/i);
fireEvent.change(descriptionTextarea, { target: { value: "Event description" } });
expect((descriptionTextarea as HTMLTextAreaElement).value).toBe("Event description");
});
it("navigates back when cancel button is clicked", () => {
renderWithProviders(<EventModal mode="create" />);
const cancelButton = screen.getByText("eventModal.buttons.cancel");
fireEvent.click(cancelButton);
expect(mockNavigate).toHaveBeenCalledWith(-1);
});
it("displays date picker for event date", () => {
renderWithProviders(<EventModal mode="create" />);
// Date input should be present
const dateInput = screen.getByLabelText(/eventModal.labels.date/i);
expect(dateInput).toBeInTheDocument();
});
it("displays time inputs for start and end time", () => {
renderWithProviders(<EventModal mode="create" />);
expect(screen.getByLabelText(/eventModal.labels.startTime/i)).toBeInTheDocument();
expect(screen.getByLabelText(/eventModal.labels.endTime/i)).toBeInTheDocument();
});
it("shows all day event toggle", () => {
renderWithProviders(<EventModal mode="create" />);
expect(screen.getByText(/eventModal.labels.allDay/i)).toBeInTheDocument();
});
it("validates required fields before submission", async () => {
renderWithProviders(<EventModal mode="create" />);
const saveButton = screen.getByText("eventModal.buttons.save");
// Try to submit without filling required fields
fireEvent.click(saveButton);
// Should not call create function without required data
await waitFor(() => {
expect(mockCreateEvent).not.toHaveBeenCalled();
});
});
});

View file

@ -1,18 +1,24 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { LanguageSelector } from "./LanguageSelector";
const mockChangeLanguage = vi.fn();
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: {
language: "en",
changeLanguage: vi.fn(),
changeLanguage: mockChangeLanguage,
},
}),
}));
describe("LanguageSelector", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
render(<LanguageSelector />);
// The SelectTrigger should be present
@ -24,4 +30,31 @@ describe("LanguageSelector", () => {
const { container } = render(<LanguageSelector />);
expect(container.querySelector('[role="combobox"]')).toBeInTheDocument();
});
it("shows current language selection", () => {
render(<LanguageSelector />);
// The trigger should show the current language code
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("opens language options when clicked", () => {
render(<LanguageSelector />);
const trigger = screen.getByRole("combobox");
fireEvent.click(trigger);
// Language options should appear
expect(screen.getByRole("option", { name: /English/i })).toBeInTheDocument();
expect(screen.getByRole("option", { name: /Français/i })).toBeInTheDocument();
});
it("changes language when option is selected", () => {
render(<LanguageSelector />);
const trigger = screen.getByRole("combobox");
fireEvent.click(trigger);
const frenchOption = screen.getByRole("option", { name: /Français/i });
fireEvent.click(frenchOption);
expect(mockChangeLanguage).toHaveBeenCalledWith("fr");
});
});

View file

@ -21,10 +21,57 @@ describe("Layout", () => {
expect(menuButton).toBeInTheDocument();
});
it("toggles mobile menu when menu button is clicked", () => {
renderWithProviders(<Layout />);
const menuButton = screen.getByRole("button", { name: /menu/i });
const navigation = screen.getByRole("navigation", { name: "Main navigation" });
const navParent = navigation.parentElement;
// Initially should be hidden on mobile (has -translate-x-full)
expect(navParent).toHaveClass("-translate-x-full");
// Click to open
fireEvent.click(menuButton);
expect(navParent).toHaveClass("translate-x-0");
// Click to close
fireEvent.click(menuButton);
expect(navParent).toHaveClass("-translate-x-full");
});
it("renders the side navigation", () => {
renderWithProviders(<Layout />);
// Check if the side navigation is present
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
});
it("renders main content area with Outlet", () => {
const { container } = renderWithProviders(<Layout />);
// Check if main element exists
const main = container.querySelector("main");
expect(main).toBeInTheDocument();
expect(main).toHaveClass("flex-1", "overflow-auto");
});
it("menu button has responsive positioning", () => {
renderWithProviders(<Layout />);
const menuButton = screen.getByRole("button", { name: /menu/i });
// Should have mobile-only visibility and fixed positioning
expect(menuButton).toHaveClass("fixed", "z-50", "md:hidden");
});
it("side navigation has responsive behavior", () => {
renderWithProviders(<Layout />);
const navigation = screen.getByRole("navigation", { name: "Main navigation" });
const navParent = navigation.parentElement;
// Should have transition and mobile/desktop behavior
expect(navParent).toHaveClass("fixed", "md:relative", "transition-all");
});
});

View file

@ -85,6 +85,7 @@ describe("ProtectedRoute", () => {
first_name: "Test",
last_name: "User",
is_temporary: false,
last_signed_in: null,
}}
>
<SessionTestProvider>

View file

@ -1,14 +1,17 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloEventsSection } from "./TabloEventsSection";
const mockNavigate = vi.fn();
// Mock hooks
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useParams: () => ({ tablo_id: "test-tablo-id" }),
useNavigate: () => vi.fn(),
useNavigate: () => mockNavigate,
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
@ -23,7 +26,22 @@ vi.mock("react-i18next", () => ({
vi.mock("../hooks/events", () => ({
useEventsByTablo: () => ({
data: [],
data: [
{
id: "event-1",
title: "Team Meeting",
start: "2024-01-15T10:00:00Z",
end: "2024-01-15T11:00:00Z",
tablo_id: "test-tablo-id",
},
{
id: "event-2",
title: "Client Call",
start: "2024-01-16T14:00:00Z",
end: "2024-01-16T15:00:00Z",
tablo_id: "test-tablo-id",
},
],
isLoading: false,
error: null,
}),
@ -49,10 +67,50 @@ describe("TabloEventsSection", () => {
image: null,
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
);
expect(container).toBeInTheDocument();
});
it("displays section title", () => {
renderWithProviders(<TabloEventsSection tablo={mockTablo} isAdmin={true} />);
expect(screen.getByText(/tabloDetails.tabs.events/i)).toBeInTheDocument();
});
it("displays events from the tablo", () => {
renderWithProviders(<TabloEventsSection tablo={mockTablo} isAdmin={true} />);
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
expect(screen.getByText("Client Call")).toBeInTheDocument();
});
it("shows add event button for admin users", () => {
renderWithProviders(<TabloEventsSection tablo={mockTablo} isAdmin={true} />);
const addButton = screen.getByRole("button", { name: /add|create|new/i });
expect(addButton).toBeInTheDocument();
});
it("navigates to events page when add button is clicked", () => {
renderWithProviders(<TabloEventsSection tablo={mockTablo} isAdmin={true} />);
const addButton = screen.getByRole("button", { name: /add|create|new/i });
fireEvent.click(addButton);
expect(mockNavigate).toHaveBeenCalled();
});
it("shows view all events link", () => {
renderWithProviders(<TabloEventsSection tablo={mockTablo} isAdmin={true} />);
const viewAllLink = screen.getByText(/tabloDetails.events.viewAll/i);
expect(viewAllLink).toBeInTheDocument();
});
it("hides add button for non-admin users", () => {
renderWithProviders(<TabloEventsSection tablo={mockTablo} isAdmin={false} />);
const addButton = screen.queryByRole("button", { name: /add|create|new/i });
expect(addButton).not.toBeInTheDocument();
});
});

View file

@ -1,6 +1,8 @@
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import { useEffect, useRef, useState } from "react";
import { usePendingTabloInvitesByTablo } from "src/hooks/tablo_invites";
import { useInviteUser } from "../hooks/invite";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
@ -23,12 +25,17 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
const [selectedColor, setSelectedColor] = useState(tablo.color || "bg-blue-500");
const { data: members } = useTabloMembers(tablo.id);
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo.id);
const [inviteEmail, setInviteEmail] = useState("");
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
const nameInputRef = useRef<HTMLInputElement>(null);
const filteredMembers = members?.filter(
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
);
useEffect(() => {
setEditData(tablo);
setSelectedColor(tablo.color || "bg-blue-500");
@ -139,7 +146,7 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
<h3 className="text-lg font-semibold text-foreground mb-4">Nom du tablo</h3>
{isEditingName ? (
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
<input
<Input
ref={nameInputRef}
type="text"
value={editData?.name}
@ -151,13 +158,12 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
setIsEditingName(false);
}
}}
className="w-full px-3 py-2 text-lg font-medium text-foreground bg-transparent border-b-2 border-primary focus:outline-none focus:border-primary"
placeholder="Nom du tablo"
/>
</ClickOutside>
) : (
<div
className="text-lg font-medium text-foreground cursor-text hover:text-primary transition-colors"
className="text-lg font-medium text-foreground cursor-text hover:text-primary hover:border-primary transition-colors border-2 border-dashed border-muted-foreground/30 rounded px-3 py-2"
onClick={() => setIsEditingName(true)}
>
{editData?.name}
@ -212,6 +218,47 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
)}
</div>
</div>
{pendingInvites && pendingInvites.length > 0 && (
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Invitations en attente
<span className="ml-2 text-sm font-normal text-muted-foreground">
({pendingInvites.length})
</span>
</h3>
<div className="space-y-2">
{pendingInvites.map((invite) => (
<div
key={invite.id}
className="flex items-center space-x-3 p-3 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
>
<div className="w-10 h-10 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-sm font-medium">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<span className="text-sm font-medium text-foreground">
{invite.invited_email}
</span>
<span className="text-xs text-muted-foreground ml-2">(En attente)</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
@ -219,16 +266,16 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Membres
{members && (
{filteredMembers && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({members.length})
({filteredMembers.length})
</span>
)}
</h3>
<div className="space-y-2">
{members && members.length > 0 ? (
members.map((member, index) => (
{filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map((member, index) => (
<div key={index} className="flex items-center space-x-3 p-3 bg-muted rounded-lg">
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-sm font-medium">
{member.name.charAt(0).toUpperCase()}
@ -252,6 +299,8 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
)}
</div>
</div>
{/* Pending Invites */}
</div>
);
};

View file

@ -1,19 +1,19 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
// Invite user by email
export const useInviteUser = () => {
const queryClient = useQueryClient();
const api = useAuthedApi();
const { mutate, isPending } = useMutation({
mutationFn: async ({ email, tablo_id }: { email: string; tablo_id: string }) => {
const { data } = await api.post("/api/v1/tablos/invite", {
const { data } = await api.post(`/api/v1/tablos/invite/${tablo_id}`, {
email,
tablo_id,
});
return data;
},
onSuccess: () => {
onSuccess: (_, { tablo_id }) => {
toast.add(
{
title: "Invitation envoyée avec succès",
@ -24,6 +24,8 @@ export const useInviteUser = () => {
timeout: 2000,
}
);
queryClient.invalidateQueries({ queryKey: ["tablo-members", tablo_id] });
queryClient.invalidateQueries({ queryKey: ["tablo-invites", tablo_id] });
},
});
return { mutate, isPending };

View file

@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import { Database } from "@xtablo/shared/types/database.types";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
type TabloInvite = Database["public"]["Tables"]["tablo_invites"]["Row"];
// Fetch all pending invites created by the current user
// export const usePendingTabloInvites = () => {
// const user = useUser();
// return useQuery({
// queryKey: ["tablo-invites", "pending", user.id],
// queryFn: async () => {
// const { data, error } = await supabase
// .from("tablo_invites")
// .select("*")
// .eq("invited_by", user.id)
// .eq("is_pending", true)
// .order("created_at", { ascending: false });
// if (error) throw error;
// return data as TabloInvite[];
// },
// enabled: !!user.id,
// });
// };
// Fetch pending invites for a specific tablo
export const usePendingTabloInvitesByTablo = (tabloId: string) => {
const user = useUser();
return useQuery({
queryKey: ["tablo-invites", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("tablo_invites")
.select("*")
.eq("invited_by", user.id)
.eq("tablo_id", tabloId)
.eq("is_pending", true)
.order("created_at", { ascending: false });
if (error) throw error;
return data as TabloInvite[];
},
enabled: !!user.id && !!tabloId,
});
};

View file

@ -50,7 +50,7 @@ export const useTabloMembers = (tabloId: string) => {
queryKey: ["tablo-members", tabloId],
queryFn: async () => {
const { data } = await api.get<{
members: { id: string; name: string; is_admin: boolean }[];
members: { id: string; name: string; is_admin: boolean; email: string }[];
}>(`/api/v1/tablos/members/${tabloId}`);
return data.members;
},

View file

@ -1,15 +1,29 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { NotFoundPage } from "./NotFoundPage";
const mockNavigate = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
};
});
describe("NotFoundPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = render(
<BrowserRouter>
@ -27,4 +41,44 @@ describe("NotFoundPage", () => {
);
expect(screen.getByText("404")).toBeInTheDocument();
});
it("displays page not found title", () => {
render(
<BrowserRouter>
<NotFoundPage />
</BrowserRouter>
);
expect(screen.getByText(/notFound.title/i)).toBeInTheDocument();
});
it("displays description message", () => {
render(
<BrowserRouter>
<NotFoundPage />
</BrowserRouter>
);
expect(screen.getByText(/notFound.description/i)).toBeInTheDocument();
});
it("displays go back button", () => {
render(
<BrowserRouter>
<NotFoundPage />
</BrowserRouter>
);
expect(screen.getByRole("button", { name: /notFound.backHome/i })).toBeInTheDocument();
});
it("navigates back when go back button is clicked", () => {
render(
<BrowserRouter>
<NotFoundPage />
</BrowserRouter>
);
const goBackButton = screen.getByRole("button", { name: /notFound.backHome/i });
fireEvent.click(goBackButton);
expect(mockNavigate).toHaveBeenCalledWith("/login");
});
});

View file

@ -2,12 +2,15 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { PublicBookingPage } from "./PublicBookingPage";
const mockNavigate = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useParams: () => ({ username_id: "test-user", event_type: "test-event" }),
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useSearchParams: () => [new URLSearchParams("date=2024-01-15&time=14:00"), vi.fn()],
useNavigate: () => mockNavigate,
};
});
@ -17,9 +20,50 @@ vi.mock("react-i18next", () => ({
}),
}));
vi.mock("../hooks/eventTypes", () => ({
usePublicEventType: () => ({
eventType: {
id: "test-event-id",
name: "Test Event Type",
duration: 30,
description: "Test event description",
},
isLoading: false,
error: null,
}),
}));
vi.mock("../hooks/availability", () => ({
usePublicAvailability: () => ({
availableSlots: [
{ date: "2024-01-15", time: "14:00" },
{ date: "2024-01-15", time: "15:00" },
{ date: "2024-01-16", time: "10:00" },
],
isLoading: false,
}),
}));
vi.mock("../hooks/bookings", () => ({
useCreateBooking: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
describe("PublicBookingPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<PublicBookingPage />);
expect(container).toBeInTheDocument();
});
// Note: PublicBookingPage has complex UI that depends on event types and availability data.
// The component structure may vary based on state, so basic rendering test is sufficient.
});
// Note: Testing loading and error states would require re-mocking the hook with different values.
// The current test suite covers the happy path. State testing is better handled with integration tests.

View file

@ -1,3 +1,4 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { PublicNotePage } from "./PublicNotePage";
@ -16,9 +17,42 @@ vi.mock("react-i18next", () => ({
}),
}));
vi.mock("../hooks/notes", () => ({
usePublicNote: () => ({
note: {
id: "test-note-id",
title: "Test Public Note",
content:
'[{"id":"c046f706-1a5a-4a4e-a2e9-54c7009a912a","type":"paragraph","props":{"backgroundColor":"default","textColor":"default","textAlignment":"left"},"content":[{"type":"text","text":"let\'s go","styles":{}}],"children":[]},{"id":"78ed0476-0fe5-48a2-b329-965d81596964","type":"paragraph","props":{"backgroundColor":"default","textColor":"default","textAlignment":"left"},"content":[],"children":[]}]',
created_at: "2023-01-01",
},
isLoading: false,
error: null,
}),
}));
describe("PublicNotePage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<PublicNotePage />);
expect(container).toBeInTheDocument();
});
it("displays note title", () => {
renderWithProviders(<PublicNotePage />);
expect(screen.getByText("Test Public Note")).toBeInTheDocument();
});
it("displays note content", () => {
renderWithProviders(<PublicNotePage />);
expect(screen.getByText(/This is the content of the public note/i)).toBeInTheDocument();
});
});
// Note: Testing loading and error states would require re-mocking the hook with different values.
// The current test suite covers the happy path. State testing is better handled with integration tests.

View file

@ -1,7 +1,10 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { ChatPage } from "./chat";
const mockSetActiveChannel = vi.fn();
vi.mock("../hooks/channel", () => ({
useChannelFromUrl: () => ({
channel: null,
@ -11,7 +14,10 @@ vi.mock("../hooks/channel", () => ({
vi.mock("../hooks/tablos", () => ({
useTablosList: () => ({
data: [],
data: [
{ id: "tablo-1", name: "Test Tablo 1" },
{ id: "tablo-2", name: "Test Tablo 2" },
],
}),
}));
@ -26,9 +32,9 @@ vi.mock("../providers/UserStoreProvider", () => ({
vi.mock("../providers/ChatProvider", () => ({
useChatClient: () => null,
useChatContext: () => ({
client: null,
client: { id: "test-client" },
channel: null,
setActiveChannel: vi.fn(),
setActiveChannel: mockSetActiveChannel,
}),
}));
@ -49,15 +55,65 @@ vi.mock("stream-chat-react", () => ({
useChannelStateContext: () => ({ channel: null }),
useCreateChatClient: () => null,
useChatContext: () => ({
client: null,
client: { id: "test-client" },
channel: null,
setActiveChannel: vi.fn(),
setActiveChannel: mockSetActiveChannel,
}),
}));
describe("ChatPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<ChatPage />);
expect(container).toBeInTheDocument();
});
it("renders channel list", () => {
renderWithProviders(<ChatPage />);
expect(screen.getByTestId("channel-list")).toBeInTheDocument();
});
it("renders channel window", () => {
renderWithProviders(<ChatPage />);
expect(screen.getByTestId("channel")).toBeInTheDocument();
expect(screen.getByTestId("window")).toBeInTheDocument();
});
it("renders message list and input", () => {
renderWithProviders(<ChatPage />);
expect(screen.getByTestId("message-list")).toBeInTheDocument();
expect(screen.getByTestId("message-input")).toBeInTheDocument();
});
it("applies correct filters for channel list", () => {
renderWithProviders(<ChatPage />);
// ChannelList should be rendered with proper filters
expect(screen.getByTestId("channel-list")).toBeInTheDocument();
});
});
// Note: Testing channel from URL would require re-mocking the hook with different values
// This is better tested with integration tests or by testing the hook separately.
describe("ChatPage - Channel List Toggle", () => {
it("starts with channel list expanded when no channel in URL", () => {
renderWithProviders(<ChatPage />);
const channelListContainer = screen.getByTestId("channel-list").parentElement;
expect(channelListContainer?.className).toContain("w-80");
});
it("has collapsible channel list", () => {
renderWithProviders(<ChatPage />);
const channelListContainer = screen.getByTestId("channel-list").parentElement;
expect(channelListContainer).toBeInTheDocument();
});
});

View file

@ -1,16 +1,133 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { FeedbackPage } from "./feedback";
const mockNavigate = vi.fn();
const mockCreateFeedback = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
};
});
vi.mock("../hooks/feedback", () => ({
useCreateFeedback: () => ({
createFeedback: mockCreateFeedback,
isSuccess: false,
isPending: false,
}),
}));
describe("FeedbackPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<FeedbackPage />);
expect(container).toBeInTheDocument();
});
it("renders form with all elements", () => {
renderWithProviders(<FeedbackPage />);
expect(screen.getByText(/pages:feedback.title/i)).toBeInTheDocument();
expect(screen.getByLabelText(/pages:feedback.form.type.label/i)).toBeInTheDocument();
expect(screen.getByLabelText(/pages:feedback.form.message.label/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /pages:feedback.buttons.send/i })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /pages:feedback.buttons.cancel/i })
).toBeInTheDocument();
});
it("shows back button that navigates back", () => {
renderWithProviders(<FeedbackPage />);
const backButton = screen.getAllByRole("button")[0]; // First button is the back button
fireEvent.click(backButton);
expect(mockNavigate).toHaveBeenCalledWith(-1);
});
it("allows selecting feedback type", async () => {
renderWithProviders(<FeedbackPage />);
const selectTrigger = screen.getByRole("combobox");
fireEvent.click(selectTrigger);
await waitFor(() => {
const bugOption = screen.getByRole("option", { name: /pages:feedback.form.type.bug/i });
expect(bugOption).toBeInTheDocument();
});
});
it("updates message textarea on change", () => {
renderWithProviders(<FeedbackPage />);
const messageTextarea = screen.getByPlaceholderText(
/pages:feedback.form.message.placeholder/i
) as HTMLTextAreaElement;
fireEvent.change(messageTextarea, { target: { value: "This is my feedback" } });
expect(messageTextarea.value).toBe("This is my feedback");
});
it("disables submit button when message is empty", () => {
renderWithProviders(<FeedbackPage />);
const submitButton = screen.getByRole("button", { name: /pages:feedback.buttons.send/i });
expect(submitButton).toBeDisabled();
});
it("enables submit button when message is filled", () => {
renderWithProviders(<FeedbackPage />);
const messageTextarea = screen.getByPlaceholderText(/pages:feedback.form.message.placeholder/i);
fireEvent.change(messageTextarea, { target: { value: "This is my feedback" } });
const submitButton = screen.getByRole("button", { name: /pages:feedback.buttons.send/i });
expect(submitButton).not.toBeDisabled();
});
it("submits form with feedback data", async () => {
renderWithProviders(<FeedbackPage />);
const messageTextarea = screen.getByPlaceholderText(/pages:feedback.form.message.placeholder/i);
fireEvent.change(messageTextarea, { target: { value: "This is my feedback" } });
const submitButton = screen.getByRole("button", { name: /pages:feedback.buttons.send/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockCreateFeedback).toHaveBeenCalledWith({
fd_type: "improvement",
message: "This is my feedback",
});
});
});
it("navigates back when cancel button is clicked", () => {
renderWithProviders(<FeedbackPage />);
const cancelButton = screen.getByRole("button", { name: /pages:feedback.buttons.cancel/i });
fireEvent.click(cancelButton);
expect(mockNavigate).toHaveBeenCalledWith(-1);
});
});
// Note: Testing success state would require re-rendering with different mock values
// which is complex with vi.mock at the module level. This would be better tested
// with integration tests or by splitting the success view into a separate component.

View file

@ -1,13 +1,18 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { JoinPage } from "./join";
const mockNavigate = vi.fn();
const mockJoinTablo = vi.fn();
const mockSearchParams = new URLSearchParams("tablo_name=Test%20Tablo&token=test-token");
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useParams: () => ({ invite_code: "test-invite" }),
useNavigate: () => vi.fn(),
useSearchParams: () => [mockSearchParams],
useNavigate: () => mockNavigate,
};
});
@ -17,9 +22,81 @@ vi.mock("react-i18next", () => ({
}),
}));
vi.mock("../hooks/invite", () => ({
useJoinTablo: () => mockJoinTablo,
}));
vi.mock("@xtablo/shared", async () => {
const actual = await vi.importActual("@xtablo/shared");
return {
...actual,
toast: {
add: vi.fn(),
},
};
});
describe("JoinPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<JoinPage />);
expect(container).toBeInTheDocument();
});
it("displays the tablo name from query params", () => {
renderWithProviders(<JoinPage />);
expect(screen.getByText(/Test Tablo/i)).toBeInTheDocument();
});
it("renders accept and reject buttons", () => {
renderWithProviders(<JoinPage />);
expect(screen.getByRole("button", { name: /Accepter l'invitation/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Refuser/i })).toBeInTheDocument();
});
it("navigates to home when reject button is clicked", () => {
renderWithProviders(<JoinPage />);
const rejectButton = screen.getByRole("button", { name: /Refuser/i });
fireEvent.click(rejectButton);
expect(mockNavigate).toHaveBeenCalledWith("/");
});
it("calls joinTablo when accept button is clicked", async () => {
renderWithProviders(<JoinPage />);
const acceptButton = screen.getByRole("button", { name: /Accepter l'invitation/i });
fireEvent.click(acceptButton);
await waitFor(() => {
expect(mockJoinTablo).toHaveBeenCalledWith(
{ token: "test-token" },
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
})
);
});
});
// Note: Testing successful join navigation would require the callback to be triggered
// which is complex to test in isolation. This is better tested with integration tests.
// Note: Testing error toast scenarios with missing user/token would require
// dynamic mocking which is complex. These scenarios are better tested with integration tests.
it("displays invitation message", () => {
renderWithProviders(<JoinPage />);
expect(screen.getByText(/Vous avez été invité\(e\) à rejoindre ce tablo/i)).toBeInTheDocument();
});
// Note: Testing URL decoding with different params would require re-rendering with new mocks
// This is covered by the existing test that shows the tablo name correctly.
});

View file

@ -1,7 +1,11 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { LoginPage } from "./login";
const mockNavigate = vi.fn();
const mockLogin = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
@ -12,16 +16,117 @@ vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => vi.fn(),
useNavigate: () => mockNavigate,
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
};
});
vi.mock("../hooks/auth", () => ({
useLoginEmail: () => ({
mutate: mockLogin,
isPending: false,
errors: null,
}),
useLoginGoogle: () => ({
mutate: vi.fn(),
}),
useSignUp: () => ({
mutate: vi.fn(),
isPending: false,
}),
}));
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<LoginPage />);
expect(container).toBeInTheDocument();
});
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();
});
it("displays theme toggle button", () => {
renderWithProviders(<LoginPage />);
const themeButton = screen.getByRole("button", { name: /auth:common.themeToggle/i });
expect(themeButton).toBeInTheDocument();
});
it("shows link to signup page", () => {
renderWithProviders(<LoginPage />);
const signupLink = screen.getByText(/auth:login.signupLink/i);
expect(signupLink).toBeInTheDocument();
});
it("updates email input on change", () => {
renderWithProviders(<LoginPage />);
const emailInput = screen.getByPlaceholderText(
/auth:login.emailPlaceholder/i
) as HTMLInputElement;
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
expect(emailInput.value).toBe("test@example.com");
});
it("updates password input on change", () => {
renderWithProviders(<LoginPage />);
const passwordInput = screen.getByPlaceholderText(
/auth:login.passwordPlaceholder/i
) as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "password123" } });
expect(passwordInput.value).toBe("password123");
});
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 });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.change(passwordInput, { target: { value: "password123" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
});
});
});
it("uses redirectUrl from localStorage when available", () => {
localStorage.setItem("redirectUrl", "/dashboard");
renderWithProviders(<LoginPage />);
// Component should read from localStorage
expect(localStorage.getItem("redirectUrl")).toBe("/dashboard");
});
it("prevents form submission when fields are empty", () => {
renderWithProviders(<LoginPage />);
const submitButton = screen.getByRole("button", { name: /auth:login.loginButton/i });
fireEvent.click(submitButton);
// Form should not submit due to HTML5 validation (required fields)
expect(mockLogin).not.toHaveBeenCalled();
});
});

View file

@ -1,12 +1,20 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import NotesPage from "./notes";
const mockNavigate = vi.fn();
const mockCreateNote = vi.fn();
const mockUpdateNote = vi.fn();
const mockDeleteNote = vi.fn();
const mockUpdateSharing = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => vi.fn(),
useNavigate: () => mockNavigate,
useParams: () => ({ noteId: undefined }),
};
});
@ -16,9 +24,150 @@ vi.mock("react-i18next", () => ({
}),
}));
describe("NotesPage", () => {
vi.mock("../hooks/notes", () => ({
useNotes: () => ({
notes: [
{ id: "note-1", title: "Test Note 1", content: "Content 1", created_at: "2023-01-01" },
{ id: "note-2", title: "Test Note 2", content: "Content 2", created_at: "2023-01-02" },
],
isLoading: false,
}),
useNote: () => ({
note: null,
isLoading: false,
}),
useCreateNote: () => ({
mutate: mockCreateNote,
isPending: false,
}),
useUpdateNote: () => ({
mutate: mockUpdateNote,
isPending: false,
}),
useDeleteNote: () => ({
mutate: mockDeleteNote,
isPending: false,
}),
useNoteSharing: () => ({
isPublic: false,
isSharedWithAllTablos: false,
isLoading: false,
}),
useUpdateNoteSharing: () => ({
mutate: mockUpdateSharing,
isPending: false,
}),
}));
vi.mock("../providers/UserStoreProvider", () => ({
useIsReadOnlyUser: () => false,
useUser: () => ({
id: "test-user-id",
name: "Test User",
}),
TestUserStoreProvider: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@xtablo/shared", async () => {
const actual = await vi.importActual("@xtablo/shared");
return {
...actual,
toast: {
add: vi.fn(),
},
};
});
describe.skip("NotesPage - Create Mode", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<NotesPage mode="create" />);
expect(container).toBeInTheDocument();
});
it("displays page title", () => {
renderWithProviders(<NotesPage mode="create" />);
expect(screen.getByText(/notes:title/i)).toBeInTheDocument();
});
it("renders notes sidebar with list of notes", () => {
renderWithProviders(<NotesPage mode="create" />);
expect(screen.getByText("Test Note 1")).toBeInTheDocument();
expect(screen.getByText("Test Note 2")).toBeInTheDocument();
});
it("renders new note button", () => {
renderWithProviders(<NotesPage mode="create" />);
const newNoteButtons = screen.getAllByRole("button", { name: /notes:newNote/i });
expect(newNoteButtons.length).toBeGreaterThan(0);
});
it("has title input field", () => {
renderWithProviders(<NotesPage mode="create" />);
expect(screen.getByPlaceholderText(/notes:titlePlaceholder/i)).toBeInTheDocument();
});
it("updates title input on change", () => {
renderWithProviders(<NotesPage mode="create" />);
const titleInput = screen.getByPlaceholderText(/notes:titlePlaceholder/i) as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "My New Note" } });
expect(titleInput.value).toBe("My New Note");
});
it("renders save button", () => {
renderWithProviders(<NotesPage mode="create" />);
expect(screen.getByRole("button", { name: /notes:save/i })).toBeInTheDocument();
});
it("calls createNote when save button is clicked", async () => {
renderWithProviders(<NotesPage mode="create" />);
const titleInput = screen.getByPlaceholderText(/notes:titlePlaceholder/i);
fireEvent.change(titleInput, { target: { value: "My New Note" } });
const saveButton = screen.getByRole("button", { name: /notes:save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockCreateNote).toHaveBeenCalled();
});
});
});
describe.skip("NotesPage - Edit Mode", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders in edit mode", () => {
const { container } = renderWithProviders(<NotesPage mode="edit" />);
expect(container).toBeInTheDocument();
});
// Note: Testing delete button visibility in edit mode with a specific noteId would require
// re-mocking useParams with different values, which is complex with module-level mocks.
});
// Note: Testing empty state and loading state would require re-mocking the hooks with different values.
// The current test suite already covers the main happy path with notes present.
// Additional state testing is better handled with integration tests.
describe.skip("NotesPage - Sidebar Toggle", () => {
it("has sidebar toggle button", () => {
renderWithProviders(<NotesPage mode="create" />);
// Sidebar should be visible by default, look for collapse button
const toggleButtons = screen.getAllByRole("button");
expect(toggleButtons.length).toBeGreaterThan(0);
});
});

View file

@ -2,18 +2,92 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { OAuthSigninPage } from "./oauth-signin";
const mockNavigate = vi.fn();
const mockSignUpToStream = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => vi.fn(),
useNavigate: () => mockNavigate,
useSearchParams: () => [new URLSearchParams(), vi.fn()],
};
});
vi.mock("@xtablo/shared/hooks/auth", () => ({
useSignUpToStream: () => ({
signUpToStream: mockSignUpToStream,
}),
}));
vi.mock("../lib/api", () => ({
api: {},
}));
describe("OAuthSigninPage", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<OAuthSigninPage />);
expect(container).toBeInTheDocument();
});
it("renders empty component", () => {
const { container } = renderWithProviders(<OAuthSigninPage />);
expect(container.firstChild).toBeEmptyDOMElement();
});
it("navigates to home when session exists without redirectUrl", () => {
renderWithProviders(<OAuthSigninPage />);
vi.advanceTimersByTime(150);
expect(mockSignUpToStream).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith("/");
});
it("navigates to redirectUrl when session exists with redirectUrl", () => {
localStorage.setItem("redirectUrl", "/dashboard");
renderWithProviders(<OAuthSigninPage />);
vi.advanceTimersByTime(150);
expect(mockSignUpToStream).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith("/dashboard");
expect(localStorage.getItem("redirectUrl")).toBeNull();
});
it("decodes redirectUrl before navigation", () => {
localStorage.setItem("redirectUrl", "%2Fdashboard%2Ftest");
renderWithProviders(<OAuthSigninPage />);
vi.advanceTimersByTime(150);
expect(mockNavigate).toHaveBeenCalledWith("/dashboard/test");
});
it("signs up to stream with access token", () => {
renderWithProviders(<OAuthSigninPage />);
vi.advanceTimersByTime(150);
expect(mockSignUpToStream).toHaveBeenCalled();
});
it("clears interval on unmount", () => {
const { unmount } = renderWithProviders(<OAuthSigninPage />);
unmount();
// If interval isn't cleared, this would cause issues
vi.advanceTimersByTime(150);
});
});

View file

@ -1,16 +1,122 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { ResetPasswordPage } from "./reset-password";
const mockNavigate = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
};
});
describe("ResetPasswordPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<ResetPasswordPage />);
expect(container).toBeInTheDocument();
});
it("renders form with email input", () => {
renderWithProviders(<ResetPasswordPage />);
expect(screen.getByText(/Mot de passe oublié/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Email/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Réinitialiser le mot de passe/i })
).toBeInTheDocument();
});
it("displays help text", () => {
renderWithProviders(<ResetPasswordPage />);
expect(screen.getByText(/Entrez votre adresse email/i)).toBeInTheDocument();
});
it("shows link back to login", () => {
renderWithProviders(<ResetPasswordPage />);
const loginLink = screen.getByText(/Retour à la connexion/i);
expect(loginLink).toBeInTheDocument();
});
it("updates email input on change", () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/Email/i) as HTMLInputElement;
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
expect(emailInput.value).toBe("test@example.com");
});
it("submits form and shows success message", async () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/Email/i);
const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/Email envoyé/i)).toBeInTheDocument();
});
});
it("displays email in success message", async () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/Email/i);
const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/test@example.com/i)).toBeInTheDocument();
});
});
it("shows return to login button in success state", async () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/Email/i);
const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i });
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Retour à la connexion/i })).toBeInTheDocument();
});
});
it("requires email field to be filled", () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/Email/i);
expect(emailInput).toHaveAttribute("required");
});
it("requires valid email format", () => {
renderWithProviders(<ResetPasswordPage />);
const emailInput = screen.getByLabelText(/Email/i);
expect(emailInput).toHaveAttribute("type", "email");
});
});

View file

@ -1,7 +1,14 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import SettingsPage from "./settings";
const mockUpdateProfile = vi.fn();
const mockUploadAvatar = vi.fn();
const mockRemoveAvatar = vi.fn();
const mockUpdateIntroduction = vi.fn();
const mockSetDraftIntroduction = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
@ -27,9 +34,266 @@ vi.mock("react-router-dom", async () => {
};
});
vi.mock("../hooks/profile", () => ({
useUpdateProfile: () => ({
mutate: mockUpdateProfile,
isPending: false,
}),
useUploadAvatar: () => ({
mutate: mockUploadAvatar,
}),
useRemoveAvatar: () => ({
mutateAsync: mockRemoveAvatar,
isPending: false,
}),
}));
vi.mock("../hooks/intros", () => ({
useIntroduction: () => ({
introduction: { intro_email: "Test introduction" },
updateIntroduction: mockUpdateIntroduction,
setDraftIntroduction: mockSetDraftIntroduction,
isPending: false,
}),
}));
vi.mock("@xtablo/shared", async () => {
const actual = await vi.importActual("@xtablo/shared");
return {
...actual,
toast: {
add: vi.fn(),
},
};
});
describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<SettingsPage />);
expect(container).toBeInTheDocument();
});
it("renders all settings sections", () => {
renderWithProviders(<SettingsPage />);
expect(screen.getByText(/settings:avatar.title/i)).toBeInTheDocument();
expect(screen.getByText(/settings:personalInfo.title/i)).toBeInTheDocument();
expect(screen.getByText(/settings:introduction.title/i)).toBeInTheDocument();
});
it("displays user information in form fields", () => {
renderWithProviders(<SettingsPage />);
const firstNameInput = screen.getByLabelText(
/settings:personalInfo.firstName/i
) as HTMLInputElement;
const lastNameInput = screen.getByLabelText(
/settings:personalInfo.lastName/i
) as HTMLInputElement;
const emailInput = screen.getByLabelText(/settings:personalInfo.email/i) as HTMLInputElement;
expect(firstNameInput.value).toBe("John");
expect(lastNameInput.value).toBe("Doe");
expect(emailInput.value).toBe("john@example.com");
});
it("email input is disabled", () => {
renderWithProviders(<SettingsPage />);
const emailInput = screen.getByLabelText(/settings:personalInfo.email/i);
expect(emailInput).toBeDisabled();
});
it("updates first name on change", () => {
renderWithProviders(<SettingsPage />);
const firstNameInput = screen.getByLabelText(
/settings:personalInfo.firstName/i
) as HTMLInputElement;
fireEvent.change(firstNameInput, { target: { value: "Jane" } });
expect(firstNameInput.value).toBe("Jane");
});
it("updates last name on change", () => {
renderWithProviders(<SettingsPage />);
const lastNameInput = screen.getByLabelText(
/settings:personalInfo.lastName/i
) as HTMLInputElement;
fireEvent.change(lastNameInput, { target: { value: "Smith" } });
expect(lastNameInput.value).toBe("Smith");
});
it("calls updateProfile with new values when save button is clicked", async () => {
renderWithProviders(<SettingsPage />);
const firstNameInput = screen.getByLabelText(/settings:personalInfo.firstName/i);
const lastNameInput = screen.getByLabelText(/settings:personalInfo.lastName/i);
fireEvent.change(firstNameInput, { target: { value: "Jane" } });
fireEvent.change(lastNameInput, { target: { value: "Smith" } });
const saveButton = screen.getByRole("button", { name: /settings:personalInfo.save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockUpdateProfile).toHaveBeenCalledWith({
firstName: "Jane",
lastName: "Smith",
});
});
});
it("displays introduction text", () => {
renderWithProviders(<SettingsPage />);
const introTextarea = screen.getByLabelText(
/settings:introduction.title/i
) as HTMLTextAreaElement;
expect(introTextarea.value).toBe("Test introduction");
});
it("updates introduction text on change", () => {
renderWithProviders(<SettingsPage />);
const introTextarea = screen.getByLabelText(/settings:introduction.title/i);
fireEvent.change(introTextarea, { target: { value: "New introduction" } });
expect(mockSetDraftIntroduction).toHaveBeenCalled();
});
it("calls updateIntroduction when save button is clicked", async () => {
renderWithProviders(<SettingsPage />);
const saveButtons = screen.getAllByRole("button", { name: /common:buttons.save/i });
const introSaveButton = saveButtons[saveButtons.length - 1]; // Last save button is for introduction
fireEvent.click(introSaveButton);
await waitFor(() => {
expect(mockUpdateIntroduction).toHaveBeenCalledWith({
intro_email: "Test introduction",
});
});
});
it("renders avatar with user initials fallback", () => {
renderWithProviders(<SettingsPage />);
const avatar = screen.getByAltText("Avatar");
expect(avatar).toBeInTheDocument();
});
it("has file input for avatar upload", () => {
renderWithProviders(<SettingsPage />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeInTheDocument();
expect(fileInput).toHaveAttribute("accept", "image/*");
});
it("shows choose file button", () => {
renderWithProviders(<SettingsPage />);
expect(screen.getByRole("button", { name: /settings:avatar.chooseFile/i })).toBeInTheDocument();
});
it("validates file size on avatar upload", async () => {
const { toast } = await import("@xtablo/shared");
renderWithProviders(<SettingsPage />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
// Create a large file (> 5MB)
const largeFile = new File(["x".repeat(6 * 1024 * 1024)], "large.jpg", { type: "image/jpeg" });
Object.defineProperty(fileInput, "files", {
value: [largeFile],
writable: false,
});
fireEvent.change(fileInput);
await waitFor(() => {
expect(toast.add).toHaveBeenCalledWith(
expect.objectContaining({
title: "settings:toasts.error",
description: "settings:toasts.fileTooLarge",
type: "error",
})
);
});
});
it("validates file type on avatar upload", async () => {
const { toast } = await import("@xtablo/shared");
renderWithProviders(<SettingsPage />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
// Create a non-image file
const textFile = new File(["test"], "test.txt", { type: "text/plain" });
Object.defineProperty(fileInput, "files", {
value: [textFile],
writable: false,
});
fireEvent.change(fileInput);
await waitFor(() => {
expect(toast.add).toHaveBeenCalledWith(
expect.objectContaining({
title: "settings:toasts.error",
description: "settings:toasts.invalidImage",
type: "error",
})
);
});
});
it("shows delete avatar button when avatar exists", () => {
renderWithProviders(<SettingsPage />);
expect(screen.getByRole("button", { name: /settings:avatar.delete/i })).toBeInTheDocument();
});
it("opens confirmation dialog when delete button is clicked", () => {
renderWithProviders(<SettingsPage />);
const deleteButton = screen.getByRole("button", { name: /settings:avatar.delete/i });
fireEvent.click(deleteButton);
expect(screen.getByText(/settings:avatar.deleteTitle/i)).toBeInTheDocument();
expect(screen.getByText(/settings:avatar.deleteDescription/i)).toBeInTheDocument();
});
it("calls removeAvatar when deletion is confirmed", async () => {
renderWithProviders(<SettingsPage />);
const deleteButton = screen.getByRole("button", { name: /settings:avatar.delete/i });
fireEvent.click(deleteButton);
// Find and click confirm button in dialog
const confirmButtons = screen.getAllByRole("button", { name: /settings:avatar.delete/i });
const confirmButton = confirmButtons[confirmButtons.length - 1]; // Last one is in the dialog
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockRemoveAvatar).toHaveBeenCalled();
});
});
it("renders language selector", () => {
renderWithProviders(<SettingsPage />);
// LanguageSelector is a separate component, just verify it's rendered
expect(screen.getByText(/settings:title/i)).toBeInTheDocument();
});
});

View file

@ -1,7 +1,11 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { SignUpPage } from "./signup";
const mockNavigate = vi.fn();
const mockSignUp = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
@ -12,16 +16,221 @@ vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => vi.fn(),
useNavigate: () => mockNavigate,
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
};
});
vi.mock("../hooks/auth", () => ({
useSignUp: () => ({
mutate: mockSignUp,
isPending: false,
}),
useLoginEmail: () => ({
mutate: vi.fn(),
isPending: false,
}),
useLoginGoogle: () => ({
mutate: vi.fn(),
}),
}));
describe("SignUpPage", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<SignUpPage />);
expect(container).toBeInTheDocument();
});
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();
});
it("shows link to login page", () => {
renderWithProviders(<SignUpPage />);
const loginLink = screen.getByText(/auth:signup.loginLink/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 confirmPasswordInput = screen.getByPlaceholderText(
/auth:signup.confirmPasswordPlaceholder/i
) as HTMLInputElement;
fireEvent.change(firstNameInput, { target: { value: "John" } });
fireEvent.change(lastNameInput, { target: { value: "Doe" } });
fireEvent.change(emailInput, { target: { value: "john@example.com" } });
fireEvent.change(passwordInput, { target: { value: "password123" } });
fireEvent.change(confirmPasswordInput, { target: { value: "password123" } });
expect(firstNameInput.value).toBe("John");
expect(lastNameInput.value).toBe("Doe");
expect(emailInput.value).toBe("john@example.com");
expect(passwordInput.value).toBe("password123");
expect(confirmPasswordInput.value).toBe("password123");
});
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 });
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
target: { value: "John" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
target: { value: "Doe" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
target: { value: "john@example.com" },
});
fireEvent.change(passwordInput, { target: { value: "short" } });
fireEvent.change(confirmPasswordInput, { target: { value: "short" } });
fireEvent.click(termsCheckbox);
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/auth:signup.errors.passwordLength/i)).toBeInTheDocument();
});
expect(mockSignUp).not.toHaveBeenCalled();
});
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 });
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
target: { value: "John" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
target: { value: "Doe" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
target: { value: "john@example.com" },
});
fireEvent.change(passwordInput, { target: { value: "password123" } });
fireEvent.change(confirmPasswordInput, { target: { value: "different123" } });
fireEvent.click(termsCheckbox);
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/auth:signup.errors.passwordMatch/i)).toBeInTheDocument();
});
expect(mockSignUp).not.toHaveBeenCalled();
});
it("shows error for invalid email", async () => {
renderWithProviders(<SignUpPage />);
const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i });
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
target: { value: "John" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
target: { value: "Doe" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
target: { value: "invalid-email" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i), {
target: { value: "password123" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.confirmPasswordPlaceholder/i), {
target: { value: "password123" },
});
fireEvent.click(termsCheckbox);
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/auth:signup.errors.invalidEmail/i)).toBeInTheDocument();
});
expect(mockSignUp).not.toHaveBeenCalled();
});
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 });
fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), {
target: { value: "John" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), {
target: { value: "Doe" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), {
target: { value: "john@example.com" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i), {
target: { value: "password123" },
});
fireEvent.change(screen.getByPlaceholderText(/auth:signup.confirmPasswordPlaceholder/i), {
target: { value: "password123" },
});
fireEvent.click(termsCheckbox);
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockSignUp).toHaveBeenCalledWith({
email: "john@example.com",
password: "password123",
first_name: "John",
last_name: "Doe",
confirm_password: "password123",
business_name: "",
});
});
});
it("requires terms checkbox to be checked", () => {
renderWithProviders(<SignUpPage />);
const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i });
expect(termsCheckbox).toHaveAttribute("required");
});
});

View file

@ -3,7 +3,7 @@ import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { ArrowLeft, BookOpen, Calendar, FileText, MessageSquare, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
@ -20,7 +20,9 @@ export const TabloDetailsPage = () => {
const { data: tablos, isLoading } = useTablosList();
const { mutateAsync: updateTablo } = useUpdateTablo();
const [activeSection, setActiveSection] = useState<TabSection>("files");
const [searchParams, setSearchParams] = useSearchParams();
const activeSection = (searchParams.get("section") as TabSection) || "files";
const [tablo, setTablo] = useState<UserTablo | null>(null);
useEffect(() => {
@ -161,7 +163,7 @@ export const TabloDetailsPage = () => {
{navigationItems.map((item) => (
<button
key={item.id}
onClick={() => setActiveSection(item.id)}
onClick={() => setSearchParams({ section: item.id })}
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
activeSection === item.id
? "bg-primary text-primary-foreground"

View file

@ -1,16 +1,144 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloPage } from "./tablo";
const mockNavigate = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
useParams: () => ({ tabloId: "test-tablo-id" }),
};
});
vi.mock("../hooks/tablos", () => ({
useTablo: () => ({
tablo: {
id: "test-tablo-id",
name: "Test Tablo",
owner_id: "test-owner-id",
},
isLoading: false,
error: null,
}),
useTablosList: () => ({
data: [{ id: "test-tablo-id", name: "Test Tablo" }],
}),
useCreateTablo: () => ({
mutate: vi.fn(),
}),
useUpdateTablo: () => ({
mutate: vi.fn(),
}),
useDeleteTablo: () => ({
mutate: vi.fn(),
}),
}));
vi.mock("../hooks/tabloData", () => ({
useTabloData: () => ({
data: [],
isLoading: false,
}),
}));
vi.mock("../providers/UserStoreProvider", () => ({
useUser: () => ({
id: "test-user-id",
name: "Test User",
}),
useIsReadOnlyUser: () => false,
TestUserStoreProvider: ({ children }: { children: React.ReactNode }) => children,
}));
describe("TabloPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
const { container } = renderWithProviders(<TabloPage />);
expect(container).toBeInTheDocument();
});
it("displays tablo name", () => {
renderWithProviders(<TabloPage />);
expect(screen.getByText("Test Tablo")).toBeInTheDocument();
});
it("renders data grid for tablo", () => {
renderWithProviders(<TabloPage />);
// AG Grid should be rendered (look for grid container)
const gridContainer = document.querySelector(".ag-root-wrapper");
expect(gridContainer).toBeInTheDocument();
});
});
describe("TabloPage - Loading State", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// it("shows loading state when tablo is being fetched", () => {
// vi.mocked(vi.importMock("../hooks/tablos")).useTablo = () => ({
// tablo: null,
// isLoading: true,
// error: null,
// });
// vi.doMock("../hooks/tablos", () => ({
// useTablo: () => ({
// tablo: null,
// isLoading: true,
// error: null,
// }),
// useTablosList: () => ({
// data: [],
// }),
// }));
// renderWithProviders(<TabloPage />);
// expect(screen.getByText(/pages:tablo.loading/i)).toBeInTheDocument();
// });
});
describe("TabloPage - Error State", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// it("shows error message when tablo cannot be loaded", () => {
// vi.mocked(vi.importMock("../hooks/tablos")).useTablo = () => ({
// tablo: null,
// isLoading: false,
// error: new Error("Tablo not found"),
// });
// vi.doMock("../hooks/tablos", () => ({
// useTablo: () => ({
// tablo: null,
// isLoading: false,
// error: new Error("Tablo not found"),
// }),
// useTablosList: () => ({
// data: [],
// }),
// }));
// renderWithProviders(<TabloPage />);
// expect(screen.getByText(/pages:tablo.error/i)).toBeInTheDocument();
// });
});

View file

@ -67,6 +67,7 @@ describe("TestUserStoreProvider", () => {
is_temporary: false,
last_name: null,
short_user_id: "short-id",
last_signed_in: null,
};
it("renders children with user", () => {

View file

@ -17,6 +17,7 @@ const defaultUser = {
avatar_url: "https://example.com/avatar.jpg",
streamToken: null,
is_temporary: false,
last_signed_in: null,
};
export const renderWithRouter = (ui: React.ReactNode, { route = "/" } = {}) => {

File diff suppressed because one or more lines are too long

View file

@ -347,6 +347,7 @@ export type Database = {
id: string;
is_temporary: boolean;
last_name: string | null;
last_signed_in: string | null;
name: string | null;
short_user_id: string;
};
@ -357,6 +358,7 @@ export type Database = {
id: string;
is_temporary?: boolean;
last_name?: string | null;
last_signed_in?: string | null;
name?: string | null;
short_user_id: string;
};
@ -367,6 +369,7 @@ export type Database = {
id?: string;
is_temporary?: boolean;
last_name?: string | null;
last_signed_in?: string | null;
name?: string | null;
short_user_id?: string;
};
@ -465,24 +468,30 @@ export type Database = {
};
tablo_invites: {
Row: {
created_at: string;
id: number;
invite_token: string;
invited_by: string;
invited_email: string;
is_pending: boolean;
tablo_id: string;
};
Insert: {
created_at?: string;
id?: number;
invite_token: string;
invited_by: string;
invited_email: string;
is_pending?: boolean;
tablo_id: string;
};
Update: {
created_at?: string;
id?: number;
invite_token?: string;
invited_by?: string;
invited_email?: string;
is_pending?: boolean;
tablo_id?: string;
};
Relationships: [
@ -520,6 +529,7 @@ export type Database = {
owner_id: string;
position: number;
status: string;
updated_at: string | null;
};
Insert: {
color?: string | null;
@ -531,6 +541,7 @@ export type Database = {
owner_id: string;
position?: number;
status?: string;
updated_at?: string | null;
};
Update: {
color?: string | null;
@ -542,6 +553,7 @@ export type Database = {
owner_id?: string;
position?: number;
status?: string;
updated_at?: string | null;
};
Relationships: [];
};

View file

@ -12,10 +12,6 @@ CREATE TABLE IF NOT EXISTS tablo_access (
CONSTRAINT fk_tablo_access_tablo_id
FOREIGN KEY (tablo_id) REFERENCES tablos(id) ON DELETE CASCADE,
-- Unique constraint to prevent duplicate access records
CONSTRAINT unique_tablo_access
UNIQUE (tablo_id, user_id)
-- Foreign key constraint to users table (auth.users)
CONSTRAINT fk_tablo_access_user_id
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE,

View file

@ -0,0 +1,10 @@
-- Add is_pending column to tablo_invites table
ALTER TABLE tablo_invites
ADD COLUMN IF NOT EXISTS is_pending BOOLEAN DEFAULT FALSE NOT NULL;
-- Add comment to document the column
COMMENT ON COLUMN tablo_invites.is_pending IS
'When TRUE, the invite is pending acceptance. When FALSE, the invite has been accepted or rejected.';
-- Create index for performance when querying pending invites
CREATE INDEX IF NOT EXISTS idx_tablo_invites_is_pending ON tablo_invites(is_pending);

49
sql/28_modify_trigger.sql Normal file
View file

@ -0,0 +1,49 @@
-- Modify the handle_new_user trigger to set is_temporary based on app_metadata.role
CREATE OR REPLACE FUNCTION
public.handle_new_user()
RETURNS TRIGGER AS
$$
DECLARE
name TEXT;
first_name TEXT;
last_name TEXT;
is_temp BOOLEAN;
BEGIN
-- Extract first_name and last_name from metadata
first_name = new.raw_user_meta_data ->> 'first_name';
last_name = new.raw_user_meta_data ->> 'last_name';
-- Determine the full name
IF new.raw_user_meta_data ->> 'name' IS NOT NULL
THEN
name = new.raw_user_meta_data ->> 'name';
-- If name is provided but not first/last, try to split it
IF first_name IS NULL AND last_name IS NULL AND name IS NOT NULL THEN
first_name = SPLIT_PART(name, ' ', 1);
IF ARRAY_LENGTH(STRING_TO_ARRAY(name, ' '), 1) > 1 THEN
last_name = SUBSTRING(name FROM LENGTH(SPLIT_PART(name, ' ', 1)) + 2);
END IF;
END IF;
ELSE
name = CONCAT(first_name, ' ', last_name);
END IF;
-- Check if the role is 'invited_user' in app_metadata
IF COALESCE(new.raw_user_meta_data->>'role', '') = 'invited_user'
THEN
is_temp = TRUE;
ELSE
is_temp = FALSE;
END IF;
INSERT INTO public.profiles (id, name, email, avatar_url, first_name, last_name, is_temporary)
VALUES (new.id, name, new.email, new.raw_user_meta_data ->> 'avatar_url', first_name, last_name, is_temp);
RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Add comment to document the change
COMMENT ON FUNCTION public.handle_new_user() IS
'Trigger function that creates a profile when a new user is created. Sets is_temporary=true for users with app_metadata.role=invited_user';

View file

@ -0,0 +1,10 @@
-- Add created_at column to tablo_invites table
ALTER TABLE tablo_invites
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL;
-- Add comment to document the column
COMMENT ON COLUMN tablo_invites.created_at IS
'Timestamp when the invite was created';
-- Create index for performance when querying by creation date
CREATE INDEX IF NOT EXISTS idx_tablo_invites_created_at ON tablo_invites(created_at);

View file

@ -0,0 +1,63 @@
-- Add last_signed_in column to profiles table
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS last_signed_in TIMESTAMP WITH TIME ZONE;
-- Add comment to document the column
COMMENT ON COLUMN profiles.last_signed_in IS
'Timestamp when the user last signed in, updated from auth.users.last_sign_in_at';
-- Create function to update last_signed_in column on profiles table
CREATE OR REPLACE FUNCTION public.create_last_signed_in_on_profiles()
RETURNS TRIGGER AS $$
BEGIN
IF (NEW.last_sign_in_at is null) THEN
RETURN NULL;
ELSE
UPDATE public.profiles
SET last_signed_in = NEW.last_sign_in_at
WHERE id = (NEW.id)::uuid;
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create trigger to update last_signed_in column on profiles table
CREATE TRIGGER trigger_on_last_signed_in
AFTER UPDATE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.create_last_signed_in_on_profiles();
-- Create function to update tablo_invites is_pending for temporary users
CREATE OR REPLACE FUNCTION public.update_tablo_invites_on_login()
RETURNS TRIGGER AS $$
BEGIN
IF (NEW.last_sign_in_at IS NULL OR NEW.last_sign_in_at = OLD.last_sign_in_at) THEN
RETURN NULL;
ELSE
-- Check if the user is temporary and update pending invites
UPDATE public.tablo_invites
SET is_pending = FALSE
WHERE invited_email = NEW.email
AND is_pending = TRUE
AND EXISTS (
SELECT 1 FROM public.profiles
WHERE id = (NEW.id)::uuid
AND is_temporary = TRUE
);
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create trigger to update tablo_invites on user login
CREATE TRIGGER trigger_update_tablo_invites_on_login
AFTER UPDATE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.update_tablo_invites_on_login();
-- Add comment to document the trigger
COMMENT ON TRIGGER trigger_update_tablo_invites_on_login ON auth.users IS
'Automatically sets is_pending=false for tablo_invites when a temporary user signs in';
-- Trigger after login: https://github.com/orgs/supabase/discussions/7463

View file

@ -0,0 +1,9 @@
-- Add RLS policy for tablo_invites table
-- Allow authenticated users to view pending invites they created
CREATE POLICY "Users can view their own pending invites" ON tablo_invites
FOR SELECT USING (
invited_by = auth.uid()
AND is_pending = TRUE
);

View file

@ -0,0 +1,14 @@
-- Remove duplicate records from tablo_access table
-- Keep only the earliest record (lowest id) for each (tablo_id, user_id) combination
DELETE FROM tablo_access
WHERE id NOT IN (
SELECT MIN(id)
FROM tablo_access
GROUP BY tablo_id, user_id
);
-- Add unique constraint to prevent duplicate access records
ALTER TABLE tablo_access
ADD CONSTRAINT unique_tablo_access
UNIQUE (tablo_id, user_id);

View file

@ -0,0 +1,18 @@
-- Remove duplicate records from tablo_invites table
-- Keep only the earliest record (lowest id) for each (tablo_id, invited_email) combination
DELETE FROM tablo_invites
WHERE id NOT IN (
SELECT MIN(id)
FROM tablo_invites
GROUP BY tablo_id, invited_email
);
-- Drop existing constraint if it exists (to avoid errors)
ALTER TABLE tablo_invites
DROP CONSTRAINT IF EXISTS unique_tablo_invitation;
-- Add unique constraint to prevent duplicate invitations
ALTER TABLE tablo_invites
ADD CONSTRAINT unique_tablo_invitation
UNIQUE (tablo_id, invited_email);

View file

@ -0,0 +1,28 @@
-- Add updated_at column to tablos table
ALTER TABLE tablos
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
-- Update existing rows to have updated_at = created_at for consistency
UPDATE tablos
SET updated_at = created_at
WHERE updated_at IS NULL;
-- Create function to update updated_at timestamp for tablos
CREATE OR REPLACE FUNCTION update_tablos_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create trigger to automatically update updated_at on tablos
CREATE TRIGGER update_tablos_updated_at
BEFORE UPDATE ON tablos
FOR EACH ROW
EXECUTE FUNCTION update_tablos_updated_at();
-- Add comment to document the column
COMMENT ON COLUMN tablos.updated_at IS
'Timestamp when the tablo was last updated (auto-updated by trigger)';

View file

@ -353,6 +353,7 @@ export type Database = {
id: string
is_temporary: boolean
last_name: string | null
last_signed_in: string | null
name: string | null
short_user_id: string
}
@ -363,6 +364,7 @@ export type Database = {
id: string
is_temporary?: boolean
last_name?: string | null
last_signed_in?: string | null
name?: string | null
short_user_id: string
}
@ -373,6 +375,7 @@ export type Database = {
id?: string
is_temporary?: boolean
last_name?: string | null
last_signed_in?: string | null
name?: string | null
short_user_id?: string
}
@ -471,24 +474,30 @@ export type Database = {
}
tablo_invites: {
Row: {
created_at: string
id: number
invite_token: string
invited_by: string
invited_email: string
is_pending: boolean
tablo_id: string
}
Insert: {
created_at?: string
id?: number
invite_token: string
invited_by: string
invited_email: string
is_pending?: boolean
tablo_id: string
}
Update: {
created_at?: string
id?: number
invite_token?: string
invited_by?: string
invited_email?: string
is_pending?: boolean
tablo_id?: string
}
Relationships: [
@ -526,6 +535,7 @@ export type Database = {
owner_id: string
position: number
status: string
updated_at: string | null
}
Insert: {
color?: string | null
@ -537,6 +547,7 @@ export type Database = {
owner_id: string
position?: number
status?: string
updated_at?: string | null
}
Update: {
color?: string | null
@ -548,6 +559,7 @@ export type Database = {
owner_id?: string
position?: number
status?: string
updated_at?: string | null
}
Relationships: []
}