diff --git a/.gitignore b/.gitignore index dd90024f53..ebb4447229 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ # Generated files .docusaurus +.playwright-mcp +.claude .cache-loader build claude_logs diff --git a/kb_allowlist.json b/kb_allowlist.json index 817c8c4088..0ce970bb8f 100644 --- a/kb_allowlist.json +++ b/kb_allowlist.json @@ -32,9 +32,6 @@ "11.0", "11.1" ], - "endpointpolicymanager": [ - "current" - ], "endpointprotector": [ "current" ], diff --git a/package-lock.json b/package-lock.json index 9bf5fc9af2..9fe815d919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -174,7 +174,6 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.34.1.tgz", "integrity": "sha512-bt5hC9vvjaKvdvsgzfXJ42Sl3qjQqoi/FD8V7HOQgtNFhwSauZOlgLwFoUiw67sM+r7ehF7QDk5WRDgY7fAkIg==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.34.1", "@algolia/requester-browser-xhr": "5.34.1", @@ -335,7 +334,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2202,7 +2200,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2225,7 +2222,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2306,7 +2302,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2670,7 +2665,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3438,7 +3432,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/faster/-/faster-3.8.1.tgz", "integrity": "sha512-XYrj3qnTm+o2d5ih5drCq9s63GJoM8vZ26WbLG5FZhURsNxTSXgHJcx11Qo7nWPUStCQkuqk1HvItzscCUnd4A==", "license": "MIT", - "peer": true, "dependencies": { "@docusaurus/types": "3.8.1", "@rspack/core": "^1.3.15", @@ -3591,7 +3584,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz", "integrity": "sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA==", "license": "MIT", - "peer": true, "dependencies": { "@docusaurus/core": "3.8.1", "@docusaurus/logger": "3.8.1", @@ -4259,7 +4251,6 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", "integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -4763,7 +4754,6 @@ "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.4.10.tgz", "integrity": "sha512-eK3H328pihiM1323OlaClKJ9WlqgGBZpcR5AqFoWsG0KD01tKCJOeZEgtCY6paRLrsQrEJwBrLntkG0fE7WNGg==", "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.17.0", "@rspack/binding": "1.4.10", @@ -5005,7 +4995,6 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5110,7 +5099,6 @@ "integrity": "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" @@ -6084,7 +6072,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6416,7 +6403,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6494,7 +6480,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6540,7 +6525,6 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.34.1.tgz", "integrity": "sha512-s70HlfBgswgEdmCYkUJG8i/ULYhbkk8N9+N8JsWUwszcp7eauPEr5tIX4BY0qDGeKWQ/qZvmt4mxwTusYY23sg==", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-abtesting": "5.34.1", "@algolia/client-analytics": "5.34.1", @@ -7032,7 +7016,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -7316,7 +7299,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -8244,7 +8226,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8576,7 +8557,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.1.tgz", "integrity": "sha512-dbeqFTLYEwlFg7UGtcZhCCG/2WayX72zK3Sq323CEX29CY81tYfVhw1MIdduCtpstB0cTOhJswWlM/OEB3Xp+Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -8998,7 +8978,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -10326,7 +10305,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15376,7 +15354,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -16093,7 +16070,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17009,7 +16985,6 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -17814,7 +17789,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -17827,7 +17801,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -17884,7 +17857,6 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" }, @@ -17913,7 +17885,6 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -20865,7 +20836,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -21222,7 +21192,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.100.2.tgz", "integrity": "sha512-QaNKAvGCDRh3wW1dsDjeMdDXwZm2vqq3zn6Pvq4rHOEOGSaUMgOOjG2Y9ZbIGzpfkJk9ZYTHpDqgDfeBDcnLaw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/src/theme/SearchBar/index.js b/src/theme/SearchBar/index.js new file mode 100644 index 0000000000..74df84ade4 --- /dev/null +++ b/src/theme/SearchBar/index.js @@ -0,0 +1,552 @@ +import React, {useCallback, useMemo, useRef, useState, useEffect} from 'react'; +import {createPortal} from 'react-dom'; +import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import {useHistory} from '@docusaurus/router'; +import { + isRegexpStringMatch, + useSearchLinkCreator, +} from '@docusaurus/theme-common'; +import { + useAlgoliaContextualFacetFilters, + useSearchResultUrlProcessor, +} from '@docusaurus/theme-search-algolia/client'; +import Translate from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import translations from '@theme/SearchTranslations'; +import {PRODUCTS} from '../../config/products'; + +let DocSearchModal = null; + +function importDocSearchModalIfNeeded() { + if (DocSearchModal) { + return Promise.resolve(); + } + return Promise.all([ + import('@docsearch/react/modal'), + import('@docsearch/react/style'), + import('./styles.css'), + ]).then(([{DocSearchModal: Modal}]) => { + DocSearchModal = Modal; + }); +} + +function useNavigator({externalUrlRegex, selectedProducts}) { + const history = useHistory(); + const createSearchLink = useSearchLinkCreator(); + + // Use useMemo instead of useState to ensure we get fresh values + const navigator = useMemo(() => { + return { + navigate(params) { + // Get the current search query directly from the DOM at navigation time + const input = + document.querySelector('.DocSearch-Input') || + document.querySelector('input[type="search"]'); + const currentQuery = input ? input.value : ''; + + // If we have a search query, redirect to full search results page instead + if (currentQuery) { + const baseLink = createSearchLink(currentQuery); + const urlParams = new URLSearchParams(); + if (selectedProducts && selectedProducts.length > 0) { + urlParams.set('products', selectedProducts.join(',')); + } + const searchPageUrl = urlParams.toString() + ? `${baseLink}&${urlParams.toString()}` + : baseLink; + history.push(searchPageUrl); + } else if (isRegexpStringMatch(externalUrlRegex, params.itemUrl)) { + window.location.href = params.itemUrl; + } else { + history.push(params.itemUrl); + } + }, + }; + }, [externalUrlRegex, history, createSearchLink, selectedProducts]); + + return navigator; +} + +function useTransformSearchClient() { + const { + siteMetadata: {docusaurusVersion}, + } = useDocusaurusContext(); + return useCallback( + (searchClient) => { + searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion); + return searchClient; + }, + [docusaurusVersion], + ); +} + +function useTransformItems(props) { + const processSearchResultUrl = useSearchResultUrlProcessor(); + const [transformItems] = useState(() => { + return (items) => + props.transformItems + ? props.transformItems(items) + : items.map((item) => ({ + ...item, + url: processSearchResultUrl(item.url), + })); + }); + return transformItems; +} + +function useResultsFooterComponent({closeModal, selectedProducts}) { + return useMemo( + () => + ({state}) => + , + [closeModal, selectedProducts], + ); +} + +function Hit({hit, children}) { + return {children}; +} + +function ResultsFooter({state, onClose, selectedProducts = []}) { + const createSearchLink = useSearchLinkCreator(); + const baseLink = createSearchLink(state.query); + + // Add product filters as URL parameters + const params = new URLSearchParams(); + if (selectedProducts.length > 0) { + params.set('products', selectedProducts.join(',')); + } + + const linkWithFilters = params.toString() + ? `${baseLink}&${params.toString()}` + : baseLink; + + return ( + + + {'See all {count} results'} + + + ); +} + +function useSearchParameters({contextualSearch, productFacetFilters = [], ...props}) { + function mergeFacetFilters(f1, f2) { + const normalize = (f) => (typeof f === 'string' ? [f] : f); + return [...normalize(f1), ...normalize(f2)]; + } + + const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters(); + + const configFacetFilters = props.searchParameters?.facetFilters ?? []; + const combinedConfigFacetFilters = + productFacetFilters.length > 0 + ? mergeFacetFilters(configFacetFilters, productFacetFilters) + : configFacetFilters; + + let facetFilters; + if (contextualSearch) { + // Use contextual filters (includes language and doc tags) + facetFilters = mergeFacetFilters(contextualSearchFacetFilters, combinedConfigFacetFilters); + } else { + // When contextualSearch is disabled, still include language filter but not doc tags + const languageFilter = contextualSearchFacetFilters.find(f => typeof f === 'string' && f.startsWith('language:')); + if (languageFilter && productFacetFilters.length > 0) { + // IMPORTANT: Don't spread productFacetFilters - it's already an array of arrays + // We want: ['language:en', ['product1', 'product2']] not ['language:en', 'product1', 'product2'] + facetFilters = [languageFilter].concat(productFacetFilters); + } else { + facetFilters = combinedConfigFacetFilters; + } + } + + return { + ...props.searchParameters, + facetFilters, + }; +} + +// Generate product options dynamically from PRODUCTS constant +// Values MUST match Algolia facet values exactly +const PRODUCT_OPTIONS = [ + {label: 'All products', value: '__all__'}, + ...PRODUCTS.map(product => ({ + label: product.name, + value: product.name, + })) +].sort((a, b) => { + // Keep "All products" first + if (a.value === '__all__') return -1; + if (b.value === '__all__') return 1; + return a.label.localeCompare(b.label); +}); + +// Helper to get versions for selected products +// Multi-select dropdown component with checkboxes +function MultiSelectDropdown({label, options, selectedValues, onChange, placeholder}) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleToggle = (value) => { + let newSelection; + if (value === '__all__') { + newSelection = []; + } else { + if (selectedValues.includes(value)) { + newSelection = selectedValues.filter(v => v !== value); + } else { + newSelection = [...selectedValues, value]; + } + } + onChange(newSelection); + }; + + const displayText = selectedValues.length === 0 + ? placeholder + : `${selectedValues.length} selected`; + + return ( +
+ + {isOpen && ( +
+ {options.map((opt) => { + const isSelected = opt.value === '__all__' + ? selectedValues.length === 0 + : selectedValues.includes(opt.value); + + return ( + + ); + })} +
+ )} +
+ ); +} + +function DocSearch({externalUrlRegex, onModalOpen, selectedProducts, ...props}) { + const navigator = useNavigator({externalUrlRegex, selectedProducts}); + const searchParameters = useSearchParameters({...props}); + const transformItems = useTransformItems(props); + const transformSearchClient = useTransformSearchClient(); + const searchContainer = useRef(null); + const searchButtonRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(undefined); + + const prepareSearchContainer = useCallback(() => { + if (!searchContainer.current) { + const divElement = document.createElement('div'); + searchContainer.current = divElement; + document.body.insertBefore(divElement, document.body.firstChild); + } + }, []); + + const openModal = useCallback(() => { + prepareSearchContainer(); + importDocSearchModalIfNeeded().then(() => { + setIsOpen(true); + // Let React render the modal, then caller can locate DOM nodes + setTimeout(() => onModalOpen?.(), 0); + }); + }, [prepareSearchContainer, onModalOpen]); + + const closeModal = useCallback(() => { + setIsOpen(false); + searchButtonRef.current?.focus(); + setInitialQuery(undefined); + }, []); + + const handleInput = useCallback( + (event) => { + if (event.key === 'f' && (event.metaKey || event.ctrlKey)) { + return; + } + event.preventDefault(); + setInitialQuery(event.key); + openModal(); + }, + [openModal], + ); + + const resultsFooterComponent = useResultsFooterComponent({ + closeModal, + selectedProducts, + }); + + useDocSearchKeyboardEvents({ + isOpen, + onOpen: openModal, + onClose: closeModal, + onInput: handleInput, + searchButtonRef, + }); + + return ( + <> + + + + + + + {isOpen && + DocSearchModal && + searchContainer.current && + createPortal( + , + searchContainer.current, + )} + + ); +} + +export default function SearchBar() { + const {siteConfig} = useDocusaurusContext(); + + // Store the current search query to preserve it across filter changes + const searchQueryRef = useRef(''); + + // Multi-select state for products + const [selectedProducts, setSelectedProducts] = useState(() => { + if (typeof window === 'undefined') return []; + const saved = localStorage.getItem('docs_product_filter'); + try { + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + + // Generate facetFilters for products + const productFacetFilters = useMemo(() => { + const filters = []; + + // Add product filters (OR logic - any of the selected products) + if (selectedProducts.length > 0) { + const productFilters = selectedProducts.map(p => `product_name:${p}`); + filters.push(productFilters); // Array within array = OR logic + } + + return filters; + }, [selectedProducts]); + + // Keep track of the search input value + useEffect(() => { + const interval = setInterval(() => { + const input = + document.querySelector('.DocSearch-Input') || + document.querySelector('input[type="search"]'); + if (input) { + searchQueryRef.current = input.value; + } + }, 100); + + return () => clearInterval(interval); + }, []); + + // Helper to refresh search - preserve query and wait for user action + const refreshSearch = useCallback(() => { + const input = + document.querySelector('.DocSearch-Input') || + document.querySelector('input[type="search"]'); + + if (input && input.value) { + searchQueryRef.current = input.value; + + // Try multiple approaches to trigger search + const query = input.value; + + // Approach 1: Simulate typing by adding/removing character + setTimeout(() => { + if (input) { + input.value = query + ' '; + input.dispatchEvent(new Event('input', {bubbles: true})); + + setTimeout(() => { + input.value = query; + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }, 50); + } + }, 50); + } + }, []); + + const onChangeProducts = useCallback((newProducts) => { + setSelectedProducts(newProducts); + if (typeof window !== 'undefined') { + localStorage.setItem('docs_product_filter', JSON.stringify(newProducts)); + } + // Don't auto-refresh - let user retype or press enter + }, []); + + // This is where we will portal the filters into the modal DOM. + const [modalHeaderEl, setModalHeaderEl] = useState(null); + + const onModalOpen = useCallback(() => { + // Try to locate the header/searchbar area in the DocSearch modal. + // DocSearch v3 uses these classnames. + const el = + document.querySelector('.DocSearch-SearchBar') || + document.querySelector('.DocSearch-Form') || + document.querySelector('.DocSearch-Modal'); + + setModalHeaderEl(el || null); + }, []); + + // When modalHeaderEl exists, insert filters BEFORE the form input if possible. + // We target .DocSearch-Form when present, otherwise the container itself. + const portalTarget = useMemo(() => { + if (!modalHeaderEl) return null; + return ( + modalHeaderEl.querySelector('.DocSearch-Form') || + modalHeaderEl + ); + }, [modalHeaderEl]); + + // Disable contextualSearch when products are selected to allow cross-product searching + const contextualSearch = selectedProducts.length === 0; + + return ( + <> + + + {portalTarget && + createPortal( + // Wrapper to align with DocSearch input +
+ +
, + portalTarget, + )} + + ); +} diff --git a/src/theme/SearchBar/styles.css b/src/theme/SearchBar/styles.css new file mode 100644 index 0000000000..fdf8dff9a4 --- /dev/null +++ b/src/theme/SearchBar/styles.css @@ -0,0 +1,14 @@ +:root { + --docsearch-primary-color: var(--ifm-color-primary); + --docsearch-text-color: var(--ifm-font-color-base); +} + +.DocSearch-Button { + margin: 0; + transition: all var(--ifm-transition-fast) + var(--ifm-transition-timing-default); +} + +.DocSearch-Container { + z-index: calc(var(--ifm-z-index-fixed) + 1); +} diff --git a/src/theme/SearchPage/index.js b/src/theme/SearchPage/index.js new file mode 100644 index 0000000000..b6a59552af --- /dev/null +++ b/src/theme/SearchPage/index.js @@ -0,0 +1,1009 @@ +/** + * Custom SearchPage that respects product and version filters from URL parameters + */ + +import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react'; +import {useHistory, useLocation} from '@docusaurus/router'; +import clsx from 'clsx'; +import algoliaSearchHelper from 'algoliasearch-helper'; +import {liteClient} from 'algoliasearch/lite'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import {HtmlClassNameProvider, PageMetadata, usePluralForm} from '@docusaurus/theme-common'; +import Translate, {translate} from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import {useAlgoliaThemeConfig, useSearchResultUrlProcessor} from '@docusaurus/theme-search-algolia/client'; +import Layout from '@theme/Layout'; +import Heading from '@theme/Heading'; +import {PRODUCTS} from '../../config/products'; +import styles from './styles.module.css'; + +// Safely strip HTML tags to plain text +function stripHtmlTagsToText(input) { + if (!input) { + return ''; + } + if (ExecutionEnvironment.canUseDOM) { + const container = document.createElement('div'); + container.innerHTML = input; + return container.textContent || container.innerText || ''; + } + // Fallback for non-DOM environments (SSR): remove all angle brackets to prevent tags + // This avoids multi-character tag patterns and ensures no HTML elements can be formed. + return input.replace(/[<>]/g, ''); +} + +// Generate product options from PRODUCTS config +const PRODUCT_OPTIONS = [ + {label: 'All products', value: '__all__'}, + ...PRODUCTS.map(product => ({ + label: product.name, + value: product.name, + })) +].sort((a, b) => { + if (a.value === '__all__') return -1; + if (b.value === '__all__') return 1; + return a.label.localeCompare(b.label); +}); + +// Checkbox-based multi-select component +function MultiSelect({label, options, selectedValues, onChange}) { + // options[0] is the "All products" sentinel — skip it for the checkbox list + const productOptions = options.filter(o => o.value !== '__all__'); + const noneSelected = selectedValues.length === 1 && selectedValues[0] === '__none__'; + const allSelected = selectedValues.length === 0 || selectedValues.includes('__all__'); + const someSelected = !allSelected && !noneSelected && selectedValues.length > 0; + + const selectAllRef = useRef(null); + + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = someSelected; + if (!someSelected) selectAllRef.current.indeterminate = false; + } + }, [someSelected]); + + function handleSelectAll() { + if (allSelected && !someSelected) { + onChange(['__none__']); // All checked → uncheck all + } else { + onChange([]); // Indeterminate or none → check all + } + } + + function handleOption(value, checked) { + // Resolve current explicit selection + const current = allSelected + ? productOptions.map(o => o.value) // [] means all + : noneSelected + ? [] // __none__ means none + : selectedValues.filter(v => v !== '__all__'); + if (checked) { + const next = current.concat(value); + onChange(next.length === productOptions.length ? [] : next); + } else { + onChange(current.filter(v => v !== value)); + } + } + + const containerStyle = { + width: '100%', + border: '2px solid var(--ifm-color-emphasis-300)', + borderRadius: '8px', + overflowY: 'auto', + background: 'var(--ifm-background-color)', + flex: 1, + minHeight: 0, + }; + + const rowStyle = { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '4px 10px', + cursor: 'pointer', + fontSize: '13px', + lineHeight: '1.3', + userSelect: 'none', + }; + + const checkboxStyle = { + width: '16px', + height: '16px', + flexShrink: 0, + cursor: 'pointer', + accentColor: 'var(--ifm-color-primary)', + }; + + const dividerStyle = { + height: '1px', + background: 'var(--ifm-color-emphasis-200)', + margin: '0', + }; + + return ( +
+ +
+ {/* Select-all row */} + +
+ {productOptions.map((opt) => { + const checked = !noneSelected && (allSelected || selectedValues.includes(opt.value)); + return ( + + ); + })} +
+
+ ); +} + +function useDocumentsFoundPlural() { + const {selectMessage} = usePluralForm(); + return (count) => + selectMessage( + count, + translate( + { + id: 'theme.SearchPage.documentsFound.plurals', + message: 'One document found|{count} documents found', + }, + {count}, + ), + ); +} + +function SearchPageContent() { + const {i18n: {currentLocale}} = useDocusaurusContext(); + const {algolia: {appId, apiKey, indexName}} = useAlgoliaThemeConfig(); + const processSearchResultUrl = useSearchResultUrlProcessor(); + const documentsFoundPlural = useDocumentsFoundPlural(); + const location = useLocation(); + const history = useHistory(); + + // Back to top button visibility + const [showBackToTop, setShowBackToTop] = useState(false); + const [showJumpToBottom, setShowJumpToBottom] = useState(false); + + // Results per page dropdown state + const [rppOpen, setRppOpen] = useState(false); + const rppRef = useRef(null); + + // Close results-per-page dropdown when clicking outside + useEffect(() => { + if (!rppOpen) return; + const handler = (e) => { + if (rppRef.current && !rppRef.current.contains(e.target)) setRppOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [rppOpen]); + + // Page jump input state + const [pageInputValue, setPageInputValue] = useState(''); + const [pageInputFocused, setPageInputFocused] = useState(false); + + // Mobile breakpoint + const [isMobile, setIsMobile] = useState(false); + const [isSmall, setIsSmall] = useState(false); + useEffect(() => { + if (!ExecutionEnvironment.canUseDOM) return; + const mqMobile = window.matchMedia('(max-width: 768px)'); + const mqSmall = window.matchMedia('(max-width: 480px)'); + setIsMobile(mqMobile.matches); + setIsSmall(mqSmall.matches); + const mobileHandler = (e) => setIsMobile(e.matches); + const smallHandler = (e) => setIsSmall(e.matches); + mqMobile.addEventListener('change', mobileHandler); + mqSmall.addEventListener('change', smallHandler); + return () => { + mqMobile.removeEventListener('change', mobileHandler); + mqSmall.removeEventListener('change', smallHandler); + }; + }, []); + + // Parse URL parameters + const urlParams = new URLSearchParams(location.search); + const queryFromUrl = urlParams.get('q') || ''; + const productsFromUrl = urlParams.get('products')?.split(',').filter(Boolean) || []; + const resultsPerPageFromUrl = parseInt(urlParams.get('resultsPerPage'), 10) || 25; + const pageFromUrl = parseInt(urlParams.get('page'), 10) || 1; + + const [searchQuery, setSearchQuery] = useState(queryFromUrl); + const [selectedProducts, setSelectedProducts] = useState(productsFromUrl); + const [resultsPerPage, setResultsPerPage] = useState(resultsPerPageFromUrl); + + // Track if we're restoring from URL (e.g., browser back button) + const restoringFromUrl = useRef(false); + const targetPageRef = useRef(null); + const isInternalNavigation = useRef(false); + + // Update state when URL changes (e.g., when navigating from search modal or browser back) + useEffect(() => { + // Skip if this was an internal navigation (we triggered the URL change ourselves) + if (isInternalNavigation.current) { + isInternalNavigation.current = false; + return; + } + + // External navigation (browser back/forward, or coming from search modal) + const urlParams = new URLSearchParams(location.search); + const newQuery = urlParams.get('q') || ''; + const newProducts = urlParams.get('products')?.split(',').filter(Boolean) || []; + const newResultsPerPage = parseInt(urlParams.get('resultsPerPage'), 10) || 25; + const newPage = parseInt(urlParams.get('page'), 10) || 1; + + setSearchQuery(newQuery); + setSelectedProducts(newProducts); + setResultsPerPage(newResultsPerPage); + + // Store target page for restoration + if (newPage > 1) { + restoringFromUrl.current = true; + targetPageRef.current = newPage - 1; + } else { + restoringFromUrl.current = false; + targetPageRef.current = null; + } + }, [location.search]); + + 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.items, // Show only current page + }; + case 'nextPage': + if (!prevState.hasMore || prevState.loading) { + return prevState; + } + return { + ...prevState, + lastPage: prevState.lastPage + 1, + loading: true, + }; + case 'prevPage': + if (prevState.lastPage === 0 || prevState.loading) { + return prevState; + } + return { + ...prevState, + lastPage: prevState.lastPage - 1, + loading: true, + }; + default: + return prevState; + } + }, + initialSearchResultState, + ); + + const algoliaClient = useMemo(() => liteClient(appId, apiKey), [appId, apiKey]); + const algoliaHelper = useMemo( + () => + algoliaSearchHelper(algoliaClient, indexName, { + hitsPerPage: resultsPerPage, + advancedSyntax: true, + disjunctiveFacets: ['language'], // Only language facet exists in index + }), + [algoliaClient, indexName, resultsPerPage], + ); + + algoliaHelper.on('result', ({results: {query, hits, page, nbHits, nbPages, facets}}) => { + if (query === '' || !Array.isArray(hits)) { + searchResultStateDispatcher({type: 'reset'}); + return; + } + + + const sanitizeValue = (value) => + value.replace(/algolia-docsearch-suggestion--highlight/g, 'search-result-match'); + + // Extract product and version from URL and filter client-side + const allItems = hits.map(({url, _highlightResult: {hierarchy}, _snippetResult: snippet = {}, product_name}) => { + const titles = Object.keys(hierarchy).map((key) => sanitizeValue(hierarchy[key].value)); + const breadcrumbs = [...titles]; + const title = titles.pop(); + + // Extract product and version from URL path like /docs/auditor/10.8/... + let product = product_name; + let version = null; + let productId = null; + + // Try to match: /docs/{product}/{version}/ or /docs/kb/{product}/ + const urlMatch = url.match(/\/docs\/(?:kb\/)?([^/]+)(?:\/([^/]+))?/); + if (urlMatch) { + productId = urlMatch[1]; + const versionPart = urlMatch[2]; + + // Map product ID to display name + const productConfig = PRODUCTS.find(p => p.id === productId); + if (productConfig) { + product = productConfig.name; + + // Convert version from URL format (10_8) to display format (10.8) + if (versionPart && versionPart !== 'kb') { + version = versionPart.replace(/_/g, '.'); + } + } + } + + // Fallback: try to extract product from breadcrumbs + if (!product && breadcrumbs.length > 0) { + product = stripHtmlTagsToText(breadcrumbs[0]); + } + if (!product) { + product = 'Unknown'; + } + + return { + title, + url: processSearchResultUrl(url), + summary: snippet.content ? `${sanitizeValue(snippet.content.value)}...` : '', + breadcrumbs, + product, + version, + productId, + originalUrl: url, + }; + }); + + // Algolia handles filtering via facetFilters, so no client-side filtering needed + const items = allItems; + + searchResultStateDispatcher({ + type: 'update', + value: { + items, + query, + totalResults: nbHits, + totalPages: nbPages, + lastPage: page, + hasMore: nbPages > page + 1, + loading: false, + }, + }); + }); + + // Pagination is now controlled by Previous/Next buttons instead of infinite scroll + + const makeSearch = useCallback( + (page = 0) => { + // Build facetFilters with product filters (matching SearchBar logic) + const facetFilters = [`language:${currentLocale}`]; + + // Add product filters (OR logic - any of the selected products) + const hasProductFilter = selectedProducts.length > 0 && !selectedProducts.includes('__all__'); + if (hasProductFilter) { + const productFilters = selectedProducts.map(p => `product_name:${p}`); + facetFilters.push(productFilters); // Array within array = OR logic + } + + algoliaHelper + .setQuery(searchQuery) + .setQueryParameter('facetFilters', facetFilters) + .setPage(page) + .search(); + }, + [searchQuery, algoliaHelper, currentLocale, selectedProducts], + ); + + // Update URL when filters or pagination change + const prevFiltersRef = useRef({searchQuery: '', selectedProducts: [], resultsPerPage: 25, page: 1}); + + useEffect(() => { + // Only update URL if values actually changed + const prev = prevFiltersRef.current; + const currentPage = (searchResultState.lastPage || 0) + 1; + + if ( + prev.searchQuery !== searchQuery || + JSON.stringify(prev.selectedProducts) !== JSON.stringify(selectedProducts) || + prev.resultsPerPage !== resultsPerPage || + prev.page !== currentPage + ) { + const params = new URLSearchParams(); + if (searchQuery) params.set('q', searchQuery); + if (selectedProducts.length > 0) params.set('products', selectedProducts.join(',')); + if (resultsPerPage !== 25) params.set('resultsPerPage', String(resultsPerPage)); + if (currentPage > 1) params.set('page', String(currentPage)); + + // Mark this as an internal navigation so location.search effect ignores it + isInternalNavigation.current = true; + history.replace({search: params.toString()}); + + prevFiltersRef.current = {searchQuery, selectedProducts, resultsPerPage, page: currentPage}; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, selectedProducts, resultsPerPage, searchResultState.lastPage]); + + // IntersectionObserver removed - using pagination buttons instead + + useEffect(() => { + searchResultStateDispatcher({type: 'reset'}); + if (searchQuery) { + searchResultStateDispatcher({type: 'loading'}); + // If restoring from URL, use the target page; otherwise start at page 0 + const startPage = (restoringFromUrl.current && targetPageRef.current !== null) + ? targetPageRef.current + : 0; + setTimeout(() => { + makeSearch(startPage); + // Clear restoration flags after search + restoringFromUrl.current = false; + targetPageRef.current = null; + }, 300); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, selectedProducts, resultsPerPage]); + + useEffect(() => { + // Only trigger pagination search if lastPage has been set by nextPage/prevPage actions + // (not during initial render where lastPage is null) + if (searchResultState.lastPage === null) { + return; + } + makeSearch(searchResultState.lastPage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchResultState.lastPage]); + + // Scroll to top when page changes (except initial load) + const isInitialLoad = useRef(true); + useEffect(() => { + if (isInitialLoad.current) { + isInitialLoad.current = false; + return; + } + if (searchResultState.items.length > 0 && !searchResultState.loading) { + window.scrollTo({top: 0, behavior: 'smooth'}); + } + }, [searchResultState.lastPage, searchResultState.items.length, searchResultState.loading]); + + // Show/hide back to top and jump to bottom buttons based on window scroll + useEffect(() => { + const handleScroll = () => { + const scrolledDown = window.scrollY > 300; + const nearBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 300; + + setShowBackToTop(scrolledDown); + setShowJumpToBottom(!scrolledDown && !nearBottom && searchResultState.items.length > 0); + }; + + handleScroll(); + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [searchResultState.items.length]); + + const scrollToTop = () => { + window.scrollTo({top: 0, behavior: 'smooth'}); + }; + + const scrollToBottom = () => { + window.scrollTo({top: document.documentElement.scrollHeight, behavior: 'smooth'}); + }; + + const pageTitle = searchQuery + ? `Search results for "${searchQuery}"` + : 'Search the documentation'; + + return ( + + + + + + +
+
+ {/* Controls — heading + search + results-per-page + doc count */} +
+ {pageTitle} +
+
+ + setSearchQuery(e.target.value)} + value={searchQuery} + autoComplete="off" + autoFocus + style={{ + width: '100%', + padding: '14px 16px', + fontSize: '16px', + borderRadius: '8px', + border: '2px solid var(--ifm-color-emphasis-300)', + marginBottom: '0', + transition: 'border-color 0.2s', + }} + onFocus={(e) => { + e.target.style.borderColor = 'var(--ifm-color-primary)'; + e.target.style.outline = 'none'; + }} + onBlur={(e) => { + e.target.style.borderColor = 'var(--ifm-color-emphasis-300)'; + }} + /> +
+
+ + + {rppOpen && ( +
+ {[25, 50, 100, 150, 200].map(n => ( +
{ setResultsPerPage(n); setRppOpen(false); }} + style={{ + padding: '8px 16px', + fontSize: '15px', + cursor: 'pointer', + background: n === resultsPerPage ? 'var(--ifm-color-primary)' : 'transparent', + color: n === resultsPerPage ? 'white' : 'var(--ifm-font-color-base)', + }} + onMouseEnter={(e) => { + if (n !== resultsPerPage) e.currentTarget.style.background = 'var(--ifm-color-emphasis-100)'; + }} + onMouseLeave={(e) => { + if (n !== resultsPerPage) e.currentTarget.style.background = 'transparent'; + }} + > + {n} +
+ ))} +
+ )} +
+
+ + {!!searchResultState.totalResults && ( +
+ {documentsFoundPlural(searchResultState.totalResults)} +
+ )} +
{/* closes controls div */} + + {/* Filters sidebar — product filters */} +
+ +
{/* closes filters div */} + + {/* Results area */} +
+ + {searchResultState.items.length > 0 ? ( +
+ {(() => { + // Group results by product + const groupedByProduct = searchResultState.items.reduce((acc, item) => { + const product = item.product || 'Unknown'; + if (!acc[product]) acc[product] = []; + acc[product].push(item); + return acc; + }, {}); + + // Sort products alphabetically + const sortedProducts = Object.keys(groupedByProduct).sort(); + + return sortedProducts.map((product) => { + const productResults = groupedByProduct[product]; + + return ( +
+ + {product} ({productResults.length} {productResults.length === 1 ? 'result' : 'results'} on this page) + + + {productResults.map(({title, url, summary, breadcrumbs}, i) => { + return ( +
+ + + + + {breadcrumbs.length > 0 && ( + + )} + + {summary && ( +

+ )} +

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

+ No results were found +

+ ), + !!searchResultState.loading && ( +
+ Loading... +
+ ), + ] + )} + + {/* Pagination controls */} + {searchResultState.items.length > 0 && ( +
+ + +
+ + Page + + { + const value = e.target.value.replace(/\D/g, ''); + setPageInputValue(value); + }} + onFocus={(e) => { + setPageInputFocused(true); + setPageInputValue(String(searchResultState.lastPage + 1)); + setTimeout(() => e.target.select(), 0); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const pageNum = parseInt(pageInputValue, 10); + if (pageNum >= 1 && pageNum <= searchResultState.totalPages) { + searchResultStateDispatcher({ + type: 'update', + value: { + ...searchResultState, + lastPage: pageNum - 1, + loading: true, + } + }); + } + setPageInputValue(''); + setPageInputFocused(false); + e.target.blur(); + } else if (e.key === 'Escape') { + setPageInputValue(''); + setPageInputFocused(false); + e.target.blur(); + } + }} + onBlur={() => { + setPageInputValue(''); + setPageInputFocused(false); + }} + style={{ + width: '60px', + padding: '6px 8px', + fontSize: '16px', + fontWeight: '500', + border: '1px solid var(--ifm-color-emphasis-300)', + borderRadius: '4px', + textAlign: 'center', + }} + /> + + of {searchResultState.totalPages} + +
+ + +
+ )} +
{/* closes results div */} +
{/* closes outer grid/flex container */} +
+ + {/* Back to top / jump to bottom buttons — mutually exclusive, same position */} + {(showBackToTop || showJumpToBottom) && ( + + )} +
+ ); +} + +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 0000000000..649c45478c --- /dev/null +++ b/src/theme/SearchPage/styles.module.css @@ -0,0 +1,41 @@ +/* Custom styles for search page */ + +.searchQueryColumn { + flex-grow: 1; +} + +.searchResultsColumn { + margin-top: 1rem; +} + +.searchResultItem { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.searchResultItemHeading { + font-size: 1.25rem; + margin-bottom: 0.5rem; +} + +.searchResultItemPath { + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.searchResultItemSummary { + color: var(--ifm-color-emphasis-700); + margin: 0; +} + +.loadingSpinner { + text-align: center; + padding: 2rem; +} + +.loader { + text-align: center; + padding: 1rem; + color: var(--ifm-color-emphasis-600); +}