Setup tests and absolute paths

This commit is contained in:
Arthur Belleville 2025-04-08 09:43:51 +02:00
parent 9c9a18516d
commit d5e39c03f0
No known key found for this signature in database
32 changed files with 3356 additions and 106 deletions

View file

@ -8,7 +8,10 @@
"typecheck": "tsc -b",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:watch": "vitest watch",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
@ -18,6 +21,10 @@
"@react-aria/toast": "^3.0.0",
"@react-stately/toast": "^3.0.0",
"@tailwindcss/container-queries": "^0.1.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.10",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
@ -28,6 +35,8 @@
"eslint": "^9.22.0",
"eslint-plugin-react": "^7.37.4",
"globals": "^16.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lucide-react": "^0.460.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"react": "19.0.0",
@ -40,7 +49,9 @@
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.0",
"typescript-eslint": "^8.26.1",
"vite": "^6.2.2"
"vite": "^6.2.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.1"
},
"dependencies": {
"@react-stately/calendar": "^3.7.1",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
import "./login-with-google.css";
export declare function LoginWithGoogle(): import("react/jsx-runtime").JSX.Element;

View file

@ -0,0 +1 @@
export {};

View file

@ -0,0 +1,41 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { LoginWithGoogle } from "./LoginWithGoogle";
import { useLoginGoogle } from "../../hooks/auth";
import { vi } from "vitest";
vi.mock("../../hooks/auth", () => ({
useLoginGoogle: vi.fn(),
}));
describe("LoginWithGoogle", () => {
it("renders the Google login button", () => {
const mockLoginWithGoogle = vi.fn();
(useLoginGoogle as ReturnType<typeof vi.fn>).mockReturnValue({
loginWithGoogle: mockLoginWithGoogle,
});
render(<LoginWithGoogle />);
const button = screen.getByRole("button", {
name: /continue with google/i,
});
expect(button).toBeInTheDocument();
expect(button).toHaveClass("login-with-google");
});
it("calls loginWithGoogle when clicked", () => {
const mockLoginWithGoogle = vi.fn();
(useLoginGoogle as ReturnType<typeof vi.fn>).mockReturnValue({
loginWithGoogle: mockLoginWithGoogle,
});
render(<LoginWithGoogle />);
const button = screen.getByRole("button", {
name: /continue with google/i,
});
fireEvent.click(button);
expect(mockLoginWithGoogle).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,92 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { Layout } from "@ui/components/Layout";
import { SessionProvider } from "@ui/contexts/SessionContext";
import { BrowserRouter } from "react-router-dom";
import { vi } from "vitest";
// Mock the SessionContext
vi.mock("../../contexts/SessionContext", () => ({
...vi.importActual("../../contexts/SessionContext"),
SessionProvider: ({ children }: { children: React.ReactNode }) => children,
useSession: () => ({
session: {
user: {
user_metadata: {
full_name: "John Doe",
avatar_url: "https://example.com/avatar.jpg",
first_name: "John",
},
},
},
}),
}));
vi.mock("../ThemeSwitcher", () => ({
ThemeSwitcher: () => <div>Theme Switcher</div>,
}));
describe("Layout", () => {
it("renders the layout with children", () => {
render(
<BrowserRouter>
{/* Mock SessionProvider just passes children through */}
<Layout>
<div>Test Content</div>
</Layout>
</BrowserRouter>
);
// Check if the content is rendered
expect(screen.getByText("Test Content")).toBeInTheDocument();
// Check if the mobile menu button is present
expect(screen.getByRole("button", { name: /menu/i })).toBeInTheDocument();
});
it.skip("toggles mobile menu when menu button is clicked", () => {
// Mock viewport width to mobile size
global.innerWidth = 500; // Mobile width
global.dispatchEvent(new Event("resize"));
render(
<BrowserRouter>
<SessionProvider>
<Layout>
<div>Test Content</div>
</Layout>
</SessionProvider>
</BrowserRouter>
);
// Get the menu button
const menuButton = screen.getByRole("button", { name: /menu/i });
// Verify initial mobile state
const navigation = screen.getByLabelText("Side Navigation");
expect(navigation).toHaveClass("-translate-x-full");
expect(navigation).not.toHaveClass("translate-x-0");
// Click the menu button to show
fireEvent.click(menuButton);
expect(navigation).toHaveClass("translate-x-0");
// Click again to hide
fireEvent.click(menuButton);
expect(navigation).toHaveClass("-translate-x-full");
});
it("renders the side navigation", () => {
render(
<BrowserRouter>
<SessionProvider>
<Layout>
<div>Test Content</div>
</Layout>
</SessionProvider>
</BrowserRouter>
);
// Check if the side navigation is present
expect(screen.getByRole("navigation")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,76 @@
import { screen, fireEvent } from "@testing-library/react";
import {
SideNavigation,
MainNavigation,
UserMenuPopover,
} from "@ui/components/NavigationBar";
import { renderWithProviders } from "@ui/utils/testHelpers";
describe("NavigationBar", () => {
describe("SideNavigation", () => {
it("renders the side navigation with correct initial state", () => {
renderWithProviders(<SideNavigation isMobileMenuOpen={false} />);
// Check if the logo is present
expect(screen.getByAltText("Logo XTablo")).toBeInTheDocument();
// Check if the title is present
expect(screen.getByText("XTablo")).toBeInTheDocument();
});
it("collapses and expands when the collapse button is clicked", () => {
renderWithProviders(<SideNavigation isMobileMenuOpen={false} />);
// Find and click the collapse button
const collapseButton = screen.getByRole("button", { name: /collapse/i });
fireEvent.click(collapseButton);
// Check if the navigation is collapsed
const navigation = screen.getByRole("navigation", {
name: "Main navigation",
});
expect(navigation).toHaveClass("w-16");
// Click again to expand
fireEvent.click(collapseButton);
expect(navigation).toHaveClass("w-48");
});
});
describe("MainNavigation", () => {
it("renders all navigation items", () => {
renderWithProviders(<MainNavigation isCollapsed={false} />);
// Check if all navigation items are present
expect(screen.getByText("Tableau de Bord")).toBeInTheDocument();
expect(screen.getByText("Devis")).toBeInTheDocument();
expect(screen.getByText("Factures")).toBeInTheDocument();
expect(screen.getByText("Planning")).toBeInTheDocument();
expect(screen.getByText("Chantiers")).toBeInTheDocument();
});
});
describe("UserMenuPopover", () => {
it("renders the user menu with correct user information", () => {
renderWithProviders(<UserMenuPopover isCollapsed={false} />);
// Check if user information is displayed
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByAltText("Avatar")).toBeInTheDocument();
});
it("opens and closes the popover when clicked", () => {
renderWithProviders(<UserMenuPopover isCollapsed={false} />);
// Click the user menu button
const userMenuButton = screen.getByRole("button", { name: /user menu/i });
fireEvent.click(userMenuButton);
// Check if the popover is open
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Click again to close
fireEvent.click(userMenuButton);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
});

View file

@ -13,24 +13,24 @@ import {
} from "lucide-react";
import { Link as RouterLink } from "react-router-dom";
import { Separator } from "react-aria-components";
import { Link } from "../ui-library/link";
import { Icon } from "../ui-library/icon";
import { Avatar, AvatarBadge } from "../ui-library/avatar";
import { Dialog } from "../ui-library/dialog";
import { Button } from "../ui-library/button";
import { Link } from "@ui/ui-library/link";
import { Icon } from "@ui/ui-library/icon";
import { Avatar, AvatarBadge } from "@ui/ui-library/avatar";
import { Dialog } from "@ui/ui-library/dialog";
import { Button } from "@ui/ui-library/button";
import {
DisclosurePanel,
DisclosureControl,
Disclosure,
} from "../ui-library/disclosure";
} from "@ui/ui-library/disclosure";
import { LinkProps } from "react-aria-components";
import { Popover } from "../ui-library/popover";
import { AvailableIcon } from "../ui-library/icons";
import { Popover } from "@ui/ui-library/popover";
import { AvailableIcon } from "@ui/ui-library/icons";
import { useState, useRef } from "react";
import logo from "../assets/icon.jpg";
import { ThemeSwitcher } from "./ThemeSwitcher";
import { useSession } from "../contexts/SessionContext";
import { Text } from "../ui-library/text";
import { Text } from "@ui/ui-library/text";
type NavLinkItem = {
isActive?: boolean;
} & LinkProps;
@ -156,11 +156,11 @@ export const SideNavigation = ({
isMobileMenuOpen: boolean;
}) => {
const isCollapsable = !isMobileMenuOpen;
const [isCollapsed, setIsCollapsed] = useState(isCollapsable ? false : true);
return (
<div
<nav
aria-label="Main navigation"
className={twMerge(
"group isolate flex flex-col overflow-y-auto overflow-x-hidden bg-navbar-background transition-all duration-300",
"fixed md:relative h-[calc(100vh-2rem)] md:h-screen z-50",
@ -179,6 +179,7 @@ export const SideNavigation = ({
"flex flex-col items-center gap-2 w-full",
isCollapsed ? "justify-center" : ""
)}
aria-label="Home"
>
<img
src={logo}
@ -202,6 +203,10 @@ export const SideNavigation = ({
variant="plain"
isIconOnly
onPress={() => setIsCollapsed(!isCollapsed)}
aria-label={
isCollapsed ? "Expand navigation" : "Collapse navigation"
}
aria-expanded={!isCollapsed}
className={twMerge(
isCollapsed ? "relative" : "absolute top-2 right-2",
"size-5 p-1",
@ -213,7 +218,9 @@ export const SideNavigation = ({
"hover:scale-110"
)}
>
<Icon>{isCollapsed ? <PlusIcon /> : <MinusIcon />}</Icon>
<Icon aria-hidden="true">
{isCollapsed ? <PlusIcon /> : <MinusIcon />}
</Icon>
</Button>
)}
</div>
@ -226,7 +233,7 @@ export const SideNavigation = ({
>
<UserMenuPopover isCollapsed={isCollapsed} />
</div>
</div>
</nav>
);
};
@ -258,10 +265,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
icon: <ConstructionIcon className="w-5 h-5" />,
},
];
return (
<nav className="flex flex-1 flex-col">
<nav className="flex flex-1 flex-col" aria-label="Primary navigation">
<ul
role="list"
className={twMerge(
"grid gap-y-1 py-3",
isCollapsed ? "pl-2.5 pr-3" : ""
@ -270,14 +277,18 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
{navItems.map(({ path, label, icon }) => (
<li key={label}>
<NavLink>
<RouterLink to={path} className="w-full">
<RouterLink
to={path}
className="w-full"
aria-label={isCollapsed ? label : undefined}
>
<div
className={twMerge(
"flex items-center gap-x-2",
isCollapsed ? "" : "pl-2"
)}
>
<Icon>{icon}</Icon>
<Icon aria-hidden="true">{icon}</Icon>
<span
className={twMerge(
"text-sm transition-all duration-300",
@ -293,6 +304,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
))}
</ul>
<ul
role="list"
className={twMerge(
"mt-auto grid gap-y-1 py-1",
isCollapsed ? "pl-2.5 pr-3" : ""
@ -303,9 +315,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
<RouterLink
to="/"
className={twMerge("w-full", isCollapsed ? "" : "pl-2")}
aria-label={isCollapsed ? "Support" : undefined}
>
<div className="flex items-center gap-x-2">
<HelpCircleIcon className="w-5 h-5" />
<Icon aria-hidden="true">
<HelpCircleIcon className="w-5 h-5" />
</Icon>
<span
className={twMerge(
"text-sm transition-all duration-300",
@ -323,9 +338,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
<RouterLink
to="/"
className={twMerge("w-full", isCollapsed ? "" : "pl-2")}
aria-label={isCollapsed ? "Feedback" : undefined}
>
<div className="flex items-center gap-x-2">
<SendIcon className="w-5 h-5" />
<Icon aria-hidden="true">
<SendIcon className="w-5 h-5" />
</Icon>
<span
className={twMerge(
"text-sm transition-all duration-300",

View file

@ -0,0 +1,145 @@
import { render, screen, waitFor } from "@testing-library/react";
import { ProtectedRoute } from "@ui/components/ProtectedRoute";
import { SessionProvider } from "@ui/contexts/SessionContext";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import * as SessionContext from "@ui/contexts/SessionContext";
import { Session } from "@supabase/supabase-js";
import { vi } from "vitest";
describe("ProtectedRoute", () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
});
it("shows loading state initially", () => {
render(
<BrowserRouter>
<SessionProvider>
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/" element={<div>Protected Content</div>} />
</Route>
</Routes>
</SessionProvider>
</BrowserRouter>
);
// Check if loading spinner is present
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("redirects to login when user is not authenticated", async () => {
render(
<BrowserRouter>
<SessionProvider>
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/" element={<div>Protected Content</div>} />
</Route>
<Route path="/login" element={<div>Login Page</div>} />
</Routes>
</SessionProvider>
</BrowserRouter>
);
// Wait for the loading state to finish
await waitFor(() => {
expect(screen.getByText("Login Page")).toBeInTheDocument();
});
});
it("redirects to landing page for first-time users", async () => {
// Set up first-time user scenario
localStorage.removeItem("xtablo-has-seen-landing-page");
render(
<BrowserRouter>
<SessionProvider>
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/" element={<div>Protected Content</div>} />
</Route>
<Route path="/landing" element={<div>Landing Page</div>} />
</Routes>
</SessionProvider>
</BrowserRouter>
);
// Wait for the loading state to finish
await waitFor(() => {
expect(screen.getByText("Landing Page")).toBeInTheDocument();
});
});
it("renders protected content when user is authenticated", async () => {
// Mock authenticated session
const mockSession: Session = {
access_token: "test-token",
refresh_token: "test-refresh-token",
expires_in: 3600,
token_type: "bearer",
user: {
id: "123",
app_metadata: {},
user_metadata: {
full_name: "Test User",
email: "test@example.com",
email_verified: true,
first_name: "Test",
last_name: "User",
business_name: "Test Business",
},
aud: "authenticated",
created_at: new Date().toISOString(),
email: "test@example.com",
role: "authenticated",
updated_at: new Date().toISOString(),
},
};
vi.spyOn(SessionContext, "useSession").mockImplementation(() => ({
session: mockSession,
}));
render(
<BrowserRouter>
<SessionProvider>
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/" element={<div>Protected Content</div>} />
</Route>
</Routes>
</SessionProvider>
</BrowserRouter>
);
// Wait for the loading state to finish
await waitFor(() => {
expect(screen.getByText("Protected Content")).toBeInTheDocument();
});
});
it("uses custom fallback route when provided", async () => {
render(
<BrowserRouter>
<SessionProvider>
<Routes>
<Route element={<ProtectedRoute fallback="/custom-login" />}>
<Route path="/" element={<div>Protected Content</div>} />
</Route>
<Route
path="/custom-login"
element={<div>Custom Login Page</div>}
/>
</Routes>
</SessionProvider>
</BrowserRouter>
);
// Wait for the loading state to finish
await waitFor(() => {
expect(screen.getByText("Custom Login Page")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,87 @@
import { render, screen, waitFor } from "@testing-library/react";
import { PublicRoute } from "@ui/components/PublicRoute";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import * as SessionContext from "@ui/contexts/SessionContext";
import { Session } from "@supabase/supabase-js";
import { vi } from "vitest";
describe("PublicRoute", () => {
it("shows loading state initially", () => {
render(
<BrowserRouter>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
</Routes>
</BrowserRouter>
);
// Check if loading spinner is present
expect(screen.getByRole("status")).toBeInTheDocument();
});
it("redirects to home when user is authenticated", async () => {
// Mock authenticated session
const mockSession: Session = {
access_token: "test-token",
refresh_token: "test-refresh-token",
expires_in: 3600,
token_type: "bearer",
user: {
id: "123",
app_metadata: {},
user_metadata: {
full_name: "Test User",
email: "test@example.com",
email_verified: true,
first_name: "Test",
last_name: "User",
business_name: "Test Business",
},
aud: "authenticated",
created_at: new Date().toISOString(),
email: "test@example.com",
role: "authenticated",
updated_at: new Date().toISOString(),
},
};
vi.spyOn(SessionContext, "useSession").mockImplementation(() => ({
session: mockSession,
}));
render(
<BrowserRouter>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</BrowserRouter>
);
// Wait for the loading state to finish
await waitFor(() => {
expect(screen.getByText("Home Page")).toBeInTheDocument();
});
});
it("renders public content when user is not authenticated", async () => {
render(
<BrowserRouter>
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<div>Login Page</div>} />
</Route>
</Routes>
</BrowserRouter>
);
// Wait for the loading state to finish
await waitFor(() => {
expect(screen.getByText("Login Page")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,61 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { SignOutButton } from "@ui/components/SignOutButton";
import * as AuthHooks from "@ui/hooks/auth";
import { UseMutationResult } from "@tanstack/react-query";
import { vi } from "vitest";
// Create a mock mutation result
const createMockMutationResult = (
mutate: ReturnType<typeof vi.fn>
): UseMutationResult<void, Error, void, unknown> => {
return {
mutate,
data: undefined,
error: null,
isError: false,
isPending: false,
isSuccess: false,
variables: undefined,
reset: vi.fn(),
status: "idle",
failureCount: 0,
failureReason: null,
isPaused: false,
isPlaceholderData: false,
fetchStatus: "idle",
isIdle: true,
context: undefined,
submittedAt: 0,
} as unknown as UseMutationResult<void, Error, void, unknown>;
};
// Mock the useLogout hook
vi.mock("../../hooks/auth", () => ({
useLogout: () => createMockMutationResult(vi.fn()),
}));
describe("SignOutButton", () => {
it("renders the sign out button", () => {
render(<SignOutButton />);
// Check if the button is rendered with correct text
expect(
screen.getByRole("button", { name: /se déconnecter/i })
).toBeInTheDocument();
});
it("calls logout function when clicked", () => {
const mockLogout = vi.fn();
vi.spyOn(AuthHooks, "useLogout").mockImplementation(() =>
createMockMutationResult(mockLogout)
);
render(<SignOutButton />);
// Click the button
fireEvent.click(screen.getByRole("button", { name: /se déconnecter/i }));
// Check if logout was called
expect(mockLogout).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,44 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { ThemeSwitcher } from "@ui/components/ThemeSwitcher";
import * as ThemeContext from "@ui/contexts/ThemeContext";
import { vi } from "vitest";
// Mock the ThemeProvider
vi.mock("../../contexts/ThemeContext", () => ({
...vi.importActual("../../contexts/ThemeContext"),
ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
useTheme: () => ({
theme: "light",
setTheme: vi.fn(),
}),
}));
describe("ThemeSwitcher", () => {
it("renders the theme switcher with correct initial theme", () => {
render(<ThemeSwitcher />);
// Check if the current theme text is displayed
expect(screen.getByText("Thème: Clair")).toBeInTheDocument();
// Check if all theme buttons are present
expect(screen.getByRole("button", { name: /light/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /system/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /dark/i })).toBeInTheDocument();
});
it("changes theme when a different theme button is clicked", () => {
const setTheme = vi.fn();
vi.spyOn(ThemeContext, "useTheme").mockImplementation(() => ({
theme: "light",
setTheme,
}));
render(<ThemeSwitcher />);
// Click the dark theme button
fireEvent.click(screen.getByRole("button", { name: /dark/i }));
// Verify that setTheme was called with 'dark'
expect(setTheme).toHaveBeenCalledWith("dark");
});
});

View file

@ -1,4 +1,4 @@
import { Button } from "../ui-library/button";
import { Button } from "@ui/ui-library/button";
import { twMerge } from "tailwind-merge";
import logo from "../assets/icon.jpg";
import { Link } from "react-router-dom";

View file

@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState } from "react";
import { Session } from "@supabase/supabase-js";
import { supabase } from "../hooks/auth";
import { Session, User } from "@supabase/supabase-js";
import { supabase } from "@ui/hooks/auth";
const SessionContext = createContext<{
session: Session | null;
@ -16,9 +16,19 @@ export const useSession = () => {
return context;
};
type Props = { children: React.ReactNode };
export const SessionProvider = ({ children }: Props) => {
const [session, setSession] = useState<Session | null>(null);
type Props = { children: React.ReactNode; testUser?: User };
export const SessionProvider = ({ children, testUser }: Props) => {
const [session, setSession] = useState<Session | null>(
testUser
? {
user: testUser,
access_token: "test_access_token",
refresh_token: "test_refresh_token",
expires_in: 3600,
token_type: "Bearer",
}
: null
);
useEffect(() => {
const authStateListener = supabase.auth.onAuthStateChange(

View file

@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { match } from "ts-pattern";
import { toast } from "../ui-library/toast/toast-queue";
import { toast } from "@ui/ui-library/toast/toast-queue";
import {
User as SupabaseUser,
Session,

View file

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { api } from "@ui/lib/api";
interface UserMetadata {
email: string;

View file

@ -1,6 +1,6 @@
import axios from "axios";
import { QueryClient } from "@tanstack/react-query";
import { IS_DEV } from "../config";
import { IS_DEV } from "@ui/config";
// Create axios instance with default config
export const api = axios.create({

View file

@ -1,5 +1,5 @@
import { twMerge } from "tailwind-merge";
import { Button } from "../ui-library/button";
import { Button } from "@ui/ui-library/button";
import { useNavigate } from "react-router-dom";
export const NotFoundPage = () => {

View file

@ -1,5 +1,5 @@
import { Button } from "../ui-library/button";
import { PlusIcon } from "../ui-library/icons";
import { Button } from "@ui/ui-library/button";
import { PlusIcon } from "@ui/ui-library/icons";
import {
AllCommunityModule,
ModuleRegistry,

View file

@ -1,8 +1,8 @@
import { Button } from "../ui-library/button";
import { Button } from "@ui/ui-library/button";
import { twMerge } from "tailwind-merge";
import logo from "../assets/icon.jpg";
import { Header } from "../components/header";
import { Header } from "@ui/components/header";
export const LandingPage = () => {
localStorage.setItem("xtablo-has-seen-landing-page", "true");

View file

@ -1,10 +1,10 @@
import { Button } from "../ui-library/button";
import { Button } from "@ui/ui-library/button";
import { twMerge } from "tailwind-merge";
import { useState } from "react";
import { Label, Input, TextField, FieldError } from "../ui-library/field";
import { useLoginEmail } from "../hooks/auth";
import { Form } from "../ui-library/form";
import { LoginWithGoogle } from "../components/BrandButtons/LoginWithGoogle";
import { Label, Input, TextField, FieldError } from "@ui/ui-library/field";
import { useLoginEmail } from "@ui/hooks/auth";
import { Form } from "@ui/ui-library/form";
import { LoginWithGoogle } from "@ui/components/BrandButtons/LoginWithGoogle";
import { Link } from "react-router-dom";
export function LoginPage() {

View file

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useSession } from "../contexts/SessionContext";
import { useSession } from "@ui/contexts/SessionContext";
export const OAuthSigninPage = () => {
const navigate = useNavigate();

View file

@ -1,10 +1,10 @@
import { Button } from "../ui-library/button";
import { Button } from "@ui/ui-library/button";
import { twMerge } from "tailwind-merge";
import { Link, useNavigate } from "react-router-dom";
import { useState } from "react";
import { Label, Input, TextField, FieldError } from "../ui-library/field";
import { Form } from "../ui-library/form";
import { Text } from "../ui-library/text";
import { Label, Input, TextField, FieldError } from "@ui/ui-library/field";
import { Form } from "@ui/ui-library/form";
import { Text } from "@ui/ui-library/text";
export function ResetPasswordPage() {
const navigate = useNavigate();

View file

@ -1,12 +1,12 @@
import { Button } from "../ui-library/button";
import { Button } from "@ui/ui-library/button";
import { twMerge } from "tailwind-merge";
import { Link, useNavigate } from "react-router-dom";
import { useState } from "react";
import { Label, Input, TextField, FieldError } from "../ui-library/field";
import { useSignUp } from "../hooks/auth";
import { Form } from "../ui-library/form";
import { Text } from "../ui-library/text";
import { LoginWithGoogle } from "../components/BrandButtons/LoginWithGoogle";
import { Label, Input, TextField, FieldError } from "@ui/ui-library/field";
import { useSignUp } from "@ui/hooks/auth";
import { Form } from "@ui/ui-library/form";
import { Text } from "@ui/ui-library/text";
import { LoginWithGoogle } from "@ui/components/BrandButtons/LoginWithGoogle";
export function SignUpPage() {
const navigate = useNavigate();

View file

@ -1,5 +1,5 @@
import { SignOutButton } from "../components/SignOutButton";
import { useSession } from "../contexts/SessionContext";
import { SignOutButton } from "@ui/components/SignOutButton";
import { useSession } from "@ui/contexts/SessionContext";
export const TabloPage = () => {
const { session } = useSession();
return (

22
ui/src/setupTests.ts Normal file
View file

@ -0,0 +1,22 @@
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { vi } from "vitest";
// Cleanup after each test case
afterEach(() => {
cleanup();
});
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

View file

@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
import {
ComboBox as RACComboBox,
ComboBoxProps as RACComboBoxProps,
@ -6,20 +6,19 @@ import {
GroupProps,
Group,
composeRenderProps,
} from 'react-aria-components';
import { ButtonProps, Button } from './button';
import { inputField } from './utils';
import { twMerge } from 'tailwind-merge';
} from "react-aria-components";
import { ButtonProps, Button } from "./button";
import { inputField } from "./utils";
import { twMerge } from "tailwind-merge";
import {
SelectListBox,
SelectListItem,
SelectListItemDescription,
SelectListItemLabel,
SelectPopover,
SelectSection,
} from './select';
import { Input } from './field';
import { ChevronDownIcon, XIcon } from './icons';
} from "./select";
import { Input } from "./field";
import { ChevronDownIcon, XIcon } from "./icons";
export function ComboBox(props: RACComboBoxProps<object>) {
return (
@ -27,7 +26,7 @@ export function ComboBox(props: RACComboBoxProps<object>) {
{...props}
data-ui="comboBox"
className={composeRenderProps(props.className, (className) =>
twMerge(['w-full min-w-56', inputField, className]),
twMerge(["w-full min-w-56", inputField, className])
)}
/>
);
@ -40,48 +39,48 @@ export function ComboBoxGroup(props: GroupProps) {
{...props}
className={composeRenderProps(props.className, (className) =>
twMerge([
'group/combobox',
'isolate',
'grid',
'grid-cols-[36px_1fr_minmax(40px,max-content)_minmax(40px,max-content)]',
'sm:grid-cols-[36px_1fr_minmax(36px,max-content)_minmax(36px,max-content)]',
'items-center',
"group/combobox",
"isolate",
"grid",
"grid-cols-[36px_1fr_minmax(40px,max-content)_minmax(40px,max-content)]",
"sm:grid-cols-[36px_1fr_minmax(36px,max-content)_minmax(36px,max-content)]",
"items-center",
// Icon
'sm:[&>[data-ui=icon]:has(+input)]:size-4',
'[&>[data-ui=icon]:has(+input)]:size-5',
'[&>[data-ui=icon]:has(+input)]:row-start-1',
'[&>[data-ui=icon]:has(+input)]:col-start-1',
'[&>[data-ui=icon]:has(+input)]:place-self-center',
'[&>[data-ui=icon]:has(+input)]:text-muted',
'[&>[data-ui=icon]:has(+input)]:z-10',
"sm:[&>[data-ui=icon]:has(+input)]:size-4",
"[&>[data-ui=icon]:has(+input)]:size-5",
"[&>[data-ui=icon]:has(+input)]:row-start-1",
"[&>[data-ui=icon]:has(+input)]:col-start-1",
"[&>[data-ui=icon]:has(+input)]:place-self-center",
"[&>[data-ui=icon]:has(+input)]:text-muted",
"[&>[data-ui=icon]:has(+input)]:z-10",
// Input
'[&>input]:row-start-1',
'[&>input]:col-span-full',
'[&>input:not([class*=pe-])]:pe-10',
'sm:[&>input:not([class*=pe-])]:pe-9',
"[&>input]:row-start-1",
"[&>input]:col-span-full",
"[&>input:not([class*=pe-])]:pe-10",
"sm:[&>input:not([class*=pe-])]:pe-9",
'[&>input:has(+[data-ui=clear]:not(:last-of-type))]:pe-20',
'sm:[&>input:has(+[data-ui=clear]:not(:last-of-type))]:pe-16',
"[&>input:has(+[data-ui=clear]:not(:last-of-type))]:pe-20",
"sm:[&>input:has(+[data-ui=clear]:not(:last-of-type))]:pe-16",
'[&:has([data-ui=icon]+input)>input]:ps-10',
'sm:[&:has([data-ui=icon]+input)>input]:ps-8',
"[&:has([data-ui=icon]+input)>input]:ps-10",
"sm:[&:has([data-ui=icon]+input)>input]:ps-8",
// Trigger button
'*:data-[ui=trigger]:row-start-1',
'*:data-[ui=trigger]:-col-end-1',
'*:data-[ui=trigger]:place-self-center',
"*:data-[ui=trigger]:row-start-1",
"*:data-[ui=trigger]:-col-end-1",
"*:data-[ui=trigger]:place-self-center",
// Clear button
'*:data-[ui=clear]:row-start-1',
'*:data-[ui=clear]:-col-end-2',
'*:data-[ui=clear]:justify-self-end',
'[&>[data-ui=clear]:last-of-type]:-col-end-1',
'[&>[data-ui=clear]:last-of-type]:place-self-center',
"*:data-[ui=clear]:row-start-1",
"*:data-[ui=clear]:-col-end-2",
"*:data-[ui=clear]:justify-self-end",
"[&>[data-ui=clear]:last-of-type]:-col-end-1",
"[&>[data-ui=clear]:last-of-type]:place-self-center",
className,
]),
])
)}
/>
);
@ -110,18 +109,18 @@ export function ComboBoxButton({
export function ComboBoxClearButton({
onPress,
}: {
onPress?: ButtonProps['onPress'];
onPress?: ButtonProps["onPress"];
}) {
const state = React.useContext(ComboBoxStateContext);
return (
<Button
className={twMerge(
'[&:not(:hover)]:text-muted',
'not-last:-me-1',
"[&:not(:hover)]:text-muted",
"not-last:-me-1",
state?.inputValue
? 'visible focus-visible:-outline-offset-2'
: 'invisible',
? "visible focus-visible:-outline-offset-2"
: "invisible"
)}
slot={null}
data-ui="clear"
@ -147,8 +146,6 @@ export const ComboBoxSection = SelectSection;
export const ComboBoxListBox = SelectListBox;
export const ComboBoxListItem = SelectListItem;
export const ComboBoxListItemLabel = SelectListItemLabel;
export const ComboBoxListItemDescription = SelectListItemDescription;

View file

@ -0,0 +1,35 @@
import { render, RenderResult } from "@testing-library/react";
import { SessionProvider } from "@ui/contexts/SessionContext";
import { BrowserRouter } from "react-router-dom";
import { ThemeProvider } from "@ui/contexts/ThemeContext";
import { User } from "@supabase/supabase-js";
export const renderWithProviders = (
ui: React.ReactNode,
opts: { user?: User } = {}
): RenderResult => {
return render(
<BrowserRouter>
<ThemeProvider>
<SessionProvider
testUser={
opts.user ?? {
id: "123",
app_metadata: {},
aud: "test",
created_at: "2021-01-01",
user_metadata: {
first_name: "John",
last_name: "Doe",
avatar_url: "https://example.com/avatar.jpg",
full_name: "John Doe",
},
}
}
>
{ui}
</SessionProvider>
</ThemeProvider>
</BrowserRouter>
);
};

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,13 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@ui/*": ["./src/*"]
}
},
"include": ["src"]
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View file

@ -11,14 +11,16 @@
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"emitDeclarationOnly": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"composite": true
},
"include": ["vite.config.ts"]
}

View file

@ -1,9 +1,21 @@
/// <reference types="vitest" />
import { defineConfig, type PluginOption } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { visualizer } from "rollup-plugin-visualizer";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), visualizer() as PluginOption, tailwindcss()],
plugins: [
react(),
visualizer() as PluginOption,
tailwindcss(),
tsconfigPaths(),
],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/setupTests.ts",
},
});