feat: implemented support for preselected sort order and filters
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
parent
e0f71d6a98
commit
226588e991
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
@ -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()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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}}`;
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user