├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── src ├── core │ ├── api.ts │ ├── types.ts │ ├── utils.ts │ └── hooks.ts ├── index.tsx ├── components │ ├── FilterToggle.tsx │ ├── Select.tsx │ ├── CollapsibleList.tsx │ ├── SearchBar.tsx │ ├── ItemsContainer.tsx │ ├── PriceFilter.tsx │ └── ColorFilters.tsx ├── global.css └── Root.tsx ├── README.md ├── .gitignore ├── tsconfig.json ├── package.json └── server ├── index.js └── data.js /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abacaj/react-simple-filter-sort-ecommerce/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abacaj/react-simple-filter-sort-ecommerce/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abacaj/react-simple-filter-sort-ecommerce/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/core/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { QueryClient } from 'react-query'; 3 | 4 | const apiClient = axios.create({ 5 | baseURL: 'http://localhost:3001', 6 | }); 7 | 8 | const queryClient = new QueryClient(); 9 | 10 | export { apiClient, queryClient }; 11 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | export type Product = { 2 | name: string; 3 | color: string[]; 4 | price: number; 5 | category: string; 6 | src: string; 7 | }; 8 | 9 | export type ProductRespone = { 10 | products: Product[]; 11 | maxPrice: number; 12 | }; 13 | 14 | export type ProductSort = 'name' | 'priceAsc' | 'priceDesc'; 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Repo for video 2 | 3 | [React JS filter, search and sort items using react-router v6](https://youtu.be/c3WSziz_u_o) 4 | 5 | 1. run yarn install 6 | 2. run yarn server 7 | 3. run yarn start 8 | 9 | # Getting Started with Create React App 10 | 11 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 12 | -------------------------------------------------------------------------------- /src/core/utils.ts: -------------------------------------------------------------------------------- 1 | export function getUniqueValues(items: V[], key: keyof V): Array { 2 | const set: Set = new Set(); 3 | 4 | items.forEach((item) => { 5 | const value = item[key]; 6 | 7 | if (Array.isArray(value)) { 8 | value.forEach((v: T) => set.add(v)); 9 | } else { 10 | set.add(value as unknown as T); 11 | } 12 | }); 13 | 14 | return Array.from(set); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import Root from 'Root'; 4 | import 'global.css'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import { QueryClientProvider } from 'react-query'; 7 | import { queryClient } from 'core/api'; 8 | 9 | const root = ReactDOM.createRoot( 10 | document.getElementById('root') as HTMLElement, 11 | ); 12 | 13 | root.render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | , 21 | ); 22 | -------------------------------------------------------------------------------- /src/components/FilterToggle.tsx: -------------------------------------------------------------------------------- 1 | export default function FilterToggle({ 2 | visible, 3 | active, 4 | onClear, 5 | onApply, 6 | }: { 7 | visible: boolean; 8 | active: boolean; 9 | onClear: () => void; 10 | onApply: () => void; 11 | }) { 12 | if (active) { 13 | return ( 14 | 17 | ); 18 | } 19 | 20 | if (visible) { 21 | return ( 22 | 25 | ); 26 | } 27 | 28 | return null; 29 | } 30 | -------------------------------------------------------------------------------- /src/core/hooks.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from 'core/api'; 2 | import { useQuery, UseQueryResult } from 'react-query'; 3 | import { ProductRespone } from 'core/types'; 4 | import { useSearchParams } from 'react-router-dom'; 5 | 6 | export function useItems(): UseQueryResult { 7 | const [search] = useSearchParams({ 8 | sort: 'name', 9 | minPrice: '0', 10 | maxPrice: '10000', 11 | }); 12 | 13 | return useQuery( 14 | ['items', search.toString()], 15 | () => 16 | apiClient 17 | .get('items', { 18 | params: search, 19 | }) 20 | .then((res) => res.data), 21 | { 22 | staleTime: 120000, 23 | }, 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Select.tsx: -------------------------------------------------------------------------------- 1 | interface SelectOption { 2 | label: string; 3 | value: string; 4 | } 5 | 6 | export default function Select({ 7 | options, 8 | label, 9 | name, 10 | defaultValue = '', 11 | onChange, 12 | }: { 13 | options: SelectOption[]; 14 | label: string; 15 | name: string; 16 | defaultValue?: string; 17 | onChange: (e: React.ChangeEvent) => void; 18 | }) { 19 | return ( 20 |
21 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/CollapsibleList.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export default function CollapsibleList({ 4 | title, 5 | children, 6 | actionButton, 7 | defaultVisible, 8 | }: { 9 | title: string; 10 | children: React.ReactNode; 11 | actionButton?: React.ReactNode; 12 | defaultVisible?: boolean; 13 | }) { 14 | const [visible, setVisible] = useState(defaultVisible); 15 | 16 | return ( 17 |
18 |
19 | 28 |
{actionButton}
29 |
30 | {visible ?
    {children}
: null} 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { RiSearch2Line } from 'react-icons/ri'; 3 | import { useSearchParams } from 'react-router-dom'; 4 | import { debounce } from 'lodash'; 5 | 6 | export default function SearchBar() { 7 | const [focused, setFocused] = useState(false); 8 | const [search, setSearch] = useSearchParams(); 9 | const onSearchChange = debounce((e: React.ChangeEvent) => { 10 | const text = e.target.value; 11 | 12 | if (text.length === 0) { 13 | search.delete('query'); 14 | setSearch(search, { 15 | replace: true, 16 | }); 17 | } else { 18 | search.set('query', text); 19 | setSearch(search, { 20 | replace: true, 21 | }); 22 | } 23 | }, 350); 24 | 25 | return ( 26 |
29 | 32 | setFocused(false)} 34 | onFocus={() => setFocused((focus) => !focus)} 35 | onChange={onSearchChange} 36 | defaultValue={search.get('query') ?? ''} 37 | id="search" 38 | name="search" 39 | className="bn outline-0" 40 | type="search" 41 | placeholder="Find items by name..." 42 | /> 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/ItemsContainer.tsx: -------------------------------------------------------------------------------- 1 | import { useItems } from 'core/hooks'; 2 | import { BarLoader } from 'react-spinners'; 3 | 4 | export default function ItemsContainer() { 5 | const getProducts = useItems(); 6 | const products = getProducts.data?.products ?? []; 7 | 8 | if (getProducts.isLoading) { 9 | return ( 10 |
11 | 12 |
13 | ); 14 | } 15 | 16 | return ( 17 |
18 |
19 | {products.map((product) => { 20 | return ( 21 | 40 | ); 41 | })} 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-filter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@radix-ui/react-checkbox": "^0.1.5", 7 | "@radix-ui/react-icons": "^1.1.1", 8 | "@radix-ui/react-slider": "^0.1.4", 9 | "@testing-library/jest-dom": "^5.14.1", 10 | "@testing-library/react": "^13.0.0", 11 | "@testing-library/user-event": "^13.2.1", 12 | "@types/jest": "^27.0.1", 13 | "@types/node": "^16.7.13", 14 | "@types/react": "^18.0.0", 15 | "@types/react-dom": "^18.0.0", 16 | "axios": "^0.27.2", 17 | "cors": "^2.8.5", 18 | "express": "^4.18.1", 19 | "lodash": "^4.17.21", 20 | "react": "^18.1.0", 21 | "react-dom": "^18.1.0", 22 | "react-icons": "^4.3.1", 23 | "react-query": "^3.39.0", 24 | "react-router-dom": "^6.3.0", 25 | "react-scripts": "5.0.1", 26 | "react-spinners": "^0.11.0", 27 | "tachyons": "^4.12.0", 28 | "typescript": "^4.4.2", 29 | "web-vitals": "^2.1.0" 30 | }, 31 | "scripts": { 32 | "start": "react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test", 35 | "eject": "react-scripts eject", 36 | "server": "node ./server/index.js" 37 | }, 38 | "eslintConfig": { 39 | "extends": [ 40 | "react-app", 41 | "react-app/jest" 42 | ] 43 | }, 44 | "browserslist": { 45 | "production": [ 46 | ">0.2%", 47 | "not dead", 48 | "not op_mini all" 49 | ], 50 | "development": [ 51 | "last 1 chrome version", 52 | "last 1 firefox version", 53 | "last 1 safari version" 54 | ] 55 | }, 56 | "devDependencies": { 57 | "@types/lodash": "^4.14.182" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @import '~tachyons/css/tachyons.min.css'; 2 | 3 | :root { 4 | --color-primary: #a463f2; 5 | --color-gray-dark: #ccc; 6 | --color-gray: #f9fafb; 7 | 8 | --font-primary: 'Inter', sans-serif; 9 | } 10 | 11 | html, 12 | body { 13 | font-family: var(--font-primary); 14 | } 15 | 16 | .bg-gray { 17 | background-color: var(--color-gray); 18 | } 19 | 20 | .color-gray { 21 | color: var(--color-gray); 22 | } 23 | 24 | .btn { 25 | cursor: pointer; 26 | } 27 | 28 | .absolute-center-y { 29 | top: 50%; 30 | transform: translateY(-50%); 31 | } 32 | 33 | .item-grid { 34 | margin-left: -1rem; 35 | margin-right: -1rem; 36 | } 37 | 38 | .w15 { 39 | width: 1.5rem; 40 | } 41 | 42 | .h15 { 43 | height: 1.5rem; 44 | } 45 | 46 | .w125 { 47 | width: 1.25rem; 48 | } 49 | 50 | .h125 { 51 | height: 1.25rem; 52 | } 53 | 54 | .checkbox { 55 | box-shadow: 0 0 0 1.5px var(--color-gray-dark); 56 | } 57 | 58 | .checkbox:hover, 59 | .checkbox:focus { 60 | border-color: var(--color-primary); 61 | box-shadow: 0 0 0 1.5px var(--color-primary); 62 | } 63 | 64 | .checkbox:hover .checkbox__icon, 65 | .checkbox:focus .checkbox__icon { 66 | color: var(--color-primary); 67 | } 68 | 69 | .checkbox [data-state='checked'] { 70 | display: flex; 71 | } 72 | 73 | .search { 74 | box-shadow: 0 2px 0 0 var(--color-gray-dark); 75 | } 76 | 77 | .search.focused { 78 | box-shadow: 0 2px 0 0 var(--color-primary); 79 | } 80 | 81 | .slider[data-orientation='horizontal'] { 82 | height: 20px; 83 | } 84 | 85 | .slider__track, 86 | .slider__range { 87 | height: 3px; 88 | } 89 | 90 | .slider__range { 91 | content: ''; 92 | display: block; 93 | position: absolute; 94 | } 95 | 96 | .slider__thumb { 97 | box-shadow: 0 0 0 2px var(--color-primary); 98 | } 99 | 100 | .slider__thumb:hover { 101 | background-color: var(--color-primary); 102 | } 103 | 104 | .select { 105 | box-shadow: 0 0 0 2px var(--color-gray-dark); 106 | } 107 | 108 | .select:focus { 109 | box-shadow: 0 0 0 2px var(--color-primary); 110 | } 111 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 19 | 23 | 24 | 33 | React App 34 | 35 | 36 | 37 |
38 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const { isNil } = require('lodash'); 4 | const data = require('./data'); 5 | 6 | const app = express(); 7 | 8 | app.use(cors()); 9 | app.use(express.json()); 10 | app.use(express.urlencoded({ extended: true })); 11 | 12 | function containsColors(colors, product) { 13 | // base case, do not skip products when there are no color filters 14 | if (!colors) return true; 15 | 16 | const selectedColors = new Set(colors.split(',')); 17 | const productColors = product.color; 18 | 19 | // check if any of the product colors are in the filter 20 | for (const color of productColors) { 21 | if (selectedColors.has(color)) { 22 | return true; 23 | } 24 | } 25 | 26 | // does not contain any of the filtered colors, skip this product 27 | return false; 28 | } 29 | 30 | function applyFilters(products, { query, sort, colors, minPrice, maxPrice }) { 31 | const filteredProducts = []; 32 | 33 | // skip products based on filters 34 | for (const product of products) { 35 | if (query && !product.name.toLowerCase().includes(query.toLowerCase())) { 36 | continue; 37 | } 38 | 39 | if (!containsColors(colors, product)) { 40 | continue; 41 | } 42 | 43 | if (!isNil(minPrice) && product.price / 100 < minPrice) { 44 | continue; 45 | } 46 | 47 | if (!isNil(maxPrice) && product.price / 100 > maxPrice) { 48 | continue; 49 | } 50 | 51 | filteredProducts.push(product); 52 | } 53 | 54 | return filteredProducts.sort((a, b) => { 55 | const { name, price } = a; 56 | const { name: nameB, price: priceB } = b; 57 | 58 | switch (sort) { 59 | case 'priceDesc': 60 | return priceB - price; 61 | case 'priceAsc': 62 | return price - priceB; 63 | default: 64 | return name.localeCompare(nameB); 65 | } 66 | }); 67 | } 68 | 69 | app.get('/items', (req, res) => { 70 | // compute the max price for the filter 71 | const maxPrice = Math.round( 72 | Math.max(...data.map((product) => product.price)), 73 | ); 74 | 75 | // fake the request to a backend search service like solr or elasticsearch 76 | setTimeout(() => { 77 | res.json({ products: applyFilters(data, req.query), maxPrice }); 78 | }, 250); 79 | }); 80 | 81 | app.listen(3001, () => { 82 | console.info('server listening on: 3001'); 83 | }); 84 | -------------------------------------------------------------------------------- /src/components/PriceFilter.tsx: -------------------------------------------------------------------------------- 1 | import * as Slider from '@radix-ui/react-slider'; 2 | import { useState } from 'react'; 3 | import { useSearchParams } from 'react-router-dom'; 4 | import CollapsibleList from 'components/CollapsibleList'; 5 | import FilterToggle from 'components/FilterToggle'; 6 | 7 | function PriceFilter({ maxPrice }: { maxPrice: number }) { 8 | const [visible, setVisible] = useState(false); 9 | const [search, setSearch] = useSearchParams(); 10 | const defaultValues = [ 11 | parseInt(search.get('minPrice') ?? '0'), 12 | parseInt(search.get('maxPrice') ?? `${maxPrice}`), 13 | ]; 14 | const [values, setValues] = useState(defaultValues); 15 | const filterActive = search.get('minPrice') !== null; 16 | const onApplyFilter = () => { 17 | search.set('minPrice', `${values[0]}`); 18 | search.set('maxPrice', `${values[1]}`); 19 | setSearch(search, { 20 | replace: true, 21 | }); 22 | }; 23 | 24 | return ( 25 | { 33 | search.delete('minPrice'); 34 | search.delete('maxPrice'); 35 | // clear local state 36 | setValues([0, maxPrice]); 37 | 38 | // clear url state 39 | setSearch(search, { 40 | replace: true, 41 | }); 42 | }} 43 | /> 44 | } 45 | > 46 |
  • 47 |
    48 |
    49 |
    50 |
    51 | ${values[0]} - ${values[1]} 52 |
    53 | { 55 | setValues([values[0], values[1]]); 56 | setVisible(true); 57 | }} 58 | className="flex items-center relative mw-100 slider" 59 | value={values} 60 | min={0} 61 | max={maxPrice} 62 | step={50} 63 | minStepsBetweenThumbs={1} 64 | > 65 | 66 | 67 | 68 | 69 | 70 | 71 |
    72 |
    73 |
    74 |
  • 75 |
    76 | ); 77 | } 78 | 79 | export default function PriceFilterContainer({ 80 | maxPrice, 81 | }: { 82 | maxPrice: number; 83 | }) { 84 | if (maxPrice === 0) return null; 85 | 86 | return ; 87 | } 88 | -------------------------------------------------------------------------------- /src/components/ColorFilters.tsx: -------------------------------------------------------------------------------- 1 | import CollapsibleList from 'components/CollapsibleList'; 2 | import { getUniqueValues } from 'core/utils'; 3 | import { useState } from 'react'; 4 | import { useItems } from 'core/hooks'; 5 | import * as Checkbox from '@radix-ui/react-checkbox'; 6 | import { CheckIcon } from '@radix-ui/react-icons'; 7 | import { useSearchParams } from 'react-router-dom'; 8 | import FilterToggle from 'components/FilterToggle'; 9 | import { Product } from 'core/types'; 10 | 11 | export default function ColorFilters() { 12 | const [search, setSearch] = useSearchParams(); 13 | const filteredColors = search.get('colors')?.split(',') ?? []; 14 | const [colors, setColors] = useState(filteredColors); 15 | const getItems = useItems(); 16 | const items = getItems.data?.products ?? []; 17 | const allColors = getUniqueValues(items, 'color'); 18 | const groupedItems = allColors 19 | .map((color) => ({ 20 | label: color, 21 | name: color, 22 | value: color, 23 | })) 24 | .sort((a, b) => a.name.localeCompare(b.name)); 25 | const onColorChange = (color: string) => (checked: Checkbox.CheckedState) => { 26 | let _colors = colors.slice(); 27 | 28 | if (checked) { 29 | _colors.push(color); 30 | } else { 31 | _colors = _colors.filter((_color) => _color !== color); 32 | } 33 | 34 | setColors(_colors); 35 | }; 36 | const hasFilters = filteredColors.length > 0; 37 | 38 | return ( 39 | 0} 45 | active={hasFilters} 46 | onApply={() => { 47 | search.set('colors', colors.join(',')); 48 | setSearch(search, { 49 | replace: true, 50 | }); 51 | }} 52 | onClear={() => { 53 | search.delete('colors'); 54 | setColors([]); 55 | setSearch(search, { 56 | replace: true, 57 | }); 58 | }} 59 | /> 60 | } 61 | > 62 | {groupedItems 63 | .filter((f) => { 64 | if (filteredColors.length === 0) { 65 | return true; 66 | } 67 | 68 | return filteredColors.includes(f.value); 69 | }) 70 | .map((field, key) => ( 71 |
  • 72 |
    73 | 81 | 82 | 83 | 84 | 85 | 88 |
    89 |
  • 90 | ))} 91 |
    92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/Root.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import SearchBar from 'components/SearchBar'; 3 | import Select from 'components/Select'; 4 | import { useItems } from 'core/hooks'; 5 | import ItemsContainer from 'components/ItemsContainer'; 6 | import { useSearchParams } from 'react-router-dom'; 7 | import ColorFilters from 'components/ColorFilters'; 8 | import PriceFilter from 'components/PriceFilter'; 9 | 10 | export default function Root() { 11 | const [search, setSearch] = useSearchParams(); 12 | const getItems = useItems(); 13 | const items = useMemo(() => getItems.data?.products ?? [], [getItems.data]); 14 | const itemCounts = useMemo( 15 | () => 16 | items.reduce>((initial, item) => { 17 | if (!isNaN(initial[item.category])) { 18 | initial[item.category] += 1; 19 | } else { 20 | initial[item.category] = 1; 21 | } 22 | 23 | return initial; 24 | }, {}), 25 | [items], 26 | ); 27 | const maxPrice = (getItems.data?.maxPrice ?? 0) / 100; 28 | 29 | return ( 30 |
    31 |
    32 |

    New arrivals

    33 | 34 |
    35 | 36 |
    37 | 38 |