diff --git a/.gitignore b/.gitignore index 16e9cbed7..28cf5727f 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ tests/test_data/rapl/* credentials* .codecarbon.config* scripts/agent-vm.personal.config.sh + +# Vite cache +.vite/ diff --git a/webapp/index.html b/webapp/index.html index 2f6b0bae9..4d8da11fb 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -4,8 +4,10 @@ + + CodeCarbon diff --git a/webapp/public/fonts/Disket-mono_EULA.pdf b/webapp/public/fonts/Disket-mono_EULA.pdf new file mode 100644 index 000000000..026af39e0 Binary files /dev/null and b/webapp/public/fonts/Disket-mono_EULA.pdf differ diff --git a/webapp/public/fonts/DisketMono-Bold.ttf b/webapp/public/fonts/DisketMono-Bold.ttf new file mode 100644 index 000000000..a33bfccb3 Binary files /dev/null and b/webapp/public/fonts/DisketMono-Bold.ttf differ diff --git a/webapp/public/fonts/DisketMono-Regular.ttf b/webapp/public/fonts/DisketMono-Regular.ttf new file mode 100644 index 000000000..b564c60ac Binary files /dev/null and b/webapp/public/fonts/DisketMono-Regular.ttf differ diff --git a/webapp/src/api/mock/data.ts b/webapp/src/api/mock/data.ts index 0d6f0a748..d768e4c95 100644 --- a/webapp/src/api/mock/data.ts +++ b/webapp/src/api/mock/data.ts @@ -4,6 +4,7 @@ import type { ExperimentReport, Organization, OrganizationReport, + OrganizationUser, IProjectToken, RunMetadata, User, @@ -242,6 +243,30 @@ function makeRunRow(args: { }; } +/* + * Fixture timestamps are anchored to the current date rather than hardcoded, so + * the mock always has data inside the dashboards' default 30-day window. They are + * spread across that window so that narrowing the date range visibly changes the + * numbers — which is the point of having a date filter to exercise. + */ +const DAY_MS = 24 * 60 * 60 * 1000; +const daysAgo = (days: number, hour = 10) => { + const d = new Date(Date.now() - days * DAY_MS); + d.setUTCHours(hour, 0, 0, 0); + return d.toISOString(); +}; + +/** Spacing between samples in `makeEmissionSeries`, in seconds. */ +export const EMISSION_INTERVAL_SECONDS = 5 * 60; + +const AT = { + baseline1: daysAgo(20), + baseline2: daysAgo(20, 11), + optimized1: daysAgo(5), + production: daysAgo(2), + tokenLastUsed: daysAgo(1, 8), +}; + // ─── Composed data (built top-down from the aggregate root) ──────────────── const organization = makeOrganization({ @@ -294,7 +319,7 @@ const experimentBaseline = makeExperiment({ projectId: ID.projects.training, name: "Baseline run", description: "First experiment baseline", - timestamp: "2026-04-01T10:00:00Z", + timestamp: AT.baseline1, }); const experimentOptimized = makeExperiment({ @@ -302,7 +327,7 @@ const experimentOptimized = makeExperiment({ projectId: ID.projects.training, name: "Optimized model", description: "Quantized variant", - timestamp: "2026-04-15T10:00:00Z", + timestamp: AT.optimized1, onCloud: true, cloudProvider: "aws", cloudRegion: "eu-west-3", @@ -313,7 +338,7 @@ const experimentProduction = makeExperiment({ projectId: ID.projects.inference, name: "Production rollout", description: "Live inference", - timestamp: "2026-04-20T10:00:00Z", + timestamp: AT.production, onCloud: true, cloudProvider: "gcp", cloudRegion: "europe-west1", @@ -340,7 +365,7 @@ const optimizedReport = makeExperimentReport({ const runBaseline1 = makeRunRow({ runId: ID.runs.baseline1, experimentId: ID.experiments.baseline, - timestamp: "2026-04-01T10:00:00Z", + timestamp: AT.baseline1, emissions: 0.617, energyConsumed: 2.839, durationSeconds: 1800, @@ -349,7 +374,7 @@ const runBaseline1 = makeRunRow({ const runBaseline2 = makeRunRow({ runId: ID.runs.baseline2, experimentId: ID.experiments.baseline, - timestamp: "2026-04-01T11:00:00Z", + timestamp: AT.baseline2, emissions: 0.617, energyConsumed: 2.839, durationSeconds: 1800, @@ -358,7 +383,7 @@ const runBaseline2 = makeRunRow({ const runOptimized1 = makeRunRow({ runId: ID.runs.optimized1, experimentId: ID.experiments.optimized, - timestamp: "2026-04-15T10:00:00Z", + timestamp: AT.optimized1, emissions: 0.567, energyConsumed: 2.345, durationSeconds: 1800, @@ -368,11 +393,63 @@ const ciToken = makeProjectToken({ id: ID.tokens.ci, projectId: ID.projects.training, name: "Local dev token", - lastUsed: "2026-04-30T08:00:00Z", + lastUsed: AT.tokenLastUsed, }); // ─── Exported aggregate (consumed by handlers.ts) ────────────────────────── +/* + * The organization's emission rows, flattened across every run. The backend's + * `/organizations/{id}/sums` filters this table by `emissions.timestamp` and + * aggregates the matches, so the mock does the same rather than returning a + * constant — otherwise a date filter cannot be exercised locally at all. + */ +function organizationEmissionRows(): Emission[] { + return [ + ...makeEmissionSeries({ + runId: ID.runs.baseline1, + samples: 12, + startedAt: new Date(AT.baseline1), + }), + ...makeEmissionSeries({ + runId: ID.runs.baseline2, + samples: 12, + startedAt: new Date(AT.baseline2), + }), + ...makeEmissionSeries({ + runId: ID.runs.optimized1, + samples: 6, + startedAt: new Date(AT.optimized1), + }), + ]; +} + +const round = (n: number, dp = 3) => Number(n.toFixed(dp)); + +/** + * Aggregate the organization's emissions over a date range, the way + * `read_organization_detailed_sums` does. Bounds are inclusive; either may be + * omitted, matching the endpoint's optional query parameters. + */ +export function organizationReportBetween( + start?: Date | null, + end?: Date | null, +): OrganizationReport { + const rows = organizationEmissionRows().filter((e) => { + const t = new Date(e.timestamp).getTime(); + if (start && t < start.getTime()) return false; + if (end && t > end.getTime()) return false; + return true; + }); + return { + name: organization.name, + emissions: round(rows.reduce((a, e) => a + e.emissions_sum, 0)), + energy_consumed: round(rows.reduce((a, e) => a + e.energy_consumed, 0)), + // Each row covers one sampling interval. + duration: rows.length * EMISSION_INTERVAL_SECONDS, + }; +} + export const MOCK = { user: adminUser, @@ -382,9 +459,26 @@ export const MOCK = { [organization.id]: organization, } as Record, report: organizationReport, + /* + * `GET /organizations/{id}/users` returns the backend's + * `OrganizationUser`: a user plus their membership of that organization, + * including `is_admin`. The admin/member split mirrors the two fixture + * users. + */ usersByOrgId: { - [organization.id]: [adminUser, memberUser], - } as Record, + [organization.id]: [ + { + ...adminUser, + organization_id: organization.id, + is_admin: true, + }, + { + ...memberUser, + organization_id: organization.id, + is_admin: false, + }, + ], + } as Record, }, project: { diff --git a/webapp/src/api/mock/handlers.ts b/webapp/src/api/mock/handlers.ts index f2d49b18f..964a9be98 100644 --- a/webapp/src/api/mock/handlers.ts +++ b/webapp/src/api/mock/handlers.ts @@ -1,4 +1,4 @@ -import { ID, MOCK, MockProjectWire } from "./data"; +import { ID, MOCK, MockProjectWire, organizationReportBetween } from "./data"; export type MockResponse = { status: number; body?: unknown }; @@ -27,7 +27,7 @@ const handlers: Handler[] = [ }, // ─── Organizations ───────────────────────────────────────────────────── - ({ pathname, method, body }) => { + ({ pathname, method, searchParams, body }) => { if (method === "GET" && pathname === "/organizations") { return ok(MOCK.organization.list); } @@ -49,7 +49,20 @@ const handlers: Handler[] = [ } const sums = pathname.match(/^\/organizations\/([^/]+)\/sums$/); if (method === "GET" && sums) { - return ok(MOCK.organization.report); + // Honour the same query parameters as the real endpoint, so the + // dashboard's date picker actually changes the figures locally. + const parse = (key: string) => { + const raw = searchParams.get(key); + if (!raw) return null; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? null : d; + }; + return ok( + organizationReportBetween( + parse("start_date"), + parse("end_date"), + ), + ); } const users = pathname.match(/^\/organizations\/([^/]+)\/users$/); if (method === "GET" && users) { diff --git a/webapp/src/api/organizations.ts b/webapp/src/api/organizations.ts index ede313dee..e064c9b32 100644 --- a/webapp/src/api/organizations.ts +++ b/webapp/src/api/organizations.ts @@ -1,4 +1,4 @@ -import { fetchApi } from "./client"; +import { fetchApi, fetchApiVoid } from "./client"; import { Organization, OrganizationSchema, @@ -42,3 +42,21 @@ export async function createOrganization(organization: { body: JSON.stringify(organization), }); } + +/* + * Add a member to an organization by email address. + * + * The endpoint looks the address up among existing accounts and subscribes it + * to the organization; it does not send an invitation, and it answers with a + * bare status object rather than the member it added, so there is nothing to + * validate and the caller refetches the list. + */ +export async function addOrganizationUser( + organizationId: string, + email: string, +): Promise { + await fetchApiVoid(`/organizations/${organizationId}/add-user`, { + method: "POST", + body: JSON.stringify({ email }), + }); +} diff --git a/webapp/src/api/schemas.ts b/webapp/src/api/schemas.ts index f7fe8772d..e70c6c9a7 100644 --- a/webapp/src/api/schemas.ts +++ b/webapp/src/api/schemas.ts @@ -16,6 +16,20 @@ export const UserSchema = z.object({ }); export type User = z.infer; +/* + * `GET /organizations/{id}/users` returns the backend's `OrganizationUser`: a + * user plus their membership of that organization. `is_admin` is the only place + * the API exposes admin rights, and it is per-organization. + */ +export const OrganizationUserSchema = z.object({ + id: z.string(), + email: z.string(), + name: z.string(), + organization_id: z.string(), + is_admin: z.boolean(), +}); +export type OrganizationUser = z.infer; + // Backend returns snake_case keys (`organization_id`); the rest of the // codebase consumes camelCase (`organizationId`). Zod's `.transform` lets // us validate the wire shape and expose the camelCase shape to the app. @@ -190,7 +204,6 @@ export interface ProjectDashboardProps { selectedRunId: string; onExperimentClick: (experimentId: string) => void; onRunClick: (runId: string) => void; - onSettingsClick: () => void; onRefresh: () => void; isLoading?: boolean; } diff --git a/webapp/src/components/account-menu.tsx b/webapp/src/components/account-menu.tsx new file mode 100644 index 000000000..7344cd173 --- /dev/null +++ b/webapp/src/components/account-menu.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import useSWR from "swr"; + +import { getOrganizations } from "@/api/organizations"; +import { Organization, OrganizationUser, User } from "@/api/schemas"; +import { fetcher } from "@/api/swr"; +import { cn } from "@/helpers/utils"; +import { useModal } from "@/hooks/useModal"; +import CreateOrganizationModal from "./createOrganizationModal"; +import { LogoutIcon } from "./icons/logout-icon"; +import { SettingsIcon } from "./icons/settings-icon"; +import { OrganizationIcon } from "./icons/organization-icon"; +import { PlusIcon } from "./icons/plus-icon"; +import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { MenuItem, MenuPanel } from "./ui/menu"; + +export default function AccountMenu({ + orgs, + selectedOrg, + onSelectOrg, + children, +}: Readonly<{ + orgs: Organization[] | undefined; + selectedOrg: string | null; + onSelectOrg: (organizationId: string) => void; + /** The rail's "Account" item, used as the trigger. */ + children: React.ReactNode; +}>) { + const [open, setOpen] = useState(false); + // Keyed on instead of `open`, so closing the menu does not drop the admin + // lookup below and regroup the organizations as it animates out. + const [hasOpened, setHasOpened] = useState(false); + const navigate = useNavigate(); + const newOrgModal = useModal(); + const [organizationList, setOrganizationList] = useState< + Organization[] | undefined + >(undefined); + + const { data: auth } = useSWR<{ user?: User }>("/auth/check", fetcher, { + revalidateOnFocus: false, + }); + + const list = organizationList ?? orgs; + + // Admin rights are exposed only per organization, on its member list, so + // this asks each in turn. Dashboards the user administers go below the rule. + const userId = auth?.user?.id; + const { data: adminOrgIds } = useSWR( + hasOpened && userId && list && list.length > 0 + ? ["organization-admin", userId, list.map((o) => o.id).join(",")] + : null, + async () => { + const ids = await Promise.all( + (list ?? []).map(async (org) => { + try { + const members: OrganizationUser[] = await fetcher( + `/organizations/${org.id}/users`, + ); + return members.some( + (m) => m.id === userId && m.is_admin, + ) + ? org.id + : null; + } catch { + // A membership that cannot be read counts as non-admin + // rather than failing the whole menu. + return null; + } + }), + ); + return new Set(ids.filter((id): id is string => id !== null)); + }, + { revalidateOnFocus: false }, + ); + + const owned = list?.filter((org) => adminOrgIds?.has(org.id)) ?? []; + const invited = list?.filter((org) => !adminOrgIds?.has(org.id)) ?? []; + + const refreshOrgList = async () => { + setOrganizationList(await getOrganizations()); + }; + + return ( + <> + { + setOpen(next); + if (next) setHasOpened(true); + }} + > + {children} + + {invited.length > 0 && ( +

+ Dashboards you've been invited to +

+ )} + + {invited.map((org) => ( + onSelectOrg(org.id)} + icon={ + + } + > + {org.name} + + ))} + + newOrgModal.open()} + icon={} + > + Add new organization + + +
+ + {owned.map((org) => ( + onSelectOrg(org.id)} + icon={ + + } + > + {org.name} + + ))} + + navigate("/settings")} + icon={} + > + Settings + + + { + window.location.href = `${import.meta.env.VITE_API_URL}/auth/logout`; + }} + icon={ + + } + > + Log out + + + + + + + ); +} diff --git a/webapp/src/components/breadcrumb.tsx b/webapp/src/components/breadcrumb.tsx deleted file mode 100644 index c823c90c8..000000000 --- a/webapp/src/components/breadcrumb.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; -import React from "react"; - -export default function BreadcrumbHeader({ - pathSegments, -}: { - pathSegments: { - title: string; - href: string | null; - }[]; -}) { - return ( - - - {pathSegments.map((segment, index) => { - const isLast = index === pathSegments.length - 1; - const title = segment.title; - const href = segment.href; - return ( - - - {href ? ( - - {title} - - ) : ( - {title} - )} - - {!isLast && } - - ); - })} - - - ); -} diff --git a/webapp/src/components/chart-row.tsx b/webapp/src/components/chart-row.tsx new file mode 100644 index 000000000..f986b9f81 --- /dev/null +++ b/webapp/src/components/chart-row.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/helpers/utils"; + +/* + * Two charts side by side, separated by a rule. + * + * The rules read as one cross across the four charts, so nothing here is spaced + * with margins: the gap around a rule is padding *inside* the cells, which leaves + * the rule running the full height of the row. A margin would end the line early + * and break the cross at its centre. + * + * The inset is a prop rather than something a caller passes through `className`: + * these are arbitrary variants, so a `[&>*:first-child]` rule outranks a `[&>*]` + * one whatever the class order, and an override would reach one cell only. + * + * The cells arrive wrapped in `Suspense` and fragments, which render no DOM node, + * so the inset reaches them through child selectors rather than by the caller + * putting it on each chart. + * + * Below `md` the columns stack and the rule turns horizontal with them. + */ +export default function ChartRow({ + insetTop = false, + insetBottom = false, + className, + children, +}: Readonly<{ + /** Space above the charts, for a row sitting under a rule. */ + insetTop?: boolean; + /** Space below them, for a row sitting above one. */ + insetBottom?: boolean; + className?: string; + children: React.ReactNode; +}>) { + return ( +
*:first-child]` reset outranks the `[&>*]` inset below and + // would silently apply it to one cell only. + "max-md:[&>*:first-child]:pb-10 max-md:[&>*:last-child]:pt-10", + // Side by side: equal space either side of the vertical rule. + "md:[&>*:first-child]:pr-10 md:[&>*:last-child]:pl-10", + "lg:[&>*:first-child]:pr-16 lg:[&>*:last-child]:pl-16", + // Applied to both cells, so the row's own edges stay level. + insetTop && "md:[&>*]:pt-10 lg:[&>*]:pt-16", + insetBottom && "md:[&>*]:pb-10 lg:[&>*]:pb-16", + className, + )} + > + {children} +
+ ); +} diff --git a/webapp/src/components/chart-section.tsx b/webapp/src/components/chart-section.tsx new file mode 100644 index 000000000..09f4e2183 --- /dev/null +++ b/webapp/src/components/chart-section.tsx @@ -0,0 +1,44 @@ +import { cn } from "@/helpers/utils"; + +/* + * A titled block of the dashboard: heading, a line saying what it is for, and + * whatever action belongs to it, above the thing itself. + * + * The charts each drew this by hand inside a `Card`. They share it now, so the + * page reads as one surface with sections on it rather than a wall of boxes, and + * the three headings cannot drift apart. It carries no border or fill of its own: + * the panels behind the redesign are the page, not the card. + */ +export default function ChartSection({ + title, + description, + action, + className, + children, +}: Readonly<{ + title: string; + description?: string; + /** Sits on the title's row, at its end. */ + action?: React.ReactNode; + className?: string; + children: React.ReactNode; +}>) { + return ( +
+
+
+

+ {title} +

+ {action} +
+ {description && ( +

+ {description} +

+ )} +
+ {children} +
+ ); +} diff --git a/webapp/src/components/consumed-energy-gauge.tsx b/webapp/src/components/consumed-energy-gauge.tsx new file mode 100644 index 000000000..0420f299e --- /dev/null +++ b/webapp/src/components/consumed-energy-gauge.tsx @@ -0,0 +1,105 @@ +import { cn } from "@/helpers/utils"; + +/* + * The "Consumed energy" gauge: one SVG whose ring, value and caption are placed + * in the design's own coordinate space, so the geometry lives in the viewBox + * and the gauge scales to whatever box its parent gives it. + * + * The arc is a fixed decorative sweep, not a proportion of the value: these + * metrics are unbounded running totals with no maximum anywhere in the API, so + * there is nothing to take a fraction of. The number in the middle is the data. + * It is drawn only when there is a value — a zero gauge shows bare track, so an + * empty range reads as empty rather than as some amount. + */ + +const VIEWBOX = 199.23; +const CENTER = 99.6152; +const RADIUS = 87.0511; +const STROKE = 25.1281; + +/** 12 o'clock, in the screen degrees used below (0 = 3 o'clock, clockwise). */ +const START_ANGLE = 270; +/** The sweep the previous gauges drew, kept so the rings look unchanged. */ +const ARC_SWEEP = 100; + +const point = (angleDeg: number) => { + const a = (angleDeg * Math.PI) / 180; + return [CENTER + RADIUS * Math.cos(a), CENTER + RADIUS * Math.sin(a)]; +}; + +/** Arc sweeping anti-clockwise from 12 o'clock by `sweep` degrees. */ +function arcPath(sweep: number) { + const [x0, y0] = point(START_ANGLE); + const [x1, y1] = point(START_ANGLE - sweep); + const largeArc = sweep > 180 ? 1 : 0; + // sweep-flag 0 draws anti-clockwise in SVG's y-down coordinate system. + return `M ${x0} ${y0} A ${RADIUS} ${RADIUS} 0 ${largeArc} 0 ${x1} ${y1}`; +} + +export default function ConsumedEnergyGauge({ + value, + label, + className, +}: Readonly<{ + /** The metric's value, shown in the middle of the ring. */ + value: number; + /** Unit caption, e.g. "kWh". */ + label: string; + className?: string; +}>) { + return ( + + + {/* + * The arc is decorative, so it stands for "there is something here" + * rather than for a proportion: at zero there is nothing to mark and + * the ring is left as bare track. + */} + {value > 0 && ( + + )} + {/* + * Figma left-aligns both labels from their own x offsets, with the + * value's baseline box starting at y 75.384 and the caption's at + * 115.769. `dominant-baseline: text-before-edge` makes those the top + * edges, matching how Figma positions the text frames. + */} + + {value} + + + {label} + + + ); +} diff --git a/webapp/src/components/consumed-energy-gauges.tsx b/webapp/src/components/consumed-energy-gauges.tsx new file mode 100644 index 000000000..de4c9a1c9 --- /dev/null +++ b/webapp/src/components/consumed-energy-gauges.tsx @@ -0,0 +1,37 @@ +import ConsumedEnergyGauge from "./consumed-energy-gauge"; +import { cn } from "@/helpers/utils"; + +/* + * The "Consumed energy" gauges: energy, emissions and duration, each a ring with + * its figure and unit. + * + * One component for both dashboards, as with the equivalences beside them — the + * design draws the same three rings on each. The gauges keep their own size and + * wrap when the row runs out of width, which is how the design lays them out (a + * gap between them, not a distribution across the width). + */ +export type Gauge = { + label: string; + value: number; +}; + +export default function ConsumedEnergyGauges({ + gauges, + className, +}: Readonly<{ + gauges: Gauge[]; + className?: string; +}>) { + return ( +
    + {gauges.map((gauge) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/webapp/src/components/create-experiment-modal.tsx b/webapp/src/components/create-experiment-modal.tsx new file mode 100644 index 000000000..a5482e996 --- /dev/null +++ b/webapp/src/components/create-experiment-modal.tsx @@ -0,0 +1,221 @@ +import { useEffect, useRef, useState } from "react"; +import { ClipboardCheck, ClipboardCopy, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { createExperiment } from "@/api/experiments"; +import { Experiment, ExperimentInput } from "@/api/schemas"; +import { Dialog, DialogContent } from "./ui/dialog"; +import { FormField } from "./ui/form-field"; +import { IconButton } from "./ui/icon-button"; +import ModalHeader from "./ui/modal-header"; +import { PrimaryButton } from "./ui/primary-button"; + +/* + * Create an experiment, in the same panel as the Create-project dialog: the two + * are the same object in the design, so they share the shell, the fields and the + * button rather than each describing them. + * + * It has a second state the other does not: once the experiment exists, the + * dialog stays open to hand over its id, since that is what the tracker needs and + * the page never shows it again. + */ +export default function CreateExperimentModal({ + projectId, + isOpen, + onClose, + onExperimentCreated, +}: { + projectId: string; + isOpen: boolean; + onClose: () => void; + onExperimentCreated?: () => void | Promise; +}) { + const [isCopied, setIsCopied] = useState(false); + const copyTimerRef = useRef | null>(null); + const [isSaving, setIsSaving] = useState(false); + const [isCreated, setIsCreated] = useState(false); + const [experimentData, setExperimentData] = useState({ + name: "", + description: "", + on_cloud: false, + project_id: "", + }); + const [createdExperiment, setCreatedExperiment] = + useState(null); + + useEffect(() => { + if (projectId && !experimentData.project_id) { + setExperimentData({ + ...experimentData, + project_id: projectId, + }); + } + }, [projectId, experimentData]); + + useEffect(() => { + return () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }; + }, []); + + const resetForm = () => { + setExperimentData({ + name: "", + description: "", + on_cloud: false, + project_id: projectId, + }); + setIsCreated(false); + setCreatedExperiment(null); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSave = async () => { + if (!experimentData.name.trim()) { + toast.error("Experiment name is required"); + return; + } + + setIsSaving(true); + + try { + const newExperiment = await createExperiment(experimentData); + setCreatedExperiment(newExperiment); + setIsCreated(true); + await onExperimentCreated?.(); + toast.success( + `Experiment ${experimentData.name} created successfully`, + ); + } catch (error) { + console.error("Failed to create experiment:", error); + toast.error("Failed to create experiment"); + } finally { + setIsSaving(false); + } + }; + + const handleCopy = (token: string | undefined) => { + if (!token) return; + navigator.clipboard + .writeText(token) + .then(() => { + setIsCopied(true); + toast.success("Experiment ID copied to clipboard"); + copyTimerRef.current = setTimeout( + () => setIsCopied(false), + 2000, + ); + }) + .catch((err) => { + console.error("Failed to copy experiment id:", err); + toast.error("Failed to copy experiment ID"); + }); + }; + + return ( + + + {!isCreated ? ( + <> + + +
{ + event.preventDefault(); + handleSave(); + }} + > + + setExperimentData({ + ...experimentData, + name: e.target.value, + }) + } + /> + + + setExperimentData({ + ...experimentData, + description: e.target.value, + }) + } + /> + +
+ + {isSaving && ( + + )} + {isSaving + ? "Creating..." + : "Create experiment"} + +
+ + + ) : ( + <> + + +
+
+

+ Id of this experiment +

+
+ + {createdExperiment?.id} + + + handleCopy(createdExperiment?.id) + } + > + {isCopied ? ( + + ) : ( + + )} + +
+
+ +
+ + Done + +
+
+ + )} +
+
+ ); +} diff --git a/webapp/src/components/create-project-modal.tsx b/webapp/src/components/create-project-modal.tsx new file mode 100644 index 000000000..47ee0b7b2 --- /dev/null +++ b/webapp/src/components/create-project-modal.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { toast } from "sonner"; + +import { createProject } from "@/api/projects"; +import { Dialog, DialogContent } from "./ui/dialog"; +import ModalHeader from "./ui/modal-header"; +import { FormField } from "./ui/form-field"; +import { PrimaryButton } from "./ui/primary-button"; + +/* + * Create a project: a dialog holding a name and a description. + * + * The design's fixed 664x524 panel is not reproduced as a size — its own + * numbers do not add up, with the form starting below where the panel ends — so + * the panel keeps its proportion instead, a little wider than tall, and narrows + * below its maximum. + */ + +interface ModalProps { + organizationId: string; + isOpen: boolean; + onClose: () => void; + onProjectCreated: () => Promise; +} + +interface CreateProjectInput { + name: string; + description: string; +} + +const CreateProjectModal: React.FC = ({ + organizationId, + isOpen, + onClose, + onProjectCreated, +}) => { + const [formData, setFormData] = useState({ + name: "", + description: "", + }); + const [isLoading, setIsLoading] = useState(false); + + const handleClose = () => { + // Reset state when closing + setFormData({ name: "", description: "" }); + onClose(); + }; + + const handleSave = async () => { + toast.promise( + async () => { + setIsLoading(true); + try { + const newProject = await createProject( + organizationId, + formData, + ); + await onProjectCreated(); // Call the callback to refresh the project list + handleClose(); // Automatically close the modal after successful creation + return newProject; // Return for the success message + } catch (error) { + console.error("Failed to create project:", error); + throw error; // Rethrow for the error message + } finally { + setIsLoading(false); + } + }, + { + loading: "Creating project...", + success: "Project created successfully!", + error: "Failed to create project", + }, + ); + }; + + return ( + + {/* The design's own close control lives in the header, so the shared + corner button is omitted. */} + + + + {/* The form shares the header's gutter. Its vertical padding is + the panel's main source of air. */} +
{ + event.preventDefault(); + handleSave(); + }} + > + + setFormData({ ...formData, name: e.target.value }) + } + /> + + + setFormData({ + ...formData, + description: e.target.value, + }) + } + /> + + {/* Separated by more than the gap between the fields, so it + reads as the end of the form rather than a third row. */} +
+ + {isLoading ? "Creating..." : "Create project"} + +
+ +
+
+ ); +}; + +export default CreateProjectModal; diff --git a/webapp/src/components/createExperimentModal.tsx b/webapp/src/components/createExperimentModal.tsx deleted file mode 100644 index e189a43d1..000000000 --- a/webapp/src/components/createExperimentModal.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { createExperiment } from "@/api/experiments"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Experiment, ExperimentInput } from "@/api/schemas"; -import { Separator } from "./ui/separator"; -import { ClipboardCheck, ClipboardCopy, Loader2 } from "lucide-react"; -import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; - -export default function CreateExperimentModal({ - projectId, - isOpen, - onClose, - onExperimentCreated, -}: { - projectId: string; - isOpen: boolean; - onClose: () => void; - onExperimentCreated?: () => void | Promise; -}) { - const [isCopied, setIsCopied] = useState(false); - const copyTimerRef = useRef | null>(null); - const [isSaving, setIsSaving] = useState(false); - const [isCreated, setIsCreated] = useState(false); - const [experimentData, setExperimentData] = useState({ - name: "", - description: "", - on_cloud: false, - project_id: "", - }); - const [createdExperiment, setCreatedExperiment] = - useState(null); - - useEffect(() => { - if (projectId && !experimentData.project_id) { - setExperimentData({ - ...experimentData, - project_id: projectId, - }); - } - }, [projectId, experimentData]); - - useEffect(() => { - return () => { - if (copyTimerRef.current) clearTimeout(copyTimerRef.current); - }; - }, []); - - const resetForm = () => { - setExperimentData({ - name: "", - description: "", - on_cloud: false, - project_id: projectId, - }); - setIsCreated(false); - setCreatedExperiment(null); - }; - - const handleClose = () => { - resetForm(); - onClose(); - }; - - const handleSave = async () => { - if (!experimentData.name.trim()) { - toast.error("Experiment name is required"); - return; - } - - setIsSaving(true); - - try { - const newExperiment = await createExperiment(experimentData); - setCreatedExperiment(newExperiment); - setIsCreated(true); - await onExperimentCreated?.(); - toast.success( - `Experiment ${experimentData.name} created successfully`, - ); - } catch (error) { - console.error("Failed to create experiment:", error); - toast.error("Failed to create experiment"); - } finally { - setIsSaving(false); - } - }; - - const handleCopy = (token: string | undefined) => { - if (!token) return; - navigator.clipboard - .writeText(token) - .then(() => { - setIsCopied(true); - toast.success("Experiment ID copied to clipboard"); - copyTimerRef.current = setTimeout( - () => setIsCopied(false), - 2000, - ); - }) - .catch((err) => { - console.error("Failed to copy experiment id:", err); - toast.error("Failed to copy experiment ID"); - }); - }; - - return ( - - - {!isCreated ? ( - <> - - Create new experiment - - -
-
- - - setExperimentData({ - ...experimentData, - name: e.target.value, - }) - } - placeholder="Experiment Name" - /> -
-
- - - setExperimentData({ - ...experimentData, - description: e.target.value, - }) - } - placeholder="Experiment Description" - /> -
-
- - - - - ) : ( - <> - - - Experiment {createdExperiment?.name} Created - - - -
-

Id of this experiment:

-
-
-                                    {createdExperiment?.id}
-                                
- -
-
- - - - - )} -
-
- ); -} diff --git a/webapp/src/components/createProjectModal.tsx b/webapp/src/components/createProjectModal.tsx deleted file mode 100644 index f4ccce028..000000000 --- a/webapp/src/components/createProjectModal.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useState } from "react"; -import { createProject } from "@/api/projects"; -import { Separator } from "./ui/separator"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "./ui/dialog"; -import { toast } from "sonner"; - -interface ModalProps { - organizationId: string; - isOpen: boolean; - onClose: () => void; - onProjectCreated: () => Promise; -} - -interface CreateProjectInput { - name: string; - description: string; -} - -const CreateProjectModal: React.FC = ({ - organizationId, - isOpen, - onClose, - onProjectCreated, -}) => { - const [formData, setFormData] = useState({ - name: "", - description: "", - }); - const [isLoading, setIsLoading] = useState(false); - - const handleSave = async () => { - toast.promise( - async () => { - setIsLoading(true); - try { - const newProject = await createProject( - organizationId, - formData, - ); - await onProjectCreated(); // Call the callback to refresh the project list - handleClose(); // Automatically close the modal after successful creation - return newProject; // Return for the success message - } catch (error) { - console.error("Failed to create project:", error); - throw error; // Rethrow for the error message - } finally { - setIsLoading(false); - } - }, - { - loading: "Creating project...", - success: "Project created successfully!", - error: "Failed to create project", - }, - ); - }; - - const handleClose = () => { - // Reset state when closing - setFormData({ name: "", description: "" }); - onClose(); - }; - - return ( - - - - Create new project - - Fill in the details to create your project - - - -
-
- - - setFormData({ - ...formData, - name: e.target.value, - }) - } - placeholder="Project Name" - /> -
-
- -
-
- - - setFormData({ - ...formData, - description: e.target.value, - }) - } - placeholder="Project Description" - /> -
-
- - - - -
-
- ); -}; - -export default CreateProjectModal; diff --git a/webapp/src/components/date-range-picker.tsx b/webapp/src/components/date-range-picker.tsx index 8f39839d6..685ecfe2b 100644 --- a/webapp/src/components/date-range-picker.tsx +++ b/webapp/src/components/date-range-picker.tsx @@ -14,9 +14,24 @@ import { format } from "date-fns"; interface DateRangePickerProps { date: DateRange; onDateChange: (newDate: DateRange | undefined) => void; + /** + * `default` keeps the outline button used elsewhere in the app. + * `dashboard` matches the design's "Input" component: a 16px + * IBM Plex Mono Regular "Dates" label above a 46px field with a + * rgba(255,255,255,0.05) fill, 2px radius, 16px horizontal padding and a + * #666666 numeric value. The design shows no calendar glyph in the field. + */ + variant?: "default" | "dashboard"; + /** Field label, used by the `dashboard` variant. */ + label?: string; } -export function DateRangePicker({ date, onDateChange }: DateRangePickerProps) { +export function DateRangePicker({ + date, + onDateChange, + variant = "default", + label = "Dates", +}: DateRangePickerProps) { const [open, setOpen] = useState(false); const [tempDateRange, setTempDateRange] = useState( date, @@ -35,8 +50,40 @@ export function DateRangePicker({ date, onDateChange }: DateRangePickerProps) { setOpen(false); }; - return ( - + /* + * The design renders the range as "01/01/2021 - 01/02/2021". A one-month + * span is dd/MM/yyyy (1 Jan to 1 Feb), which also matches this dashboard's + * default 30-day range; read as MM/dd/yyyy it would be a single day. + */ + const formatted = (pattern: string) => + date?.from + ? date.to + ? `${format(date.from, pattern)} - ${format(date.to, pattern)}` + : format(date.from, pattern) + : null; + + const trigger = + variant === "dashboard" ? ( +
+ + + + +
+ ) : ( + ); + + return ( + + {trigger}
- - - Run Metadata - - Hardware and environment details - - - -
-
- - CPU: - - {emissionTimeSeries.metadata.cpu_model} ( - {emissionTimeSeries.metadata.cpu_count} cores) - -
- {emissionTimeSeries.metadata.gpu_model && ( -
- - GPU: - - {emissionTimeSeries.metadata.gpu_model} ( - {emissionTimeSeries.metadata.gpu_count}) - -
- )} + + { + if (!emissionTimeSeries) return; + exportEmissionsTimeSeriesCsv( + emissionTimeSeries, + projectName, + experimentName, + ); + }} + loadingMessage="Exporting time series..." + successMessage="Time series exported successfully" + errorMessage="Failed to export time series" + /> + ) + } + > +
+ {Object.keys(chartConfig).map((key) => { + const chart = key as keyof typeof chartConfig; + return ( + + ); + })} +
+ + + + + format(new Date(value), tickFmt) + } + /> + + { + const tooltipPayload = payload as + | TimeSeriesTooltipPayload + | undefined; + const point = + tooltipPayload?.[0]?.payload; + + if (!point) { + return ""; + } + + return format( + new Date(point.ts), + "MMM d, yyyy HH:mm:ss", + ); + }} + /> + } + /> + + + +
+ +
+
+ + CPU: + + {emissionTimeSeries.metadata.cpu_model} ( + {emissionTimeSeries.metadata.cpu_count} cores) + +
+ {emissionTimeSeries.metadata.gpu_model && (
- - RAM: + + GPU: - {emissionTimeSeries.metadata.ram_total_size} GB + {emissionTimeSeries.metadata.gpu_model} ( + {emissionTimeSeries.metadata.gpu_count})
-
- OS:{" "} - {emissionTimeSeries.metadata.os} -
-
- Python:{" "} - {emissionTimeSeries.metadata.python_version} -
-
- Region:{" "} - {emissionTimeSeries.metadata.region} -
+ )} +
+ + RAM: + + {emissionTimeSeries.metadata.ram_total_size} GB +
- - - - -
-
-
- Emissions Time Series - - Showing emissions rate and energy consumed - over time - -
- {!isPublicView && ( - { - if (!emissionTimeSeries) return; - exportEmissionsTimeSeriesCsv( - emissionTimeSeries, - projectName, - experimentName, - ); - }} - loadingMessage="Exporting time series..." - successMessage="Time series exported successfully" - errorMessage="Failed to export time series" - /> - )} -
+
+ OS:{" "} + {emissionTimeSeries.metadata.os}
-
- {Object.keys(chartConfig).map((key) => { - const chart = key as keyof typeof chartConfig; - return ( - - ); - })} +
+ Python:{" "} + {emissionTimeSeries.metadata.python_version}
- - - - - - - format(new Date(value), tickFmt) - } - /> - - { - const tooltipPayload = payload as - | TimeSeriesTooltipPayload - | undefined; - const point = - tooltipPayload?.[0]?.payload; - - if (!point) { - return ""; - } - - return format( - new Date(point.ts), - "MMM d, yyyy HH:mm:ss", - ); - }} - /> - } - /> - - - - - -
+
+ Region:{" "} + {emissionTimeSeries.metadata.region} +
+
+ + ); } diff --git a/webapp/src/components/equivalence-list.tsx b/webapp/src/components/equivalence-list.tsx new file mode 100644 index 000000000..d6ceda295 --- /dev/null +++ b/webapp/src/components/equivalence-list.tsx @@ -0,0 +1,113 @@ +import { cn } from "@/helpers/utils"; + +/* + * The "Equal to" list: an icon, the figure it stands for, and a caption saying + * what the figure means. + * + * One component for both dashboards, since the design draws the same item in + * each. Only the direction differs: the global dashboard spreads them across the + * width of its section, and the project dashboard stacks them in a column beside + * its gauges. That is the `direction` prop, and it is the only thing a caller + * decides — an item always looks the same. + * + * The captions wrap to two lines at the measure the design gives them, so the + * column keeps its shape rather than stretching to the longest caption. + */ +export type Equivalence = { + icon: string; + alt: string; + value: string; + caption: string; +}; + +/* + * The icon, wording and unit for each equivalence, declared once. + * + * Both dashboards compute these from the same helpers, so they must read the + * same. They did not: the two pages had drifted to different captions for the + * same number, and one of them rendered kilometres with no unit at all. + * + * The caption for the first is per-capita emissions, not household energy — that + * is what `getEquivalentCitizenPercentage` divides by (a US citizen's yearly + * CO2e, over 52 weeks). The design's own copy says "an american household weekly + * energy consumption", which describes neither half of that. + */ +export function equivalences({ + citizen, + transportation, + tvTime, +}: { + /** Percentage of a citizen's weekly emissions, already rounded. */ + citizen: string; + /** Kilometres, already rounded. */ + transportation: string; + /** Days, already rounded. */ + tvTime: string; +}): Equivalence[] { + return [ + { + icon: "/icons/household_consumption.svg", + alt: "Household consumption icon", + value: `${citizen}%`, + caption: "Of a U.S. citizen's weekly emissions", + }, + { + icon: "/icons/transportation.svg", + alt: "Transportation icon", + value: `${transportation} km`, + caption: "Kilometers ridden", + }, + { + icon: "/icons/tv.svg", + alt: "TV icon", + value: `${tvTime} days`, + caption: "Of watching TV", + }, + ]; +} + +export default function EquivalenceList({ + items, + direction = "row", + className, +}: Readonly<{ + items: Equivalence[]; + direction?: "row" | "column"; + className?: string; +}>) { + return ( +
    + {items.map((item) => ( +
  • + {item.alt} +
    +

    + {item.value} +

    +

    + {item.caption} +

    +
    +
  • + ))} +
+ ); +} diff --git a/webapp/src/components/experiment-bar-chart.tsx b/webapp/src/components/experiment-bar-chart.tsx index e8a6d0920..739b88daf 100644 --- a/webapp/src/components/experiment-bar-chart.tsx +++ b/webapp/src/components/experiment-bar-chart.tsx @@ -1,13 +1,6 @@ import { ExperimentReport } from "@/api/schemas"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { ChartConfig, ChartContainer, @@ -16,6 +9,7 @@ import { } from "@/components/ui/chart"; import { exportExperimentsToCsv } from "@/utils/export"; import { useMemo, useState } from "react"; +import ChartSection from "./chart-section"; import ChartSkeleton from "./chart-skeleton"; import { ExportCsvButton } from "./export-csv-button"; @@ -84,16 +78,11 @@ export default function ExperimentsBarChart({ } return ( - - -
- Project experiment runs - - Click an experiment to see the runs on the chart on the - right - -
- {!isPublicView && ( + + ) + } + > + + {experimentsReportData.length > 0 ? ( + + + value.slice(0, 3)} + /> + } + /> + } + radius={4} + /> + + ) : ( +
+

+ No data available +

+
)} -
- - - {experimentsReportData.length > 0 ? ( - - - value.slice(0, 3)} - /> - - } - /> - } - radius={4} - /> - - ) : ( -
-

- No data available -

-
- )} -
-
-
+ + ); } diff --git a/webapp/src/components/export-csv-button.tsx b/webapp/src/components/export-csv-button.tsx index d480114b4..cce269d0e 100644 --- a/webapp/src/components/export-csv-button.tsx +++ b/webapp/src/components/export-csv-button.tsx @@ -1,5 +1,5 @@ -import { Button } from "@/components/ui/button"; -import { Download, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; +import { DownloadIcon } from "@/components/icons/download-icon"; import { useState } from "react"; import { toast } from "sonner"; import { @@ -45,19 +45,19 @@ export function ExportCsvButton({ - +

Download .csv export

diff --git a/webapp/src/components/icons/account-circle-icon.tsx b/webapp/src/components/icons/account-circle-icon.tsx new file mode 100644 index 000000000..2f18adee2 --- /dev/null +++ b/webapp/src/components/icons/account-circle-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Account in circle icon — a head and shoulders inside a circle. + */ + +export function AccountCircleIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/account-icon.tsx b/webapp/src/components/icons/account-icon.tsx new file mode 100644 index 000000000..88cd957bf --- /dev/null +++ b/webapp/src/components/icons/account-icon.tsx @@ -0,0 +1,62 @@ +import { FigmaIconProps } from "./types"; + +/* + * Account icon — a head and shoulders in a circle, drawn in the same pixel-art + * style as the rail's other icons. + */ + +export function AccountIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/arrow-back-ios-icon.tsx b/webapp/src/components/icons/arrow-back-ios-icon.tsx new file mode 100644 index 000000000..aa4f30d2d --- /dev/null +++ b/webapp/src/components/icons/arrow-back-ios-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Back icon — a chevron pointing left. + */ + +export function ArrowBackIosIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/download-icon.tsx b/webapp/src/components/icons/download-icon.tsx new file mode 100644 index 000000000..aee4d1183 --- /dev/null +++ b/webapp/src/components/icons/download-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Download icon — an arrow pointing down onto a line, drawn in the same + * pixel-art style as the rail's icons. + */ + +export function DownloadIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/global-icon.tsx b/webapp/src/components/icons/global-icon.tsx new file mode 100644 index 000000000..648369142 --- /dev/null +++ b/webapp/src/components/icons/global-icon.tsx @@ -0,0 +1,33 @@ +import { FigmaIconProps } from "./types"; + +/* + * Global icon — a globe, drawn in the same pixel-art style as the rail's other + * icons. + */ + +export function GlobalIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/glossary-icon.tsx b/webapp/src/components/icons/glossary-icon.tsx new file mode 100644 index 000000000..5efda6241 --- /dev/null +++ b/webapp/src/components/icons/glossary-icon.tsx @@ -0,0 +1,39 @@ +import { FigmaIconProps } from "./types"; + +/* + * Glossary icon — an org chart with a smiling robot at the top branching down + * to three nodes, drawn in the same pixel-art style as the rail's other icons. + */ + +export function GlossaryIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/lock-icon.tsx b/webapp/src/components/icons/lock-icon.tsx new file mode 100644 index 000000000..04ab6c302 --- /dev/null +++ b/webapp/src/components/icons/lock-icon.tsx @@ -0,0 +1,23 @@ +import { FigmaIconProps } from "./types"; + +/* + * Lock icon — a closed padlock, drawn in the same pixel-art style as the rail's + * icons. + */ + +export function LockIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/logout-icon.tsx b/webapp/src/components/icons/logout-icon.tsx new file mode 100644 index 000000000..b8d2b1cd4 --- /dev/null +++ b/webapp/src/components/icons/logout-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Log-out icon — an arrow leaving through a doorway, drawn in the same pixel-art + * style as the rail's icons. + */ + +export function LogoutIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/members-icon.tsx b/webapp/src/components/icons/members-icon.tsx new file mode 100644 index 000000000..837ef4eba --- /dev/null +++ b/webapp/src/components/icons/members-icon.tsx @@ -0,0 +1,73 @@ +import { FigmaIconProps } from "./types"; + +/* + * Members icon — three figures side by side, middle one popping out, + * drawn in the same pixel-art style as the rail's other icons. + */ + +export function MembersIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/more-vert-icon.tsx b/webapp/src/components/icons/more-vert-icon.tsx new file mode 100644 index 000000000..cf3b0ecb9 --- /dev/null +++ b/webapp/src/components/icons/more-vert-icon.tsx @@ -0,0 +1,22 @@ +import { FigmaIconProps } from "./types"; + +/* + * Vertical more icon — three dots in a vertical line, pixel-art style. + */ + +export function MoreVertIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/organization-icon.tsx b/webapp/src/components/icons/organization-icon.tsx new file mode 100644 index 000000000..1f9dfc74d --- /dev/null +++ b/webapp/src/components/icons/organization-icon.tsx @@ -0,0 +1,72 @@ +import { FigmaIconProps } from "./types"; + +/* + * Organization icon — an office building. + */ + +export function OrganizationIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/plus-icon.tsx b/webapp/src/components/icons/plus-icon.tsx new file mode 100644 index 000000000..f82fe3574 --- /dev/null +++ b/webapp/src/components/icons/plus-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Plus icon — a plus sign. + */ + +export function PlusIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/projects-icon.tsx b/webapp/src/components/icons/projects-icon.tsx new file mode 100644 index 000000000..23877c57e --- /dev/null +++ b/webapp/src/components/icons/projects-icon.tsx @@ -0,0 +1,35 @@ +import { FigmaIconProps } from "./types"; + +/* + * Projects icon — two magazine files with documents standing in them, a smiley face + * on the front one, drawn in the same pixel-art style as the rail's other icons. + */ + +export function ProjectsIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/refresh-icon.tsx b/webapp/src/components/icons/refresh-icon.tsx new file mode 100644 index 000000000..425f362da --- /dev/null +++ b/webapp/src/components/icons/refresh-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Refresh icon — two arrows chasing each other in a circle, drawn in the same + * pixel-art style as the rail's icons. + */ + +export function RefreshIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/settings-icon.tsx b/webapp/src/components/icons/settings-icon.tsx new file mode 100644 index 000000000..b5c6663f8 --- /dev/null +++ b/webapp/src/components/icons/settings-icon.tsx @@ -0,0 +1,23 @@ +import { FigmaIconProps } from "./types"; + +/* + * Settings icon — a cog, drawn in the same pixel-art style as the rail's icons. + */ + +export function SettingsIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/share-icon.tsx b/webapp/src/components/icons/share-icon.tsx new file mode 100644 index 000000000..8a4487b67 --- /dev/null +++ b/webapp/src/components/icons/share-icon.tsx @@ -0,0 +1,23 @@ +import { FigmaIconProps } from "./types"; + +/* + * Share icon — three nodes joined by lines, drawn in the same pixel-art style as + * the rail's icons. + */ + +export function ShareIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/types.ts b/webapp/src/components/icons/types.ts new file mode 100644 index 000000000..124b7fad1 --- /dev/null +++ b/webapp/src/components/icons/types.ts @@ -0,0 +1,16 @@ +/* + * Single declaration of the contract for every icon in this directory. + * + * An icon takes nothing but a class: it draws with `currentColor` and at the + * size its caller gives it, so state colour and dimensions belong to the + * control around it rather than to the glyph. + * + * Named for where the set comes from: nearly all of these are exported verbatim + * from the Code Carbon Figma file. A few were not in the Figma and were + * designed apart from the rest, but in the same style, and they share this same + * contract. Seeing `FigmaIconProps` in a module is the quiet signal that the + * icon was added during the dashboard redesign. + */ +export type FigmaIconProps = { + className?: string; +}; diff --git a/webapp/src/components/icons/user-single-aim-icon.tsx b/webapp/src/components/icons/user-single-aim-icon.tsx new file mode 100644 index 000000000..89ccf1a04 --- /dev/null +++ b/webapp/src/components/icons/user-single-aim-icon.tsx @@ -0,0 +1,52 @@ +import { FigmaIconProps } from "./types"; + +/* + * User single aim icon — one figure inside a targeting reticle, drawn in + * pixel-art style. + */ + +export function UserSingleAimIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/member-row.tsx b/webapp/src/components/member-row.tsx new file mode 100644 index 000000000..92a3eafb7 --- /dev/null +++ b/webapp/src/components/member-row.tsx @@ -0,0 +1,125 @@ +import { OrganizationUser } from "@/api/schemas"; +import { cn } from "@/helpers/utils"; +import { MoreVertIcon } from "./icons/more-vert-icon"; +import { UserSingleAimIcon } from "./icons/user-single-aim-icon"; +import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { MenuItem, MenuPanel } from "./ui/menu"; +import { TableCell, TableRow } from "./ui/table"; + +/* + * One member of the organization: an avatar, their name and email, their + * standing in it, and a menu of the actions that apply to them. + * + * Unlike a project row this is not a link — there is no member page to open — + * so nothing lights on hover and the overflow menu is its only control. + * + * The avatar is decorative: no API field can fill the design's photo circle, so + * it renders the design's member glyph and the name beside it is the identity. + */ + +/* + * The trigger's padding, and the gap its menu keeps from the glyph. Radix + * anchors to the trigger's whole box, so both are cancelled to bring the panel + * back to the dots. Same values as a project row's, because it is the same + * control. + */ +const TRIGGER_INSET = 20; +const MENU_GAP = 4; + +export default function MemberRow({ + member, + onSettings, + onDelete, +}: Readonly<{ + member: OrganizationUser; + /** Undefined leaves the action in the menu but inert, as it is today. */ + onSettings?: () => void; + onDelete?: () => void; +}>) { + /* The API's name can come back empty; the email is the only field always + present, so it becomes the row's title when there is nothing above it. */ + const name = member.name?.trim(); + + return ( + + +
+ + {/* Wraps rather than overflows: an email address is long + and a narrow screen has to hold it. */} +
+ {name && ( + + {name} + + )} + + {member.email} + +
+
+
+ + {/* + * The design's status note. It reads "(Invited unnaccepted yet)", + * which nothing in the API can tell us — adding a member subscribes + * an existing account immediately, so there is no pending state. The + * slot instead carries the one thing the membership does record, + * which is whether they administer the organization. + */} + + {member.is_admin && ( + + (Admin) + + )} + + + {/* Only as wide as its trigger. */} + + + + + + {/* + * The design fills this menu with "Resend invite", which has + * no endpoint behind it. It keeps the two actions the page + * has always offered on a member instead, and they stay + * disabled while they stay unimplemented — as they were + * before the redesign. + */} + + + Settings + + + Delete + + + + +
+ ); +} diff --git a/webapp/src/components/mobile-header.tsx b/webapp/src/components/mobile-header.tsx deleted file mode 100644 index ba7d3c91a..000000000 --- a/webapp/src/components/mobile-header.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; -import { Organization } from "@/api/schemas"; -import { Menu } from "lucide-react"; -import { Link } from "react-router-dom"; -import { useState } from "react"; -import NavBar from "./navbar"; - -export default function MobileHeader({ - orgs, -}: { - orgs: Organization[] | undefined; -}) { - const [isSheetOpen, setSheetOpened] = useState(false); - - return ( -
- {/* Drawer that shows only on small screens */} - - setSheetOpened(true)}> - - - -
- setSheetOpened(false)} - className="flex flex-1 justify-center items-center gap-2 pt-6 font-semibold" - > - Logo - -
- -
-
-
- ); -} diff --git a/webapp/src/components/nav-item.tsx b/webapp/src/components/nav-item.tsx deleted file mode 100644 index 8f9f4f103..000000000 --- a/webapp/src/components/nav-item.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from "./ui/button"; - -export default function NavItem({ - isSelected, - onClick, - icon, - children, - paddingY = 3, -}: { - isSelected: boolean; - onClick?: () => void; - icon?: React.ReactNode; - children: React.ReactNode; - paddingY?: number; -}) { - return ( - - ); -} diff --git a/webapp/src/components/navbar.tsx b/webapp/src/components/navbar.tsx deleted file mode 100644 index a1431db8f..000000000 --- a/webapp/src/components/navbar.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { Organization } from "@/api/schemas"; -import { SelectGroup } from "@radix-ui/react-select"; -import { - AreaChart, - Building, - Home, - LogOutIcon, - UserIcon, - Users, -} from "lucide-react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; -import NavItem from "./nav-item"; -import { - Select, - SelectContent, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "./ui/select"; -import CreateOrganizationModal from "./createOrganizationModal"; -import { getOrganizations } from "@/api/organizations"; -import { Button } from "./ui/button"; -import { useModal } from "@/hooks/useModal"; - -const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; -export default function NavBar({ - orgs, - setSheetOpened, -}: Readonly<{ - orgs: Organization[] | undefined; - setSheetOpened?: (value: boolean) => void; -}>) { - const [selected, setSelected] = useState(null); - const navigate = useNavigate(); - const [selectedOrg, setSelectedOrg] = useState(() => { - try { - return localStorage.getItem("organizationId"); - } catch { - return null; - } - }); - const iconStyles = "h-4 w-4 flex-shrink-0 text-muted-foreground"; - const { pathname } = useLocation(); - const newOrgModal = useModal(); - const [organizationList, setOrganizationList] = useState< - Organization[] | undefined - >([]); - const [isDropdownOpen, setDropdownOpen] = useState(false); - - useEffect(() => { - if (pathname.includes("/members")) { - setSelected("members"); - } else if (pathname.includes("/profile")) { - setSelected("profile"); - } else if (pathname.includes("/projects")) { - setSelected("projects"); - return; - } else { - setSelected("home"); - } - }, [pathname, orgs]); - - useEffect(() => { - if (orgs) { - setOrganizationList(orgs); - } - }, [orgs]); - - useEffect(() => { - if (!organizationList?.length) { - setSelectedOrg(null); - return; - } - const routeOrgId = pathname.split("/")[1]; - if (organizationList.some((org) => org.id === routeOrgId)) { - setSelectedOrg(routeOrgId); - } else if (!organizationList.some((org) => org.id === selectedOrg)) { - setSelectedOrg(organizationList[0].id); - } - }, [pathname, organizationList, selectedOrg]); - - useEffect(() => { - if (!selectedOrg) return; - try { - localStorage.setItem("organizationId", selectedOrg); - const organizationName = organizationList?.find( - (organization) => organization.id === selectedOrg, - )?.name; - if (organizationName) { - localStorage.setItem("organizationName", organizationName); - } - } catch (error) { - console.error("Error writing to localStorage:", error); - } - }, [selectedOrg, organizationList]); - - const handleNewOrgClick = async () => { - newOrgModal.open(); - setDropdownOpen(false); // Close the dropdown menu - }; - - const refreshOrgList = async () => { - // Fetch the updated list of organizations from the server - const orgs = await getOrganizations(); - setOrganizationList(orgs); - }; - - return ( -
- -
- ); -} diff --git a/webapp/src/components/project-actions.tsx b/webapp/src/components/project-actions.tsx new file mode 100644 index 000000000..427eb30d3 --- /dev/null +++ b/webapp/src/components/project-actions.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import { toast } from "sonner"; + +import { + getEmissionsTimeSeries, + getRunEmissionsByExperiment, +} from "@/api/runs"; +import { ExperimentReport, Project } from "@/api/schemas"; +import { useModal } from "@/hooks/useModal"; +import { cn } from "@/helpers/utils"; +import { exportToJson } from "@/utils/export"; +import ProjectSettingsModal from "./project-settings-modal"; +import { DownloadIcon } from "./icons/download-icon"; +import { RefreshIcon } from "./icons/refresh-icon"; +import { SettingsIcon } from "./icons/settings-icon"; +import ShareProjectButton from "./share-project-button"; +import { IconButton } from "./ui/icon-button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; + +/* + * The actions that apply to a project as a whole: refresh, share, export, and + * settings. + * + * Its own component so it can sit in the page's heading, beside the project's + * name, rather than inside the panels below — the dashboard's data flows down, + * but these controls belong to the project, not to any panel. It owns the state + * only it uses (the refresh and export spinners, the settings dialog). + * + * The controls are square outlined icon buttons, so they carry the same radius + * and the same hover as every other control in the app. + */ +export default function ProjectActions({ + project, + experimentsReportData, + runData, + onRefresh, + onProjectUpdated, + className, +}: Readonly<{ + project: Project; + experimentsReportData: ExperimentReport[]; + runData: { experimentId: string; startDate: string; endDate: string }; + /** Refetches the dashboard, behind the refresh control. */ + onRefresh: () => void | Promise; + /** Runs after the settings dialog saves; defaults to a full refresh. */ + onProjectUpdated?: () => void | Promise; + className?: string; +}>) { + const settingsModal = useModal(); + const [isExporting, setIsExporting] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await onRefresh(); + } finally { + setIsRefreshing(false); + } + }; + + const handleJsonExport = () => { + if (isExporting) return; + + setIsExporting(true); + + toast.promise( + (async () => { + // Prepare the experiments data with runs for each experiment + const experimentsWithRuns = await Promise.all( + experimentsReportData.map(async (exp) => { + // Fetch runs for each experiment + const runs = await getRunEmissionsByExperiment( + exp.experiment_id, + runData.startDate, + runData.endDate, + ); + + // Fetch metadata and emissions for each run + const runsWithDetails = await Promise.all( + runs.map(async (run) => { + // Get emissions time series data (includes metadata) + const emissionsData = + await getEmissionsTimeSeries(run.runId); + + // Return run with metadata and emissions + return { + ...run, + emissions_value: run.emissions, + emissions: + emissionsData?.emissions || undefined, + metadata: + emissionsData.metadata || undefined, + }; + }), + ); + + // Return experiment data with its enhanced runs + return { + experiment_id: exp.experiment_id, + name: exp.name, + emissions: exp.emissions, + energy_consumed: exp.energy_consumed, + duration: exp.duration, + runs: runsWithDetails, + }; + }), + ); + + // Format the project data according to the requested structure + const formattedData = { + projects: [ + { + // Include all project properties + id: project.id, + name: project.name, + description: project.description, + public: project.public, + organizationId: project.organizationId, + experiments: experimentsWithRuns, + + // Add extra metadata + date_range: { + startDate: runData.startDate, + endDate: runData.endDate, + }, + }, + ], + }; + + exportToJson(formattedData); + // Small delay to make the loading state visible + await new Promise((resolve) => setTimeout(resolve, 500)); + setIsExporting(false); + })(), + { + loading: "Exporting JSON data...", + success: "JSON data exported successfully", + error: "Failed to export JSON data", + }, + ); + }; + + return ( +
+
+ + + + + + + + +

Refresh data

+
+
+
+ + + + + + + + + +

Download JSON export

+
+
+
+ + + +
+ + +
+ ); +} diff --git a/webapp/src/components/project-dashboard-base.tsx b/webapp/src/components/project-dashboard-base.tsx index d26e19272..0a35b269b 100644 --- a/webapp/src/components/project-dashboard-base.tsx +++ b/webapp/src/components/project-dashboard-base.tsx @@ -1,5 +1,4 @@ import { DateRangePicker } from "@/components/date-range-picker"; -import { Separator } from "@/components/ui/separator"; import { getDefaultDateRange } from "@/helpers/date-utils"; import { ExperimentReport, @@ -10,10 +9,13 @@ import { } from "@/api/schemas"; import { lazy, ReactNode, Suspense, useState } from "react"; import { DateRange } from "react-day-picker"; +import ChartRow from "./chart-row"; import ChartSkeleton from "./chart-skeleton"; -import CreateExperimentModal from "./createExperimentModal"; -import { Button } from "./ui/button"; -import { Card, CardContent } from "./ui/card"; +import ConsumedEnergyGauges from "./consumed-energy-gauges"; +import { PlusIcon } from "./icons/plus-icon"; +import { PrimaryButton } from "./ui/primary-button"; +import EquivalenceList, { equivalences } from "./equivalence-list"; +import CreateExperimentModal from "./create-experiment-modal"; import { Select, SelectContent, @@ -29,7 +31,6 @@ import { toast } from "sonner"; // experiment dropdown. Radix Select forbids an empty string as an item value. const ALL_EXPERIMENTS = "__all__"; -const RadialChart = lazy(() => import("@/components/radial-chart")); const ExperimentsBarChart = lazy( () => import("@/components/experiment-bar-chart"), ); @@ -107,20 +108,17 @@ export default function ProjectDashboardBase({ }; return ( -
-
- {headerContent ? ( - headerContent - ) : ( -
-

{project.name}

-

- {project.description} -

-
- )} -
+ /* + * No gap on this column: the charts below are separated by rules that have + * to meet, and a gap here would push the horizontal one away from the + * vertical ones. Each block carries its own space instead. + */ +
+
+ {headerContent} +
onDateChange(newDate || getDefaultDateRange()) @@ -128,234 +126,145 @@ export default function ProjectDashboardBase({ />
-
-
- {isLoading ? ( - <> - - - - - ) : ( - <> -
-
- Household consumption icon -
-
-

- {convertedValues.citizen} % -

-

- Of a U.S citizen weekly energy emissions -

-
-
-
-
- Transportation icon -
-
-

- {convertedValues.transportation} -

-

- Kilometers ridden -

-
-
-
-
- TV icon -
-
-

- {convertedValues.tvTime} days -

-

- Of watching TV -

-
-
- - )} -
-
- {isLoading ? ( - - - - - - ) : ( - - - - - - } - > - - - )} -
-
- {isLoading ? ( - - - - - - ) : ( - - - - - - } - > - - - )} -
-
+
+ {isLoading ? ( +
+ + + +
+ ) : ( + + )} + +
+

+ Consumed energy +

{isLoading ? ( - - - - - +
+ + + +
) : ( - - - - - - } - > - - + )} -
+
- - -
-

- {projectExperiments.length === 0 - ? isPublicView - ? "No experiment data in the selected date range" // This is because for public projects we show only the experiments that have runs, but for private projects we show in this list as well the projects created but without runs yet - : "No experiments have been created yet." - : "Set of experiments included in this project"} +

+

+ Experiments +

+ + {projectExperiments.length === 0 && ( +

+ {isPublicView + ? // Public projects list only experiments that have + // runs; private ones also list those created without + // any runs yet. + "No experiment data in the selected date range" + : "No experiments have been created yet."}

+ )} + +
+ {projectExperiments.length !== 0 && ( +
+ + {selectedExperiment && ( +
+ {selectedExperiment.description && ( +

+ {selectedExperiment.description} +

+ )} + {!isPublicView && ( +
+ + Experiment id + + + {selectedExperiment.id} + + +
+ )} +
+ )} +
+ )} + {!isPublicView && ( -
- + + Add an experiment + setIsExperimentModalOpen(false)} onExperimentCreated={onExperimentCreated} /> -
+ )}
- {projectExperiments.length !== 0 && ( -
- - {selectedExperiment && ( - - {selectedExperiment.description && ( -

- {selectedExperiment.description} -

- )} - {!isPublicView && ( -
- - Experiment id - - - {selectedExperiment.id} - - -
- )} -
- )} -
- )} - -
+
+ {isLoading ? ( <> @@ -386,25 +295,22 @@ export default function ProjectDashboardBase({ )} -
+ {selectedRunId && selectedRunId != "" && ( - <> - -
- {isLoading ? ( - - ) : ( - }> - - - )} -
- +
+ {isLoading ? ( + + ) : ( + }> + + + )} +
)}
); diff --git a/webapp/src/components/project-dashboard.tsx b/webapp/src/components/project-dashboard.tsx index c5714f3b7..1efee919e 100644 --- a/webapp/src/components/project-dashboard.tsx +++ b/webapp/src/components/project-dashboard.tsx @@ -1,31 +1,15 @@ -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - getEmissionsTimeSeries, - getRunEmissionsByExperiment, -} from "@/api/runs"; import { ProjectDashboardProps } from "@/api/schemas"; -import { exportToJson } from "@/utils/export"; -import { - Download, - LockIcon, - RefreshCw, - SettingsIcon, - Share2Icon, -} from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; import ProjectDashboardBase from "./project-dashboard-base"; -import ProjectSettingsModal from "./project-settings-modal"; -import ShareProjectButton from "./share-project-button"; -import { useModal } from "@/hooks/useModal"; +/* + * The private project dashboard: the shared panels, wired to the authenticated + * data. + * + * The project's own controls — refresh, share, export, settings — used to live + * here as a header row passed down to the base. They now sit in the page's + * heading beside the project's name, as `ProjectActions`, so nothing about the + * project's identity is rendered from inside its panels. + */ export default function ProjectDashboard({ project, date, @@ -39,229 +23,26 @@ export default function ProjectDashboard({ selectedRunId, onExperimentClick, onRunClick, - onSettingsClick, onRefresh, isLoading, }: ProjectDashboardProps) { - const settingsModal = useModal(); - const [isExporting, setIsExporting] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); - - const handleRefresh = async () => { - setIsRefreshing(true); - try { - await onRefresh(); - } finally { - setIsRefreshing(false); - } - }; - - const handleJsonExport = () => { - if (isExporting) return; - - setIsExporting(true); - - toast.promise( - (async () => { - // Prepare the experiments data with runs for each experiment - const experimentsWithRuns = await Promise.all( - experimentsReportData.map(async (exp) => { - // Fetch runs for each experiment - const runs = await getRunEmissionsByExperiment( - exp.experiment_id, - runData.startDate, - runData.endDate, - ); - - // Fetch metadata and emissions for each run - const runsWithDetails = await Promise.all( - runs.map(async (run) => { - // Get emissions time series data (includes metadata) - const emissionsData = - await getEmissionsTimeSeries(run.runId); - - // Return run with metadata and emissions - return { - ...run, - emissions_value: run.emissions, - emissions: - emissionsData?.emissions || undefined, - metadata: - emissionsData.metadata || undefined, - }; - }), - ); - - // Return experiment data with its enhanced runs - return { - experiment_id: exp.experiment_id, - name: exp.name, - emissions: exp.emissions, - energy_consumed: exp.energy_consumed, - duration: exp.duration, - runs: runsWithDetails, - }; - }), - ); - - // Format the project data according to the requested structure - const formattedData = { - projects: [ - { - // Include all project properties - id: project.id, - name: project.name, - description: project.description, - public: project.public, - organizationId: project.organizationId, - experiments: experimentsWithRuns, - - // Add extra metadata - date_range: { - startDate: runData.startDate, - endDate: runData.endDate, - }, - }, - ], - }; - - exportToJson(formattedData); - // Small delay to make the loading state visible - await new Promise((resolve) => setTimeout(resolve, 500)); - setIsExporting(false); - })(), - { - loading: "Exporting JSON data...", - success: "JSON data exported successfully", - error: "Failed to export JSON data", - }, - ); - }; - - const headerContent = ( -
-
-

- Project {project.name} -

- {project.public !== undefined && ( -
- -
- )} -
-
- - - - - - -

Refresh data

-
-
-
- - - - - - - -

Download JSON export

-
-
-
- -
-
- ); - return ( -
- - - { - // Call the original onSettingsClick to refresh the data - onSettingsClick(); - }} - /> -
- ); -} - -export function ProjectVisibilityBadge({ isPublic }: { isPublic: boolean }) { - return isPublic ? ( - - - Public - - ) : ( - - - Private - + ); } diff --git a/webapp/src/components/project-row.tsx b/webapp/src/components/project-row.tsx new file mode 100644 index 000000000..0d025e923 --- /dev/null +++ b/webapp/src/components/project-row.tsx @@ -0,0 +1,99 @@ +import { Link } from "react-router-dom"; + +import { Project } from "@/api/schemas"; +import { cn } from "@/helpers/utils"; +import { MoreVertIcon } from "./icons/more-vert-icon"; +import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { MenuItem, MenuPanel } from "./ui/menu"; +import { TableCell, TableRow } from "./ui/table"; + +/* + * One project in the list: its name, its secondary text, and a menu of the + * actions that apply to it. + * + * Both texts are links filling their cells, so the whole band is a click + * target, and they light together on hover as one item. The actions cell is + * excluded from that: its trigger is a small glyph, and if the cell lit with it + * there would be no way to tell the button from merely being near it. + */ + +/** Lets the row's hover exclude the actions cell. */ +const ACTIONS_CELL = "project-row-actions"; + +/* + * The trigger's padding, and the gap its menu keeps from the glyph. Radix anchors + * a menu to the trigger's box — the whole hit area — so cancelling that padding on + * both axes anchors the panel to the dots themselves, and enlarging the hit area + * no longer pushes the menu away from what opened it. + */ +const TRIGGER_INSET = 20; +const MENU_GAP = 4; + +const CELL_LINK = + "type-mono-medium block break-words py-5 text-cc-white outline-none transition-colors " + + "group-[:hover:not(:has(.project-row-actions:hover))]:text-cc-button-hover " + + "focus-visible:ring-2 focus-visible:ring-cc-lime lg:py-6 motion-reduce:transition-none"; + +export default function ProjectRow({ + project, + href, + onSettings, + onDelete, +}: Readonly<{ + project: Project; + href: string; + onSettings: () => void; + onDelete: () => void; +}>) { + return ( + + {/* The name takes half the table, which is what starts the secondary + text at its own column. */} + + + {project.name} + + + + + {project.description && ( + + {project.description} + + )} + + + {/* Only as wide as its trigger. */} + + + + + + + Settings + Delete + + + + + ); +} diff --git a/webapp/src/components/project-settings-modal.tsx b/webapp/src/components/project-settings-modal.tsx index 0befd0ba9..d7025e24f 100644 --- a/webapp/src/components/project-settings-modal.tsx +++ b/webapp/src/components/project-settings-modal.tsx @@ -1,22 +1,29 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { updateProject } from "@/api/projects"; import { Project } from "@/api/schemas"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ProjectTokensTable } from "./projectTokens/projectTokenTable"; -import { updateProject } from "@/api/projects"; -import { toast } from "sonner"; -import { Loader2 } from "lucide-react"; +import ShareProjectButton from "./share-project-button"; +import { Dialog, DialogContent } from "./ui/dialog"; +import ModalHeader from "./ui/modal-header"; +import { FormField } from "./ui/form-field"; +import { PrimaryButton } from "./ui/primary-button"; +import { Switch } from "./ui/switch"; +import { TabNavList, TabNavTrigger } from "./ui/tab-nav"; +import { Tabs, TabsContent } from "./ui/tabs"; + +/* + * Project settings: the Create-project modal's panel, fields and button, wider + * because it also holds the API-tokens table. The design has no frame for this + * dialog, so it is the redesign's vocabulary applied to the controls it had. + * + * The fields save on submit; the public toggle saves the moment it is flipped, + * which is what lets the sharing link it controls appear and disappear with it. + * Only a failed submit keeps the dialog open, so edits that did not save are + * still there to retry. + */ interface ProjectSettingsModalProps { open: boolean; @@ -37,12 +44,51 @@ export default function ProjectSettingsModal({ const [isSaving, setIsSaving] = useState(false); const [activeTab, setActiveTab] = useState("general"); - // Update form when project changes + /* + * The dialog stays mounted between openings, so the tab it was left on would + * otherwise still be showing the next time it opens. Settings starts on + * General; the tokens tab is somewhere you go, not somewhere you resume. + */ + useEffect(() => { + if (open) setActiveTab("general"); + }, [open]); + + /* + * Reset the form when the dialog moves to a *different* project, keyed on the + * id rather than the object. The toggle below saves as it is flipped, which + * refreshes the project and hands this component a new object; keying on the + * object would make that refresh overwrite whatever the user had typed. + */ useEffect(() => { setName(project.name || ""); setDescription(project.description || ""); setIsPublic(project.public || false); - }, [project]); + }, [project.id, project.name, project.description, project.public]); + + /* + * The public toggle saves on its own, so the sharing link it controls appears + * and disappears with it rather than waiting for the form to be submitted. + * + * It writes only the flag: the name and description it sends are the *saved* + * ones, not what is currently in the fields, so flipping the switch never + * quietly commits half-typed text. The switch moves first and rolls back if + * the write fails, so it always shows what is actually stored. + */ + const handlePublicChange = async (next: boolean) => { + setIsPublic(next); + try { + await updateProject(project.id, { + name: project.name, + description: project.description, + public: next, + }); + onProjectUpdated(); + } catch (error) { + console.error("Error updating project visibility:", error); + setIsPublic(!next); + toast.error("Failed to change project visibility"); + } + }; const handleSave = async () => { setIsSaving(true); @@ -54,95 +100,107 @@ export default function ProjectSettingsModal({ }); toast.success("Project settings updated successfully"); onProjectUpdated(); + onOpenChange(false); } catch (error) { + // Left open on failure, so the edits that failed to save are still + // there to retry rather than being discarded. console.error("Error updating project:", error); toast.error("Failed to update project settings"); } finally { setIsSaving(false); - onOpenChange(false); } }; return ( - - - Project Settings - - Manage your project settings and API tokens - - + + - - - General - - - API Tokens - - - - -
-
- - setName(e.target.value)} - placeholder="Enter project name" - className="mt-1" - /> -
-
- - - setDescription(e.target.value) - } - placeholder="Enter project description" - className="mt-1" - /> -
-
+ + General + API Tokens + + + +
{ + event.preventDefault(); + handleSave(); + }} + > + setName(e.target.value)} + /> + + setDescription(e.target.value)} + /> + +
-
-
- - - - + + {/* Appears and disappears with the toggle above, + which saves itself. */} + + +
+ + {isSaving && ( + + )} + {isSaving ? "Saving..." : "Save changes"} + +
+ - + {/* Not redesigned yet — the table keeps its current look. */} + diff --git a/webapp/src/components/project-visibility-badge.tsx b/webapp/src/components/project-visibility-badge.tsx new file mode 100644 index 000000000..9eaf33520 --- /dev/null +++ b/webapp/src/components/project-visibility-badge.tsx @@ -0,0 +1,35 @@ +import { LockIcon } from "./icons/lock-icon"; +import { ShareIcon } from "./icons/share-icon"; +import { Badge } from "./ui/badge"; + +/* + * Whether a project is visible to anyone with its link, shown beside its name. + * + * Its own module rather than an export of the project dashboard, so the page + * heading can show it without importing from the panels below it. + * + * The glyphs are the redesign's own; the pill around them is still the badge as + * it was. They are drawn a little larger than the 12px the old icons used — + * pixel-art strokes need the room to stay legible at this size. + */ +export default function ProjectVisibilityBadge({ + isPublic, +}: Readonly<{ isPublic: boolean }>) { + return isPublic ? ( + + + Public + + ) : ( + + + Private + + ); +} diff --git a/webapp/src/components/projectTokens/projectTokenTable.tsx b/webapp/src/components/projectTokens/projectTokenTable.tsx index 87b03ddcf..ec4517365 100644 --- a/webapp/src/components/projectTokens/projectTokenTable.tsx +++ b/webapp/src/components/projectTokens/projectTokenTable.tsx @@ -6,7 +6,10 @@ import CustomRowToken from "@/components/projectTokens/custom-row-token"; import { useMemo, useState, useEffect, useRef } from "react"; import { Loader2, ClipboardCopy, ClipboardCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { PlusIcon } from "@/components/icons/plus-icon"; +import { FormField } from "@/components/ui/form-field"; +import { PrimaryButton } from "@/components/ui/primary-button"; +import { SecondaryButton } from "@/components/ui/secondary-button"; import { toast } from "sonner"; import copy from "copy-to-clipboard"; @@ -103,14 +106,17 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => { return (
-
+
{!isCreatingToken && !createdToken ? ( - + + Create a new token + ) : createdToken ? (

@@ -152,36 +158,44 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => {

Create new token

-
- { + event.preventDefault(); + handleCreateToken(); + }} + > + setTokenName(e.target.value)} - placeholder="Token Name" - className="flex-grow" disabled={isSubmitting} + containerClassName="min-w-0 flex-1" /> - - -
+
+ + {isSubmitting && ( + + )} + {isSubmitting ? "Creating..." : "Create"} + + setIsCreatingToken(false)} + disabled={isSubmitting} + > + Cancel + +
+
)}
diff --git a/webapp/src/components/radial-chart.tsx b/webapp/src/components/radial-chart.tsx deleted file mode 100644 index efe7fa02b..000000000 --- a/webapp/src/components/radial-chart.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { - Label, - PolarGrid, - PolarRadiusAxis, - RadialBar, - RadialBarChart, -} from "recharts"; - -import { Card, CardContent } from "@/components/ui/card"; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; - -type chartDataType = { - label: string; - value: number; -}; - -export default function RadialChart({ - data, -}: Readonly<{ data: chartDataType }>) { - const chartConfig = { - value: { - label: data?.label || "", - color: "hsl(var(--primary))", - }, - } satisfies ChartConfig; - - // Check if data is missing or empty - if (!data || data.value === undefined) { - return ( - - -
- No data available -
-
-
- ); - } - - return ( - - - - - - } - /> - - - - - - - - ); -} diff --git a/webapp/src/components/runs-scatter-chart.tsx b/webapp/src/components/runs-scatter-chart.tsx index ad2a0594a..50b127a0a 100644 --- a/webapp/src/components/runs-scatter-chart.tsx +++ b/webapp/src/components/runs-scatter-chart.tsx @@ -2,17 +2,11 @@ import { RunReport } from "@/api/schemas"; import { format } from "date-fns"; import { Label, Scatter, ScatterChart, Tooltip, XAxis, YAxis } from "recharts"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { getRunEmissionsByExperiment } from "@/api/runs"; import { exportRunsToCsv } from "@/utils/export"; import { pickTimeFormat } from "@/helpers/time-axis"; import { useEffect, useMemo, useState } from "react"; +import ChartSection from "./chart-section"; import ChartSkeleton from "./chart-skeleton"; import { ExportCsvButton } from "./export-csv-button"; import { ChartConfig, ChartContainer } from "./ui/chart"; @@ -123,15 +117,11 @@ export default function RunsScatterChart({ } return ( - - -
- Scatter Chart - Emissions by Run Id - - Click a run to see time series - -
- {!isPublicView && ( + - )} -
- - - {runsReportsData.length > 0 ? ( - + + {runsReportsData.length > 0 ? ( + + + format(new Date(value), tickFmt) + } > - - format(new Date(value), tickFmt) - } - > - - - - } /> - onRunClick(data.runId)} - cursor="pointer" + - ) : ( -
-

- No data available -

-
- )} -
-
-
+ + + {/* + * `insideLeft` anchors at the top of the axis and the + * rotation pivots on that anchor, so the label hangs + * from the top unless it is told to anchor on its own + * middle. + */} + + } /> + onRunClick(data.runId)} + cursor="pointer" + /> + + ) : ( +
+

+ No data available +

+
+ )} + + ); } diff --git a/webapp/src/components/settings-nav-item.tsx b/webapp/src/components/settings-nav-item.tsx new file mode 100644 index 000000000..152d96797 --- /dev/null +++ b/webapp/src/components/settings-nav-item.tsx @@ -0,0 +1,64 @@ +import { cn } from "@/helpers/utils"; + +/* + * A row in the Settings page's sub-navigation. + * + * The design draws unselected rows at 50% opacity and gives no hover or pressed + * state, so those are built from what it does define: hover brings the row to + * full opacity over a #2b2b2b wash — the same fill the selected row uses — and + * pressing settles on that fill outright. Disabled rows stay flat. + * + * The selected row is not a button. It is the panel you are already on, so there + * is nothing to press: it renders as a `span` marked `aria-current`, which is + * also what keeps it out of the tab order. + */ + +const ROW = + "flex items-center gap-0 rounded-menu px-5 py-2.5 text-left outline-none " + + "focus-visible:ring-2 focus-visible:ring-cc-lime"; + +const LABEL = "type-display type-settings-nav whitespace-nowrap p-2.5"; + +export default function SettingsNavItem({ + icon: Icon, + label, + isCurrent, + onClick, + disabled, +}: Readonly<{ + icon: (props: { className?: string }) => React.JSX.Element; + label: string; + isCurrent?: boolean; + onClick?: () => void; + disabled?: boolean; +}>) { + const tone = isCurrent ? "text-cc-lime" : "text-cc-white"; + + if (isCurrent) { + return ( + + + {label} + + ); + } + + return ( + + ); +} diff --git a/webapp/src/components/share-project-button.tsx b/webapp/src/components/share-project-button.tsx index 32ef913dd..64b5595c5 100644 --- a/webapp/src/components/share-project-button.tsx +++ b/webapp/src/components/share-project-button.tsx @@ -1,23 +1,32 @@ -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { ShareIcon } from "@/components/icons/share-icon"; +import { IconButton } from "@/components/ui/icon-button"; +import { SecondaryButton } from "@/components/ui/secondary-button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import copy from "copy-to-clipboard"; -import { CheckIcon, CopyIcon, Share2Icon } from "lucide-react"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; interface ShareProjectButtonProps { projectId: string; isPublic: boolean; + /* + * How the control presents itself. `icon` is the round icon button the + * project dashboard has always shown; `labelled` is the redesign's outlined + * "Copy link" button, used where the control sits in a form beside the + * setting that produces the link. Both open the same panel. + */ + trigger?: "icon" | "labelled"; } export default function ShareProjectButton({ projectId, isPublic, + trigger = "icon", }: ShareProjectButtonProps) { const [copied, setCopied] = useState(false); const copyTimerRef = useRef | null>(null); @@ -51,41 +60,45 @@ export default function ShareProjectButton({
- + {trigger === "labelled" ? ( + + + Copy link + + ) : ( + + + + )} - -
-

- Share this project -

-

- Anyone with this link can view this project's - emissions data without authentication. -

-
- - +
diff --git a/webapp/src/components/sidebar-rail.tsx b/webapp/src/components/sidebar-rail.tsx new file mode 100644 index 000000000..ca04b8d09 --- /dev/null +++ b/webapp/src/components/sidebar-rail.tsx @@ -0,0 +1,207 @@ +import * as React from "react"; +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { Organization } from "@/api/schemas"; +import { cn } from "@/helpers/utils"; +import AccountMenu from "./account-menu"; +import { GlobalIcon } from "./icons/global-icon"; +import { ProjectsIcon } from "./icons/projects-icon"; +import { AccountIcon } from "./icons/account-icon"; +import { MembersIcon } from "./icons/members-icon"; + +/* + * The app's primary navigation: the destinations stacked at the top of a + * vertical rail with the account item pinned to the bottom, and the same items + * as a bottom bar below `md`. The rail's width lives in the `rail` token. + * + * Icons take their fill from `currentColor`, so colouring a control moves its + * icon and label together — green for the page you are on, white otherwise. + */ + +/* + * A control in the rail: an icon above its label, green when it is the page you + * are on and white otherwise. + * + * A plain button that forwards its props, so the destinations use it for + * navigation and the account control uses it as a menu trigger. + */ +const RailButton = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<"button"> & { + icon: (props: { className?: string }) => React.JSX.Element; + label: string; + isSelected?: boolean; + } +>(({ icon: Icon, label, isSelected, className, ...props }, ref) => ( + +)); +RailButton.displayName = "RailButton"; + +type RailItem = { + key: string; + label: string; + Icon: (props: { className?: string }) => React.JSX.Element; + path: (orgId: string) => string; +}; + +/* + * The third item is Members, matching the destination it has always pointed at + * (`/:organizationId/members`). Figma labels it "Glossary" and draws a different + * glyph; `GlossaryIcon` is still exported from its own module for whenever a + * glossary page exists. + */ +const ITEMS: RailItem[] = [ + { key: "global", label: "Global", Icon: GlobalIcon, path: (o) => `/${o}` }, + { + key: "projects", + label: "Projects", + Icon: ProjectsIcon, + path: (o) => `/${o}/projects`, + }, + { + key: "members", + label: "Members", + Icon: MembersIcon, + path: (o) => `/${o}/members`, + }, +]; + +export default function SidebarRail({ + orgs, + className, +}: Readonly<{ + orgs: Organization[] | undefined; + className?: string; +}>) { + const { pathname } = useLocation(); + const navigate = useNavigate(); + + // Same selected-organization resolution the previous nav used, so the + // localStorage contract and URL-derived org are unchanged. + const [selectedOrg, setSelectedOrg] = useState(() => { + try { + return localStorage.getItem("organizationId"); + } catch { + return null; + } + }); + + useEffect(() => { + if (selectedOrg) return; + try { + const localOrg = localStorage.getItem("organizationId"); + const found = orgs?.find((org) => org.id === localOrg); + if (localOrg && found) { + setSelectedOrg(localOrg); + } else if (orgs && orgs.length > 0) { + setSelectedOrg(orgs[0].id); + } + } catch (error) { + console.error("Error reading from localStorage:", error); + } + }, [selectedOrg, orgs]); + + useEffect(() => { + if (!selectedOrg) return; + try { + if (localStorage.getItem("organizationId") !== selectedOrg) { + localStorage.setItem("organizationId", selectedOrg); + } + const orgName = orgs?.find((org) => org.id === selectedOrg)?.name; + if (orgName) { + localStorage.setItem("organizationName", orgName); + } + } catch (error) { + console.error("Error writing to localStorage:", error); + } + }, [selectedOrg, orgs]); + + // Keep the rail in step with the org in the URL. + useEffect(() => { + const orgId = pathname.split("/")[1]; + if (orgId && orgs?.some((org) => org.id === orgId)) { + setSelectedOrg(orgId); + } + }, [pathname, orgs]); + + const selectedKey = pathname.includes("/projects") + ? "projects" + : pathname.includes("/members") + ? "members" + : "global"; + + return ( + + ); +} diff --git a/webapp/src/components/ui/dialog.tsx b/webapp/src/components/ui/dialog.tsx index 37a694ff3..84c2db997 100644 --- a/webapp/src/components/ui/dialog.tsx +++ b/webapp/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< , - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + /** + * Omits the built-in corner close button, for dialogs whose own header + * provides one (e.g. the redesigned Create-project modal). + */ + hideClose?: boolean; + } +>(({ className, children, hideClose = false, ...props }, ref) => ( - - {children} - - - Close - - +
+ + {children} + {!hideClose && ( + + + Close + + )} + +
)); DialogContent.displayName = DialogPrimitive.Content.displayName; diff --git a/webapp/src/components/ui/form-field.tsx b/webapp/src/components/ui/form-field.tsx new file mode 100644 index 000000000..4b6170bcf --- /dev/null +++ b/webapp/src/components/ui/form-field.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; + +import { cn } from "@/helpers/utils"; + +/* + * A labelled text field, as the design draws it: a 16px label, then a 46px + * control on a 5% white fill with a 2px radius and a #666 placeholder. + * + * The label and the control are one component because the design treats them as + * one — the 4px between them and the label's type are part of the field, not + * decisions a form remakes each time it needs one. `id` is required for the same + * reason: the label is only a label if it points at something. + * + * Separate from `ui/input.tsx`, which is the shadcn input the screens outside the + * redesign still use, and which carries no label. + */ +type FormFieldProps = Omit, "id"> & { + id: string; + label: string; + /** + * Hides the label visually while leaving it for assistive technology, for the + * rare field whose surroundings already name it. + */ + hideLabel?: boolean; + /** Classes for the wrapper; `className` styles the control itself. */ + containerClassName?: string; +}; + +export const FormField = React.forwardRef( + ( + { + id, + label, + className, + containerClassName, + hideLabel = false, + type = "text", + ...props + }, + ref, + ) => ( +
+ + +
+ ), +); +FormField.displayName = "FormField"; diff --git a/webapp/src/components/ui/icon-button.tsx b/webapp/src/components/ui/icon-button.tsx new file mode 100644 index 000000000..8cf3917d3 --- /dev/null +++ b/webapp/src/components/ui/icon-button.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; + +import { cn } from "@/helpers/utils"; +import { SecondaryButton } from "./secondary-button"; + +/* + * An icon-only action: the outlined button, square and sized to its glyph. + * + * It renders through `SecondaryButton` rather than restating its treatment, so + * the hover to #a0c55b and the pressed green stay in one place — this makes it + * square, drops the text padding, and quiets it at rest. The 2px radius comes + * with it, which is the radius every other control in the app carries. + * + * At rest the glyph is the design's "Gray" (#949494) — the fill it gives secondary + * icons — inside a #464646 outline, rather than the white a labelled button uses. + * These sit beside a page heading and are not what the eye should land on first; + * the hover is what makes them findable. + * + * Callers must give it an `aria-label`: there is no text to name it. + */ +type IconButtonProps = React.ComponentPropsWithoutRef< + typeof SecondaryButton +> & { + "aria-label": string; +}; + +export const IconButton = React.forwardRef( + ({ className, ...props }, ref) => ( + + ), +); +IconButton.displayName = "IconButton"; diff --git a/webapp/src/components/ui/menu.tsx b/webapp/src/components/ui/menu.tsx new file mode 100644 index 000000000..87c06f574 --- /dev/null +++ b/webapp/src/components/ui/menu.tsx @@ -0,0 +1,81 @@ +import * as React from "react"; + +import { cn } from "@/helpers/utils"; +import { + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; + +/* + * The redesign's dropdown menu: a dark panel with a green edge, and rows that + * highlight as one. + * + * Both menus in the app — the account menu at the end of the rail, and a project + * row's overflow menu — are the same object in the design, so they render through + * these rather than each restating the panel's border, fill and shadow. + * + * The design defines a single highlight treatment and does not distinguish hover + * from selected, so `isCurrent` and the hover/focus state share it: a #2b2b2b + * fill with a green right edge and a green label. Focus is bound as well as hover + * so the menu is fully operable from the keyboard — Radix moves focus with the + * arrow keys and also sets it on hover. + */ + +export const MenuPanel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +MenuPanel.displayName = "MenuPanel"; + +type MenuItemProps = React.ComponentPropsWithoutRef & { + /** Rendered before the label, at the row's own size. */ + icon?: React.ReactNode; + /** Marks the row as the thing currently being viewed. */ + isCurrent?: boolean; +}; + +export const MenuItem = React.forwardRef< + React.ElementRef, + MenuItemProps +>(({ className, children, icon, isCurrent, ...props }, ref) => ( + + {icon} + {/* Fills the remaining width rather than sitting in a fixed text box, so + a long name wraps instead of overflowing. */} + + {children} + + +)); +MenuItem.displayName = "MenuItem"; diff --git a/webapp/src/components/ui/modal-header.tsx b/webapp/src/components/ui/modal-header.tsx new file mode 100644 index 000000000..affbfd488 --- /dev/null +++ b/webapp/src/components/ui/modal-header.tsx @@ -0,0 +1,47 @@ +import { PlusIcon } from "@/components/icons/plus-icon"; +import { DialogClose, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/helpers/utils"; + +/* + * A dialog's header: the title in the display face with the close control + * beside it, above the rule that separates it from the body. Dialogs using this + * pass `hideClose` to `DialogContent`, since this is their close control. + * + * The title's size is a utility rather than a `type-*` class deliberately: + * `DialogTitle` ships its own `text-lg`, which `twMerge` cannot tell is the + * same property as a project class, so both survive and the utility wins. + */ +export default function ModalHeader({ + title, + className, + children, +}: Readonly<{ + title: string; + className?: string; + /** Optional controls placed before the close button. */ + children?: React.ReactNode; +}>) { + return ( + +
+ + {title} + + +
+ {children} + + + + Close + +
+
+
+ ); +} diff --git a/webapp/src/components/ui/popover.tsx b/webapp/src/components/ui/popover.tsx index 4b80d593d..75bf8219f 100644 --- a/webapp/src/components/ui/popover.tsx +++ b/webapp/src/components/ui/popover.tsx @@ -19,7 +19,16 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 w-72 p-4 outline-none", + // The menus' surface, but outlined in the page's own rule rather + // than their green: a menu's border marks where the pointer is + // working, and a panel you only read does not need that much. + "rounded-menu border border-cc-rule bg-cc-background text-cc-white shadow-menu", + // Its own open/close animation, which is unaffected by the + // centring problem the dialog had — a popover is placed by Radix, + // not by a transform of its own. + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "motion-reduce:animate-none motion-reduce:transition-none", className, )} {...props} diff --git a/webapp/src/components/ui/primary-button.tsx b/webapp/src/components/ui/primary-button.tsx new file mode 100644 index 000000000..1415a9ba2 --- /dev/null +++ b/webapp/src/components/ui/primary-button.tsx @@ -0,0 +1,50 @@ +import * as React from "react"; + +import { cn } from "@/helpers/utils"; + +/* + * The redesign's primary action: the lime button, wherever the design calls for + * one. + * + * Separate from `ui/button.tsx` rather than added to it as a variant — that + * button's variants are built on the shadcn token set (`bg-primary`, + * `ring-offset-background`) and every screen the redesign has not reached still + * renders them, so its shape is not free to change. This one owns the design's + * treatment and nothing else renders through it by accident. + * + * `ringOffset` exists because the focus ring is drawn against whatever the button + * sits on: the page's surface behind a page-level action, the panel's behind one + * in a dialog. It is the only thing about the button that its context decides. + */ +type PrimaryButtonProps = React.ComponentPropsWithoutRef<"button"> & { + ringOffset?: "background" | "page"; +}; + +export const PrimaryButton = React.forwardRef< + HTMLButtonElement, + PrimaryButtonProps +>( + ( + { className, type = "button", ringOffset = "background", ...props }, + ref, + ) => ( + -
- - -
-

API tokens

-
- -
-
-
-
- ); -} diff --git a/webapp/src/pages/ProjectsPage.tsx b/webapp/src/pages/ProjectsPage.tsx index 1a7d1d66a..7da02b755 100644 --- a/webapp/src/pages/ProjectsPage.tsx +++ b/webapp/src/pages/ProjectsPage.tsx @@ -1,43 +1,85 @@ -import BreadcrumbHeader from "@/components/breadcrumb"; -import CreateProjectModal from "@/components/createProjectModal"; -import CustomRow from "@/components/custom-row"; +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import useSWR from "swr"; +import { toast } from "sonner"; + +import CreateProjectModal from "@/components/create-project-modal"; import DeleteProjectModal from "@/components/delete-project-modal"; import ErrorMessage from "@/components/error-message"; import Loader from "@/components/loader"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; +import ProjectRow from "@/components/project-row"; +import ProjectSettingsModal from "@/components/project-settings-modal"; +import { PlusIcon } from "@/components/icons/plus-icon"; +import { PrimaryButton } from "@/components/ui/primary-button"; import { Table, TableBody } from "@/components/ui/table"; + import { fetcher } from "@/api/swr"; +import { deleteProject, getProjects } from "@/api/projects"; +import { Organization, Project } from "@/api/schemas"; import { useModal } from "@/hooks/useModal"; -import { getProjects, deleteProject } from "@/api/projects"; -import { Project } from "@/api/schemas"; -import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import useSWR from "swr"; -import { toast } from "sonner"; +/* + * The Projects page, in its empty and populated states, which differ only in + * where the primary action sits: centred in the empty area, or on the heading + * row beside the title. + * + * The design's frames still draw the older in-page navigation; that lives in + * `SidebarRail` and `AccountMenu` now and is not rebuilt here. + * + * A row's secondary text is the project's description — the design labels it + * "Last updated on 02/02/24", and the API carries no timestamp. + */ export default function ProjectsPage() { const { organizationId } = useParams<{ organizationId: string }>(); - let organizationName: string | null = null; - try { - organizationName = localStorage.getItem("organizationName"); - } catch { - organizationName = null; - } + const createModal = useModal(); + const settingsModal = useModal(); const deleteModal = useModal(); const [projectList, setProjectList] = useState([]); + const [projectToEdit, setProjectToEdit] = useState(null); const [projectToDelete, setProjectToDelete] = useState( null, ); - const handleClick = async () => { - createModal.open(); - }; + /* + * The breadcrumb's organization name. Fetched like the Global dashboard does, + * with the name the navigation already cached as the fallback while the request + * is in flight, so the crumb never flashes a bare id. + */ + const { data: organization } = useSWR( + organizationId ? `/organizations/${organizationId}` : null, + fetcher, + { revalidateOnFocus: false }, + ); + let cachedOrganizationName: string | null = null; + try { + cachedOrganizationName = localStorage.getItem("organizationName"); + } catch { + cachedOrganizationName = null; + } + const organizationName = + organization?.name || cachedOrganizationName || organizationId!; + + const { + data: projects, + error, + isLoading, + } = useSWR(`/projects?organization=${organizationId}`, fetcher); + + useEffect(() => { + if (projects) { + setProjectList(projects); + } + }, [projects]); const refreshProjectList = async () => { - const projects = await getProjects(organizationId!); - setProjectList(projects || []); + const refreshed = await getProjects(organizationId!); + setProjectList(refreshed || []); + }; + + const handleSettingsClick = (project: Project) => { + setProjectToEdit(project); + settingsModal.open(); }; const handleDeleteClick = (project: Project) => { @@ -56,22 +98,6 @@ export default function ProjectsPage() { } }; - const { - data: projects, - error, - isLoading, - } = useSWR( - `/projects?organization=${organizationId}`, - fetcher, - {}, - ); - - useEffect(() => { - if (projects) { - setProjectList(projects); - } - }, [projects]); - if (isLoading) { return ; } @@ -80,73 +106,115 @@ export default function ProjectsPage() { return ; } + const sortedProjects = [...projectList].sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()), + ); + const hasProjects = sortedProjects.length > 0; + + /* The same button in the design's two placements: on the heading row, and + centred in the empty state. */ + const addProjectButton = ( + + + Add a project + + ); + return ( -
- -
-
-

Projects

- - + /* The Global dashboard's content column: one scrolling region whose + padding is the single horizontal gutter for everything inside it. */ +
+ {/* The parent crumb hovers to white rather than to the design's + button-hover green, which is the colour of the current crumb beside + it — hovering should not make a link look like the page you are + already on. */} + + + {/* The action joins the heading only when there are projects; the + empty state centres it instead. */} +
+

+ Projects +

+ {hasProjects && addProjectButton} +
+ + {hasProjects ? ( + + + {sortedProjects.map((project) => ( + handleSettingsClick(project)} + onDelete={() => handleDeleteClick(project)} + /> + ))} + +
+ ) : ( + /* + * The design gives the empty region a fixed 525px height purely to + * centre its contents in the frame; here it takes the space the + * column has left over and centres within that, with a minimum so + * it still reads as an empty area when the viewport is short. + */ +
+ {addProjectButton} +

+ You have no projects added yet... +

- - - - {projectList && - projectList - .sort((a, b) => - a.name - .toLowerCase() - .localeCompare( - b.name.toLowerCase(), - ), - ) - .map((project) => ( - - handleDeleteClick(project) - } - /> - ))} - -
-
- {projectToDelete && ( - - )} -
+ )} + + + + {/* + * Settings opens in place rather than navigating away. The project + * dashboard's own settings control already opens this same modal, so + * the row menu now behaves the way the rest of the app does, and acting + * on a row no longer costs you the list you were working in. The row + * already holds the whole project, so nothing is refetched. + */} + {projectToEdit && ( + + )} + + {projectToDelete && ( + + )}
); } diff --git a/webapp/src/pages/SettingsPage.tsx b/webapp/src/pages/SettingsPage.tsx new file mode 100644 index 000000000..d6c600bda --- /dev/null +++ b/webapp/src/pages/SettingsPage.tsx @@ -0,0 +1,118 @@ +import { useNavigate } from "react-router-dom"; +import useSWR from "swr"; + +import { User } from "@/api/schemas"; +import { fetcher } from "@/api/swr"; +import SettingsNavItem from "@/components/settings-nav-item"; +import { FormField } from "@/components/ui/form-field"; +import { PrimaryButton } from "@/components/ui/primary-button"; +import { AccountCircleIcon } from "@/components/icons/account-circle-icon"; +import { ArrowBackIosIcon } from "@/components/icons/arrow-back-ios-icon"; +import { LockIcon } from "@/components/icons/lock-icon"; + +const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; + +/* + * The Settings page: a hugging sub-nav beside a bounded content column, both + * starting at the same top edge. + * + * The frame has no sidebar rail and carries its own "Go back" control, so this + * is a standalone page routed under AuthGuard rather than a child of + * DashboardLayout. + */ + +export default function SettingsPage() { + const navigate = useNavigate(); + + // The signed-in user's real email, from the endpoint AuthGuard already uses. + // Figma shows the field in its "Disabled" state with placeholder text; the + // value here is the actual address rather than that placeholder. + const { data: auth, isLoading } = useSWR<{ user?: User }>( + "/auth/check", + fetcher, + { revalidateOnFocus: false }, + ); + const email = auth?.user?.email; + + /* + * Email address and password are both owned by the OIDC provider — the API + * exposes no endpoint for either, and the previous navigation's "Profile" + * item already sent users there. So "Change" and "Password" hand off to it, + * and are disabled when no provider URL is configured. + */ + const goToProvider = () => { + if (USER_PROFILE_URL) window.location.href = USER_PROFILE_URL; + }; + + return ( +
+
+ {/* Sub-nav */} + + + {/* Content */} +
+

+ Profile +

+ +
+

+ Email +

+

+ Manage your email address to receive important + updates and notifications +

+ +
+ + + Change + +
+
+
+
+
+ ); +} diff --git a/webapp/src/router.tsx b/webapp/src/router.tsx index fcd852371..2e935ae06 100644 --- a/webapp/src/router.tsx +++ b/webapp/src/router.tsx @@ -11,8 +11,8 @@ const HomePage = lazy(() => import("./pages/HomePage")); const OrgDashboardPage = lazy(() => import("./pages/OrgDashboardPage")); const ProjectsPage = lazy(() => import("./pages/ProjectsPage")); const ProjectDashboardPage = lazy(() => import("./pages/ProjectDashboardPage")); -const ProjectSettingsPage = lazy(() => import("./pages/ProjectSettingsPage")); const MembersPage = lazy(() => import("./pages/MembersPage")); +const SettingsPage = lazy(() => import("./pages/SettingsPage")); function SuspenseWrapper({ children }: { children: React.ReactNode }) { return }>{children}; @@ -43,6 +43,21 @@ export const router = createBrowserRouter([ ), }, + /* + * Settings sits outside DashboardLayout: its design has no sidebar + * rail and provides its own "Go back" control, so it is a standalone + * authenticated page. + */ + { + path: "/settings", + element: ( + + + + + + ), + }, { element: ( @@ -82,14 +97,6 @@ export const router = createBrowserRouter([ ), }, - { - path: "/:organizationId/projects/:projectId/settings", - element: ( - - - - ), - }, { path: "/:organizationId/members", element: ( diff --git a/webapp/tailwind.config.ts b/webapp/tailwind.config.ts index 31cf1a474..6a05f589b 100644 --- a/webapp/tailwind.config.ts +++ b/webapp/tailwind.config.ts @@ -13,7 +13,59 @@ const config = { }, }, extend: { + spacing: { + /* + * Structural dimensions from the redesign, each defined once + * and only where it belongs. + * + * `rail` is the compact sidebar's own width. `gauge` is the + * consumed-energy ring's diameter (Figma 199.23, normalised — + * the ring's proportions live in the SVG viewBox, so the + * rendered size is free). `heading` is the gap between a + * section heading and its content (Figma 46px, which is off + * the default scale but consistent across both sections). + */ + rail: "101px", + gauge: "200px", + heading: "46px", + /* Account menu panel width and row/field control height. */ + control: "46px", + }, + boxShadow: { + /* Figma 218:14541 — account menu panel. */ + menu: "0 4px 10px rgba(0, 0, 0, 0.25)", + /* Figma 218:11927 — modal panel. */ + dialog: "0 4px 20px rgba(0, 0, 0, 0.25)", + }, + fontFamily: { + // IBM Plex Mono. `font-mono` is already applied on . + mono: ["var(--font-mono)"], + // Disket Mono Bold — the design's display face, self-hosted + // from public/fonts. See the note in globals.css. + display: ["var(--font-display)"], + }, colors: { + /* + * Figma variables from frame 218:7838, added additively. The + * existing shadcn tokens below are untouched: `--primary` + * already resolves to #BFFB4F and `--background` to #181818, + * so those two are reused rather than duplicated in markup. + */ + cc: { + background: "var(--cc-background)", + "page-background": "var(--cc-page-background)", + lime: "var(--cc-lime)", + white: "var(--cc-white)", + "dark-gray": "var(--cc-dark-gray)", + "darkest-gray": "var(--cc-darkest-gray)", + gray: "var(--cc-gray)", + "button-hover": "var(--cc-button-hover)", + "text-input-gray": "var(--cc-text-input-gray)", + "breadcrumb-gray": "var(--cc-breadcrumb-gray)", + rule: "var(--cc-rule)", + "gauge-track": "var(--cc-gauge-track)", + "gauge-label": "var(--cc-gauge-label)", + }, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", @@ -49,6 +101,9 @@ const config = { }, }, borderRadius: { + /* Design-specific radii from the Figma frame. */ + menu: "4px", + field: "2px", lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", diff --git a/webapp/tests/api/mock/handlers.test.ts b/webapp/tests/api/mock/handlers.test.ts index b0dd2c4ca..9cc9d7e96 100644 --- a/webapp/tests/api/mock/handlers.test.ts +++ b/webapp/tests/api/mock/handlers.test.ts @@ -48,6 +48,66 @@ describe("resolveMock — organizations", () => { expect((r.body as { name: string }).name).toBe("Mock Organization"); }); + /* + * The real endpoint filters the emissions table by timestamp; these pin the + * mock to the same behaviour so the dashboard's date picker can be exercised + * — and regressions in date handling caught — without a live backend. + */ + describe("org sums honour the date range", () => { + type Report = { + emissions: number; + energy_consumed: number; + duration: number; + }; + const sums = (qs = ""): Report => + resolveMock(url(`/organizations/${ID.org}/sums${qs}`), "GET") + .body as Report; + const iso = (daysAgo: number) => + new Date(Date.now() - daysAgo * 86_400_000).toISOString(); + + it("aggregates every emission when no range is given", () => { + const all = sums(); + // 12 + 12 + 6 sampled rows across the fixture's three runs. + expect(all.duration).toBe(30 * 5 * 60); + expect(all.emissions).toBeGreaterThan(0); + expect(all.energy_consumed).toBeGreaterThan(0); + }); + + it("excludes runs outside the range", () => { + const all = sums(); + // Fixtures sit at now-20d (x2) and now-5d; a 7-day window keeps only + // the most recent one, which has 6 of the 30 rows. + const week = sums(`?start_date=${iso(7)}&end_date=${iso(0)}`); + expect(week.duration).toBe(6 * 5 * 60); + expect(week.emissions).toBeLessThan(all.emissions); + expect(week.emissions).toBeGreaterThan(0); + }); + + it("returns zeros for a range containing nothing", () => { + const empty = sums(`?start_date=${iso(400)}&end_date=${iso(390)}`); + expect(empty).toMatchObject({ + emissions: 0, + energy_consumed: 0, + duration: 0, + }); + }); + + it("widening the range can only add emissions", () => { + const week = sums(`?start_date=${iso(7)}&end_date=${iso(0)}`); + const month = sums(`?start_date=${iso(30)}&end_date=${iso(0)}`); + expect(month.emissions).toBeGreaterThan(week.emissions); + expect(month.duration).toBeGreaterThan(week.duration); + }); + + it("ignores an unparseable date rather than failing", () => { + const r = resolveMock( + url(`/organizations/${ID.org}/sums?start_date=not-a-date`), + "GET", + ); + expect(r.status).toBe(200); + }); + }); + it("synthesizes an added user on POST /add-user", () => { const r = resolveMock( url(`/organizations/${ID.org}/add-user`), diff --git a/webapp/tests/components/account-menu.test.tsx b/webapp/tests/components/account-menu.test.tsx new file mode 100644 index 000000000..092f7e004 --- /dev/null +++ b/webapp/tests/components/account-menu.test.tsx @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const fetcherMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/swr", () => ({ fetcher: fetcherMock, swrConfig: {} })); +vi.mock("@/api/organizations", () => ({ getOrganizations: vi.fn() })); + +import AccountMenu from "@/components/account-menu"; +import { renderWithRouter } from "../test-utils"; +import { SWRConfig } from "swr"; + +const orgs = [ + { id: "o1", name: "Mozilla", description: "" }, + { id: "o2", name: "Wecasa", description: "" }, +]; + +/* + * `o1` is administered by the signed-in user and `o2` is not, which is the split + * the menu draws: administered dashboards below the rule, invited ones above it. + */ +function mockApi() { + fetcherMock.mockImplementation((key: string) => { + if (key === "/auth/check") + return Promise.resolve({ user: { id: "u1" } }); + if (key === "/organizations/o1/users") + return Promise.resolve([{ id: "u1", is_admin: true }]); + if (key === "/organizations/o2/users") + return Promise.resolve([{ id: "u1", is_admin: false }]); + return Promise.resolve([]); + }); +} + +function renderMenu() { + const onSelectOrg = vi.fn(); + renderWithRouter( + new Map(), dedupingInterval: 0 }}> + + + + , + ); + return { onSelectOrg }; +} + +beforeEach(() => { + fetcherMock.mockReset(); + navigateMock.mockReset(); + mockApi(); +}); + +describe("AccountMenu", () => { + it("lists the dashboards and the account actions once opened", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + + expect( + await screen.findByRole("menuitem", { name: "Mozilla" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Wecasa" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /add new organization/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Settings" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Log out" }), + ).toBeInTheDocument(); + }); + + it("marks the dashboard being viewed", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + + expect( + await screen.findByRole("menuitem", { name: "Mozilla" }), + ).toHaveAttribute("aria-current", "true"); + expect( + screen.getByRole("menuitem", { name: "Wecasa" }), + ).not.toHaveAttribute("aria-current"); + }); + + it("separates administered dashboards from invited ones", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + + // The heading only appears when there is something invited to list. + expect( + await screen.findByText(/dashboards you've been invited to/i), + ).toBeInTheDocument(); + + const items = screen + .getAllByRole("menuitem") + .map((item) => item.textContent); + // Invited first, administered after the rule. + expect(items.indexOf("Wecasa")).toBeLessThan(items.indexOf("Mozilla")); + }); + + it("keeps that split after the menu is closed and reopened", async () => { + renderMenu(); + const trigger = screen.getByRole("button", { name: "Account" }); + + await userEvent.click(trigger); + await screen.findByText(/dashboards you've been invited to/i); + await userEvent.keyboard("{Escape}"); + await userEvent.click(trigger); + + // Regression: keying the admin lookup on `open` dropped the result here, + // and every dashboard fell back into the invited group. + const items = screen + .getAllByRole("menuitem") + .map((item) => item.textContent); + expect(items.indexOf("Wecasa")).toBeLessThan(items.indexOf("Mozilla")); + }); + + it("switches dashboard when one is picked", async () => { + const { onSelectOrg } = renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Wecasa" }), + ); + expect(onSelectOrg).toHaveBeenCalledWith("o2"); + }); + + it("goes to settings from its own row", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Settings" }), + ); + await waitFor(() => + expect(navigateMock).toHaveBeenCalledWith("/settings"), + ); + }); +}); diff --git a/webapp/tests/components/breadcrumb.test.tsx b/webapp/tests/components/breadcrumb.test.tsx deleted file mode 100644 index 6eac8ae3e..000000000 --- a/webapp/tests/components/breadcrumb.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import BreadcrumbHeader from "@/components/breadcrumb"; -import { renderWithRouter } from "../test-utils"; - -describe("BreadcrumbHeader", () => { - it("renders all segments and links the ones with hrefs", () => { - renderWithRouter( - , - ); - - const orgLink = screen.getByRole("link", { name: /org/i }); - expect(orgLink).toHaveAttribute("href", "/org-1"); - expect(screen.getByRole("link", { name: /projects/i })).toHaveAttribute( - "href", - "/org-1/projects", - ); - // Last segment (no href) is plain text, not a link. - expect(screen.getByText("Project A").tagName).toBe("SPAN"); - }); -}); diff --git a/webapp/tests/components/consumed-energy-gauge.test.tsx b/webapp/tests/components/consumed-energy-gauge.test.tsx new file mode 100644 index 000000000..91ba6e940 --- /dev/null +++ b/webapp/tests/components/consumed-energy-gauge.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import ConsumedEnergyGauge from "@/components/consumed-energy-gauge"; + +/* + * The arc stands for "there is something here", so an empty range must not draw + * it — otherwise a zero gauge looks like some amount. + */ +describe("ConsumedEnergyGauge", () => { + it("draws the arc when there is a value", () => { + const { container } = render( + , + ); + expect(container.querySelector("path")).not.toBeNull(); + }); + + it("draws no arc at zero", () => { + const { container } = render( + , + ); + expect(container.querySelector("path")).toBeNull(); + // The track and the figures stay. + expect(container.querySelector("circle")).not.toBeNull(); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("names itself with its value and unit", () => { + render(); + expect(screen.getByRole("img", { name: "3 days" })).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/createProjectModal.test.tsx b/webapp/tests/components/create-project-modal.test.tsx similarity index 59% rename from webapp/tests/components/createProjectModal.test.tsx rename to webapp/tests/components/create-project-modal.test.tsx index f6601a64a..7e5a56f51 100644 --- a/webapp/tests/components/createProjectModal.test.tsx +++ b/webapp/tests/components/create-project-modal.test.tsx @@ -7,7 +7,7 @@ vi.mock("@/api/projects", () => ({ createProject: createProjectMock, })); -import CreateProjectModal from "@/components/createProjectModal"; +import CreateProjectModal from "@/components/create-project-modal"; beforeEach(() => { createProjectMock.mockReset(); @@ -22,6 +22,25 @@ beforeEach(() => { }); describe("CreateProjectModal", () => { + it("keeps the submit action disabled until a name is entered", async () => { + render( + , + ); + + const submit = screen.getByRole("button", { + name: /^create project$/i, + }); + expect(submit).toBeDisabled(); + + await userEvent.type(screen.getByLabelText(/^name$/i), "New project"); + expect(submit).toBeEnabled(); + }); + it("submits the form with name + description and the parent org id", async () => { const onProjectCreated = vi.fn().mockResolvedValue(undefined); const onClose = vi.fn(); @@ -35,17 +54,16 @@ describe("CreateProjectModal", () => { />, ); + // The redesign labels the fields "Name" and "Description", and the + // action "Create project". + await userEvent.type(screen.getByLabelText(/^name$/i), "New project"); await userEvent.type( - screen.getByPlaceholderText(/project name/i), - "New project", - ); - await userEvent.type( - screen.getByPlaceholderText(/project description/i), + screen.getByLabelText(/^description$/i), "Some desc", ); await userEvent.click( - screen.getByRole("button", { name: /^create$/i }), + screen.getByRole("button", { name: /^create project$/i }), ); // toast.promise resolves the inner thunk asynchronously. diff --git a/webapp/tests/components/equivalence-list.test.tsx b/webapp/tests/components/equivalence-list.test.tsx new file mode 100644 index 000000000..c7d06d63e --- /dev/null +++ b/webapp/tests/components/equivalence-list.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import EquivalenceList, { equivalences } from "@/components/equivalence-list"; + +/* + * Both dashboards render these from one builder, so the captions and units + * cannot drift apart again — they did once, and one page showed kilometres with + * no unit at all. + */ +describe("equivalences", () => { + it("carries the unit on every figure", () => { + const [citizen, transport, tv] = equivalences({ + citizen: "1.50", + transportation: "42.00", + tvTime: "7.00", + }); + + expect(citizen.value).toBe("1.50%"); + expect(transport.value).toBe("42.00 km"); + expect(tv.value).toBe("7.00 days"); + }); + + it("describes the first figure as a citizen's emissions, which is what it is", () => { + const [citizen] = equivalences({ + citizen: "1", + transportation: "1", + tvTime: "1", + }); + expect(citizen.caption).toMatch(/citizen/i); + expect(citizen.caption).toMatch(/emissions/i); + }); +}); + +describe("EquivalenceList", () => { + it("renders a figure and caption per item", () => { + render( + , + ); + + expect(screen.getByText("1.50%")).toBeInTheDocument(); + expect(screen.getByText("42.00 km")).toBeInTheDocument(); + expect(screen.getByText("7.00 days")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); +}); diff --git a/webapp/tests/components/nav-item.test.tsx b/webapp/tests/components/nav-item.test.tsx deleted file mode 100644 index f3d57ddfb..000000000 --- a/webapp/tests/components/nav-item.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import NavItem from "@/components/nav-item"; - -describe("NavItem", () => { - it("renders children and fires onClick", async () => { - const onClick = vi.fn(); - render( - - Dashboard - , - ); - - const button = screen.getByRole("button", { name: /dashboard/i }); - await userEvent.click(button); - expect(onClick).toHaveBeenCalledOnce(); - }); - - it("applies the selected styling when isSelected is true", () => { - render(Home); - const button = screen.getByRole("button", { name: /home/i }); - expect(button.className).toContain("text-primary"); - }); -}); diff --git a/webapp/tests/components/navbar.test.tsx b/webapp/tests/components/navbar.test.tsx deleted file mode 100644 index e44c66ef8..000000000 --- a/webapp/tests/components/navbar.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -const navigateMock = vi.hoisted(() => vi.fn()); -vi.mock("react-router-dom", async () => { - const actual = - await vi.importActual( - "react-router-dom", - ); - return { - ...actual, - useNavigate: () => navigateMock, - useLocation: () => ({ pathname: "/o1" }), - }; -}); - -vi.mock("@/api/organizations", () => ({ - getOrganizations: vi.fn().mockResolvedValue([]), -})); - -import NavBar from "@/components/navbar"; -import { renderWithRouter } from "../test-utils"; - -beforeEach(() => { - navigateMock.mockReset(); -}); - -const orgs = [ - { id: "o1", name: "Acme", description: "" }, - { id: "o2", name: "Beta", description: "" }, -]; - -describe("NavBar", () => { - it("renders the primary nav items", () => { - renderWithRouter(); - expect( - screen.getByRole("button", { name: /home/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /projects/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /members/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /log out/i }), - ).toBeInTheDocument(); - }); - - it("navigates to the organization root when Home is clicked", async () => { - renderWithRouter(); - await userEvent.click(screen.getByRole("button", { name: /home/i })); - expect(navigateMock).toHaveBeenCalledWith("/o1"); - }); - - it("navigates to //projects when Projects is clicked", async () => { - renderWithRouter(); - await userEvent.click( - screen.getByRole("button", { name: /projects/i }), - ); - expect(navigateMock).toHaveBeenCalledWith("/o1/projects"); - }); -}); diff --git a/webapp/tests/components/project-actions.test.tsx b/webapp/tests/components/project-actions.test.tsx new file mode 100644 index 000000000..9ecd15941 --- /dev/null +++ b/webapp/tests/components/project-actions.test.tsx @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@/api/projects", () => ({ updateProject: vi.fn() })); +vi.mock("@/api/runs", () => ({ + getEmissionsTimeSeries: vi.fn(), + getRunEmissionsByExperiment: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/components/projectTokens/projectTokenTable", () => ({ + ProjectTokensTable: () =>
, +})); + +const exportToJsonMock = vi.hoisted(() => vi.fn()); +vi.mock("@/utils/export", () => ({ exportToJson: exportToJsonMock })); + +import ProjectActions from "@/components/project-actions"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly run", + public: false, + organizationId: "o1", + experiments: [], +}; + +const runData = { + experimentId: "e1", + startDate: "2024-01-01", + endDate: "2024-02-01", +}; + +beforeEach(() => { + exportToJsonMock.mockReset(); +}); + +function renderActions() { + const onRefresh = vi.fn(); + render( + , + ); + return { onRefresh }; +} + +describe("ProjectActions", () => { + it("offers the project's actions, each named", () => { + renderActions(); + expect( + screen.getByRole("button", { name: /refresh data/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /download json export/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /project settings/i }), + ).toBeInTheDocument(); + }); + + it("hides the share control while the project is private", () => { + renderActions(); + expect( + screen.queryByRole("button", { name: /share project/i }), + ).not.toBeInTheDocument(); + }); + + it("refreshes when asked", async () => { + const { onRefresh } = renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /refresh data/i }), + ); + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()); + }); + + it("exports the project as JSON", async () => { + renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /download json export/i }), + ); + await waitFor(() => expect(exportToJsonMock).toHaveBeenCalledOnce()); + + const [payload] = exportToJsonMock.mock.calls[0]; + expect(payload.projects[0].id).toBe("p1"); + expect(payload.projects[0].date_range).toEqual({ + startDate: runData.startDate, + endDate: runData.endDate, + }); + }); + + it("opens project settings in place", async () => { + renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /project settings/i }), + ); + expect( + await screen.findByRole("heading", { name: /project settings/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/project-row.test.tsx b/webapp/tests/components/project-row.test.tsx new file mode 100644 index 000000000..98134d59d --- /dev/null +++ b/webapp/tests/components/project-row.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import ProjectRow from "@/components/project-row"; +import { Table, TableBody } from "@/components/ui/table"; +import { renderWithRouter } from "../test-utils"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly training run", + public: false, + organizationId: "o1", + experiments: [], +}; + +function renderRow(overrides: Partial[0]> = {}) { + const onSettings = vi.fn(); + const onDelete = vi.fn(); + renderWithRouter( + + + + +
, + ); + return { onSettings, onDelete }; +} + +describe("ProjectRow", () => { + it("links both the name and the secondary text to the project", () => { + renderRow(); + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(2); + links.forEach((link) => + expect(link).toHaveAttribute("href", "/o1/projects/p1"), + ); + }); + + it("omits the secondary link when there is no description", () => { + renderRow({ project: { ...project, description: "" } }); + expect(screen.getAllByRole("link")).toHaveLength(1); + }); + + it("names its overflow trigger after the project", () => { + renderRow(); + expect( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ).toBeInTheDocument(); + }); + + it("calls back when a menu action is picked", async () => { + const { onSettings, onDelete } = renderRow(); + + await userEvent.click( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: /settings/i }), + ); + expect(onSettings).toHaveBeenCalledOnce(); + expect(onDelete).not.toHaveBeenCalled(); + + await userEvent.click( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: /delete/i }), + ); + expect(onDelete).toHaveBeenCalledOnce(); + }); +}); diff --git a/webapp/tests/components/project-settings-modal.test.tsx b/webapp/tests/components/project-settings-modal.test.tsx new file mode 100644 index 000000000..e2db51151 --- /dev/null +++ b/webapp/tests/components/project-settings-modal.test.tsx @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const updateProjectMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/projects", () => ({ updateProject: updateProjectMock })); + +// The token table fetches on mount and is not what these tests are about. +vi.mock("@/components/projectTokens/projectTokenTable", () => ({ + ProjectTokensTable: () =>
, +})); + +import ProjectSettingsModal from "@/components/project-settings-modal"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly training run", + public: false, + organizationId: "o1", + experiments: [], +}; + +beforeEach(() => { + updateProjectMock.mockReset(); + updateProjectMock.mockResolvedValue(project); +}); + +function renderModal(overrides: Record = {}) { + const onOpenChange = vi.fn(); + const onProjectUpdated = vi.fn(); + render( + , + ); + return { onOpenChange, onProjectUpdated }; +} + +describe("ProjectSettingsModal", () => { + it("saves the visibility toggle on its own and reveals the sharing link", async () => { + renderModal(); + + expect( + screen.queryByRole("button", { name: /copy link/i }), + ).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("switch", { name: /make project public/i }), + ); + + await waitFor(() => + expect(updateProjectMock).toHaveBeenCalledWith("p1", { + name: project.name, + description: project.description, + public: true, + }), + ); + expect( + await screen.findByRole("button", { name: /copy link/i }), + ).toBeInTheDocument(); + }); + + it("does not commit unsaved field edits when the toggle is flipped", async () => { + renderModal(); + + const name = screen.getByLabelText(/^name$/i); + await userEvent.clear(name); + await userEvent.type(name, "Renamed but unsaved"); + + await userEvent.click( + screen.getByRole("switch", { name: /make project public/i }), + ); + + await waitFor(() => + expect(updateProjectMock).toHaveBeenCalledWith( + "p1", + expect.objectContaining({ name: project.name }), + ), + ); + }); + + it("rolls the toggle back when the write fails", async () => { + updateProjectMock.mockRejectedValue(new Error("nope")); + vi.spyOn(console, "error").mockImplementation(() => {}); + renderModal(); + + const toggle = screen.getByRole("switch", { + name: /make project public/i, + }); + await userEvent.click(toggle); + + await waitFor(() => + expect(toggle).toHaveAttribute("data-state", "unchecked"), + ); + expect( + screen.queryByRole("button", { name: /copy link/i }), + ).not.toBeInTheDocument(); + }); + + it("closes on a successful save", async () => { + const { onOpenChange, onProjectUpdated } = renderModal(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => expect(onProjectUpdated).toHaveBeenCalled()); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("stays open when the save fails, so the edits survive", async () => { + updateProjectMock.mockRejectedValue(new Error("nope")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const { onOpenChange } = renderModal(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => expect(updateProjectMock).toHaveBeenCalled()); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + + it("opens on General, whichever tab it was left on", async () => { + const { rerender } = render( + , + ); + + await userEvent.click(screen.getByRole("tab", { name: /api tokens/i })); + expect( + screen.getByRole("tab", { name: /api tokens/i }), + ).toHaveAttribute("aria-selected", "true"); + + // Closed and opened again: the dialog stays mounted between openings. + rerender( + , + ); + rerender( + , + ); + + expect(screen.getByRole("tab", { name: /general/i })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("disables the save action until the project has a name", async () => { + renderModal(); + + const save = screen.getByRole("button", { name: /save changes/i }); + expect(save).toBeEnabled(); + + await userEvent.clear(screen.getByLabelText(/^name$/i)); + expect(save).toBeDisabled(); + }); +}); diff --git a/webapp/tests/components/project-visibility-badge.test.tsx b/webapp/tests/components/project-visibility-badge.test.tsx new file mode 100644 index 000000000..b757373b3 --- /dev/null +++ b/webapp/tests/components/project-visibility-badge.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import ProjectVisibilityBadge from "@/components/project-visibility-badge"; + +describe("ProjectVisibilityBadge", () => { + it("says Public when the project is public", () => { + render(); + expect(screen.getByText("Public")).toBeInTheDocument(); + }); + + it("says Private otherwise", () => { + render(); + expect(screen.getByText("Private")).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/projectTokens/projectTokenTable.test.tsx b/webapp/tests/components/projectTokens/projectTokenTable.test.tsx index 5732e69f0..ba71b1fff 100644 --- a/webapp/tests/components/projectTokens/projectTokenTable.test.tsx +++ b/webapp/tests/components/projectTokens/projectTokenTable.test.tsx @@ -32,7 +32,7 @@ describe("ProjectTokensTable", () => { renderWithRouter(); await userEvent.click( await screen.findByRole("button", { - name: /\+ create a new token/i, + name: /create a new token/i, }), ); expect(screen.getByPlaceholderText(/token name/i)).toBeInTheDocument(); diff --git a/webapp/tests/components/radial-chart.test.tsx b/webapp/tests/components/radial-chart.test.tsx deleted file mode 100644 index 357b645d5..000000000 --- a/webapp/tests/components/radial-chart.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; - -class NoopResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} -// @ts-expect-error -- jsdom doesn't have it -globalThis.ResizeObserver = NoopResizeObserver; - -import RadialChart from "@/components/radial-chart"; - -describe("RadialChart", () => { - it("falls back to 'No data available' when value is undefined", () => { - render( - // @ts-expect-error -- testing the missing-value branch - , - ); - expect(screen.getByText(/no data available/i)).toBeInTheDocument(); - }); - - it("does not show the empty-state when value is defined", () => { - render(); - // The fallback is hidden when a value is provided. We don't assert - // on Recharts SVG output because jsdom doesn't measure layout, so - // the chart never reaches the rendered phase here. - expect( - screen.queryByText(/no data available/i), - ).not.toBeInTheDocument(); - }); -}); diff --git a/webapp/tests/components/share-project-button.test.tsx b/webapp/tests/components/share-project-button.test.tsx index d955e22aa..aa5d6131b 100644 --- a/webapp/tests/components/share-project-button.test.tsx +++ b/webapp/tests/components/share-project-button.test.tsx @@ -40,9 +40,9 @@ describe("ShareProjectButton", () => { screen.getByRole("button", { name: /share project/i }), ); - const input = await screen.findByDisplayValue( - /\/public\/projects\/p1$/, - ); - expect(input).toBeInTheDocument(); + // The link is shown as text, not in a field: it is read and copied, + // never typed into. + const link = await screen.findByText(/\/public\/projects\/p1$/); + expect(link).toBeInTheDocument(); }); }); diff --git a/webapp/tests/components/sidebar-rail.test.tsx b/webapp/tests/components/sidebar-rail.test.tsx new file mode 100644 index 000000000..d93058506 --- /dev/null +++ b/webapp/tests/components/sidebar-rail.test.tsx @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +const locationMock = vi.hoisted(() => ({ current: { pathname: "/o1" } })); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useNavigate: () => navigateMock, + useLocation: () => locationMock.current, + }; +}); + +// The account menu fetches on open; the rail's own behaviour is what is tested. +vi.mock("@/components/account-menu", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import SidebarRail from "@/components/sidebar-rail"; +import { renderWithRouter } from "../test-utils"; + +const orgs = [ + { id: "o1", name: "Mozilla", description: "" }, + { id: "o2", name: "Wecasa", description: "" }, +]; + +beforeEach(() => { + navigateMock.mockReset(); + localStorage.clear(); + locationMock.current = { pathname: "/o1" }; +}); + +describe("SidebarRail", () => { + it("offers the dashboard's destinations", () => { + renderWithRouter(); + ["Global", "Projects", "Members", "Account"].forEach((label) => + expect( + screen.getByRole("button", { name: label }), + ).toBeInTheDocument(), + ); + }); + + it("marks the destination matching the current path", () => { + locationMock.current = { pathname: "/o1/projects" }; + renderWithRouter(); + + expect( + screen.getByRole("button", { name: "Projects" }), + ).toHaveAttribute("aria-current", "page"); + expect( + screen.getByRole("button", { name: "Global" }), + ).not.toHaveAttribute("aria-current"); + }); + + it("navigates within the organization in the URL", async () => { + locationMock.current = { pathname: "/o2/members" }; + renderWithRouter(); + + await userEvent.click(screen.getByRole("button", { name: "Projects" })); + expect(navigateMock).toHaveBeenCalledWith("/o2/projects"); + }); + + it("remembers the organization it resolved, for the pages that read it", () => { + renderWithRouter(); + expect(localStorage.getItem("organizationId")).toBe("o1"); + expect(localStorage.getItem("organizationName")).toBe("Mozilla"); + }); + + it("does not navigate before an organization is known", async () => { + renderWithRouter(); + await userEvent.click(screen.getByRole("button", { name: "Projects" })); + expect(navigateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/tests/components/ui/form-field.test.tsx b/webapp/tests/components/ui/form-field.test.tsx new file mode 100644 index 000000000..edf409b22 --- /dev/null +++ b/webapp/tests/components/ui/form-field.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { FormField } from "@/components/ui/form-field"; + +describe("FormField", () => { + it("ties its label to its control", () => { + render(); + expect(screen.getByLabelText("Token name")).toBe( + screen.getByRole("textbox"), + ); + }); + + it("keeps the accessible name when the label is hidden", () => { + render(); + // Still reachable by name, but not shown as a visible label. + expect(screen.getByLabelText("Email address")).toBeInTheDocument(); + expect(screen.getByText("Email address")).toHaveClass("sr-only"); + }); +}); diff --git a/webapp/tests/pages/MembersPage.test.tsx b/webapp/tests/pages/MembersPage.test.tsx index 355e2e9b1..5e35a12c1 100644 --- a/webapp/tests/pages/MembersPage.test.tsx +++ b/webapp/tests/pages/MembersPage.test.tsx @@ -16,14 +16,28 @@ vi.mock("@/api/swr", () => ({ swrConfig: {}, })); +const addOrganizationUserMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/organizations", () => ({ + addOrganizationUser: addOrganizationUserMock, +})); + import MembersPage from "@/pages/MembersPage"; import { renderWithRouter } from "../test-utils"; import { SWRConfig } from "swr"; beforeEach(() => { fetcherMock.mockReset(); + addOrganizationUserMock.mockReset(); + addOrganizationUserMock.mockResolvedValue(undefined); }); +function mockOrganization(url: string) { + if (url.endsWith("/organizations/o1")) { + return Promise.resolve({ id: "o1", name: "Acme", description: "" }); + } + return undefined; +} + function renderWithSwr(node: React.ReactNode) { return renderWithRouter( new Map(), dedupingInterval: 0 }}> @@ -33,7 +47,7 @@ function renderWithSwr(node: React.ReactNode) { } describe("MembersPage", () => { - it("renders the user list once loaded", async () => { + it("renders the member list once loaded", async () => { // SWR calls the fetcher per key; first matching response wins per key. fetcherMock.mockImplementation((url: string) => { if (url.endsWith("/users")) { @@ -42,43 +56,90 @@ describe("MembersPage", () => { id: "u1", name: "Alice", email: "alice@example.com", - is_active: true, - organizations: ["o1"], + organization_id: "o1", + is_admin: true, }, ]); } - if (url.endsWith("/organizations/o1")) { - return Promise.resolve({ - id: "o1", - name: "Acme", - description: "", - }); - } - return Promise.resolve(null); + return mockOrganization(url) ?? Promise.resolve(null); }); renderWithSwr(); expect(await screen.findByText("Alice")).toBeInTheDocument(); expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + // The status slot carries the only standing the API records. + expect(screen.getByText("(Admin)")).toBeInTheDocument(); + }); + + it("shows the empty state when the organization has no members", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + + renderWithSwr(); + + expect( + await screen.findByText(/you have no members invited yet/i), + ).toBeInTheDocument(); + }); + + it("keeps the invite button disabled until an address is typed", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + + renderWithSwr(); + + const button = await screen.findByRole("button", { name: /invite/i }); + expect(button).toBeDisabled(); + + await userEvent.type( + screen.getByLabelText(/invite via email/i), + "new@example.com", + ); + expect(button).toBeEnabled(); + }); + + it("invites the typed address", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + + renderWithSwr(); + + await userEvent.type( + await screen.findByLabelText(/invite via email/i), + "new@example.com", + ); + await userEvent.click(screen.getByRole("button", { name: /invite/i })); + + expect(addOrganizationUserMock).toHaveBeenCalledWith( + "o1", + "new@example.com", + ); }); - it("opens the add-member form when the button is clicked", async () => { + it("does not send an invalid address to the API", async () => { fetcherMock.mockImplementation((url: string) => { if (url.endsWith("/users")) return Promise.resolve([]); - if (url.endsWith("/organizations/o1")) - return Promise.resolve({ - id: "o1", - name: "Acme", - description: "", - }); - return Promise.resolve(null); + return mockOrganization(url) ?? Promise.resolve(null); }); renderWithSwr(); - await userEvent.click( - await screen.findByRole("button", { name: /\+ add a member/i }), + + await userEvent.type( + await screen.findByLabelText(/invite via email/i), + "not-an-email", ); - expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /invite/i })); + + // `type="email"` fails the form's own constraint validation, so the + // submit never reaches the handler. The page's schema check behind it + // covers whatever the browser lets through. + expect(addOrganizationUserMock).not.toHaveBeenCalled(); }); }); diff --git a/webapp/tests/pages/SettingsPage.test.tsx b/webapp/tests/pages/SettingsPage.test.tsx new file mode 100644 index 000000000..0c9c5a42c --- /dev/null +++ b/webapp/tests/pages/SettingsPage.test.tsx @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const fetcherMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/swr", () => ({ fetcher: fetcherMock, swrConfig: {} })); + +import SettingsPage from "@/pages/SettingsPage"; +import { renderWithRouter } from "../test-utils"; +import { SWRConfig } from "swr"; + +function renderPage() { + return renderWithRouter( + new Map(), dedupingInterval: 0 }}> + + , + ); +} + +beforeEach(() => { + fetcherMock.mockReset(); + navigateMock.mockReset(); + fetcherMock.mockResolvedValue({ user: { email: "person@example.com" } }); +}); + +describe("SettingsPage", () => { + it("shows the signed-in address rather than a placeholder", async () => { + renderPage(); + expect( + await screen.findByDisplayValue("person@example.com"), + ).toBeDisabled(); + }); + + it("marks Profile as the panel being viewed, and does not make it pressable", () => { + renderPage(); + // The current row is not a button: there is nothing to press. + expect( + screen.queryByRole("button", { name: /profile/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByText("Profile", { selector: "span" }), + ).toBeInTheDocument(); + }); + + it("goes back through history", async () => { + renderPage(); + await userEvent.click(screen.getByRole("button", { name: /go back/i })); + expect(navigateMock).toHaveBeenCalledWith(-1); + }); + + it("disables the provider hand-offs when no provider is configured", () => { + renderPage(); + // VITE_OIDC_PROFILE_URL is unset in tests, so both are inert. + expect( + screen.getByRole("button", { name: /password/i }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: /change/i })).toBeDisabled(); + }); +}); diff --git a/webapp/tests/setup.ts b/webapp/tests/setup.ts index 8ed0c3f4e..1b45f39c4 100644 --- a/webapp/tests/setup.ts +++ b/webapp/tests/setup.ts @@ -2,6 +2,24 @@ import "@testing-library/jest-dom/vitest"; import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; +/* + * jsdom implements neither of these, and Radix reaches for both: `useSize` + * observes a control's box (the switch does), and its popper measures scroll + * geometry. Stubbed here rather than in each test, so a component that happens + * to use one does not fail for a reason unrelated to what is being tested. + */ +if (!("ResizeObserver" in globalThis)) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + afterEach(() => { cleanup(); });