diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/DownloadCountProcessor.java similarity index 99% rename from server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java rename to server/src/main/java/org/eclipse/openvsx/analytics/ingestion/DownloadCountProcessor.java index 0742de673..06252caae 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/DownloadCountProcessor.java @@ -7,7 +7,7 @@ * * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion; import java.time.LocalDateTime; import java.util.List; diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/DownloadCountService.java similarity index 94% rename from server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java rename to server/src/main/java/org/eclipse/openvsx/analytics/ingestion/DownloadCountService.java index b2193f071..93362b9f0 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountService.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/DownloadCountService.java @@ -7,7 +7,7 @@ * * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion; import java.time.ZoneId; @@ -18,6 +18,8 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import org.eclipse.openvsx.analytics.ingestion.aws.AwsDownloadCountHandler; +import org.eclipse.openvsx.analytics.ingestion.azure.AzureDownloadCountHandler; import org.eclipse.openvsx.entities.FileResource; import org.eclipse.openvsx.migration.HandlerJobRequest; diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/AwsDownloadCountHandler.java similarity index 98% rename from server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java rename to server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/AwsDownloadCountHandler.java index 2e70996b5..63486e0e8 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/AwsDownloadCountHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/AwsDownloadCountHandler.java @@ -7,7 +7,7 @@ * * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion.aws; import java.io.*; import java.nio.charset.StandardCharsets; @@ -34,6 +34,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountProcessor; import org.eclipse.openvsx.entities.Extension; import org.eclipse.openvsx.entities.FileResource; import org.eclipse.openvsx.migration.HandlerJobRequest; diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/CloudFrontLogFileParser.java similarity index 96% rename from server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java rename to server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/CloudFrontLogFileParser.java index 73ea9e8a7..7da5b172e 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParser.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/CloudFrontLogFileParser.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion.aws; class CloudFrontLogFileParser implements LogFileParser { @Override diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/FastlyLogFileParser.java similarity index 97% rename from server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java rename to server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/FastlyLogFileParser.java index 1374e812e..6a9ab57b5 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/FastlyLogFileParser.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/FastlyLogFileParser.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion.aws; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/LogFileParser.java b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/LogFileParser.java similarity index 92% rename from server/src/main/java/org/eclipse/openvsx/storage/log/LogFileParser.java rename to server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/LogFileParser.java index f426b0041..aff3133c6 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/LogFileParser.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/LogFileParser.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion.aws; import org.jspecify.annotations.Nullable; diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/LogRecord.java similarity index 92% rename from server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java rename to server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/LogRecord.java index 39cbe7fc1..0657e4837 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/LogRecord.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/aws/LogRecord.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion.aws; import org.jspecify.annotations.NonNull; diff --git a/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/azure/AzureDownloadCountHandler.java similarity index 98% rename from server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java rename to server/src/main/java/org/eclipse/openvsx/analytics/ingestion/azure/AzureDownloadCountHandler.java index bc6e989d6..f7996d433 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/log/AzureDownloadCountHandler.java +++ b/server/src/main/java/org/eclipse/openvsx/analytics/ingestion/azure/AzureDownloadCountHandler.java @@ -7,7 +7,7 @@ * * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion.azure; import java.io.IOException; import java.net.URI; @@ -42,6 +42,7 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountProcessor; import org.eclipse.openvsx.entities.FileResource; import org.eclipse.openvsx.migration.HandlerJobRequest; import org.eclipse.openvsx.settings.SettingsService; diff --git a/server/src/main/java/org/eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java b/server/src/main/java/org/eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java index 781898c01..01e786284 100644 --- a/server/src/main/java/org/eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java +++ b/server/src/main/java/org/eclipse/openvsx/mirror/aop/DownloadCountServiceAspect.java @@ -20,7 +20,7 @@ @ConditionalOnProperty(value = "ovsx.data.mirror.enabled", havingValue = "true") public class DownloadCountServiceAspect { - @Around("execution(* org.eclipse.openvsx.storage.log.*DownloadCountService.isEnabled(..))") + @Around("execution(* org.eclipse.openvsx.analytics.ingestion.*DownloadCountService.isEnabled(..))") public Object isEnabled(ProceedingJoinPoint ignoredPjp) throws Throwable { return false; } diff --git a/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java b/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java index 6629ddc51..0c8b1cce8 100644 --- a/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java +++ b/server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java @@ -36,7 +36,7 @@ import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; -import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountService; import org.eclipse.openvsx.util.HttpHeadersUtil; import org.eclipse.openvsx.util.TempFile; import org.eclipse.openvsx.util.UrlUtil; diff --git a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java index e4f68bea6..abe4d573d 100644 --- a/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java @@ -79,7 +79,7 @@ import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; import org.eclipse.openvsx.storage.*; -import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountService; import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; import org.eclipse.openvsx.util.ChangesCursor; import org.eclipse.openvsx.util.LogService; diff --git a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java index 448644a7a..8107ce643 100644 --- a/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/adapter/VSCodeAPITest.java @@ -59,7 +59,7 @@ import org.eclipse.openvsx.security.OAuth2UserServices; import org.eclipse.openvsx.security.SecurityConfig; import org.eclipse.openvsx.storage.*; -import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountService; import org.eclipse.openvsx.util.TargetPlatform; import org.eclipse.openvsx.util.VersionService; import org.eclipse.openvsx.web.JacksonConfig; diff --git a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java index 6fe026925..967f73630 100644 --- a/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java +++ b/server/src/test/java/org/eclipse/openvsx/admin/AdminAPITest.java @@ -103,7 +103,7 @@ import org.eclipse.openvsx.storage.GoogleCloudStorageService; import org.eclipse.openvsx.storage.LocalStorageService; import org.eclipse.openvsx.storage.StorageUtilService; -import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountService; import org.eclipse.openvsx.trustedpublishing.TrustedPublishingConfig; import org.eclipse.openvsx.util.LogService; import org.eclipse.openvsx.util.TargetPlatform; diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java b/server/src/test/java/org/eclipse/openvsx/analytics/ingestion/aws/CloudFrontLogFileParserTest.java similarity index 96% rename from server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java rename to server/src/test/java/org/eclipse/openvsx/analytics/ingestion/aws/CloudFrontLogFileParserTest.java index 3191efd59..1c96f287e 100644 --- a/server/src/test/java/org/eclipse/openvsx/storage/log/CloudFrontLogFileParserTest.java +++ b/server/src/test/java/org/eclipse/openvsx/analytics/ingestion/aws/CloudFrontLogFileParserTest.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion.aws; import java.io.BufferedReader; import java.io.IOException; diff --git a/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java b/server/src/test/java/org/eclipse/openvsx/analytics/ingestion/aws/FastlyLogFileParserTest.java similarity index 96% rename from server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java rename to server/src/test/java/org/eclipse/openvsx/analytics/ingestion/aws/FastlyLogFileParserTest.java index a571e7298..badf429b0 100644 --- a/server/src/test/java/org/eclipse/openvsx/storage/log/FastlyLogFileParserTest.java +++ b/server/src/test/java/org/eclipse/openvsx/analytics/ingestion/aws/FastlyLogFileParserTest.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -package org.eclipse.openvsx.storage.log; +package org.eclipse.openvsx.analytics.ingestion.aws; import java.io.BufferedReader; import java.io.IOException; diff --git a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java index 03bc6b483..1dc60ea72 100644 --- a/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/eclipse/EclipseServiceTest.java @@ -50,7 +50,7 @@ import org.eclipse.openvsx.scanning.ExtensionScanService; import org.eclipse.openvsx.search.SearchUtilService; import org.eclipse.openvsx.storage.*; -import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountService; import org.eclipse.openvsx.util.ErrorResultException; import org.eclipse.openvsx.util.LogService; import org.eclipse.openvsx.util.TargetPlatform; diff --git a/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java b/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java index ec9885eb5..b2967ccf9 100644 --- a/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java +++ b/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceTest.java @@ -31,7 +31,7 @@ import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; -import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountService; import static org.eclipse.openvsx.entities.FileResource.README; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceUploadFileTest.java b/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceUploadFileTest.java index a02a4209a..212a09afa 100644 --- a/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceUploadFileTest.java +++ b/server/src/test/java/org/eclipse/openvsx/storage/StorageUtilServiceUploadFileTest.java @@ -24,7 +24,7 @@ import org.eclipse.openvsx.metrics.ExtensionDownloadMetrics; import org.eclipse.openvsx.repositories.RepositoryService; import org.eclipse.openvsx.search.SearchUtilService; -import org.eclipse.openvsx.storage.log.DownloadCountService; +import org.eclipse.openvsx.analytics.ingestion.DownloadCountService; import org.eclipse.openvsx.util.TempFile; import static org.assertj.core.api.Assertions.assertThat; diff --git a/server/src/test/resources/org/eclipse/openvsx/storage/log/cloudfront.log b/server/src/test/resources/org/eclipse/openvsx/analytics/ingestion/aws/cloudfront.log similarity index 100% rename from server/src/test/resources/org/eclipse/openvsx/storage/log/cloudfront.log rename to server/src/test/resources/org/eclipse/openvsx/analytics/ingestion/aws/cloudfront.log diff --git a/server/src/test/resources/org/eclipse/openvsx/storage/log/fastly.log b/server/src/test/resources/org/eclipse/openvsx/analytics/ingestion/aws/fastly.log similarity index 100% rename from server/src/test/resources/org/eclipse/openvsx/storage/log/fastly.log rename to server/src/test/resources/org/eclipse/openvsx/analytics/ingestion/aws/fastly.log diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index 4656b6d1b..3a45e1468 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -26,6 +26,20 @@ This change log covers only the frontend library (webui) of Open VSX. - Publishing goes through TanStack Query: `publishExtension` and `createNamespace` are mutation hooks (`usePublishExtension`, `useCreateNamespace`), and both service methods lose their `AbortController` parameter — writes are no longer aborted, and retries are the query client's to own. The user's extension list is a query too (`useUserExtensions`), read by the settings tab and by the publish queue as it follows a package, so a card appears in the list as soon as the registry has the package - `ExtensionCard` accepts an `Extension` as well as a `SearchEntry`, and takes optional `to`, `linkState`, `overlay`, `footerStart`, `dimmed`, `tone` and `iconPending` props so other surfaces can reuse it instead of copying it +### Added + +- Add a `Pill` component — the clickable glass pill the category pills are built on, now usable on its own — and extract the `MonoSlash`, `glassSurface` and `compactControl` page primitives out of the search field, the pills and the search header +- Add `userLoading` to `MainContext`, so custom pages can tell "not logged in" from "still resolving the user" +- Add a `userMenuContent` slot to `PageSettings.elements`: extra entries for the logged-in account menu, rendered above the admin entry. The slot receives a `MenuEntry` component to build entries with, so each entry is styled by the menu it appears in — the desktop and mobile menus style theirs differently, and a consumer cannot match both on its own +- Add an `adminPages` slot to `PageSettings.elements`: extra admin dashboard pages, each declaring a name, icon, optional description and optional category, and each appearing in the side panel, as a card on the dashboard overview and as a route. Contributions are additive — a category name matching a built-in group appends to it, and a page whose path would shadow a built-in one is ignored +- Widen the published API for consumers building their own pages: the request layer (`sendRequest`, `sendNonRetriableRequest`, `ErrorResponse`, `controllerFromSignal`), `MainContext`, `AppProviders`, `NotFound`, `createDefaultTheme` with the `MONO_FONT`/`NAVBAR_HEIGHT` tokens, the `createRoute`/`createAbsoluteURL`/`addQuery`/`formatCompactNumber`/`toRelativeTime` utils, the `useDebouncedCallback` and `useGridCursor` hooks, the navbar-chrome, search-focus and page-search-bar hooks, the category icon helpers, `ExtensionDetailRoutes`, and the `itemIcon`/`MenuItemText` building blocks for `userMenuContent` entries + +### Changed + +- Rename `ScrollToTop` to `ScrollRestoration`, matching what it does on back/forward navigation +- Rename the extension tint context to `navbar-chrome-context` and add a second channel to it: a page with sections pinned under the navbar can extend the navbar's blur fan down to back them (`useExtendNavbarBlur`) +- Give Popover and Autocomplete popups the same floating-paper treatment as the other menus, and stop Popovers locking body scroll — the lock jumps the scroll position on mobile and shifts the pinned chrome + ### Fixed - Fix a React warning ("Received `true` for a non-boolean attribute `notched`") from the admin dashboard's publisher role filter, whose custom `InputBase` doesn't consume the `notched` prop MUI's `Select` injects for the (unused) outlined variant diff --git a/webui/src/app-providers.tsx b/webui/src/app-providers.tsx index 72f7e626f..56e458a63 100644 --- a/webui/src/app-providers.tsx +++ b/webui/src/app-providers.tsx @@ -33,7 +33,7 @@ interface AppProvidersProps { * bottom in one place. Ordered outer→inner; keyboard shortcuts and search wrap * every route (admin included). Router, theme, and Helmet stay at the app entry — * they're supplied by whoever mounts the library. Lower-tier, feature-scoped - * providers (e.g. extension tint) stay with their feature. + * providers (e.g. the navbar chrome) stay with their feature. */ export const AppProviders: FunctionComponent = ({ mainContext, diff --git a/webui/src/components/category-pill.tsx b/webui/src/components/category-pill.tsx index 61802996d..9f896bda8 100644 --- a/webui/src/components/category-pill.tsx +++ b/webui/src/components/category-pill.tsx @@ -12,31 +12,8 @@ ********************************************************************************/ import { FunctionComponent, useEffect, useRef } from 'react'; -import { ButtonBase, SvgIconProps } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { accentHover, focusOutline } from './page-primitives'; - -const Root = styled(ButtonBase, { - shouldForwardProp: prop => prop !== 'isSelected' -})<{ isSelected?: boolean }>(({ theme, isSelected }) => ({ - display: 'inline-flex', - alignItems: 'center', - gap: '0.4375rem', - flexShrink: 0, - overflow: 'hidden', - backgroundColor: isSelected ? theme.palette.accentSoft : theme.palette.surface2, - border: `1px solid ${isSelected ? theme.palette.secondary.main : theme.palette.divider}`, - color: isSelected ? theme.palette.secondary.light : theme.palette.text.secondary, - fontSize: '0.8125rem', - fontWeight: isSelected ? 600 : 500, - padding: '0.4375rem 0.8125rem', - borderRadius: theme.shape.borderRadiusPill, - whiteSpace: 'nowrap', - fontFamily: 'inherit', - transition: 'border-color 0.14s, color 0.14s', - ...(isSelected ? {} : accentHover(theme)), - ...focusOutline(theme) -})); +import { SvgIconProps } from '@mui/material'; +import { Pill } from './pill'; export interface CategoryPillProps { label: string; @@ -56,9 +33,9 @@ export const CategoryPill: FunctionComponent = ({ label, icon }, [isSelected]); return ( - - + + {label} - + ); }; diff --git a/webui/src/components/extension-searchfield.tsx b/webui/src/components/extension-searchfield.tsx index 20d41cbe1..a2f442b9b 100644 --- a/webui/src/components/extension-searchfield.tsx +++ b/webui/src/components/extension-searchfield.tsx @@ -17,7 +17,7 @@ import { alpha, styled } from '@mui/material/styles'; import { MONO_FONT } from '../default/theme'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Close'; -import { focusRing } from './page-primitives'; +import { focusRing, MonoSlash } from './page-primitives'; interface ExtensionSearchfieldProps { onSearchChanged: (s: string) => void; @@ -49,15 +49,6 @@ const SearchWrap = styled(Box, { '&:focus-within': focusRing(theme) })); -const MonoSlash = styled('span')(({ theme }) => ({ - fontFamily: MONO_FONT, - color: theme.palette.secondary.light, - fontSize: '1.0625rem', - lineHeight: 1, - flexShrink: 0, - userSelect: 'none' -})); - const SearchInput = styled(InputBase)(({ theme }) => ({ flex: 1, fontFamily: MONO_FONT, diff --git a/webui/src/components/page-primitives.tsx b/webui/src/components/page-primitives.tsx index 24325177d..c7eca5cd2 100644 --- a/webui/src/components/page-primitives.tsx +++ b/webui/src/components/page-primitives.tsx @@ -13,6 +13,7 @@ import { Box, Typography } from '@mui/material'; import { alpha, CSSObject, styled, Theme } from '@mui/material/styles'; +import { MONO_FONT } from '../default/theme'; /** Normalized gap between stacked sections; the owl selector skips the first (and any null) child. */ export const SectionStack = styled(Box)(({ theme }) => ({ @@ -42,6 +43,16 @@ export const ExtensionGrid = styled(Box)({ } }); +/** Leading '/' glyph of the search fields — the field's mark and the shortcut hint in one. */ +export const MonoSlash = styled('span')(({ theme }) => ({ + fontFamily: MONO_FONT, + color: theme.palette.secondary.light, + fontSize: '1.0625rem', + lineHeight: 1, + flexShrink: 0, + userSelect: 'none' +})); + /** Small uppercase label used to head sections, columns and sidebars. */ export const Eyebrow = styled(Typography)(({ theme }) => ({ fontSize: '0.75rem', @@ -107,6 +118,32 @@ export const TagChip = styled('span', { shouldForwardProp: prop => prop !== 'acc }) ); +/** + * Translucent glass fill for controls floating over scrolled content (pills, pinned toolbars). + * Backdrop filters re-blur on every scrolled frame, so keep the frosted surfaces small. + */ +export const glassSurface = (theme: Theme, opacity = 0.7) => ({ + backgroundColor: alpha(theme.palette.surface2, opacity), + backdropFilter: 'blur(2px) saturate(1.8)' +}); + +/** + * Compact toolbar control (the "Sort by" select, filter autocompletes): one 30px text row on a + * paper fill. Selects take it directly via sx; composite inputs spread it into their outlined + * root. It carries nested selectors for the select's inner slots, so it belongs in sx (or another + * emotion context) and will not work in a plain style prop. + */ +export const compactControl = (theme: Theme) => ({ + fontSize: '0.8125rem', + fontWeight: 500, + color: theme.palette.text.primary, + height: '1.875rem', + backgroundColor: theme.palette.background.paper, + borderRadius: `${theme.shape.borderRadius}px`, + '& .MuiSelect-select': { padding: '0.25rem 2rem 0.25rem 0.625rem' }, + '& .MuiSelect-icon': { color: theme.palette.text.disabled, fontSize: '1.125rem' } +}); + /** Hover treatment for chips and pills: accent border and text color. Suppressed on touch devices. */ export const accentHover = (theme: Theme) => ({ '@media (hover: hover)': { diff --git a/webui/src/components/pill.tsx b/webui/src/components/pill.tsx new file mode 100644 index 000000000..6168b0065 --- /dev/null +++ b/webui/src/components/pill.tsx @@ -0,0 +1,43 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { ButtonBase } from '@mui/material'; +import { alpha, styled } from '@mui/material/styles'; +import { accentHover, focusOutline, glassSurface } from './page-primitives'; + +/** + * Clickable pill primitive: a translucent glass surface flipping to an accent fill when + * selected — the same treatment as the extension detail page's sticky tabs. + */ +export const Pill = styled(ButtonBase, { + shouldForwardProp: prop => prop !== 'isSelected' +})<{ isSelected?: boolean }>(({ theme, isSelected }) => ({ + display: 'inline-flex', + alignItems: 'center', + gap: '0.4375rem', + flexShrink: 0, + overflow: 'hidden', + ...glassSurface(theme), + ...(isSelected ? { backgroundColor: alpha(theme.palette.secondary.main, 0.7) } : {}), + border: `1px solid ${isSelected ? theme.palette.secondary.main : theme.palette.divider}`, + color: isSelected ? theme.palette.secondary.contrastText : theme.palette.text.secondary, + fontSize: '0.8125rem', + fontWeight: isSelected ? 600 : 500, + padding: '0.4375rem 0.8125rem', + borderRadius: theme.shape.borderRadiusPill, + whiteSpace: 'nowrap', + fontFamily: 'inherit', + transition: 'border-color 0.14s, color 0.14s, background 0.14s', + ...(isSelected ? {} : accentHover(theme)), + ...focusOutline(theme) +})); diff --git a/webui/src/context.ts b/webui/src/context.ts index 25c65bf64..3f8e8f8b0 100644 --- a/webui/src/context.ts +++ b/webui/src/context.ts @@ -18,6 +18,8 @@ export interface MainContext { pageSettings: PageSettings; handleError: (err: unknown, options?: { onClose?: () => void }) => void; user?: UserData; + /** The initial user fetch is still in flight, so `user` being undefined doesn't yet mean logged out. */ + userLoading: boolean; updateUser: () => void; loginProviders?: Record; version?: RegistryVersion; diff --git a/webui/src/context/extension-tint-context.tsx b/webui/src/context/extension-tint-context.tsx deleted file mode 100644 index 6b48ff499..000000000 --- a/webui/src/context/extension-tint-context.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2026 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * https://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { createContext, FunctionComponent, ReactNode, useContext, useMemo, useState } from 'react'; - -/** - * The tint region an extension detail page declares while mounted. The page - * only describes it; the nav bar compares the depth against its own scroll - * position to decide when to wear the color. - */ -export interface ExtensionTint { - // Gallery color the nav wears while the region backs it, flipping its - // content to the contrast color. Null for default-colored bands, which - // keep the nav on theme colors. - color: string | null; - // Document offset where the region ends; scrolled past it the nav returns - // to theme colors. - depth: number; -} - -const ExtensionTintContext = createContext<{ - tint: ExtensionTint | null; - setTint: (tint: ExtensionTint | null) => void; -}>({ tint: null, setTint: () => {} }); - -// eslint-disable-next-line react-refresh/only-export-components -export function useExtensionTint(): ExtensionTint | null { - return useContext(ExtensionTintContext).tint; -} - -// eslint-disable-next-line react-refresh/only-export-components -export function useSetExtensionTint(): (tint: ExtensionTint | null) => void { - return useContext(ExtensionTintContext).setTint; -} - -export const ExtensionTintProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { - const [tint, setTint] = useState(null); - const value = useMemo(() => ({ tint, setTint }), [tint]); - return {children}; -}; diff --git a/webui/src/context/navbar-chrome-context.tsx b/webui/src/context/navbar-chrome-context.tsx new file mode 100644 index 000000000..0328ad376 --- /dev/null +++ b/webui/src/context/navbar-chrome-context.tsx @@ -0,0 +1,85 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { createContext, FunctionComponent, ReactNode, useContext, useEffect, useMemo, useState } from 'react'; + +/** + * The tint region an extension detail page declares while mounted. The page + * only describes it; the nav bar compares the depth against its own scroll + * position to decide when to wear the color. + */ +export interface ExtensionTint { + // Gallery color the nav wears while the region backs it, flipping its + // content to the contrast color. Null for default-colored bands, which + // keep the nav on theme colors. + color: string | null; + // Document offset where the region ends; scrolled past it the nav returns + // to theme colors. + depth: number; +} + +// What the current page asks of the navbar chrome, declared while mounted: +// a tint over the page's gallery band, and extra depth for the blur fan to +// back sections pinned under the bar. +interface NavbarChrome { + tint: ExtensionTint | null; + setTint: (tint: ExtensionTint | null) => void; + blurDepth: number; + setBlurDepth: (depth: number) => void; +} + +const NavbarChromeContext = createContext({ + tint: null, + setTint: () => {}, + blurDepth: 0, + setBlurDepth: () => {} +}); + +export const NavbarChromeProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { + const [tint, setTint] = useState(null); + const [blurDepth, setBlurDepth] = useState(0); + const value = useMemo(() => ({ tint, setTint, blurDepth, setBlurDepth }), [tint, blurDepth]); + return {children}; +}; + +// eslint-disable-next-line react-refresh/only-export-components +export function useExtensionTint(): ExtensionTint | null { + return useContext(NavbarChromeContext).tint; +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useSetExtensionTint(): (tint: ExtensionTint | null) => void { + return useContext(NavbarChromeContext).setTint; +} + +/** The navbar reads how far pages want the blur fan extended. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useNavbarBlurExtent(): number { + return useContext(NavbarChromeContext).blurDepth; +} + +/** + * Extends the navbar's blur fan by the given depth (px) while the calling component is mounted — + * for page sections pinned under the navbar (sticky headers, tab rows) that float on the fan. + * Only one mounted component may extend the fan at a time. There is no ownership stack: the last + * writer sets the depth, and whichever writer unmounts first resets it to 0, so a second concurrent + * writer would lose the fan while still mounted. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useExtendNavbarBlur(depth: number): void { + const { setBlurDepth } = useContext(NavbarChromeContext); + useEffect(() => { + setBlurDepth(depth); + return () => setBlurDepth(0); + }, [depth, setBlurDepth]); +} diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index eed397184..75bc1435a 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -8,14 +8,24 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { FunctionComponent, PropsWithChildren, useContext, useRef, useState } from 'react'; -import { Avatar, IconButton, Link, Menu, MenuItem, Typography } from '@mui/material'; -import { useLocation, Link as RouteLink } from 'react-router'; +import { + ComponentType, + FunctionComponent, + PropsWithChildren, + useCallback, + useContext, + useMemo, + useRef, + useState +} from 'react'; +import { Avatar, Button, IconButton, Link, Menu, MenuItem, Typography } from '@mui/material'; +import { useLocation, useNavigate, Link as RouteLink } from 'react-router'; import { UserAvatar } from '../pages/user/avatar'; import { UserSettingsRoutes } from '../pages/user/user-settings-routes'; import { PublishRoutes } from '../pages/publish/publish-routes'; import { styled, Theme } from '@mui/material/styles'; import { MainContext } from '../context'; +import { UserMenuEntryProps } from '../page-settings'; import { KbdKey } from '../components/kbd-key'; import { PublishButton } from '../components/publish/publish-button'; import { useShortcut } from '../hooks/use-shortcut'; @@ -57,11 +67,26 @@ export const MenuItemText: FunctionComponent = ({ children }) }; export const MobileUserAvatar: FunctionComponent = () => { - const { user } = useContext(MainContext); + const { user, pageSettings } = useContext(MainContext); + const { userMenuContent: UserMenuContent } = pageSettings.elements; const logoutFormRef = useRef(null); const anchorRef = useRef(null); const [open, setOpen] = useState(false); - const close = () => setOpen(false); + const close = useCallback(() => setOpen(false), []); + // Stable identity: a new component type each render would remount contributed entries, + // refetching whatever they load. + const MenuEntry = useMemo>(() => { + const Entry = ({ to, icon: Icon, children }: UserMenuEntryProps) => ( + + + + {children} + + + ); + Entry.displayName = 'MobileUserMenuEntry'; + return Entry; + }, [close]); if (!user) { return null; } @@ -93,6 +118,7 @@ export const MobileUserAvatar: FunctionComponent = () => { Settings + {UserMenuContent ? : null} {user.role === 'admin' ? ( diff --git a/webui/src/default/theme.tsx b/webui/src/default/theme.tsx index 06102434c..d9dec2799 100644 --- a/webui/src/default/theme.tsx +++ b/webui/src/default/theme.tsx @@ -372,6 +372,15 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { } }, MuiPopover: { + // no body scroll lock: toggling it jumps the scroll position on mobile and + // shifts the pinned chrome; dialogs (MuiModal directly) keep theirs + defaultProps: { disableScrollLock: true }, + styleOverrides: { + paper: ({ theme }) => floatingPaper(theme) + } + }, + // Autocomplete popups (e.g. the publisher filter) render their own paper, not a popover. + MuiAutocomplete: { styleOverrides: { paper: ({ theme }) => floatingPaper(theme) } diff --git a/webui/src/index.ts b/webui/src/index.ts index 04cb7e807..4fe79833c 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -35,6 +35,8 @@ export { ExtensionCard, type ExtensionCardProps } from './components/extension-c // The nav's Publish button: the link to the publish page, the keyboard shortcut behind it, and the // app's drop target for .vsix packages, so a custom menu keeps drag-and-drop publishing. export { PublishButton, type PublishButtonProps } from './components/publish/publish-button'; +export { CategoryPill, type CategoryPillProps } from './components/category-pill'; +export { Pill } from './components/pill'; export * from './components/page-primitives'; export * from './components/page-container'; // Leaf hook modules keep their helpers private, so `export *` exposes only the @@ -56,3 +58,38 @@ export { // useSignal, subscribe with useSignalEffect. export * from './hooks/use-signal'; export * from './hooks/use-signal-effect'; + +// Request layer, for consumers whose registry exposes endpoints this library +// doesn't know about: they build their own service on top of these. +export { sendRequest, sendNonRetriableRequest, type ErrorResponse } from './server-request'; +export { controllerFromSignal } from './query-client'; + +// The app-wide context and the provider stack that supplies it — needed both by +// custom pages reading `service`/`user` and by consumer-side test harnesses. +export { MainContext } from './context'; +export { AppProviders } from './app-providers'; + +// `createAbsoluteURL` and `addQuery` are the other half of the request layer above: +// building an endpoint against `service.serverUrl` needs them. +export { createRoute, createAbsoluteURL, addQuery, formatCompactNumber, toRelativeTime } from './utils'; +export { NotFound } from './not-found'; + +// Theme tokens shared with the library chrome, so custom pages line up with it. +export { default as createDefaultTheme, MONO_FONT, NAVBAR_HEIGHT, NAVBAR_HEIGHT_PX } from './default/theme'; + +export * from './hooks/use-debounced-callback'; +export * from './hooks/use-grid-cursor'; + +// Navbar chrome: what a mounted page asks of the nav bar — a tint over its +// gallery band, and extra blur depth to back sections pinned under the bar. +export { useSetExtensionTint, useExtendNavbarBlur, type ExtensionTint } from './context/navbar-chrome-context'; +export { useSearchFocus, type ResultsNavAction } from './context/search/search-focus-context'; +export { usePageSearchBar, type PageSearchBarValue } from './context/search/page-search-bar-context'; + +export { useCategories, CATEGORY_ICONS, DefaultCategoryIcon } from './components/categories'; + +// Route paths, for linking into the built-in pages. +export { ExtensionDetailRoutes } from './pages/extension-detail/extension-detail-routes'; + +// Shape of the pages contributed through `PageSettings.elements.adminPages`. +export type { AdminPage, AdminPageCategory } from './pages/admin-dashboard/nav-types'; diff --git a/webui/src/layout/app-layout.tsx b/webui/src/layout/app-layout.tsx index 03fa90014..ca2190fef 100644 --- a/webui/src/layout/app-layout.tsx +++ b/webui/src/layout/app-layout.tsx @@ -19,7 +19,7 @@ import { Banner } from '../components/banner'; import { ShortcutsModal } from '../components/shortcuts-modal'; import { MainContext } from '../context'; import { useSearch } from '../hooks/use-search'; -import { ExtensionTintProvider } from '../context/extension-tint-context'; +import { NavbarChromeProvider } from '../context/navbar-chrome-context'; import { useShortcut } from '../hooks/use-shortcut'; import { getCookieValueByKey, setCookie } from '../utils'; import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes'; @@ -36,7 +36,7 @@ import { NotFound } from '../not-found'; import { NAVBAR_HEIGHT } from '../default/theme'; import { AppNavbar } from './app-navbar'; import { AppFooter } from './app-footer'; -import { ScrollToTop } from './scroll-to-top'; +import { ScrollRestoration } from './scroll-restoration'; const UserSettings = lazy(() => import('../pages/user/user-settings').then(m => ({ default: m.UserSettings }))); @@ -94,7 +94,7 @@ const AppLayoutContent: FunctionComponent = props => { return ( - + {BannerComponent ? ( = props => { }; // Keyboard shortcuts and search now live app-wide in AppProviders; this keeps -// only the feature-scoped tint provider. +// only the navbar-chrome channel the pages below declare into. export const AppLayout: FunctionComponent = props => ( - + - + ); export interface AppLayoutProps { diff --git a/webui/src/layout/app-navbar.tsx b/webui/src/layout/app-navbar.tsx index 430405dae..bd3f2ed9f 100644 --- a/webui/src/layout/app-navbar.tsx +++ b/webui/src/layout/app-navbar.tsx @@ -18,7 +18,7 @@ import { Link as RouteLink } from 'react-router'; import { HeaderMenu } from '../header-menu'; import { MainContext } from '../context'; import { usePageSearchBar } from '../context/search/page-search-bar-context'; -import { useExtensionTint } from '../context/extension-tint-context'; +import { useExtensionTint, useNavbarBlurExtent } from '../context/navbar-chrome-context'; import { OpenVsxMark } from '../components/openvsx-mark'; import { NavSearchField } from './nav-search-field'; import { NAVBAR_HEIGHT_PX } from '../default/theme'; @@ -101,8 +101,17 @@ export const AppNavbar: FunctionComponent = () => { const { navTint, washColor } = useNavTint(); - const fanBottom = { xs: '-48px', sm: '-80px' }; - const tintBottom = { xs: '-48px', sm: '-150px' }; + // Pages with pinned sections can deepen the fan so it backs them too — desktop only: + // on mobile the stretched blur reads as a smear, so pinned rows bring their own glass. + // Only the lightest layer stretches (backdrop-filter cost scales with area and re-runs + // every scroll frame); the tint washes are plain gradients and stretch fully. + const extraBlurDepth = useNavbarBlurExtent(); + const fanBottom = (stretched: boolean) => ({ + xs: '-48px', + sm: '-80px', + md: `-${80 + (stretched ? extraBlurDepth : 0)}px` + }); + const tintBottom = { xs: '-48px', sm: '-150px', md: `-${150 + extraBlurDepth}px` }; // While a gallery band backs the nav, the chrome wears its color and the // content flips to the contrast color, so the two surfaces read as one. @@ -150,7 +159,7 @@ export const AppNavbar: FunctionComponent = () => { left: 0, right: 0, top: 0, - bottom: fanBottom, + bottom: fanBottom(i === BLUR_LAYERS.length - 1), pointerEvents: 'none', zIndex: 0, opacity: showFan ? 1 : 0, diff --git a/webui/src/layout/scroll-to-top.tsx b/webui/src/layout/scroll-restoration.tsx similarity index 66% rename from webui/src/layout/scroll-to-top.tsx rename to webui/src/layout/scroll-restoration.tsx index 9b2cdc82b..e91095f69 100644 --- a/webui/src/layout/scroll-to-top.tsx +++ b/webui/src/layout/scroll-restoration.tsx @@ -14,13 +14,12 @@ import { FunctionComponent, useLayoutEffect } from 'react'; import { useLocation, useNavigationType } from 'react-router'; -// BrowserRouter leaves window scroll untouched on navigation, so a page opened -// from deep in a long list would start at that old offset. Reset to the top on -// forward navigations only: POP (back/forward) keeps the browser's native -// scroll restoration, and same-path param updates (e.g. search filters, which -// replace in place) must not jump either — hence keying on pathname alone. -// Links that swap content in place opt out via state `{ preserveScroll: true }`. -export const ScrollToTop: FunctionComponent = () => { +// BrowserRouter never scrolls on push, so reset to the top on forward navigations only: POP +// keeps the browser's restoration, and same-path param updates must not jump (hence keying on +// pathname alone). Links that swap content in place opt out via state `{ preserveScroll: true }`. +// TODO Use react-router's once on a data router — also fixes the pop +// landing clamped to the outgoing page's height: https://github.com/eclipse-openvsx/openvsx/issues/2079 +export const ScrollRestoration: FunctionComponent = () => { const { pathname, state } = useLocation(); const navigationType = useNavigationType(); const preserveScroll = (state as { preserveScroll?: boolean } | null)?.preserveScroll; diff --git a/webui/src/main.tsx b/webui/src/main.tsx index 7e62e8b16..556c37026 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -147,6 +147,7 @@ export const Main: FunctionComponent = props => { service: props.service, pageSettings: props.pageSettings, user, + userLoading, updateUser, loginProviders, handleError: onError, diff --git a/webui/src/page-settings.ts b/webui/src/page-settings.ts index 84d089cbe..9100b9ecd 100644 --- a/webui/src/page-settings.ts +++ b/webui/src/page-settings.ts @@ -11,8 +11,26 @@ import { ComponentType, ReactNode } from 'react'; import { SxProps, Theme } from '@mui/material/styles'; import { Extension, NamespaceDetails, SortBy } from './extension-registry-types'; +import { AdminPage } from './pages/admin-dashboard/nav-types'; import { Cookie } from './utils'; +/** One entry contributed to the account menu, rendered by the menu's own styling. */ +export interface UserMenuEntryProps { + /** Route the entry navigates to; the menu closes on click. */ + to: string; + /** Icon component (e.g. a MUI icon), sized and coloured by the menu. */ + icon: ComponentType<{ sx?: SxProps }>; + /** The entry's label. */ + children: ReactNode; +} + +export interface UserMenuContentProps { + /** Dismisses the menu. `MenuEntry` already does this on click. */ + close: () => void; + /** Renders one entry styled to match the menu it appears in. */ + MenuEntry: ComponentType; +} + export interface FooterLink { label: ReactNode; href: string; @@ -116,6 +134,13 @@ export interface PageSettings { toolbarContent?: ComponentType; defaultMenuContent?: ComponentType; mobileMenuContent?: ComponentType; + /** + * Extra entries for the logged-in account menu, rendered above the admin entry. The same + * component is used by the desktop and the mobile menu, which style their entries + * differently, so build entries with the supplied {@link UserMenuContentProps.MenuEntry} + * rather than a `MenuItem` of your own. + */ + userMenuContent?: ComponentType; footer?: FooterSettings; home?: HomePageSettings; searchHeader?: ComponentType; @@ -123,6 +148,11 @@ export interface PageSettings { claimNamespace?: ComponentType<{ namespace: string; extension?: Extension; sx?: SxProps }>; downloadTerms?: ComponentType; additionalRoutes?: ReactNode; + /** + * Extra pages for the admin dashboard, each appearing in the side panel, as a card on the + * dashboard overview and as a route. Additive only — see {@link AdminPage}. + */ + adminPages?: AdminPage[]; banner?: { content: ComponentType; props?: { diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index bcaed40d8..d3b523f4f 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { FunctionComponent, ReactNode, useContext, lazy, Suspense } from 'react'; +import { FunctionComponent, ReactNode, useContext, useMemo, lazy, Suspense } from 'react'; import { Box, Container, CssBaseline, Typography, IconButton } from '@mui/material'; import { styled } from '@mui/material/styles'; import { Route, Routes, useNavigate } from 'react-router'; @@ -27,10 +27,11 @@ import SpeedIcon from '@mui/icons-material/Speed'; import StarIcon from '@mui/icons-material/Star'; import { LoginComponent } from '../../default/login'; import { MainContext } from '../../context'; +import { createRoute } from '../../utils'; import { AdminDashboardRoutes } from './admin-dashboard-routes'; import { AdminSidepanel } from './admin-sidepanel'; import { AdminHeader } from './admin-header'; -import { isNavGroup, NavEntry } from './nav-types'; +import { AdminPage, isNavGroup, NavEntry, NavGroup, RouteEntry } from './nav-types'; import { NamespaceAdmin } from './namespace-admin'; import { PublisherAdmin } from './publisher-admin'; @@ -117,19 +118,62 @@ const navConfig: NavEntry[] = [ } ]; -const routeNames: { [key: string]: string } = { - [AdminDashboardRoutes.MAIN]: 'Admin Dashboard', - ...navConfig.reduce<{ [key: string]: string }>((acc, entry) => { - if (isNavGroup(entry)) { - entry.children.forEach(child => { - acc[child.path] = child.name; - }); +/** First path segment of every built-in page, so a contributed page cannot shadow one. */ +const builtInSegments = new Set( + navConfig + .flatMap(entry => (isNavGroup(entry) ? entry.children : [entry])) + .map(entry => entry.path.slice(AdminDashboardRoutes.MAIN.length + 1).split('/')[0]) +); + +/** + * Contributed paths come from a consumer, so they are normalized before anything derives a route or a + * nav link from them. A leading slash would leave the shadowing check below inspecting an empty first + * segment - so '/customers' would pass it and sit in the nav next to the built-in page it names - and + * createRoute would join it into '/admin-dashboard//customers'. + */ +const normalizePagePath = (path: string) => path.replace(/^\/+/, '').replace(/\/+$/, ''); + +const toRouteEntry = (page: AdminPage): RouteEntry => ({ + path: createRoute([AdminDashboardRoutes.ROOT, page.path]), + name: page.name, + icon: page.icon, + description: page.description +}); + +/** Appends contributed pages, merging each category into a group of that name if one already exists. */ +function withContributedPages(pages: AdminPage[]): NavEntry[] { + const entries = navConfig.map(entry => (isNavGroup(entry) ? { ...entry, children: [...entry.children] } : entry)); + for (const page of pages) { + const entry = toRouteEntry(page); + if (!page.category) { + entries.push(entry); + continue; + } + const group = entries.find((e): e is NavGroup => isNavGroup(e) && e.name === page.category!.name); + if (group) { + group.children.push(entry); } else { - acc[entry.path] = entry.name; + entries.push({ name: page.category.name, icon: page.category.icon, children: [entry] }); } - return acc; - }, {}) -}; + } + return entries; +} + +function buildRouteNames(items: NavEntry[]): { [key: string]: string } { + return { + [AdminDashboardRoutes.MAIN]: 'Admin Dashboard', + ...items.reduce<{ [key: string]: string }>((acc, entry) => { + if (isNavGroup(entry)) { + entry.children.forEach(child => { + acc[child.path] = child.name; + }); + } else { + acc[entry.path] = entry.name; + } + return acc; + }, {}) + }; +} const ScrollableContent = styled(Box)(({ theme }) => ({ flex: 1, @@ -164,7 +208,18 @@ const Message: FunctionComponent<{ message: string }> = ({ message }) => { }; export const AdminDashboard: FunctionComponent = props => { - const { user, loginProviders } = useContext(MainContext); + const { user, loginProviders, pageSettings } = useContext(MainContext); + + const adminPages = pageSettings.elements.adminPages; + const contributed = useMemo( + () => + (adminPages ?? []) + .map(page => ({ ...page, path: normalizePagePath(page.path) })) + .filter(page => page.path.length > 0 && !builtInSegments.has(page.path.split('/')[0])), + [adminPages] + ); + const navItems = useMemo(() => withContributedPages(contributed), [contributed]); + const routeNames = useMemo(() => buildRouteNames(navItems), [navItems]); const navigate = useNavigate(); const toMainPage = () => navigate('/'); @@ -174,7 +229,7 @@ export const AdminDashboard: FunctionComponent = props => { content = ( - + @@ -197,7 +252,11 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> - } /> + {/* Splat so a contributed page can render nested routes; it also matches the bare path. */} + {contributed.map(page => ( + + ))} + } /> diff --git a/webui/src/pages/admin-dashboard/nav-types.ts b/webui/src/pages/admin-dashboard/nav-types.ts index 1f7ea1b6c..ebb02f174 100644 --- a/webui/src/pages/admin-dashboard/nav-types.ts +++ b/webui/src/pages/admin-dashboard/nav-types.ts @@ -29,3 +29,35 @@ export interface NavGroup { export type NavEntry = RouteEntry | NavGroup; export const isNavGroup = (entry: NavEntry): entry is NavGroup => 'children' in entry; + +/** Side panel and overview grouping for contributed admin pages. */ +export interface AdminPageCategory { + name: string; + icon: ReactNode; +} + +/** + * An admin dashboard page contributed by a consumer through + * `PageSettings.elements.adminPages`. It shows up in the side panel, as a card on the + * dashboard overview, and as a route under the admin dashboard. + */ +export interface AdminPage { + /** + * Path below the admin dashboard root, without a leading slash (e.g. `'analytics/agents'`). + * The page also receives everything below it, so it may render nested routes of its own. + * A page whose first segment is one the built-in pages already use is ignored — contributed + * pages can only be added, never override a built-in one. + */ + path: string; + name: string; + icon: ReactNode; + /** Shown under the page name on the dashboard overview. */ + description?: string; + /** + * Groups the page in the side panel and on the overview. Pages sharing a category name are + * merged into one group, and a name matching a built-in group appends to that group. + * Without a category the page sits at the top level. + */ + category?: AdminPageCategory; + element: ReactNode; +} diff --git a/webui/src/pages/extension-detail/extension-detail.tsx b/webui/src/pages/extension-detail/extension-detail.tsx index 41155370c..bd6d06587 100644 --- a/webui/src/pages/extension-detail/extension-detail.tsx +++ b/webui/src/pages/extension-detail/extension-detail.tsx @@ -43,7 +43,7 @@ import { useExtensionDetail } from './use-extension-details'; import { KbdKey } from '../../components/kbd-key'; import { useShortcut } from '../../hooks/use-shortcut'; import { NAVBAR_HEIGHT, NAVBAR_HEIGHT_PX } from '../../default/theme'; -import { useSetExtensionTint } from '../../context/extension-tint-context'; +import { useSetExtensionTint } from '../../context/navbar-chrome-context'; import { PageContainer } from '../../components/page-container'; import { PillTab, PillTabs } from '../../components/pill-tabs'; import SaveAltIcon from '@mui/icons-material/SaveAlt'; @@ -406,7 +406,7 @@ export const ExtensionDetail: FunctionComponent = () => { const effectiveVersion = isTabSegment(version) ? undefined : version; const activeTab = parseTab(version); - // Tab switches preserve scroll (see ScrollToTop); when scrolled deep, glide + // Tab switches preserve scroll (see ScrollRestoration); when scrolled deep, glide // up so the new panel starts under the pinned pills. const bandRef = useRef(null); const prevTab = useRef(activeTab); diff --git a/webui/src/pages/search/search-header.tsx b/webui/src/pages/search/search-header.tsx index 2aa5de40d..4434b44b2 100644 --- a/webui/src/pages/search/search-header.tsx +++ b/webui/src/pages/search/search-header.tsx @@ -16,6 +16,7 @@ import { Box, IconButton, Select, MenuItem, Typography, SelectChangeEvent } from import { SortBy, SortOrder } from '../../extension-registry-types'; import { ExtensionCategory } from '../../extension-registry-types'; import { Theme } from '@mui/material/styles'; +import { compactControl } from '../../components/page-primitives'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; @@ -81,16 +82,7 @@ export const SearchHeader: FunctionComponent = props => { onChange={handleSortByChange} size='small' inputProps={{ 'aria-label': 'Sort by' }} - sx={{ - fontSize: '0.8125rem', - fontWeight: 500, - color: 'text.primary', - height: '1.875rem', - bgcolor: 'background.paper', - borderRadius: 1, - '& .MuiSelect-select': { py: '0.25rem', pl: '0.625rem' }, - '& .MuiSelect-icon': { color: 'text.disabled', fontSize: '1.125rem' } - }}> + sx={compactControl}> Relevance Date Downloads diff --git a/webui/src/pages/user/avatar.tsx b/webui/src/pages/user/avatar.tsx index 5a2c8fe18..334f9dc43 100644 --- a/webui/src/pages/user/avatar.tsx +++ b/webui/src/pages/user/avatar.tsx @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { FunctionComponent, useContext, useRef, useState } from 'react'; +import { ComponentType, FunctionComponent, useCallback, useContext, useMemo, useRef, useState } from 'react'; import { Avatar, Box, IconButton, Link, Menu, MenuItem, Typography } from '@mui/material'; import { Link as RouteLink } from 'react-router'; import SettingsIcon from '@mui/icons-material/Settings'; @@ -17,6 +17,7 @@ import LogoutIcon from '@mui/icons-material/Logout'; import { UserSettingsRoutes } from './user-settings-routes'; import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard-routes'; import { MainContext } from '../../context'; +import { UserMenuEntryProps } from '../../page-settings'; import { LogoutForm } from './logout'; // Radius, font and min-height come from the MuiMenuItem theme override. @@ -33,7 +34,21 @@ const iconSx = { fontSize: '1.0625rem', color: 'text.disabled', flexShrink: 0 }; export const UserAvatar: FunctionComponent = () => { const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + // Stable identity: a new component type each render would remount contributed entries, + // refetching whatever they load. + const MenuEntry = useMemo>(() => { + const Entry = ({ to, icon: Icon, children }: UserMenuEntryProps) => ( + + + {children} + + ); + Entry.displayName = 'UserMenuEntry'; + return Entry; + }, [close]); const context = useContext(MainContext); + const { userMenuContent: UserMenuContent } = context.pageSettings.elements; const anchorRef = useRef(null); const logoutFormRef = useRef(null); @@ -123,6 +138,7 @@ export const UserAvatar: FunctionComponent = () => { Settings + {UserMenuContent ? : null} {user.role === 'admin' && ( '/logout', + getCsrfToken: vi.fn().mockResolvedValue({ value: 'csrf' }) +} as unknown as ExtensionRegistryService; + +// Records the sx the host hands the icon, which is how we assert the mobile menu — not the +// consumer — owns the entry's presentation. +let iconSx: SxProps | undefined; +const ProbeIcon: FunctionComponent<{ sx?: SxProps }> = ({ sx }) => { + iconSx = sx; + return ; +}; + +const ConsumerEntry: FunctionComponent = ({ MenuEntry }) => ( + + Analytics + +); + +function renderAvatar(userMenuContent?: PageSettings['elements']['userMenuContent']) { + return renderWithProviders(, { + mainContext: { service, user: admin, pageSettings: { elements: { userMenuContent } } as PageSettings } + }); +} + +/** The avatar itself is a menu entry; opening its menu adds the rest. */ +async function openMenu(): Promise { + await userEvent.click(screen.getByText(admin.loginName)); +} + +describe('MobileUserAvatar', () => { + it('renders the pageSettings userMenuContent entries above the admin entry', async () => { + renderAvatar(ConsumerEntry); + await openMenu(); + + expect(menuItemLabels()).toEqual([ + admin.loginName, + admin.loginName, + 'Settings', + 'Analytics', + 'Admin Dashboard', + 'Log Out' + ]); + }); + + it('closes the menu when a contributed entry is clicked', async () => { + renderAvatar(ConsumerEntry); + await openMenu(); + + await userEvent.click(screen.getByText('Analytics')); + + await waitFor(() => expect(menuItemLabels()).toEqual([admin.loginName])); + }); + + it('styles a contributed entry like its own', async () => { + iconSx = undefined; + renderAvatar(ConsumerEntry); + await openMenu(); + + // The mobile menu's own icon styling, which differs from the desktop menu's. + expect(iconSx).toEqual({ mr: 1, width: '16px', height: '16px' }); + }); + + it('renders only the built-in entries when no userMenuContent is configured', async () => { + renderAvatar(); + await openMenu(); + + expect(menuItemLabels()).toEqual([admin.loginName, admin.loginName, 'Settings', 'Admin Dashboard', 'Log Out']); + }); +}); diff --git a/webui/test/unit/main.spec.tsx b/webui/test/unit/main.spec.tsx new file mode 100644 index 000000000..cd5048c12 --- /dev/null +++ b/webui/test/unit/main.spec.tsx @@ -0,0 +1,67 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, useContext } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderInEntryShell } from './support/test-providers'; +import { Main } from '../../src/main'; +import { MainContext } from '../../src/context'; +import { PageSettings } from '../../src/page-settings'; +import { ErrorResult, UserData } from '../../src/extension-registry-types'; +import { ExtensionRegistryService } from '../../src/extension-registry-service'; + +// Stands in for a consumer page: the only way to observe what the context carries. +const ContextProbe: FunctionComponent = () => { + const { userLoading, user } = useContext(MainContext); + return
{`loading=${userLoading} user=${user?.loginName ?? 'none'}`}
; +}; + +const pageSettings = { + pageTitle: 'test', + // A custom home component replaces the built-in landing page, so the probe is what '/' renders. + elements: { home: ContextProbe }, + urls: { extensionDefaultIcon: '', namespaceAccessInfo: '' } +} as PageSettings; + +function renderMain() { + let resolveUser: (result: UserData | ErrorResult) => void = () => {}; + const service = { + getUser: vi.fn(() => new Promise(resolve => (resolveUser = resolve))), + getRegistryVersion: vi.fn().mockResolvedValue({ version: '1.0.0' }) + } as unknown as ExtensionRegistryService; + // `loginProviders` supplied so Main skips its own login-providers fetch. + renderInEntryShell(
); + return { resolveUser: (result: UserData | ErrorResult) => resolveUser(result) }; +} + +describe('Main', () => { + it('exposes userLoading on MainContext while the initial user fetch is in flight', async () => { + const { resolveUser } = renderMain(); + + expect(screen.getByTestId('probe')).toHaveTextContent('loading=true user=none'); + + resolveUser({ loginName: 'testuser', tokensUrl: '', createTokenUrl: '' }); + + await waitFor(() => expect(screen.getByTestId('probe')).toHaveTextContent('loading=false user=testuser')); + }); + + it('clears userLoading when the user turns out not to be logged in', async () => { + const { resolveUser } = renderMain(); + + // An error result with HTTP OK is how the server reports "not logged in". + resolveUser({ error: 'Not logged in' }); + + await waitFor(() => expect(screen.getByTestId('probe')).toHaveTextContent('loading=false user=none')); + }); +}); diff --git a/webui/test/unit/pages/admin-dashboard/admin-dashboard.spec.tsx b/webui/test/unit/pages/admin-dashboard/admin-dashboard.spec.tsx new file mode 100644 index 000000000..795f64d6a --- /dev/null +++ b/webui/test/unit/pages/admin-dashboard/admin-dashboard.spec.tsx @@ -0,0 +1,96 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { describe, expect, it } from 'vitest'; +import { screen } from '@testing-library/react'; +import { AdminDashboard } from '../../../../src/pages/admin-dashboard/admin-dashboard'; +import { AdminPage } from '../../../../src/pages/admin-dashboard/nav-types'; +import { MainContext } from '../../../../src/context'; +import { PageSettings } from '../../../../src/page-settings'; +import { UserData } from '../../../../src/extension-registry-types'; +import { renderWithProviders } from '../../support/test-providers'; + +const admin = { loginName: 'root', role: 'admin' } as UserData; + +const agentsPage: AdminPage = { + path: 'analytics/agents', + name: 'Agents', + icon: , + description: 'Link customers to analytics user agents', + element:
contributed page body
+}; + +/** + * `AdminDashboard` owns a nested `Routes`, and in the app it is mounted under + * `/admin-dashboard/*` so those paths resolve relative to that match. Rendering it + * directly means the same route table resolves from the root instead, so a route here + * is the contributed `path` without the dashboard prefix. + */ +function renderDashboard(adminPages: AdminPage[] | undefined, route = '/') { + const pageSettings = { elements: { adminPages } } as PageSettings; + const mainContext: Partial = { user: admin, pageSettings }; + return renderWithProviders(, { route, mainContext }); +} + +describe('AdminDashboard contributed pages', () => { + it('shows a contributed page as an overview card with its description', () => { + renderDashboard([agentsPage]); + + expect(screen.getByText('Link customers to analytics user agents')).toBeInTheDocument(); + // Once in the side panel, once as the overview card. + expect(screen.getAllByText('Agents')).toHaveLength(2); + }); + + it('renders a contributed page at its own route', () => { + renderDashboard([agentsPage], '/analytics/agents'); + + expect(screen.getByText('contributed page body')).toBeInTheDocument(); + // The overview fallback must not also match. + expect(screen.queryByText('Welcome to the Admin Dashboard')).not.toBeInTheDocument(); + }); + + it('merges a category into the built-in group of the same name instead of adding a second one', () => { + const { unmount } = renderDashboard(undefined); + const withoutContribution = screen.getAllByText('Rate Limiting').length; + unmount(); + + renderDashboard([{ ...agentsPage, category: { name: 'Rate Limiting', icon: } }]); + + expect(screen.getAllByText('Rate Limiting')).toHaveLength(withoutContribution); + expect(screen.getByText('Link customers to analytics user agents')).toBeInTheDocument(); + }); + + it('ignores a contributed page that would shadow a built-in one', () => { + renderDashboard([{ ...agentsPage, path: 'customers', name: 'Not Customers' }]); + + expect(screen.queryByText('Not Customers')).not.toBeInTheDocument(); + expect(screen.queryByText('Link customers to analytics user agents')).not.toBeInTheDocument(); + // The built-in entry is untouched. + expect(screen.getAllByText('Customers').length).toBeGreaterThan(0); + }); + + // adminPages is consumer-provided, and a leading slash used to leave the shadowing check looking + // at an empty first segment, so this slipped past it and sat in the nav beside the built-in page. + it('ignores a shadowing page however its path is written', () => { + renderDashboard([{ ...agentsPage, path: '/customers/', name: 'Not Customers' }]); + + expect(screen.queryByText('Not Customers')).not.toBeInTheDocument(); + expect(screen.getAllByText('Customers').length).toBeGreaterThan(0); + }); + + it('ignores a contributed page with an empty path', () => { + renderDashboard([{ ...agentsPage, path: '/', name: 'Rootless' }]); + + expect(screen.queryByText('Rootless')).not.toBeInTheDocument(); + }); +}); diff --git a/webui/test/unit/pages/user/avatar.spec.tsx b/webui/test/unit/pages/user/avatar.spec.tsx new file mode 100644 index 000000000..7d0a149e9 --- /dev/null +++ b/webui/test/unit/pages/user/avatar.spec.tsx @@ -0,0 +1,93 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SxProps, Theme } from '@mui/material/styles'; +import { menuItemLabels } from '../../support/menu-queries'; +import { renderWithProviders } from '../../support/test-providers'; +import { UserAvatar } from '../../../../src/pages/user/avatar'; +import { PageSettings, UserMenuContentProps } from '../../../../src/page-settings'; +import { UserData } from '../../../../src/extension-registry-types'; +import { ExtensionRegistryService } from '../../../../src/extension-registry-service'; + +const admin: UserData = { loginName: 'testuser', tokensUrl: '', createTokenUrl: '', role: 'admin' }; + +// The logout entry wraps a real form that reads both of these while rendering. +const service = { + getLogoutUrl: () => '/logout', + getCsrfToken: vi.fn().mockResolvedValue({ value: 'csrf' }) +} as unknown as ExtensionRegistryService; + +// Records the sx the host hands the icon, which is how we assert the desktop menu — not the +// consumer — owns the entry's presentation. +let iconSx: SxProps | undefined; +const ProbeIcon: FunctionComponent<{ sx?: SxProps }> = ({ sx }) => { + iconSx = sx; + return ; +}; + +const ConsumerEntry: FunctionComponent = ({ MenuEntry }) => ( + + Analytics + +); + +function renderAvatar(userMenuContent?: PageSettings['elements']['userMenuContent']) { + return renderWithProviders(, { + mainContext: { service, user: admin, pageSettings: { elements: { userMenuContent } } as PageSettings } + }); +} + +async function openMenu(): Promise { + await userEvent.click(screen.getByRole('button', { name: 'User menu' })); +} + +describe('UserAvatar', () => { + it('renders the pageSettings userMenuContent entries above the admin entry', async () => { + renderAvatar(ConsumerEntry); + await openMenu(); + + expect(menuItemLabels()).toEqual(['Settings', 'Analytics', 'Admin Dashboard', 'Log out']); + }); + + it('closes the menu when a contributed entry is clicked', async () => { + renderAvatar(ConsumerEntry); + await openMenu(); + + await userEvent.click(screen.getByText('Analytics')); + + await waitFor(() => expect(menuItemLabels()).toEqual([])); + }); + + it('styles a contributed entry like its own, and links it to the given route', async () => { + iconSx = undefined; + renderAvatar(ConsumerEntry); + await openMenu(); + + // The desktop menu's own icon styling, not anything the consumer chose. + expect(iconSx).toEqual({ fontSize: '1.0625rem', color: 'text.disabled', flexShrink: 0 }); + // The label is the link's only text, so this is the anchor itself (role queries over + // a MUI menu crash under jsdom 30). + expect(screen.getByText('Analytics')).toHaveAttribute('href', '/analytics'); + }); + + it('renders only the built-in entries when no userMenuContent is configured', async () => { + renderAvatar(); + await openMenu(); + + expect(menuItemLabels()).toEqual(['Settings', 'Admin Dashboard', 'Log out']); + }); +}); diff --git a/webui/test/unit/support/menu-queries.ts b/webui/test/unit/support/menu-queries.ts new file mode 100644 index 000000000..ffd34a496 --- /dev/null +++ b/webui/test/unit/support/menu-queries.ts @@ -0,0 +1,22 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +/** + * Labels of the currently rendered menu entries, in DOM order. A plain selector rather + * than `getAllByRole`: jsdom 30.0.0 throws on the second `getComputedStyle` of an element + * carrying a percentage `calc()` — MUI's menu paper has one — and role queries compute + * styles per candidate to filter out inaccessible nodes. + */ +export function menuItemLabels(): string[] { + return Array.from(document.querySelectorAll('[role="menuitem"]')).map(item => item.textContent ?? ''); +} diff --git a/webui/test/unit/support/test-providers.tsx b/webui/test/unit/support/test-providers.tsx index 97c70ebbc..80155fb21 100644 --- a/webui/test/unit/support/test-providers.tsx +++ b/webui/test/unit/support/test-providers.tsx @@ -66,11 +66,25 @@ function mainContextValue(overrides?: Partial): MainContext { // well-typed rather than relying on `as PageSettings` to paper over a missing required field. pageSettings: { elements: {} } as PageSettings, handleError: () => {}, + userLoading: false, updateUser: () => {}, ...overrides }; } +/** + * Only the entry-shell providers the library expects from whoever mounts it (theme, + * router). Use it for components that mount `AppProviders` themselves — `Main` above + * all; everything else wants `TestProviders`. + */ +export function TestEntryShell({ children, route = '/' }: { children: ReactNode; route?: string }) { + return ( + + {children} + + ); +} + export function TestProviders({ children, route = '/', @@ -78,18 +92,28 @@ export function TestProviders({ mainContext }: ProviderOptions & { children: ReactNode }) { return ( - - - - {children} - - - + + + {children} + + ); } +/** `render` with only the entry shell — the counterpart of {@link TestEntryShell}. */ +export function renderInEntryShell( + ui: ReactElement, + options: { route?: string } & Omit = {} +) { + const { route, ...rtl } = options; + return render(ui, { + wrapper: ({ children }) => {children}, + ...rtl + }); +} + /** `render` with the app providers around it. Extra RTL options pass through. */ export function renderWithProviders(ui: ReactElement, options: ProviderOptions & Omit = {}) { const { route, queryClient, mainContext, ...rtl } = options; diff --git a/webui/vite.config.mts b/webui/vite.config.mts index 635752247..001fdf72f 100644 --- a/webui/vite.config.mts +++ b/webui/vite.config.mts @@ -29,7 +29,13 @@ export default defineConfig(() => ({ testTimeout: 15000, // Spawning a fork per CPU all at once (each booting jsdom + React) can itself blow past // vitest's own worker-startup timeout under load; capping concurrency avoids that pileup. - maxWorkers: '50%' + maxWorkers: '50%', + server: { + deps: { + // their ESM builds import directory paths Node's resolver rejects; let vite bundle them + inline: ['@mui/x-charts', '@mui/x-data-grid', '@mui/x-date-pickers'] + } + } }, // lightningcss (Vite 8's default CSS transformer) ships no prebuilt binary for ppc64le, // and its minifier isn't needed once postcss is handling transforms - see