diff --git a/app/components/Button.js b/app/components/Button.js
index bd546e2f..5e381807 100644
--- a/app/components/Button.js
+++ b/app/components/Button.js
@@ -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 (
-
+
{hasIcon && icon}
{hasText && }
+ {disclosure && }
);
diff --git a/app/components/Checkbox.js b/app/components/Checkbox.js
index 33536cf8..8f7243f9 100644
--- a/app/components/Checkbox.js
+++ b/app/components/Checkbox.js
@@ -25,6 +25,7 @@ const Wrapper = styled.div`
const Label = styled.label`
display: flex;
align-items: center;
+ user-select: none;
`;
export default function Checkbox({
diff --git a/app/components/DropdownMenu/DropdownMenu.js b/app/components/DropdownMenu/DropdownMenu.js
index 2408b214..849b5b8c 100644
--- a/app/components/DropdownMenu/DropdownMenu.js
+++ b/app/components/DropdownMenu/DropdownMenu.js
@@ -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 {
@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 {
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 {
>
{({ closePortal, openPortal, portal }) => (
-
+
{portal(
)}
@@ -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;
diff --git a/app/components/Empty.js b/app/components/Empty.js
index c12ed2fb..1c990992 100644
--- a/app/components/Empty.js
+++ b/app/components/Empty.js
@@ -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) => {
diff --git a/app/routes.js b/app/routes.js
index 3dc4c867..8cf486a6 100644
--- a/app/routes.js
+++ b/app/routes.js
@@ -96,7 +96,7 @@ export default function Routes() {
/>
-
+
diff --git a/app/scenes/Search/Search.js b/app/scenes/Search/Search.js
index 63e1c277..9814a519 100644
--- a/app/scenes/Search/Search.js
+++ b/app/scenes/Search/Search.js
@@ -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 {
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 {
};
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 {
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 we’re in the middle of fetching
@@ -113,7 +173,10 @@ class Search extends React.Component {
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 {
});
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 {
)}
{this.pinToTop && (
-
+ this.handleFilterChange({ includeArchived })
+ }
+ />
+
+ this.handleFilterChange({ collectionId })
+ }
+ />
+ this.handleFilterChange({ userId })}
+ />
+ this.handleFilterChange({ dateFilter })}
/>
)}
- {showEmpty && No matching documents.}
+ {showEmpty && (
+
+ No results found for search.{' '}
+ {this.isFiltered && (
+
+
+ Clear Filters
+ .
+
+ )}
+
+ )}
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));
diff --git a/app/scenes/Search/components/CollectionFilter.js b/app/scenes/Search/components/CollectionFilter.js
new file mode 100644
index 00000000..91905bed
--- /dev/null
+++ b/app/scenes/Search/components/CollectionFilter.js
@@ -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 {
+ render() {
+ const { onSelect, collectionId, collections } = this.props;
+ const collectionOptions = collections.orderedData.map(user => ({
+ key: user.id,
+ label: user.name,
+ }));
+
+ return (
+
+ );
+ }
+}
+
+export default inject('collections')(CollectionFilter);
diff --git a/app/scenes/Search/components/DateFilter.js b/app/scenes/Search/components/DateFilter.js
new file mode 100644
index 00000000..605416d0
--- /dev/null
+++ b/app/scenes/Search/components/DateFilter.js
@@ -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 (
+
+ );
+};
+
+export default DateFilter;
diff --git a/app/scenes/Search/components/FilterOption.js b/app/scenes/Search/components/FilterOption.js
new file mode 100644
index 00000000..ac72c0cc
--- /dev/null
+++ b/app/scenes/Search/components/FilterOption.js
@@ -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 (
+
+
+
+
+ {label}
+ {note && {note}}
+
+ {active && }
+
+
+
+ );
+};
+
+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;
diff --git a/app/scenes/Search/components/FilterOptions.js b/app/scenes/Search/components/FilterOptions.js
new file mode 100644
index 00000000..072dfa85
--- /dev/null
+++ b/app/scenes/Search/components/FilterOptions.js
@@ -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 (
+
+
+ {options.map(option => (
+ onSelect(option.key)}
+ active={option.key === activeKey}
+ {...option}
+ />
+ ))}
+
+
+ );
+};
+
+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 (
+
+ {props.label}
+
+ }
+ leftAlign
+ >
+ {({ closePortal }) => (
+
+ {props.children}
+
+ )}
+
+ );
+};
+
+const DropdownButton = styled(SearchFilter)`
+ margin-right: 8px;
+`;
+
+const List = styled('ol')`
+ list-style: none;
+ margin: 0;
+ padding: 0;
+`;
+
+export default FilterOptions;
diff --git a/app/scenes/Search/components/StatusFilter.js b/app/scenes/Search/components/StatusFilter.js
new file mode 100644
index 00000000..01622454
--- /dev/null
+++ b/app/scenes/Search/components/StatusFilter.js
@@ -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 (
+
+ );
+};
+
+export default StatusFilter;
diff --git a/app/scenes/Search/components/UserFilter.js b/app/scenes/Search/components/UserFilter.js
new file mode 100644
index 00000000..3b2d3b2f
--- /dev/null
+++ b/app/scenes/Search/components/UserFilter.js
@@ -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 {
+ 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 (
+
+ );
+ }
+}
+
+export default inject('users')(UserFilter);
diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js
index 21cfacf8..8c09c4ac 100644
--- a/app/stores/DocumentsStore.js
+++ b/app/stores/DocumentsStore.js
@@ -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 {
query: string,
options: PaginationParams = {}
): Promise => {
+ // $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');
diff --git a/package.json b/package.json
index ed84a2b0..df4f3f6e 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/api/documents.js b/server/api/documents.js
index ceda0512..fb3e2681 100644
--- a/server/api/documents.js
+++ b/server/api/documents.js
@@ -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,
});
diff --git a/server/api/documents.test.js b/server/api/documents.test.js
index 8d5a6dfa..8f325499 100644
--- a/server/api/documents.test.js
+++ b/server/api/documents.test.js
@@ -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();
diff --git a/server/middlewares/validation.js b/server/middlewares/validation.js
index f6225fb2..394e9966 100644
--- a/server/middlewares/validation.js
+++ b/server/middlewares/validation.js
@@ -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);
diff --git a/server/migrations/20190423051708-add-search-indexes.js b/server/migrations/20190423051708-add-search-indexes.js
new file mode 100644
index 00000000..2b0bb691
--- /dev/null
+++ b/server/migrations/20190423051708-add-search-indexes.js
@@ -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']);
+ },
+};
diff --git a/server/models/Document.js b/server/models/Document.js
index a20f3f03..a76d488c 100644
--- a/server/models/Document.js
+++ b/server/models/Document.js
@@ -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,
},
});
diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js
index 4fb3bc5c..b6bf768d 100644
--- a/server/pages/developers/Api.js
+++ b/server/pages/developers/Api.js
@@ -256,7 +256,13 @@ export default function Api() {
+
+
+
diff --git a/shared/components/Time.js b/shared/components/Time.js
index 0e7b140a..2400a6c4 100644
--- a/shared/components/Time.js
+++ b/shared/components/Time.js
@@ -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 (
-
+
+
+
);
}
diff --git a/shared/styles/theme.js b/shared/styles/theme.js
index 693d78e6..50a5fedb 100644
--- a/shared/styles/theme.js
+++ b/shared/styles/theme.js
@@ -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,
diff --git a/yarn.lock b/yarn.lock
index d73df765..1d959cd7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"