diff --git a/e2e/nextjs-app/src/app/components/error-display/custom-error.e2e.tsx b/e2e/nextjs-app/src/app/components/error-display/custom-error.e2e.tsx new file mode 100644 index 0000000000..74605cd295 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/error-display/custom-error.e2e.tsx @@ -0,0 +1,16 @@ +"use client"; +import { ErrorDisplay } from "@lifesg/react-design-system/error-display"; + +export default function Story() { + return ( + + You can pass a JSX component here as well + + } + /> + ); +} diff --git a/e2e/nextjs-app/src/app/components/error-display/default.e2e.tsx b/e2e/nextjs-app/src/app/components/error-display/default.e2e.tsx new file mode 100644 index 0000000000..8b3018fbe7 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/error-display/default.e2e.tsx @@ -0,0 +1,10 @@ +"use client"; +import { ErrorDisplay } from "@lifesg/react-design-system/error-display"; + +export default function Story() { + return ( +
+ +
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/error-display/image-only.e2e.tsx b/e2e/nextjs-app/src/app/components/error-display/image-only.e2e.tsx new file mode 100644 index 0000000000..8ec928b5ca --- /dev/null +++ b/e2e/nextjs-app/src/app/components/error-display/image-only.e2e.tsx @@ -0,0 +1,6 @@ +"use client"; +import { ErrorDisplay } from "@lifesg/react-design-system/error-display"; + +export default function Story() { + return ; +} diff --git a/e2e/nextjs-app/src/app/components/error-display/maintenance.e2e.tsx b/e2e/nextjs-app/src/app/components/error-display/maintenance.e2e.tsx new file mode 100644 index 0000000000..90f288807f --- /dev/null +++ b/e2e/nextjs-app/src/app/components/error-display/maintenance.e2e.tsx @@ -0,0 +1,11 @@ +"use client"; +import { ErrorDisplay } from "@lifesg/react-design-system/error-display"; + +export default function Story() { + return ( + + ); +} diff --git a/e2e/nextjs-app/src/app/components/error-display/with-action-button.e2e.tsx b/e2e/nextjs-app/src/app/components/error-display/with-action-button.e2e.tsx new file mode 100644 index 0000000000..886f8dd9ce --- /dev/null +++ b/e2e/nextjs-app/src/app/components/error-display/with-action-button.e2e.tsx @@ -0,0 +1,22 @@ +"use client"; +import { ErrorDisplay } from "@lifesg/react-design-system/error-display"; +import { useState } from "react"; + +export default function Story() { + const [clicked, setClicked] = useState(false); + + return ( + <> + setClicked(true), + }} + /> + {clicked && ( +
clicked
+ )} + + ); +} diff --git a/e2e/nextjs-app/src/app/globals.css b/e2e/nextjs-app/src/app/globals.css index 08d906c57a..2beb4e0836 100644 --- a/e2e/nextjs-app/src/app/globals.css +++ b/e2e/nextjs-app/src/app/globals.css @@ -15,3 +15,9 @@ .story-padding { padding: 1rem; } + +@media (prefers-color-scheme: dark) { + .story-background { + background-color: #000000; + } +} diff --git a/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Custom-error-renders-custom-title-and-description--custom-error-mount.png b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Custom-error-renders-custom-title-and-description--custom-error-mount.png new file mode 100644 index 0000000000..8eee105b57 Binary files /dev/null and b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Custom-error-renders-custom-title-and-description--custom-error-mount.png differ diff --git a/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default--default-mount.png b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default--default-mount.png new file mode 100644 index 0000000000..6ed9cdbe23 Binary files /dev/null and b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default--default-mount.png differ diff --git a/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default-dark-mode---default-dark.png b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default-dark-mode---default-dark.png new file mode 100644 index 0000000000..11cb0d2279 Binary files /dev/null and b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default-dark-mode---default-dark.png differ diff --git a/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default-mobile-viewport---default-mobile.png b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default-mobile-viewport---default-mobile.png new file mode 100644 index 0000000000..e071f3b68b Binary files /dev/null and b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Default-mobile-viewport---default-mobile.png differ diff --git a/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Image-only-renders-without-title-and-description--image-only-mount.png b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Image-only-renders-without-title-and-description--image-only-mount.png new file mode 100644 index 0000000000..299e532d62 Binary files /dev/null and b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Image-only-renders-without-title-and-description--image-only-mount.png differ diff --git a/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Maintenance-renders-dateString-in-description--maintenance-mount.png b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Maintenance-renders-dateString-in-description--maintenance-mount.png new file mode 100644 index 0000000000..90f016ae64 Binary files /dev/null and b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-Maintenance-renders-dateString-in-description--maintenance-mount.png differ diff --git a/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-With-action-button-renders-and-fires-onClick--with-action-button-mount.png b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-With-action-button-renders-and-fires-onClick--with-action-button-mount.png new file mode 100644 index 0000000000..64f18fc6d2 Binary files /dev/null and b/e2e/tests/components/error-display/__screenshots__/chromium/ErrorDisplay-With-action-button-renders-and-fires-onClick--with-action-button-mount.png differ diff --git a/e2e/tests/components/error-display/error-display.e2e.spec.ts b/e2e/tests/components/error-display/error-display.e2e.spec.ts new file mode 100644 index 0000000000..8246a688ba --- /dev/null +++ b/e2e/tests/components/error-display/error-display.e2e.spec.ts @@ -0,0 +1,130 @@ +import { expect, test as base, Page } from "@playwright/test"; +import { AbstractStoryPage, compareScreenshot } from "../../utils"; + +class StoryPage extends AbstractStoryPage { + protected readonly component = "error-display"; + constructor(page: Page) { + super(page); + } +} + +const test = base.extend<{ story: StoryPage }>({ + story: async ({ page }, use) => { + const story = new StoryPage(page); + await use(story); + }, +}); + +test.describe("ErrorDisplay", () => { + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("default"); + }); + + test("Default", async ({ story }) => { + await compareScreenshot(story, "default-mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("default", { mode: "dark" }); + }); + + test("Default (dark mode)", async ({ story }) => { + await compareScreenshot(story, "default-dark"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("default", { size: "mobile" }); + }); + + test("Default (mobile viewport)", async ({ story }) => { + await compareScreenshot(story, "default-mobile"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("with-action-button"); + }); + + test("With action button renders and fires onClick", async ({ + story, + }) => { + const button = story.page.getByRole("button", { + name: "Continue anyway", + }); + + await test.step("Screenshot matches on mount", async () => { + await compareScreenshot(story, "with-action-button-mount"); + }); + + await test.step("Clicking the button fires the handler", async () => { + await button.click(); + await expect( + story.page.getByTestId("continue-button-click-result") + ).toBeVisible(); + }); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("image-only"); + }); + + test("Image only renders without title and description", async ({ + story, + }) => { + await expect( + story.page.getByTestId("error-display--title") + ).not.toBeVisible(); + await expect( + story.page.getByTestId("error-display--description") + ).not.toBeVisible(); + await compareScreenshot(story, "image-only-mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("custom-error"); + }); + + test("Custom error renders custom title and description", async ({ + story, + }) => { + await test.step("Custom title is rendered", async () => { + await expect( + story.page.getByRole("heading", { + level: 2, + name: "My Custom 404", + }) + ).toBeVisible(); + }); + + await test.step("Custom description with JSX is rendered", async () => { + await expect( + story.page.getByTestId("error-display--description") + ).toContainText("JSX component"); + }); + + await compareScreenshot(story, "custom-error-mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("maintenance"); + }); + + test("Maintenance renders dateString in description", async ({ + story, + }) => { + await compareScreenshot(story, "maintenance-mount"); + }); + }); +}); \ No newline at end of file diff --git a/src/error-display/error-display-data.tsx b/src/error-display/error-display-data.tsx index 6f72eb3ce0..d3ad50a549 100644 --- a/src/error-display/error-display-data.tsx +++ b/src/error-display/error-display-data.tsx @@ -1,4 +1,4 @@ -import type { V3_ResourceScheme, V3_ThemeSpec } from "../v3_theme/types"; +import type { ThemeType } from "../theme"; import { renderDescriptionWithProps } from "./error-display-helper-comp"; import type { ErrorDisplayImagePathAttributes } from "./helper"; import { ErrorDisplayHelper } from "./helper"; @@ -450,13 +450,14 @@ interface ErrorDisplayDataAttrs { export const generateErrorDisplayData = ( ImgPathsObject: Record, - theme?: V3_ThemeSpec + mobile: string, + tablet: string ) => new Map([ [ "400", { - img: imgAttributeHelper(ImgPathsObject["400"], theme), + img: imgAttributeHelper(ImgPathsObject["400"], mobile, tablet), title: "Something went wrong", description: "This could be a temporary problem, so please refresh the page or try again later.", @@ -465,7 +466,7 @@ export const generateErrorDisplayData = ( [ "403", { - img: imgAttributeHelper(ImgPathsObject["403"], theme), + img: imgAttributeHelper(ImgPathsObject["403"], mobile, tablet), title: "Error loading page", description: "You may not have permission to view this page. If someone gave you this link, let them know about this error.", @@ -474,7 +475,7 @@ export const generateErrorDisplayData = ( [ "404", { - img: imgAttributeHelper(ImgPathsObject["404"], theme), + img: imgAttributeHelper(ImgPathsObject["404"], mobile, tablet), title: "Page not found", description: "If you entered or pasted the URL, check that it’s correct. If someone gave you this link, let them know about this error.", @@ -483,7 +484,7 @@ export const generateErrorDisplayData = ( [ "408", { - img: imgAttributeHelper(ImgPathsObject["408"], theme), + img: imgAttributeHelper(ImgPathsObject["408"], mobile, tablet), title: "Something went wrong", description: "This could be a temporary problem, so please refresh the page or try again later.", @@ -492,7 +493,7 @@ export const generateErrorDisplayData = ( [ "500", { - img: imgAttributeHelper(ImgPathsObject["500"], theme), + img: imgAttributeHelper(ImgPathsObject["500"], mobile, tablet), title: "Something went wrong", description: "We're working on a fix for the problem. Please try again later.", @@ -501,7 +502,7 @@ export const generateErrorDisplayData = ( [ "502", { - img: imgAttributeHelper(ImgPathsObject["502"], theme), + img: imgAttributeHelper(ImgPathsObject["502"], mobile, tablet), title: "Something went wrong", description: "This could be a temporary problem, so please refresh the page or try again later.", @@ -510,7 +511,7 @@ export const generateErrorDisplayData = ( [ "503", { - img: imgAttributeHelper(ImgPathsObject["503"], theme), + img: imgAttributeHelper(ImgPathsObject["503"], mobile, tablet), title: "Service under maintenance", description: "This service is currently unavailable. Please try again later.", @@ -519,7 +520,7 @@ export const generateErrorDisplayData = ( [ "504", { - img: imgAttributeHelper(ImgPathsObject["504"], theme), + img: imgAttributeHelper(ImgPathsObject["504"], mobile, tablet), title: "Something went wrong", description: "This could be a temporary problem, so please refresh the page or try again later.", @@ -528,7 +529,11 @@ export const generateErrorDisplayData = ( [ "confirmation", { - img: imgAttributeHelper(ImgPathsObject["confirmation"], theme), + img: imgAttributeHelper( + ImgPathsObject["confirmation"], + mobile, + tablet + ), title: "Leave and lose changes?", description: "You have unsaved changes. If you leave this page, you will lose the changes you’ve made.", @@ -537,7 +542,11 @@ export const generateErrorDisplayData = ( [ "link-error", { - img: imgAttributeHelper(ImgPathsObject["link-error"], theme), + img: imgAttributeHelper( + ImgPathsObject["link-error"], + mobile, + tablet + ), title: "Link has expired", description: "If you entered or pasted the URL, check that it’s correct. If someone gave you this link, let them know it has expired.", @@ -546,7 +555,11 @@ export const generateErrorDisplayData = ( [ "logout", { - img: imgAttributeHelper(ImgPathsObject["logout"], theme), + img: imgAttributeHelper( + ImgPathsObject["logout"], + mobile, + tablet + ), title: "You’ve been logged out", description: "It looks like you’ve left, so we logged you out to protect your privacy.", @@ -557,7 +570,8 @@ export const generateErrorDisplayData = ( { img: imgAttributeHelper( ImgPathsObject["insufficient-credits"], - theme + mobile, + tablet ), title: "Insufficient credits", description: @@ -567,7 +581,11 @@ export const generateErrorDisplayData = ( [ "inactivity", { - img: imgAttributeHelper(ImgPathsObject["inactivity"], theme), + img: imgAttributeHelper( + ImgPathsObject["inactivity"], + mobile, + tablet + ), title: "Are you still there?", description: "You’ve been inactive for a while. To protect your privacy, you’ll be logged out soon.\n\nIf you wish to stay on this page, let us know now.", @@ -577,7 +595,7 @@ export const generateErrorDisplayData = ( [ "maintenance", { - img: imgAttributeHelper(ImgPathsObject["503"], theme), + img: imgAttributeHelper(ImgPathsObject["503"], mobile, tablet), title: "Service under maintenance", description: "This service is currently unavailable. Please try again later.", @@ -587,7 +605,11 @@ export const generateErrorDisplayData = ( [ "no-item-found", { - img: imgAttributeHelper(ImgPathsObject["no-item-found"], theme), + img: imgAttributeHelper( + ImgPathsObject["no-item-found"], + mobile, + tablet + ), title: "No results found", description: "Try adjusting your search or filters to find what you’re looking for.", @@ -598,7 +620,8 @@ export const generateErrorDisplayData = ( { img: imgAttributeHelper( ImgPathsObject["payment-unsuccessful"], - theme + mobile, + tablet ), title: "Unsuccessful payment", description: "Your payment was unsuccessful. Please try again.", @@ -609,7 +632,8 @@ export const generateErrorDisplayData = ( { img: imgAttributeHelper( ImgPathsObject["transfer-unsuccessful"], - theme + mobile, + tablet ), title: "Unsuccessful transfer", description: @@ -621,7 +645,8 @@ export const generateErrorDisplayData = ( { img: imgAttributeHelper( ImgPathsObject["unsupported-browser"], - theme + mobile, + tablet ), title: "Browser not supported", description: @@ -633,7 +658,8 @@ export const generateErrorDisplayData = ( { img: imgAttributeHelper( ImgPathsObject["unsupported-browser"], - theme + mobile, + tablet ), title: "Browser version not supported", description: @@ -643,7 +669,11 @@ export const generateErrorDisplayData = ( [ "warning", { - img: imgAttributeHelper(ImgPathsObject["warning"], theme), + img: imgAttributeHelper( + ImgPathsObject["warning"], + mobile, + tablet + ), title: "Are you sure?", description: "You will lose your progress.", }, @@ -652,17 +682,26 @@ export const generateErrorDisplayData = ( export const getErrorDisplayData = ( type: ErrorDisplayType, - resourceScheme: V3_ResourceScheme, - theme?: V3_ThemeSpec + resourceScheme: ThemeType, + mobile: string, + tablet: string ) => { switch (resourceScheme) { case "bookingsg": - return generateErrorDisplayData(BsgImgPaths, theme).get(type); + return generateErrorDisplayData(BsgImgPaths, mobile, tablet).get( + type + ); case "ccube": - return generateErrorDisplayData(CCubeImgPaths, theme).get(type); + return generateErrorDisplayData(CCubeImgPaths, mobile, tablet).get( + type + ); case "mylegacy": - return generateErrorDisplayData(MyLegacyImgPaths, theme).get(type); + return generateErrorDisplayData( + MyLegacyImgPaths, + mobile, + tablet + ).get(type); default: - return generateErrorDisplayData(ImgPaths, theme).get(type); + return generateErrorDisplayData(ImgPaths, mobile, tablet).get(type); } }; diff --git a/src/error-display/error-display.style.tsx b/src/error-display/error-display.style.tsx deleted file mode 100644 index 1409665c97..0000000000 --- a/src/error-display/error-display.style.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import styled from "styled-components"; - -import { Button } from "../button"; -import { Markup } from "../markup"; -import { Typography } from "../typography"; -import { V3_Colour, V3_MediaQuery, V3_Spacing } from "../v3_theme"; - -export const Container = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - position: relative; -`; - -export const Img = styled.img` - position: relative; - width: 400px; - height: auto; - - ${V3_MediaQuery.MaxWidth.sm} { - width: 320px; - } - - ${V3_MediaQuery.MaxWidth.xs} { - width: 288px; - } - - ${V3_MediaQuery.MaxWidth.xxs} { - width: 240px; - } -`; - -export const TextContainer = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - max-width: 656px; - white-space: pre-wrap; -`; - -export const Title = styled(Typography.HeadingMD)` - margin: ${V3_Spacing["spacing-32"]} 0 ${V3_Spacing["spacing-16"]}; - text-align: center; -`; - -export const DescriptionContainer = styled(Markup)` - color: ${V3_Colour.text}; - text-align: center; - - p + p { - margin-top: ${V3_Spacing["spacing-16"]}; - } -`; - -export const ActionButton = styled(Button.Default)` - margin: ${V3_Spacing["spacing-32"]} auto 0; - width: 21rem; - - ${V3_MediaQuery.MaxWidth.sm} { - width: 100%; - } -`; diff --git a/src/error-display/error-display.styles.ts b/src/error-display/error-display.styles.ts new file mode 100644 index 0000000000..f211d6cb77 --- /dev/null +++ b/src/error-display/error-display.styles.ts @@ -0,0 +1,61 @@ +import { css } from "@linaria/core"; + +import { Colour, MediaQuery, Spacing } from "../theme"; + +export const container = css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; +`; + +export const img = css` + position: relative; + width: 400px; + height: auto; + + ${MediaQuery.MaxWidth.sm} { + width: 320px; + } + + ${MediaQuery.MaxWidth.xs} { + width: 288px; + } + + ${MediaQuery.MaxWidth.xxs} { + width: 240px; + } +`; + +export const textContainer = css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 656px; + white-space: pre-wrap; +`; + +export const title = css` + margin: ${Spacing["spacing-32"]} 0 ${Spacing["spacing-16"]}; + text-align: center; +`; + +export const descriptionContainer = css` + color: ${Colour.text}; + text-align: center; + + p + p { + margin-top: ${Spacing["spacing-16"]}; + } +`; + +export const actionButton = css` + margin: ${Spacing["spacing-32"]} auto 0; + width: 21rem; + + ${MediaQuery.MaxWidth.sm} { + width: 100%; + } +`; diff --git a/src/error-display/error-display.tsx b/src/error-display/error-display.tsx index ae8109c444..888fcf0345 100644 --- a/src/error-display/error-display.tsx +++ b/src/error-display/error-display.tsx @@ -1,16 +1,13 @@ +import clsx from "clsx"; import type React from "react"; import { useContext } from "react"; -import { ThemeContext } from "styled-components"; -import { V3_LifeSGTheme } from "../v3_theme"; -import { - ActionButton, - Container, - DescriptionContainer, - Img, - TextContainer, - Title, -} from "./error-display.style"; +import { Button } from "../button"; +import { Markup } from "../markup"; +import { Breakpoint, useDesignToken } from "../theme"; +import { ThemeContext } from "../theme/theme-provider/context"; +import { Typography } from "../typography"; +import * as styles from "./error-display.styles"; import { getErrorDisplayData } from "./error-display-data"; import { InactivityTimer } from "./inactivity-timer"; import type { @@ -28,16 +25,20 @@ export const ErrorDisplay = ({ additionalProps, imageOnly, illustrationScheme, + className, ...otherProps }: ErrorDisplayProps) => { // ============================================================================= // CONST, STATE, REF // ============================================================================= const theme = useContext(ThemeContext); + const mobile = useDesignToken(Breakpoint["sm-max"]) || Breakpoint["sm-max"]; + const tablet = useDesignToken(Breakpoint["lg-max"]) || Breakpoint["lg-max"]; const defaultAssets = getErrorDisplayData( type, - illustrationScheme || (theme || V3_LifeSGTheme).resourceScheme, - theme + illustrationScheme || theme?.theme || "lifesg", + mobile, + tablet ); const inactivityAttrs = type === "inactivity" @@ -85,7 +86,7 @@ export const ErrorDisplay = ({ ...actionButton, }; - return ; + return