Skip to content

Commit

Permalink
feat(frontend): add search, filter, sorting
Browse files Browse the repository at this point in the history
  • Loading branch information
jerroydmoore committed Jan 3, 2020
1 parent b567ad0 commit b3eebd6
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 28 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ This is the both the present and WIP features list. If there are features you wa
- [x] Client-side caching.
- [ ] Deploy using terraform or cloudformation.
- [ ] Automate deployments within GitHub.
- [ ] Add search capability.
- [ ] Add filtering capabilities: on-site, new, noted, applied, hidden, etc.
- [ ] Add sorting capabilities: posted date, popularity, view count, company.
- [x] Add search capability.
- [x] Add filtering capabilities: on-site, new, noted, applied, hidden, etc.
- [x] Add sorting capabilities: posted date, popularity, view count, company.
- [ ] Add notes capability: users can manage private notes per post.
- [ ] Add capability to upvote or downvote.
- [ ] Add discussions.
Expand Down
122 changes: 122 additions & 0 deletions frontend/src/components/filterControls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { Button, ButtonGroup, Card, Col, Container, Form, FormControl, FormGroup, Row } from 'react-bootstrap';

const DESC = '↗';
const ASC = '↘';

const FilterControls = ({ onChange, searchPattern: initialSearchPattern, sort, filterFlags }) => {
const [searchPattern, setSearchPattern] = useState(initialSearchPattern);

let isDesc = sort && sort[0] === '-';
sort = !isDesc ? sort : sort.substr(1);

function updateFromFromControl(setter) {
return ({ target }) => {
const value = target.type === 'checkbox' ? target.checked : target.value;
setter(value);
};
}

const onFilterClick = (field) => {
if (filterFlags[field]) {
onChange(field, undefined);
} else {
onChange(field, 'y');
}
};
const filterCheckboxGroup = [
{ label: 'On-Site', field: 'onsite' },
{ label: 'Remote', field: 'remote' },
{ label: 'Internships available', field: 'intern' },
{ label: 'Visa', field: 'visa' },
// TODO add more checkboxes to sort by
].map(({ label, field }) => {
return (
<Form.Check
type="checkbox"
key={field}
label={label}
id={`${field}FilterCheckbox`}
checked={filterFlags[field]}
onChange={() => onFilterClick(field)}
inline
/>
);
});

const onSortClick = (field) => {
isDesc = sort === field ? !isDesc : false;
onChange('sort', (isDesc ? '-' : '') + field);
};
const sortingButtons = [
{ label: 'Posted Date', field: 'postedDate' },
{ label: 'Company', field: 'title' },
// TODO add more fields to sort by
].map(({ label, field }) => {
const selected = field === sort;
const variant = selected ? 'primary' : 'light';
const order = selected && isDesc ? DESC : ASC;
return (
<Button key={field} variant={variant} size="sm" onClick={() => onSortClick(field)}>
{label} {order}
</Button>
);
});

const handleSubmit = (event) => {
try {
if (initialSearchPattern !== searchPattern) {
onChange('searchPattern', searchPattern);
}
} catch (err) {
console.error(`filterControl submit error: ${err}`);
}
event.preventDefault();
return false;
};

return (
<Container>
<Card className="filter-controls">
<Card.Body>
<h5>Advanced Search:</h5>
<form onSubmit={handleSubmit}>
<FormGroup as={Row}>
<Form.Label column xs={12} sm={3} md={2} id="searchTitleLabel">
Search:
</Form.Label>
<Col xs={12} sm={9} md={10}>
<FormControl
placeholder='Use regular expessions to search among posts, e.g. "(sf|san francisco)"'
aria-describedby="searchTitleLabel"
value={searchPattern}
onChange={updateFromFromControl(setSearchPattern)}
onBlur={() => onChange('searchPattern', searchPattern)}
/>
</Col>
</FormGroup>
<FormGroup as={Row}>
<Form.Label column xs={12} sm={3} md={2} id="filterLabel">
Filters:
</Form.Label>
<Col xs={12} sm={9} md={10}>
{filterCheckboxGroup}
</Col>
</FormGroup>
<FormGroup as={Row}>
<Form.Label column xs={12} sm={3} md={2} id="sortLabel">
Sort:
</Form.Label>
<Col xs={12} sm={9} md={10}>
<ButtonGroup aria-label="sort job postings">{sortingButtons}</ButtonGroup>
</Col>
</FormGroup>
<input type="submit" style={{ display: 'none' }} />
</form>
</Card.Body>
</Card>
</Container>
);
};

export default FilterControls;
7 changes: 2 additions & 5 deletions frontend/src/components/jobPost.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,14 @@ import { Card } from 'react-bootstrap';
import { Markup } from 'interweave';

const JobPost = ({ job }) => {
const endOfTitle = job.body.indexOf('<p>');
const title = job.body.substr(0, endOfTitle);
const body = job.body.substr(endOfTitle);
return (
<Card className="job-posting">
<Card.Body>
<Card.Title>
<Markup content={title} />
<Markup content={job.title} />
</Card.Title>
<Card.Text>
<Markup content={body} />
<Markup content={job.body} />
<small className="authorship">
Posted on{' '}
<a
Expand Down
103 changes: 89 additions & 14 deletions frontend/src/components/jobPostingsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,26 @@ import { navigate } from 'gatsby';

import { getJobPostings, getMonths } from '../services/jobPostings';

import FilterControls from './filterControls';
import JobPost from './jobPost';
import Layout from '../components/layout';
import MonthPicker from '../components/monthPicker';
import Pagination from './pagination';
import PostPlaceholder from './postPlaceholder';
import SEO from '../components/seo';

const defaultValue = {
page: '1',
hitsPerPage: '20',
sort: 'postedDate',
};
const encodeUriParam = (param) => encodeURIComponent(param).replace(/%20/g, '+');
function gotoJobPostings(month, page = 1, hitsPerPage = 20) {
navigate(`/jobPostings?month=${encodeUriParam(month)}&hitsPerPage=${hitsPerPage}&page=${page}`);
function gotoJobPostingsPage(month, params = '') {
params = params.toString();
if (params.length > 0) {
params = '&' + params;
}
navigate(`/jobPostings?month=${encodeUriParam(month)}${params}`);
}

function validateOptionalDigitParam(value, defaultValue) {
Expand All @@ -21,23 +31,78 @@ function validateOptionalDigitParam(value, defaultValue) {
return parseInt(value, 10);
}
}
function validateStringOfList(value, list, defaultValue) {
value = typeof value === 'undefined' ? defaultValue : value;
if (list.includes(value)) {
return value;
}
}

const JobPostingsPage = ({
month,
page: initialPageValue,
hitsPerPage: initialHitsPerPage,
sort: initialSortValue,
filterFlags,
searchPattern,
}) => {
filterFlags = filterFlags || {};
function updateJobPostingsPage(opts) {
opts = {
page: initialPageValue,
hitsPerPage: initialHitsPerPage,
sort: initialSortValue,
...filterFlags,
searchPattern,
...opts,
};
delete opts.months;
for (const [name, value] of Object.entries(opts)) {
if (typeof value === 'undefined' || value === defaultValue[name]) {
delete opts[name];
}
}
if (opts.page && !opts.hitsPerPage) {
opts.hitsPerPage = defaultValue.hitsPerPage; // always include hitsPerPage when page is present
}
gotoJobPostingsPage(month, new URLSearchParams(opts));
}
console.log(
`render JobPostingsPage(${JSON.stringify({
month,
page: initialPageValue,
searchPattern,
sort: initialSortValue,
filterFlags,
})})`
);

const JobPostingsPage = ({ month, page, hitsPerPage }) => {
console.log(`render JobPostingsPage(${month}, ${page}, ${hitsPerPage})`);
page = validateOptionalDigitParam(page, 1);
hitsPerPage = validateOptionalDigitParam(hitsPerPage, 20);
const page = validateOptionalDigitParam(initialPageValue, defaultValue.page);
const hitsPerPage = validateOptionalDigitParam(initialHitsPerPage, defaultValue.hitsPerPage);
const sort = validateStringOfList(
initialSortValue,
['postedDate', '-postedDate', 'title', '-title'],
defaultValue.sort
);

const areParamsOk = page && hitsPerPage;
const areParamsOk = page && hitsPerPage && sort;

const [errMsg, setErrMsg] = useState(areParamsOk ? null : 'Invalid parameters. Check your query string parameters.');
const [monthList, setMonthList] = useState([]);
const [jobPostings, setJobPostings] = useState(null);
const [jobCount, setJobCount] = useState(0);
const [maxPage, setMaxPage] = useState(1);

// useEffect's second arg cannot accept Objects
const { onsite, remote, intern, visa } = filterFlags;

useEffect(() => {
const filterFlags = { onsite, remote, intern, visa };
console.log(
`Update jobs: ${JSON.stringify({ areParamsOk, month, page, hitsPerPage, searchPattern, filterFlags, sort })}`
);
if (!areParamsOk) {
console.log('params are bad');
console.warn('params are bad');
return;
}
setJobPostings(null);
Expand All @@ -47,7 +112,7 @@ const JobPostingsPage = ({ month, page, hitsPerPage }) => {
setTimeout(() => {
// setTimeout allows the placeholders to render
// while the large datasets render for large datasets.
getJobPostings({ month, page, hitsPerPage })
getJobPostings({ month, page, hitsPerPage, searchPattern, filterFlags, sort })
.then(({ posts, postsTotal, numberOfPages }) => {
setJobPostings(posts);
setJobCount(postsTotal);
Expand All @@ -58,7 +123,7 @@ const JobPostingsPage = ({ month, page, hitsPerPage }) => {
setErrMsg(err.message);
});
}, 1);
}, [areParamsOk, month, page, hitsPerPage]);
}, [areParamsOk, month, page, hitsPerPage, searchPattern, onsite, remote, intern, visa, sort]);
useEffect(() => {
getMonths()
.then(setMonthList)
Expand All @@ -68,19 +133,29 @@ const JobPostingsPage = ({ month, page, hitsPerPage }) => {
});
}, []);

const updatePage = (newPage) => {
gotoJobPostings(month, newPage, hitsPerPage);
const gotoPage = (page) => {
updateJobPostingsPage({ page, hitsPerPage: hitsPerPage });
};

const pagination = !errMsg && jobPostings && jobPostings.length > 0 && (
<Pagination active={page} max={maxPage} onChange={updatePage} />
<Pagination active={page} max={maxPage} onChange={gotoPage} />
);

const onFilterChange = (field, value) => {
if (value === '') {
value = undefined;
}
// when filters change, reset the user back to the first page.
updateJobPostingsPage({ [field]: value, page: undefined });
};

return (
<Layout>
<SEO title={month} />

<FilterControls onChange={onFilterChange} searchPattern={searchPattern} filterFlags={filterFlags} sort={sort} />
<div className="postings-header">
<MonthPicker selected={month} jobCount={jobCount} items={monthList} onChange={gotoJobPostings} />
<MonthPicker selected={month} jobCount={jobCount} items={monthList} onChange={gotoJobPostingsPage} />
{pagination}
</div>
{errMsg && <div>Error: {errMsg}</div>}
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ header {
display: inline-block
}

.filter-controls {
background-color: #F6F6EF;
border: 0;
}

.active-month {
border: #000000 solid 1px;
background: transparent;
Expand Down
18 changes: 15 additions & 3 deletions frontend/src/pages/jobPostings.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@ import React from 'react';
import JobPostingsPage from '../components/jobPostingsPage';
import withLocation from '../components/withLocation';

const JobPostings = (props) => (
<JobPostingsPage month={props.search.month} page={props.search.page} hitsPerPage={props.search.hitsPerPage} />
);
const JobPostings = (props) => {
const { onsite, remote, intern, visa } = props.search;
const filterFlags = { onsite, remote, intern, visa };

return (
<JobPostingsPage
month={props.search.month}
page={props.search.page}
hitsPerPage={props.search.hitsPerPage}
searchPattern={props.search.searchPattern}
filterFlags={filterFlags}
sort={props.search.sort}
/>
);
};

export default withLocation(JobPostings);
Loading

0 comments on commit b3eebd6

Please sign in to comment.