diff --git a/apps/p99/helm/values.yaml b/apps/p99/helm/values.yaml
index 4af2932..a4046b5 100644
--- a/apps/p99/helm/values.yaml
+++ b/apps/p99/helm/values.yaml
@@ -5,3 +5,11 @@ ui-base:
image:
repository: ghcr.io/diamondlightsource/atlas/p99
tag: # Updated by CI job with latest release version
+
+ upstreams:
+ - id: blueapi
+ path: /api/
+ rewriteTarget: /
+ target:
+ external:
+ uri: https://p99-blueapi.diamond.ac.uk/
diff --git a/apps/p99/package.json b/apps/p99/package.json
index ae40421..d245b92 100644
--- a/apps/p99/package.json
+++ b/apps/p99/package.json
@@ -14,12 +14,17 @@
"coverage": "vitest run --coverage"
},
"dependencies": {
+ "@atlas/blueapi": "workspace:*",
+ "@atlas/blueapi-query": "workspace:*",
"@diamondlightsource/sci-react-ui": "^0.2.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^6.5.0",
"@mui/material": "<7.0.0",
- "react-router-dom": "^7.7.1"
+ "@tanstack/react-query": "^5.90.21",
+ "@testing-library/react": "^15.0.7",
+ "react-router-dom": "^7.7.1",
+ "vite-plugin-relay": "^2.1.0"
},
"devDependencies": {
"@atlas/vitest-conf": "workspace:*",
diff --git a/apps/p99/src/components/DevicePanel.test.tsx b/apps/p99/src/components/DevicePanel.test.tsx
new file mode 100644
index 0000000..253ff29
--- /dev/null
+++ b/apps/p99/src/components/DevicePanel.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen } from "@atlas/vitest-conf";
+import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui";
+import { DevicePanel } from "./DevicePanel";
+import { describe, it, expect } from "vitest";
+
+const mockDevicesData = {
+ devices: [
+ { name: "detector-1", type: "detector" },
+ { name: "motor-x", type: "motor" },
+ { name: "motor-y", type: "motor" },
+ ],
+};
+
+describe("DevicePanel", () => {
+ it("renders the devices panel with title", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Connected Devices/)).toBeInTheDocument();
+ });
+
+ it("displays the correct device count", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Connected Devices \(3\)/)).toBeInTheDocument();
+ });
+
+ it("renders all devices as chips", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("detector-1")).toBeInTheDocument();
+ expect(screen.getByText("motor-x")).toBeInTheDocument();
+ expect(screen.getByText("motor-y")).toBeInTheDocument();
+ });
+
+ it("shows empty state when no devices are present", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Connected Devices \(0\)/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/No devices detected on the worker/),
+ ).toBeInTheDocument();
+ });
+
+ it("handles undefined devicesData gracefully", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Connected Devices \(0\)/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/No devices detected on the worker/),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/p99/src/components/DevicePanel.tsx b/apps/p99/src/components/DevicePanel.tsx
new file mode 100644
index 0000000..5d6c14d
--- /dev/null
+++ b/apps/p99/src/components/DevicePanel.tsx
@@ -0,0 +1,85 @@
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Stack,
+ Chip,
+ Box,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";
+
+interface DeviceInfo {
+ name: string;
+ [key: string]: unknown;
+}
+
+interface DevicePanelProps {
+ devicesData:
+ | {
+ devices?: DeviceInfo[];
+ }
+ | undefined;
+}
+
+export function DevicePanel({ devicesData }: DevicePanelProps) {
+ const devices = devicesData?.devices || [];
+
+ return (
+
+
+ }>
+
+
+
+ Connected Devices ({devices.length})
+
+
+
+
+ {devices.length === 0 ? (
+
+ No devices detected on the worker.
+
+ ) : (
+
+ {devices.map((device: DeviceInfo) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/p99/src/components/PlanCard.test.tsx b/apps/p99/src/components/PlanCard.test.tsx
new file mode 100644
index 0000000..3156b05
--- /dev/null
+++ b/apps/p99/src/components/PlanCard.test.tsx
@@ -0,0 +1,159 @@
+import { render, screen, waitFor } from "@atlas/vitest-conf";
+import { fireEvent } from "@testing-library/react";
+import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui";
+import { PlanCard } from "./PlanCard";
+import type { Plan } from "@atlas/blueapi";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+
+const mockUseGetWorkerState = vi.fn(() => ({ data: "IDLE" }));
+const mockSubmitTask = {
+ mutateAsync: vi.fn(() => Promise.resolve({ task_id: "task-1" })),
+};
+const mockStartTask = { mutateAsync: vi.fn(() => Promise.resolve()) };
+
+vi.mock("@atlas/blueapi-query", () => ({
+ useGetWorkerState: () => mockUseGetWorkerState(),
+ useSubmitTask: () => mockSubmitTask,
+ useSetActiveTask: () => mockStartTask,
+}));
+
+const mockPlan: Plan = {
+ name: "test-plan",
+ description: "Test plan description\n\nParameters:\nSome params",
+ schema: {
+ properties: {
+ param1: {
+ type: "string",
+ title: "Parameter 1",
+ description: "First parameter",
+ },
+ param2: {
+ type: "number",
+ title: "Parameter 2",
+ description: "Second parameter",
+ },
+ },
+ required: ["param1"],
+ },
+};
+
+describe("PlanCard", () => {
+ const mockOnSuccess = vi.fn();
+ const mockOnError = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseGetWorkerState.mockReset();
+ mockUseGetWorkerState.mockReturnValue({ data: "IDLE" });
+ mockSubmitTask.mutateAsync.mockClear();
+ mockStartTask.mutateAsync.mockClear();
+ });
+
+ it("renders the plan name and description", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("test-plan")).toBeInTheDocument();
+ expect(screen.getByText(/Test plan description/)).toBeInTheDocument();
+ });
+
+ it("displays Python chip", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Python")).toBeInTheDocument();
+ });
+
+ it("renders accordion with configure details", async () => {
+ render(
+
+
+ ,
+ );
+
+ // Click accordion to expand
+ const accordionButton = screen.getByRole("button", {
+ name: /Configure & View Details/i,
+ });
+ fireEvent.click(accordionButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Protocol Documentation/)).toBeInTheDocument();
+ });
+ });
+
+ it("enables submit button when worker is idle", () => {
+ render(
+
+
+ ,
+ );
+
+ const buttons = screen.getAllByRole("button");
+ const runButton = buttons.find(btn => btn.textContent?.includes("Run"));
+ expect(runButton).not.toBeDisabled();
+ });
+
+ it("disables submit button when worker is busy", () => {
+ mockUseGetWorkerState.mockReturnValue({ data: "RUNNING" });
+
+ render(
+
+
+ ,
+ );
+
+ const buttons = screen.getAllByRole("button");
+ const runButton = buttons.find(btn => btn.textContent?.includes("Run"));
+ expect(runButton).toBeDisabled();
+ });
+
+ it("shows configure & view details button", () => {
+ render(
+
+
+ ,
+ );
+
+ const configButton = screen.getByRole("button", {
+ name: /Configure & View Details/i,
+ });
+ expect(configButton).toBeInTheDocument();
+ });
+});
diff --git a/apps/p99/src/components/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx
new file mode 100644
index 0000000..58e1a2a
--- /dev/null
+++ b/apps/p99/src/components/PlanCard.tsx
@@ -0,0 +1,286 @@
+import { useState } from "react";
+import {
+ Card,
+ CardContent,
+ Stack,
+ Typography,
+ Chip,
+ Divider,
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ TextField,
+ Box,
+ Button,
+ CircularProgress,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import CodeIcon from "@mui/icons-material/Code";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import type { Plan } from "@atlas/blueapi";
+import {
+ useGetWorkerState,
+ useSetActiveTask,
+ useSubmitTask,
+} from "@atlas/blueapi-query";
+
+interface SchemaProperty {
+ type?: string;
+ title?: string;
+ description?: string;
+}
+
+interface PydanticValidationError {
+ loc?: (string | number)[];
+ msg: string;
+ type: string;
+}
+
+interface PlanCardProps {
+ plan: Plan;
+ instrumentSession: string;
+ onSuccess: (msg: string) => void;
+ onError: (msg: string) => void;
+}
+
+export function PlanCard({
+ plan,
+ instrumentSession,
+ onSuccess,
+ onError,
+}: PlanCardProps) {
+ const [formValues, setFormValues] = useState>(
+ {},
+ );
+ const [submitting, setSubmitting] = useState(false);
+ const submitTask = useSubmitTask();
+ const startTask = useSetActiveTask();
+ const workerState = useGetWorkerState();
+
+ const cleanDescription = plan.description?.split(/parameters/i)[0].trim();
+
+ // JSON Schema blocks from the generic Plan type object
+ const planSchema = plan.schema as
+ | { properties?: Record; required?: string[] }
+ | undefined;
+ const properties = planSchema?.properties || {};
+ const requiredFields = planSchema?.required || [];
+
+ const handleInputChange = (paramName: string, value: string) => {
+ setFormValues(prev => ({ ...prev, [paramName]: value }));
+ };
+
+ const handleSubmit = async () => {
+ setSubmitting(true);
+ try {
+ const processedParams: Record<
+ string,
+ string | number | (string | number)[]
+ > = {};
+
+ Object.entries(properties).forEach(([key, value]) => {
+ const userValue = formValues[key];
+
+ if (userValue === undefined || userValue === "") return;
+
+ if (value.type === "array") {
+ if (typeof userValue === "string") {
+ processedParams[key] = userValue
+ .split(",")
+ .map(item => item.trim())
+ .filter(item => item !== "");
+ } else {
+ processedParams[key] = [userValue];
+ }
+ } else if (value.type === "number" || value.type === "integer") {
+ processedParams[key] = Number(userValue);
+ } else {
+ processedParams[key] = userValue;
+ }
+ });
+
+ const submitResult = await submitTask.mutateAsync({
+ name: plan.name || "",
+ params: processedParams,
+ instrument_session: instrumentSession,
+ });
+
+ await startTask.mutateAsync(submitResult.task_id);
+ onSuccess(`Plan ${plan.name} started successfully!`);
+ } catch (err: unknown) {
+ const axiosError = err as {
+ response?: { data?: { detail?: string | PydanticValidationError[] } };
+ };
+ const errorData = axiosError.response?.data?.detail;
+ let userFriendlyMessage = `Execution failed for ${plan.name}.`;
+
+ if (typeof errorData === "string") {
+ userFriendlyMessage = errorData;
+ } else if (Array.isArray(errorData)) {
+ userFriendlyMessage = errorData
+ .map((e: PydanticValidationError) => {
+ const field = e.loc ? e.loc[e.loc.length - 1] : "Parameter";
+ return `${field}: ${e.msg}`;
+ })
+ .join(" | ");
+ }
+
+ onError(userFriendlyMessage);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {plan.name}
+
+
+
+
+
+
+
+ } sx={{ px: 0 }}>
+
+
+
+ Configure & View Details
+
+
+
+
+
+
+ {cleanDescription && (
+
+
+ Protocol Documentation:
+
+
+ {cleanDescription}
+
+
+ )}
+
+ {Object.entries(properties).map(([key, value]) => {
+ const isRequired = requiredFields.includes(key);
+ const isDevice =
+ key.toLowerCase().includes("device") ||
+ key.toLowerCase().includes("detector");
+
+ return (
+ handleInputChange(key, e.target.value)}
+ helperText={
+ value.type === "array"
+ ? `Enter values separated by commas (e.g. det1, det2). ${value.description || ""}`
+ : isDevice
+ ? `Enter device ID (e.g. motor_x). ${value.description || ""}`
+ : value.description
+ }
+ slotProps={{ inputLabel: { shrink: true } }}
+ placeholder={
+ value.type === "array"
+ ? "det1, det2"
+ : isDevice
+ ? "e.g., detector_a"
+ : ""
+ }
+ sx={{
+ ...(isRequired && {
+ "& .MuiInputLabel-root": {
+ color: "warning.main",
+ fontWeight: "bold",
+ },
+ "& .MuiOutlinedInput-root": {
+ "& fieldset": {
+ borderColor: "rgba(237, 108, 2, 0.5)",
+ },
+ "&:hover fieldset": { borderColor: "warning.main" },
+ },
+ }),
+ }}
+ />
+ );
+ })}
+
+
+
+
+
+
+ :
+ }
+ onClick={handleSubmit}
+ disabled={submitting || workerState.data !== "IDLE"}
+ >
+ {submitting ? "Running..." : `Run ${plan.name}`}
+
+
+
+ );
+}
diff --git a/apps/p99/src/components/WorkerStatusBar.test.tsx b/apps/p99/src/components/WorkerStatusBar.test.tsx
new file mode 100644
index 0000000..acf1fb1
--- /dev/null
+++ b/apps/p99/src/components/WorkerStatusBar.test.tsx
@@ -0,0 +1,188 @@
+import { render, screen, waitFor } from "@atlas/vitest-conf";
+import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui";
+import { WorkerStatusBar } from "./WorkerStatusBar";
+import { describe, it, expect, vi } from "vitest";
+
+describe("WorkerStatusBar", () => {
+ const mockOnSync = vi.fn();
+ const mockOnSessionChange = vi.fn();
+
+ it("renders the title and subtitle", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("P99 Control")).toBeInTheDocument();
+ expect(screen.getByText("Beamline Plan Library")).toBeInTheDocument();
+ });
+
+ it("displays worker state as IDLE", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/STATE: IDLE/)).toBeInTheDocument();
+ });
+
+ it("displays worker state as RUNNING", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/STATE: RUNNING/)).toBeInTheDocument();
+ expect(screen.getByText(/ID: task-/)).toBeInTheDocument();
+ });
+
+ it("displays worker state as PAUSED", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/STATE: PAUSED/)).toBeInTheDocument();
+ });
+
+ it("displays PANICKED state", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/STATE: PANICKED/)).toBeInTheDocument();
+ });
+
+ it("displays ABORTING state", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/STATE: ABORTING/)).toBeInTheDocument();
+ });
+
+ it("renders sync button", () => {
+ render(
+
+
+ ,
+ );
+
+ const syncButton = screen.getByRole("button", { name: /sync/i });
+ expect(syncButton).toBeInTheDocument();
+ });
+
+ it("disables sync button when fetching", () => {
+ render(
+
+
+ ,
+ );
+
+ const syncButton = screen.getByRole("button", { name: /sync/i });
+ expect(syncButton).toBeDisabled();
+ });
+
+ it("calls onSync when sync button is clicked", async () => {
+ render(
+
+
+ ,
+ );
+
+ const syncButton = screen.getByRole("button", { name: /sync/i });
+ syncButton.click();
+
+ await waitFor(() => {
+ expect(mockOnSync).toHaveBeenCalled();
+ });
+ });
+
+ it("displays UNKNOWN state", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/STATE: UNKNOWN/)).toBeInTheDocument();
+ });
+});
diff --git a/apps/p99/src/components/WorkerStatusBar.tsx b/apps/p99/src/components/WorkerStatusBar.tsx
new file mode 100644
index 0000000..e90614c
--- /dev/null
+++ b/apps/p99/src/components/WorkerStatusBar.tsx
@@ -0,0 +1,130 @@
+import {
+ Box,
+ Typography,
+ Stack,
+ Button,
+ CircularProgress,
+ Paper,
+ TextField,
+} from "@mui/material";
+import LoopIcon from "@mui/icons-material/Loop";
+
+import type { WorkerState } from "@atlas/blueapi";
+
+interface WorkerStatusBarProps {
+ workerState: WorkerState;
+ activeTaskId: string | null;
+ isFetching: boolean;
+ onSync: () => void;
+ instrumentSession: string;
+ onInstrumentSessionChange: (session: string) => void;
+}
+
+const getStatusColor = (state: WorkerState) => {
+ switch (state) {
+ case "RUNNING":
+ return { bg: "#e8f5e9", text: "#2e7d32", border: "#a5d6a7" };
+ case "IDLE":
+ return { bg: "#e3f2fd", text: "#1565c0", border: "#90caf9" };
+ case "PAUSED":
+ return { bg: "#fff3e0", text: "#ef6c00", border: "#ffcc80" };
+ case "PANICKED":
+ case "ABORTING":
+ return { bg: "#ffebee", text: "#c62828", border: "#ef9a9a" };
+ default:
+ return { bg: "#f5f5f5", text: "#616161", border: "#e0e0e0" };
+ }
+};
+export function WorkerStatusBar({
+ workerState,
+ activeTaskId,
+ isFetching,
+ onSync,
+ instrumentSession,
+ onInstrumentSessionChange,
+}: WorkerStatusBarProps) {
+ const statusStyle = getStatusColor(workerState);
+ return (
+
+
+
+
+ P99 Control
+
+
+ Beamline Plan Library
+
+
+
+
+ onInstrumentSessionChange(e.target.value)}
+ sx={{ minWidth: 180, bgcolor: "background.paper" }}
+ slotProps={{ inputLabel: { shrink: true } }}
+ />
+
+
+ {workerState === "RUNNING" && (
+
+ )}
+ STATE: {workerState}
+ {activeTaskId && (
+
+ ID: {activeTaskId.substring(0, 8)}...
+
+ )}
+
+
+ }
+ onClick={onSync}
+ disabled={isFetching}
+ >
+ Sync
+
+
+
+
+ );
+}
diff --git a/apps/p99/src/main.tsx b/apps/p99/src/main.tsx
index 68187c4..72851a2 100644
--- a/apps/p99/src/main.tsx
+++ b/apps/p99/src/main.tsx
@@ -6,6 +6,10 @@ import { createRoot } from "react-dom/client";
import Dashboard from "./routes/Dashboard.tsx";
import { Layout } from "./routes/Layout.tsx";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { BlueapiProvider } from "@atlas/blueapi-query";
+import { createApi } from "@atlas/blueapi";
+
const router = createBrowserRouter([
{
path: "/",
@@ -19,10 +23,17 @@ const router = createBrowserRouter([
},
]);
+const queryClient = new QueryClient();
+export const api = createApi("/api");
+
createRoot(document.getElementById("root")!).render(
-
+
+
+
+
+
,
);
diff --git a/apps/p99/src/routes/Dashboard.tsx b/apps/p99/src/routes/Dashboard.tsx
index 2456fc8..55169e6 100644
--- a/apps/p99/src/routes/Dashboard.tsx
+++ b/apps/p99/src/routes/Dashboard.tsx
@@ -1,17 +1,121 @@
-import { Box, Container, Typography } from "@mui/material";
+import { useState } from "react";
+import { Box, Container, Alert, Grid2 } from "@mui/material";
+import {
+ usePlans,
+ useDevices,
+ useGetWorkerState,
+ useActiveTask,
+} from "@atlas/blueapi-query";
+import type { Plan } from "@atlas/blueapi";
+
+import { WorkerStatusBar } from "../components/WorkerStatusBar";
+import { DevicePanel } from "../components/DevicePanel";
+import { PlanCard } from "../components/PlanCard";
+
+interface FeedbackState {
+ type: "success" | "error";
+ msg: string;
+}
function Dashboard() {
+ const { data: plansData, isError } = usePlans();
+ const { data: devicesData } = useDevices();
+ const {
+ data: workerState,
+ isFetching: isWorkerFetching,
+ refetch: refetchWorkerState,
+ } = useGetWorkerState();
+ const { data: activeTask } = useActiveTask();
+ const activeTaskId = activeTask?.task_id ?? null;
+
+ const [feedback, setFeedback] = useState(null);
+ const [instrumentSession, setInstrumentSession] = useState("p99-session-01");
+
return (
-
-
-
- P99
-
-
- Minimal P99
-
-
-
+
+ {/* Sticky Global Status Header */}
+
+
+ {/* Sticky Global Feedback Alert Wrapper */}
+ {feedback && (
+
+ setFeedback(null)}
+ elevation={3}
+ >
+ {feedback.msg}
+
+
+ )}
+
+
+ {isError && (
+
+ Unauthorized or Service Unreachable. Check proxy configs or Keycloak
+ authentication.
+
+ )}
+
+
+ {/* LEFT COLUMN: Scrollable Plans Library */}
+
+
+ {(plansData?.plans || []).map((plan: Plan) => (
+
+
+ setFeedback({ type: "success", msg })
+ }
+ onError={(msg: string) =>
+ setFeedback({ type: "error", msg })
+ }
+ />
+
+ ))}
+
+
+
+ {/* RIGHT COLUMN: Persistent Device Panel */}
+
+
+
+
+
+
);
}
diff --git a/apps/p99/vite.config.ts b/apps/p99/vite.config.ts
index 98967c2..bbbc621 100644
--- a/apps/p99/vite.config.ts
+++ b/apps/p99/vite.config.ts
@@ -1,8 +1,27 @@
+// apps/p99/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
+import relay from "vite-plugin-relay";
+// export default defineConfig({
+// plugins: [react(), relay],
+// define: {
+// global: {},
+// },
+// server: {
+// proxy: {
+// "/api": {
+// target: "https://p99-blueapi.diamond.ac.uk",
+// //target: "http://localhost:8000",
+// changeOrigin: true,
+// secure: false,
+// rewrite: path => path.replace(/^\/api/, ""),
+// },
+// },
+// },
+// });
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), relay],
define: {
global: {},
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c7bc823..c59c8b7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -115,6 +115,12 @@ importers:
apps/p99:
dependencies:
+ '@atlas/blueapi':
+ specifier: workspace:*
+ version: link:../../packages/blueapi
+ '@atlas/blueapi-query':
+ specifier: workspace:*
+ version: link:../../packages/blueapi-query
'@diamondlightsource/sci-react-ui':
specifier: ^0.2.0
version: 0.2.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@mui/icons-material@6.5.0(@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
@@ -130,9 +136,18 @@ importers:
'@mui/material':
specifier: <7.0.0
version: 6.5.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@tanstack/react-query':
+ specifier: ^5.90.21
+ version: 5.90.21(react@18.3.1)
+ '@testing-library/react':
+ specifier: ^15.0.7
+ version: 15.0.7(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom:
specifier: ^7.7.1
version: 7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ vite-plugin-relay:
+ specifier: ^2.1.0
+ version: 2.1.0(babel-plugin-relay@20.1.1)(vite@7.3.1(@types/node@25.3.3)(terser@5.46.1))
devDependencies:
'@atlas/vitest-conf':
specifier: workspace:*
@@ -4440,6 +4455,7 @@ packages:
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate.io-boolean@1.0.4: