diff --git a/src/components/BeamlineSelect.tsx b/src/components/BeamlineSelect.tsx index f1ca259..d381900 100644 --- a/src/components/BeamlineSelect.tsx +++ b/src/components/BeamlineSelect.tsx @@ -7,6 +7,7 @@ import { FileContext, executeAction } from "@diamondlightsource/cs-web-lib"; import { CHANGE_BEAMLINE } from "../store"; import { Tooltip } from "@mui/material"; import { BeamlineTreeStateContext } from "../App"; +import { buildUrl } from "../utils/urlUtils"; const MenuItem = styled(MuiMenuItem)({ "&.Mui-disabled": { @@ -32,9 +33,10 @@ export default function BeamlineSelect() { location: "main", description: undefined, file: { - path: - state.beamlines[event.target.value].host + - state.beamlines[event.target.value].topLevelScreen, + path: buildUrl( + state.beamlines[event.target.value].host, + state.beamlines[event.target.value].topLevelScreen + ), macros: {}, defaultProtocol: "ca" } diff --git a/src/components/ScreenTreeView.tsx b/src/components/ScreenTreeView.tsx index 5ac29b3..c9243a2 100644 --- a/src/components/ScreenTreeView.tsx +++ b/src/components/ScreenTreeView.tsx @@ -4,6 +4,7 @@ import { TreeViewBaseItem, TreeViewItemId } from "@mui/x-tree-view"; import { executeAction, FileContext } from "@diamondlightsource/cs-web-lib"; import { BeamlineTreeStateContext } from "../App"; import { MenuContext } from "../routes/MainPage"; +import { buildUrl } from "../utils/urlUtils"; export default function ScreenTreeView() { const { state } = useContext(BeamlineTreeStateContext); @@ -16,9 +17,13 @@ export default function ScreenTreeView() { }; const handleClick = (itemId: string) => { - const newScreen = - state.beamlines[state.currentBeamline].host + - state.beamlines[state.currentBeamline].filePathIds[itemId].file; + const fileMetadata = + state.beamlines[state.currentBeamline].filePathIds[itemId]; + const newScreen = buildUrl( + state.beamlines[state.currentBeamline].host, + fileMetadata.file + ); + executeAction( { type: "OPEN_PAGE", diff --git a/src/components/SynopticBreadcrumbs.tsx b/src/components/SynopticBreadcrumbs.tsx index 9d26d5a..51ae55a 100644 --- a/src/components/SynopticBreadcrumbs.tsx +++ b/src/components/SynopticBreadcrumbs.tsx @@ -5,6 +5,7 @@ import { Breadcrumbs, Link } from "@mui/material"; import { executeAction, FileContext } from "@diamondlightsource/cs-web-lib"; import { BeamlineStateProperties } from "../store"; import { BeamlineTreeStateContext } from "../App"; +import { buildUrl } from "../utils/urlUtils"; export const SynopticBreadcrumbs = () => { const { state } = useContext(BeamlineTreeStateContext); @@ -44,12 +45,11 @@ const handleClick = .split("/") .at(-1) as string; - const filepath = - Object.values(currentBeamlineState.filePathIds).find( - x => x.urlId === urlId - )?.file ?? ""; + const fileMetadata = Object.values(currentBeamlineState.filePathIds).find( + x => x.urlId === urlId + ); + const newScreen = buildUrl(currentBeamlineState.host, fileMetadata?.file); - const newScreen = currentBeamlineState.host + filepath; executeAction( { type: "OPEN_PAGE", diff --git a/src/routes/EditorPage.tsx b/src/routes/EditorPage.tsx index 18d7251..0b48760 100644 --- a/src/routes/EditorPage.tsx +++ b/src/routes/EditorPage.tsx @@ -11,6 +11,7 @@ import { parseScreenTree } from "../utils/parser"; import { executeAction, FileContext } from "@diamondlightsource/cs-web-lib"; import Editor from "../components/Editor"; import { useParams } from "react-router-dom"; +import { buildUrl } from "../utils/urlUtils"; /** * Displays a mock editor page with palette and Phoebus @@ -63,7 +64,7 @@ export function EditorPage() { const newBeamlineState = newBeamlines[params.beamline]; const newScreen = params.screenUrlId ? newBeamlineState.filePathIds[params.screenUrlId].file - : newBeamlineState.host + newBeamlineState.topLevelScreen; + : buildUrl(newBeamlineState.host, newBeamlineState.topLevelScreen); executeAction( { type: "OPEN_PAGE", diff --git a/src/routes/MainPage.tsx b/src/routes/MainPage.tsx index 2b5d664..4a49474 100644 --- a/src/routes/MainPage.tsx +++ b/src/routes/MainPage.tsx @@ -16,6 +16,7 @@ import { RotatingLines } from "react-loader-spinner"; import { SynopticBreadcrumbs } from "../components/SynopticBreadcrumbs"; import { BeamlineTreeStateContext } from "../App"; import { useParams } from "react-router-dom"; +import { buildUrl } from "../utils/urlUtils"; export const MenuContext = createContext<{ menuOpen: boolean; @@ -54,7 +55,7 @@ export function MainPage() { for (const [beamline, item] of Object.entries(newBeamlines)) { try { const [tree, fileIDs, firstFile] = await parseScreenTree( - item.host + item.entryPoint + buildUrl(item.host, item.entryPoint) ); item.screenTree = tree; item.filePathIds = fileIDs; @@ -82,8 +83,10 @@ export function MainPage() { x => x.urlId === params.screenUrlId )?.file; - const newScreen = - newBeamlineState.host + (filepath ?? newBeamlineState.topLevelScreen); + const newScreen = buildUrl( + newBeamlineState.host, + filepath ?? newBeamlineState.topLevelScreen + ); executeAction( { diff --git a/src/tests/utils/urlUtils.test.ts b/src/tests/utils/urlUtils.test.ts new file mode 100644 index 0000000..93bcce0 --- /dev/null +++ b/src/tests/utils/urlUtils.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { buildUrl, isFullyQualifiedUrl } from "../../utils/urlUtils"; + +describe("urlUtils", () => { + describe("isFullyQualifiedUrl", () => { + it("should return true when url is valid", async () => { + const result = isFullyQualifiedUrl("https://diamond.ac.uk:4000/path1"); + expect(result).toEqual(true); + }); + + it("should return false when url is undefined", async () => { + const result = isFullyQualifiedUrl(undefined); + expect(result).toEqual(false); + }); + + it("should return false when url is invalid", async () => { + const result = isFullyQualifiedUrl("abcde1234.ac.uk"); + expect(result).toEqual(false); + }); + }); + + describe("buildUrl", () => { + it("should use base url when the joined path args don't make a fully qualified URL", async () => { + const baseUrl = "http://diamond.ac.uk"; + const args = ["path1"]; + + const result = buildUrl(baseUrl, ...args); + + expect(result).toEqual("http://diamond.ac.uk/path1"); + }); + + it("should return the default base url when the path args not specified", async () => { + const baseUrl = "http://diamond.ac.uk"; + + const result = buildUrl(baseUrl); + + expect(result).toEqual("http://diamond.ac.uk/"); + }); + + it("should return the default base url when the only path arg is undefined", async () => { + const baseUrl = "http://diamond.ac.uk"; + + const result = buildUrl(baseUrl, undefined); + + expect(result).toEqual("http://diamond.ac.uk/"); + }); + + it("url encodes the path string", async () => { + const baseUrl = "http://diamond.ac.uk"; + const args = ["path 1"]; + + const result = buildUrl(baseUrl, ...args); + + expect(result).toEqual("http://diamond.ac.uk/path%201"); + }); + + it("removes trailing and leading slash", async () => { + const baseUrl = "http://diamond.ac.uk/"; + const args = ["/path1/"]; + + const result = buildUrl(baseUrl, ...args); + + expect(result).toEqual("http://diamond.ac.uk/path1"); + }); + + it("Joins multiple path args with / separator", async () => { + const baseUrl = "http://diamond.ac.uk"; + const args = ["/path1/", "/path2", "path3/"]; + + const result = buildUrl(baseUrl, ...args); + + expect(result).toEqual("http://diamond.ac.uk/path1/path2/path3"); + }); + + it("Joins multiple path args with / separator, ignores filename component of host path", async () => { + const baseUrl = "http://diamond.ac.uk/path0/filename"; + const args = ["/path1/"]; + + const result = buildUrl(baseUrl, ...args); + + expect(result).toEqual("http://diamond.ac.uk/path0/path1"); + }); + + it("Joins multiple path args with / separator, trailing slash on the url", async () => { + const baseUrl = "http://diamond.ac.uk/path0/path1/"; + const args = ["/path10/", "/path20"]; + + const result = buildUrl(baseUrl, ...args); + + expect(result).toEqual("http://diamond.ac.uk/path0/path1/path10/path20"); + }); + + it("ignores default base url if the args start with a fully qualified url", async () => { + const baseUrl = "http://diamond.ac.uk"; + const args = ["http://ral.ac.uk/", "path1", "path2"]; + + const result = buildUrl(baseUrl, ...args); + + expect(result).toEqual("http://ral.ac.uk/path1/path2"); + }); + + it("ignores default base url if the args start with a fully qualified url and even if the base Url contains a path", async () => { + const baseUrl = "https://diamond.ac.uk/path0"; + const args = ["https://ral.ac.uk:4000/", "path1", "path2"]; + + const result = buildUrl(baseUrl, ...args); + + expect(result).toEqual("https://ral.ac.uk:4000/path1/path2"); + }); + + it("ignores default base url if the args start with a fully qualified url, when base url is invalid", async () => { + const args = ["http://ral.ac.uk/", "path1", "path2"]; + + const result = buildUrl("abcd", ...args); + + expect(result).toEqual("http://ral.ac.uk/path1/path2"); + }); + + it("ignores default base url if the args start with a fully qualified url, when base url is undefined", async () => { + const args = ["http://ral.ac.uk/", "path1", "path2"]; + + const result = buildUrl(undefined, ...args); + + expect(result).toEqual("http://ral.ac.uk/path1/path2"); + }); + + it("Returns a relative path if base url is an empty string and path is not a fully qualified URL", async () => { + const args = ["path1", "filename.json"]; + + const result = buildUrl("", ...args); + + expect(result).toEqual("/path1/filename.json"); + }); + }); +}); diff --git a/src/utils/urlUtils.ts b/src/utils/urlUtils.ts new file mode 100644 index 0000000..1a48e94 --- /dev/null +++ b/src/utils/urlUtils.ts @@ -0,0 +1,32 @@ +export const isFullyQualifiedUrl = (url: string): boolean => { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +}; + +export const buildUrl = ( + defaultBaseHost: string, + ...args: (string | undefined)[] +) => { + const path = + args + ?.filter(s => s != null && s !== "") + .map(s => s?.replace(/\/+$/, "").replace(/^\/+/, "")) + .join("/") ?? ""; + + if (isFullyQualifiedUrl(path)) { + const parsedUrl = new URL(path); + return parsedUrl.toString(); + } + + if (isFullyQualifiedUrl(defaultBaseHost)) { + const parsedUrl = new URL(path, defaultBaseHost); + return parsedUrl.toString(); + } + + // Assume a local relative path + return `/${path}`; +};