diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 03e23b6f2..f59bdaa54 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -29,7 +29,13 @@ jobs:
run: npm run test:unit
env:
CI: true
- - name: End to End Tests
+ - name: End to End Tests (Linux)
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y xvfb && xvfb-run npm run test:playwright:headed
+ env:
+ CI: true
+ - name: End to End Tests (Windows & MacOS)
+ if: runner.os != 'Linux'
run: npm run test:playwright
env:
CI: true
diff --git a/package-lock.json b/package-lock.json
index 04f13d6e4..84746a7d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@ipguk/react-ui",
- "version": "5.1.1",
+ "version": "5.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ipguk/react-ui",
- "version": "5.1.1",
+ "version": "5.2.0",
"hasInstallScript": true,
"license": "MIT",
"devDependencies": {
@@ -20190,8 +20190,9 @@
},
"node_modules/express": {
"version": "4.18.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
+ "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
diff --git a/package.json b/package.json
index 4345df956..307809cb7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@ipguk/react-ui",
- "version": "5.1.1",
+ "version": "5.2.0",
"description": "React UI component library for IPG web applications",
"author": "IPG-Automotive-UK",
"license": "MIT",
@@ -18,6 +18,7 @@
"storybook:serve:stop": "forever stop ./serve-storybook.js",
"test": "run-s test:unit test:lint test:playwright",
"test:playwright": "npm run storybook:build && npm run storybook:serve:start && npx playwright test && npm run storybook:serve:stop",
+ "test:playwright:headed": "npm run storybook:build && npm run storybook:serve:start && npx playwright test --headed && npm run storybook:serve:stop",
"test:lint": "eslint ./src",
"test:unit": "cross-env CI=1 react-scripts test --env=jsdom --collectCoverage=true",
"test:watch": "react-scripts test --env=jsdom",
diff --git a/playwright.config.ts b/playwright.config.ts
index 12c870677..f7b48c203 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -21,12 +21,10 @@ export default defineConfig({
name: "chromium",
use: { ...devices["Desktop Chrome"] }
},
-
{
name: "firefox",
use: { ...devices["Desktop Firefox"] }
},
-
{
name: "webkit",
use: { ...devices["Desktop Safari"] }
@@ -40,6 +38,7 @@ export default defineConfig({
testMatch: /.*(spec)\.(js|ts|mjs)/,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
+ screenshot: "only-on-failure",
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
diff --git a/src/ConditionalDialog/ConditionalDialog.spec.ts b/src/ConditionalDialog/ConditionalDialog.spec.ts
new file mode 100644
index 000000000..0675b7d73
--- /dev/null
+++ b/src/ConditionalDialog/ConditionalDialog.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from "@playwright/test";
+
+test("Fullscreen dialog title and content rendered", async ({ page }) => {
+ // Navigate to the default story of ConditionalDialog component in Storybook
+ await page.goto(
+ "http://localhost:6006/?path=/story/dialog-conditionaldialog--default"
+ );
+
+ // Get the title of the fullscreen dialog and assert that it is correct
+ const title = await page
+ .frameLocator('iframe[title="storybook-preview-iframe"]')
+ .getByText("Dialog Title")
+ .textContent();
+ expect(title).toBe("Dialog Title");
+
+ // Get the content of the fullscreen dialog and assert that it is correct
+ const content = await page
+ .frameLocator('iframe[title="storybook-preview-iframe"]')
+ .getByLabel("Dialog Title")
+ .locator("div")
+ .nth(2)
+ .innerHTML();
+ expect(content).toBe("
Content goes here
");
+});
+
+test("Condition false should not render dialog", async ({ page }) => {
+ // Navigate to the condition false story of ConditionalDialog component in Storybook
+ await page.goto(
+ "http://localhost:6006/?path=/story/dialog-conditionaldialog--condition-false"
+ );
+
+ // locate the iframe
+ const frame = page.frameLocator('iframe[title="storybook-preview-iframe"]');
+
+ // try locate the open dialog and assert that it is not visible
+ const openDialog = await frame.locator("#open-dialog");
+ expect(await openDialog.isVisible()).toBeFalsy();
+
+ // Get the content of the body and assert that it is correct
+ const content = await page
+ .frameLocator('iframe[title="storybook-preview-iframe"]')
+ .getByText("Content goes here")
+ .textContent();
+
+ expect(content).toBe("Content goes here");
+});
diff --git a/src/ConditionalDialog/ConditionalDialog.stories.tsx b/src/ConditionalDialog/ConditionalDialog.stories.tsx
new file mode 100644
index 000000000..897760eb3
--- /dev/null
+++ b/src/ConditionalDialog/ConditionalDialog.stories.tsx
@@ -0,0 +1,46 @@
+import { Meta, StoryFn } from "@storybook/react";
+
+import ConditionalDialog from "./ConditionalDialog";
+import { ConditionalDialogProps } from "./ConditionalDialog.types";
+import React from "react";
+
+/**
+ * Story metadata
+ */
+const meta: Meta = {
+ component: ConditionalDialog,
+ title: "Dialog/ConditionalDialog"
+};
+export default meta;
+
+// Story Template
+const Template: StoryFn = args => {
+ // Render the ConditionalDialog component with the current arguments
+ return (
+
+ Content goes here
+
+ );
+};
+
+// Define the default story
+export const Default = {
+ // Set the default values for the story's arguments
+ args: {
+ condition: true,
+ onClose: () => {},
+ title: "Dialog Title"
+ },
+ // Set the render function to use the story template
+ render: Template
+};
+
+// Define the condition false story
+export const conditionFalse = {
+ // Set the condition false value for the story's arguments
+ args: {
+ condition: false
+ },
+ // Set the render function to use the story template
+ render: Template
+};
diff --git a/src/ConditionalDialog/ConditionalDialog.tsx b/src/ConditionalDialog/ConditionalDialog.tsx
new file mode 100644
index 000000000..dddfa3984
--- /dev/null
+++ b/src/ConditionalDialog/ConditionalDialog.tsx
@@ -0,0 +1,29 @@
+import { Box, Dialog, DialogContent } from "@mui/material";
+
+import { ConditionalDialogProps } from "./ConditionalDialog.types";
+import DialogTitle from "../DialogTitle";
+import React from "react";
+
+// This component is used to render a dialog that uses the full width (if the condition is true)
+const ConditionalDialog = (props: ConditionalDialogProps) => {
+ const { condition, onClose, children, title } = props;
+ // If the condition is true, render a dialog with the specified title and content
+ if (condition) {
+ return (
+
+ );
+ }
+ // If the condition is false, render the children as-is
+ else {
+ return children;
+ }
+};
+
+export default ConditionalDialog;
diff --git a/src/ConditionalDialog/ConditionalDialog.types.ts b/src/ConditionalDialog/ConditionalDialog.types.ts
new file mode 100644
index 000000000..0004d0550
--- /dev/null
+++ b/src/ConditionalDialog/ConditionalDialog.types.ts
@@ -0,0 +1,8 @@
+import { DialogTitleProps } from "../DialogTitle";
+
+export type ConditionalDialogProps = {
+ condition: boolean;
+ onClose: DialogTitleProps["onClose"];
+ children: React.ReactNode;
+ title?: React.ReactNode;
+};
diff --git a/src/ConditionalDialog/index.ts b/src/ConditionalDialog/index.ts
new file mode 100644
index 000000000..ff1ff17e3
--- /dev/null
+++ b/src/ConditionalDialog/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./ConditionalDialog";
+export type { ConditionalDialogProps } from "./ConditionalDialog.types";
diff --git a/src/LinePlot/LinePlot.tsx b/src/LinePlot/LinePlot.tsx
index da8167115..3ff490045 100644
--- a/src/LinePlot/LinePlot.tsx
+++ b/src/LinePlot/LinePlot.tsx
@@ -52,7 +52,7 @@ const LinePlot = ({
height="100%"
width="100%"
>
- {showTitle ? (
+ {!isFullscreen && showTitle ? (
{
+ // Navigate to the SurfacePlot component in Storybook
+ await page.goto(
+ "http://localhost:6006/?path=/story/plots-surfaceplot--default"
+ );
+
+ // Click the fullscreen button in the Plotly toolbar
+ await page
+ .frameLocator('iframe[title="storybook-preview-iframe"]')
+ .locator("div:nth-child(4) > .modebar-btn")
+ .click();
+
+ // Get the title of the fullscreen dialog
+ const title = await page
+ .frameLocator('iframe[title="storybook-preview-iframe"]')
+ .locator(".MuiDialogTitle-root")
+ .textContent();
+
+ // Assert that the title is equal to "Surface Plot"
+ expect(title).toBe("Surface Plot");
+});
diff --git a/src/SurfacePlot/SurfacePlot.stories.tsx b/src/SurfacePlot/SurfacePlot.stories.tsx
new file mode 100644
index 000000000..ec717e8fc
--- /dev/null
+++ b/src/SurfacePlot/SurfacePlot.stories.tsx
@@ -0,0 +1,60 @@
+import { Meta, StoryFn } from "@storybook/react";
+
+import React from "react";
+import SurfacePlot from "./SurfacePlot";
+import { SurfacePlotProps } from "./SurfacePlot.types";
+import { useArgs } from "@storybook/client-api";
+
+/**
+ * Story metadata
+ */
+const meta: Meta = {
+ component: SurfacePlot,
+ title: "Plots/SurfacePlot"
+};
+export default meta;
+
+// Story Template
+const Template: StoryFn = args => {
+ // Get the current values of the story's arguments and a function to update them
+ const [{ showTitle }, updateArgs] = useArgs();
+
+ // Update the showTitle argument whenever it changes
+ React.useEffect(() => {
+ updateArgs({ showTitle });
+ }, [showTitle, updateArgs]);
+
+ // Render the SurfacePlot component with the current arguments
+ return ;
+};
+
+// Define the default story
+export const Default = {
+ // Set the default values for the story's arguments
+ args: {
+ markers: false,
+ minHeight: 0,
+ showTitle: false,
+ title: "Surface Plot",
+ xdata: [0.18, 0.36, 0.55, 0.73, 0.91, 1],
+ xlabel: "Normalized Torque (-)",
+ ydata: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
+ ylabel: "Normalized Rotational Speed (-)",
+ zdata: [
+ [0.76, 0.76, 0.72, 0.7, 0.65, 0.6],
+ [0.86, 0.78, 0.76, 0.74, 0.7, 0.65],
+ [0.9, 0.9, 0.88, 0.86, 0.86, 0.78],
+ [0.92, 0.92, 0.92, 0.9, 0.88, 0.78],
+ [0.92, 0.92, 0.92, 0.92, 0.7, 0.65],
+ [0.92, 0.94, 0.94, 0.7, 0.65, 0.6],
+ [0.92, 0.94, 0.94, 0.7, 0.65, 0.6],
+ [0.92, 0.94, 0.7, 0.65, 0.6, 0.6],
+ [0.92, 0.94, 0.7, 0.65, 0.6, 0.6],
+ [0.92, 0.94, 0.7, 0.65, 0.6, 0.6],
+ [0.92, 0.92, 0.7, 0.65, 0.6, 0.6]
+ ],
+ zlabel: "Efficiency (-)"
+ },
+ // Set the render function to use the story template
+ render: Template
+};
diff --git a/src/SurfacePlot/SurfacePlot.tsx b/src/SurfacePlot/SurfacePlot.tsx
new file mode 100644
index 000000000..1cbc2bf3e
--- /dev/null
+++ b/src/SurfacePlot/SurfacePlot.tsx
@@ -0,0 +1,149 @@
+import { Box, Typography, useTheme } from "@mui/material";
+import React, { useState } from "react";
+
+import ConditionalDialog from "../ConditionalDialog";
+import Plotly from "react-plotly.js";
+import { SurfacePlotProps } from "./SurfacePlot.types";
+import { getConfig } from "../utils/plotlyConfig";
+
+// The `SurfacePlot` component renders a 3D surface plot using Plotly.
+const SurfacePlot = ({
+ xdata = [],
+ ydata = [],
+ zdata = [],
+ xlabel = "",
+ ylabel = "",
+ zlabel = "",
+ title = "",
+ markers = false,
+ showTitle = false
+}: SurfacePlotProps) => {
+ // theme hook
+ const theme = useTheme();
+
+ // state for fullscreen
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ // callback for fullscreen button
+ const handleClickFullscreen = () => {
+ setIsFullscreen(true);
+ };
+
+ // callback for closing fullscreen
+ const handleClose = () => {
+ setIsFullscreen(false);
+ };
+
+ // get config for plotly
+ const config = getConfig({ handleClickFullscreen, isFullscreen });
+
+ return (
+
+
+ {!isFullscreen && showTitle ? (
+
+ {title || ""}
+
+ ) : null}
+
+
+
+ );
+};
+
+export default SurfacePlot;
diff --git a/src/SurfacePlot/SurfacePlot.types.ts b/src/SurfacePlot/SurfacePlot.types.ts
new file mode 100644
index 000000000..3206401ff
--- /dev/null
+++ b/src/SurfacePlot/SurfacePlot.types.ts
@@ -0,0 +1,40 @@
+// Surface3D.types.ts
+
+export interface SurfacePlotProps {
+ /*
+ markers is a boolean that determines whether or not the points are marked.
+ */
+ markers?: boolean;
+ /*
+ showTitle is a boolean that determines whether or not the title is shown on the main view.
+ */
+ showTitle?: boolean;
+ /*
+ Title of the plot.
+ */
+ title?: string;
+ /*
+ Arrays of numbers that represent the X coordinates of the points to be plotted.
+ */
+ xdata: number[];
+ /*
+ Label for the X axis.
+ */
+ xlabel: string;
+ /*
+ Arrays of numbers that represent the Y coordinates of the points to be plotted.
+ */
+ ydata: number[];
+ /*
+ Label for the Y axis.
+ */
+ ylabel: string;
+ /*
+ 2D array of numbers that represent the Z coordinates of the points to be plotted.
+ */
+ zdata: number[][];
+ /*
+ Label for the Z axis.
+ */
+ zlabel: string;
+}
diff --git a/src/SurfacePlot/SurfacePlotClientOnly.tsx b/src/SurfacePlot/SurfacePlotClientOnly.tsx
new file mode 100644
index 000000000..ec7628d0a
--- /dev/null
+++ b/src/SurfacePlot/SurfacePlotClientOnly.tsx
@@ -0,0 +1,19 @@
+import React, { Suspense, lazy } from "react";
+
+import ClientOnly from "../ClientOnly";
+import { SurfacePlotProps } from "./SurfacePlot.types";
+
+const LazyImportedSurfacePlot = lazy(() => import("./SurfacePlot"));
+
+/**
+ * SurfacePlot component wrapped in ClientOnly for use with server side rendering.
+ */
+export default function SurfacePlotClientOnly(props: SurfacePlotProps) {
+ return (
+
+ Loading plot...}>
+
+
+
+ );
+}
diff --git a/src/SurfacePlot/index.ts b/src/SurfacePlot/index.ts
new file mode 100644
index 000000000..3c95136c6
--- /dev/null
+++ b/src/SurfacePlot/index.ts
@@ -0,0 +1,7 @@
+// This file exports the default export of the SurfacePlotClientOnly module and the SurfacePlotProps type from the SurfacePlot.types module.
+
+// Import the default export from the SurfacePlotClientOnly module
+export { default } from "./SurfacePlotClientOnly";
+
+// Import the SurfacePlotProps type from the SurfacePlot.types module
+export type { SurfacePlotProps } from "./SurfacePlot.types";
diff --git a/src/index.ts b/src/index.ts
index f73a574d0..a66320fe3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -80,10 +80,15 @@ export {
type LabelSelectorProps
} from "./LabelSelector";
export { default as LinePlot, type LinePlotProps } from "./LinePlot";
+export { default as SurfacePlot, type SurfacePlotProps } from "./SurfacePlot";
export { default as Loading, type LoadingProps } from "./Loading";
export { default as LoginForm, type LoginFormProps } from "./LoginForm";
export { default as ModelButton, type ModelButtonProps } from "./ModelButton";
export { default as MultiColor, type MultiColorProps } from "./MultiColor";
+export {
+ default as ConditionalDialog,
+ type ConditionalDialogProps
+} from "./ConditionalDialog";
export {
default as MultiLabelPopover,
type MultiLabelPopoverProps
diff --git a/src/utils/plotlyConfig.ts b/src/utils/plotlyConfig.ts
new file mode 100644
index 000000000..c202369e2
--- /dev/null
+++ b/src/utils/plotlyConfig.ts
@@ -0,0 +1,34 @@
+// svg path for fullscreen icon in plotly menu bar
+const fullscreenIcon = {
+ height: 1792,
+ path: "M256 1408h1280v-768h-1280v768zm1536-1120v1216q0 66-47 113t-113 47h-1472q-66 0-113-47t-47-113v-1216q0-66 47-113t113-47h1472q66 0 113 47t47 113z",
+ width: 1792
+};
+
+type ConfigProps = {
+ isFullscreen: boolean;
+ handleClickFullscreen: () => void;
+};
+
+/**
+ * Returns a plotly config function with provided callback for clicking fullscreen
+ */
+export const getConfig = ({
+ isFullscreen,
+ handleClickFullscreen
+}: ConfigProps) => {
+ return {
+ displaylogo: false, // never display plotly logo
+ modeBarButtonsToAdd: !isFullscreen // if we are not in full screen, show a button to launch to fullscreen
+ ? [
+ {
+ click: handleClickFullscreen,
+ direction: "up",
+ icon: fullscreenIcon,
+ name: "Fullscreen",
+ title: "Fullscreen"
+ }
+ ]
+ : []
+ };
+};