Skip to content

Commit 65c2c3f

Browse files
authored
feat: add search filters for platform and gen version (#8526)
1 parent abc9869 commit 65c2c3f

File tree

5 files changed

+263
-7
lines changed

5 files changed

+263
-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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { useState, useEffect, useCallback } 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+
30+
const cleanupContainer = useCallback((container: HTMLElement | null) => {
31+
container?.remove();
32+
}, []);
33+
34+
useEffect(() => {
35+
const observer = new MutationObserver(() => {
36+
const modal = document.querySelector('.DocSearch-Modal');
37+
const searchBar = modal?.querySelector('.DocSearch-SearchBar');
38+
39+
if (searchBar && !modal?.querySelector('.search-filters-container')) {
40+
const container = document.createElement('div');
41+
container.className = 'search-filters-container';
42+
searchBar.insertAdjacentElement('afterend', container);
43+
setPortalContainer(container);
44+
} else if (!modal) {
45+
setPortalContainer((prev) => {
46+
cleanupContainer(prev);
47+
return null;
48+
});
49+
}
50+
});
51+
observer.observe(document.body, { childList: true, subtree: false });
52+
return () => {
53+
observer.disconnect();
54+
setPortalContainer((prev) => {
55+
cleanupContainer(prev);
56+
return null;
57+
});
58+
};
59+
}, [cleanupContainer]);
60+
61+
if (!portalContainer) return null;
62+
63+
return createPortal(
64+
<div className="search-filters" role="toolbar" aria-label="Search filters">
65+
<div className="search-filters__group">
66+
<span className="search-filters__label">Platform:</span>
67+
<select
68+
className="search-filters__select"
69+
value={platformFilter}
70+
onChange={(e) => onPlatformChange(e.target.value as PlatformFilter)}
71+
aria-label="Platform filter"
72+
>
73+
<option value="all">All</option>
74+
{PLATFORMS.map((p) => (
75+
<option key={p} value={p}>
76+
{PLATFORM_DISPLAY_NAMES[p]}
77+
</option>
78+
))}
79+
</select>
80+
</div>
81+
<div className="search-filters__separator" />
82+
<div className="search-filters__group">
83+
<span className="search-filters__label">Version:</span>
84+
<select
85+
className="search-filters__select"
86+
value={genFilter}
87+
onChange={(e) => onGenChange(e.target.value as GenFilter)}
88+
aria-label="Generation filter"
89+
>
90+
<option value="gen2">{GEN_DISPLAY.gen2}</option>
91+
<option value="gen1">{GEN_DISPLAY.gen1}</option>
92+
<option value="both">{GEN_DISPLAY.both}</option>
93+
</select>
94+
</div>
95+
</div>,
96+
portalContainer
97+
);
98+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import * as React from 'react';
2+
import { render, waitFor, act, fireEvent } 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+
async function waitForFilters() {
46+
await waitFor(() => {
47+
expect(mockModal.querySelector('.search-filters')).toBeInTheDocument();
48+
});
49+
}
50+
51+
it('should render both select dropdowns inside the modal', async () => {
52+
renderFilters();
53+
await waitForFilters();
54+
const selects = mockModal.querySelectorAll('.search-filters__select');
55+
expect(selects.length).toBe(2);
56+
});
57+
58+
it('should include "All" option in platform select', async () => {
59+
renderFilters();
60+
await waitForFilters();
61+
const platformSelect = mockModal.querySelectorAll('.search-filters__select')[0] as HTMLSelectElement;
62+
const options = Array.from(platformSelect.options).map((o) => o.value);
63+
expect(options).toContain('all');
64+
expect(options).toContain('react');
65+
});
66+
67+
it('should include Gen options in version select', async () => {
68+
renderFilters();
69+
await waitForFilters();
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 waitForFilters();
78+
const platformSelect = mockModal.querySelectorAll('.search-filters__select')[0] as HTMLSelectElement;
79+
expect(platformSelect.value).toBe('swift');
80+
});
81+
82+
it('should call onPlatformChange when platform is changed', async () => {
83+
renderFilters('react', 'gen2');
84+
await waitForFilters();
85+
const platformSelect = mockModal.querySelectorAll('.search-filters__select')[0] as HTMLSelectElement;
86+
await act(async () => {
87+
fireEvent.change(platformSelect, { target: { value: 'angular' } });
88+
});
89+
expect(onPlatformChange).toHaveBeenCalledWith('angular');
90+
});
91+
92+
it('should call onGenChange when version is changed', async () => {
93+
renderFilters('react', 'gen2');
94+
await waitForFilters();
95+
const genSelect = mockModal.querySelectorAll('.search-filters__select')[1] as HTMLSelectElement;
96+
await act(async () => {
97+
fireEvent.change(genSelect, { target: { value: 'gen1' } });
98+
});
99+
expect(onGenChange).toHaveBeenCalledWith('gen1');
100+
});
101+
});

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)