Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
"react/only-export-components": "off"
}
}
9 changes: 6 additions & 3 deletions frontend/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect } from "react";
import type { Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { I18nextProvider } from "react-i18next";
import { HashRouter } from "react-router-dom";
import "../src/index.css";
import { handlers } from "../src/mocks/handlers";
import { loadBrandingConfig } from "../src/branding/brandingConfig";
Expand Down Expand Up @@ -49,9 +50,11 @@ const preview: Preview = {
}, [i18n, context.globals.locale]);

return (
<I18nextProvider i18n={i18n}>
<Story />
</I18nextProvider>
<HashRouter>
<I18nextProvider i18n={i18n}>
<Story />
</I18nextProvider>
</HashRouter>
);
},
],
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.10",
"react-icons": "^5.7.0",
"react-router-dom": "^7.18.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0"
Expand Down
20 changes: 20 additions & 0 deletions frontend/public/brand-graphic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions frontend/src/_test_utilities/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ReactElement, ReactNode } from "react";
import { render as rtlRender, type RenderOptions } from "@testing-library/react";
import { HashRouter } from "react-router-dom";

// Wraps components under test in app-wide providers
export const AllTheProviders = ({ children }: { children: ReactNode }) => {
return <HashRouter>{children}</HashRouter>;
};

function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
return rtlRender(ui, { wrapper: AllTheProviders, ...options });
}

export * from "@testing-library/react";
export * from "@testing-library/user-event";
export { render };
10 changes: 4 additions & 6 deletions frontend/src/App.tsx → frontend/src/app/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { Outlet } from "react-router-dom";
import { AppSidebar } from "@/components/app-sidebar";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { HomePage } from "@/pages/HomePage";

function App() {
export const Layout = () => {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<HomePage />
<Outlet />
</SidebarInset>
</SidebarProvider>
);
}

export default App;
};
11 changes: 11 additions & 0 deletions frontend/src/app/ProtectedRoute/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { type ReactNode } from "react";

interface ProtectedRouteProps {
children: ReactNode;
}

const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
return <>{children}</>;
};

export default ProtectedRoute;
48 changes: 48 additions & 0 deletions frontend/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createHashRouter, Navigate, RouterProvider } from "react-router-dom";
import ProtectedRoute from "@/app/ProtectedRoute/ProtectedRoute";
import { routerPaths } from "@/app/routerPaths";
import { Layout } from "@/app/Layout";
import { HomePage } from "@/pages/HomePage";
import { Login } from "@/pages/Login/Login";
import { Register } from "@/pages/Register/Register";

const router = createHashRouter([
{
path: routerPaths.LOGIN,
element: (
<ProtectedRoute>
<Login />
</ProtectedRoute>
),
},
{
path: routerPaths.REGISTER,
element: (
<ProtectedRoute>
<Register />
</ProtectedRoute>
),
},
{
path: routerPaths.ROOT,
element: <Layout />,
children: [
{
index: true,
element: (
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
),
},
],
},
{
path: "*",
element: <Navigate to={routerPaths.ROOT} replace />,
},
]);

export default function App() {
return <RouterProvider router={router} />;
}
5 changes: 5 additions & 0 deletions frontend/src/app/routerPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const routerPaths = {
ROOT: "/",
LOGIN: "/login",
REGISTER: "/register",
};
11 changes: 11 additions & 0 deletions frontend/src/auth/auth.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface LoginRequest {
email: string;
password: string;
}

export interface RegisterRequest {
fullName: string;
organization: string;
email: string;
password: string;
}
33 changes: 33 additions & 0 deletions frontend/src/auth/components/AuthLayout/AuthLayout.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AuthLayout } from "./AuthLayout";

const meta = {
component: AuthLayout,
tags: ["autodocs"],
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AuthLayout>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
children: (
<div className="grid gap-4">
<div className="grid gap-1">
<h2 className="text-3xl font-bold text-foreground">Form panel</h2>
<p className="text-muted-foreground">Each page renders its content in this slot.</p>
</div>
<div className="flex h-12 items-center rounded-card border border-border bg-card px-4 text-sm text-muted-foreground">
Field
</div>
<div className="flex h-12 items-center rounded-card border border-border bg-card px-4 text-sm text-muted-foreground">
Field
</div>
<div className="flex h-12 items-center justify-center rounded-pill bg-tabiya-green font-semibold text-tabiya-blue">
Action
</div>
</div>
),
},
};
50 changes: 50 additions & 0 deletions frontend/src/auth/components/AuthLayout/AuthLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AuthLayout, DATA_TEST_ID } from "./AuthLayout";

describe("AuthLayout", () => {
describe("Render tests", () => {
it("should render the brand panel copy from the real translations", () => {
// GIVEN the layout with slot content
// WHEN it is rendered
render(
<AuthLayout>
<div>form slot</div>
</AuthLayout>
);

// THEN the brand headline, subcopy, and footer are present
expect(screen.getByText("The dashboard behind every deployment.")).toBeInTheDocument();
expect(screen.getByText(/how engaged they are/)).toBeInTheDocument();
expect(screen.getByText("Open-source digital public infrastructure for jobs.")).toBeInTheDocument();
});

it("should render the provided form content in the form panel", () => {
// GIVEN slot content
// WHEN rendered
render(
<AuthLayout>
<button>Sign in</button>
</AuthLayout>
);

// THEN the slot content appears inside the form panel
const formPanel = screen.getByTestId(DATA_TEST_ID.FORM_PANEL);
expect(formPanel).toContainElement(screen.getByRole("button", { name: "Sign in" }));
});

it("should render both the brand and form panels", () => {
// GIVEN the layout
// WHEN rendered
render(
<AuthLayout>
<div>form</div>
</AuthLayout>
);

// THEN both structural panels exist
expect(screen.getByTestId(DATA_TEST_ID.BRAND_PANEL)).toBeInTheDocument();
expect(screen.getByTestId(DATA_TEST_ID.FORM_PANEL)).toBeInTheDocument();
});
});
});
54 changes: 54 additions & 0 deletions frontend/src/auth/components/AuthLayout/AuthLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { getAppName, getLogoInverseUrl } from "@/branding/brandingConfig";

const uniqueId = "2dce0a1d-275e-4a46-952a-d9193ddd1c15";

export const DATA_TEST_ID = {
CONTAINER: `auth-layout-container-${uniqueId}`,
BRAND_PANEL: `auth-layout-brand-panel-${uniqueId}`,
FORM_PANEL: `auth-layout-form-panel-${uniqueId}`,
};

export interface AuthLayoutProps {
children: ReactNode;
}

export function AuthLayout({ children }: Readonly<AuthLayoutProps>) {
const { t } = useTranslation();

return (
<div className="flex min-h-screen flex-col lg:flex-row" data-testid={DATA_TEST_ID.CONTAINER}>
<section
data-testid={DATA_TEST_ID.BRAND_PANEL}
className="relative flex flex-col overflow-hidden bg-tabiya-blue px-6 py-10 text-white sm:px-10 sm:py-12 lg:w-1/2 lg:px-14 lg:py-16"
>
<img src={getLogoInverseUrl()} alt={getAppName()} className="relative z-10 mx-auto h-7 w-auto sm:h-8 lg:mx-0" />

<div className="relative z-10 mt-10 max-w-xl lg:mt-auto">
<p className="font-mono text-xs text-tabiya-green uppercase">{t("auth.brand.eyebrow")}</p>
<h1 className="mt-5 text-4xl font-bold tracking-tight text-balance sm:text-5xl">
{t("auth.brand.headline")}
</h1>
<p className="mt-5 max-w-md text-base leading-relaxed text-white/70 sm:text-lg">{t("auth.brand.subcopy")}</p>
</div>

<p className="relative z-10 mt-8 text-sm text-white/50 lg:mt-auto lg:pt-14">{t("auth.brand.footer")}</p>

<img
src="/brand-graphic.svg"
alt="brand graphic"
aria-hidden="true"
className="pointer-events-none absolute -right-16 top-10 w-[280px] opacity-50 sm:w-[400px] lg:w-[460px]"
/>
</section>

<section
data-testid={DATA_TEST_ID.FORM_PANEL}
className="flex flex-1 items-center justify-center bg-tabiya-grey px-6 py-12 sm:px-10 lg:w-1/2 lg:py-16 lg:px-20"
>
<div className="w-full max-w-md">{children}</div>
</section>
</div>
);
}
40 changes: 40 additions & 0 deletions frontend/src/auth/components/Field/Field.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Mail } from "lucide-react";
import { Field } from "./Field";

const meta = {
component: Field,
tags: ["autodocs"],
parameters: { layout: "centered" },
decorators: [
(Story) => (
<div className="w-80">
<Story />
</div>
),
],
args: {
id: "email",
label: "Email",
placeholder: "you@partner.org",
type: "email",
icon: <Mail />,
},
} satisfies Meta<typeof Field>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const WithValue: Story = {
args: { defaultValue: "you@partner.org" },
};

export const WithError: Story = {
args: { error: "Enter a valid email address." },
};

export const VisibleLabel: Story = {
args: { labelHidden: false },
};
Loading
Loading