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( { - 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} )} @@ -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"