From 6f9766ff27151d917a22085b95f5e74748c72327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Sun, 19 Jul 2026 10:45:16 +0200 Subject: [PATCH 01/21] Hide unavailable landscape downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- .../src/layout/common/DownloadDropdown.tsx | 254 ++++++++++-------- ui/webapp/src/utils/getDownloadBlob.test.ts | 46 ++++ ui/webapp/src/utils/getDownloadBlob.ts | 36 +++ .../src/utils/isDownloadAvailable.test.ts | 29 ++ ui/webapp/src/utils/isDownloadAvailable.ts | 13 + 5 files changed, 266 insertions(+), 112 deletions(-) create mode 100644 ui/webapp/src/utils/getDownloadBlob.test.ts create mode 100644 ui/webapp/src/utils/getDownloadBlob.ts create mode 100644 ui/webapp/src/utils/isDownloadAvailable.test.ts create mode 100644 ui/webapp/src/utils/isDownloadAvailable.ts diff --git a/ui/webapp/src/layout/common/DownloadDropdown.tsx b/ui/webapp/src/layout/common/DownloadDropdown.tsx index efdff2bc..80accaad 100644 --- a/ui/webapp/src/layout/common/DownloadDropdown.tsx +++ b/ui/webapp/src/layout/common/DownloadDropdown.tsx @@ -1,8 +1,10 @@ import { SVGIcon, SVGIconKind, useOutsideClick } from 'common'; import isUndefined from 'lodash/isUndefined'; -import { createSignal, Show } from 'solid-js'; +import { createSignal, onCleanup, onMount, Show } from 'solid-js'; import { BANNER_ID } from '../../data'; +import getDownloadBlob from '../../utils/getDownloadBlob'; +import isDownloadAvailable from '../../utils/isDownloadAvailable'; import styles from './DownloadDropdown.module.css'; enum DocType { @@ -38,55 +40,73 @@ const DownloadDropdown = () => { const [ref, setRef] = createSignal(); const [visibleDropdown, setVisibleDropdown] = createSignal(false); const [downloadingFile, setDownloadingFile] = createSignal(); + const [availableScreenshotFormats, setAvailableScreenshotFormats] = createSignal>(new Set()); useOutsideClick([ref], [BANNER_ID], visibleDropdown, () => setVisibleDropdown(false)); - const downloadFile = (doc: DocType, format: Format) => { - async function getFile() { + const getDocumentUrl = (doc: DocType, format: Format) => + import.meta.env.MODE === 'development' ? `../../static/docs/${doc}.${format}` : `./docs/${doc}.${format}`; + + const downloadFile = async (doc: DocType, format: Format) => { + if (!isUndefined(downloadingFile())) { + return; + } + + setDownloadingFile({ doc, format }); + + try { + const blob = await getDownloadBlob({ + blobType: contentBlob[format], + contentType: contentType[format], + responseAsBlob: [Format.PDF, Format.PNG].includes(format), + url: getDocumentUrl(doc, format), + }); + const link: HTMLAnchorElement = document.createElement('a'); + const objectUrl = window.URL.createObjectURL(blob); + + // Use a temporary link to preserve the generated file name. try { - setDownloadingFile({ doc: doc, format: format }); - fetch( - import.meta.env.MODE === 'development' ? `../../static/docs/${doc}.${format}` : `./docs/${doc}.${format}`, - { - method: 'get', - headers: { - 'content-type': contentType[format], - }, - } - ) - .then(async (response) => { - if (response.ok) { - if ([Format.PDF, Format.PNG].includes(format)) { - return response.blob(); - } else { - const data = await response.text(); - const blob = new Blob([data], { - type: contentBlob[format], - }); - return blob; - } - } else { - throw Error; - } - }) - .then((blob) => { - const link: HTMLAnchorElement = document.createElement('a'); - link.download = `${doc}.${format}`; - link.href = window.URL.createObjectURL(blob); - link.style.display = 'none'; - document.body.appendChild(link); - link.click(); - setDownloadingFile(); - setVisibleDropdown(false); - }); - } catch { - // TODO - error downloading - setDownloadingFile(); - setVisibleDropdown(false); + link.download = `${doc}.${format}`; + link.href = objectUrl; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + } finally { + link.remove(); + window.URL.revokeObjectURL(objectUrl); } + } catch { + return; + } finally { + // Always clear the pending state, including when the file is missing. + setDownloadingFile(); + setVisibleDropdown(false); } - getFile(); }; + onMount(() => { + // Screenshot generation is optional, so only expose artifacts that exist. + const abortController = new AbortController(); + + void Promise.all( + [Format.PDF, Format.PNG].map(async (format) => ({ + available: await isDownloadAvailable(getDocumentUrl(DocType.Landscape, format), abortController.signal), + format, + })) + ) + .then((results) => { + setAvailableScreenshotFormats( + new Set(results.filter((result) => result.available).map((result) => result.format)) + ); + }) + .catch(() => { + if (!abortController.signal.aborted) { + setAvailableScreenshotFormats(new Set()); + } + }); + + onCleanup(() => abortController.abort()); + }); + return (
- -
  • - +
  • + + +
  • + -
  • + + +
  • Data files
  • -
  • - - -
  • - -
  • + + {(option) => ( + void downloadFile(option)} + option={option} + /> + )} +
  • Data files
  • -
  • - -
  • -
  • - -
  • + + {(option) => ( + void downloadFile(option)} + option={option} + /> + )} +
    @@ -285,3 +229,43 @@ const DownloadDropdown = () => { }; export default DownloadDropdown; + +// Reuse identical markup and pending behavior for every download option. +const DownloadMenuItem = (props: DownloadMenuItemProps) => ( +
  • + +
  • +); + +const getDocumentName = (option: Pick) => `${option.doc}.${option.format}`; + +const getDocumentUrl = (option: Pick) => + import.meta.env.MODE === 'development' + ? `../../static/docs/${getDocumentName(option)}` + : `./docs/${getDocumentName(option)}`; diff --git a/ui/webapp/src/utils/getDownloadBlob.ts b/ui/webapp/src/utils/getDownloadBlob.ts index 8c361b21..5b8e36b6 100644 --- a/ui/webapp/src/utils/getDownloadBlob.ts +++ b/ui/webapp/src/utils/getDownloadBlob.ts @@ -25,6 +25,7 @@ const getDownloadBlob = async ({ throw new Error(`Unable to download document: ${response.status}`); } + // Preserve binary responses while assigning the expected MIME type to text exports. if (responseAsBlob) { return response.blob(); } diff --git a/ui/webapp/src/utils/isDownloadAvailable.test.ts b/ui/webapp/src/utils/isDownloadAvailable.test.ts index 972a6b2f..aa6908b3 100644 --- a/ui/webapp/src/utils/isDownloadAvailable.test.ts +++ b/ui/webapp/src/utils/isDownloadAvailable.test.ts @@ -17,13 +17,20 @@ describe('isDownloadAvailable', () => { await expect(isDownloadAvailable('./docs/landscape.pdf')).resolves.toBe(true); expect(fetchMock).toHaveBeenCalledWith('./docs/landscape.pdf', { method: 'head', - signal: undefined, }); }); it('should report a missing document as unavailable', async () => { fetchMock.mockResolvedValue({ ok: false }); - await expect(isDownloadAvailable('./docs/landscape.pdf')).resolves.toBe(false); + await expect(isDownloadAvailable('./docs/landscape.png')).resolves.toBe(false); + }); + + it('should reuse the availability request for the same document', async () => { + fetchMock.mockResolvedValue({ ok: true }); + + await Promise.all([isDownloadAvailable('./docs/items.csv'), isDownloadAvailable('./docs/items.csv')]); + + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/ui/webapp/src/utils/isDownloadAvailable.ts b/ui/webapp/src/utils/isDownloadAvailable.ts index bde1eb0d..1df2b5f1 100644 --- a/ui/webapp/src/utils/isDownloadAvailable.ts +++ b/ui/webapp/src/utils/isDownloadAvailable.ts @@ -1,13 +1,25 @@ +// Share availability checks between the desktop and mobile dropdown instances. +const availabilityRequests = new Map>(); + /** - * Check whether a downloadable document exists without fetching its contents. + * Check and cache whether a downloadable document exists without fetching its contents. */ -const isDownloadAvailable = async (url: string, signal?: AbortSignal): Promise => { - const response = await fetch(url, { - method: 'head', - signal, - }); +const isDownloadAvailable = (url: string): Promise => { + const cachedRequest = availabilityRequests.get(url); + if (cachedRequest) { + return cachedRequest; + } + + const request = fetch(url, { method: 'head' }) + .then((response) => response.ok) + .catch((error: unknown) => { + // Allow a later check to retry after a transient request failure. + availabilityRequests.delete(url); + throw error; + }); + availabilityRequests.set(url, request); - return response.ok; + return request; }; export default isDownloadAvailable; From a752d5a8d54c85ea59c53658c54d517deb74cb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 20 Jul 2026 10:26:31 +0200 Subject: [PATCH 03/21] Truncate long mobile member badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- ui/common/src/components/ItemModalMobileContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/common/src/components/ItemModalMobileContent.tsx b/ui/common/src/components/ItemModalMobileContent.tsx index 2fa8cad9..36d9518c 100644 --- a/ui/common/src/components/ItemModalMobileContent.tsx +++ b/ui/common/src/components/ItemModalMobileContent.tsx @@ -216,7 +216,7 @@ export const ItemModalMobileContent = (props: Props) => {
    {itemInfo()!.member_subcategory} member
    From 44728ac46b8011eceb14868c3720614014bab739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 20 Jul 2026 10:29:57 +0200 Subject: [PATCH 04/21] Add common and embed-item packages to CI linting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a0c765c..b9ae912e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,24 @@ jobs: working-directory: ./ui/webapp run: yarn test + lint-common: + runs-on: + labels: ubuntu-latest-8-cores + env: + NODE_OPTIONS: --max_old_space_size=4096 + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Install dependencies + working-directory: ./ui/common + run: yarn install --network-concurrency 1 + - name: Run prettier + working-directory: ./ui/common + run: yarn format:diff + - name: Run eslint + working-directory: ./ui/common + run: yarn lint + lint-and-test-embed: runs-on: labels: ubuntu-latest-8-cores @@ -65,3 +83,21 @@ jobs: - name: Run eslint working-directory: ./ui/embed run: yarn lint + + lint-embed-item: + runs-on: + labels: ubuntu-latest-8-cores + env: + NODE_OPTIONS: --max_old_space_size=4096 + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Install dependencies + working-directory: ./ui/embed-item + run: yarn install --network-concurrency 1 + - name: Run prettier + working-directory: ./ui/embed-item + run: yarn format:diff + - name: Run eslint + working-directory: ./ui/embed-item + run: yarn lint From 77d30846d23b43443476519eeac8eefaa06b6b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 20 Jul 2026 10:39:57 +0200 Subject: [PATCH 05/21] Display tiny language percentages as less than 0.01% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- ui/common/src/components/LanguagesStats.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ui/common/src/components/LanguagesStats.tsx b/ui/common/src/components/LanguagesStats.tsx index e64dea14..548d415a 100644 --- a/ui/common/src/components/LanguagesStats.tsx +++ b/ui/common/src/components/LanguagesStats.tsx @@ -36,7 +36,7 @@ export const LanguagesStats = (props: Props) => { return ( { ); }; + +/** Formats positive values below two-decimal precision without displaying zero. */ +const formatPercentage = (value: number) => { + if (value > 0 && value.toFixed(2) === '0.00') { + return '<0.01%'; + } + + return `${Number.isInteger(value) ? value : value.toFixed(2)}%`; +}; From f7e998393417410d2fed979dbcdc156fff03a7f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 20 Jul 2026 12:50:37 +0200 Subject: [PATCH 06/21] Update footer copyright year to 2026 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- docs/config/settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config/settings.yml b/docs/config/settings.yml index 6529f17b..3eda1780 100644 --- a/docs/config/settings.yml +++ b/docs/config/settings.yml @@ -212,7 +212,7 @@ footer: youtube: "https://www.youtube.com/c/cloudnativefdn" logo: "https://raw.githubusercontent.com/cncf/artwork/master/other/cncf/horizontal/white/cncf-white.svg" text: | - Copyright © 2024 The Linux Foundation®. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our [Trademark Usage page](https://www.linuxfoundation.org/trademark-usage). Linux is a registered trademark of Linus Torvalds. [Privacy Policy](https://www.linuxfoundation.org/privacy) and [Terms of Use](https://www.linuxfoundation.org/terms). This website contains data received from Crunchbase. This data is not licensed pursuant to the Apache License. It is subject to Crunchbase's Data Access Terms, available at , and is only permitted to be used with Linux Foundation landscape projects. + Copyright © 2026 The Linux Foundation®. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our [Trademark Usage page](https://www.linuxfoundation.org/trademark-usage). Linux is a registered trademark of Linus Torvalds. [Privacy Policy](https://www.linuxfoundation.org/privacy) and [Terms of Use](https://www.linuxfoundation.org/terms). This website contains data received from Crunchbase. This data is not licensed pursuant to the Apache License. It is subject to Crunchbase's Data Access Terms, available at , and is only permitted to be used with Linux Foundation landscape projects. # Grid items size (optional) # From e5aa1e5765802fd0ae0b59f49b5a9c8bd552b518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 20 Jul 2026 13:12:33 +0200 Subject: [PATCH 07/21] Handle missing items in the item details modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- .../common/itemModal/ItemModal.module.css | 2 +- .../src/layout/common/itemModal/index.tsx | 48 ++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/ui/webapp/src/layout/common/itemModal/ItemModal.module.css b/ui/webapp/src/layout/common/itemModal/ItemModal.module.css index e8ad2204..83deb47a 100644 --- a/ui/webapp/src/layout/common/itemModal/ItemModal.module.css +++ b/ui/webapp/src/layout/common/itemModal/ItemModal.module.css @@ -1,4 +1,4 @@ -.loadingWrapper { +.stateWrapper { min-height: 500px; } diff --git a/ui/webapp/src/layout/common/itemModal/index.tsx b/ui/webapp/src/layout/common/itemModal/index.tsx index 6ae9cf07..483fad71 100644 --- a/ui/webapp/src/layout/common/itemModal/index.tsx +++ b/ui/webapp/src/layout/common/itemModal/index.tsx @@ -1,4 +1,4 @@ -import { ItemModalContent, ItemModalMobileContent, Loading, Modal, useBreakpointDetect } from 'common'; +import { ItemModalContent, ItemModalMobileContent, Loading, Modal, NoData, useBreakpointDetect } from 'common'; import isNull from 'lodash/isNull'; import isUndefined from 'lodash/isUndefined'; import { createEffect, createSignal, Show } from 'solid-js'; @@ -38,7 +38,7 @@ const ItemModal = () => { } else { setParentInfo(undefined); } - setItemInfo(itemTmp); + setItemInfo(itemTmp ?? null); } catch { setItemInfo(null); } @@ -63,33 +63,49 @@ const ItemModal = () => { id={ITEM_VIEW} > +
    } > + + <> +
    We couldn't find this item.
    +

    It may have been removed or the link may be incorrect.

    + +
    + + } + > + updateActiveItemId()} + hideOrganizationSection={HIDE_ORGANIZATION_SECTION_IN_PROJECTS} + /> + } + > + updateActiveItemId()} hideOrganizationSection={HIDE_ORGANIZATION_SECTION_IN_PROJECTS} /> - } - > - updateActiveItemId()} - hideOrganizationSection={HIDE_ORGANIZATION_SECTION_IN_PROJECTS} - /> +
    From 0739b5ee7be537c4bc684c6bb6712af6bd822f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 20 Jul 2026 13:37:39 +0200 Subject: [PATCH 08/21] Add development proxy for landscape data and assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- .gitignore | 1 + README.md | 27 ++++++++ ui/webapp/.env.local.example | 3 + ui/webapp/index.html | 39 +++++++---- .../src/layout/navigation/EmbedModal.tsx | 2 +- ui/webapp/vite.config.ts | 65 ++++++++++++++----- 6 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 ui/webapp/.env.local.example diff --git a/.gitignore b/.gitignore index 83104dce..69db4515 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ ui/embed/static/* ui/embed-item/dist ui/embed-item/node_modules ui/webapp/dist +ui/webapp/.env.local ui/webapp/node_modules ui/webapp/static/* yarn-debug.log* diff --git a/README.md b/README.md index a5fb49ee..e91a233e 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,33 @@ Commands: help Print this message or the help of the given subcommand(s) ``` +### UI development + +The SolidJS web application is located in `ui/webapp`. Install its dependencies +and start the Vite development server from the repository root: + +```text +yarn --cwd ui/webapp install +yarn --cwd ui/webapp dev +``` + +By default, the development server loads datasets and generated assets from +`ui/webapp/static`. To develop against data from an existing landscape, copy the +example environment file and configure the landscape URL: + +```text +cp ui/webapp/.env.local.example ui/webapp/.env.local +``` + +```text +VITE_PROXY_TARGET=https://landscape.cncf.io +``` + +Restart the development server after changing `.env.local`. The proxy handles +datasets, documents, images, and logos during development only; production +builds are not affected. Remove `VITE_PROXY_TARGET` to use the local generated +assets again. + ## Usage To see **landscape2** in action, we will go through the process of creating, building and serving a new landscape from scratch. The following instructions will assume that the `landscape2` binary is available in your PATH. Alternatively, you can launch a container from the [image provided](https://github.com/cncf/landscape2/pkgs/container/landscape2) where the **landscape2** CLI tool is ready to use. diff --git a/ui/webapp/.env.local.example b/ui/webapp/.env.local.example new file mode 100644 index 00000000..d0233e0b --- /dev/null +++ b/ui/webapp/.env.local.example @@ -0,0 +1,3 @@ +# Copy this file to .env.local and restart Vite after changing the target. +# Requests for development data and generated assets will use this landscape. +# VITE_PROXY_TARGET=https://landscape.cncf.io diff --git a/ui/webapp/index.html b/ui/webapp/index.html index 7ddd33ee..8529f33c 100644 --- a/ui/webapp/index.html +++ b/ui/webapp/index.html @@ -37,17 +37,7 @@ src="https://cmp.osano.com/{{ osano.customer_id }}/{{ osano.customer_configuration_id }}/osano.js"> {% endif %} <% } %> - <% if (isDev) { %> - - <% } else { %> + <% if (!isDev) { %> + <% } else { %> + <% } %> diff --git a/ui/webapp/src/layout/navigation/EmbedModal.tsx b/ui/webapp/src/layout/navigation/EmbedModal.tsx index 41a6e075..f7a83265 100644 --- a/ui/webapp/src/layout/navigation/EmbedModal.tsx +++ b/ui/webapp/src/layout/navigation/EmbedModal.tsx @@ -554,7 +554,7 @@ const EmbedModal = () => { createEffect( on(displayItemModal, () => { if (displayItemModal()) { - import(`${embedOrigin()}/embed-item.js`).then(() => { + import(/* @vite-ignore */ `${embedOrigin()}/embed-item.js`).then(() => { setEmbedScriptLoaded(true); }); } diff --git a/ui/webapp/vite.config.ts b/ui/webapp/vite.config.ts index 47ece53e..1a0f39ff 100644 --- a/ui/webapp/vite.config.ts +++ b/ui/webapp/vite.config.ts @@ -1,11 +1,11 @@ import fs from 'node:fs'; import isNull from 'lodash/isNull'; import isUndefined from 'lodash/isUndefined'; -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vite' +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, loadEnv } from 'vite'; import { ViteEjsPlugin } from 'vite-plugin-ejs'; -import solid from 'vite-plugin-solid' +import solid from 'vite-plugin-solid'; if (!process.env.SASS_SILENCE_DEPRECATIONS) { process.env.SASS_SILENCE_DEPRECATIONS = 'import,global-builtin,color-functions'; @@ -23,17 +23,14 @@ const bootstrapDir = [ ].find((candidate) => fs.existsSync(candidate)); const regex = //g; +const DEVELOPMENT_PROXY_PATHS = [ + '/static/data', + '/static/docs', + '/static/images', + '/static/logos', +]; -function getCSSHref(str: string) { - const match = regex.exec(str); - if (!isNull(match)) { - return match[1]; - } else { - return undefined; - } -} - -export default defineConfig({ +export default defineConfig(({ mode }) => ({ base: '', // Default '/', static path build: { rollupOptions: { @@ -67,8 +64,9 @@ export default defineConfig({ }, }], preview: { - port: 8000 + port: 8000, }, + server: getDevelopmentServerConfig(mode), resolve: { dedupe: [ "lodash", @@ -85,4 +83,39 @@ export default defineConfig({ }, }, }, -}) +})); + +/** Creates an opt-in dev proxy for datasets and their generated assets. */ +const getDevelopmentServerConfig = (mode: string) => { + const proxyTarget = loadEnv(mode, process.cwd(), '').VITE_PROXY_TARGET; + if (!proxyTarget) { + return undefined; + } + + const targetUrl = new URL(proxyTarget); + const targetBasePath = targetUrl.pathname.replace(/\/+$/, ''); + const rewritePath = (requestPath: string) => + `${targetBasePath}${requestPath.replace(/^\/static/, '')}`; + + return { + proxy: Object.fromEntries( + DEVELOPMENT_PROXY_PATHS.map((path) => [ + path, + { + target: targetUrl.origin, + changeOrigin: true, + rewrite: rewritePath, + }, + ]) + ), + }; +}; + +const getCSSHref = (str: string) => { + const match = regex.exec(str); + if (!isNull(match)) { + return match[1]; + } else { + return undefined; + } +}; From d33da5d5d05a5e049a2a399bd2571b7ff4dbe170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 20 Jul 2026 16:33:52 +0200 Subject: [PATCH 09/21] Improve download error handling and retry states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- .../src/layout/common/DownloadDropdown.tsx | 143 ++++++++++++------ .../src/layout/common/itemModal/index.tsx | 4 +- ui/webapp/src/utils/getDownloadBlob.test.ts | 26 ++++ .../src/utils/isDownloadAvailable.test.ts | 22 ++- ui/webapp/src/utils/isDownloadAvailable.ts | 11 +- ui/webapp/vite.config.ts | 89 +++++------ 6 files changed, 199 insertions(+), 96 deletions(-) diff --git a/ui/webapp/src/layout/common/DownloadDropdown.tsx b/ui/webapp/src/layout/common/DownloadDropdown.tsx index 26bbc960..b91ceca1 100644 --- a/ui/webapp/src/layout/common/DownloadDropdown.tsx +++ b/ui/webapp/src/layout/common/DownloadDropdown.tsx @@ -1,5 +1,5 @@ import { SVGIcon, SVGIconKind, useOutsideClick } from 'common'; -import { createSignal, For, onCleanup, onMount, Show } from 'solid-js'; +import { createSignal, createUniqueId, For, onCleanup, onMount, Show } from 'solid-js'; import { BANNER_ID } from '../../data'; import getDownloadBlob from '../../utils/getDownloadBlob'; @@ -18,6 +18,12 @@ enum Format { PNG = 'png', } +enum ScreenshotAvailabilityStatus { + Checking = 'checking', + Error = 'error', + Ready = 'ready', +} + interface DownloadMenuItemProps { disabled: boolean; downloading: boolean; @@ -91,19 +97,48 @@ const LANDSCAPE_DOWNLOAD_OPTIONS: DownloadOption[] = [ const SCREENSHOT_FORMATS = [Format.PDF, Format.PNG]; const DownloadDropdown = () => { + const dropdownId = createUniqueId(); + const [availableScreenshotFormats, setAvailableScreenshotFormats] = createSignal>(new Set()); + const [downloadError, setDownloadError] = createSignal(); + const [downloadingFile, setDownloadingFile] = createSignal(); const [ref, setRef] = createSignal(); + const [screenshotAvailabilityStatus, setScreenshotAvailabilityStatus] = createSignal( + ScreenshotAvailabilityStatus.Checking + ); const [visibleDropdown, setVisibleDropdown] = createSignal(false); - const [downloadingFile, setDownloadingFile] = createSignal(); - const [availableScreenshotFormats, setAvailableScreenshotFormats] = createSignal>(new Set()); + let componentMounted = false; useOutsideClick([ref], [BANNER_ID], visibleDropdown, () => setVisibleDropdown(false)); // Keep each successfully generated screenshot format available independently. const availableLandscapeDownloads = () => LANDSCAPE_DOWNLOAD_OPTIONS.filter((option) => availableScreenshotFormats().has(option.format)); - const isDownloading = (option: DownloadOption) => { - const currentDownload = downloadingFile(); - return currentDownload?.doc === option.doc && currentDownload.format === option.format; + const checkScreenshotAvailability = async () => { + setScreenshotAvailabilityStatus(ScreenshotAvailabilityStatus.Checking); + const results = await Promise.all( + SCREENSHOT_FORMATS.map(async (format) => { + try { + return { + available: await isDownloadAvailable(getDocumentUrl({ doc: DocType.Landscape, format })), + failed: false, + format, + }; + } catch { + return { available: false, failed: true, format }; + } + }) + ); + + if (componentMounted) { + setAvailableScreenshotFormats( + new Set(results.filter((result) => result.available).map((result) => result.format)) + ); + setScreenshotAvailabilityStatus( + results.some((result) => result.failed) + ? ScreenshotAvailabilityStatus.Error + : ScreenshotAvailabilityStatus.Ready + ); + } }; const downloadFile = async (option: DownloadOption) => { @@ -111,6 +146,7 @@ const DownloadDropdown = () => { return; } + setDownloadError(); setDownloadingFile(option); try { @@ -131,41 +167,28 @@ const DownloadDropdown = () => { link.style.display = 'none'; document.body.appendChild(link); link.click(); + setVisibleDropdown(false); } finally { link.remove(); window.URL.revokeObjectURL(objectUrl); } } catch { - return; + setDownloadError('Unable to download the file. Please try again.'); } finally { // Always clear the pending state, including when the file is missing. setDownloadingFile(); - setVisibleDropdown(false); } }; + const isDownloading = (option: DownloadOption) => { + const currentDownload = downloadingFile(); + return currentDownload?.doc === option.doc && currentDownload.format === option.format; + }; + onMount(() => { // Screenshot generation is optional, so only expose artifacts that exist. - let componentMounted = true; - - void Promise.all( - SCREENSHOT_FORMATS.map(async (format) => ({ - available: await isDownloadAvailable(getDocumentUrl({ doc: DocType.Landscape, format })), - format, - })) - ) - .then((results) => { - if (componentMounted) { - setAvailableScreenshotFormats( - new Set(results.filter((result) => result.available).map((result) => result.format)) - ); - } - }) - .catch(() => { - if (componentMounted) { - setAvailableScreenshotFormats(new Set()); - } - }); + componentMounted = true; + void checkScreenshotAvailability(); onCleanup(() => { componentMounted = false; @@ -180,34 +203,61 @@ const DownloadDropdown = () => { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - setVisibleDropdown(!visibleDropdown()); + const nextVisibleDropdown = !visibleDropdown(); + if (nextVisibleDropdown) { + setDownloadError(); + } + setVisibleDropdown(nextVisibleDropdown); }} - aria-label="Open dropdown" + aria-controls={dropdownId} + aria-expanded={visibleDropdown()} + aria-label="Download files" > diff --git a/ui/webapp/src/layout/common/itemModal/index.tsx b/ui/webapp/src/layout/common/itemModal/index.tsx index 483fad71..37da29a2 100644 --- a/ui/webapp/src/layout/common/itemModal/index.tsx +++ b/ui/webapp/src/layout/common/itemModal/index.tsx @@ -73,9 +73,7 @@ const ItemModal = () => { +
    <>
    We couldn't find this item.
    diff --git a/ui/webapp/src/utils/getDownloadBlob.test.ts b/ui/webapp/src/utils/getDownloadBlob.test.ts index 5ce30f2d..ed152497 100644 --- a/ui/webapp/src/utils/getDownloadBlob.test.ts +++ b/ui/webapp/src/utils/getDownloadBlob.test.ts @@ -28,6 +28,32 @@ describe('getDownloadBlob', () => { expect(result).toBe(expectedBlob); }); + it('should convert a text response to a blob with the requested type', async () => { + const textMock = jest.fn().mockResolvedValue('name,category\nProject,Database'); + fetchMock.mockResolvedValue({ + ok: true, + text: textMock, + }); + + const result = await getDownloadBlob({ + blobType: 'text/csv', + contentType: 'text/csv;charset=UTF-8', + responseAsBlob: false, + url: './docs/items.csv', + }); + + const reader = new FileReader(); + const resultText = new Promise((resolve, reject) => { + reader.addEventListener('load', () => resolve(reader.result as string), { once: true }); + reader.addEventListener('error', () => reject(reader.error), { once: true }); + }); + reader.readAsText(result); + + expect(textMock).toHaveBeenCalledTimes(1); + expect(result.type).toBe('text/csv'); + await expect(resultText).resolves.toBe('name,category\nProject,Database'); + }); + it('should reject when the document does not exist', async () => { fetchMock.mockResolvedValue({ ok: false, diff --git a/ui/webapp/src/utils/isDownloadAvailable.test.ts b/ui/webapp/src/utils/isDownloadAvailable.test.ts index aa6908b3..aea46c64 100644 --- a/ui/webapp/src/utils/isDownloadAvailable.test.ts +++ b/ui/webapp/src/utils/isDownloadAvailable.test.ts @@ -21,11 +21,22 @@ describe('isDownloadAvailable', () => { }); it('should report a missing document as unavailable', async () => { - fetchMock.mockResolvedValue({ ok: false }); + fetchMock.mockResolvedValue({ ok: false, status: 404 }); await expect(isDownloadAvailable('./docs/landscape.png')).resolves.toBe(false); }); + it('should allow an unexpected HTTP failure to be retried', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }).mockResolvedValueOnce({ ok: true }); + + await expect(isDownloadAvailable('./docs/retry-http.pdf')).rejects.toThrow( + 'Unable to check document availability: 503' + ); + await expect(isDownloadAvailable('./docs/retry-http.pdf')).resolves.toBe(true); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('should reuse the availability request for the same document', async () => { fetchMock.mockResolvedValue({ ok: true }); @@ -33,4 +44,13 @@ describe('isDownloadAvailable', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it('should allow a failed availability request to be retried', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('Network error')).mockResolvedValueOnce({ ok: true }); + + await expect(isDownloadAvailable('./docs/retry.pdf')).rejects.toThrow('Network error'); + await expect(isDownloadAvailable('./docs/retry.pdf')).resolves.toBe(true); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/ui/webapp/src/utils/isDownloadAvailable.ts b/ui/webapp/src/utils/isDownloadAvailable.ts index 1df2b5f1..7f6e3e3d 100644 --- a/ui/webapp/src/utils/isDownloadAvailable.ts +++ b/ui/webapp/src/utils/isDownloadAvailable.ts @@ -1,5 +1,6 @@ // Share availability checks between the desktop and mobile dropdown instances. const availabilityRequests = new Map>(); +const MISSING_STATUS_CODES = new Set([404, 410]); /** * Check and cache whether a downloadable document exists without fetching its contents. @@ -11,7 +12,15 @@ const isDownloadAvailable = (url: string): Promise => { } const request = fetch(url, { method: 'head' }) - .then((response) => response.ok) + .then((response) => { + if (response.ok) { + return true; + } + if (MISSING_STATUS_CODES.has(response.status)) { + return false; + } + throw new Error(`Unable to check document availability: ${response.status}`); + }) .catch((error: unknown) => { // Allow a later check to retry after a transient request failure. availabilityRequests.delete(url); diff --git a/ui/webapp/vite.config.ts b/ui/webapp/vite.config.ts index 1a0f39ff..bf071311 100644 --- a/ui/webapp/vite.config.ts +++ b/ui/webapp/vite.config.ts @@ -1,8 +1,9 @@ import fs from 'node:fs'; -import isNull from 'lodash/isNull'; -import isUndefined from 'lodash/isUndefined'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + +import isNull from 'lodash/isNull'; +import isUndefined from 'lodash/isUndefined'; import { defineConfig, loadEnv } from 'vite'; import { ViteEjsPlugin } from 'vite-plugin-ejs'; import solid from 'vite-plugin-solid'; @@ -12,23 +13,16 @@ if (!process.env.SASS_SILENCE_DEPRECATIONS) { } const projectDir = fileURLToPath(new URL('.', import.meta.url)); -const scssIncludePaths = [ - path.resolve(projectDir, 'node_modules'), - path.resolve(projectDir, '../../node_modules'), -].filter((p) => fs.existsSync(p)); - const bootstrapDir = [ path.resolve(projectDir, 'node_modules/bootstrap'), path.resolve(projectDir, '../../node_modules/bootstrap'), ].find((candidate) => fs.existsSync(candidate)); - -const regex = //g; -const DEVELOPMENT_PROXY_PATHS = [ - '/static/data', - '/static/docs', - '/static/images', - '/static/logos', -]; +const CSS_HREF_PATTERN = //; +const DEVELOPMENT_PROXY_PATHS = ['/static/data', '/static/docs', '/static/images', '/static/logos']; +const scssIncludePaths = [ + path.resolve(projectDir, 'node_modules'), + path.resolve(projectDir, '../../node_modules'), +].filter((candidate) => fs.existsSync(candidate)); export default defineConfig(({ mode }) => ({ base: '', // Default '/', static path @@ -46,34 +40,33 @@ export default defineConfig(({ mode }) => ({ }, }, }, - plugins: [ViteEjsPlugin(), solid(), { - name: 'inject-css-preload', - transformIndexHtml(html) { - const assetHref = getCSSHref(html); - if (!isUndefined(assetHref)) { - return [ - { - tag: 'link', - attrs: { rel: 'preload', href: assetHref, as: 'style', crossorigin: true }, - injectTo: 'head-prepend', - }, - ] - } else { - return html; - } + plugins: [ + ViteEjsPlugin(), + solid(), + { + name: 'inject-css-preload', + transformIndexHtml(html) { + const assetHref = getCSSHref(html); + if (!isUndefined(assetHref)) { + return [ + { + tag: 'link', + attrs: { rel: 'preload', href: assetHref, as: 'style', crossorigin: true }, + injectTo: 'head-prepend', + }, + ]; + } else { + return html; + } + }, }, - }], + ], preview: { port: 8000, }, server: getDevelopmentServerConfig(mode), resolve: { - dedupe: [ - "lodash", - "moment", - "solid-js", - "solid-js/web" - ], + dedupe: ['lodash', 'moment', 'solid-js', 'solid-js/web'], ...(bootstrapDir ? { alias: { bootstrap: bootstrapDir } } : {}), }, css: { @@ -85,6 +78,16 @@ export default defineConfig(({ mode }) => ({ }, })); +/** Extracts the generated stylesheet URL from rendered HTML. */ +const getCSSHref = (html: string) => { + const match = CSS_HREF_PATTERN.exec(html); + if (!isNull(match)) { + return match[1]; + } else { + return undefined; + } +}; + /** Creates an opt-in dev proxy for datasets and their generated assets. */ const getDevelopmentServerConfig = (mode: string) => { const proxyTarget = loadEnv(mode, process.cwd(), '').VITE_PROXY_TARGET; @@ -94,8 +97,7 @@ const getDevelopmentServerConfig = (mode: string) => { const targetUrl = new URL(proxyTarget); const targetBasePath = targetUrl.pathname.replace(/\/+$/, ''); - const rewritePath = (requestPath: string) => - `${targetBasePath}${requestPath.replace(/^\/static/, '')}`; + const rewritePath = (requestPath: string) => `${targetBasePath}${requestPath.replace(/^\/static/, '')}`; return { proxy: Object.fromEntries( @@ -110,12 +112,3 @@ const getDevelopmentServerConfig = (mode: string) => { ), }; }; - -const getCSSHref = (str: string) => { - const match = regex.exec(str); - if (!isNull(match)) { - return match[1]; - } else { - return undefined; - } -}; From dba2c23a7fbd0671d37a9ebcaff74180016fc932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 20 Jul 2026 16:38:41 +0200 Subject: [PATCH 10/21] Fix modal keyboard and focus accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- ui/common/src/components/Modal.tsx | 15 ++-- ui/common/src/hooks/index.ts | 1 + ui/common/src/hooks/useModalFocus.ts | 69 +++++++++++++++++++ ui/common/src/index.tsx | 2 +- ui/embed-item/src/common/ItemModal.tsx | 59 ++++++++++------ .../src/layout/common/FullScreenModal.tsx | 30 +++----- 6 files changed, 125 insertions(+), 51 deletions(-) create mode 100644 ui/common/src/hooks/useModalFocus.ts diff --git a/ui/common/src/components/Modal.tsx b/ui/common/src/components/Modal.tsx index f5522e13..ab8c7ad7 100644 --- a/ui/common/src/components/Modal.tsx +++ b/ui/common/src/components/Modal.tsx @@ -3,7 +3,7 @@ import { createEffect, createSignal, JSXElement, Show } from 'solid-js'; import { css, keyframes } from 'solid-styled-components'; import { BANNER_ID } from '../data/data'; -import { useBodyScroll, useOutsideClick } from '../hooks'; +import { useBodyScroll, useModalFocus, useOutsideClick } from '../hooks'; import { SVGIconKind } from '../types/types'; import { SVGIcon } from './SVGIcon'; @@ -117,19 +117,18 @@ const Btn = css` export const Modal = (props: Props) => { const [openStatus, setOpenStatus] = createSignal(false); + const [modalRef, setModalRef] = createSignal(); const [ref, setRef] = createSignal(); - useOutsideClick([ref], [BANNER_ID], openStatus, () => { - closeModal(); - }); - - useBodyScroll(openStatus, 'modal'); - const closeModal = () => { setOpenStatus(false); props.onClose(); }; + useBodyScroll(openStatus, 'modal'); + useModalFocus(modalRef, openStatus, closeModal); + useOutsideClick([ref], [BANNER_ID], openStatus, closeModal); + createEffect(() => { setOpenStatus(props.open); }); @@ -141,12 +140,12 @@ export const Modal = (props: Props) => { ); - -const getDocumentName = (option: Pick) => `${option.doc}.${option.format}`; - -const getDocumentUrl = (option: Pick) => - import.meta.env.MODE === 'development' - ? `../../static/docs/${getDocumentName(option)}` - : `./docs/${getDocumentName(option)}`; diff --git a/ui/webapp/src/layout/common/__tests__/DownloadDropdown.test.tsx b/ui/webapp/src/layout/common/__tests__/DownloadDropdown.test.tsx new file mode 100644 index 00000000..3f057844 --- /dev/null +++ b/ui/webapp/src/layout/common/__tests__/DownloadDropdown.test.tsx @@ -0,0 +1,115 @@ +import { render } from 'solid-js/web'; + +import getDownloadBlob from '../../../utils/getDownloadBlob'; +import isDownloadAvailable from '../../../utils/isDownloadAvailable'; +import DownloadDropdown from '../DownloadDropdown'; + +jest.mock('../../../utils/getDownloadBlob'); +jest.mock('../../../utils/isDownloadAvailable'); +jest.mock('../../../data', () => ({ BANNER_ID: 'banner' })); +jest.mock('../../../utils/downloadDocument', () => ({ + getDownloadDocumentName: ({ doc, format }: { doc: string; format: string }) => `${doc}.${format}`, + getDownloadDocumentUrl: ({ doc, format }: { doc: string; format: string }) => `./docs/${doc}.${format}`, +})); + +const getDownloadBlobMock = getDownloadBlob as jest.MockedFunction; +const isDownloadAvailableMock = isDownloadAvailable as jest.MockedFunction; + +describe('DownloadDropdown', () => { + const originalCreateObjectURL = window.URL.createObjectURL; + const originalRevokeObjectURL = window.URL.revokeObjectURL; + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + dispose = undefined; + getDownloadBlobMock.mockReset(); + isDownloadAvailableMock.mockReset(); + }); + + afterEach(() => { + dispose?.(); + window.URL.createObjectURL = originalCreateObjectURL; + window.URL.revokeObjectURL = originalRevokeObjectURL; + document.body.replaceChildren(); + jest.restoreAllMocks(); + }); + + it('should show available formats and recover from a failed availability check', async () => { + isDownloadAvailableMock.mockRejectedValueOnce(new TypeError('Network error')).mockResolvedValueOnce(false); + dispose = render(() => , container); + + await flushPromises(); + expect(container.textContent).toContain('Unable to check every landscape download.'); + expect(getButton(container, 'Download landscape in PDF format')).toBeNull(); + + isDownloadAvailableMock.mockResolvedValue(true); + getButtonByText(container, 'Try again')!.click(); + await flushPromises(); + + expect(getButton(container, 'Download landscape in PDF format')).not.toBeNull(); + expect(getButton(container, 'Download landscape in PNG format')).not.toBeNull(); + }); + + it('should disable duplicate downloads and clean up a generated link', async () => { + const blob = new Blob(['items'], { type: 'text/csv' }); + const download = createDeferredPromise(); + const anchorClick = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(); + + isDownloadAvailableMock.mockResolvedValue(false); + getDownloadBlobMock.mockReturnValue(download.promise); + window.URL.createObjectURL = jest.fn(() => 'blob:items'); + window.URL.revokeObjectURL = jest.fn(); + dispose = render(() => , container); + await flushPromises(); + + getButton(container, 'Download files')!.click(); + const itemsButton = getButton(container, 'Download items in CSV format')!; + const projectsButton = getButton(container, 'Download projects in CSV format')!; + itemsButton.click(); + projectsButton.click(); + + expect(getDownloadBlobMock).toHaveBeenCalledTimes(1); + expect(projectsButton.disabled).toBe(true); + + download.resolve(blob); + await flushPromises(); + + expect(anchorClick).toHaveBeenCalledTimes(1); + expect(window.URL.createObjectURL).toHaveBeenCalledWith(blob); + expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:items'); + expect(document.querySelector('a[download="items.csv"]')).toBeNull(); + expect(projectsButton.disabled).toBe(false); + }); + + it('should show a recoverable download error', async () => { + isDownloadAvailableMock.mockResolvedValue(false); + getDownloadBlobMock.mockRejectedValue(new TypeError('Network error')); + dispose = render(() => , container); + await flushPromises(); + + getButton(container, 'Download items in CSV format')!.click(); + await flushPromises(); + + expect(container.textContent).toContain('Unable to download the file. Please try again.'); + expect(getButton(container, 'Download items in CSV format')!.disabled).toBe(false); + }); +}); + +const createDeferredPromise = () => { + let resolvePromise!: (value: Value) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +}; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const getButton = (container: HTMLElement, label: string) => + container.querySelector(`button[aria-label="${label}"]`); + +const getButtonByText = (container: HTMLElement, text: string) => + Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.trim() === text); diff --git a/ui/webapp/src/layout/common/itemModal/__tests__/ItemModal.test.tsx b/ui/webapp/src/layout/common/itemModal/__tests__/ItemModal.test.tsx new file mode 100644 index 00000000..f442792a --- /dev/null +++ b/ui/webapp/src/layout/common/itemModal/__tests__/ItemModal.test.tsx @@ -0,0 +1,105 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; + +import type { Item } from '../../../../types'; +import itemsDataGetter from '../../../../utils/itemsDataGetter'; +import ItemModal from '..'; + +const mockUpdateActiveItemId = jest.fn(); +const [mockVisibleItemId, mockSetVisibleItemId] = createSignal(); + +jest.mock('../../../../data', () => ({ + BASE_PATH: '', + FOUNDATION: 'Foundation', + HIDE_ORGANIZATION_SECTION_IN_PROJECTS: false, + ITEM_VIEW: 'item-view', + SMALL_DEVICES_BREAKPOINTS: [], +})); +jest.mock('../../../../utils/itemsDataGetter', () => ({ + __esModule: true, + default: { + getItemById: jest.fn(), + getItemByName: jest.fn(), + }, +})); +jest.mock('../../../stores/activeItem', () => ({ + useActiveItemId: () => mockVisibleItemId, + useUpdateActiveItemId: () => mockUpdateActiveItemId, +})); +jest.mock('../../../stores/fullData', () => ({ + useFullDataReady: () => () => true, +})); +jest.mock('../../../stores/upcomingEventData', () => ({ + useEventVisibleContent: () => () => undefined, +})); + +const getItemByIdMock = itemsDataGetter.getItemById as unknown as jest.MockedFunction< + (itemId: string) => Promise +>; + +describe('ItemModal', () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + dispose = undefined; + getItemByIdMock.mockReset(); + mockSetVisibleItemId(undefined); + mockUpdateActiveItemId.mockReset(); + }); + + afterEach(() => { + dispose?.(); + document.body.replaceChildren(); + }); + + it('should distinguish loading, missing, and subsequent valid items', async () => { + const missingItem = createDeferredPromise(); + getItemByIdMock.mockReturnValueOnce(missingItem.promise).mockResolvedValueOnce({ id: 'valid' } as Item); + mockSetVisibleItemId('missing'); + dispose = render(() => , container); + + expect(container.textContent).toContain('Loading...'); + + missingItem.resolve(undefined); + await flushPromises(); + expect(container.textContent).toContain("We couldn't find this item."); + + mockSetVisibleItemId('valid'); + expect(container.textContent).toContain('Loading...'); + await flushPromises(); + + expect(container.textContent).toContain('Item content'); + expect(container.textContent).not.toContain("We couldn't find this item."); + }); + + it('should ignore an older item request after the selection changes', async () => { + const firstItem = createDeferredPromise(); + const secondItem = createDeferredPromise(); + getItemByIdMock.mockReturnValueOnce(firstItem.promise).mockReturnValueOnce(secondItem.promise); + mockSetVisibleItemId('first'); + dispose = render(() => , container); + + mockSetVisibleItemId('second'); + secondItem.resolve({ id: 'second' } as Item); + await flushPromises(); + expect(container.textContent).toContain('Item content'); + + firstItem.resolve(undefined); + await flushPromises(); + expect(container.textContent).toContain('Item content'); + expect(container.textContent).not.toContain("We couldn't find this item."); + }); +}); + +const createDeferredPromise = () => { + let resolvePromise!: (value: Value) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +}; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/ui/webapp/src/layout/common/itemModal/index.tsx b/ui/webapp/src/layout/common/itemModal/index.tsx index 37da29a2..f26c1c0a 100644 --- a/ui/webapp/src/layout/common/itemModal/index.tsx +++ b/ui/webapp/src/layout/common/itemModal/index.tsx @@ -1,7 +1,7 @@ import { ItemModalContent, ItemModalMobileContent, Loading, Modal, NoData, useBreakpointDetect } from 'common'; import isNull from 'lodash/isNull'; import isUndefined from 'lodash/isUndefined'; -import { createEffect, createSignal, Show } from 'solid-js'; +import { batch, createEffect, createSignal, Show } from 'solid-js'; import { BASE_PATH, @@ -25,29 +25,42 @@ const ItemModal = () => { const [itemInfo, setItemInfo] = createSignal(undefined); const [parentInfo, setParentInfo] = createSignal(); const { point } = useBreakpointDetect(); + let activeRequestId = 0; - createEffect(() => { - async function fetchItemInfo() { - try { - const itemTmp = await itemsDataGetter.getItemById(visibleItemId()! as string); - if (!isUndefined(itemTmp) && !isUndefined(itemTmp!.parent_project)) { - const parentItem = await itemsDataGetter.getItemByName(itemTmp.parent_project); - if (!isUndefined(parentItem)) { - setParentInfo(parentItem); - } - } else { - setParentInfo(undefined); - } - setItemInfo(itemTmp ?? null); - } catch { + const fetchItemInfo = async (itemId: string, requestId: number) => { + try { + const item = await itemsDataGetter.getItemById(itemId); + const parentItem = !isUndefined(item?.parent_project) + ? await itemsDataGetter.getItemByName(item.parent_project) + : undefined; + if (requestId !== activeRequestId) return; + + batch(() => { + setItemInfo(item ?? null); + setParentInfo(parentItem); + }); + } catch { + if (requestId === activeRequestId) { setItemInfo(null); } } + }; + + createEffect(() => { + const itemId = visibleItemId(); + const requestId = ++activeRequestId; - if (visibleItemId() && fullDataReady()) { - fetchItemInfo(); + if (itemId && fullDataReady()) { + batch(() => { + setItemInfo(undefined); + setParentInfo(undefined); + }); + void fetchItemInfo(itemId, requestId); } else { - setItemInfo(undefined); + batch(() => { + setItemInfo(undefined); + setParentInfo(undefined); + }); } }); diff --git a/ui/webapp/src/layout/guide/__tests__/Guide.test.tsx b/ui/webapp/src/layout/guide/__tests__/Guide.test.tsx new file mode 100644 index 00000000..c7d57f52 --- /dev/null +++ b/ui/webapp/src/layout/guide/__tests__/Guide.test.tsx @@ -0,0 +1,100 @@ +import { render } from 'solid-js/web'; + +import Guide from '..'; + +const mockNavigate = jest.fn(); +const mockSetGuideContent = jest.fn(); +const mockSetGuideToc = jest.fn(); + +jest.mock('@solidjs/router', () => ({ + useLocation: () => ({ hash: '', search: '', state: {} }), + useNavigate: () => mockNavigate, +})); +jest.mock('../../../data', () => ({ + GUIDE_PATH: '/guide', + SMALL_DEVICES_BREAKPOINTS: [], +})); +jest.mock('../../../utils/goToElement', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../../../utils/guideData', () => ({ getGuideUrl: () => './data/guide.json' })); +jest.mock('../../../utils/isElementInView', () => ({ __esModule: true, default: () => true })); +jest.mock('../../../utils/normalizeId', () => ({ + __esModule: true, + default: ({ subtitle, title }: { subtitle?: string; title: string }) => + [title, subtitle].filter(Boolean).join('-').toLowerCase(), +})); +jest.mock('../../../utils/scrollToTop', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../../common/ButtonToTopScroll', () => ({ __esModule: true, default: () => createElement('Top') })); +jest.mock('../../common/Sidebar', () => ({ Sidebar: (props: { children: Node }) => <>{props.children} })); +jest.mock('../../navigation/Footer', () => ({ __esModule: true, default: () => createElement('Footer') })); +jest.mock('../../stores/guideFile', () => ({ + useGuideFileContent: () => () => undefined, + useGuideTOC: () => () => [], + useSetGuideFileContent: () => mockSetGuideContent, + useSetGuideTOC: () => mockSetGuideToc, +})); +jest.mock('../../stores/mobileTOC', () => ({ + useMobileTOCStatus: () => () => false, + useSetMobileTOCStatus: () => jest.fn(), +})); +jest.mock('../SubcategoryExtended', () => ({ __esModule: true, default: () => createElement('Extended') })); +jest.mock('../ToC', () => ({ __esModule: true, default: () => createElement('Contents') })); + +describe('Guide', () => { + const originalFetch = global.fetch; + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + let fetchMock: jest.MockedFunction; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + dispose = undefined; + fetchMock = jest.fn(); + global.fetch = fetchMock; + window.guide = undefined; + mockNavigate.mockReset(); + mockSetGuideContent.mockReset(); + mockSetGuideToc.mockReset(); + }); + + afterEach(() => { + dispose?.(); + global.fetch = originalFetch; + document.body.replaceChildren(); + }); + + it('should recover from a failed guide request', async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 503 } as Response).mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ + categories: [{ category: 'Overview', content: '

    Guide content

    ', subcategories: [] }], + }), + ok: true, + } as unknown as Response); + dispose = render(() => , container); + + await flushPromises(); + expect(container.textContent).toContain("We couldn't load the guide."); + expect(container.textContent).not.toContain('Footer'); + + getButtonByText(container, 'Try again')!.click(); + await flushPromises(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('Overview'); + expect(container.textContent).toContain('Guide content'); + expect(container.textContent).toContain('Footer'); + expect(mockSetGuideContent).toHaveBeenCalledTimes(1); + expect(mockSetGuideToc).toHaveBeenCalledTimes(1); + }); +}); + +const createElement = (text: string) => { + const element = document.createElement('div'); + element.textContent = text; + return element; +}; + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const getButtonByText = (container: HTMLElement, text: string) => + Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.trim() === text); diff --git a/ui/webapp/src/layout/guide/index.tsx b/ui/webapp/src/layout/guide/index.tsx index 4ef3188e..053b0669 100644 --- a/ui/webapp/src/layout/guide/index.tsx +++ b/ui/webapp/src/layout/guide/index.tsx @@ -6,6 +6,7 @@ import { createEffect, createMemo, createSignal, For, Match, on, onMount, Show, import { GUIDE_PATH, SMALL_DEVICES_BREAKPOINTS } from '../../data'; import { CategoryGuide, Guide, StateContent, SubcategoryGuide, ToCTitle } from '../../types'; import goToElement from '../../utils/goToElement'; +import { getGuideUrl } from '../../utils/guideData'; import isElementInView from '../../utils/isElementInView'; import buildNormalizedId from '../../utils/normalizeId'; import scrollToTop from '../../utils/scrollToTop'; @@ -74,9 +75,7 @@ const GuideIndex = () => { try { let data = window.guide; if (isUndefined(data)) { - const response = await fetch( - import.meta.env.MODE === 'development' ? '../../static/data/guide.json' : './data/guide.json' - ); + const response = await fetch(getGuideUrl()); if (!response.ok) { throw new Error(`Unable to load guide: ${response.status}`); } diff --git a/ui/webapp/src/layout/index.tsx b/ui/webapp/src/layout/index.tsx index f06bb676..0e8d7525 100644 --- a/ui/webapp/src/layout/index.tsx +++ b/ui/webapp/src/layout/index.tsx @@ -24,31 +24,6 @@ interface Props { children?: JSXElement; } -const FullDataContent = (props: Props) => { - const fullDataStatus = useFullDataStatus(); - const retryFullData = useRetryFullData(); - - return ( - - -
    -
    We couldn't load the landscape data.
    - -
    -
    - - } - > -
    {props.children}
    -
    - ); -}; - const Layout = (props: Props) => { const [statsVisible, setStatsVisible] = createSignal(true); @@ -76,8 +51,7 @@ const Layout = (props: Props) => {
    {props.children}
    - - + @@ -93,4 +67,40 @@ const Layout = (props: Props) => { ); }; +const FullDataModals = () => { + const fullDataStatus = useFullDataStatus(); + + return ( + + + + + ); +}; + export default Layout; + +const FullDataContent = (props: Props) => { + const fullDataStatus = useFullDataStatus(); + const retryFullData = useRetryFullData(); + + return ( + + +
    +
    We couldn't load the landscape data.
    + +
    +
    + + } + > +
    {props.children}
    +
    + ); +}; diff --git a/ui/webapp/src/layout/stores/fullData.tsx b/ui/webapp/src/layout/stores/fullData.tsx index 10075d07..d1f49bd6 100644 --- a/ui/webapp/src/layout/stores/fullData.tsx +++ b/ui/webapp/src/layout/stores/fullData.tsx @@ -39,10 +39,12 @@ export function useFullDataReady() { return useFullData().fullDataReady; } +/** Return the current full-data loading status accessor. */ export function useFullDataStatus() { return useFullData().fullDataStatus; } +/** Return the callback that retries loading the full dataset. */ export function useRetryFullData() { return useFullData().retryFullData; } diff --git a/ui/webapp/src/test-utils/commonMock.tsx b/ui/webapp/src/test-utils/commonMock.tsx new file mode 100644 index 00000000..72f04215 --- /dev/null +++ b/ui/webapp/src/test-utils/commonMock.tsx @@ -0,0 +1,66 @@ +import type { Accessor, JSXElement } from 'solid-js'; + +export enum SVGIconKind { + CSV = 'csv', + Download = 'download', + PDF = 'pdf', + PNG = 'png', + ToC = 'toc', +} + +export interface Item { + id: string; + [key: string]: unknown; +} + +interface ChildrenProps { + children: JSXElement; +} + +interface ItemContentProps { + basePath?: string; + foundation: string; + hideOrganizationSection?: boolean; + item?: unknown; + onClose?: () => void; + parentInfo?: unknown; +} + +interface ModalProps extends ChildrenProps { + bodyClass?: string; + id?: string; + modalDialogClass?: string; + onClose: () => void; + open: boolean; + size?: string; + title: string; +} + +export const ItemModalContent = (props: ItemContentProps) => { + void props; + return createTextElement('Item content'); +}; +export const ItemModalMobileContent = (props: ItemContentProps) => { + void props; + return createTextElement('Mobile item content'); +}; +export const Loading = (props: { spinnerClass?: string }) => { + void props; + return createTextElement('Loading...'); +}; +export const Modal = (props: ModalProps) => <>{props.children}; +export const NoData = (props: ChildrenProps) => <>{props.children}; +export const SVGIcon = (props: { class?: string; kind: SVGIconKind }) => { + void props; + return document.createElement('span'); +}; +export const useBreakpointDetect = () => ({ point: (() => undefined) as Accessor }); +export const useOutsideClick = (...arguments_: unknown[]) => { + void arguments_; +}; + +const createTextElement = (text: string) => { + const element = document.createElement('div'); + element.textContent = text; + return element; +}; diff --git a/ui/webapp/src/utils/getDownloadBlob.test.ts b/ui/webapp/src/utils/__tests__/getDownloadBlob.test.ts similarity index 98% rename from ui/webapp/src/utils/getDownloadBlob.test.ts rename to ui/webapp/src/utils/__tests__/getDownloadBlob.test.ts index 80364658..50e363c3 100644 --- a/ui/webapp/src/utils/getDownloadBlob.test.ts +++ b/ui/webapp/src/utils/__tests__/getDownloadBlob.test.ts @@ -1,4 +1,4 @@ -import getDownloadBlob from './getDownloadBlob'; +import getDownloadBlob from '../getDownloadBlob'; const fetchMock = jest.fn(); diff --git a/ui/webapp/src/utils/isDownloadAvailable.test.ts b/ui/webapp/src/utils/__tests__/isDownloadAvailable.test.ts similarity index 97% rename from ui/webapp/src/utils/isDownloadAvailable.test.ts rename to ui/webapp/src/utils/__tests__/isDownloadAvailable.test.ts index a2cc28a0..b00b91c0 100644 --- a/ui/webapp/src/utils/isDownloadAvailable.test.ts +++ b/ui/webapp/src/utils/__tests__/isDownloadAvailable.test.ts @@ -1,4 +1,4 @@ -import isDownloadAvailable from './isDownloadAvailable'; +import isDownloadAvailable from '../isDownloadAvailable'; const fetchMock = jest.fn(); diff --git a/ui/webapp/src/utils/downloadDocument.ts b/ui/webapp/src/utils/downloadDocument.ts new file mode 100644 index 00000000..c4fe57a8 --- /dev/null +++ b/ui/webapp/src/utils/downloadDocument.ts @@ -0,0 +1,13 @@ +interface DownloadDocument { + doc: string; + format: string; +} + +/** Return the generated file name for a downloadable document. */ +export const getDownloadDocumentName = (document: DownloadDocument) => `${document.doc}.${document.format}`; + +/** Return the environment-specific URL for a downloadable document. */ +export const getDownloadDocumentUrl = (document: DownloadDocument) => + import.meta.env.MODE === 'development' + ? `../../static/docs/${getDownloadDocumentName(document)}` + : `./docs/${getDownloadDocumentName(document)}`; diff --git a/ui/webapp/src/utils/guideData.ts b/ui/webapp/src/utils/guideData.ts new file mode 100644 index 00000000..2ae7727d --- /dev/null +++ b/ui/webapp/src/utils/guideData.ts @@ -0,0 +1,3 @@ +/** Return the environment-specific URL for the guide dataset. */ +export const getGuideUrl = () => + import.meta.env.MODE === 'development' ? '../../static/data/guide.json' : './data/guide.json'; diff --git a/ui/webapp/src/utils/itemsDataGetter.ts b/ui/webapp/src/utils/itemsDataGetter.ts index ad5408e6..2412344f 100644 --- a/ui/webapp/src/utils/itemsDataGetter.ts +++ b/ui/webapp/src/utils/itemsDataGetter.ts @@ -175,6 +175,7 @@ export class ItemsDataGetter { return this.status === 'ready'; } + /** Return the current full-data loading status. */ public getStatus(): ItemsDataLoadStatus { return this.status; } diff --git a/ui/webapp/tsconfig.jest.json b/ui/webapp/tsconfig.jest.json index b0c9905d..12faccc9 100644 --- a/ui/webapp/tsconfig.jest.json +++ b/ui/webapp/tsconfig.jest.json @@ -3,6 +3,10 @@ "compilerOptions": { "module": "ESNext", "moduleResolution": "node", + "baseUrl": ".", + "paths": { + "common": ["src/test-utils/commonMock.tsx"] + }, "allowImportingTsExtensions": false, "noEmit": false, "isolatedModules": false, @@ -10,6 +14,6 @@ "allowSyntheticDefaultImports": true, "types": ["jest", "node"] }, - "include": ["src/**/*"], + "include": ["src/**/*", "../common/src/**/*", "../embed-item/src/**/*"], "exclude": ["node_modules", "dist"] -} \ No newline at end of file +} From ad3381f0b667cbaa9f920f13f3980992fb70c148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Fri, 24 Jul 2026 17:48:30 +0200 Subject: [PATCH 18/21] Hide empty landscape download section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- .../src/layout/common/DownloadDropdown.tsx | 63 +++++++++---------- .../__tests__/DownloadDropdown.test.tsx | 17 +++++ 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/ui/webapp/src/layout/common/DownloadDropdown.tsx b/ui/webapp/src/layout/common/DownloadDropdown.tsx index 8766bd60..affbbbb6 100644 --- a/ui/webapp/src/layout/common/DownloadDropdown.tsx +++ b/ui/webapp/src/layout/common/DownloadDropdown.tsx @@ -114,6 +114,9 @@ const DownloadDropdown = () => { const availableLandscapeDownloads = () => LANDSCAPE_DOWNLOAD_OPTIONS.filter((option) => availableScreenshotFormats().has(option.format)); + const shouldShowLandscapeDownloads = () => + screenshotAvailabilityStatus() !== ScreenshotAvailabilityStatus.Ready || availableLandscapeDownloads().length > 0; + const checkScreenshotAvailability = async () => { setScreenshotAvailabilityStatus(ScreenshotAvailabilityStatus.Checking); const results = await Promise.all( @@ -224,41 +227,35 @@ const DownloadDropdown = () => { >
      -
    • -
      Landscape
      -
    • - - {(option) => ( - void downloadFile(option)} - option={option} - /> - )} - - -
    • - Checking landscape downloads... -
    • -
      - +
    • - +
      Landscape
    • -
      - -
    • No landscape downloads available.
    • + + {(option) => ( + void downloadFile(option)} + option={option} + /> + )} + + +
    • + Checking landscape downloads... +
    • +
      + +
    • + +
    • +
    • Data files
      diff --git a/ui/webapp/src/layout/common/__tests__/DownloadDropdown.test.tsx b/ui/webapp/src/layout/common/__tests__/DownloadDropdown.test.tsx index 3f057844..d7b18080 100644 --- a/ui/webapp/src/layout/common/__tests__/DownloadDropdown.test.tsx +++ b/ui/webapp/src/layout/common/__tests__/DownloadDropdown.test.tsx @@ -84,6 +84,20 @@ describe('DownloadDropdown', () => { expect(projectsButton.disabled).toBe(false); }); + it('should omit the landscape section when no screenshot formats are available', async () => { + isDownloadAvailableMock.mockResolvedValue(false); + dispose = render(() => , container); + await flushPromises(); + + getButton(container, 'Download files')!.click(); + + expect(getElementByText(container, 'Landscape')).toBeUndefined(); + expect(getElementByText(container, 'No landscape downloads available.')).toBeUndefined(); + expect(getElementByText(container, 'Data files')).not.toBeUndefined(); + expect(getButton(container, 'Download items in CSV format')).not.toBeNull(); + expect(getButton(container, 'Download projects in CSV format')).not.toBeNull(); + }); + it('should show a recoverable download error', async () => { isDownloadAvailableMock.mockResolvedValue(false); getDownloadBlobMock.mockRejectedValue(new TypeError('Network error')); @@ -113,3 +127,6 @@ const getButton = (container: HTMLElement, label: string) => const getButtonByText = (container: HTMLElement, text: string) => Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.trim() === text); + +const getElementByText = (container: HTMLElement, text: string) => + Array.from(container.querySelectorAll('*')).find((element) => element.textContent?.trim() === text); From 6b47f0bab1642c4b2e6e71a0768ddf8a27f8634e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Fri, 24 Jul 2026 18:02:37 +0200 Subject: [PATCH 19/21] Fix stacked modal scroll locking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- .../src/hooks/__tests__/useBodyScroll.test.ts | 72 +++++++++++++++++ ui/common/src/hooks/useBodyScroll.ts | 24 +++++- .../zoomModal/__tests__/ZoomModal.test.tsx | 80 +++++++++++++++++++ .../src/layout/common/zoomModal/index.tsx | 2 +- ui/webapp/src/test-utils/commonMock.tsx | 2 +- 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 ui/common/src/hooks/__tests__/useBodyScroll.test.ts create mode 100644 ui/webapp/src/layout/common/zoomModal/__tests__/ZoomModal.test.tsx diff --git a/ui/common/src/hooks/__tests__/useBodyScroll.test.ts b/ui/common/src/hooks/__tests__/useBodyScroll.test.ts new file mode 100644 index 00000000..0767de4c --- /dev/null +++ b/ui/common/src/hooks/__tests__/useBodyScroll.test.ts @@ -0,0 +1,72 @@ +import { createRoot, createSignal } from 'solid-js'; + +import { useBodyScroll } from '../useBodyScroll'; + +describe('useBodyScroll', () => { + let disposeTestRoot: (() => void) | undefined; + + afterEach(() => { + disposeTestRoot?.(); + document.body.removeAttribute('class'); + }); + + it('should keep scrolling locked when another modal instance is closed', async () => { + const [activeModalOpen, setActiveModalOpen] = createSignal(true); + const [inactiveModalOpen] = createSignal(false); + + disposeTestRoot = createRoot((disposeRoot) => { + useBodyScroll(activeModalOpen, 'modal'); + useBodyScroll(inactiveModalOpen, 'modal'); + return disposeRoot; + }); + await Promise.resolve(); + + expect(document.body.classList.contains('noScroll-modal')).toBe(true); + + setActiveModalOpen(false); + await Promise.resolve(); + + expect(document.body.classList.contains('noScroll-modal')).toBe(false); + }); + + it('should keep scrolling locked until the last stacked modal closes', async () => { + const [childModalOpen, setChildModalOpen] = createSignal(true); + const [parentModalOpen, setParentModalOpen] = createSignal(true); + + disposeTestRoot = createRoot((disposeRoot) => { + useBodyScroll(parentModalOpen, 'modal'); + useBodyScroll(childModalOpen, 'modal'); + return disposeRoot; + }); + await Promise.resolve(); + + expect(document.body.classList.contains('noScroll-modal')).toBe(true); + + setChildModalOpen(false); + await Promise.resolve(); + + expect(document.body.classList.contains('noScroll-modal')).toBe(true); + + setParentModalOpen(false); + await Promise.resolve(); + + expect(document.body.classList.contains('noScroll-modal')).toBe(false); + }); + + it('should release an active scroll lock during cleanup', async () => { + const [modalOpen] = createSignal(true); + + disposeTestRoot = createRoot((disposeRoot) => { + useBodyScroll(modalOpen, 'modal'); + return disposeRoot; + }); + await Promise.resolve(); + + expect(document.body.classList.contains('noScroll-modal')).toBe(true); + + disposeTestRoot(); + disposeTestRoot = undefined; + + expect(document.body.classList.contains('noScroll-modal')).toBe(false); + }); +}); diff --git a/ui/common/src/hooks/useBodyScroll.ts b/ui/common/src/hooks/useBodyScroll.ts index 21bf388a..78e7f37f 100644 --- a/ui/common/src/hooks/useBodyScroll.ts +++ b/ui/common/src/hooks/useBodyScroll.ts @@ -1,17 +1,37 @@ import { Accessor, createEffect, onCleanup } from 'solid-js'; +const bodyScrollLocks = new Map>(); + +/** Keeps body scrolling locked while the provided accessor is blocked. */ export const useBodyScroll = (blocked: Accessor, elType: string) => { const className = `noScroll-${elType}`; + const lockId = Symbol(className); createEffect(() => { if (blocked()) { + const locks = bodyScrollLocks.get(className) || new Set(); + locks.add(lockId); + bodyScrollLocks.set(className, locks); document.body.classList.add(className); } else { - document.body.classList.remove(className); + releaseBodyScrollLock(className, lockId); } }); onCleanup(() => { - document.body.classList.remove(className); + releaseBodyScrollLock(className, lockId); }); }; + +const releaseBodyScrollLock = (className: string, lockId: symbol) => { + const locks = bodyScrollLocks.get(className); + if (!locks) { + return; + } + + locks.delete(lockId); + if (locks.size === 0) { + bodyScrollLocks.delete(className); + document.body.classList.remove(className); + } +}; diff --git a/ui/webapp/src/layout/common/zoomModal/__tests__/ZoomModal.test.tsx b/ui/webapp/src/layout/common/zoomModal/__tests__/ZoomModal.test.tsx new file mode 100644 index 00000000..7bac1171 --- /dev/null +++ b/ui/webapp/src/layout/common/zoomModal/__tests__/ZoomModal.test.tsx @@ -0,0 +1,80 @@ +import { Accessor, JSXElement } from 'solid-js'; +import { render } from 'solid-js/web'; + +import itemsDataGetter from '../../../../utils/itemsDataGetter'; +import ZoomModal from '../index'; + +let mockInitialRefs: Accessor[] | undefined; + +jest.mock( + '@solid-primitives/resize-observer', + () => ({ + createElementSize: () => ({ width: null }), + }), + { virtual: true } +); +jest.mock('../../../../data', () => ({ + ITEM_VIEW: 'item-view', + ZOOM_LEVELS: { 10: [100, 100] }, +})); +jest.mock('../../../../utils/itemsDataGetter', () => ({ + __esModule: true, + default: { + getItemsBySection: jest.fn(), + }, +})); +jest.mock('../../../explore/grid/GridItem', () => ({ + __esModule: true, + default: () => globalThis.document.createElement('div'), +})); +jest.mock('../../../stores/fullData', () => ({ + useFullDataReady: () => () => true, +})); +jest.mock('../../../stores/visibleZoomSection', () => ({ + useSetVisibleZoom: () => jest.fn(), + useVisibleZoom: () => () => ({ + bgColor: '#000', + category: 'Category', + subcategory: 'Subcategory', + }), +})); +jest.mock('../../FullScreenModal', () => ({ + __esModule: true, + default: (props: { children: JSXElement; initialRefs?: Accessor[] }) => { + mockInitialRefs = props.initialRefs; + return <>{props.children}; + }, +})); + +const getItemsBySectionMock = itemsDataGetter.getItemsBySection as jest.MockedFunction< + typeof itemsDataGetter.getItemsBySection +>; + +describe('ZoomModal', () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + dispose = undefined; + mockInitialRefs = undefined; + getItemsBySectionMock.mockReset(); + getItemsBySectionMock.mockReturnValue([]); + }); + + afterEach(() => { + dispose?.(); + document.body.replaceChildren(); + }); + + it('should provide an outside-click ref before the modal content mounts', async () => { + dispose = render(() => , container); + await flushPromises(); + + expect(mockInitialRefs).toHaveLength(1); + expect(mockInitialRefs![0]()).toBeInstanceOf(HTMLDivElement); + }); +}); + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/ui/webapp/src/layout/common/zoomModal/index.tsx b/ui/webapp/src/layout/common/zoomModal/index.tsx index b9829803..81f02704 100644 --- a/ui/webapp/src/layout/common/zoomModal/index.tsx +++ b/ui/webapp/src/layout/common/zoomModal/index.tsx @@ -85,7 +85,7 @@ const ZoomModal = () => { ] : undefined} + initialRefs={[modal as Accessor]} excludedIds={[ITEM_VIEW]} onClose={() => updateActiveSection()} title={`${visibleZoomSection()!.category} / ${visibleZoomSection()!.subcategory}`} diff --git a/ui/webapp/src/test-utils/commonMock.tsx b/ui/webapp/src/test-utils/commonMock.tsx index 72f04215..bd807a08 100644 --- a/ui/webapp/src/test-utils/commonMock.tsx +++ b/ui/webapp/src/test-utils/commonMock.tsx @@ -44,7 +44,7 @@ export const ItemModalMobileContent = (props: ItemContentProps) => { void props; return createTextElement('Mobile item content'); }; -export const Loading = (props: { spinnerClass?: string }) => { +export const Loading = (props: { spinnerClass?: string; transparentBg?: boolean }) => { void props; return createTextElement('Loading...'); }; From 719b4c9b7628fb13a3ff3039895e677852d96815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 27 Jul 2026 13:53:42 +0200 Subject: [PATCH 20/21] Move UI Development Guide to Webapp README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- README.md | 27 --------------------------- ui/webapp/README.md | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 27 deletions(-) create mode 100644 ui/webapp/README.md diff --git a/README.md b/README.md index e91a233e..a5fb49ee 100644 --- a/README.md +++ b/README.md @@ -96,33 +96,6 @@ Commands: help Print this message or the help of the given subcommand(s) ``` -### UI development - -The SolidJS web application is located in `ui/webapp`. Install its dependencies -and start the Vite development server from the repository root: - -```text -yarn --cwd ui/webapp install -yarn --cwd ui/webapp dev -``` - -By default, the development server loads datasets and generated assets from -`ui/webapp/static`. To develop against data from an existing landscape, copy the -example environment file and configure the landscape URL: - -```text -cp ui/webapp/.env.local.example ui/webapp/.env.local -``` - -```text -VITE_PROXY_TARGET=https://landscape.cncf.io -``` - -Restart the development server after changing `.env.local`. The proxy handles -datasets, documents, images, and logos during development only; production -builds are not affected. Remove `VITE_PROXY_TARGET` to use the local generated -assets again. - ## Usage To see **landscape2** in action, we will go through the process of creating, building and serving a new landscape from scratch. The following instructions will assume that the `landscape2` binary is available in your PATH. Alternatively, you can launch a container from the [image provided](https://github.com/cncf/landscape2/pkgs/container/landscape2) where the **landscape2** CLI tool is ready to use. diff --git a/ui/webapp/README.md b/ui/webapp/README.md new file mode 100644 index 00000000..aa4f286c --- /dev/null +++ b/ui/webapp/README.md @@ -0,0 +1,26 @@ +# UI development + +The SolidJS web application is located in this directory. Install its dependencies +and start the Vite development server from the repository root: + +```text +yarn --cwd ui/webapp install +yarn --cwd ui/webapp dev +``` + +By default, the development server loads datasets and generated assets from +`ui/webapp/static`. To develop against data from an existing landscape, copy the +example environment file and configure the landscape URL: + +```text +cp ui/webapp/.env.local.example ui/webapp/.env.local +``` + +```text +VITE_PROXY_TARGET=https://landscape.cncf.io +``` + +Restart the development server after changing `.env.local`. The proxy handles +datasets, documents, images, and logos during development only; production +builds are not affected. Remove `VITE_PROXY_TARGET` to use the local generated +assets again. From e94666e827926910b1453cab9010f4b6a648a319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cintia=20S=C3=A1nchez=20Garc=C3=ADa?= Date: Mon, 27 Jul 2026 14:08:50 +0200 Subject: [PATCH 21/21] Fix Solid type resolution in frontend tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cintia Sánchez García --- ui/webapp/tsconfig.jest.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/webapp/tsconfig.jest.json b/ui/webapp/tsconfig.jest.json index 12faccc9..2070e0a1 100644 --- a/ui/webapp/tsconfig.jest.json +++ b/ui/webapp/tsconfig.jest.json @@ -5,7 +5,9 @@ "moduleResolution": "node", "baseUrl": ".", "paths": { - "common": ["src/test-utils/commonMock.tsx"] + "common": ["src/test-utils/commonMock.tsx"], + "solid-js": ["node_modules/solid-js"], + "solid-js/*": ["node_modules/solid-js/*"] }, "allowImportingTsExtensions": false, "noEmit": false,