Improved search filtering (#940)

* Filter search by collectionId

* Improve spec, remove recursive import

* Add userId filter for documents.search

* 💚

* Search filter UI

* WIP UI

* Date filtering
Prevent dupe menu

* Refactor

* button

* Added year option, improved hover states

* Add new indexes

* Remove manual string interpolation in SQL construction

* Move dateFilter validation to controller

* Fixes: Double query when changing filter
Fixes: Visual jump between filters in dropdown

* Add option to clear filters

* More clearly define dropdowns in dark mode

* Checkbox -> Checkmark
This commit is contained in:
Tom Moor 2019-04-23 07:31:20 -07:00 committed by GitHub
parent a256eba856
commit da7fdfef0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 679 additions and 76 deletions

View File

@ -2,6 +2,7 @@
import * as React from 'react';
import styled from 'styled-components';
import { darken } from 'polished';
import { ExpandedIcon } from 'outline-icons';
const RealButton = styled.button`
display: inline-block;
@ -22,6 +23,10 @@ const RealButton = styled.button`
cursor: pointer;
user-select: none;
svg {
fill: ${props => props.theme.buttonText};
}
&::-moz-focus-inner {
padding: 0;
border: 0;
@ -45,6 +50,10 @@ const RealButton = styled.button`
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px;
border: 1px solid ${darken(0.1, props.theme.buttonNeutralBackground)};
svg {
fill: ${props.theme.buttonNeutralText};
}
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
border: 1px solid ${darken(0.15, props.theme.buttonNeutralBackground)};
@ -70,8 +79,9 @@ const Label = styled.span`
`;
const Inner = styled.span`
padding: 0 ${props => (props.small ? 8 : 12)}px;
display: flex;
padding: 0 ${props => (props.small ? 8 : 12)}px;
padding-right: ${props => (props.disclosure ? 2 : props.small ? 8 : 12)}px;
line-height: ${props => (props.small ? 24 : 28)}px;
justify-content: center;
align-items: center;
@ -88,6 +98,7 @@ export type Props = {
className?: string,
children?: React.Node,
small?: boolean,
disclosure?: boolean,
};
export default function Button({
@ -95,6 +106,7 @@ export default function Button({
icon,
children,
value,
disclosure,
small,
...rest
}: Props) {
@ -103,9 +115,10 @@ export default function Button({
return (
<RealButton small={small} {...rest}>
<Inner hasIcon={hasIcon} small={small}>
<Inner hasIcon={hasIcon} small={small} disclosure={disclosure}>
{hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
{disclosure && <ExpandedIcon />}
</Inner>
</RealButton>
);

View File

@ -25,6 +25,7 @@ const Wrapper = styled.div`
const Label = styled.label`
display: flex;
align-items: center;
user-select: none;
`;
export default function Checkbox({

View File

@ -8,21 +8,32 @@ import styled from 'styled-components';
import Flex from 'shared/components/Flex';
import { fadeAndScaleIn } from 'shared/styles/animations';
let previousClosePortal;
type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {
label: React.Node,
onOpen?: () => void,
onClose?: () => void,
children?: React.Node,
children?: Children,
className?: string,
style?: Object,
leftAlign?: boolean,
};
@observer
class DropdownMenu extends React.Component<Props> {
@observable top: number;
@observable right: number;
@observable left: number;
handleOpen = (openPortal: (SyntheticEvent<*>) => *) => {
handleOpen = (
openPortal: (SyntheticEvent<*>) => void,
closePortal: () => void
) => {
return (ev: SyntheticMouseEvent<*>) => {
ev.preventDefault();
const currentTarget = ev.currentTarget;
@ -32,7 +43,18 @@ class DropdownMenu extends React.Component<Props> {
const bodyRect = document.body.getBoundingClientRect();
const targetRect = currentTarget.getBoundingClientRect();
this.top = targetRect.bottom - bodyRect.top;
this.right = bodyRect.width - targetRect.left - targetRect.width;
if (this.props.leftAlign) {
this.left = targetRect.left;
} else {
this.right = bodyRect.width - targetRect.left - targetRect.width;
}
// attempt to keep only one flyout menu open at once
if (previousClosePortal) {
previousClosePortal();
}
previousClosePortal = closePortal;
openPortal(ev);
}
};
@ -51,18 +73,27 @@ class DropdownMenu extends React.Component<Props> {
>
{({ closePortal, openPortal, portal }) => (
<React.Fragment>
<Label onClick={this.handleOpen(openPortal)}>{label}</Label>
<Label onClick={this.handleOpen(openPortal, closePortal)}>
{label}
</Label>
{portal(
<Menu
onClick={ev => {
ev.stopPropagation();
closePortal();
}}
onClick={
typeof children === 'function'
? undefined
: ev => {
ev.stopPropagation();
closePortal();
}
}
style={this.props.style}
top={this.top}
left={this.left}
right={this.right}
>
{children}
{typeof children === 'function'
? children({ closePortal })
: children}
</Menu>
)}
</React.Fragment>
@ -83,10 +114,11 @@ const Label = styled(Flex).attrs({
const Menu = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
transform-origin: ${({ left }) => (left !== undefined ? '25%' : '75%')} 0;
position: absolute;
right: ${({ right }) => right}px;
${({ left }) => (left !== undefined ? `left: ${left}px` : '')};
${({ right }) => (right !== undefined ? `right: ${right}px` : '')};
top: ${({ top }) => top}px;
z-index: 1000;

View File

@ -3,7 +3,7 @@ import * as React from 'react';
import styled from 'styled-components';
type Props = {
children: string,
children: React.Node,
};
const Empty = (props: Props) => {

View File

@ -96,7 +96,7 @@ export default function Routes() {
/>
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:query" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
<Route component={NotFound} />
</Switch>

View File

@ -3,35 +3,40 @@ import * as React from 'react';
import ReactDOM from 'react-dom';
import keydown from 'react-keydown';
import Waypoint from 'react-waypoint';
import { withRouter } from 'react-router-dom';
import { withRouter, Link } from 'react-router-dom';
import { observable, action } from 'mobx';
import { observer, inject } from 'mobx-react';
import { debounce } from 'lodash';
import queryString from 'query-string';
import styled from 'styled-components';
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
import DocumentsStore from 'stores/DocumentsStore';
import UsersStore from 'stores/UsersStore';
import { searchUrl } from 'utils/routeHelpers';
import { meta } from 'utils/keyboard';
import Flex from 'shared/components/Flex';
import Empty from 'components/Empty';
import Fade from 'components/Fade';
import Checkbox from 'components/Checkbox';
import HelpText from 'components/HelpText';
import CenteredContent from 'components/CenteredContent';
import LoadingIndicator from 'components/LoadingIndicator';
import DocumentPreview from 'components/DocumentPreview';
import PageTitle from 'components/PageTitle';
import SearchField from './components/SearchField';
import StatusFilter from './components/StatusFilter';
import CollectionFilter from './components/CollectionFilter';
import UserFilter from './components/UserFilter';
import DateFilter from './components/DateFilter';
type Props = {
history: Object,
match: Object,
location: Object,
documents: DocumentsStore,
users: UsersStore,
notFound: ?boolean,
};
@ -40,20 +45,24 @@ class Search extends React.Component<Props> {
firstDocument: ?DocumentPreview;
@observable query: string = '';
@observable params: URLSearchParams = new URLSearchParams();
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable isFetching: boolean = false;
@observable includeArchived: boolean = false;
@observable pinToTop: boolean = !!this.props.match.params.query;
@observable pinToTop: boolean = !!this.props.match.params.term;
componentDidMount() {
this.handleTermChange();
this.handleQueryChange();
}
componentDidUpdate(prevProps) {
if (prevProps.match.params.query !== this.props.match.params.query) {
if (prevProps.location.search !== this.props.location.search) {
this.handleQueryChange();
}
if (prevProps.match.params.term !== this.props.match.params.term) {
this.handleTermChange();
}
}
@keydown('esc')
@ -79,7 +88,18 @@ class Search extends React.Component<Props> {
};
handleQueryChange = () => {
const query = this.props.match.params.query;
this.params = new URLSearchParams(this.props.location.search);
this.offset = 0;
this.allowLoadMore = true;
// To prevent "no results" showing before debounce kicks in
this.isFetching = true;
this.fetchResultsDebounced();
};
handleTermChange = () => {
const query = this.props.match.params.term;
this.query = query ? query : '';
this.offset = 0;
this.allowLoadMore = true;
@ -90,11 +110,51 @@ class Search extends React.Component<Props> {
this.fetchResultsDebounced();
};
handleFilterChange = ev => {
this.includeArchived = ev.target.checked;
this.fetchResultsDebounced();
handleFilterChange = search => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
});
};
get includeArchived() {
return this.params.get('includeArchived') === 'true';
}
get collectionId() {
const id = this.params.get('collectionId');
return id ? id : undefined;
}
get userId() {
const id = this.params.get('userId');
return id ? id : undefined;
}
get dateFilter() {
const id = this.params.get('dateFilter');
return id ? id : undefined;
}
get isFiltered() {
return (
this.dateFilter ||
this.userId ||
this.collectionId ||
this.includeArchived
);
}
get title() {
const query = this.query;
const title = 'Search';
if (query) return `${query} ${title}`;
return title;
}
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
@ -113,7 +173,10 @@ class Search extends React.Component<Props> {
const results = await this.props.documents.search(this.query, {
offset: this.offset,
limit: DEFAULT_PAGINATION_LIMIT,
dateFilter: this.dateFilter,
includeArchived: this.includeArchived,
collectionId: this.collectionId,
userId: this.userId,
});
if (results.length > 0) this.pinToTop = true;
@ -136,20 +199,16 @@ class Search extends React.Component<Props> {
});
updateLocation = query => {
this.props.history.replace(searchUrl(query));
this.props.history.replace({
pathname: searchUrl(query),
search: this.props.location.search,
});
};
setFirstDocumentRef = ref => {
this.firstDocument = ref;
};
get title() {
const query = this.query;
const title = 'Search';
if (query) return `${query} - ${title}`;
return title;
}
render() {
const { documents, notFound, location } = this.props;
const results = documents.searchResults(this.query);
@ -183,16 +242,40 @@ class Search extends React.Component<Props> {
)}
{this.pinToTop && (
<Filters>
<Checkbox
label="Include archived"
name="includeArchived"
checked={this.includeArchived}
onChange={this.handleFilterChange}
small
<StatusFilter
includeArchived={this.includeArchived}
onSelect={includeArchived =>
this.handleFilterChange({ includeArchived })
}
/>
<CollectionFilter
collectionId={this.collectionId}
onSelect={collectionId =>
this.handleFilterChange({ collectionId })
}
/>
<UserFilter
userId={this.userId}
onSelect={userId => this.handleFilterChange({ userId })}
/>
<DateFilter
dateFilter={this.dateFilter}
onSelect={dateFilter => this.handleFilterChange({ dateFilter })}
/>
</Filters>
)}
{showEmpty && <Empty>No matching documents.</Empty>}
{showEmpty && (
<Empty>
No results found for search.{' '}
{this.isFiltered && (
<React.Fragment>
&nbsp;<Link to={this.props.location.pathname}>
Clear Filters
</Link>.
</React.Fragment>
)}
</Empty>
)}
<ResultList column visible={this.pinToTop}>
<StyledArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
@ -252,8 +335,13 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
`;
const Filters = styled(Flex)`
border-bottom: 1px solid ${props => props.theme.divider};
margin-bottom: 10px;
margin-bottom: 12px;
opacity: 0.85;
transition: opacity 100ms ease-in-out;
&:hover {
opacity: 1;
}
`;
export default withRouter(inject('documents')(Search));

View File

@ -0,0 +1,39 @@
// @flow
import * as React from 'react';
import { observer, inject } from 'mobx-react';
import FilterOptions from './FilterOptions';
import CollectionsStore from 'stores/CollectionsStore';
const defaultOption = {
key: undefined,
label: 'Any collection',
};
type Props = {
collections: CollectionsStore,
collectionId: ?string,
onSelect: (key: ?string) => void,
};
@observer
class CollectionFilter extends React.Component<Props> {
render() {
const { onSelect, collectionId, collections } = this.props;
const collectionOptions = collections.orderedData.map(user => ({
key: user.id,
label: user.name,
}));
return (
<FilterOptions
options={[defaultOption, ...collectionOptions]}
activeKey={collectionId}
onSelect={onSelect}
defaultLabel="Any collection"
selectedPrefix="Collection:"
/>
);
}
}
export default inject('collections')(CollectionFilter);

View File

@ -0,0 +1,29 @@
// @flow
import * as React from 'react';
import FilterOptions from './FilterOptions';
const options = [
{ key: undefined, label: 'Any time' },
{ key: 'day', label: 'Past day' },
{ key: 'week', label: 'Past week' },
{ key: 'month', label: 'Past month' },
{ key: 'year', label: 'Past year' },
];
type Props = {
dateFilter: ?string,
onSelect: (key: ?string) => void,
};
const DateFilter = ({ dateFilter, onSelect }: Props) => {
return (
<FilterOptions
options={options}
activeKey={dateFilter}
onSelect={onSelect}
defaultLabel="Any time"
/>
);
};
export default DateFilter;

View File

@ -0,0 +1,59 @@
// @flow
import * as React from 'react';
import { CheckmarkIcon } from 'outline-icons';
import styled from 'styled-components';
import HelpText from 'components/HelpText';
import Flex from 'shared/components/Flex';
type Props = {
label: string,
note?: string,
onSelect: () => void,
active: boolean,
};
const FilterOption = ({ label, note, onSelect, active }: Props) => {
return (
<ListItem active={active}>
<Anchor onClick={active ? undefined : onSelect}>
<Flex align="center" justify="space-between">
<span>
{label}
{note && <HelpText small>{note}</HelpText>}
</span>
{active && <Checkmark />}
</Flex>
</Anchor>
</ListItem>
);
};
const Checkmark = styled(CheckmarkIcon)`
flex-shrink: 0;
padding-left: 4px;
fill: ${props => props.theme.text};
`;
const Anchor = styled('a')`
display: flex;
flex-direction: column;
font-size: 15px;
padding: 4px 8px;
color: ${props => props.theme.text};
min-height: 32px;
${HelpText} {
font-weight: normal;
}
&:hover {
background: ${props => props.theme.listItemHoverBackground};
}
`;
const ListItem = styled('li')`
list-style: none;
font-weight: ${props => (props.active ? '600' : 'normal')};
`;
export default FilterOption;

View File

@ -0,0 +1,99 @@
// @flow
import * as React from 'react';
import { find } from 'lodash';
import styled from 'styled-components';
import Scrollable from 'components/Scrollable';
import Button from 'components/Button';
import { DropdownMenu } from 'components/DropdownMenu';
import FilterOption from './FilterOption';
type Props = {
options: {
key: ?string,
label: string,
note?: string,
}[],
activeKey: ?string,
defaultLabel?: string,
selectedPrefix?: string,
onSelect: (key: ?string) => void,
};
const FilterOptions = ({
options,
activeKey,
defaultLabel,
selectedPrefix = '',
onSelect,
}: Props) => {
const selected = find(options, { key: activeKey }) || options[0];
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : '';
return (
<DropdownButton label={activeKey ? selectedLabel : defaultLabel}>
<List>
{options.map(option => (
<FilterOption
key={option.key}
onSelect={() => onSelect(option.key)}
active={option.key === activeKey}
{...option}
/>
))}
</List>
</DropdownButton>
);
};
const Content = styled('div')`
padding: 0 8px;
width: 250px;
max-height: 50vh;
p {
margin-bottom: 0;
}
`;
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
border-color: transparent;
height: 28px;
&:hover {
background: transparent;
}
`;
const SearchFilter = props => {
return (
<DropdownMenu
className={props.className}
label={
<StyledButton neutral disclosure small>
{props.label}
</StyledButton>
}
leftAlign
>
{({ closePortal }) => (
<Content>
<Scrollable>{props.children}</Scrollable>
</Content>
)}
</DropdownMenu>
);
};
const DropdownButton = styled(SearchFilter)`
margin-right: 8px;
`;
const List = styled('ol')`
list-style: none;
margin: 0;
padding: 0;
`;
export default FilterOptions;

View File

@ -0,0 +1,34 @@
// @flow
import * as React from 'react';
import FilterOptions from './FilterOptions';
const options = [
{
key: undefined,
label: 'Active documents',
note: 'Documents in collections you are able to access',
},
{
key: 'true',
label: 'All documents',
note: 'Include documents that are in the archive',
},
];
type Props = {
includeArchived: boolean,
onSelect: (key: ?string) => void,
};
const StatusFilter = ({ includeArchived, onSelect }: Props) => {
return (
<FilterOptions
options={options}
activeKey={includeArchived ? 'true' : undefined}
onSelect={onSelect}
defaultLabel="Active documents"
/>
);
};
export default StatusFilter;

View File

@ -0,0 +1,43 @@
// @flow
import * as React from 'react';
import { observer, inject } from 'mobx-react';
import FilterOptions from './FilterOptions';
import UsersStore from 'stores/UsersStore';
const defaultOption = {
key: undefined,
label: 'Any author',
};
type Props = {
users: UsersStore,
userId: ?string,
onSelect: (key: ?string) => void,
};
@observer
class UserFilter extends React.Component<Props> {
componentDidMount() {
this.props.users.fetchPage({ limit: 100 });
}
render() {
const { onSelect, userId, users } = this.props;
const userOptions = users.orderedData.map(user => ({
key: user.id,
label: user.name,
}));
return (
<FilterOptions
options={[defaultOption, ...userOptions]}
activeKey={userId}
onSelect={onSelect}
defaultLabel="Any author"
selectedPrefix="Author:"
/>
);
}
}
export default inject('users')(UserFilter);

View File

@ -1,6 +1,15 @@
// @flow
import { observable, action, computed, runInAction } from 'mobx';
import { without, map, find, orderBy, filter, compact, uniq } from 'lodash';
import {
without,
map,
find,
orderBy,
filter,
compact,
omitBy,
uniq,
} from 'lodash';
import { client } from 'utils/ApiClient';
import naturalSort from 'shared/utils/naturalSort';
import invariant from 'invariant';
@ -225,8 +234,10 @@ export default class DocumentsStore extends BaseStore<Document> {
query: string,
options: PaginationParams = {}
): Promise<SearchResult[]> => {
// $FlowFixMe
const compactedOptions = omitBy(options, o => !o);
const res = await client.get('/documents.search', {
...options,
...compactedOptions,
query,
});
invariant(res && res.data, 'Search response should be available');

View File

@ -120,7 +120,7 @@
"mobx-react": "^5.4.2",
"natural-sort": "^1.0.0",
"nodemailer": "^4.4.0",
"outline-icons": "^1.8.0-0",
"outline-icons": "^1.8.0",
"oy-vey": "^0.10.0",
"pg": "^6.1.5",
"pg-hstore": "2.3.2",

View File

@ -378,13 +378,37 @@ router.post('documents.restore', auth(), async ctx => {
});
router.post('documents.search', auth(), pagination(), async ctx => {
const { query, includeArchived } = ctx.body;
const { query, includeArchived, collectionId, userId, dateFilter } = ctx.body;
const { offset, limit } = ctx.state.pagination;
const user = ctx.state.user;
ctx.assertPresent(query, 'query is required');
const user = ctx.state.user;
if (collectionId) {
ctx.assertUuid(collectionId, 'collectionId must be a UUID');
const collection = await Collection.findById(collectionId);
authorize(user, 'read', collection);
}
let collaboratorIds = undefined;
if (userId) {
ctx.assertUuid(userId, 'userId must be a UUID');
collaboratorIds = [userId];
}
if (dateFilter) {
ctx.assertIn(
dateFilter,
['day', 'week', 'month', 'year'],
'dateFilter must be one of day,week,month,year'
);
}
const results = await Document.searchForUser(user, query, {
includeArchived: includeArchived === 'true',
collaboratorIds,
collectionId,
dateFilter,
offset,
limit,
});

View File

@ -410,7 +410,7 @@ describe('#documents.search', async () => {
it('should return draft documents created by user', async () => {
const { user } = await seed();
await buildDocument({
const document = await buildDocument({
title: 'search term',
text: 'search term',
publishedAt: null,
@ -424,7 +424,7 @@ describe('#documents.search', async () => {
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].document.text).toEqual('search term');
expect(body.data[0].document.id).toEqual(document.id);
});
it('should not return draft documents created by other users', async () => {
@ -482,7 +482,70 @@ describe('#documents.search', async () => {
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].document.text).toEqual('search term');
expect(body.data[0].document.id).toEqual(document.id);
});
it('should return documents for a specific user', async () => {
const { user } = await seed();
const document = await buildDocument({
title: 'search term',
text: 'search term',
teamId: user.teamId,
userId: user.id,
});
// This one will be filtered out
await buildDocument({
title: 'search term',
text: 'search term',
teamId: user.teamId,
});
const res = await server.post('/api/documents.search', {
body: {
token: user.getJwtToken(),
query: 'search term',
userId: user.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].document.id).toEqual(document.id);
});
it('should return documents for a specific collection', async () => {
const { user } = await seed();
const collection = await buildCollection();
const document = await buildDocument({
title: 'search term',
text: 'search term',
teamId: user.teamId,
});
// This one will be filtered out
await buildDocument({
title: 'search term',
text: 'search term',
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post('/api/documents.search', {
body: {
token: user.getJwtToken(),
query: 'search term',
collectionId: document.collectionId,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].document.id).toEqual(document.id);
});
it('should not return documents in private collections not a member of', async () => {
@ -505,6 +568,20 @@ describe('#documents.search', async () => {
expect(body.data.length).toEqual(0);
});
it('should not allow unknown dateFilter values', async () => {
const { user } = await seed();
const res = await server.post('/api/documents.search', {
body: {
token: user.getJwtToken(),
query: 'search term',
dateFilter: 'DROP TABLE students;',
},
});
expect(res.status).toEqual(400);
});
it('should require authentication', async () => {
const res = await server.post('/api/documents.search');
const body = await res.json();

View File

@ -12,6 +12,12 @@ export default function validation() {
}
};
ctx.assertIn = (value, options, message) => {
if (!options.includes(value)) {
throw new ValidationError(message);
}
};
ctx.assertNotEmpty = (value, message) => {
if (value === '') {
throw new ValidationError(message);

View File

@ -0,0 +1,13 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addIndex('documents', ['updatedAt']);
await queryInterface.addIndex('documents', ['archivedAt']);
await queryInterface.addIndex('documents', ['collaboratorIds']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('documents', ['updatedAt']);
await queryInterface.removeIndex('documents', ['archivedAt']);
await queryInterface.removeIndex('documents', ['collaboratorIds']);
},
};

View File

@ -215,32 +215,60 @@ Document.searchForUser = async (
const offset = options.offset || 0;
const wildcardQuery = `${sequelize.escape(query)}:*`;
const sql = `
SELECT
id,
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"collectionId" IN(:collectionIds) AND
${options.includeArchived ? '' : '"archivedAt" IS NULL AND'}
"deletedAt" IS NULL AND
("publishedAt" IS NOT NULL OR "createdById" = '${user.id}')
ORDER BY
"searchRanking" DESC,
"updatedAt" DESC
LIMIT :limit
OFFSET :offset;
`;
// Ensure we're filtering by the users accessible collections. If
// collectionId is passed as an option it is assumed that the authorization
// has already been done in the router
let collectionIds;
if (options.collectionId) {
collectionIds = [options.collectionId];
} else {
collectionIds = await user.collectionIds();
}
let dateFilter;
if (options.dateFilter) {
dateFilter = `1 ${options.dateFilter}`;
}
// Build the SQL query to get documentIds, ranking, and search term context
const sql = `
SELECT
id,
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"collectionId" IN(:collectionIds) AND
${
options.dateFilter ? '"updatedAt" > now() - interval :dateFilter AND' : ''
}
${
options.collaboratorIds
? '"collaboratorIds" @> ARRAY[:collaboratorIds]::uuid[] AND'
: ''
}
${options.includeArchived ? '' : '"archivedAt" IS NULL AND'}
"deletedAt" IS NULL AND
("publishedAt" IS NOT NULL OR "createdById" = :userId)
ORDER BY
"searchRanking" DESC,
"updatedAt" DESC
LIMIT :limit
OFFSET :offset;
`;
const collectionIds = await user.collectionIds();
const results = await sequelize.query(sql, {
type: sequelize.QueryTypes.SELECT,
replacements: {
teamId: user.teamId,
userId: user.id,
collaboratorIds: options.collaboratorIds,
query: wildcardQuery,
limit,
offset,
collectionIds,
dateFilter,
},
});

View File

@ -256,7 +256,13 @@ export default function Api() {
</Description>
<Arguments>
<Argument id="query" description="Search query" required />
<Argument id="userId" description="User ID" />
<Argument id="collectionId" description="Collection ID" />
<Argument id="includeArchived" description="Boolean" />
<Argument
id="dateFilter"
description="Date range to consider (day, week, month or year)"
/>
</Arguments>
</Method>

View File

@ -1,5 +1,6 @@
// @flow
import * as React from 'react';
import Tooltip from 'components/Tooltip';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
import format from 'date-fns/format';
@ -11,9 +12,9 @@ type Props = {
function Time({ dateTime, children }: Props) {
const date = new Date(dateTime);
return (
<time dateTime={dateTime} title={format(date, 'MMMM Do, YYYY h:mm a')}>
{children || distanceInWordsToNow(date)}
</time>
<Tooltip tooltip={format(date, 'MMMM Do, YYYY h:mm a')} placement="bottom">
<time dateTime={dateTime}>{children || distanceInWordsToNow(date)}</time>
</Tooltip>
);
}

View File

@ -110,12 +110,12 @@ export const dark = {
menuBackground: lighten(0.015, colors.almostBlack),
menuShadow:
'0 0 0 1px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.08)',
'0 0 0 1px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.08), inset 0 0 1px rgba(255,255,255,.2)',
divider: darken(0.2, colors.slate),
inputBorder: colors.slateDark,
inputBorderFocused: colors.slate,
listItemHoverBackground: colors.black10,
listItemHoverBackground: colors.black50,
listItemHoverBorder: colors.black50,
toolbarBackground: colors.white,

View File

@ -6441,9 +6441,9 @@ outline-icons@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.6.0.tgz#6c7897d354e6bd77ca5498cd3a989b8cb9482574"
outline-icons@^1.8.0-0:
version "1.8.0-0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.8.0-0.tgz#a3499cc0837626541e6bc00c2bfed7279d1c8bb3"
outline-icons@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.8.0.tgz#e2ebdc6e69db5a79ca3dfdd60b533bbeb8edf1ef"
oy-vey@^0.10.0:
version "0.10.0"