From d8e5097c1e493f9555ebac6072d6e38dedb6a5d4 Mon Sep 17 00:00:00 2001 From: Adrian Goh Jun Wei Date: Wed, 11 Dec 2024 10:36:15 +0800 Subject: [PATCH] isom-1678 dynamic blocks for muis fix (#922) * update CSP to use env.NEXT_PUBLIC_S3_ASSETS_DOMAIN_NAME * fix: replace useQuery with useState and useEffect * npm run format:fix * add error state * add errorMessage to schema * add error state UI + use twVariant * update stories * fix - separate error and loading state * add errorMessage to studio default block * format * update preview-tw * fix - screen wide component --- apps/studio/next.config.mjs | 6 +- apps/studio/public/assets/css/preview-tw.css | 17 ++ .../src/components/PageEditor/constants.ts | 6 + package-lock.json | 42 ---- packages/components/package.json | 1 - .../interfaces/complex/DynamicDataBanner.ts | 5 + .../DynamicDataBanner.stories.tsx | 127 ++++++++++-- .../DynamicDataBanner/DynamicDataBanner.tsx | 16 +- .../DynamicDataBannerClient.tsx | 180 ++++++++++++------ .../layouts/Homepage/Homepage.stories.tsx | 19 ++ 10 files changed, 301 insertions(+), 118 deletions(-) diff --git a/apps/studio/next.config.mjs b/apps/studio/next.config.mjs index b5cdecaa99..fc477702f8 100644 --- a/apps/studio/next.config.mjs +++ b/apps/studio/next.config.mjs @@ -74,7 +74,11 @@ const ContentSecurityPolicy = ` https://*.wogaa.sg https://placehold.co https://cdn.growthbook.io - ${env.NODE_ENV === "production" ? "https://isomer-user-content.by.gov.sg" : "https://*.by.gov.sg"} + ${ + !!env.NEXT_PUBLIC_S3_ASSETS_DOMAIN_NAME + ? `https://${env.NEXT_PUBLIC_S3_ASSETS_DOMAIN_NAME}` + : "https://*.by.gov.sg" + } https://via.intercom.io https://api.intercom.io https://api.au.intercom.io diff --git a/apps/studio/public/assets/css/preview-tw.css b/apps/studio/public/assets/css/preview-tw.css index 1f50274986..d8a74a956b 100644 --- a/apps/studio/public/assets/css/preview-tw.css +++ b/apps/studio/public/assets/css/preview-tw.css @@ -4929,6 +4929,10 @@ video { padding-bottom: 5rem; } + .md\:pb-3 { + padding-bottom: 0.75rem; + } + .md\:pt-16 { padding-top: 4rem; } @@ -5139,6 +5143,10 @@ video { align-items: flex-start; } + .lg\:items-end { + align-items: flex-end; + } + .lg\:justify-start { justify-content: flex-start; } @@ -5147,6 +5155,10 @@ video { justify-content: flex-end; } + .lg\:justify-center { + justify-content: center; + } + .lg\:justify-between { justify-content: space-between; } @@ -5219,6 +5231,11 @@ video { padding-right: 2rem; } + .lg\:py-0 { + padding-top: 0px; + padding-bottom: 0px; + } + .lg\:py-16 { padding-top: 4rem; padding-bottom: 4rem; diff --git a/apps/studio/src/components/PageEditor/constants.ts b/apps/studio/src/components/PageEditor/constants.ts index ebc2b570a5..74254d5a26 100644 --- a/apps/studio/src/components/PageEditor/constants.ts +++ b/apps/studio/src/components/PageEditor/constants.ts @@ -209,6 +209,12 @@ export const DEFAULT_BLOCKS: Record< ], url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", label: "View all dates", + errorMessage: [ + { + type: "text", + text: "Oops! Having trouble loading the data. Try refreshing — that usually does the trick!", + }, + ], }, } diff --git a/package-lock.json b/package-lock.json index 7a066b312c..bd9e474dbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33199,7 +33199,6 @@ "@govtechsg/sgds-react": "^2.5.1", "@headlessui/react": "^2.1.2", "@sinclair/typebox": "^0.33.12", - "@tanstack/react-query": "^4.36.1", "date-fns": "^4.1.0", "interweave": "^13.1.0", "interweave-ssr": "^2.0.0", @@ -33382,32 +33381,6 @@ "storybook": "^8.3.3" } }, - "packages/components/node_modules/@tanstack/query-core": { - "version": "5.61.5", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.61.5.tgz", - "integrity": "sha512-iG5vqurEOEbv+paP6kW3zPENa99kSIrd1THISJMaTwVlJ+N5yjVDNOUwp9McK2DWqWCXM3v13ubBbAyhxT78UQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "packages/components/node_modules/@tanstack/react-query": { - "version": "5.61.5", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.61.5.tgz", - "integrity": "sha512-rjy8aqPgBBEz/rjJnpnuhi8TVkVTorMUsJlM3lMvrRb5wK6yzfk34Er0fnJ7w/4qyF01SnXsLB/QsTBsLF5PaQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.61.5" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, "packages/components/node_modules/@types/node": { "version": "22.7.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.2.tgz", @@ -34455,21 +34428,6 @@ "tooling/typescript": { "name": "@isomer/tsconfig", "version": "0.0.0" - }, - "tooling/template/node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.13", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.13.tgz", - "integrity": "sha512-V26ezyjPqQpDBV4lcWIh8B/QICQ4v+M5Bo9ykLN+sqeKKBxJVDpEc6biDVyluTXTC40f5IqCU0ttth7Es2ZuMw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/packages/components/package.json b/packages/components/package.json index a29bbbdfa9..1c646805b2 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -107,7 +107,6 @@ "@govtechsg/sgds-react": "^2.5.1", "@headlessui/react": "^2.1.2", "@sinclair/typebox": "^0.33.12", - "@tanstack/react-query": "^4.36.1", "date-fns": "^4.1.0", "interweave": "^13.1.0", "interweave-ssr": "^2.0.0", diff --git a/packages/components/src/interfaces/complex/DynamicDataBanner.ts b/packages/components/src/interfaces/complex/DynamicDataBanner.ts index 0dc3e1cd87..80d31ad750 100644 --- a/packages/components/src/interfaces/complex/DynamicDataBanner.ts +++ b/packages/components/src/interfaces/complex/DynamicDataBanner.ts @@ -2,6 +2,7 @@ import type { Static } from "@sinclair/typebox" import { Type } from "@sinclair/typebox" import type { IsomerSiteProps, LinkComponentType } from "~/types" +import { TextSchema } from "../native/Text" export const DYNAMIC_DATA_BANNER_TYPE = "dynamicdatabanner" @@ -47,6 +48,10 @@ export const DynamicDataBannerSchema = Type.Object( maxItems: NUMBER_OF_DATA, }, ), + errorMessage: Type.Array(TextSchema, { + title: "Error message", + description: "The error message to display if the data is not loaded", + }), label: Type.Optional( Type.String({ title: "Link text", diff --git a/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBanner.stories.tsx b/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBanner.stories.tsx index bc5fe1a777..d8f68f9b5b 100644 --- a/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBanner.stories.tsx +++ b/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBanner.stories.tsx @@ -2,32 +2,129 @@ import type { Meta, StoryObj } from "@storybook/react" import { withChromaticModes } from "@isomer/storybook-config" -import { DynamicDataBannerUI } from "./DynamicDataBannerClient" +import { DynamicDataBanner } from "./DynamicDataBanner" +import { getSingaporeDateYYYYMMDD } from "./utils" -const meta: Meta = { +const meta: Meta = { title: "Next/Components/DynamicDataBanner", - component: DynamicDataBannerUI, + component: DynamicDataBanner, parameters: { layout: "fullscreen", chromatic: withChromaticModes(["mobile", "tablet", "desktop"]), }, + args: { + site: { + siteName: "Isomer Next", + siteMap: { + id: "1", + title: "Home", + permalink: "/", + lastModified: "", + layout: "homepage", + summary: "", + }, + theme: "isomer-next", + isGovernment: true, + logoUrl: "https://www.isomer.gov.sg/images/isomer-logo.svg", + navBarItems: [], + footerItems: { + privacyStatementLink: "https://www.isomer.gov.sg/privacy", + termsOfUseLink: "https://www.isomer.gov.sg/terms", + siteNavItems: [], + }, + lastUpdated: "1 Jan 2021", + search: { + type: "searchSG", + clientId: "", + }, + }, + apiEndpoint: "https://jsonplaceholder.com/muis_prayers_time", + title: "hijriDate", + data: [ + { + label: "Subuh", + key: "subuh", + }, + { + label: "Syuruk", + key: "syuruk", + }, + { + label: "Zohor", + key: "zohor", + }, + { + label: "Asar", + key: "asar", + }, + { + label: "Maghrib", + key: "maghrib", + }, + { + label: "Ishak", + key: "isyak", + }, + ], + url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + label: "View all dates", + errorMessage: [ + { + text: "Not seeing the prayer times? ", + type: "text", + }, + { + text: "Report an issue", + type: "text", + marks: [ + { type: "bold" }, + { + type: "link", + attrs: { + href: "https://www.form.gov.sg/some-link", + }, + }, + ], + }, + ], + }, } export default meta -type Story = StoryObj +type Story = StoryObj export const Default: Story = { - args: { - title: "1 Rejab 1446H", - data: [ - { label: "Subuh", value: "5:43am" }, - { label: "Syuruk", value: "7:07am" }, - { label: "Zohor", value: "1:09pm" }, - { label: "Asar", value: "4.33pm" }, - { label: "Maghrib", value: "7.10pm" }, - { label: "Isyak", value: "8.25pm" }, + parameters: { + mockData: [ + { + url: "https://jsonplaceholder.com/muis_prayers_time", + method: "GET", + status: 200, + response: { + [getSingaporeDateYYYYMMDD()]: { + hijriDate: "17 Jamadilawal 1442H", + subuh: "5:44am", + syuruk: "7:08am", + zohor: "1:10pm", + asar: "4:34pm", + maghrib: "7:11pm", + isyak: "8:25pm", + }, + }, + }, + ], + }, +} + +export const Error: Story = { + parameters: { + mockData: [ + { + url: "https://jsonplaceholder.com/muis_prayers_time", + method: "GET", + status: 500, + response: {}, + }, ], - url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - label: "View all dates", }, } diff --git a/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBanner.tsx b/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBanner.tsx index 6244407715..447d79d80c 100644 --- a/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBanner.tsx +++ b/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBanner.tsx @@ -1,5 +1,6 @@ import type { DynamicDataBannerProps } from "~/interfaces" -import { getReferenceLinkHref } from "~/utils" +import { getReferenceLinkHref, getTextAsHtml } from "~/utils" +import BaseParagraph from "../../internal/BaseParagraph/BaseParagraph" import { DynamicDataBannerClient } from "./DynamicDataBannerClient" export const DynamicDataBanner = ({ @@ -8,6 +9,7 @@ export const DynamicDataBanner = ({ data, url, label, + errorMessage, site, LinkComponent, }: DynamicDataBannerProps) => { @@ -18,7 +20,17 @@ export const DynamicDataBanner = ({ data={data} url={getReferenceLinkHref(url, site.siteMap, site.assetsBaseUrl)} label={label} - LinkComponent={LinkComponent} + errorMessageBaseParagraph={ + + } /> ) } diff --git a/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBannerClient.tsx b/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBannerClient.tsx index e41141e841..679e3859f1 100644 --- a/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBannerClient.tsx +++ b/packages/components/src/templates/next/components/complex/DynamicDataBanner/DynamicDataBannerClient.tsx @@ -1,24 +1,64 @@ "use client" -import { - QueryClient, - QueryClientProvider, - useQuery, -} from "@tanstack/react-query" +import { useEffect, useState } from "react" import type { DynamicDataBannerProps } from "~/interfaces" import { NUMBER_OF_DATA } from "~/interfaces" +import { tv } from "~/lib/tv" import { ComponentContent } from "../../internal/customCssClass" import { Link } from "../../internal/Link" import { getSingaporeDateLong, getSingaporeDateYYYYMMDD } from "./utils" -export const DynamicDataBannerUI = ({ +const createDynamicDataBannerStyles = tv({ + slots: { + screenWideOuterContainer: "bg-brand-canvas", + outerContainer: `${ComponentContent} grid grid-cols-1 gap-5 px-6 pb-4 pt-6 md:gap-4 md:px-10 md:py-2 lg:grid-cols-12 lg:justify-between lg:justify-items-stretch lg:gap-0`, + basicInfoContainer: + "flex flex-row items-center justify-between md:gap-1 md:py-3 lg:col-span-3 lg:flex-col lg:items-start lg:justify-start", + basicInfoInnerContainer: "flex flex-col items-start justify-start gap-1", + title: "prose-headline-base-medium", + dateWithDesktopUrl: "prose-label-sm-regular flex flex-row gap-2", + url: "prose-label-sm-medium flex text-link visited:text-link-visited hover:text-link-hover", + dataInfoContainer: + "grid gap-y-1 md:flex md:justify-between md:justify-items-center lg:col-span-8 lg:col-start-5", + errorMessageContainer: + "flex flex-1 flex-col gap-1 md:pb-3 lg:items-end lg:justify-center lg:py-0", + individualDataContainer: + "flex w-fit flex-col items-start justify-center gap-0.5 py-3 md:items-center", + individualDataLabel: "prose-body-sm", + individualDataValue: "prose-headline-lg-medium", + showOnMobileOnly: "block md:hidden", + showOnTabletOnly: "hidden md:block lg:hidden", + showOnDesktopOnly: "hidden lg:block", + }, + variants: { + success: { + true: { + dataInfoContainer: ["grid-cols-3"], + }, + }, + }, +}) +const compoundStyles = createDynamicDataBannerStyles() + +type DynamicDataBannerClientProps = Omit< + DynamicDataBannerProps, + "type" | "site" | "errorMessage" +> & { + errorMessageBaseParagraph?: React.ReactNode +} + +const DynamicDataBannerUI = ({ title, data, url, label, + errorMessageBaseParagraph, LinkComponent, -}: Pick & { +}: Pick< + DynamicDataBannerClientProps, + "title" | "label" | "url" | "LinkComponent" | "errorMessageBaseParagraph" +> & { data: { label: string; value: string }[] }) => { const shouldRenderUrl: boolean = !!url && !!label @@ -27,7 +67,7 @@ export const DynamicDataBannerUI = ({ {label} @@ -35,75 +75,112 @@ export const DynamicDataBannerUI = ({ } return ( -
-
-
-
- {title &&
{title}
} -
+
+
+
+
+ {title &&
{title}
} +
{getSingaporeDateLong()} {shouldRenderUrl && ( -
{renderUrl()}
+
+ {renderUrl()} +
)}
{shouldRenderUrl && ( -
{renderUrl()}
+
+ {renderUrl()} +
)}
-
- {data.slice(0, NUMBER_OF_DATA).map((singleData) => ( -
-
{singleData.label}
-
{singleData.value}
+
+ {!!errorMessageBaseParagraph ? ( +
+ {errorMessageBaseParagraph}
- ))} + ) : ( + data.slice(0, NUMBER_OF_DATA).map((singleData) => ( +
+
+ {singleData.label} +
+
+ {singleData.value} +
+
+ )) + )}
{shouldRenderUrl && ( -
{renderUrl()}
+
{renderUrl()}
)}
) } -type DynamicDataBannerClientProps = Omit< - DynamicDataBannerProps, - "type" | "site" -> - -const DynamicDataBannerContent = ({ +export const DynamicDataBannerClient = ({ apiEndpoint, title, data, url, label, + errorMessageBaseParagraph, LinkComponent, }: DynamicDataBannerClientProps) => { - const { - isPending, - error, - data: apiData, - } = useQuery({ - queryKey: [getSingaporeDateYYYYMMDD()], - queryFn: async () => { - const response = await fetch(apiEndpoint) - return (await response.json()) as Record - }, - }) + const [isLoading, setLoading] = useState(true) + const [isError, setError] = useState(false) + const [dynamicData, setDynamicData] = useState>({}) + + // This is to ensure that the component is mounted before the query is executed + // because next.js will attempt to execute the query during static site generation + // which will fail because it requires "fetch" (browser API) to be available, which isn't the case + // Ref: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#browser-apis + // Also not using react-query's useQuery hook because it's not compatible with this approach of using useEffect + useEffect(() => { + // we now have access to fetch here + fetch(apiEndpoint) + .then((res) => res.json()) + .then((apiData) => { + if (!apiData?.[getSingaporeDateYYYYMMDD()]) { + throw new Error("No data found for current date") + } + setDynamicData(apiData[getSingaporeDateYYYYMMDD()]) + setLoading(false) + }) + .catch((error) => { + console.error("Error fetching data:", error) + setLoading(false) + setError(true) + }) + }, []) - if (isPending || error || data.length !== NUMBER_OF_DATA) + if (isError) { + return ( + + ) + } + + if (isLoading || data.length !== NUMBER_OF_DATA) return - const values = apiData[getSingaporeDateYYYYMMDD()] as Record return ( ({ label: singleData.label, - value: values[singleData.key] || "-- : --", + value: dynamicData[singleData.key] || "-- : --", }))} url={url} label={label} @@ -111,14 +188,3 @@ const DynamicDataBannerContent = ({ /> ) } - -const queryClient = new QueryClient() -export const DynamicDataBannerClient = ( - props: DynamicDataBannerClientProps, -) => { - return ( - - - - ) -} diff --git a/packages/components/src/templates/next/layouts/Homepage/Homepage.stories.tsx b/packages/components/src/templates/next/layouts/Homepage/Homepage.stories.tsx index 316ff6355e..d02b443b35 100644 --- a/packages/components/src/templates/next/layouts/Homepage/Homepage.stories.tsx +++ b/packages/components/src/templates/next/layouts/Homepage/Homepage.stories.tsx @@ -259,6 +259,25 @@ export const Default: Story = { ], url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", label: "View all dates", + errorMessage: [ + { + text: "Not seeing the prayer times? ", + type: "text", + }, + { + text: "Report an issue", + type: "text", + marks: [ + { type: "bold" }, + { + type: "link", + attrs: { + href: "https://www.form.gov.sg/some-link", + }, + }, + ], + }, + ], }, { type: "infobar",