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:
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);
|
||||
Reference in New Issue
Block a user