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 ( + + + {title} + + + {children} + + + ); + } + // 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" + } + ] + : [] + }; +};