feat: implemented support for preselected sort order and filters

Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
Raul Kele 2022-11-08 12:31:56 +02:00
parent e0f71d6a98
commit 226588e991
11 changed files with 188 additions and 26 deletions

View File

@ -3,7 +3,9 @@ import userEvent from '@testing-library/user-event';
import { api } from 'api';
import Explore from 'components/Explore';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { createSearchParams, MemoryRouter } from 'react-router-dom';
import filterConstants from 'utilities/filterConstants.js';
import { sortByCriteria } from 'utilities/sortCriteria.js';
// router mock
const mockedUsedNavigate = jest.fn();
@ -15,7 +17,7 @@ jest.mock('react-router-dom', () => ({
const StateExploreWrapper = (props) => {
const queryString = props.search || '';
return (
<MemoryRouter initialEntries={[queryString]}>
<MemoryRouter initialEntries={[`/explore?${queryString.toString()}`]}>
<Explore />
</MemoryRouter>
);
@ -196,4 +198,20 @@ describe('Explore component', () => {
userEvent.click(newOption);
expect(await screen.findByText('Alphabetical')).toBeInTheDocument();
});
it('should get preselected filters and sorting order from query params', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(
<StateExploreWrapper
search={createSearchParams({
filter: filterConstants.osFilters[0].value,
sortby: sortByCriteria.downloads.value
})}
/>
);
const sortyBySelect = await screen.findByText(sortByCriteria.downloads.label);
expect(sortyBySelect).toBeInTheDocument();
const filterCheckboxes = await screen.findAllByRole('checkbox');
expect(filterCheckboxes[0]).toBeChecked();
});
});

View File

@ -1,20 +1,23 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import FilterCard from 'components/FilterCard';
import React from 'react';
import React, { useState } from 'react';
import filterConstants from 'utilities/filterConstants';
const StateFilterCardWrapper = () => {
return <FilterCard title="Products" filters={filterConstants.osFilters} updateFilters={() => {}} filterValue={[]} />;
const [filters, setFilters] = useState([]);
return (
<FilterCard title="Products" filters={filterConstants.osFilters} updateFilters={setFilters} filterValue={filters} />
);
};
describe('Filters components', () => {
it('renders the filters cards', () => {
it('renders the filters cards', async () => {
render(<StateFilterCardWrapper />);
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
const checkbox = screen.getAllByRole('checkbox');
expect(checkbox[0]).not.toBeChecked();
fireEvent.click(checkbox[0]);
expect(checkbox[0]).toBeChecked();
await waitFor(() => expect(checkbox[0]).toBeChecked());
});
});

View File

@ -1,7 +1,9 @@
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import Home from 'components/Home';
import React from 'react';
import { createSearchParams } from 'react-router-dom';
import { sortByCriteria } from 'utilities/sortCriteria';
// useNavigate mock
const mockedUsedNavigate = jest.fn();
@ -158,4 +160,21 @@ describe('Home component', () => {
render(<Home />);
await waitFor(() => expect(error).toBeCalledTimes(1));
});
it('should redirect to explore page when clicking view all popular', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<Home />);
const viewAllButtons = await screen.findAllByText(/view all/i);
expect(viewAllButtons).toHaveLength(2);
fireEvent.click(viewAllButtons[0]);
expect(mockedUsedNavigate).toHaveBeenCalledWith({
pathname: `/explore`,
search: createSearchParams({ sortby: sortByCriteria.downloads.value }).toString()
});
fireEvent.click(viewAllButtons[1]);
expect(mockedUsedNavigate).toHaveBeenCalledWith({
pathname: `/explore`,
search: createSearchParams({ sortby: sortByCriteria.updateTime.value }).toString()
});
});
});

View File

@ -2,15 +2,16 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import RepoDetails from 'components/RepoDetails';
import React from 'react';
import { api } from 'api';
import { createSearchParams } from 'react-router-dom';
// uselocation mock
const mockUseLocationValue = {
pathname: "'localhost:3000/image/test'",
search: '',
hash: '',
state: { lastDate: '' }
search: ''
};
const mockUseNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => {
@ -19,7 +20,7 @@ jest.mock('react-router-dom', () => ({
useLocation: () => {
return mockUseLocationValue;
},
useNavigate: () => jest.fn()
useNavigate: () => mockUseNavigate
}));
const mockRepoDetailsData = {
@ -39,7 +40,13 @@ const mockRepoDetailsData = {
MaxSeverity: 'CRITICAL',
Count: 15
}
}
},
Platforms: [
{
Os: 'linux',
Arch: 'amd64'
}
]
}
}
};
@ -201,4 +208,15 @@ describe('Repo details component', () => {
expect(await screen.findByTestId('tags-container')).toBeInTheDocument();
expect(screen.queryByTestId('overview-container')).not.toBeInTheDocument();
});
it('should render platform chips and they should redirect to explore page', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockRepoDetailsData } });
render(<RepoDetails />);
const osChip = await screen.findByText(/linux/i);
fireEvent.click(osChip);
expect(mockUseNavigate).toHaveBeenCalledWith({
pathname: '/explore',
search: createSearchParams({ filter: 'linux' }).toString()
});
});
});

View File

@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import userEvent from '@testing-library/user-event';
import RepoCard from 'components/RepoCard';
import { createSearchParams } from 'react-router-dom';
// usenavigate mock
const mockedUsedNavigate = jest.fn();
@ -19,7 +20,8 @@ const mockImage = {
licenses: '',
vendor: '',
size: '585',
tags: ''
tags: '',
platforms: [{ Os: 'linux', Arch: 'amd64' }]
};
afterEach(() => {
@ -44,4 +46,24 @@ describe('Repo card component', () => {
userEvent.click(cardTitle);
expect(mockedUsedNavigate).toBeCalledWith(`/image/${mockImage.name}`);
});
it('navigates to explore page when platform chip is clicked', async () => {
render(
<RepoCard
name={mockImage.name}
version={mockImage.latestVersion}
description={mockImage.description}
vendor={mockImage.vendor}
key={1}
lastUpdated={mockImage.lastUpdated}
platforms={mockImage.platforms}
/>
);
const osChip = await screen.findByText(/linux/i);
fireEvent.click(osChip);
expect(mockedUsedNavigate).toHaveBeenCalledWith({
pathname: '/explore',
search: createSearchParams({ filter: 'linux' }).toString()
});
});
});

View File

@ -85,7 +85,7 @@ const endpoints = {
sortBy = sortByCriteria.relevance.value,
filter = {}
}) => {
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;
const searchParam = !isEmpty(searchQuery) ? `query:"${searchQuery}"` : `query:""`;
const paginationParam = `requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
} sortBy: ${sortBy}}`;

View File

@ -84,6 +84,27 @@ function Explore() {
return filter;
};
const deconstructFilterQuery = () => {
const preselectedFilter = queryParams.get('filter');
if (!isEmpty(preselectedFilter)) {
if (filterConstants.osFilters.map((f) => f.value).includes(preselectedFilter)) {
setOSFilters([...osFilters, preselectedFilter]);
} else if (filterConstants.archFilters.map((f) => f.value).includes(preselectedFilter)) {
setArchFilters([...archFilters, preselectedFilter]);
}
queryParams.delete('filter');
}
const preselectedSortOrder = queryParams.get('sortby');
if (!isEmpty(preselectedSortOrder)) {
const debug = Object.values(sortByCriteria);
const sortFilterValue = debug.find((sbc) => sbc.value === preselectedSortOrder);
if (sortFilterValue) {
setSortFilter(sortFilterValue.value);
}
queryParams.delete('sortby');
}
};
const getPaginatedResults = () => {
setIsLoading(true);
api
@ -139,6 +160,11 @@ function Explore() {
resetPagination();
}, [search, queryParams, imageFilters, osFilters, archFilters, sortFilter]);
// on component mount, check query params for preselected filters
useEffect(() => {
deconstructFilterQuery();
}, []);
const handleSortChange = (event) => {
setSortFilter(event.target.value);
};

View File

@ -1,5 +1,6 @@
import { Card, CardContent, Checkbox, FormControlLabel, Stack, Tooltip, Typography } from '@mui/material';
import { makeStyles } from '@mui/styles';
import { isBoolean, isArray } from 'lodash';
import React from 'react';
const useStyles = makeStyles(() => ({
@ -22,7 +23,7 @@ function FilterCard(props) {
const handleFilterClicked = (event, changedFilterLabel, changedFilterValue) => {
const { checked } = event.target;
// since checkboxes are controlled, we first have to manually perform the checkbox checked update
if (checked) {
if (filters[0]?.type === 'boolean') {
updateFilters(checked);
@ -40,6 +41,14 @@ function FilterCard(props) {
}
};
const getCheckboxStatus = (label) => {
if (isArray(filterValue)) {
return filterValue?.includes(label);
} else if (isBoolean(filterValue)) {
return filterValue;
}
};
const getFilterRows = () => {
const filterRows = filters;
return filterRows.map((filter, index) => {
@ -50,7 +59,7 @@ function FilterCard(props) {
control={<Checkbox />}
label={filter.label}
id={title}
// checked={filter.label === selectedFilter}
checked={getCheckboxStatus(filter.label)}
onChange={() => handleFilterClicked(event, filter.label, filter.value)}
/>
</Tooltip>

View File

@ -6,6 +6,8 @@ import React, { useEffect, useMemo, useState } from 'react';
import RepoCard from './RepoCard';
import { mapToRepo } from 'utilities/objectModels';
import Loading from './Loading';
import { useNavigate, createSearchParams } from 'react-router-dom';
import { sortByCriteria } from 'utilities/sortCriteria';
const useStyles = makeStyles(() => ({
gridWrapper: {
@ -57,12 +59,19 @@ const useStyles = makeStyles(() => ({
lineHeight: '150%',
letterSpacing: '0.009375rem',
width: '65%'
},
viewAll: {
color: '#00000099',
cursor: 'pointer',
textAlign: 'right',
width: '100%'
}
}));
function Home() {
const [isLoading, setIsLoading] = useState(true);
const [homeData, setHomeData] = useState([]);
const navigate = useNavigate();
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
@ -89,6 +98,10 @@ function Home() {
};
}, []);
const handleClickViewAll = (target) => {
navigate({ pathname: `/explore`, search: createSearchParams({ sortby: target }).toString() });
};
const renderMostPopular = () => {
return (
homeData &&
@ -180,15 +193,31 @@ function Home() {
</Typography>
</Stack>
</Grid>
{/* currently most popular will be by downloads until stars are implemented */}
<Typography
variant="body2"
className={classes.viewAll}
onClick={() => handleClickViewAll(sortByCriteria.downloads.value)}
>
View all
</Typography>
{renderMostPopular()}
{/* <Typography variant="h4" align="left" className={classes.sectionTitle}>
Bookmarks
</Typography>
{renderBookmarks()} */}
<Stack></Stack>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Recently updated images
</Typography>
<Stack justifyContent="space-between" alignItems="end" direction="row" sx={{ width: '100%' }}>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Recently updated images
</Typography>
<Typography
variant="body2"
className={classes.viewAll}
onClick={() => handleClickViewAll(sortByCriteria.updateTime.value)}
>
View all
</Typography>
</Stack>
{renderRecentlyUpdated()}
</Stack>
)}

View File

@ -1,6 +1,6 @@
// react global
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, createSearchParams } from 'react-router-dom';
// utility
import { DateTime } from 'luxon';
@ -111,14 +111,21 @@ function RepoCard(props) {
navigate(`/image/${encodeURIComponent(name)}`);
};
const handlePlatformChipClick = (event) => {
const { textContent } = event.target;
event.stopPropagation();
event.preventDefault();
navigate({ pathname: `/explore`, search: createSearchParams({ filter: textContent }).toString() });
};
const platformChips = () => {
// if platforms not received, mock data
const platformsOsArch = platforms || [];
return platformsOsArch.map((platform, index) => (
<Stack key={`stack${platform?.Os}${platform?.Arch}`} alignItems="center" direction="row" spacing={2}>
<Chip
key={`${name}${platform?.Os}${index}`}
label={platform?.Os}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
@ -128,6 +135,7 @@ function RepoCard(props) {
<Chip
key={`${name}${platform?.Arch}${index}`}
label={platform?.Arch}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
@ -152,7 +160,7 @@ function RepoCard(props) {
return (
<Card variant="outlined" className={classes.card}>
<CardActionArea
onClick={() => goToDetails()}
onClick={goToDetails}
classes={{
root: classes.cardBtn,
focusHighlight: classes.focusHighlight

View File

@ -1,9 +1,9 @@
// react global
import { useParams } from 'react-router-dom';
import React, { useEffect, useMemo, useState } from 'react';
// utility
import { api, endpoints } from '../api';
import { useParams, useNavigate, createSearchParams } from 'react-router-dom';
// components
import Tags from './Tags.jsx';
@ -128,6 +128,7 @@ function RepoDetails() {
const [selectedTab, setSelectedTab] = useState('Overview');
// get url param from <Route here (i.e. image name)
const { name } = useParams();
const navigate = useNavigate();
const abortController = useMemo(() => new AbortController(), []);
const classes = useStyles();
@ -154,6 +155,13 @@ function RepoDetails() {
};
}, [name]);
const handlePlatformChipClick = (event) => {
const { textContent } = event.target;
event.stopPropagation();
event.preventDefault();
navigate({ pathname: `/explore`, search: createSearchParams({ filter: textContent }).toString() });
};
const platformChips = () => {
const platforms = repoDetailData?.platforms || [];
@ -162,6 +170,7 @@ function RepoDetails() {
<Chip
key={`${name}${platform?.Os}${index}`}
label={platform?.Os}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',
@ -171,6 +180,7 @@ function RepoDetails() {
<Chip
key={`${name}${platform?.Arch}${index}`}
label={platform?.Arch}
onClick={handlePlatformChipClick}
sx={{
backgroundColor: '#E0E5EB',
color: '#52637A',