From 5322502e59285c95577a1513bd056222935c98c5 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Tue, 14 Jul 2026 13:21:36 +0000 Subject: [PATCH 1/9] Alphabetise plans --- packages/blueapi-ui/src/PlanBrowser.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/blueapi-ui/src/PlanBrowser.tsx b/packages/blueapi-ui/src/PlanBrowser.tsx index 4d051d9b..bdfeb907 100644 --- a/packages/blueapi-ui/src/PlanBrowser.tsx +++ b/packages/blueapi-ui/src/PlanBrowser.tsx @@ -23,7 +23,11 @@ export function PlanBrowser({ plans, renderPlan }: PlanBrowserProps) { + a.name.localeCompare(b.name, undefined, { + sensitivity: "base", + }), + )} selectedPlan={selectedPlan} updateSelection={setSelectedPlan} /> From 8611632f2935bac07a46aa209995d2a5bc5aba75 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Tue, 14 Jul 2026 13:57:28 +0000 Subject: [PATCH 2/9] Fetch plans from PlanBrowser itself Now in its own subdir --- apps/i15-1/src/routes/Plans.tsx | 10 +------- .../{ => PlanBrowser}/PlanBrowser.test.tsx | 25 ++++++++++++------- .../src/{ => PlanBrowser}/PlanBrowser.tsx | 7 ++++-- .../{ => PlanBrowser}/PlanParameters.test.tsx | 2 +- .../src/{ => PlanBrowser}/PlanParameters.tsx | 6 ++--- .../SearchablePlanList.test.tsx | 0 .../{ => PlanBrowser}/SearchablePlanList.tsx | 0 packages/blueapi-ui/src/index.tsx | 4 +-- 8 files changed, 28 insertions(+), 26 deletions(-) rename packages/blueapi-ui/src/{ => PlanBrowser}/PlanBrowser.test.tsx (79%) rename packages/blueapi-ui/src/{ => PlanBrowser}/PlanBrowser.tsx (90%) rename packages/blueapi-ui/src/{ => PlanBrowser}/PlanParameters.test.tsx (97%) rename packages/blueapi-ui/src/{ => PlanBrowser}/PlanParameters.tsx (93%) rename packages/blueapi-ui/src/{ => PlanBrowser}/SearchablePlanList.test.tsx (100%) rename packages/blueapi-ui/src/{ => PlanBrowser}/SearchablePlanList.tsx (100%) diff --git a/apps/i15-1/src/routes/Plans.tsx b/apps/i15-1/src/routes/Plans.tsx index 1ba9ec6b..3da13ac7 100644 --- a/apps/i15-1/src/routes/Plans.tsx +++ b/apps/i15-1/src/routes/Plans.tsx @@ -1,16 +1,8 @@ import { PlanBrowser } from "@atlas/blueapi-ui"; import { PlanParameters } from "@atlas/blueapi-ui"; -import { usePlans } from "@atlas/blueapi-query"; function JsonFormsPlans() { - const { data } = usePlans(); - - return ( - } - /> - ); + return } />; } export default JsonFormsPlans; diff --git a/packages/blueapi-ui/src/PlanBrowser.test.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx similarity index 79% rename from packages/blueapi-ui/src/PlanBrowser.test.tsx rename to packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx index 0b0ea64d..6415765a 100644 --- a/packages/blueapi-ui/src/PlanBrowser.test.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx @@ -1,19 +1,26 @@ -import type { Plan } from "@atlas/blueapi"; +import type { Plan, PlansResponse } from "@atlas/blueapi"; import { render, screen, userEvent } from "@atlas/vitest-conf"; import { PlanBrowser } from "./PlanBrowser"; - -const plans: Plan[] = [ - { name: "Plan 1", schema: {}, description: "" }, - { name: "Plan 2", schema: {}, description: "" }, - { name: "Plan 3", schema: {}, description: "" }, -]; +import { usePlans } from "@atlas/blueapi-query"; + +// mock usePlans hook +vi.mock("@atlas/blueapi-query"); +const usePlansMock = vi.mocked(usePlans); +const plansResponse: PlansResponse = { + plans: [ + { name: "Plan 1", schema: {}, description: "" }, + { name: "Plan 2", schema: {}, description: "" }, + { name: "Plan 3", schema: {}, description: "" }, + ], +}; +usePlansMock.mockReturnValue({ data: plansResponse } as any); const renderPlan = (plan: Plan) => (
{plan.name}
); function renderBrowser() { - return render(); + return render(); } describe("PlanBrowser", () => { @@ -28,7 +35,7 @@ describe("PlanBrowser", () => { it("does not invoke renderPlan before selection", () => { const mockRender = vi.fn(); - render(); + render(); expect(mockRender).not.toBeCalled(); }); diff --git a/packages/blueapi-ui/src/PlanBrowser.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx similarity index 90% rename from packages/blueapi-ui/src/PlanBrowser.tsx rename to packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx index bdfeb907..ac80d79a 100644 --- a/packages/blueapi-ui/src/PlanBrowser.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx @@ -8,15 +8,18 @@ import { Typography, } from "@mui/material"; import SearchablePlanList from "./SearchablePlanList"; +import { usePlans } from "@atlas/blueapi-query"; export type PlanBrowserProps = { - plans: Plan[]; renderPlan: (plan: Plan) => ReactNode; }; -export function PlanBrowser({ plans, renderPlan }: PlanBrowserProps) { +export function PlanBrowser({ renderPlan }: PlanBrowserProps) { const [selectedPlan, setSelectedPlan] = useState(null); + const { data } = usePlans(); + const plans = data ? data.plans : []; + return ( diff --git a/packages/blueapi-ui/src/PlanParameters.test.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanParameters.test.tsx similarity index 97% rename from packages/blueapi-ui/src/PlanParameters.test.tsx rename to packages/blueapi-ui/src/PlanBrowser/PlanParameters.test.tsx index 571adde5..8dd2c1ea 100644 --- a/packages/blueapi-ui/src/PlanParameters.test.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanParameters.test.tsx @@ -17,7 +17,7 @@ vi.mock("@jsonforms/react", () => { export function RunPlanButton() { return ; } -vi.mock("./RunPlanButton", () => ({ +vi.mock("../RunPlanButton", () => ({ RunPlanButton: RunPlanButton, })); diff --git a/packages/blueapi-ui/src/PlanParameters.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanParameters.tsx similarity index 93% rename from packages/blueapi-ui/src/PlanParameters.tsx rename to packages/blueapi-ui/src/PlanBrowser/PlanParameters.tsx index 3a34fdd9..789421da 100644 --- a/packages/blueapi-ui/src/PlanParameters.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanParameters.tsx @@ -5,9 +5,9 @@ import { materialRenderers, materialCells, } from "@jsonforms/material-renderers"; -import { sanitisePlan, type SchemaNode } from "./utils/schema"; +import { sanitisePlan, type SchemaNode } from "../utils/schema"; import type { Plan } from "@atlas/blueapi"; -import { RunPlanButton } from "./RunPlanButton"; +import { RunPlanButton } from "../RunPlanButton"; import { ErrorBoundary } from "react-error-boundary"; @@ -79,7 +79,7 @@ export const PlanParameters: React.FC = ( id="instrumentSession" label="Instrument Session" defaultValue={instrumentSession} - onChange={(e) => setInstrumentSession(e.target.value)} + onChange={e => setInstrumentSession(e.target.value)} > diff --git a/packages/blueapi-ui/src/SearchablePlanList.test.tsx b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx similarity index 100% rename from packages/blueapi-ui/src/SearchablePlanList.test.tsx rename to packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx diff --git a/packages/blueapi-ui/src/SearchablePlanList.tsx b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx similarity index 100% rename from packages/blueapi-ui/src/SearchablePlanList.tsx rename to packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx diff --git a/packages/blueapi-ui/src/index.tsx b/packages/blueapi-ui/src/index.tsx index c6fa52fe..e6c18af7 100644 --- a/packages/blueapi-ui/src/index.tsx +++ b/packages/blueapi-ui/src/index.tsx @@ -1,3 +1,3 @@ -export * from "./PlanBrowser"; -export * from "./PlanParameters"; +export * from "./PlanBrowser/PlanBrowser"; +export * from "./PlanBrowser/PlanParameters"; export * from "./RunPlanButton"; From 3f2e36cf1474aed4dba80efbc23f303eb11c345a Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Tue, 14 Jul 2026 14:35:07 +0000 Subject: [PATCH 3/9] Decide how to render plans from within PlanBrowser itself --- apps/i15-1/src/router.tsx | 4 +-- apps/i15-1/src/routes/Plans.tsx | 8 ----- .../src/PlanBrowser/PlanBrowser.test.tsx | 13 ++++++-- .../src/PlanBrowser/PlanBrowser.tsx | 9 ++---- .../src/PlanBrowser/PlanParameters.tsx | 32 ++++++++----------- 5 files changed, 28 insertions(+), 38 deletions(-) delete mode 100644 apps/i15-1/src/routes/Plans.tsx diff --git a/apps/i15-1/src/router.tsx b/apps/i15-1/src/router.tsx index ceb370cb..9000e93f 100644 --- a/apps/i15-1/src/router.tsx +++ b/apps/i15-1/src/router.tsx @@ -9,8 +9,8 @@ import { import Robot from "./routes/Robot"; import Dashboard from "./routes/Dashboard"; import Playlist from "./routes/Playlist"; -import Plans from "./routes/Plans"; import { QueueView } from "./routes/QueueView"; +import { PlanBrowser } from "@atlas/blueapi-ui"; const navigation: SectionGroup[] = [ { @@ -64,7 +64,7 @@ const navigation: SectionGroup[] = [ }, { name: "Plans", - element: , + element: , }, ], }, diff --git a/apps/i15-1/src/routes/Plans.tsx b/apps/i15-1/src/routes/Plans.tsx deleted file mode 100644 index 3da13ac7..00000000 --- a/apps/i15-1/src/routes/Plans.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { PlanBrowser } from "@atlas/blueapi-ui"; -import { PlanParameters } from "@atlas/blueapi-ui"; - -function JsonFormsPlans() { - return } />; -} - -export default JsonFormsPlans; diff --git a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx index 6415765a..b9fd5fa4 100644 --- a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx @@ -2,6 +2,7 @@ import type { Plan, PlansResponse } from "@atlas/blueapi"; import { render, screen, userEvent } from "@atlas/vitest-conf"; import { PlanBrowser } from "./PlanBrowser"; import { usePlans } from "@atlas/blueapi-query"; +import { PlanParameters } from "./PlanParameters"; // mock usePlans hook vi.mock("@atlas/blueapi-query"); @@ -15,12 +16,18 @@ const plansResponse: PlansResponse = { }; usePlansMock.mockReturnValue({ data: plansResponse } as any); -const renderPlan = (plan: Plan) => ( +// mock JSONForms +const renderPlan = ({ plan }: { plan: Plan }) => (
{plan.name}
); +vi.mock("./PlanParameters"); +const paramsComponentMock = vi.mocked(PlanParameters); +paramsComponentMock.mockImplementation(renderPlan); + +// render component under test function renderBrowser() { - return render(); + return render(); } describe("PlanBrowser", () => { @@ -35,7 +42,7 @@ describe("PlanBrowser", () => { it("does not invoke renderPlan before selection", () => { const mockRender = vi.fn(); - render(); + render(); expect(mockRender).not.toBeCalled(); }); diff --git a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx index ac80d79a..229aa1e0 100644 --- a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx @@ -9,12 +9,9 @@ import { } from "@mui/material"; import SearchablePlanList from "./SearchablePlanList"; import { usePlans } from "@atlas/blueapi-query"; +import { PlanParameters } from "./PlanParameters"; -export type PlanBrowserProps = { - renderPlan: (plan: Plan) => ReactNode; -}; - -export function PlanBrowser({ renderPlan }: PlanBrowserProps) { +export function PlanBrowser() { const [selectedPlan, setSelectedPlan] = useState(null); const { data } = usePlans(); @@ -47,7 +44,7 @@ export function PlanBrowser({ renderPlan }: PlanBrowserProps) { > {selectedPlan ? ( - {renderPlan(selectedPlan)} + ) : ( diff --git a/packages/blueapi-ui/src/PlanBrowser/PlanParameters.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanParameters.tsx index 789421da..a143f22d 100644 --- a/packages/blueapi-ui/src/PlanBrowser/PlanParameters.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanParameters.tsx @@ -25,18 +25,12 @@ function UIFallback() { ); } -type PlanParametersProps = { - plan: Plan; -}; - interface PlansParameters { [key: string]: any; } -export const PlanParameters: React.FC = ( - props: PlanParametersProps, -) => { - const plan = sanitisePlan(props.plan); +export function PlanParameters({ plan }: { plan: Plan }) { + const sanitisedPlan = sanitisePlan(plan); const [planParameters, setPlanParameters] = useState({}); // TODO: Remove InstrumentSession box and state, retrieve from context when submitting. @@ -44,30 +38,30 @@ export const PlanParameters: React.FC = ( const [instrumentSession, setInstrumentSession] = useState("cm12345-1"); return ( - + - {props.plan.name} + {plan.name} - {props.plan.description && ( + {plan.description && ( - {props.plan.description} + {plan.description} )} - {(plan.schema as SchemaNode).skip ? ( + {(sanitisedPlan.schema as SchemaNode).skip ? ( ) : ( - setPlanParameters({ ...planParameters, [props.plan.name]: data }) + setPlanParameters({ ...planParameters, [plan.name]: data }) } /> )} @@ -84,11 +78,11 @@ export const PlanParameters: React.FC = ( ); -}; +} From 83c9273122cab2682fb9e6402e0f4b048813d532 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Tue, 14 Jul 2026 14:38:06 +0000 Subject: [PATCH 4/9] wip: layout improvements --- packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx | 4 ++-- packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx index 229aa1e0..f0b98750 100644 --- a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx @@ -18,7 +18,7 @@ export function PlanBrowser() { const plans = data ? data.plans : []; return ( - + @@ -59,6 +59,6 @@ export function PlanBrowser() { - + ); } diff --git a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx index b043321f..0cad2544 100644 --- a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx @@ -23,7 +23,7 @@ export default function SearchablePlanList({ const matchingPlans = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return plans; - return plans.filter((plan) => plan.name.toLowerCase().includes(q)); + return plans.filter(plan => plan.name.toLowerCase().includes(q)); }, [plans, query]); return ( @@ -43,11 +43,11 @@ export default function SearchablePlanList({ size="small" label="Search plans" value={query} - onChange={(e) => setQuery(e.target.value)} + onChange={e => setQuery(e.target.value)} />
{/* */} - {matchingPlans.map((plan) => { + {matchingPlans.map(plan => { const selected = selectedPlan?.name === plan.name; return ( Date: Tue, 14 Jul 2026 14:53:30 +0000 Subject: [PATCH 5/9] scrollable plans list --- .../src/PlanBrowser/PlanBrowser.tsx | 16 ++-- .../PlanBrowser/SearchablePlanList.test.tsx | 7 +- .../src/PlanBrowser/SearchablePlanList.tsx | 89 ++++++++++--------- 3 files changed, 55 insertions(+), 57 deletions(-) diff --git a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx index f0b98750..c04434ff 100644 --- a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx @@ -1,12 +1,6 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import type { Plan } from "@atlas/blueapi"; -import { - Box, - Container, - Grid2 as Grid, - Paper, - Typography, -} from "@mui/material"; +import { Box, Grid2 as Grid, Paper, Typography } from "@mui/material"; import SearchablePlanList from "./SearchablePlanList"; import { usePlans } from "@atlas/blueapi-query"; import { PlanParameters } from "./PlanParameters"; @@ -18,10 +12,10 @@ export function PlanBrowser() { const plans = data ? data.plans : []; return ( - - + + - + a.name.localeCompare(b.name, undefined, { diff --git a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx index 6b019866..a621f5e0 100644 --- a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx @@ -26,7 +26,7 @@ function renderList( } describe("SearchablePlanList", () => { - it("renders search, heading, and all plans initially", () => { + it("renders search and all plans initially", () => { renderList(); // search field @@ -34,9 +34,6 @@ describe("SearchablePlanList", () => { screen.getByRole("textbox", { name: /search plans/i }), ).toBeInTheDocument(); - // heading - expect(screen.getByText("Plans")).toBeInTheDocument(); - // all items for (const p of plans) { expect(screen.getByRole("button", { name: p.name })).toBeInTheDocument(); @@ -123,8 +120,6 @@ describe("SearchablePlanList", () => { it("handles empty plans array", () => { renderList({ plans: [] }); - // List is empty, but the heading and search are present - expect(screen.getByText("Plans")).toBeInTheDocument(); expect( screen.getByRole("textbox", { name: /search plans/i }), ).toBeInTheDocument(); diff --git a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx index 0cad2544..05b0d903 100644 --- a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx @@ -2,6 +2,7 @@ import { Box, List, ListItemButton, + Paper, TextField, Typography, } from "@mui/material"; @@ -27,47 +28,55 @@ export default function SearchablePlanList({ }, [plans, query]); return ( - <> - - - Plans - - - Run or queue predefined acquisition plans for this beamline. - + + + setQuery(e.target.value)} + /> - - - setQuery(e.target.value)} - /> + + + + {matchingPlans.map(plan => { + const selected = selectedPlan?.name === plan.name; + return ( + updateSelection(plan)} + > + {plan.name} + + ); + })} + {matchingPlans.length === 0 && plans.length > 0 && ( + + + No plans match “{query}”. + + + )} + - {/* */} - {matchingPlans.map(plan => { - const selected = selectedPlan?.name === plan.name; - return ( - updateSelection(plan)} - > - {plan.name} - - ); - })} - {matchingPlans.length === 0 && plans.length > 0 && ( - - - No plans match “{query}”. - - - )} - - + + ); } From 1aee631e8165e9abde14149167e2633ce611b370 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 15 Jul 2026 10:02:58 +0000 Subject: [PATCH 6/9] Fix accessibility issues --- .../src/PlanBrowser/SearchablePlanList.test.tsx | 10 +++++----- .../blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx index a621f5e0..2251f46c 100644 --- a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.test.tsx @@ -42,7 +42,7 @@ describe("SearchablePlanList", () => { // no selection initially for (const p of plans) { expect(screen.getByRole("button", { name: p.name })).toHaveAttribute( - "aria-selected", + "aria-current", "false", ); } @@ -69,16 +69,16 @@ describe("SearchablePlanList", () => { const selectedButton = screen.getByRole("button", { name: "Dark Current" }); // none selected initially - expect(selectedButton).toHaveAttribute("aria-selected", "false"); + expect(selectedButton).toHaveAttribute("aria-current", "false"); // re-render with selected plan const selected = { name: "Dark Current", schema: {}, description: "" }; rerender(); - // selected plan marked so with aria-selected - expect(selectedButton).toHaveAttribute("aria-selected", "true"); + // selected plan marked so with aria-current + expect(selectedButton).toHaveAttribute("aria-current", "true"); // others remain unselected expect(screen.getByRole("button", { name: "Align Beam" })).toHaveAttribute( - "aria-selected", + "aria-current", "false", ); }); diff --git a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx index 05b0d903..41f3a873 100644 --- a/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/SearchablePlanList.tsx @@ -60,8 +60,9 @@ export default function SearchablePlanList({ updateSelection(plan)} + aria-label={plan.name} + aria-current={selected} > {plan.name} From c724edd2e2507e1ec7e19401048212e507acb074 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 15 Jul 2026 11:50:30 +0000 Subject: [PATCH 7/9] Fix Visr's Plans page --- apps/visr/src/routes/Plans.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/visr/src/routes/Plans.tsx b/apps/visr/src/routes/Plans.tsx index 0bbe264f..765e3fb0 100644 --- a/apps/visr/src/routes/Plans.tsx +++ b/apps/visr/src/routes/Plans.tsx @@ -1,16 +1,7 @@ import { PlanBrowser } from "@atlas/blueapi-ui"; -import { PlanParameters } from "@atlas/blueapi-ui"; -import { usePlans } from "@atlas/blueapi-query"; function JsonFormsPlans() { - const { data } = usePlans(); - - return ( - } - /> - ); + return ; } export default JsonFormsPlans; From e8dec6f4838ebf6d3bc3e9bbae588a3d0a45c020 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 15 Jul 2026 15:23:15 +0000 Subject: [PATCH 8/9] Add compact layout to plans page --- packages/blueapi-ui/package.json | 1 + .../src/PlanBrowser/PlanBrowser.tsx | 134 +++++++++++++++--- pnpm-lock.yaml | 18 ++- 3 files changed, 129 insertions(+), 24 deletions(-) diff --git a/packages/blueapi-ui/package.json b/packages/blueapi-ui/package.json index deea929e..cba77235 100644 --- a/packages/blueapi-ui/package.json +++ b/packages/blueapi-ui/package.json @@ -17,6 +17,7 @@ "@jsonforms/material-renderers": "3.6.0", "@jsonforms/react": "3.6.0", "@tanstack/react-query": "^5.90.21", + "lucide-react": "^1.24.0", "react-error-boundary": "^6.0.0" }, "devDependencies": { diff --git a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx index c04434ff..87404eee 100644 --- a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.tsx @@ -1,16 +1,88 @@ import { useState } from "react"; import type { Plan } from "@atlas/blueapi"; -import { Box, Grid2 as Grid, Paper, Typography } from "@mui/material"; +import { + Box, + Button, + Drawer, + Grid2 as Grid, + Paper, + Stack, + Toolbar, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; import SearchablePlanList from "./SearchablePlanList"; import { usePlans } from "@atlas/blueapi-query"; import { PlanParameters } from "./PlanParameters"; +import { LucideArrowRight } from "lucide-react"; -export function PlanBrowser() { - const [selectedPlan, setSelectedPlan] = useState(null); +type Props = { + plans: Plan[]; + selected: Plan | null; + select: (plan: Plan) => void; +}; - const { data } = usePlans(); - const plans = data ? data.plans : []; +function CompactLayout({ plans, selected, select }: Props) { + const [open, setOpen] = useState(false); + return ( + + setOpen(false)}> + + + + + + + + + + + + + + + ); +} +function PlanParametersWrapper({ selected }: { selected: Plan | null }) { + return ( + + {selected ? ( + + + + ) : ( + + + Select a plan + + + Choose from the list to see details. + + + )} + + ); +} + +function FullLayout({ plans, selected, select }: Props) { return ( @@ -22,8 +94,8 @@ export function PlanBrowser() { sensitivity: "base", }), )} - selectedPlan={selectedPlan} - updateSelection={setSelectedPlan} + selectedPlan={selected} + updateSelection={select} /> @@ -36,23 +108,43 @@ export function PlanBrowser() { display: "flex", }} > - {selectedPlan ? ( - - - - ) : ( - - - Select a plan - - - Choose from the list on the left to see details. - - - )} +
); } + +export function PlanBrowser() { + const [selectedPlan, setSelectedPlan] = useState(null); + + const { data } = usePlans(); + const plans = data + ? data.plans.sort((a, b) => + a.name.localeCompare(b.name, undefined, { + sensitivity: "base", + }), + ) + : []; + + const theme = useTheme(); + const bigEnoughForFullLayout = useMediaQuery(theme.breakpoints.up("md")); + + if (bigEnoughForFullLayout) { + return ( + + ); + } + return ( + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62702b06..fe5e19ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,7 +71,7 @@ importers: version: link:../../packages/pvws-config '@diamondlightsource/cs-web-lib': specifier: 0.10.15 - version: 0.10.15(@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)(graphql@16.14.2)(plotly.js@2.35.3(mapbox-gl@1.13.3)(webpack@5.106.2(postcss@8.5.15)))(react-dom@18.3.1(react@18.3.1))(react-gauge-component@2.0.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 0.10.15(@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)(graphql@16.14.2)(plotly.js@2.35.3(mapbox-gl@1.13.3)(webpack@5.106.2))(react-dom@18.3.1(react@18.3.1))(react-gauge-component@2.0.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@diamondlightsource/sci-react-ui': specifier: ^0.4.0 version: 0.4.1(@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))(@jsonforms/core@3.6.0)(@jsonforms/material-renderers@3.6.0(a2958437c922d885fd4a4f8b5e701907))(@jsonforms/react@3.6.0(@jsonforms/core@3.6.0)(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) @@ -411,6 +411,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@18.3.1) + lucide-react: + specifier: ^1.24.0 + version: 1.24.0(react@18.3.1) react-error-boundary: specifier: ^6.0.0 version: 6.1.1(react@18.3.1) @@ -429,7 +432,7 @@ importers: dependencies: '@diamondlightsource/cs-web-lib': specifier: 0.10.15 - version: 0.10.15(@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)(plotly.js@2.35.3(mapbox-gl@1.13.3)(webpack@5.106.2))(react-dom@18.3.1(react@18.3.1))(react-gauge-component@2.0.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 0.10.15(@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)(graphql@16.14.2)(plotly.js@2.35.3(mapbox-gl@1.13.3)(webpack@5.106.2(postcss@8.5.15)))(react-dom@18.3.1(react@18.3.1))(react-gauge-component@2.0.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@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) @@ -4362,6 +4365,11 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@1.24.0: + resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -6379,7 +6387,7 @@ snapshots: - graphql - plotly.js - '@diamondlightsource/cs-web-lib@0.10.15(@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)(plotly.js@2.35.3(mapbox-gl@1.13.3)(webpack@5.106.2))(react-dom@18.3.1(react@18.3.1))(react-gauge-component@2.0.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@diamondlightsource/cs-web-lib@0.10.15(@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)(graphql@16.14.2)(plotly.js@2.35.3(mapbox-gl@1.13.3)(webpack@5.106.2))(react-dom@18.3.1(react@18.3.1))(react-gauge-component@2.0.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: '@mui/icons-material': 7.3.11(@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) '@reduxjs/toolkit': 2.12.0(react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) @@ -10507,6 +10515,10 @@ snapshots: dependencies: react: 18.3.1 + lucide-react@1.24.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} maath@0.10.8(@types/three@0.164.1)(three@0.164.1): From 49aed635a860cff61e2200e07bb8f4d7d89e0c95 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 17 Jul 2026 14:49:42 +0000 Subject: [PATCH 9/9] Fix PlanBrowser tests; test both layouts --- .../src/PlanBrowser/PlanBrowser.test.tsx | 132 +++++++++++------- 1 file changed, 85 insertions(+), 47 deletions(-) diff --git a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx index b9fd5fa4..c905b997 100644 --- a/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx +++ b/packages/blueapi-ui/src/PlanBrowser/PlanBrowser.test.tsx @@ -3,6 +3,7 @@ import { render, screen, userEvent } from "@atlas/vitest-conf"; import { PlanBrowser } from "./PlanBrowser"; import { usePlans } from "@atlas/blueapi-query"; import { PlanParameters } from "./PlanParameters"; +import { useMediaQuery } from "@mui/material"; // mock usePlans hook vi.mock("@atlas/blueapi-query"); @@ -30,68 +31,105 @@ function renderBrowser() { return render(); } +vi.mock("@mui/material", async () => { + const actual = + await vi.importActual("@mui/material"); + + return { + ...actual, + useMediaQuery: vi.fn(), + }; +}); + +const mockedUseMediaQuery = vi.mocked(useMediaQuery); + describe("PlanBrowser", () => { - it("shows a placeholder before initial plan selection", () => { - renderBrowser(); + describe("Full layout", () => { + beforeEach(() => { + mockedUseMediaQuery.mockReturnValue(true); + }); - expect(screen.getByText("Select a plan")).toBeInTheDocument(); - expect( - screen.getByText("Choose from the list on the left to see details."), - ).toBeInTheDocument(); - }); + it("shows a placeholder before initial plan selection and list of plans", () => { + renderBrowser(); - it("does not invoke renderPlan before selection", () => { - const mockRender = vi.fn(); - render(); - expect(mockRender).not.toBeCalled(); - }); + expect(screen.getByText("Select a plan")).toBeInTheDocument(); + expect( + screen.getByText("Choose from the list to see details."), + ).toBeInTheDocument(); + }); - it("renders plan details when a plan is selected", async () => { - renderBrowser(); + it("renders plan details when a plan is selected", async () => { + renderBrowser(); - const selectedPlan = screen.getByRole("button", { name: "Plan 2" }); - const user = userEvent.setup(); - await user.click(selectedPlan); + const selectedPlan = screen.getByRole("button", { name: "Plan 2" }); + const user = userEvent.setup(); + await user.click(selectedPlan); - // placeholder disappears... - expect(screen.queryByText("Select a plan")).not.toBeInTheDocument(); + // placeholder disappears... + expect(screen.queryByText("Select a plan")).not.toBeInTheDocument(); - // ...plan details appear - const planDetails = screen.getByTestId("plan-view"); - expect(planDetails).toBeInTheDocument(); - expect(planDetails).toHaveTextContent("Plan 2"); - }); + // ...plan details appear + const planDetails = screen.getByTestId("plan-view"); + expect(planDetails).toBeInTheDocument(); + expect(planDetails).toHaveTextContent("Plan 2"); + }); - it("renders plan details with every selection", async () => { - renderBrowser(); + it("renders plan details with every selection", async () => { + renderBrowser(); - const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: "Plan 3" })); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Plan 3" })); - const planDetails = screen.getByTestId("plan-view"); - expect(planDetails).toBeInTheDocument(); - expect(planDetails).toHaveTextContent("Plan 3"); + const planDetails = screen.getByTestId("plan-view"); + expect(planDetails).toBeInTheDocument(); + expect(planDetails).toHaveTextContent("Plan 3"); - await user.click(screen.getByRole("button", { name: "Plan 1" })); - expect(planDetails).toHaveTextContent("Plan 1"); - }); + await user.click(screen.getByRole("button", { name: "Plan 1" })); + expect(planDetails).toHaveTextContent("Plan 1"); + }); - it("persists plan details through search/filtering", async () => { - renderBrowser(); - const user = userEvent.setup(); + it("persists plan details through search/filtering", async () => { + renderBrowser(); + const user = userEvent.setup(); - // select plan 1 - await user.click(screen.getByRole("button", { name: "Plan 1" })); + // select plan 1 + await user.click(screen.getByRole("button", { name: "Plan 1" })); - // plan 1 details appear - const planDetails = screen.getByTestId("plan-view"); - expect(planDetails).toHaveTextContent("Plan 1"); + // plan 1 details appear + const planDetails = screen.getByTestId("plan-view"); + expect(planDetails).toHaveTextContent("Plan 1"); - // search for a different plan - const searchbox = screen.getByRole("textbox", { name: /search plans/i }); - await user.type(searchbox, "Plan 3"); + // search for a different plan + const searchbox = screen.getByRole("textbox", { name: /search plans/i }); + await user.type(searchbox, "Plan 3"); + + // but user has not selected it, so plan 1 details remain + expect(planDetails).toHaveTextContent("Plan 1"); + }); + }); - // but user has not selected it, so plan 1 details remain - expect(planDetails).toHaveTextContent("Plan 1"); + describe("Compact layout", () => { + beforeEach(() => { + mockedUseMediaQuery.mockReturnValue(false); + }); + + it("shows a placeholder before initial plan selection and 'View plans' button", () => { + renderBrowser(); + + expect(screen.getByText("View plans")).toBeInTheDocument(); + expect( + screen.getByText("Choose from the list to see details."), + ).toBeInTheDocument(); + }); + + it("opens drawer with plans list when 'View plans' button is clicked", async () => { + renderBrowser(); + const user = userEvent.setup(); + const button = screen.getByRole("button", { name: "View plans" }); + await user.click(button); + plansResponse.plans.forEach(plan => + expect(screen.getByText(plan.name)).toBeVisible(), + ); + }); }); });