Skip to content

Commit 113f355

Browse files
harsh62claude
andcommitted
feat: add search filters for platform and gen version
Add visible filter controls inside the DocSearch modal so users can narrow search results by platform and Gen version. Platform uses optionalFacetFilters for boosting (cross-platform results still appear, ranked lower). Gen version uses hard filtering with Gen1/Gen2/Both options. Defaults to All platforms and Gen 2. Also adds getMissingResultsUrl so zero-result searches show a link to report missing content on GitHub. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent abc9869 commit 113f355

File tree

5 files changed

+238
-7
lines changed

5 files changed

+238
-7
lines changed

src/components/Layout/LayoutHeader.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useContext, useRef } from 'react';
1+
import { useContext, useRef, useState, useMemo } from 'react';
22
import { useRouter } from 'next/router';
33
import { Button, Flex, View, VisuallyHidden } from '@aws-amplify/ui-react';
44
import classNames from 'classnames';
@@ -19,6 +19,8 @@ import { PageLastUpdated } from '../PageLastUpdated';
1919
import Feedback from '../Feedback';
2020
import RepoActions from '../Menu/RepoActions';
2121
import { usePathWithoutHash } from '@/utils/usePathWithoutHash';
22+
import { SearchFilters } from '@/components/Search';
23+
import type { GenFilter, PlatformFilter } from '@/components/Search';
2224

2325
export const LayoutHeader = ({
2426
currentPlatform,
@@ -39,6 +41,14 @@ export const LayoutHeader = ({
3941
const router = useRouter();
4042
const asPathWithNoHash = usePathWithoutHash();
4143

44+
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
45+
const [genFilter, setGenFilter] = useState<GenFilter>('gen2');
46+
47+
const searchParams = useMemo(() => ({
48+
...(platformFilter !== 'all' && { optionalFacetFilters: [`platform:${platformFilter}`] }),
49+
...(genFilter !== 'both' && { facetFilters: [`gen:${genFilter}`] })
50+
}), [platformFilter, genFilter]);
51+
4252
const handleMenuToggle = () => {
4353
if (!menuOpen) {
4454
toggleMenuOpen(true);
@@ -90,13 +100,17 @@ export const LayoutHeader = ({
90100
appId={process.env.ALGOLIA_APP_ID || ALGOLIA_APP_ID}
91101
indexName={process.env.ALGOLIA_INDEX_NAME || ALGOLIA_INDEX_NAME}
92102
apiKey={process.env.ALGOLIA_API_KEY || ALGOLIA_API_KEY}
93-
searchParameters={{
94-
facetFilters: [
95-
`platform:${currentPlatform}`,
96-
`gen:${isGen1 ? 'gen1' : 'gen2'}`
97-
]
98-
}}
103+
searchParameters={searchParams}
99104
transformItems={transformItems}
105+
getMissingResultsUrl={({ query }) =>
106+
`https://github.com/aws-amplify/docs/issues/new?title=${encodeURIComponent(`[search] Missing results for: ${query}`)}&labels=v2`
107+
}
108+
/>
109+
<SearchFilters
110+
platformFilter={platformFilter}
111+
genFilter={genFilter}
112+
onPlatformChange={setPlatformFilter}
113+
onGenChange={setGenFilter}
100114
/>
101115
</View>
102116
</View>
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useState, useEffect, useRef } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import { PLATFORMS, PLATFORM_DISPLAY_NAMES } from '@/data/platforms';
4+
import type { Platform } from '@/data/platforms';
5+
6+
export type GenFilter = 'gen1' | 'gen2' | 'both';
7+
export type PlatformFilter = Platform | 'all';
8+
9+
interface SearchFiltersProps {
10+
platformFilter: PlatformFilter;
11+
genFilter: GenFilter;
12+
onPlatformChange: (platform: PlatformFilter) => void;
13+
onGenChange: (gen: GenFilter) => void;
14+
}
15+
16+
const GEN_DISPLAY: Record<GenFilter, string> = {
17+
gen2: 'Gen 2',
18+
gen1: 'Gen 1',
19+
both: 'Both'
20+
};
21+
22+
export function SearchFilters({
23+
platformFilter,
24+
genFilter,
25+
onPlatformChange,
26+
onGenChange
27+
}: SearchFiltersProps) {
28+
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
29+
const containerRef = useRef<HTMLDivElement | null>(null);
30+
31+
useEffect(() => {
32+
const observer = new MutationObserver(() => {
33+
const modal = document.querySelector('.DocSearch-Modal');
34+
const searchBar = modal?.querySelector('.DocSearch-SearchBar');
35+
36+
if (searchBar && !modal?.querySelector('.search-filters-container')) {
37+
const container = document.createElement('div');
38+
container.className = 'search-filters-container';
39+
searchBar.insertAdjacentElement('afterend', container);
40+
containerRef.current = container;
41+
setPortalContainer(container);
42+
} else if (!modal) {
43+
containerRef.current = null;
44+
setPortalContainer(null);
45+
}
46+
});
47+
observer.observe(document.body, { childList: true, subtree: true });
48+
return () => {
49+
observer.disconnect();
50+
containerRef.current?.remove();
51+
};
52+
}, []);
53+
54+
if (!portalContainer) return null;
55+
56+
return createPortal(
57+
<div className="search-filters" role="toolbar" aria-label="Search filters">
58+
<div className="search-filters__group">
59+
<span className="search-filters__label">Platform:</span>
60+
<select
61+
className="search-filters__select"
62+
value={platformFilter}
63+
onChange={(e) => onPlatformChange(e.target.value as PlatformFilter)}
64+
aria-label="Platform filter"
65+
>
66+
<option value="all">All</option>
67+
{PLATFORMS.map((p) => (
68+
<option key={p} value={p}>
69+
{PLATFORM_DISPLAY_NAMES[p]}
70+
</option>
71+
))}
72+
</select>
73+
</div>
74+
<div className="search-filters__separator" />
75+
<div className="search-filters__group">
76+
<span className="search-filters__label">Version:</span>
77+
<select
78+
className="search-filters__select"
79+
value={genFilter}
80+
onChange={(e) => onGenChange(e.target.value as GenFilter)}
81+
aria-label="Generation filter"
82+
>
83+
<option value="gen2">{GEN_DISPLAY.gen2}</option>
84+
<option value="gen1">{GEN_DISPLAY.gen1}</option>
85+
<option value="both">{GEN_DISPLAY.both}</option>
86+
</select>
87+
</div>
88+
</div>,
89+
portalContainer
90+
);
91+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as React from 'react';
2+
import { render, waitFor } from '@testing-library/react';
3+
import { SearchFilters } from '../SearchFilters';
4+
import type { GenFilter, PlatformFilter } from '../SearchFilters';
5+
6+
describe('SearchFilters', () => {
7+
let mockModal: HTMLElement;
8+
let onPlatformChange: jest.Mock;
9+
let onGenChange: jest.Mock;
10+
11+
beforeEach(() => {
12+
onPlatformChange = jest.fn();
13+
onGenChange = jest.fn();
14+
});
15+
16+
afterEach(() => {
17+
if (mockModal) mockModal.remove();
18+
});
19+
20+
function renderFilters(
21+
platformFilter: PlatformFilter = 'react',
22+
genFilter: GenFilter = 'gen2'
23+
) {
24+
const result = render(
25+
<SearchFilters
26+
platformFilter={platformFilter}
27+
genFilter={genFilter}
28+
onPlatformChange={onPlatformChange}
29+
onGenChange={onGenChange}
30+
/>
31+
);
32+
// Simulate DocSearch modal DOM structure
33+
mockModal = document.createElement('div');
34+
mockModal.className = 'DocSearch-Modal';
35+
const searchBar = document.createElement('div');
36+
searchBar.className = 'DocSearch-SearchBar';
37+
const form = document.createElement('form');
38+
form.className = 'DocSearch-Form';
39+
searchBar.appendChild(form);
40+
mockModal.appendChild(searchBar);
41+
document.body.appendChild(mockModal);
42+
return result;
43+
}
44+
45+
it('should render both select dropdowns inside the modal', async () => {
46+
renderFilters();
47+
await waitFor(() => {
48+
expect(mockModal.querySelector('.search-filters')).toBeInTheDocument();
49+
});
50+
const selects = mockModal.querySelectorAll('.search-filters__select');
51+
expect(selects.length).toBe(2);
52+
});
53+
54+
it('should include "All" option in platform select', async () => {
55+
renderFilters();
56+
await waitFor(() => {
57+
expect(mockModal.querySelector('.search-filters')).toBeInTheDocument();
58+
});
59+
const platformSelect = mockModal.querySelectorAll('.search-filters__select')[0] as HTMLSelectElement;
60+
const options = Array.from(platformSelect.options).map((o) => o.value);
61+
expect(options).toContain('all');
62+
expect(options).toContain('react');
63+
});
64+
65+
it('should include Gen options in version select', async () => {
66+
renderFilters();
67+
await waitFor(() => {
68+
expect(mockModal.querySelector('.search-filters')).toBeInTheDocument();
69+
});
70+
const genSelect = mockModal.querySelectorAll('.search-filters__select')[1] as HTMLSelectElement;
71+
const options = Array.from(genSelect.options).map((o) => o.value);
72+
expect(options).toEqual(['gen2', 'gen1', 'both']);
73+
});
74+
75+
it('should reflect current platform value', async () => {
76+
renderFilters('swift', 'gen2');
77+
await waitFor(() => {
78+
expect(mockModal.querySelector('.search-filters')).toBeInTheDocument();
79+
});
80+
const platformSelect = mockModal.querySelectorAll('.search-filters__select')[0] as HTMLSelectElement;
81+
expect(platformSelect.value).toBe('swift');
82+
});
83+
});

src/components/Search/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { SearchFilters } from './SearchFilters';
2+
export type { GenFilter, PlatformFilter } from './SearchFilters';

src/styles/algolia.scss

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,44 @@
105105
box-shadow: inset 0 0 0 2px var(--amplify-colors-border-focus);
106106
}
107107
}
108+
109+
// Search filters — compact row below search input inside the modal
110+
.search-filters {
111+
display: flex;
112+
align-items: center;
113+
gap: var(--amplify-space-xs);
114+
padding: 6px var(--amplify-space-small);
115+
border-bottom: 1px solid var(--amplify-colors-border-tertiary);
116+
font-size: 11px;
117+
118+
&__group {
119+
display: flex;
120+
align-items: center;
121+
gap: 4px;
122+
}
123+
124+
&__label {
125+
color: var(--amplify-colors-font-tertiary);
126+
}
127+
128+
&__separator {
129+
width: 1px;
130+
height: 16px;
131+
background: var(--amplify-colors-border-tertiary);
132+
}
133+
134+
&__select {
135+
font-size: 11px;
136+
padding: 2px 4px;
137+
border: 1px solid var(--amplify-colors-border-secondary);
138+
border-radius: var(--amplify-radii-small);
139+
background: var(--amplify-colors-background-primary);
140+
color: var(--amplify-colors-font-primary);
141+
cursor: pointer;
142+
143+
&:focus-visible {
144+
outline: 2px solid var(--amplify-colors-border-focus);
145+
outline-offset: 1px;
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)