diff --git a/.changeset/opt-out-banners.md b/.changeset/opt-out-banners.md new file mode 100644 index 00000000..652dee33 --- /dev/null +++ b/.changeset/opt-out-banners.md @@ -0,0 +1,5 @@ +--- +'@node-core/doc-kit': minor +--- + +Add banner opt-out diff --git a/e2e/announcement-banner.spec.js b/e2e/announcement-banner.spec.js index 2fecf4b3..95e77d8b 100644 --- a/e2e/announcement-banner.spec.js +++ b/e2e/announcement-banner.spec.js @@ -17,9 +17,12 @@ test.describe('Announcement Banner', () => { await page.goto('/assert.html'); - const banner = page.getByRole('region', { name: 'Announcements' }); + const banner = page.getByRole('region', { name: 'Announcement' }); await expect(banner).toBeVisible(); await expect(banner).toContainText('Important announcement for all users'); + await expect( + banner.getByRole('button', { name: 'Close banner' }) + ).toBeVisible(); }); test('renders a banner with a link', async ({ page }) => { @@ -39,7 +42,7 @@ test.describe('Announcement Banner', () => { await page.goto('/assert.html'); - const banner = page.getByRole('region', { name: 'Announcements' }); + const banner = page.getByRole('region', { name: 'Announcement' }); await expect(banner).toBeVisible(); const link = banner.getByRole('link', { name: 'Read the release notes' }); @@ -60,7 +63,37 @@ test.describe('Announcement Banner', () => { await page.waitForLoadState('networkidle'); await expect( - page.getByRole('region', { name: 'Announcements' }) + page.getByRole('region', { name: 'Announcement' }) ).not.toBeAttached(); }); + + test('persists dismissal until the banner text changes', async ({ page }) => { + let text = 'Dismiss this announcement'; + + await page.route(REMOTE_CONFIG_URL, route => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + websiteBanners: { index: { text } }, + }), + }) + ); + + await page.goto('/assert.html'); + + const banner = page.getByRole('region', { name: 'Announcement' }); + await banner.getByRole('button', { name: 'Close banner' }).click(); + await expect(banner).not.toBeAttached(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('banner-dismissal'))) + .toBe(text); + + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(banner).not.toBeAttached(); + + text = 'A new announcement'; + await page.reload(); + await expect(banner).toContainText(text); + }); }); diff --git a/package-lock.json b/package-lock.json index ec3d6bb1..15373b75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@node-core/doc-kit", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@node-core/doc-kit", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@actions/core": "^3.0.0", "@heroicons/react": "^2.2.0", "@minify-html/wasm": "^0.18.1", "@node-core/rehype-shiki": "^1.4.2", - "@node-core/ui-components": "^1.7.0", + "@node-core/ui-components": "^1.7.2", "@orama/orama": "^3.1.18", "@orama/ui": "^1.5.4", "@rollup/plugin-virtual": "^3.0.2", @@ -436,7 +436,6 @@ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -448,7 +447,6 @@ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1324,6 +1322,7 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1477,9 +1476,9 @@ } }, "node_modules/@node-core/ui-components": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@node-core/ui-components/-/ui-components-1.7.1.tgz", - "integrity": "sha512-i1un8y0yth6WdT0OXpAQeL8XIkohcZ2P8khSC0oQstWG8H7PCRqX+mdZlJAuVUKq3MaQnC2fO34Q3j7L4TenXg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@node-core/ui-components/-/ui-components-1.7.2.tgz", + "integrity": "sha512-lszPY9QTNNzTEtLr3ujQ1COpmqTm1BrHlN/mgiFM042ucDtrgjykotiLlstexiE7ZUHxamtg/LKP3sfzTsD9vw==", "dependencies": { "@heroicons/react": "^2.2.0", "@orama/orama": "^3.1.18", @@ -1554,6 +1553,7 @@ "resolved": "https://registry.npmjs.org/@orama/cuid2/-/cuid2-2.2.3.tgz", "integrity": "sha512-Lcak3chblMejdlSHgYU2lS2cdOhDpU6vkfIJH4m+YKvqQyLqs1bB8+w6NT1MG5bO12NUK2GFc34Mn2xshMIQ1g==", "license": "MIT", + "peer": true, "dependencies": { "@noble/hashes": "^1.1.5" } @@ -1571,7 +1571,8 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/@orama/oramacore-events-parser/-/oramacore-events-parser-0.0.5.tgz", "integrity": "sha512-yAuSwog+HQBAXgZ60TNKEwu04y81/09mpbYBCmz1RCxnr4ObNY2JnPZI7HmALbjAhLJ8t5p+wc2JHRK93ubO4w==", - "license": "AGPL-3.0" + "license": "AGPL-3.0", + "peer": true }, "node_modules/@orama/stopwords": { "version": "3.1.18", @@ -2910,7 +2911,6 @@ "integrity": "sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3068,7 +3068,6 @@ "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", @@ -3533,7 +3532,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4230,7 +4228,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/data-view-buffer": { "version": "1.0.2", @@ -4734,7 +4733,6 @@ "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -8532,7 +8530,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8609,7 +8606,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.4.tgz", "integrity": "sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -8722,7 +8718,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9352,7 +9347,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.8.5", @@ -10379,7 +10375,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, diff --git a/package.json b/package.json index a95dc8b8..9421a236 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@heroicons/react": "^2.2.0", "@minify-html/wasm": "^0.18.1", "@node-core/rehype-shiki": "^1.4.2", - "@node-core/ui-components": "^1.7.0", + "@node-core/ui-components": "^1.7.2", "@orama/orama": "^3.1.18", "@orama/ui": "^1.5.4", "@rollup/plugin-virtual": "^3.0.2", diff --git a/src/generators/web/ui/components/AnnouncementBanner/RemoteLoadableBanner.jsx b/src/generators/web/ui/components/AnnouncementBanner/RemoteLoadableBanner.jsx deleted file mode 100644 index e6116383..00000000 --- a/src/generators/web/ui/components/AnnouncementBanner/RemoteLoadableBanner.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import { lazy, Suspense } from 'preact/compat'; - -import AnnouncementBanner from './index.jsx'; -import { loadBanners } from './loadBanners.mjs'; - -import { remoteConfigUrl, version } from '#theme/config'; - -// TODO: Revisit SERVER global usage (https://github.com/nodejs/doc-kit/issues/353) -const LazyBanners = SERVER - ? null - : lazy(async () => { - const active = await loadBanners(remoteConfigUrl, version.major); - - if (!active.length) { - return { default: () => null }; - } - - return { default: () => }; - }); - -const RemoteLoadableBanner = SERVER - ? () =>
- : () => ( -
- - - -
- ); - -export default RemoteLoadableBanner; diff --git a/src/generators/web/ui/components/AnnouncementBanner/__tests__/loadBanners.test.mjs b/src/generators/web/ui/components/AnnouncementBanner/__tests__/loadBanners.test.mjs deleted file mode 100644 index cb0560c4..00000000 --- a/src/generators/web/ui/components/AnnouncementBanner/__tests__/loadBanners.test.mjs +++ /dev/null @@ -1,174 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { loadBanners } from '../loadBanners.mjs'; - -const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday -const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow - -const makeResponse = (banners, ok = true) => ({ - ok, - json: async () => ({ websiteBanners: banners }), -}); - -describe('loadBanners', () => { - describe('fetch behavior', () => { - it('fetches from the given URL', async t => { - t.mock.method(global, 'fetch', () => Promise.resolve(makeResponse({}))); - - await loadBanners('https://example.com/site.json', null); - - assert.equal(global.fetch.mock.calls.length, 1); - assert.equal( - global.fetch.mock.calls[0].arguments[0], - 'https://example.com/site.json' - ); - }); - - it('returns an empty array on non-ok response', async t => { - t.mock.method(global, 'fetch', () => - Promise.resolve(makeResponse({}, false)) - ); - - const result = await loadBanners('https://example.com/site.json', null); - - assert.deepEqual(result, []); - }); - - it('handles fetch errors silently', async t => { - t.mock.method(global, 'fetch', () => - Promise.reject(new Error('Network error')) - ); - - const result = await loadBanners('https://example.com/site.json', null); - - assert.deepEqual(result, []); - }); - - it('returns an empty array when remoteConfig is absent', async t => { - t.mock.method(global, 'fetch', () => Promise.resolve(makeResponse({}))); - - const result = await loadBanners(undefined, null); - - assert.deepEqual(result, []); - assert.equal(global.fetch.mock.calls.length, 0); - }); - }); - - describe('banner selection', () => { - it('returns the active global (index) banner', async t => { - const banner = { text: 'Global banner', type: 'warning' }; - t.mock.method(global, 'fetch', () => - Promise.resolve(makeResponse({ index: banner })) - ); - - const result = await loadBanners('https://example.com/site.json', null); - - assert.deepEqual(result, [banner]); - }); - - it('returns the active version-specific banner', async t => { - const banner = { text: 'v20 banner', type: 'warning' }; - t.mock.method(global, 'fetch', () => - Promise.resolve(makeResponse({ v20: banner })) - ); - - const result = await loadBanners('https://example.com/site.json', 20); - - assert.deepEqual(result, [banner]); - }); - - it('returns both global and version banners when both are active', async t => { - const globalBanner = { text: 'Global banner', type: 'warning' }; - const versionBanner = { text: 'v20 banner', type: 'error' }; - t.mock.method(global, 'fetch', () => - Promise.resolve( - makeResponse({ index: globalBanner, v20: versionBanner }) - ) - ); - - const result = await loadBanners('https://example.com/site.json', 20); - - assert.deepEqual(result, [globalBanner, versionBanner]); - }); - - it('returns global banner first, version banner second', async t => { - const globalBanner = { text: 'Global', type: 'warning' }; - const versionBanner = { text: 'v22', type: 'error' }; - t.mock.method(global, 'fetch', () => - Promise.resolve( - makeResponse({ index: globalBanner, v22: versionBanner }) - ) - ); - - const result = await loadBanners('https://example.com/site.json', 22); - - assert.equal(result[0], globalBanner); - assert.equal(result[1], versionBanner); - }); - - it('does not include the version banner when versionMajor is null', async t => { - const globalBanner = { text: 'Global banner', type: 'warning' }; - const versionBanner = { text: 'v20 banner', type: 'error' }; - t.mock.method(global, 'fetch', () => - Promise.resolve( - makeResponse({ index: globalBanner, v20: versionBanner }) - ) - ); - - const result = await loadBanners('https://example.com/site.json', null); - - assert.deepEqual(result, [globalBanner]); - }); - - it('returns an empty array when websiteBanners is absent', async t => { - t.mock.method(global, 'fetch', () => - Promise.resolve({ ok: true, json: async () => ({}) }) - ); - - const result = await loadBanners('https://example.com/site.json', null); - - assert.deepEqual(result, []); - }); - }); - - describe('date filtering', () => { - it('excludes a banner whose endDate has passed', async t => { - const banner = { text: 'Expired', type: 'warning', endDate: PAST }; - t.mock.method(global, 'fetch', () => - Promise.resolve(makeResponse({ index: banner })) - ); - - const result = await loadBanners('https://example.com/site.json', null); - - assert.deepEqual(result, []); - }); - - it('excludes a banner whose startDate is in the future', async t => { - const banner = { text: 'Upcoming', type: 'warning', startDate: FUTURE }; - t.mock.method(global, 'fetch', () => - Promise.resolve(makeResponse({ index: banner })) - ); - - const result = await loadBanners('https://example.com/site.json', null); - - assert.deepEqual(result, []); - }); - - it('includes a banner within its active date range', async t => { - const banner = { - text: 'Active', - type: 'warning', - startDate: PAST, - endDate: FUTURE, - }; - t.mock.method(global, 'fetch', () => - Promise.resolve(makeResponse({ index: banner })) - ); - - const result = await loadBanners('https://example.com/site.json', null); - - assert.deepEqual(result, [banner]); - }); - }); -}); diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx deleted file mode 100644 index e6be507a..00000000 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; -import Banner from '@node-core/ui-components/Common/Banner'; - -import styles from './index.module.css'; - -/** - * @param {import('./types.d.ts').AnnouncementBannerProps} props - */ -const AnnouncementBanner = ({ banners }) => ( -
- {banners.map(banner => ( - - {banner.link ? ( - - {banner.text} - - ) : ( - banner.text - )} - {banner.link && } - - ))} -
-); - -export default AnnouncementBanner; diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.module.css b/src/generators/web/ui/components/AnnouncementBanner/index.module.css deleted file mode 100644 index d8a38d10..00000000 --- a/src/generators/web/ui/components/AnnouncementBanner/index.module.css +++ /dev/null @@ -1,14 +0,0 @@ -.banners { - animation: slideDown 300ms ease-out 400ms backwards; -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-0.5rem); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs deleted file mode 100644 index 8e1cd4d3..00000000 --- a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { isBannerActive } from '../../utils/banner.mjs'; - -/** - * Fetches and returns active banners for the given version. - * Returns an empty array when remoteConfigUrl is absent, the response is not ok, - * or on any fetch/parse failure. - * - * @param {string | undefined} remoteConfigUrl - * @param {number | null} versionMajor - * @returns {Promise>} - */ -export const loadBanners = async (remoteConfigUrl, versionMajor) => { - if (!remoteConfigUrl) { - return []; - } - - try { - const res = await fetch(remoteConfigUrl); - - /** @type {import('./types.d.ts').RemoteConfig} */ - const { websiteBanners = {} } = await res.json(); - - const keys = ['index', versionMajor != null && `v${versionMajor}`].filter( - Boolean - ); - - return keys - .map(key => websiteBanners[key]) - .filter(banner => banner && isBannerActive(banner)); - } catch { - return []; - } -}; diff --git a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts deleted file mode 100644 index 8ea873de..00000000 --- a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { BannerProps } from '@node-core/ui-components/Common/Banner'; - -export type BannerEntry = { - startDate?: string; - endDate?: string; - text: string; - link?: string; - type?: BannerProps['type']; -}; - -export type RemoteConfig = { - websiteBanners?: Record; -}; - -export type AnnouncementBannerProps = { - banners: BannerEntry[]; -}; diff --git a/src/generators/web/ui/components/Banner.jsx b/src/generators/web/ui/components/Banner.jsx new file mode 100644 index 00000000..1c5bb915 --- /dev/null +++ b/src/generators/web/ui/components/Banner.jsx @@ -0,0 +1,32 @@ +import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; +import Banner from '@node-core/ui-components/Common/Banner'; + +import useBanners from '../hooks/useBanners.mjs'; + +import { remoteConfigUrl, version } from '#theme/config'; + +const Banners = () => { + const [banner, dismissBanner] = useBanners(remoteConfigUrl, version.major); + + return ( + banner && ( + dismissBanner(banner)} + > + {banner.link ? ( + + {banner.text} + + ) : ( + banner.text + )} + {banner.link && } + + ) + ); +}; + +export default Banners; diff --git a/src/generators/web/ui/components/Layout/index.jsx b/src/generators/web/ui/components/Layout/index.jsx index 8b2992c8..f7e9ce7f 100644 --- a/src/generators/web/ui/components/Layout/index.jsx +++ b/src/generators/web/ui/components/Layout/index.jsx @@ -1,7 +1,7 @@ import TableOfContents from '@node-core/ui-components/Common/TableOfContents'; import Article from '@node-core/ui-components/Containers/Article'; -import RemoteLoadableBanner from '../AnnouncementBanner/RemoteLoadableBanner'; +import Banner from '../Banner'; import Footer from '#theme/Footer'; import MetaBar from '#theme/Metabar'; @@ -19,7 +19,7 @@ import SideBar from '#theme/Sidebar'; */ export default ({ metadata, headings, readingTime, children }) => ( <> - + {CLIENT && }
diff --git a/src/generators/web/ui/hooks/useBanners.mjs b/src/generators/web/ui/hooks/useBanners.mjs new file mode 100644 index 00000000..a7f7b1b9 --- /dev/null +++ b/src/generators/web/ui/hooks/useBanners.mjs @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react'; + +const STORAGE_KEY = 'banner-dismissal'; + +/** + * @typedef {object} BannerEntry + * @property {string} text + * @property {string} [startDate] + * @property {string} [endDate] + * @property {string} [link] + * @property {'default' | 'warning' | 'error'} [type] + */ + +/** + * @typedef {BannerEntry & { section: string }} ActiveBanner + */ + +/** + * The global banner uses the same key as nodejs.org. + * + * @param {string} section + */ +const getStorageKey = section => + section === 'index' ? STORAGE_KEY : `${STORAGE_KEY}-${section}`; + +/** + * Checks whether a banner should be displayed based on its date range. + * + * @param {BannerEntry} banner + */ +export const isBannerActive = ({ startDate, endDate }) => { + const now = Date.now(); + + return ( + (!startDate || now >= new Date(startDate)) && + (!endDate || now <= new Date(endDate)) + ); +}; + +/** + * Fetches the first active banner, preferring the global banner over + * the version-specific one. + * + * @param {string | undefined} remoteConfigUrl + * @param {number | null} versionMajor + * @returns {Promise} + */ +export const loadBanner = async (remoteConfigUrl, versionMajor) => { + if (!remoteConfigUrl) { + return null; + } + + const response = await fetch(remoteConfigUrl); + + /** @type {{ websiteBanners?: Record }} */ + const { websiteBanners = {} } = await response.json(); + + const sections = + versionMajor == null ? ['index'] : ['index', `v${versionMajor}`]; + + for (const section of sections) { + const banner = websiteBanners[section]; + + if (banner && isBannerActive(banner)) { + return { ...banner, section }; + } + } + + return null; +}; + +/** + * Checks whether the current text of a banner was dismissed. + * + * @param {ActiveBanner} banner + */ +export const isBannerDismissed = banner => + localStorage.getItem(getStorageKey(banner.section)) === banner.text; + +/** + * Persists a banner dismissal. + * + * @param {ActiveBanner} banner + */ +export const saveBannerDismissal = banner => + localStorage.setItem(getStorageKey(banner.section), banner.text); + +/** + * Loads, filters, and dismisses the announcement banner. + * + * @param {string | undefined} remoteConfigUrl + * @param {number | null} versionMajor + * @returns {[ActiveBanner | null, () => void]} + */ +export default (remoteConfigUrl, versionMajor) => { + const [banner, setBanner] = useState( + /** @type {ActiveBanner | null} */ (null) + ); + + useEffect(() => { + let mounted = true; + + loadBanner(remoteConfigUrl, versionMajor) + .then(loaded => { + if (mounted) { + setBanner(loaded && !isBannerDismissed(loaded) ? loaded : null); + } + }) + .catch(() => {}); + + return () => { + mounted = false; + }; + }, [remoteConfigUrl, versionMajor]); + + const dismissBanner = useCallback(() => { + setBanner(current => { + if (current) { + saveBannerDismissal(current); + } + + return null; + }); + }, []); + + return [banner, dismissBanner]; +}; diff --git a/src/generators/web/ui/utils/__tests__/banner.test.mjs b/src/generators/web/ui/utils/__tests__/banner.test.mjs deleted file mode 100644 index 262d2c9d..00000000 --- a/src/generators/web/ui/utils/__tests__/banner.test.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { isBannerActive } from '../banner.mjs'; - -const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday -const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow - -const banner = (overrides = {}) => ({ - text: 'Test banner', - ...overrides, -}); - -describe('isBannerActive', () => { - describe('no startDate, no endDate', () => { - it('is always active', () => { - assert.equal(isBannerActive(banner()), true); - }); - }); - - describe('startDate only', () => { - it('is active when startDate is in the past', () => { - assert.equal(isBannerActive(banner({ startDate: PAST })), true); - }); - - it('is not active when startDate is in the future', () => { - assert.equal(isBannerActive(banner({ startDate: FUTURE })), false); - }); - }); - - describe('endDate only', () => { - it('is active when endDate is in the future', () => { - assert.equal(isBannerActive(banner({ endDate: FUTURE })), true); - }); - - it('is not active when endDate is in the past', () => { - assert.equal(isBannerActive(banner({ endDate: PAST })), false); - }); - }); - - describe('startDate and endDate', () => { - it('is active when now is within the range', () => { - assert.equal( - isBannerActive(banner({ startDate: PAST, endDate: FUTURE })), - true - ); - }); - - it('is not active when now is before the range', () => { - assert.equal( - isBannerActive(banner({ startDate: FUTURE, endDate: FUTURE })), - false - ); - }); - - it('is not active when now is after the range', () => { - assert.equal( - isBannerActive(banner({ startDate: PAST, endDate: PAST })), - false - ); - }); - }); -}); diff --git a/src/generators/web/ui/utils/banner.mjs b/src/generators/web/ui/utils/banner.mjs deleted file mode 100644 index 4afa820a..00000000 --- a/src/generators/web/ui/utils/banner.mjs +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Checks whether a banner should be displayed based on its date range. - * Both `startDate` and `endDate` are optional; if omitted the banner is - * considered open-ended in that direction. - * - * @param {import('../components/AnnouncementBanner/types').BannerEntry} banner - * @returns {boolean} - */ -export const isBannerActive = ({ startDate, endDate }) => { - const now = Date.now(); - return ( - (!startDate || now >= new Date(startDate)) && - (!endDate || now <= new Date(endDate)) - ); -};