diff --git a/frontend/src/Link.tsx b/frontend/src/Link.tsx index 030422a0..00a36c1e 100644 --- a/frontend/src/Link.tsx +++ b/frontend/src/Link.tsx @@ -1,4 +1,4 @@ -import { Anchor } from "@mantine/core"; +import { Anchor, AnchorProps } from "@mantine/core"; import { useRemails } from "./hooks/useRemails.ts"; import { RouteParams } from "./router.ts"; import { RouteName } from "./routes.ts"; @@ -8,18 +8,23 @@ interface LinkProps { params?: RouteParams; underline?: "always" | "hover" | "never"; children: React.ReactNode; + style?: AnchorProps; } -export function Link({ to, params, underline, children }: LinkProps) { - const { navigate } = useRemails(); +export function Link({ to, params, underline, children, style }: LinkProps) { + const { navigate, routeToPath } = useRemails(); const onClick = (e: React.MouseEvent) => { + if (e.defaultPrevented || e.ctrlKey || e.metaKey) { + return; + } + e.preventDefault(); navigate(to, params); }; return ( - + {children} ); diff --git a/frontend/src/Login.tsx b/frontend/src/Login.tsx index 49301527..e5074601 100644 --- a/frontend/src/Login.tsx +++ b/frontend/src/Login.tsx @@ -23,6 +23,14 @@ interface LoginProps { setUser: (user: User | null) => void; } +type LoginType = "login" | "register" | "reset_password"; + +const BUTTON_NAMES: { [type in LoginType]: string } = { + login: "Login", + register: "Register", + reset_password: "Reset password", +}; + export default function Login({ setUser }: LoginProps) { const { state: { routerState }, @@ -30,24 +38,11 @@ export default function Login({ setUser }: LoginProps) { redirect, } = useRemails(); - let type: "login" | "register" | "reset_password" = "login"; - let buttonName = "Login"; - - switch (routerState.params.type) { - case "register": - type = "register"; - buttonName = "Register"; - break; - case "reset_password": - type = "reset_password"; - buttonName = "Reset password"; - break; - } + const type = routerState.params.type in BUTTON_NAMES ? (routerState.params.type as LoginType) : "login"; + const buttonName = BUTTON_NAMES[type]; const [globalError, setGlobalError] = useState(null); - const xIcon = ; - const form = useForm({ initialValues: { email: "", @@ -193,7 +188,7 @@ export default function Login({ setUser }: LoginProps) { /> )} - {globalError && {globalError}} + {globalError && }>{globalError}} diff --git a/frontend/src/components/EditButton.tsx b/frontend/src/components/EditButton.tsx index 6f775dcb..90dbcd96 100644 --- a/frontend/src/components/EditButton.tsx +++ b/frontend/src/components/EditButton.tsx @@ -5,12 +5,15 @@ import { RouteName } from "../routes.ts"; import { RouteParams } from "../router.ts"; export default function EditButton({ route, params }: { route: RouteName; params: RouteParams }) { - const { navigate } = useRemails(); + const { navigate, routeToPath } = useRemails(); return ( + ); } diff --git a/frontend/src/layout/NavBar.tsx b/frontend/src/layout/NavBar.tsx index b10b67c7..68d3cae8 100644 --- a/frontend/src/layout/NavBar.tsx +++ b/frontend/src/layout/NavBar.tsx @@ -1,9 +1,42 @@ -import { NavLink } from "@mantine/core"; +import { BoxProps, NavLink as MantineNavLink } from "@mantine/core"; import { IconChartBar, IconGavel, IconServer, IconSettings, IconWorldWww } from "@tabler/icons-react"; import { useRemails } from "../hooks/useRemails.ts"; import { useDisclosure } from "@mantine/hooks"; import { NewOrganization } from "../components/organizations/NewOrganization.tsx"; import OrgDropDown from "./OrgDropDown.tsx"; +import { RouteName } from "../routes.ts"; + +interface NavLinkProps { + label: string; + route: RouteName; + active: boolean; + close: () => void; + leftSection?: React.ReactNode; + style?: BoxProps; +} + +function NavLink({ label, route, active, close, leftSection, style }: NavLinkProps) { + const { navigate, routeToPath } = useRemails(); + + return ( + { + if (e.defaultPrevented || e.ctrlKey || e.metaKey) { + return; + } + + e.preventDefault(); + navigate(route); + close(); + }} + {...style} + /> + ); +} export function NavBar({ close }: { close: () => void }) { const { @@ -20,14 +53,12 @@ export function NavBar({ close }: { close: () => void }) { <> {user.global_role === "admin" && ( } - onClick={() => { - navigate("admin"); - close(); - }} + style={{ mb: "md" }} /> )} @@ -42,41 +73,33 @@ export function NavBar({ close }: { close: () => void }) { } - onClick={() => { - navigate("projects"); - close(); - }} + style={{ mt: "md" }} /> } - onClick={() => { - navigate("domains"); - close(); - }} /> } - onClick={() => { - navigate("statistics"); - close(); - }} /> } - onClick={() => { - navigate("settings"); - close(); - }} /> ); diff --git a/frontend/src/layout/Tabs.tsx b/frontend/src/layout/Tabs.tsx index d2f83114..0be522d9 100644 --- a/frontend/src/layout/Tabs.tsx +++ b/frontend/src/layout/Tabs.tsx @@ -16,6 +16,7 @@ export default function Tabs({ tabs, keepMounted }: { tabs: Tab[]; keepMounted?: const { state: { routerState }, navigate, + routeToPath, } = useRemails(); const default_route = tabs[0].route; @@ -30,7 +31,15 @@ export default function Tabs({ tabs, keepMounted }: { tabs: Tab[]; keepMounted?: {tabs.map((t) => ( - + e.preventDefault()} + size="lg" + value={t.route} + leftSection={t.icon} + key={t.route} + {...{ href: routeToPath(t.route) }} + > {t.name} ))} diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 1609d361..ceda0432 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -151,7 +151,7 @@ export class Router { return null; } - navigate(name: RouteName, params: RouteParams): FullRouterState { + navigate(name: RouteName, params: RouteParams = {}, resetParamCache = true): FullRouterState { const route = this.routes.find((route) => route.name === name); if (!route) { throw new Error(`Route with name ${name} not found`); @@ -162,7 +162,9 @@ export class Router { const query = Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined)); const pathParams = { ...this.pathParamCache, ...query }; - this.pathParamCache = {}; + if (resetParamCache) { + this.pathParamCache = {}; + } path = path.replace(/{(\w+)}/g, (_match, key) => { const value = pathParams[key]; diff --git a/frontend/tests/domains.spec.ts b/frontend/tests/domains.spec.ts index 5ffc833c..6c9498fe 100644 --- a/frontend/tests/domains.spec.ts +++ b/frontend/tests/domains.spec.ts @@ -1,11 +1,11 @@ -import { expect, test } from "../playwright/fixtures.ts"; +import { expect, test } from "./fixtures.ts"; import { createProject, deleteProject, uuidRegex } from "./util.ts"; import { Page } from "@playwright/test"; import { v4 as uuid } from "uuid"; async function toDomains(page: Page) { // Navigate to domains page - await page.locator("a").filter({ hasText: "Domains" }).click(); + await page.getByRole("link", { name: "Domains", exact: true }).click(); // Check we are on the user domains page { @@ -48,7 +48,13 @@ test("basic domain lifecycle", async ({ page }) => { const domain = await createDomain(page); // go to settings - await page.getByRole("table").getByRole("row").filter({ hasText: domain }).getByRole("button").click(); + await page + .getByRole("table") + .getByRole("row") + .filter({ hasText: domain }) + .getByRole("link") + .locator(".tabler-icon.tabler-icon-edit") + .click(); // delete domain await page.getByRole("button", { name: "Delete" }).click(); @@ -75,7 +81,13 @@ test("attach project afterward", async ({ page }) => { const domain = await createDomain(page); // go to settings - await page.getByRole("table").getByRole("row").filter({ hasText: domain }).getByRole("button").click(); + await page + .getByRole("table") + .getByRole("row") + .filter({ hasText: domain }) + .getByRole("link") + .locator(".tabler-icon.tabler-icon-edit") + .click(); // Click dropdown await page.getByRole("textbox", { name: "Usable by" }).click(); @@ -88,7 +100,7 @@ test("attach project afterward", async ({ page }) => { await expect(page.getByText("Domain updated")).toBeVisible(); // go back using the breadcrumbs - await page.getByRole("button", { name: "domains" }).click(); + await page.getByRole("link", { name: "domains", exact: true }).click(); // check table row await expect( diff --git a/frontend/playwright/fixtures.ts b/frontend/tests/fixtures.ts similarity index 96% rename from frontend/playwright/fixtures.ts rename to frontend/tests/fixtures.ts index 53feaa12..32d2da9b 100644 --- a/frontend/playwright/fixtures.ts +++ b/frontend/tests/fixtures.ts @@ -3,7 +3,7 @@ import { test as baseTest } from "@playwright/test"; import fs from "fs"; import path from "path"; -import { createAccount } from "../tests/util.ts"; +import { createAccount } from "./util.ts"; export * from "@playwright/test"; export const test = baseTest.extend({ diff --git a/frontend/tests/organizationSettings.spec.ts b/frontend/tests/organizationSettings.spec.ts index 0754fc7f..97dd45fb 100644 --- a/frontend/tests/organizationSettings.spec.ts +++ b/frontend/tests/organizationSettings.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "../playwright/fixtures"; +import { test, expect } from "./fixtures.ts"; import { test as baseTest } from "@playwright/test"; import { createAccount, uuidRegex } from "./util.ts"; import { Page } from "@playwright/test"; @@ -150,7 +150,7 @@ test("organization API key", async ({ page }) => { await expect(row.getByRole("cell", { name: "Playwright test API key", exact: true })).toBeVisible(); // Open API key details - await row.getByRole("button").locator(".tabler-icon.tabler-icon-edit").click(); + await row.getByRole("link").locator(".tabler-icon.tabler-icon-edit").click(); // Check we are put on the API key details page { diff --git a/frontend/tests/projects.spec.ts b/frontend/tests/projects.spec.ts index 52cbdd0a..d3dae52c 100644 --- a/frontend/tests/projects.spec.ts +++ b/frontend/tests/projects.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "../playwright/fixtures.ts"; +import { expect, test } from "./fixtures.ts"; import { createProject, deleteProject, uuidRegex } from "./util.ts"; test("Project lifecycle", async ({ page }) => { @@ -17,17 +17,13 @@ test("Project lifecycle", async ({ page }) => { } // Back to projects list - await page.locator("a").filter({ hasText: "Projects" }).click(); + await page.getByRole("link", { name: "Projects", exact: true }).click(); // Check the new project is listed await expect(page.getByRole("cell", { name: projectUuid })).toBeVisible(); // click edit button - await page - .getByRole("row", { name: projectUuid }) - .getByRole("button") - .locator(".tabler-icon.tabler-icon-edit") - .click(); + await page.getByRole("row", { name: projectUuid }).getByRole("link").locator(".tabler-icon.tabler-icon-edit").click(); // Check we are on the edit project page { @@ -49,7 +45,7 @@ test("Project lifecycle", async ({ page }) => { await expect(page.getByRole("heading", { name: "renamed project" })).toBeVisible(); // Use breadcrumb to go back to projects list - await page.getByRole("button", { name: "projects" }).click(); + await page.getByRole("link", { name: "projects", exact: true }).click(); // Check the renamed project is listed await expect(page.getByRole("cell", { name: "renamed project" })).toBeVisible(); @@ -57,7 +53,7 @@ test("Project lifecycle", async ({ page }) => { // Back to edit project page await page .getByRole("row", { name: "renamed project" }) - .getByRole("button") + .getByRole("link") .locator(".tabler-icon.tabler-icon-edit") .click(); @@ -109,7 +105,7 @@ test("Credentials lifecycle", async ({ page }) => { await expect(page.getByLabel("Credentials")).toContainText("This is created by Playwright"); // Go to credential edit page - await page.getByRole("table").getByRole("button").locator(".tabler-icon.tabler-icon-edit").click(); + await page.getByRole("table").getByRole("link").locator(".tabler-icon.tabler-icon-edit").click(); // Check we are on the credentials edit page { @@ -125,13 +121,13 @@ test("Credentials lifecycle", async ({ page }) => { await expect(page.getByText("SMTP credential updated")).toBeVisible({ timeout: 10_000 }); // Use breadcrumb to go back to the credentials list - await page.getByRole("button", { name: "credentials" }).click(); + await page.getByRole("link", { name: "credentials", exact: true }).click(); // Ensure updated description is visible await expect(page.getByLabel("Credentials")).toContainText("This is made by Playwright"); // Back to credential edit page - await page.getByRole("table").getByRole("button").locator(".tabler-icon.tabler-icon-edit").click(); + await page.getByRole("table").getByRole("link").locator(".tabler-icon.tabler-icon-edit").click(); // Delete the credential await page.getByRole("button", { name: "Delete" }).click(); diff --git a/frontend/tests/util.ts b/frontend/tests/util.ts index 605469fd..86185684 100644 --- a/frontend/tests/util.ts +++ b/frontend/tests/util.ts @@ -18,7 +18,7 @@ export async function createProject(page: Page): Promise { export async function deleteProject(page: Page) { await page.goto("/"); - await page.getByRole("row").getByRole("button").locator(".tabler-icon.tabler-icon-edit").click(); + await page.getByRole("row").getByRole("link").locator(".tabler-icon.tabler-icon-edit").click(); await page.getByRole("button", { name: "Delete" }).click(); await page.getByRole("button", { name: "Confirm" }).click(); } diff --git a/setup.sh b/setup.sh index aa1c2272..b17b5dff 100755 --- a/setup.sh +++ b/setup.sh @@ -18,5 +18,11 @@ popd cargo sqlx database reset -y cargo sqlx migrate run -# run the full application -cargo run --bin app --features load-fixtures +# load fixtures +psql -h localhost -U remails -w remails < src/fixtures/organizations.sql +psql -h localhost -U remails -w remails < src/fixtures/api_users.sql +psql -h localhost -U remails -w remails < src/fixtures/projects.sql +psql -h localhost -U remails -w remails < src/fixtures/runtime_config.sql + +# run the application +cargo run --bin app