Skip to content

Commit

Permalink
feat: add search functionality (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
hobroker authored May 8, 2022
1 parent 99a5958 commit 1bcee36
Show file tree
Hide file tree
Showing 20 changed files with 499 additions and 109 deletions.
13 changes: 9 additions & 4 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ type FullShow {
name: String!
originCountry: String!
status: Status!
tallImage: String!
wideImage: String!
tallImage: String
wideImage: String
}

input FullShowInput {
Expand Down Expand Up @@ -77,8 +77,8 @@ type PartialShow {
name: String!
originCountry: String!
status: Status!
tallImage: String!
wideImage: String!
tallImage: String
wideImage: String
}

type Preference {
Expand All @@ -102,6 +102,7 @@ type Query {
listUpNext: [Episode!]!
listUpcoming: [Episode!]!
me: User!
search(input: SearchInput!): [PartialShow!]!
}

type Review {
Expand All @@ -112,6 +113,10 @@ type Review {
user: User!
}

input SearchInput {
query: String!
}

type Season {
airDate: DateTime!
description: String
Expand Down
6 changes: 3 additions & 3 deletions src/components/IndefiniteLoading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import React from 'react';
import { Box, CircularProgress } from '@mui/material';

export enum IndefiniteLoadingSize {
small = 24,
large = 48,
Small = 24,
Large = 48,
}

interface Props {
size?: IndefiniteLoadingSize;
}

const IndefiniteLoading = ({ size = IndefiniteLoadingSize.large }: Props) => (
const IndefiniteLoading = ({ size = IndefiniteLoadingSize.Large }: Props) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<CircularProgress color="inherit" size={size} thickness={4} />
</Box>
Expand Down
7 changes: 5 additions & 2 deletions src/features/navigation/components/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { styled } from '@mui/material/styles';
import NavigationProvider from '../contexts/NavigationContext';
import LogoText from '../../logo/components/LogoText';
import { StaticRoute } from '../../router/constants';
import SearchProvider from '../../search/contexts/SearchContext';
import Search from '../../search/components/Search';
import ElevationScroll from './ElevationScroll';
import Search from './Search';
import NotificationsBadge from './NotificationsBadge';
import UserItem from './UserItem/UserItem';

Expand All @@ -24,7 +25,9 @@ const Navigation = () => (
</Button>
</Box>
<Box sx={{ mr: 1, flexGrow: 1 }}>
<Search />
<SearchProvider>
<Search />
</SearchProvider>
</Box>
<Box sx={{ mr: 1 }}>
<Button onClick={() => {}} href={StaticRoute.MyShows}>
Expand Down
44 changes: 0 additions & 44 deletions src/features/navigation/components/Search.tsx

This file was deleted.

51 changes: 51 additions & 0 deletions src/features/search/components/ListItemSearchResult.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { styled } from '@mui/material/styles';
import { ListItemButton, ListItemText, Typography } from '@mui/material';
import { DynamicRoute } from '../../router/constants';
import { slugifyShow } from '../../shows/utils/slugify';
import CustomImage from '../../shows/components/CustomImage';

const Text = styled(Typography)`
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box !important;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
white-space: normal;
`;

interface Props {
externalId: number;
name: string;
description: string;
wideImage?: string | null;
}

const ListItemSearchResult = ({
wideImage,
externalId,
name,
description,
}: Props) => {
const navigate = useNavigate();
const href = DynamicRoute.Show(slugifyShow({ externalId, name }));
const onClick = useCallback(() => navigate(href), [href, navigate]);

return (
<ListItemButton
sx={{ height: 75, display: 'flex', alignItems: 'flex-start', gap: 1 }}
onClick={onClick}
>
<CustomImage path={wideImage} type="wide" sx={{ height: '100%' }} />
<ListItemText>
<Text variant="body2">
<span style={{ fontWeight: 'bold' }}>{name}</span>
{' - '} {description}
</Text>
</ListItemText>
</ListItemButton>
);
};

export default ListItemSearchResult;
21 changes: 21 additions & 0 deletions src/features/search/components/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React, { useContext, useEffect, useState } from 'react';
import { SearchContext } from '../contexts/SearchContext';
import useDebounce from '../../../hooks/useDebounce';
import SearchInput from './SearchInput';
import SearchContent from './SearchContent';

const Search = () => {
const { setQuery } = useContext(SearchContext);
const [value, setValue] = useState('');
const debouncedQuery = useDebounce(value);

useEffect(() => setQuery(debouncedQuery), [debouncedQuery, setQuery]);

return (
<SearchInput onSearch={setValue} value={value}>
<SearchContent />
</SearchInput>
);
};

export default Search;
47 changes: 47 additions & 0 deletions src/features/search/components/SearchContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { useContext } from 'react';
import { Box, List, Typography } from '@mui/material';
import { SearchContext } from '../contexts/SearchContext';
import IndefiniteLoading, {
IndefiniteLoadingSize,
} from '../../../components/IndefiniteLoading';
import ListItemSearchResult from './ListItemSearchResult';

const SearchContent = () => {
const { loading, query, results } = useContext(SearchContext);

if (loading) {
return (
<Box sx={{ p: 2 }}>
<IndefiniteLoading size={IndefiniteLoadingSize.Small} />
</Box>
);
}

if (query.length === 0) {
return null;
}

if (results.length === 0) {
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center' }}>
<Typography variant="subtitle1">No results found</Typography>
</Box>
);
}

return (
<List>
{results.map(({ externalId, name, wideImage, description }) => (
<ListItemSearchResult
key={externalId}
externalId={externalId}
name={name}
wideImage={wideImage}
description={description}
/>
))}
</List>
);
};

export default SearchContent;
84 changes: 84 additions & 0 deletions src/features/search/components/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, {
ChangeEvent,
PropsWithChildren,
useCallback,
useEffect,
useState,
} from 'react';
import { styled } from '@mui/material/styles';
import { Box, ClickAwayListener, OutlinedInput, Paper } from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import { useLocation } from 'react-router-dom';

const SearchIconWrapper = styled(Box)`
padding-inline: ${({ theme }) => theme.spacing(2)};
height: 100%;
position: absolute;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
`;

const SearchOverlay = styled(Paper)`
max-height: 300px;
position: absolute;
top: 100%;
left: 0;
width: 100%;
overflow-y: scroll;
`;

const StyledInputBase = styled(OutlinedInput)`
color: ${({ theme }) => theme.palette.text.primary};
width: 100%;
z-index: 1;
& .MuiInputBase-input {
padding: ${({ theme }) => theme.spacing(1.5, 1, 1.5, 0)};
padding-left: calc(1rem + ${({ theme }) => theme.spacing(4)});
}
`;

interface Props {
onSearch: (search: string) => void;
value: string;
}

const SearchInput = ({
onSearch,
value,
children,
}: PropsWithChildren<Props>) => {
const { pathname } = useLocation();
const onChange = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => onSearch(value),
[onSearch],
);
const [isFocused, setIsFocused] = useState(false);
const isOverlayOpen = !!value && isFocused;
const onFocus = useCallback(() => setIsFocused(true), []);
const onBlur = useCallback(() => setIsFocused(false), []);

useEffect(() => onSearch(''), [onSearch, pathname]);

return (
<ClickAwayListener onClickAway={onBlur}>
<Box sx={{ position: 'relative' }}>
<SearchIconWrapper>
<SearchIcon color="primary" />
</SearchIconWrapper>
<StyledInputBase
placeholder="Search…"
value={value}
onChange={onChange}
onFocus={onFocus}
/>
<SearchOverlay sx={{ display: isOverlayOpen ? 'initial' : 'none' }}>
{children}
</SearchOverlay>
</Box>
</ClickAwayListener>
);
};

export default SearchInput;
Loading

0 comments on commit 1bcee36

Please sign in to comment.