Skip to content

Commit 08064b7

Browse files
authored
Merge pull request #2347 from ably/WEB-4087
[WEB-4087] - New Examples index page with filtering & search
2 parents 0d520dd + 64512e7 commit 08064b7

24 files changed

+710
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from 'react';
2+
import Icon from '@ably/ui/core/Icon';
3+
import cn from '@ably/ui/core/utils/cn';
4+
5+
const ExamplesCheckbox = ({
6+
label,
7+
name,
8+
value,
9+
disabled = false,
10+
isChecked = false,
11+
handleSelect,
12+
}: {
13+
label: string;
14+
name: string;
15+
value: string;
16+
disabled?: boolean;
17+
isChecked?: boolean;
18+
handleSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
19+
}) => {
20+
return (
21+
<div className="flex items-center mb-0">
22+
<input
23+
data-ui-checkbox-native=""
24+
type="checkbox"
25+
id={name}
26+
data-testid={name}
27+
name={name}
28+
className="ui-checkbox-input"
29+
value={value}
30+
checked={isChecked}
31+
disabled={disabled}
32+
onChange={(e) => handleSelect(e)}
33+
/>
34+
<div
35+
data-ui-checkbox-styled=""
36+
className={cn(['ui-checkbox-styled', disabled && '!border-neutral-800 !bg-orange-600'])}
37+
>
38+
<Icon
39+
size="1rem"
40+
name="icon-gui-check-outline"
41+
additionalCSS={cn(['ui-checkbox-styled-tick cursor-pointer', disabled && 'text-neutral-000'])}
42+
/>
43+
</div>
44+
<label htmlFor={name} className="ui-text-p4 text-neutral-900">
45+
{label}
46+
</label>
47+
</div>
48+
);
49+
};
50+
51+
export default ExamplesCheckbox;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor, queryByText } from '@testing-library/react';
3+
import '@testing-library/jest-dom/extend-expect';
4+
import ExamplesContent from './ExamplesContent';
5+
import examples, { Example } from '../../data/examples';
6+
import { ImageProps } from '../Image';
7+
8+
jest.mock('gatsby-plugin-image', () => {
9+
return {
10+
StaticImage: jest.fn(({ alt }) => <img alt={alt} />),
11+
};
12+
});
13+
14+
jest.mock('./ExamplesGrid', () => {
15+
return jest.fn(({ examples }) => (
16+
<div>
17+
{examples.map((example: Example) => (
18+
<div key={example.name}>{example.name}</div>
19+
))}
20+
</div>
21+
));
22+
});
23+
24+
jest.mock('./ExamplesNoResults', () => {
25+
return jest.fn(() => <div>No results found</div>);
26+
});
27+
28+
describe('ExamplesContent', () => {
29+
const exampleImages = [{ src: 'image1.png' }, { src: 'image2.png' }] as ImageProps[];
30+
31+
it('renders the ExamplesContent component', () => {
32+
render(<ExamplesContent exampleImages={exampleImages} />);
33+
expect(screen.getByText('Examples')).toBeInTheDocument();
34+
expect(
35+
screen.getByText(
36+
'From avatar stacks to live cursors, learn how deliver live chat, multiplayer collaboration features, and more.',
37+
),
38+
).toBeInTheDocument();
39+
});
40+
41+
it('renders the ExamplesGrid with filtered examples', () => {
42+
render(<ExamplesContent exampleImages={exampleImages} />);
43+
examples.examples.forEach((example) => {
44+
expect(screen.getByText(example.name)).toBeInTheDocument();
45+
});
46+
});
47+
48+
it('filters examples based on search input (no results)', () => {
49+
render(<ExamplesContent exampleImages={exampleImages} />);
50+
const searchInput = screen.getByPlaceholderText('Find an example');
51+
fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
52+
expect(screen.getByText('No results found')).toBeInTheDocument();
53+
expect(screen.queryByText('Member location')).not.toBeInTheDocument();
54+
expect(screen.queryByText('Avatar stack')).not.toBeInTheDocument();
55+
});
56+
57+
it('filters examples based on search input (with results)', () => {
58+
render(<ExamplesContent exampleImages={exampleImages} />);
59+
const searchInput = screen.getByPlaceholderText('Find an example');
60+
fireEvent.change(searchInput, { target: { value: 'avatar' } });
61+
expect(screen.queryByText('No results found')).not.toBeInTheDocument();
62+
expect(screen.queryByText('Member location')).not.toBeInTheDocument();
63+
expect(screen.getByText('Avatar stack')).toBeInTheDocument();
64+
});
65+
66+
it('filters examples based on product filter selection', () => {
67+
// Make the screen wider than the default of 1024px as desktop filtering only works >= 1040px
68+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 });
69+
window.dispatchEvent(new Event('resize'));
70+
71+
render(<ExamplesContent exampleImages={exampleImages} />);
72+
const productFilterInput = screen.getByTestId('product-spaces');
73+
fireEvent.click(productFilterInput);
74+
75+
expect(screen.queryByText('Avatar stack')).not.toBeInTheDocument();
76+
expect(screen.getByText('Member location')).toBeInTheDocument();
77+
});
78+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
2+
import { StaticImage } from 'gatsby-plugin-image';
3+
import ExamplesGrid from './ExamplesGrid';
4+
import ExamplesFilter from './ExamplesFilter';
5+
import { ImageProps } from '../Image';
6+
import examples from '../../data/examples';
7+
import { filterSearchExamples } from './filter-search-examples';
8+
import ExamplesNoResults from './ExamplesNoResults';
9+
10+
export type SelectedFilters = { products: string[]; useCases: string[] };
11+
12+
const ExamplesContent = ({ exampleImages }: { exampleImages: ImageProps[] }) => {
13+
const [selected, setSelected] = useState<SelectedFilters>({ products: [], useCases: [] });
14+
const [searchTerm, setSearchTerm] = useState('');
15+
const [filteredExamples, setFilteredExamples] = useState(examples.examples);
16+
17+
const handleSearch = useCallback((e: ChangeEvent<HTMLInputElement>) => {
18+
setSearchTerm(e.target.value);
19+
}, []);
20+
21+
useEffect(() => {
22+
const filteredExamples = filterSearchExamples(examples.examples, selected, searchTerm);
23+
setFilteredExamples(filteredExamples);
24+
}, [selected, searchTerm]);
25+
26+
return (
27+
<>
28+
<section className="mx-auto px-24 md:px-0 max-w-[1152px] relative">
29+
<div className="w-full sm:w-1/2 max-w-[600px] pt-80 sm:pt-96">
30+
<h1 className="ui-text-title text-title">Examples</h1>
31+
<p className="ui-text-sub-header mt-16">
32+
From avatar stacks to live cursors, learn how deliver live chat, multiplayer collaboration features, and
33+
more.
34+
</p>
35+
</div>
36+
<div className="w-full my-40 sm:my-64 flex flex-col sm:flex-row gap-x-40">
37+
<div className="w-full sm:w-[20%] relative">
38+
<ExamplesFilter selected={selected} setSelected={setSelected} handleSearch={handleSearch} />
39+
</div>
40+
<div className="w-full sm:w-[80%] mt-40 sm:mt-0">
41+
{filteredExamples.length > 0 ? (
42+
<ExamplesGrid exampleImages={exampleImages} examples={filteredExamples} searchTerm={searchTerm} />
43+
) : (
44+
<ExamplesNoResults />
45+
)}
46+
</div>
47+
</div>
48+
</section>
49+
50+
<StaticImage
51+
src="./images/GridPattern.png"
52+
placeholder="blurred"
53+
width={660}
54+
height={282}
55+
alt="Grid Pattern"
56+
className="!absolute -z-10 right-0 top-64 !hidden sm:!block w-[60%] md:w-[40%]"
57+
/>
58+
59+
<StaticImage
60+
src="./images/GridMobile.png"
61+
placeholder="blurred"
62+
width={260}
63+
alt="Grid Pattern"
64+
className="-z-10 right-0 top-64 !absolute !block sm:!hidden"
65+
/>
66+
</>
67+
);
68+
};
69+
70+
export default ExamplesContent;
+186
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import React, { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2+
import ReactDOM from 'react-dom';
3+
import Icon from '@ably/ui/core/Icon';
4+
import { products } from '@ably/ui/core/ProductTile/data';
5+
import ExamplesCheckbox from './ExamplesCheckbox';
6+
import examples from '../../data/examples';
7+
import Button from '@ably/ui/core/Button';
8+
import cn from '@ably/ui/core/utils/cn';
9+
import { useOnClickOutside } from 'src/hooks';
10+
import Badge from '@ably/ui/core/Badge';
11+
import { SelectedFilters } from './ExamplesContent';
12+
13+
const ExamplesFilter = ({
14+
selected,
15+
setSelected,
16+
handleSearch,
17+
}: {
18+
selected: SelectedFilters;
19+
setSelected: Dispatch<SetStateAction<SelectedFilters>>;
20+
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
21+
}) => {
22+
const filterMenuRef = useRef<HTMLDivElement>(null);
23+
const [expandFilterMenu, setExpandFilterMenu] = useState(false);
24+
const [localSelected, setLocalSelected] = useState<SelectedFilters>(selected);
25+
26+
const handleSelect = useCallback((e: ChangeEvent<HTMLInputElement>, filterType: keyof SelectedFilters) => {
27+
setLocalSelected((prevSelected) => {
28+
if (e.target.value === 'all') {
29+
return {
30+
...prevSelected,
31+
[filterType]: [],
32+
};
33+
}
34+
35+
const newSelected = prevSelected[filterType].includes(e.target.value)
36+
? prevSelected[filterType].filter((item) => item !== e.target.value)
37+
: [...prevSelected[filterType], e.target.value];
38+
39+
return {
40+
...prevSelected,
41+
[filterType]: Array.from(new Set(newSelected)),
42+
};
43+
});
44+
}, []);
45+
46+
const filters = useMemo(
47+
() => [
48+
{
49+
key: 'product',
50+
data: products,
51+
selected: localSelected.products,
52+
handleSelect: (e: ChangeEvent<HTMLInputElement>) => handleSelect(e, 'products'),
53+
},
54+
{
55+
key: 'use-case',
56+
data: examples.useCases,
57+
selected: localSelected.useCases,
58+
handleSelect: (e: ChangeEvent<HTMLInputElement>) => handleSelect(e, 'useCases'),
59+
},
60+
],
61+
[localSelected.products, localSelected.useCases, handleSelect],
62+
);
63+
64+
const closeFilterMenu = useCallback(() => {
65+
setExpandFilterMenu(false);
66+
setLocalSelected(selected);
67+
}, [selected]);
68+
69+
useOnClickOutside(closeFilterMenu, filterMenuRef);
70+
71+
useEffect(() => {
72+
const handleResize = () => {
73+
if (window.innerWidth >= 1040) {
74+
setExpandFilterMenu(false);
75+
}
76+
};
77+
78+
window.addEventListener('resize', handleResize);
79+
return () => window.removeEventListener('resize', handleResize);
80+
}, []);
81+
82+
useEffect(() => {
83+
if (window.innerWidth >= 1040) {
84+
setSelected(localSelected);
85+
}
86+
}, [expandFilterMenu, localSelected, setSelected]);
87+
88+
const activeFilters = useMemo(
89+
() => selected.products.length + selected.useCases.length,
90+
[selected.products, selected.useCases],
91+
);
92+
93+
const handleApply = () => {
94+
setSelected(localSelected);
95+
setExpandFilterMenu(false);
96+
};
97+
98+
return (
99+
<>
100+
<div className="h-[34px] sm:h-[30px] w-20 absolute left-8 top-4 flex items-center justify-center select-none cursor-default">
101+
<Icon name={'icon-gui-magnifying-glass-outline'} size="1rem" />
102+
</div>
103+
<input
104+
type="search"
105+
className="ui-input pl-36 w-full h-40 sm:h-34 ui-text-p3"
106+
placeholder="Find an example"
107+
autoComplete="off"
108+
aria-label="Search examples"
109+
role="searchbox"
110+
onChange={(e) => handleSearch(e)}
111+
/>
112+
<Button
113+
className="flex sm:hidden mt-16 w-full"
114+
variant="secondary"
115+
leftIcon="icon-gui-adjustments-horizontal-outline"
116+
onClick={() => setExpandFilterMenu(true)}
117+
>
118+
Filter
119+
{activeFilters > 0 ? <Badge>{activeFilters}</Badge> : null}
120+
</Button>
121+
{expandFilterMenu &&
122+
ReactDOM.createPortal(
123+
<div className="fixed inset-0 bg-neutral-1300 opacity-10 z-20" onClick={() => setExpandFilterMenu(false)} />,
124+
document.body,
125+
)}
126+
<div
127+
ref={filterMenuRef}
128+
className={cn(
129+
'fixed bottom-0 bg-white dark:bg-neutral-1300 z-30 w-full left-0 gap-20 mt-20 translate-y-full sm:static sm:translate-y-0 sm:flex sm:flex-col sm:bg-transparent sm:z-0 transition-[transform,colors] sm:transition-colors rounded-t-2xl sm:rounded-none max-h-[calc(100%-64px)] overflow-y-scroll',
130+
{
131+
'translate-y-0': expandFilterMenu,
132+
},
133+
)}
134+
>
135+
<div className="flex justify-between items-center sm:hidden h-64 px-16 py-8 bg-neutral-000 dark:bg-neutral-1300 border border-neutral-300 dark:border-neutral-1000 rounded-t-2xl sm:rounded-none">
136+
<p className="ui-text-p1 font-bold text-neutral-1300 dark:text-neutral-000">Filters</p>
137+
<button onClick={closeFilterMenu} aria-label="Close filter menu">
138+
<Icon name="icon-gui-x-mark-outline" size="24px" />
139+
</button>
140+
</div>
141+
{filters.map(({ key, selected, handleSelect, data }) => (
142+
<div key={key} className="p-16 pt-24">
143+
<p className="ui-text-overline2 font-medium text-neutral-700">{key.replace(/-/g, ' ').toUpperCase()}</p>
144+
<div className="mt-8 flex ui-text-p4 flex-col gap-8">
145+
<ExamplesCheckbox
146+
label="All"
147+
name={`${key}-all`}
148+
value="all"
149+
disabled={selected.length === 0}
150+
isChecked={selected.length === 0}
151+
handleSelect={handleSelect}
152+
/>
153+
{Object.entries(data).map(([itemKey, item]) => (
154+
<ExamplesCheckbox
155+
key={itemKey}
156+
label={item.label}
157+
name={`${key}-${itemKey}`}
158+
value={itemKey}
159+
handleSelect={handleSelect}
160+
isChecked={selected.includes(itemKey)}
161+
/>
162+
))}
163+
</div>
164+
</div>
165+
))}
166+
<div className="sm:hidden p-16 flex gap-12">
167+
<Button
168+
className="w-full flex-1"
169+
variant="primary"
170+
disabled={
171+
localSelected.products.length === selected.products.length &&
172+
localSelected.useCases.length === selected.useCases.length &&
173+
localSelected.products.every((product) => selected.products.includes(product)) &&
174+
localSelected.useCases.every((useCase) => selected.useCases.includes(useCase))
175+
}
176+
onClick={handleApply}
177+
>
178+
Apply
179+
</Button>
180+
</div>
181+
</div>
182+
</>
183+
);
184+
};
185+
186+
export default ExamplesFilter;

0 commit comments

Comments
 (0)