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:
@@ -2,6 +2,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { darken } from 'polished';
|
import { darken } from 'polished';
|
||||||
|
import { ExpandedIcon } from 'outline-icons';
|
||||||
|
|
||||||
const RealButton = styled.button`
|
const RealButton = styled.button`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -22,6 +23,10 @@ const RealButton = styled.button`
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: ${props => props.theme.buttonText};
|
||||||
|
}
|
||||||
|
|
||||||
&::-moz-focus-inner {
|
&::-moz-focus-inner {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -45,6 +50,10 @@ const RealButton = styled.button`
|
|||||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px;
|
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px;
|
||||||
border: 1px solid ${darken(0.1, props.theme.buttonNeutralBackground)};
|
border: 1px solid ${darken(0.1, props.theme.buttonNeutralBackground)};
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: ${props.theme.buttonNeutralText};
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
|
||||||
border: 1px solid ${darken(0.15, props.theme.buttonNeutralBackground)};
|
border: 1px solid ${darken(0.15, props.theme.buttonNeutralBackground)};
|
||||||
@@ -70,8 +79,9 @@ const Label = styled.span`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const Inner = styled.span`
|
const Inner = styled.span`
|
||||||
padding: 0 ${props => (props.small ? 8 : 12)}px;
|
|
||||||
display: flex;
|
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;
|
line-height: ${props => (props.small ? 24 : 28)}px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -88,6 +98,7 @@ export type Props = {
|
|||||||
className?: string,
|
className?: string,
|
||||||
children?: React.Node,
|
children?: React.Node,
|
||||||
small?: boolean,
|
small?: boolean,
|
||||||
|
disclosure?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Button({
|
export default function Button({
|
||||||
@@ -95,6 +106,7 @@ export default function Button({
|
|||||||
icon,
|
icon,
|
||||||
children,
|
children,
|
||||||
value,
|
value,
|
||||||
|
disclosure,
|
||||||
small,
|
small,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -103,9 +115,10 @@ export default function Button({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RealButton small={small} {...rest}>
|
<RealButton small={small} {...rest}>
|
||||||
<Inner hasIcon={hasIcon} small={small}>
|
<Inner hasIcon={hasIcon} small={small} disclosure={disclosure}>
|
||||||
{hasIcon && icon}
|
{hasIcon && icon}
|
||||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||||
|
{disclosure && <ExpandedIcon />}
|
||||||
</Inner>
|
</Inner>
|
||||||
</RealButton>
|
</RealButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const Wrapper = styled.div`
|
|||||||
const Label = styled.label`
|
const Label = styled.label`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function Checkbox({
|
export default function Checkbox({
|
||||||
|
|||||||
@@ -8,21 +8,32 @@ import styled from 'styled-components';
|
|||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import { fadeAndScaleIn } from 'shared/styles/animations';
|
import { fadeAndScaleIn } from 'shared/styles/animations';
|
||||||
|
|
||||||
|
let previousClosePortal;
|
||||||
|
|
||||||
|
type Children =
|
||||||
|
| React.Node
|
||||||
|
| ((options: { closePortal: () => void }) => React.Node);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: React.Node,
|
label: React.Node,
|
||||||
onOpen?: () => void,
|
onOpen?: () => void,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
children?: React.Node,
|
children?: Children,
|
||||||
className?: string,
|
className?: string,
|
||||||
style?: Object,
|
style?: Object,
|
||||||
|
leftAlign?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class DropdownMenu extends React.Component<Props> {
|
class DropdownMenu extends React.Component<Props> {
|
||||||
@observable top: number;
|
@observable top: number;
|
||||||
@observable right: number;
|
@observable right: number;
|
||||||
|
@observable left: number;
|
||||||
|
|
||||||
handleOpen = (openPortal: (SyntheticEvent<*>) => *) => {
|
handleOpen = (
|
||||||
|
openPortal: (SyntheticEvent<*>) => void,
|
||||||
|
closePortal: () => void
|
||||||
|
) => {
|
||||||
return (ev: SyntheticMouseEvent<*>) => {
|
return (ev: SyntheticMouseEvent<*>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const currentTarget = ev.currentTarget;
|
const currentTarget = ev.currentTarget;
|
||||||
@@ -32,7 +43,18 @@ class DropdownMenu extends React.Component<Props> {
|
|||||||
const bodyRect = document.body.getBoundingClientRect();
|
const bodyRect = document.body.getBoundingClientRect();
|
||||||
const targetRect = currentTarget.getBoundingClientRect();
|
const targetRect = currentTarget.getBoundingClientRect();
|
||||||
this.top = targetRect.bottom - bodyRect.top;
|
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);
|
openPortal(ev);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -51,18 +73,27 @@ class DropdownMenu extends React.Component<Props> {
|
|||||||
>
|
>
|
||||||
{({ closePortal, openPortal, portal }) => (
|
{({ closePortal, openPortal, portal }) => (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Label onClick={this.handleOpen(openPortal)}>{label}</Label>
|
<Label onClick={this.handleOpen(openPortal, closePortal)}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
{portal(
|
{portal(
|
||||||
<Menu
|
<Menu
|
||||||
onClick={ev => {
|
onClick={
|
||||||
ev.stopPropagation();
|
typeof children === 'function'
|
||||||
closePortal();
|
? undefined
|
||||||
}}
|
: ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
closePortal();
|
||||||
|
}
|
||||||
|
}
|
||||||
style={this.props.style}
|
style={this.props.style}
|
||||||
top={this.top}
|
top={this.top}
|
||||||
|
left={this.left}
|
||||||
right={this.right}
|
right={this.right}
|
||||||
>
|
>
|
||||||
{children}
|
{typeof children === 'function'
|
||||||
|
? children({ closePortal })
|
||||||
|
: children}
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -83,10 +114,11 @@ const Label = styled(Flex).attrs({
|
|||||||
|
|
||||||
const Menu = styled.div`
|
const Menu = styled.div`
|
||||||
animation: ${fadeAndScaleIn} 200ms ease;
|
animation: ${fadeAndScaleIn} 200ms ease;
|
||||||
transform-origin: 75% 0;
|
transform-origin: ${({ left }) => (left !== undefined ? '25%' : '75%')} 0;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: ${({ right }) => right}px;
|
${({ left }) => (left !== undefined ? `left: ${left}px` : '')};
|
||||||
|
${({ right }) => (right !== undefined ? `right: ${right}px` : '')};
|
||||||
top: ${({ top }) => top}px;
|
top: ${({ top }) => top}px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: string,
|
children: React.Node,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Empty = (props: Props) => {
|
const Empty = (props: Props) => {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function Routes() {
|
|||||||
/>
|
/>
|
||||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||||
<Route exact path="/search" component={Search} />
|
<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 path="/404" component={Error404} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -3,35 +3,40 @@ import * as React from 'react';
|
|||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import keydown from 'react-keydown';
|
import keydown from 'react-keydown';
|
||||||
import Waypoint from 'react-waypoint';
|
import Waypoint from 'react-waypoint';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter, Link } from 'react-router-dom';
|
||||||
import { observable, action } from 'mobx';
|
import { observable, action } from 'mobx';
|
||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
import queryString from 'query-string';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||||
|
|
||||||
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
|
import { DEFAULT_PAGINATION_LIMIT } from 'stores/BaseStore';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
import UsersStore from 'stores/UsersStore';
|
||||||
import { searchUrl } from 'utils/routeHelpers';
|
import { searchUrl } from 'utils/routeHelpers';
|
||||||
import { meta } from 'utils/keyboard';
|
import { meta } from 'utils/keyboard';
|
||||||
|
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import Empty from 'components/Empty';
|
import Empty from 'components/Empty';
|
||||||
import Fade from 'components/Fade';
|
import Fade from 'components/Fade';
|
||||||
import Checkbox from 'components/Checkbox';
|
|
||||||
|
|
||||||
import HelpText from 'components/HelpText';
|
import HelpText from 'components/HelpText';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import LoadingIndicator from 'components/LoadingIndicator';
|
import LoadingIndicator from 'components/LoadingIndicator';
|
||||||
import DocumentPreview from 'components/DocumentPreview';
|
import DocumentPreview from 'components/DocumentPreview';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import SearchField from './components/SearchField';
|
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 = {
|
type Props = {
|
||||||
history: Object,
|
history: Object,
|
||||||
match: Object,
|
match: Object,
|
||||||
location: Object,
|
location: Object,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
|
users: UsersStore,
|
||||||
notFound: ?boolean,
|
notFound: ?boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,20 +45,24 @@ class Search extends React.Component<Props> {
|
|||||||
firstDocument: ?DocumentPreview;
|
firstDocument: ?DocumentPreview;
|
||||||
|
|
||||||
@observable query: string = '';
|
@observable query: string = '';
|
||||||
|
@observable params: URLSearchParams = new URLSearchParams();
|
||||||
@observable offset: number = 0;
|
@observable offset: number = 0;
|
||||||
@observable allowLoadMore: boolean = true;
|
@observable allowLoadMore: boolean = true;
|
||||||
@observable isFetching: boolean = false;
|
@observable isFetching: boolean = false;
|
||||||
@observable includeArchived: boolean = false;
|
@observable pinToTop: boolean = !!this.props.match.params.term;
|
||||||
@observable pinToTop: boolean = !!this.props.match.params.query;
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
this.handleTermChange();
|
||||||
this.handleQueryChange();
|
this.handleQueryChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (prevProps.match.params.query !== this.props.match.params.query) {
|
if (prevProps.location.search !== this.props.location.search) {
|
||||||
this.handleQueryChange();
|
this.handleQueryChange();
|
||||||
}
|
}
|
||||||
|
if (prevProps.match.params.term !== this.props.match.params.term) {
|
||||||
|
this.handleTermChange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keydown('esc')
|
@keydown('esc')
|
||||||
@@ -79,7 +88,18 @@ class Search extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleQueryChange = () => {
|
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.query = query ? query : '';
|
||||||
this.offset = 0;
|
this.offset = 0;
|
||||||
this.allowLoadMore = true;
|
this.allowLoadMore = true;
|
||||||
@@ -90,11 +110,51 @@ class Search extends React.Component<Props> {
|
|||||||
this.fetchResultsDebounced();
|
this.fetchResultsDebounced();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleFilterChange = ev => {
|
handleFilterChange = search => {
|
||||||
this.includeArchived = ev.target.checked;
|
this.props.history.replace({
|
||||||
this.fetchResultsDebounced();
|
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
|
@action
|
||||||
loadMoreResults = async () => {
|
loadMoreResults = async () => {
|
||||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
// 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<Props> {
|
|||||||
const results = await this.props.documents.search(this.query, {
|
const results = await this.props.documents.search(this.query, {
|
||||||
offset: this.offset,
|
offset: this.offset,
|
||||||
limit: DEFAULT_PAGINATION_LIMIT,
|
limit: DEFAULT_PAGINATION_LIMIT,
|
||||||
|
dateFilter: this.dateFilter,
|
||||||
includeArchived: this.includeArchived,
|
includeArchived: this.includeArchived,
|
||||||
|
collectionId: this.collectionId,
|
||||||
|
userId: this.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (results.length > 0) this.pinToTop = true;
|
if (results.length > 0) this.pinToTop = true;
|
||||||
@@ -136,20 +199,16 @@ class Search extends React.Component<Props> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateLocation = query => {
|
updateLocation = query => {
|
||||||
this.props.history.replace(searchUrl(query));
|
this.props.history.replace({
|
||||||
|
pathname: searchUrl(query),
|
||||||
|
search: this.props.location.search,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
setFirstDocumentRef = ref => {
|
setFirstDocumentRef = ref => {
|
||||||
this.firstDocument = ref;
|
this.firstDocument = ref;
|
||||||
};
|
};
|
||||||
|
|
||||||
get title() {
|
|
||||||
const query = this.query;
|
|
||||||
const title = 'Search';
|
|
||||||
if (query) return `${query} - ${title}`;
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { documents, notFound, location } = this.props;
|
const { documents, notFound, location } = this.props;
|
||||||
const results = documents.searchResults(this.query);
|
const results = documents.searchResults(this.query);
|
||||||
@@ -183,16 +242,40 @@ class Search extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
{this.pinToTop && (
|
{this.pinToTop && (
|
||||||
<Filters>
|
<Filters>
|
||||||
<Checkbox
|
<StatusFilter
|
||||||
label="Include archived"
|
includeArchived={this.includeArchived}
|
||||||
name="includeArchived"
|
onSelect={includeArchived =>
|
||||||
checked={this.includeArchived}
|
this.handleFilterChange({ includeArchived })
|
||||||
onChange={this.handleFilterChange}
|
}
|
||||||
small
|
/>
|
||||||
|
<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>
|
</Filters>
|
||||||
)}
|
)}
|
||||||
{showEmpty && <Empty>No matching documents.</Empty>}
|
{showEmpty && (
|
||||||
|
<Empty>
|
||||||
|
No results found for search.{' '}
|
||||||
|
{this.isFiltered && (
|
||||||
|
<React.Fragment>
|
||||||
|
<Link to={this.props.location.pathname}>
|
||||||
|
Clear Filters
|
||||||
|
</Link>.
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
<ResultList column visible={this.pinToTop}>
|
<ResultList column visible={this.pinToTop}>
|
||||||
<StyledArrowKeyNavigation
|
<StyledArrowKeyNavigation
|
||||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||||
@@ -252,8 +335,13 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const Filters = styled(Flex)`
|
const Filters = styled(Flex)`
|
||||||
border-bottom: 1px solid ${props => props.theme.divider};
|
margin-bottom: 12px;
|
||||||
margin-bottom: 10px;
|
opacity: 0.85;
|
||||||
|
transition: opacity 100ms ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default withRouter(inject('documents')(Search));
|
export default withRouter(inject('documents')(Search));
|
||||||
|
|||||||
39
app/scenes/Search/components/CollectionFilter.js
Normal file
39
app/scenes/Search/components/CollectionFilter.js
Normal 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);
|
||||||
29
app/scenes/Search/components/DateFilter.js
Normal file
29
app/scenes/Search/components/DateFilter.js
Normal 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;
|
||||||
59
app/scenes/Search/components/FilterOption.js
Normal file
59
app/scenes/Search/components/FilterOption.js
Normal 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;
|
||||||
99
app/scenes/Search/components/FilterOptions.js
Normal file
99
app/scenes/Search/components/FilterOptions.js
Normal 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;
|
||||||
34
app/scenes/Search/components/StatusFilter.js
Normal file
34
app/scenes/Search/components/StatusFilter.js
Normal 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;
|
||||||
43
app/scenes/Search/components/UserFilter.js
Normal file
43
app/scenes/Search/components/UserFilter.js
Normal 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);
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { observable, action, computed, runInAction } from 'mobx';
|
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 { client } from 'utils/ApiClient';
|
||||||
import naturalSort from 'shared/utils/naturalSort';
|
import naturalSort from 'shared/utils/naturalSort';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
@@ -225,8 +234,10 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
query: string,
|
query: string,
|
||||||
options: PaginationParams = {}
|
options: PaginationParams = {}
|
||||||
): Promise<SearchResult[]> => {
|
): Promise<SearchResult[]> => {
|
||||||
|
// $FlowFixMe
|
||||||
|
const compactedOptions = omitBy(options, o => !o);
|
||||||
const res = await client.get('/documents.search', {
|
const res = await client.get('/documents.search', {
|
||||||
...options,
|
...compactedOptions,
|
||||||
query,
|
query,
|
||||||
});
|
});
|
||||||
invariant(res && res.data, 'Search response should be available');
|
invariant(res && res.data, 'Search response should be available');
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
"mobx-react": "^5.4.2",
|
"mobx-react": "^5.4.2",
|
||||||
"natural-sort": "^1.0.0",
|
"natural-sort": "^1.0.0",
|
||||||
"nodemailer": "^4.4.0",
|
"nodemailer": "^4.4.0",
|
||||||
"outline-icons": "^1.8.0-0",
|
"outline-icons": "^1.8.0",
|
||||||
"oy-vey": "^0.10.0",
|
"oy-vey": "^0.10.0",
|
||||||
"pg": "^6.1.5",
|
"pg": "^6.1.5",
|
||||||
"pg-hstore": "2.3.2",
|
"pg-hstore": "2.3.2",
|
||||||
|
|||||||
@@ -378,13 +378,37 @@ router.post('documents.restore', auth(), async ctx => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('documents.search', auth(), pagination(), 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 { offset, limit } = ctx.state.pagination;
|
||||||
|
const user = ctx.state.user;
|
||||||
ctx.assertPresent(query, 'query is required');
|
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, {
|
const results = await Document.searchForUser(user, query, {
|
||||||
includeArchived: includeArchived === 'true',
|
includeArchived: includeArchived === 'true',
|
||||||
|
collaboratorIds,
|
||||||
|
collectionId,
|
||||||
|
dateFilter,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ describe('#documents.search', async () => {
|
|||||||
|
|
||||||
it('should return draft documents created by user', async () => {
|
it('should return draft documents created by user', async () => {
|
||||||
const { user } = await seed();
|
const { user } = await seed();
|
||||||
await buildDocument({
|
const document = await buildDocument({
|
||||||
title: 'search term',
|
title: 'search term',
|
||||||
text: 'search term',
|
text: 'search term',
|
||||||
publishedAt: null,
|
publishedAt: null,
|
||||||
@@ -424,7 +424,7 @@ describe('#documents.search', async () => {
|
|||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.length).toEqual(1);
|
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 () => {
|
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(res.status).toEqual(200);
|
||||||
expect(body.data.length).toEqual(1);
|
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 () => {
|
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);
|
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 () => {
|
it('should require authentication', async () => {
|
||||||
const res = await server.post('/api/documents.search');
|
const res = await server.post('/api/documents.search');
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|||||||
@@ -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) => {
|
ctx.assertNotEmpty = (value, message) => {
|
||||||
if (value === '') {
|
if (value === '') {
|
||||||
throw new ValidationError(message);
|
throw new ValidationError(message);
|
||||||
|
|||||||
13
server/migrations/20190423051708-add-search-indexes.js
Normal file
13
server/migrations/20190423051708-add-search-indexes.js
Normal 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']);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -215,32 +215,60 @@ Document.searchForUser = async (
|
|||||||
const offset = options.offset || 0;
|
const offset = options.offset || 0;
|
||||||
const wildcardQuery = `${sequelize.escape(query)}:*`;
|
const wildcardQuery = `${sequelize.escape(query)}:*`;
|
||||||
|
|
||||||
const sql = `
|
// Ensure we're filtering by the users accessible collections. If
|
||||||
SELECT
|
// collectionId is passed as an option it is assumed that the authorization
|
||||||
id,
|
// has already been done in the router
|
||||||
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
let collectionIds;
|
||||||
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
|
if (options.collectionId) {
|
||||||
FROM documents
|
collectionIds = [options.collectionId];
|
||||||
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
} else {
|
||||||
"collectionId" IN(:collectionIds) AND
|
collectionIds = await user.collectionIds();
|
||||||
${options.includeArchived ? '' : '"archivedAt" IS NULL AND'}
|
}
|
||||||
"deletedAt" IS NULL AND
|
|
||||||
("publishedAt" IS NOT NULL OR "createdById" = '${user.id}')
|
let dateFilter;
|
||||||
ORDER BY
|
if (options.dateFilter) {
|
||||||
"searchRanking" DESC,
|
dateFilter = `1 ${options.dateFilter}`;
|
||||||
"updatedAt" DESC
|
}
|
||||||
LIMIT :limit
|
|
||||||
OFFSET :offset;
|
// 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, {
|
const results = await sequelize.query(sql, {
|
||||||
type: sequelize.QueryTypes.SELECT,
|
type: sequelize.QueryTypes.SELECT,
|
||||||
replacements: {
|
replacements: {
|
||||||
|
teamId: user.teamId,
|
||||||
|
userId: user.id,
|
||||||
|
collaboratorIds: options.collaboratorIds,
|
||||||
query: wildcardQuery,
|
query: wildcardQuery,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
collectionIds,
|
collectionIds,
|
||||||
|
dateFilter,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -256,7 +256,13 @@ export default function Api() {
|
|||||||
</Description>
|
</Description>
|
||||||
<Arguments>
|
<Arguments>
|
||||||
<Argument id="query" description="Search query" required />
|
<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="includeArchived" description="Boolean" />
|
||||||
|
<Argument
|
||||||
|
id="dateFilter"
|
||||||
|
description="Date range to consider (day, week, month or year)"
|
||||||
|
/>
|
||||||
</Arguments>
|
</Arguments>
|
||||||
</Method>
|
</Method>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import Tooltip from 'components/Tooltip';
|
||||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
|
|
||||||
@@ -11,9 +12,9 @@ type Props = {
|
|||||||
function Time({ dateTime, children }: Props) {
|
function Time({ dateTime, children }: Props) {
|
||||||
const date = new Date(dateTime);
|
const date = new Date(dateTime);
|
||||||
return (
|
return (
|
||||||
<time dateTime={dateTime} title={format(date, 'MMMM Do, YYYY h:mm a')}>
|
<Tooltip tooltip={format(date, 'MMMM Do, YYYY h:mm a')} placement="bottom">
|
||||||
{children || distanceInWordsToNow(date)}
|
<time dateTime={dateTime}>{children || distanceInWordsToNow(date)}</time>
|
||||||
</time>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,12 +110,12 @@ export const dark = {
|
|||||||
|
|
||||||
menuBackground: lighten(0.015, colors.almostBlack),
|
menuBackground: lighten(0.015, colors.almostBlack),
|
||||||
menuShadow:
|
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),
|
divider: darken(0.2, colors.slate),
|
||||||
inputBorder: colors.slateDark,
|
inputBorder: colors.slateDark,
|
||||||
inputBorderFocused: colors.slate,
|
inputBorderFocused: colors.slate,
|
||||||
|
|
||||||
listItemHoverBackground: colors.black10,
|
listItemHoverBackground: colors.black50,
|
||||||
listItemHoverBorder: colors.black50,
|
listItemHoverBorder: colors.black50,
|
||||||
|
|
||||||
toolbarBackground: colors.white,
|
toolbarBackground: colors.white,
|
||||||
|
|||||||
@@ -6441,9 +6441,9 @@ outline-icons@^1.6.0:
|
|||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.6.0.tgz#6c7897d354e6bd77ca5498cd3a989b8cb9482574"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.6.0.tgz#6c7897d354e6bd77ca5498cd3a989b8cb9482574"
|
||||||
|
|
||||||
outline-icons@^1.8.0-0:
|
outline-icons@^1.8.0:
|
||||||
version "1.8.0-0"
|
version "1.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.8.0-0.tgz#a3499cc0837626541e6bc00c2bfed7279d1c8bb3"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.8.0.tgz#e2ebdc6e69db5a79ca3dfdd60b533bbeb8edf1ef"
|
||||||
|
|
||||||
oy-vey@^0.10.0:
|
oy-vey@^0.10.0:
|
||||||
version "0.10.0"
|
version "0.10.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user