From 5f29808f93993401e3e5b95894163aec5ff34fe3 Mon Sep 17 00:00:00 2001 From: Christopher Hakkaart Date: Thu, 26 Feb 2026 16:51:23 +1300 Subject: [PATCH 1/6] Stable search page and results --- docusaurus.config.js | 7 +- src/theme/SearchPage/generalUtils.js | 23 ++ src/theme/SearchPage/index.js | 374 +++++++++++++++++++++++++ src/theme/SearchPage/styles.module.css | 132 +++++++++ src/theme/SearchPage/useSearchPage.js | 51 ++++ 5 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 src/theme/SearchPage/generalUtils.js create mode 100644 src/theme/SearchPage/index.js create mode 100644 src/theme/SearchPage/styles.module.css create mode 100644 src/theme/SearchPage/useSearchPage.js diff --git a/docusaurus.config.js b/docusaurus.config.js index ef6c7c735..d1eacf858 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -295,13 +295,14 @@ export default async function createConfigAsync() { }, ], apiKey: process.env.TYPESENSE_SEARCH_API_KEY, - connectionTimeoutSeconds: 2, + connectionTimeoutSeconds: 5, // Default value }, typesenseSearchParameters: { query_by: 'content,hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3', - group_by: 'url_without_anchor', + group_by: 'url', group_limit: 1, - num_typos: 1, + per_page: 20, + num_typos: 2, prioritize_exact_match: true, filter_by: 'docusaurus_tag:!=[default,doc_tag_doc_list,blog_posts_list,blog_tags_posts,doc_tags_list,blog_tags_list]', // TODO Remove once the scraper is updated }, diff --git a/src/theme/SearchPage/generalUtils.js b/src/theme/SearchPage/generalUtils.js new file mode 100644 index 000000000..adb498600 --- /dev/null +++ b/src/theme/SearchPage/generalUtils.js @@ -0,0 +1,23 @@ +"use strict"; +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.useTitleFormatter = useTitleFormatter; +const tslib_1 = require("tslib"); +// Source: https://github.com/facebook/docusaurus/blob/a308fb7c81832cca354192fe2984f52749441249/packages/docusaurus-theme-common/src/utils/generalUtils.ts +// Context: https://github.com/typesense/docusaurus-theme-search-typesense/issues/27#issuecomment-1415757477 +const useDocusaurusContext_1 = tslib_1.__importDefault(require("@docusaurus/useDocusaurusContext")); +/** + * Formats the page's title based on relevant site config and other contexts. + */ +function useTitleFormatter(title) { + const { siteConfig } = (0, useDocusaurusContext_1.default)(); + const { title: siteTitle, titleDelimiter } = siteConfig; + return title?.trim().length + ? `${title.trim()} ${titleDelimiter} ${siteTitle}` + : siteTitle; +} diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js new file mode 100644 index 000000000..ea291f043 --- /dev/null +++ b/src/theme/SearchPage/index.js @@ -0,0 +1,374 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Swizzled from docusaurus-theme-search-typesense to remove hardcoded + * group_by: 'url' so search page results match the search bar. + */ +/* eslint-disable jsx-a11y/no-autofocus */ +import React, { useEffect, useMemo, useState, useReducer, useRef } from 'react'; +import clsx from 'clsx'; +import algoliaSearchHelper from 'algoliasearch-helper'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import { HtmlClassNameProvider, usePluralForm, isRegexpStringMatch, useEvent, +// @ts-ignore + } from '@docusaurus/theme-common'; +import { useSearchPage } from './useSearchPage'; +// @ts-ignore +import { useTitleFormatter } from './generalUtils'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { useAllDocsData } from '@docusaurus/plugin-content-docs/client'; +import Translate, { translate } from '@docusaurus/Translate'; +import Layout from '@theme/Layout'; +import styles from './styles.module.css'; +import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter'; +// Very simple pluralization: probably good enough for now +function useDocumentsFoundPlural() { + const { selectMessage } = usePluralForm(); + return (count) => selectMessage(count, translate({ + id: 'theme.SearchPage.documentsFound.plurals', + description: 'Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)', + message: 'One document found|{count} documents found', + }, { count })); +} +function useDocsSearchVersionsHelpers() { + const allDocsData = useAllDocsData(); + // State of the version select menus / algolia facet filters + // docsPluginId -> versionName map + const [searchVersions, setSearchVersions] = useState(() => Object.entries(allDocsData).reduce((acc, [pluginId, pluginData]) => ({ + ...acc, + [pluginId]: pluginData.versions[0].name, + }), {})); + // Set the value of a single select menu + const setSearchVersion = (pluginId, searchVersion) => setSearchVersions((s) => ({ ...s, [pluginId]: searchVersion })); + const versioningEnabled = Object.values(allDocsData).some((docsData) => docsData.versions.length > 1); + return { + allDocsData, + versioningEnabled, + searchVersions, + setSearchVersion, + }; +} +// We want to display one select per versioned docs plugin instance +function SearchVersionSelectList({ docsSearchVersionsHelpers, }) { + const versionedPluginEntries = Object.entries(docsSearchVersionsHelpers.allDocsData) + // Do not show a version select for unversioned docs plugin instances + .filter(([, docsData]) => docsData.versions.length > 1); + return (
+ {versionedPluginEntries.map(([pluginId, docsData]) => { + const labelPrefix = versionedPluginEntries.length > 1 ? `${pluginId}: ` : ''; + return (); + })} +
); +} +function SearchPageContent() { + const { siteConfig: { themeConfig }, i18n: { currentLocale }, } = useDocusaurusContext(); + const { typesense: { typesenseCollectionName, typesenseServerConfig, typesenseSearchParameters, contextualSearch, externalUrlRegex, }, } = themeConfig; + const documentsFoundPlural = useDocumentsFoundPlural(); + const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers(); + const { searchQuery, setSearchQuery } = useSearchPage(); + // inputValue tracks the live input; searchQuery only updates on submit + const [inputValue, setInputValue] = useState(searchQuery); + // Sync inputValue when searchQuery is populated from the URL on hydration + useEffect(() => { + setInputValue(searchQuery); + }, [searchQuery]); + const initialSearchResultState = { + items: [], + query: null, + totalResults: null, + totalPages: null, + lastPage: null, + hasMore: null, + loading: null, + }; + const [searchResultState, searchResultStateDispatcher] = useReducer((prevState, data) => { + switch (data.type) { + case 'reset': { + return initialSearchResultState; + } + case 'loading': { + return { ...prevState, loading: true }; + } + case 'update': { + if (searchQuery !== data.value.query) { + return prevState; + } + return { + ...data.value, + items: data.value.lastPage === 0 + ? data.value.items + : prevState.items.concat(data.value.items), + }; + } + case 'advance': { + const hasMore = prevState.totalPages > prevState.lastPage + 1; + return { + ...prevState, + lastPage: hasMore ? prevState.lastPage + 1 : prevState.lastPage, + hasMore, + }; + } + default: + return prevState; + } + }, initialSearchResultState); + // Memoize the adapter and helper so they're only created once, not on every render. + // Creating a new TypesenseInstantSearchAdapter on every render causes repeated + // network activity and accumulates stale event listeners. + // eslint-disable-next-line react-hooks/exhaustive-deps + const typesenseInstantSearchAdapter = useMemo(() => new TypesenseInstantSearchAdapter({ + server: typesenseServerConfig, + additionalSearchParameters: { + // Defaults matching typesense-docsearch-react (SearchBar) behaviour + query_by: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content', + include_fields: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content,anchor,url,type,id', + highlight_full_fields: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content', + group_by: 'url', + group_limit: 1, + sort_by: 'item_priority:desc', + snippet_threshold: 8, + highlight_affix_num_tokens: 4, + ...typesenseSearchParameters, + }, + }), []); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps + const algoliaHelper = useMemo(() => algoliaSearchHelper( + typesenseInstantSearchAdapter.searchClient, + typesenseCollectionName, + { + hitsPerPage: typesenseSearchParameters.per_page ?? 20, + advancedSyntax: true, + ...(contextualSearch && { disjunctiveFacets: ['language', 'docusaurus_tag'] }), + } + ), []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + const sanitizeValue = (value) => value.replace(/algolia-docsearch-suggestion--highlight/g, 'search-result-match'); + function handleResult({ results: { query, hits, page, nbHits, nbPages } }) { + if (query === '' || !Array.isArray(hits)) { + searchResultStateDispatcher({ type: 'reset' }); + return; + } + const items = hits.map(({ url, _highlightResult, _snippetResult: snippet = {}, }) => { + const parsedURL = new URL(url); + const titles = [0, 1, 2, 3, 4, 5, 6] + .map((lvl) => { + const highlightResult = _highlightResult[`hierarchy.lvl${lvl}`]; + return highlightResult + ? sanitizeValue(highlightResult.value) + : ''; + }) + .filter((v) => v); + return { + title: titles.pop(), + url: isRegexpStringMatch(externalUrlRegex, parsedURL.href) + ? parsedURL.href + : parsedURL.pathname + parsedURL.hash, + summary: snippet.content + ? `${sanitizeValue(snippet.content.value)}...` + : '', + breadcrumbs: titles, + }; + }); + searchResultStateDispatcher({ + type: 'update', + value: { + items, + query, + totalResults: nbHits, + totalPages: nbPages, + lastPage: page, + hasMore: nbPages > page + 1, + loading: false, + }, + }); + } + function handleError(e) { + console.error(e); + } + algoliaHelper.on('result', handleResult); + algoliaHelper.on('error', handleError); + return () => { + algoliaHelper.removeAllListeners('result'); + algoliaHelper.removeAllListeners('error'); + }; + }, [algoliaHelper]); // algoliaHelper is stable (useMemo with []), so this runs once + const [loaderRef, setLoaderRef] = useState(null); + const prevY = useRef(0); + const observer = useRef(ExecutionEnvironment.canUseIntersectionObserver && + new IntersectionObserver((entries) => { + const { isIntersecting, boundingClientRect: { y: currentY }, } = entries[0]; + if (isIntersecting && prevY.current > currentY) { + searchResultStateDispatcher({ type: 'advance' }); + } + prevY.current = currentY; + }, { threshold: 1 })); + const getTitle = () => searchQuery + ? translate({ + id: 'theme.SearchPage.existingResultsTitle', + message: 'Search results for "{query}"', + description: 'The search page title for non-empty query', + }, { + query: searchQuery, + }) + : translate({ + id: 'theme.SearchPage.emptyResultsTitle', + message: 'Search the documentation', + description: 'The search page title for empty query', + }); + const makeSearch = useEvent((page = 0) => { + if (contextualSearch) { + algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default'); + algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale); + Object.entries(docsSearchVersionsHelpers.searchVersions).forEach(([pluginId, searchVersion]) => { + algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', `docs-${pluginId}-${searchVersion}`); + }); + } + algoliaHelper.setQuery(searchQuery).setPage(page).search(); + }); + useEffect(() => { + if (!loaderRef) { + return undefined; + } + const currentObserver = observer.current; + if (currentObserver) { + currentObserver.observe(loaderRef); + return () => currentObserver.unobserve(loaderRef); + } + return () => true; + }, [loaderRef]); + useEffect(() => { + searchResultStateDispatcher({ type: 'reset' }); + if (searchQuery) { + searchResultStateDispatcher({ type: 'loading' }); + makeSearch(); + } + }, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch]); + useEffect(() => { + if (!searchResultState.lastPage || searchResultState.lastPage === 0) { + return; + } + makeSearch(searchResultState.lastPage); + }, [makeSearch, searchResultState.lastPage]); + return ( + + {useTitleFormatter(getTitle())} + {/* + We should not index search pages + See https://github.com/facebook/docusaurus/pull/3233 + */} + + + +
+

{getTitle()}

+ +
{ e.preventDefault(); setSearchQuery(inputValue); }}> +
+
+ setInputValue(e.target.value)} value={inputValue} autoComplete="off" autoFocus/> + +
+
+
+ +
+
+ {!!searchResultState.totalResults && + documentsFoundPlural(searchResultState.totalResults)} +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + {searchResultState.items.length > 0 ? (
+ {searchResultState.items.map(({ title, url, summary, breadcrumbs }, i) => (
+

+ +

+ + {breadcrumbs.length > 0 && ()} + + {summary && (

)} +

))} +
) : ([ + searchQuery && !searchResultState.loading && (

+ + No results were found + +

), + !!searchResultState.loading && (
), + ])} + + {searchResultState.hasMore && (
+ + Fetching new results... + +
)} +
+ ); +} +export default function SearchPage() { + return ( + + ); +} diff --git a/src/theme/SearchPage/styles.module.css b/src/theme/SearchPage/styles.module.css new file mode 100644 index 000000000..db0175bdc --- /dev/null +++ b/src/theme/SearchPage/styles.module.css @@ -0,0 +1,132 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.searchQueryInput, +.searchVersionInput { + border-radius: var(--ifm-global-radius); + border: 2px solid var(--ifm-toc-border-color); + font: var(--ifm-font-size-base) var(--ifm-font-family-base); + padding: 0.8rem; + width: 100%; + background: var(--docsearch-searchbox-focus-background); + color: var(--docsearch-text-color); + margin-bottom: 0.5rem; + transition: border var(--ifm-transition-fast) ease; +} + +.searchQueryInput:focus, +.searchVersionInput:focus { + border-color: var(--docsearch-primary-color); + outline: none; +} + +.searchQueryInput::placeholder { + color: var(--docsearch-muted-color); +} + +.searchInputRow { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.searchInputRow .searchQueryInput { + flex: 1; + margin-bottom: 0; +} + +.searchQueryButton { + padding: 0.8rem 1.6rem; + border-radius: var(--ifm-global-radius); + border: 2px solid var(--ifm-color-primary); + background: var(--ifm-color-primary); + color: var(--ifm-color-white); + font: var(--ifm-font-size-base) var(--ifm-font-family-base); + cursor: pointer; + white-space: nowrap; + transition: background var(--ifm-transition-fast) ease, border-color var(--ifm-transition-fast) ease; +} + +.searchQueryButton:hover { + background: var(--ifm-color-primary-dark); + border-color: var(--ifm-color-primary-dark); +} + +.searchResultsColumn { + font-size: 0.9rem; + font-weight: bold; +} + +.algoliaLogo { + max-width: 150px; +} + +.algoliaLogoPathFill { + fill: var(--ifm-font-color-base); +} + +.searchResultItem { + padding: 1rem 0; + border-bottom: 1px solid var(--ifm-toc-border-color); +} + +.searchResultItemHeading { + font-weight: 400; + margin-bottom: 0; +} + +.searchResultItemPath { + font-size: 0.8rem; + color: var(--ifm-color-content-secondary); + --ifm-breadcrumb-separator-size-multiplier: 1; +} + +.searchResultItemSummary { + margin: 0.5rem 0 0; + font-style: italic; +} + +@media only screen and (max-width: 996px) { + .searchResultsColumn { + max-width: 60% !important; + } + + .searchLogoColumn { + max-width: 40% !important; + padding-left: 0 !important; + } +} + +.loadingSpinner { + width: 3rem; + height: 3rem; + border: 0.4em solid #eee; + border-top-color: var(--ifm-color-primary); + border-radius: 50%; + animation: loading-spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes loading-spin { + 100% { + transform: rotate(360deg); + } +} + +.loader { + margin-top: 2rem; +} + +:global(.search-page-wrapper .theme-layout-main) { + width: 100%; +} + +:global(.search-result-match) { + color: var(--docsearch-hit-color); + background: rgb(255 215 142 / 25%); + padding: 0.09em 0; +} diff --git a/src/theme/SearchPage/useSearchPage.js b/src/theme/SearchPage/useSearchPage.js new file mode 100644 index 000000000..abccd751d --- /dev/null +++ b/src/theme/SearchPage/useSearchPage.js @@ -0,0 +1,51 @@ +"use strict"; +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.useSearchPage = useSearchPage; +const tslib_1 = require("tslib"); +// Source: https://github.com/facebook/docusaurus/blob/a308fb7c81832cca354192fe2984f52749441249/packages/docusaurus-theme-common/src/hooks/useSearchPage.ts +// Context: https://github.com/typesense/docusaurus-theme-search-typesense/issues/27#issuecomment-1415757477 +const react_1 = require("react"); +const router_1 = require("@docusaurus/router"); +const useDocusaurusContext_1 = tslib_1.__importDefault(require("@docusaurus/useDocusaurusContext")); +const SEARCH_PARAM_QUERY = 'q'; +/** Some utility functions around search queries. */ +function useSearchPage() { + const history = (0, router_1.useHistory)(); + const { siteConfig: { baseUrl }, } = (0, useDocusaurusContext_1.default)(); + const [searchQuery, setSearchQueryState] = (0, react_1.useState)(''); + // Init search query just after React hydration + (0, react_1.useEffect)(() => { + const searchQueryStringValue = + // @ts-ignore + new URLSearchParams(window.location.search).get(SEARCH_PARAM_QUERY) ?? ''; + setSearchQueryState(searchQueryStringValue); + }, []); + const setSearchQuery = (0, react_1.useCallback)((newSearchQuery) => { + // @ts-ignore + const searchParams = new URLSearchParams(window.location.search); + if (newSearchQuery) { + searchParams.set(SEARCH_PARAM_QUERY, newSearchQuery); + } + else { + searchParams.delete(SEARCH_PARAM_QUERY); + } + history.replace({ + search: searchParams.toString(), + }); + setSearchQueryState(newSearchQuery); + }, [history]); + const generateSearchPageLink = (0, react_1.useCallback)((targetSearchQuery) => + // Refer to https://github.com/facebook/docusaurus/pull/2838 + `${baseUrl}search?${SEARCH_PARAM_QUERY}=${encodeURIComponent(targetSearchQuery)}`, [baseUrl]); + return { + searchQuery, + setSearchQuery, + generateSearchPageLink, + }; +} From 6756a4a78b4fed4a205c42b8365ed47f13a6fb8c Mon Sep 17 00:00:00 2001 From: Christopher Hakkaart Date: Fri, 27 Feb 2026 12:25:42 +1300 Subject: [PATCH 2/6] Search page update --- docusaurus.config.js | 11 +- src/theme/SearchPage/index.js | 334 ++++++++++++++++++++++--- src/theme/SearchPage/styles.module.css | 225 +++++++++++++++-- 3 files changed, 521 insertions(+), 49 deletions(-) diff --git a/docusaurus.config.js b/docusaurus.config.js index d1eacf858..a274ceff4 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -1,4 +1,5 @@ const path = require("path"); +const platformEnterpriseVersions = require("./platform-enterprise_versions.json"); import "dotenv/config"; import platform_enterprise_latest_version from "./platform-enterprise_latest_version.js"; import { @@ -7,6 +8,14 @@ import { getSeqeraPresetOptions } from "@seqera/docusaurus-preset-seqera"; +// Build the search filter_by dynamically so old platform-enterprise versions are +// excluded automatically whenever a new version is added to versions.json. +// versions.json is ordered newest-first; index 0 is the current/latest version. +const oldEnterpriseVersionTags = platformEnterpriseVersions + .slice(1) + .map((v) => `docs-platform-enterprise-${v}`); +const searchFilterBy = `docusaurus_tag:!=[default,doc_tag_doc_list,blog_posts_list,blog_tags_posts,doc_tags_list,blog_tags_list${oldEnterpriseVersionTags.length ? `,${oldEnterpriseVersionTags.join(",")}` : ""}]`; + export default async function createConfigAsync() { const changelog = { @@ -304,7 +313,7 @@ export default async function createConfigAsync() { per_page: 20, num_typos: 2, prioritize_exact_match: true, - filter_by: 'docusaurus_tag:!=[default,doc_tag_doc_list,blog_posts_list,blog_tags_posts,doc_tags_list,blog_tags_list]', // TODO Remove once the scraper is updated + filter_by: searchFilterBy, // Old platform-enterprise versions excluded automatically via searchFilterBy above }, contextualSearch: false, placeholder: 'Search Seqera docs...', diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js index ea291f043..7084115c5 100644 --- a/src/theme/SearchPage/index.js +++ b/src/theme/SearchPage/index.js @@ -26,6 +26,178 @@ import Translate, { translate } from '@docusaurus/Translate'; import Layout from '@theme/Layout'; import styles from './styles.module.css'; import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter'; +// Non-content docusaurus_tag values to always exclude from search results. +// Verified against live Typesense facets — blog/doc-list tags have zero documents +// and are kept here defensively in case content is re-indexed with those tags. +const NON_CONTENT_TAGS = ['default', 'doc_tag_doc_list', 'blog_posts_list', 'blog_tags_posts', 'doc_tags_list', 'blog_tags_list']; +// Maps URL path prefixes to [label, pluginId, customTag]. +// - pluginId: matches the Docusaurus plugin id in allDocsData (versioned/unversioned plugins) +// - customTag: explicit docusaurus_tag for products served via URL rewrite with no local plugin +// (e.g. Nextflow is an external site rewritten to /nextflow/, indexed as docs-default-current) +const PRODUCT_ROUTES = [ + ['/platform-enterprise/', 'Platform Enterprise', 'platform-enterprise', null], + ['/platform-cloud/', 'Platform Cloud', 'platform-cloud', null], + ['/platform-cli/', 'Platform CLI', 'platform-cli', null], + ['/platform-api/', 'Platform API', 'platform-api', null], + ['/nextflow/', 'Nextflow', null, 'docs-default-current'], + ['/multiqc/', 'MultiQC', 'multiqc', null], + ['/wave/', 'Wave', 'wave', null], + ['/fusion/', 'Fusion', 'fusion', null], + ['/changelog/', 'Changelog', null, null], +]; +function getProductLabel(pathname) { + const match = PRODUCT_ROUTES.find(([prefix]) => pathname.startsWith(prefix)); + return match ? match[1] : null; +} +// Custom dropdown for product/version filtering. +// A native