Skip to content
This repository has been archived by the owner on Apr 12, 2023. It is now read-only.

Commit

Permalink
✨ improve search capabilities (#74)
Browse files Browse the repository at this point in the history
Search is now powered by Postgres full-text search capabilities.
  • Loading branch information
hgwood authored Nov 27, 2020
1 parent a361158 commit e3f98eb
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 54 deletions.
4 changes: 3 additions & 1 deletion .hasura/metadata/functions.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
[]
- function:
schema: public
name: search_resume
8 changes: 8 additions & 0 deletions .hasura/migrations/1606232610783_full_text_search/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
drop function if exists search_resume;

drop index if exists resume_full_text_search_idx;

alter table resume
drop column full_text_search_vector;

drop function if exists to_tsvector_multilang;
23 changes: 23 additions & 0 deletions .hasura/migrations/1606232610783_full_text_search/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
create function to_tsvector_multilang(text) returns tsvector as $$
select to_tsvector('french', $1) ||
to_tsvector('english', $1)
$$ language sql immutable;

alter table resume
add column full_text_search_vector tsvector
generated always as (
setweight(to_tsvector('simple', metadata), 'A')
|| setweight(to_tsvector_multilang(content), 'C')
) stored;

create index resume_full_text_search_idx on resume using gin(full_text_search_vector);

create function search_resume(search text) returns setof resume as $$
select resume.*
from latest_resume
join resume using (uuid, version_date)
where resume.full_text_search_vector @@ websearch_to_tsquery(search)
order by
ts_rank_cd(resume.full_text_search_vector, websearch_to_tsquery(search)) desc,
resume.last_modified desc
$$ language sql stable;
8 changes: 8 additions & 0 deletions app/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,12 @@ export const authorizedFetch = (url, init) => {
});
};

export const abortableAuthorizedFetch = (url, init) => {
const abortController = new AbortController();
init = init || {};
init.signal = abortController.signal;
const promise = authorizedFetch(url, init)
return { promise, abort: () => abortController.abort() };
}

export default auth;
2 changes: 1 addition & 1 deletion app/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ class Header extends Component {
<Typography variant="subheading" color="inherit" className={this.classes.grow}>Home</Typography>
</Link> -
<Link to={`list`}>
<Typography variant="subheading" color="inherit" className={this.classes.grow}>List</Typography>
<Typography variant="subheading" color="inherit" className={this.classes.grow}>Search</Typography>
</Link> -
<Link to={`help`}>
<Typography variant="subheading" color="inherit" className={this.classes.grow}>Help</Typography>
Expand Down
63 changes: 33 additions & 30 deletions app/components/List.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import Paper from '@material-ui/core/Paper';
import Avatar from '@material-ui/core/Avatar';
import SearchIcon from '@material-ui/icons/Search';
import Input from '@material-ui/core/Input';
import { authorizedFetch } from '../auth';
import { abortableAuthorizedFetch } from '../auth';
import debounce from "lodash.debounce";

const theme = createMuiTheme();

Expand Down Expand Up @@ -49,9 +50,6 @@ const styles = theme => ({
position: 'relative',
borderRadius: theme.shape.borderRadius,
background: '#c30030',
marginBottom: '20px',
// borderBottomLeftRadius: 0,
// borderBottomRightRadius: 0,
color: 'white',
backgroundColor: '#c30030',
marginLeft: 0,
Expand All @@ -66,6 +64,9 @@ const styles = theme => ({
alignItems: 'center',
justifyContent: 'center',
},
searchTip: {
marginBottom: '20px'
},
inputRoot: {
color: 'white',
width: '100%',
Expand All @@ -86,42 +87,41 @@ const styles = theme => ({
}
});

const lastModifiedFormatter = new Intl.DateTimeFormat()

class ListAll extends Component {

constructor(props) {
super(props);
const { classes } = props;
this.classes = classes;
this.state = { resumes: [], filteredResumes: [] };

this.filterList = this.filterList.bind(this);
this.state = { resumes: [], abortFetch: () => {} };
}

componentDidMount() {
authorizedFetch(`/resumes`)
.then(res => res.json())
.then(data => {
this.setState({ resumes: data, filteredResumes: data });
});
this.searchResumesWithDebounce = debounce(this.searchResumes.bind(this), 200);
}

componentWillUnmount() {
this.searchResumesWithDebounce.cancel();
}

filterList(event) {
if (event.target.value.length < 3) {
this.setState({ filteredResumes: this.state.resumes });
} else {
this.setState({
filteredResumes: this.state.resumes.filter(resume => {
return JSON.stringify(resume).toLocaleLowerCase().includes(event.target.value.toLocaleLowerCase());
})
searchResumes(search) {
const url = new URL("/resumes", "http://example.com");
url.searchParams.set("search", search);
this.state.abortFetch();
const { promise, abort } = abortableAuthorizedFetch(url.pathname + url.search);
this.setState({ abortFetch: abort })
promise
.then((res) => res.json())
.then((data) => {
this.setState({ resumes: data });
});
}
}

render() {
return (
<div className={classNames(this.classes.layout, this.classes.cardGrid)}>
<h4>Resumes ({this.state.resumes.length})</h4>
<br/><br/>
<div className={this.classes.search}>
<div className={this.classes.searchIcon}>
<SearchIcon />
Expand All @@ -133,9 +133,12 @@ class ListAll extends Component {
root: this.classes.inputRoot,
input: this.classes.inputInput,
}}
onChange={this.filterList}
onChange={event => this.searchResumesWithDebounce(event.target.value)}
/>
</div>
<div className={this.classes.searchTip}>
<small>Tip: use quotes to match exact phrases, the OR keyword to match at least one term, and the minus sign to exclude terms</small>
</div>

<Paper className={this.classes.root}>
<Table className={this.classes.table}>
Expand All @@ -151,7 +154,7 @@ class ListAll extends Component {
</TableRow>
</TableHead>
<TableBody>
{this.state.filteredResumes.map(resume => {
{this.state.resumes.map(resume => {
return (
<TableRow key={resume.uuid}>
<TableCell component="th" scope="resume">
Expand All @@ -162,12 +165,12 @@ class ListAll extends Component {
</TableCell>
<TableCell>
<Link className={this.classes.link} to={`app/${resume.uuid}`}>
{resume.uuid}
Edit
</Link>
</TableCell>
<TableCell>
<Link className={this.classes.link} to={`app/${resume.uuid}/view`}>
{resume.uuid}
View
</Link>
</TableCell>
<TableCell>
Expand All @@ -177,7 +180,7 @@ class ListAll extends Component {
{resume.metadata.lang}
</TableCell>
<TableCell>
{resume.last_modified}
{lastModifiedFormatter.format(new Date(resume.last_modified))}
</TableCell>
</TableRow>
);
Expand All @@ -194,4 +197,4 @@ ListAll.propTypes = {
classes: PropTypes.object.isRequired,
};

export default withStyles(styles)(ListAll);
export default withStyles(styles)(ListAll);
72 changes: 50 additions & 22 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,29 +268,57 @@ api.get("/resumes/mine", jwtCheck, async (req, res) => {

// API
api.get("/resumes", jwtCheck, (req, res) => {
executeQueryWithCallback(
`
{
resume: latest_resume(order_by: {last_modified: desc}) {
uuid
metadata
path
version
last_modified
const { search } = req.query;
if (search && search.length > 0) {
executeQueryWithCallback(
`query ($search: String) {
resume: search_resume(args: {search: $search} limit: 50) {
uuid
metadata
path
version
last_modified
}
}`,
{ search },
req,
res,
function (data) {
res.status(200).json(
data.rows.map(row => {
row.metadata = JSON.parse(row.metadata);
return row;
})
);
}
}`,
undefined,
req,
res,
function(data) {
res.status(200).json(
data.rows.map(row => {
row.metadata = JSON.parse(row.metadata);
return row;
})
);
}
);
);
} else if (search === "") {
res.status(200).json([]);
} else {
executeQueryWithCallback(
`
{
resume: latest_resume(order_by: {last_modified: desc}) {
uuid
metadata
path
version
last_modified
}
}`,
undefined,
req,
res,
function (data) {
res.status(200).json(
data.rows.map(row => {
row.metadata = JSON.parse(row.metadata);
return row;
})
);
}
);
}
});

api.get("/resumes/complete", authApi, (req, res) => {
Expand Down

0 comments on commit e3f98eb

Please sign in to comment.