feat: Search shared documents (#3126)

* provide a type-ahead search input on shared document pages that allow search of child document tree
* improve keyboard navigation handling of all search views
* improve coloring on dark mode list selection states
* refactor PaginatedList component to eliminate edge cases
This commit is contained in:
Nan Yu
2022-04-08 10:40:51 -07:00
committed by GitHub
parent 5fb5e69181
commit 75a868e5e8
22 changed files with 804 additions and 168 deletions

View File

@@ -1,3 +1,4 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { transparentize } from "polished"; import { transparentize } from "polished";
import * as React from "react"; import * as React from "react";
@@ -9,7 +10,6 @@ import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator"; import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
import usePolicy from "~/hooks/usePolicy"; import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
@@ -49,21 +49,25 @@ function CollectionDescription({ collection }: Props) {
[isExpanded] [isExpanded]
); );
const handleSave = useDebouncedCallback(async (getValue) => { const handleSave = React.useMemo(
try { () =>
await collection.save({ debounce(async (getValue) => {
description: getValue(), try {
}); await collection.save({
setDirty(false); description: getValue(),
} catch (err) { });
showToast( setDirty(false);
t("Sorry, an error occurred saving the collection", { } catch (err) {
type: "error", showToast(
}) t("Sorry, an error occurred saving the collection", {
); type: "error",
throw err; })
} );
}, 1000); throw err;
}
}, 1000),
[]
);
const handleChange = React.useCallback( const handleChange = React.useCallback(
(getValue) => { (getValue) => {

View File

@@ -41,10 +41,10 @@ function Highlight({
); );
} }
const Mark = styled.mark` export const Mark = styled.mark`
background: ${(props) => props.theme.searchHighlight}; background: ${(props) => props.theme.searchHighlight};
border-radius: 2px; border-radius: 2px;
padding: 0 4px; padding: 0 2px;
`; `;
export default Highlight; export default Highlight;

View File

@@ -119,6 +119,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
onChange?: ( onChange?: (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown; ) => unknown;
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown; onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown; onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown; onBlur?: (ev: React.SyntheticEvent) => unknown;
@@ -126,7 +127,7 @@ export type Props = React.HTMLAttributes<HTMLInputElement> & {
@observer @observer
class Input extends React.Component<Props> { class Input extends React.Component<Props> {
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>(); input = this.props.innerRef;
@observable @observable
focused = false; focused = false;
@@ -147,10 +148,6 @@ class Input extends React.Component<Props> {
} }
}; };
focus() {
this.input.current?.focus();
}
render() { render() {
const { const {
type = "text", type = "text",

View File

@@ -2,7 +2,7 @@ import { SearchIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components"; import { useTheme } from "styled-components";
import Input, { Props as InputProps } from "./Input"; import Input, { Props as InputProps } from "~/components/Input";
type Props = InputProps & { type Props = InputProps & {
placeholder?: string; placeholder?: string;
@@ -11,7 +11,10 @@ type Props = InputProps & {
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown; onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
}; };
export default function InputSearch(props: Props) { function InputSearch(
props: Props,
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
) {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const [isFocused, setIsFocused] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false);
@@ -39,7 +42,10 @@ export default function InputSearch(props: Props) {
onBlur={handleBlur} onBlur={handleBlur}
margin={0} margin={0}
labelHidden labelHidden
innerRef={ref}
{...rest} {...rest}
/> />
); );
} }
export default React.forwardRef(InputSearch);

View File

@@ -8,7 +8,7 @@ import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown"; import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard"; import { isModKey } from "~/utils/keyboard";
import { searchPath } from "~/utils/routeHelpers"; import { searchPath } from "~/utils/routeHelpers";
import Input from "./Input"; import Input, { Outline } from "./Input";
type Props = { type Props = {
source: string; source: string;
@@ -30,7 +30,7 @@ function InputSearchPage({
collectionId, collectionId,
source, source,
}: Props) { }: Props) {
const inputRef = React.useRef<Input>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const theme = useTheme(); const theme = useTheme();
const history = useHistory(); const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -67,7 +67,7 @@ function InputSearchPage({
return ( return (
<InputMaxWidth <InputMaxWidth
ref={inputRef} innerRef={inputRef}
type="search" type="search"
placeholder={placeholder || `${t("Search")}`} placeholder={placeholder || `${t("Search")}`}
value={value} value={value}
@@ -89,6 +89,10 @@ function InputSearchPage({
const InputMaxWidth = styled(Input)` const InputMaxWidth = styled(Input)`
max-width: 30vw; max-width: 30vw;
${Outline} {
border-radius: 16px;
}
`; `;
export default observer(InputSearchPage); export default observer(InputSearchPage);

View File

@@ -3,19 +3,24 @@ import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText"; import PlaceholderText, {
Props as PlaceholderTextProps,
} from "~/components/PlaceholderText";
type Props = { type Props = {
count?: number; count?: number;
className?: string;
header?: PlaceholderTextProps;
body?: PlaceholderTextProps;
}; };
const ListPlaceHolder = ({ count }: Props) => { const ListPlaceHolder = ({ count, className, header, body }: Props) => {
return ( return (
<Fade> <Fade>
{times(count || 2, (index) => ( {times(count || 2, (index) => (
<Item key={index} column auto> <Item key={index} className={className} column auto>
<PlaceholderText header delay={0.2 * index} /> <PlaceholderText {...header} header delay={0.2 * index} />
<PlaceholderText delay={0.2 * index} /> <PlaceholderText {...body} delay={0.2 * index} />
</Item> </Item>
))} ))}
</Fade> </Fade>

View File

@@ -15,32 +15,33 @@ import { dateToHeading } from "~/utils/dates";
type Props = WithTranslation & type Props = WithTranslation &
RootStore & { RootStore & {
fetch?: (options: Record<string, any> | null | undefined) => Promise<any>; fetch?: (
options: Record<string, any> | null | undefined
) => Promise<any> | undefined;
options?: Record<string, any>; options?: Record<string, any>;
heading?: React.ReactNode; heading?: React.ReactNode;
empty?: React.ReactNode; empty?: React.ReactNode;
items: any[]; loading?: React.ReactElement;
items?: any[];
renderItem: ( renderItem: (
item: any, item: any,
index: number, index: number,
composite: CompositeStateReturn compositeProps: CompositeStateReturn
) => React.ReactNode; ) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode; renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
}; };
@observer @observer
class PaginatedList extends React.Component<Props> { class PaginatedList extends React.Component<Props> {
isInitiallyLoaded = this.props.items.length > 0;
@observable
isLoaded = false;
@observable @observable
isFetchingMore = false; isFetchingMore = false;
@observable @observable
isFetching = false; isFetching = false;
fetchCounter = 0;
@observable @observable
renderCount: number = DEFAULT_PAGINATION_LIMIT; renderCount: number = DEFAULT_PAGINATION_LIMIT;
@@ -70,7 +71,6 @@ class PaginatedList extends React.Component<Props> {
this.renderCount = DEFAULT_PAGINATION_LIMIT; this.renderCount = DEFAULT_PAGINATION_LIMIT;
this.isFetching = false; this.isFetching = false;
this.isFetchingMore = false; this.isFetchingMore = false;
this.isLoaded = false;
}; };
fetchResults = async () => { fetchResults = async () => {
@@ -78,7 +78,9 @@ class PaginatedList extends React.Component<Props> {
return; return;
} }
this.isFetching = true; this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = DEFAULT_PAGINATION_LIMIT; const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.fetch({ const results = await this.props.fetch({
limit, limit,
offset: this.offset, offset: this.offset,
@@ -92,9 +94,12 @@ class PaginatedList extends React.Component<Props> {
} }
this.renderCount += limit; this.renderCount += limit;
this.isLoaded = true;
this.isFetching = false; // only the most recent fetch should end the loading state
this.isFetchingMore = false; if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
}; };
@action @action
@@ -105,7 +110,7 @@ class PaginatedList extends React.Component<Props> {
} }
// If there are already cached results that we haven't yet rendered because // If there are already cached results that we haven't yet rendered because
// of lazy rendering then show another page. // of lazy rendering then show another page.
const leftToRender = this.props.items.length - this.renderCount; const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
if (leftToRender > 1) { if (leftToRender > 1) {
this.renderCount += DEFAULT_PAGINATION_LIMIT; this.renderCount += DEFAULT_PAGINATION_LIMIT;
@@ -120,20 +125,24 @@ class PaginatedList extends React.Component<Props> {
}; };
render() { render() {
const { items, heading, auth, empty, renderHeading } = this.props; const { items, heading, auth, empty, renderHeading, onEscape } = this.props;
let previousHeading = ""; let previousHeading = "";
const showList = !!items?.length;
const showEmpty = items?.length === 0;
const showLoading = const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded; this.isFetching && !this.isFetchingMore && !showList && !showEmpty;
const showEmpty = !items.length && !showLoading;
const showList =
(this.isLoaded || this.isInitiallyLoaded) && !showLoading && !showEmpty;
return ( return (
<> <>
{showEmpty && empty} {showEmpty && empty}
{showList && ( {showList && (
<> <>
{heading} {heading}
<ArrowKeyNavigation aria-label={this.props["aria-label"]}> <ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
>
{(composite: CompositeStateReturn) => {(composite: CompositeStateReturn) =>
items.slice(0, this.renderCount).map((item, index) => { items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem( const children = this.props.renderItem(
@@ -180,11 +189,12 @@ class PaginatedList extends React.Component<Props> {
)} )}
</> </>
)} )}
{showLoading && ( {showLoading &&
<DelayedMount> (this.props.loading || (
<PlaceholderList count={5} /> <DelayedMount>
</DelayedMount> <PlaceholderList count={5} />
)} </DelayedMount>
))}
</> </>
); );
} }

View File

@@ -4,7 +4,7 @@ import { randomInteger } from "@shared/random";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import { pulsate } from "~/styles/animations"; import { pulsate } from "~/styles/animations";
type Props = { export type Props = {
header?: boolean; header?: boolean;
height?: number; height?: number;
minWidth?: number; minWidth?: number;

View File

@@ -1,41 +1,50 @@
import * as React from "react"; import * as React from "react";
import { Dialog } from "reakit/Dialog"; import { Dialog } from "reakit/Dialog";
import { Popover as ReakitPopover } from "reakit/Popover"; import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles"; import { depths } from "@shared/styles";
import useMobile from "~/hooks/useMobile"; import useMobile from "~/hooks/useMobile";
import { fadeAndScaleIn } from "~/styles/animations"; import { fadeAndScaleIn } from "~/styles/animations";
type Props = { type Props = PopoverProps & {
tabIndex?: number; children: React.ReactNode;
width?: number; width?: number;
shrink?: boolean;
tabIndex?: number;
}; };
const Popover: React.FC<Props> = ({ children, width = 380, ...rest }) => { const Popover: React.FC<Props> = ({
children,
shrink,
width = 380,
...rest
}) => {
const isMobile = useMobile(); const isMobile = useMobile();
if (isMobile) { if (isMobile) {
return ( return (
<Dialog {...rest} modal> <Dialog {...rest} modal>
<Contents>{children}</Contents> <Contents $shrink={shrink}>{children}</Contents>
</Dialog> </Dialog>
); );
} }
return ( return (
<ReakitPopover {...rest}> <ReakitPopover {...rest}>
<Contents $width={width}>{children}</Contents> <Contents $shrink={shrink} $width={width}>
{children}
</Contents>
</ReakitPopover> </ReakitPopover>
); );
}; };
const Contents = styled.div<{ $width?: number }>` const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
animation: ${fadeAndScaleIn} 200ms ease; animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0; transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground}; background: ${(props) => props.theme.menuBackground};
border-radius: 6px; border-radius: 6px;
padding: 12px 24px; padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
max-height: 50vh; max-height: 50vh;
overflow-y: scroll; overflow-y: scroll;
box-shadow: ${(props) => props.theme.menuShadow}; box-shadow: ${(props) => props.theme.menuShadow};

View File

@@ -0,0 +1,148 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
type Props = {
document: Document;
highlight: string;
context: string | undefined;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
shareId?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(
props: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { document, highlight, context, shareId, ...rest } = props;
return (
<CompositeItem
as={DocumentLink}
ref={ref}
dir={document.dir}
to={{
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
state: {
title: document.titleWithDefault,
},
}}
{...rest}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Heading>
{
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
}
</Content>
</CompositeItem>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
align-items: center;
padding: 6px 12px;
max-height: 50vh;
&:not(:last-child) {
margin-bottom: 4px;
}
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
&:${hover},
&:active,
&:focus,
&:focus-within {
background: ${(props) => props.theme.listItemHoverBackground};
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
`}
`;
const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 18px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${(props) => props.theme.text};
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
${Mark} {
padding: 0;
}
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${(props) => props.theme.textTertiary};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
${Mark} {
padding: 0;
}
`;
export default observer(React.forwardRef(DocumentListItem));

View File

@@ -0,0 +1,197 @@
import { debounce } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { depths } from "@shared/styles";
import Empty from "~/components/Empty";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Placeholder from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
import SearchListItem from "./SearchListItem";
type Props = { shareId: string };
function SearchPopover({ shareId }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const popover = usePopoverState({
placement: "bottom-start",
unstable_offset: [-24, 0],
modal: true,
});
const [query, setQuery] = React.useState("");
const searchResults = documents.searchResults(query);
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
Record<string, any>[] | undefined
>(searchResults);
React.useEffect(() => {
if (searchResults) {
setCachedQuery(query);
setCachedSearchResults(searchResults);
popover.show();
}
}, [searchResults, query, popover.show]);
const performSearch = React.useCallback(
async ({ query, ...options }: Record<string, any>) => {
if (query?.length > 0) {
return await documents.search(query, { shareId, ...options });
}
return undefined;
},
[documents, shareId]
);
const handleSearch = React.useMemo(
() =>
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setQuery(value.trim());
// covers edge case: user manually dismisses popover then
// quickly edits input resulting in no change in query
// the useEffect that normally shows the popover will miss it
if (value === cachedQuery) {
popover.show();
}
if (!value.length) {
popover.hide();
}
}, 300),
[popover, cachedQuery]
);
const searchInputRef = popover.unstable_referenceRef;
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
const handleEscapeList = React.useCallback(
() => searchInputRef?.current?.focus(),
[searchInputRef]
);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
if (searchResults) {
popover.show();
}
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
if (ev.currentTarget.value.length) {
if (
ev.currentTarget.value.length === ev.currentTarget.selectionStart
) {
popover.show();
}
firstSearchItem.current?.focus();
}
}
if (ev.key === "ArrowUp") {
if (popover.visible) {
popover.hide();
if (!ev.shiftKey) {
ev.preventDefault();
}
}
if (ev.currentTarget.value) {
if (ev.currentTarget.selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
ev.preventDefault();
}
}
}
if (ev.key === "Escape") {
if (popover.visible) {
popover.hide();
ev.preventDefault();
}
}
},
[popover, searchResults]
);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => {
// props assumes the disclosure is a button, but we want a type-ahead
// so we take the aria props, and ref and ignore the event handlers
return (
<StyledInputSearch
aria-controls={props["aria-controls"]}
aria-expanded={props["aria-expanded"]}
aria-haspopup={props["aria-haspopup"]}
ref={props.ref}
onChange={handleSearch}
onKeyDown={handleKeyDown}
/>
);
}}
</PopoverDisclosure>
<Popover
{...popover}
aria-label={t("Results")}
unstable_autoFocusOnShow={false}
style={{ zIndex: depths.sidebar + 1 }}
shrink
>
<PaginatedList
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
onEscape={handleEscapeList}
empty={
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item, index, compositeProps) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
ref={index === 0 ? firstSearchItem : undefined}
document={item.document}
context={item.context}
highlight={cachedQuery}
onClick={popover.hide}
{...compositeProps}
/>
)}
/>
</Popover>
</>
);
}
const NoResults = styled(Empty)`
padding: 0 12px;
margin: 6px 0;
`;
const PlaceholderList = styled(Placeholder)`
padding: 6px 12px;
`;
const StyledInputSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
`;
export default observer(SearchPopover);

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import Scrollable from "~/components/Scrollable"; import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types"; import { NavigationNode } from "~/types";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
@@ -19,6 +20,9 @@ function SharedSidebar({ rootNode, shareId }: Props) {
return ( return (
<Sidebar> <Sidebar>
<ScrollContainer flex> <ScrollContainer flex>
<TopSection>
<SearchPopover shareId={shareId} />
</TopSection>
<Section> <Section>
<DocumentLink <DocumentLink
index={0} index={0}
@@ -38,4 +42,12 @@ const ScrollContainer = styled(Scrollable)`
padding-bottom: 16px; padding-bottom: 16px;
`; `;
const TopSection = styled(Section)`
// this weird looking && increases the specificity of the style rule
&& {
margin-top: 16px;
margin-bottom: 16px;
}
`;
export default observer(SharedSidebar); export default observer(SharedSidebar);

View File

@@ -100,9 +100,31 @@ class Search extends React.Component<Props> {
return this.goBack(); return this.goBack();
} }
if (ev.key === "ArrowDown") { if (ev.key === "ArrowUp") {
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionEnd = ev.currentTarget.selectionEnd || 0;
if (selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = length;
ev.preventDefault();
}
}
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
ev.preventDefault(); ev.preventDefault();
if (ev.currentTarget.value) {
const length = ev.currentTarget.value.length;
const selectionStart = ev.currentTarget.selectionStart || 0;
if (selectionStart < length) {
ev.currentTarget.selectionStart = length;
ev.currentTarget.selectionEnd = length;
return;
}
}
if (this.compositeRef) { if (this.compositeRef) {
const linkItems = this.compositeRef.querySelectorAll( const linkItems = this.compositeRef.querySelectorAll(
"[href]" "[href]"
@@ -269,7 +291,7 @@ class Search extends React.Component<Props> {
render() { render() {
const { documents, notFound, t } = this.props; const { documents, notFound, t } = this.props;
const results = documents.searchResults(this.query); const results = documents.searchResults(this.query);
const showEmpty = !this.isLoading && this.query && results.length === 0; const showEmpty = !this.isLoading && this.query && results?.length === 0;
return ( return (
<Scene textTitle={this.title}> <Scene textTitle={this.title}>
@@ -345,7 +367,7 @@ class Search extends React.Component<Props> {
aria-label={t("Search Results")} aria-label={t("Search Results")}
> >
{(compositeProps) => {(compositeProps) =>
results.map((result) => { results?.map((result) => {
const document = documents.data.get(result.document.id); const document = documents.data.get(result.document.id);
if (!document) { if (!document) {
return null; return null;

View File

@@ -31,6 +31,7 @@ export type SearchParams = {
includeDrafts?: boolean; includeDrafts?: boolean;
collectionId?: string; collectionId?: string;
userId?: string; userId?: string;
shareId?: string;
}; };
type ImportOptions = { type ImportOptions = {
@@ -41,7 +42,7 @@ export default class DocumentsStore extends BaseStore<Document> {
sharedTreeCache: Map<string, NavigationNode | undefined> = new Map(); sharedTreeCache: Map<string, NavigationNode | undefined> = new Map();
@observable @observable
searchCache: Map<string, SearchResult[]> = new Map(); searchCache: Map<string, SearchResult[] | undefined> = new Map();
@observable @observable
backlinks: Map<string, string[]> = new Map(); backlinks: Map<string, string[]> = new Map();
@@ -170,8 +171,8 @@ export default class DocumentsStore extends BaseStore<Document> {
return naturalSort(this.inCollection(collectionId), "title"); return naturalSort(this.inCollection(collectionId), "title");
} }
searchResults(query: string): SearchResult[] { searchResults(query: string): SearchResult[] | undefined {
return this.searchCache.get(query) || []; return this.searchCache.get(query);
} }
@computed @computed

View File

@@ -0,0 +1,18 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("search_queries", "shareId", {
type: Sequelize.UUID,
defaultValue: null,
allowNull: true,
references: {
model: "shares",
key: "id"
},
});
},
down: async (queryInterface) => {
await queryInterface.removeColumn("search_queries", "shareId");
},
};

View File

@@ -4,6 +4,7 @@ import {
buildCollection, buildCollection,
buildTeam, buildTeam,
buildUser, buildUser,
buildShare,
} from "@server/test/factories"; } from "@server/test/factories";
import { flushdb, seed } from "@server/test/support"; import { flushdb, seed } from "@server/test/support";
import slugify from "@server/utils/slugify"; import slugify from "@server/utils/slugify";
@@ -26,7 +27,7 @@ paragraph 2`,
const document = await buildDocument({ const document = await buildDocument({
version: 0, version: 0,
text: `# Heading text: `# Heading
*paragraph*`, *paragraph*`,
}); });
expect(document.getSummary()).toBe("paragraph"); expect(document.getSummary()).toBe("paragraph");
@@ -174,7 +175,7 @@ describe("#searchForTeam", () => {
expect(results[0].document?.id).toBe(document.id); expect(results[0].document?.id).toBe(document.id);
}); });
test("should not return search results from private collections", async () => { test("should not return results from private collections without providing collectionId", async () => {
const team = await buildTeam(); const team = await buildTeam();
const collection = await buildCollection({ const collection = await buildCollection({
permission: null, permission: null,
@@ -189,6 +190,52 @@ describe("#searchForTeam", () => {
expect(results.length).toBe(0); expect(results.length).toBe(0);
}); });
test("should return results from private collections when collectionId is provided", async () => {
const team = await buildTeam();
const collection = await buildCollection({
permission: null,
teamId: team.id,
});
await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test",
});
const { results } = await Document.searchForTeam(team, "test", {
collectionId: collection.id,
});
expect(results.length).toBe(1);
});
test("should return results from document tree of shared document", async () => {
const team = await buildTeam();
const collection = await buildCollection({
permission: null,
teamId: team.id,
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test 1",
});
await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test 2",
});
const share = await buildShare({
documentId: document.id,
includeChildDocuments: true,
});
const { results } = await Document.searchForTeam(team, "test", {
collectionId: collection.id,
share,
});
expect(results.length).toBe(1);
});
test("should handle no collections", async () => { test("should handle no collections", async () => {
const team = await buildTeam(); const team = await buildTeam();
const { results } = await Document.searchForTeam(team, "test"); const { results } = await Document.searchForTeam(team, "test");

View File

@@ -1,4 +1,5 @@
import removeMarkdown from "@tommoor/remove-markdown"; import removeMarkdown from "@tommoor/remove-markdown";
import invariant from "invariant";
import { compact, find, map, uniq } from "lodash"; import { compact, find, map, uniq } from "lodash";
import randomstring from "randomstring"; import randomstring from "randomstring";
import { import {
@@ -38,6 +39,7 @@ import slugify from "@server/utils/slugify";
import Backlink from "./Backlink"; import Backlink from "./Backlink";
import Collection from "./Collection"; import Collection from "./Collection";
import Revision from "./Revision"; import Revision from "./Revision";
import Share from "./Share";
import Star from "./Star"; import Star from "./Star";
import Team from "./Team"; import Team from "./Team";
import User from "./User"; import User from "./User";
@@ -45,7 +47,7 @@ import View from "./View";
import ParanoidModel from "./base/ParanoidModel"; import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
type SearchResponse = { export type SearchResponse = {
results: { results: {
ranking: number; ranking: number;
context: string; context: string;
@@ -58,10 +60,13 @@ type SearchOptions = {
limit?: number; limit?: number;
offset?: number; offset?: number;
collectionId?: string; collectionId?: string;
share?: Share;
dateFilter?: DateFilter; dateFilter?: DateFilter;
collaboratorIds?: string[]; collaboratorIds?: string[];
includeArchived?: boolean; includeArchived?: boolean;
includeDrafts?: boolean; includeDrafts?: boolean;
snippetMinWords?: number;
snippetMaxWords?: number;
}; };
const serializer = new MarkdownSerializer(); const serializer = new MarkdownSerializer();
@@ -436,12 +441,24 @@ class Document extends ParanoidModel {
query: string, query: string,
options: SearchOptions = {} options: SearchOptions = {}
): Promise<SearchResponse> { ): Promise<SearchResponse> {
const limit = options.limit || 15;
const offset = options.offset || 0;
const wildcardQuery = `${escape(query)}:*`; const wildcardQuery = `${escape(query)}:*`;
const collectionIds = await team.collectionIds(); const {
snippetMinWords = 20,
snippetMaxWords = 30,
limit = 15,
offset = 0,
} = options;
// If the team has access no public collections then shortcircuit the rest of this // restrict to specific collection if provided
// enables search in private collections if specified
let collectionIds;
if (options.collectionId) {
collectionIds = [options.collectionId];
} else {
collectionIds = await team.collectionIds();
}
// short circuit if no relevant collections
if (!collectionIds.length) { if (!collectionIds.length) {
return { return {
results: [], results: [],
@@ -449,11 +466,25 @@ class Document extends ParanoidModel {
}; };
} }
// Build the SQL query to get documentIds, ranking, and search term context // restrict to documents in the tree of a shared document when one is provided
let documentIds;
if (options.share?.includeChildDocuments) {
const sharedDocument = await options.share.$get("document");
invariant(sharedDocument, "Cannot find document for share");
const childDocumentIds = await sharedDocument.getChildDocumentIds();
documentIds = [sharedDocument.id, ...childDocumentIds];
}
const documentClause = documentIds ? `"id" IN(:documentIds) AND` : "";
// Build the SQL query to get result documentIds, ranking, and search term context
const whereClause = ` const whereClause = `
"searchVector" @@ to_tsquery('english', :query) AND "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND "teamId" = :teamId AND
"collectionId" IN(:collectionIds) AND "collectionId" IN(:collectionIds) AND
${documentClause}
"deletedAt" IS NULL AND "deletedAt" IS NULL AND
"publishedAt" IS NOT NULL "publishedAt" IS NOT NULL
`; `;
@@ -461,7 +492,7 @@ class Document extends ParanoidModel {
SELECT SELECT
id, id,
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking", 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" ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=:snippetMinWords, MaxWords=:snippetMaxWords') as "searchContext"
FROM documents FROM documents
WHERE ${whereClause} WHERE ${whereClause}
ORDER BY ORDER BY
@@ -479,6 +510,9 @@ class Document extends ParanoidModel {
teamId: team.id, teamId: team.id,
query: wildcardQuery, query: wildcardQuery,
collectionIds, collectionIds,
documentIds,
snippetMinWords,
snippetMaxWords,
}; };
const resultsQuery = this.sequelize!.query(selectSql, { const resultsQuery = this.sequelize!.query(selectSql, {
type: QueryTypes.SELECT, type: QueryTypes.SELECT,
@@ -526,8 +560,12 @@ class Document extends ParanoidModel {
query: string, query: string,
options: SearchOptions = {} options: SearchOptions = {}
): Promise<SearchResponse> { ): Promise<SearchResponse> {
const limit = options.limit || 15; const {
const offset = options.offset || 0; snippetMinWords = 20,
snippetMaxWords = 30,
limit = 15,
offset = 0,
} = options;
const wildcardQuery = `${escape(query)}:*`; const wildcardQuery = `${escape(query)}:*`;
// Ensure we're filtering by the users accessible collections. If // Ensure we're filtering by the users accessible collections. If
@@ -580,7 +618,7 @@ class Document extends ParanoidModel {
SELECT SELECT
id, id,
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking", 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" ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=:snippetMinWords, MaxWords=:snippetMaxWords') as "searchContext"
FROM documents FROM documents
WHERE ${whereClause} WHERE ${whereClause}
ORDER BY ORDER BY
@@ -601,6 +639,8 @@ class Document extends ParanoidModel {
query: wildcardQuery, query: wildcardQuery,
collectionIds, collectionIds,
dateFilter, dateFilter,
snippetMinWords,
snippetMaxWords,
}; };
const resultsQuery = this.sequelize!.query(selectSql, { const resultsQuery = this.sequelize!.query(selectSql, {
type: QueryTypes.SELECT, type: QueryTypes.SELECT,

View File

@@ -38,7 +38,7 @@ Object {
exports[`#documents.search should require authentication 1`] = ` exports[`#documents.search should require authentication 1`] = `
Object { Object {
"error": "authentication_required", "error": "authentication_required",
"message": "Authentication required", "message": "Authentication error",
"ok": false, "ok": false,
"status": 401, "status": 401,
} }

View File

@@ -433,7 +433,7 @@ describe("#documents.info", () => {
id: document.id, id: document.id,
}, },
}); });
expect(res.status).toEqual(403); expect(res.status).toEqual(401);
}); });
it("should require authorization with incorrect token", async () => { it("should require authorization with incorrect token", async () => {
@@ -633,7 +633,7 @@ describe("#documents.export", () => {
id: document.id, id: document.id,
}, },
}); });
expect(res.status).toEqual(403); expect(res.status).toEqual(401);
}); });
it("should require authorization with incorrect token", async () => { it("should require authorization with incorrect token", async () => {
@@ -944,6 +944,59 @@ describe("#documents.search", () => {
expect(body.data[0].document.text).toEqual("# Much test support"); expect(body.data[0].document.text).toEqual("# Much test support");
}); });
it("should return results using shareId", async () => {
const findableDocument = await buildDocument({
title: "search term",
text: "random text",
});
await buildDocument({
title: "search term",
text: "should not be found",
userId: findableDocument.createdById,
teamId: findableDocument.teamId,
});
const share = await buildShare({
includeChildDocuments: true,
documentId: findableDocument.id,
teamId: findableDocument.teamId,
});
const res = await server.post("/api/documents.search", {
body: {
query: "search term",
shareId: share.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].document.id).toEqual(share.documentId);
});
it("should not allow search if child documents are not included", async () => {
const findableDocument = await buildDocument({
title: "search term",
text: "random text",
});
const share = await buildShare({
includeChildDocuments: false,
document: findableDocument,
});
const res = await server.post("/api/documents.search", {
body: {
query: "search term",
shareId: share.id,
},
});
expect(res.status).toEqual(400);
});
it("should return results in ranked order", async () => { it("should return results in ranked order", async () => {
const { user } = await seed(); const { user } = await seed();
const firstResult = await buildDocument({ const firstResult = await buildDocument({
@@ -1287,7 +1340,11 @@ describe("#documents.search", () => {
}); });
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", {
body: {
query: "search term",
},
});
const body = await res.json(); const body = await res.json();
expect(res.status).toEqual(401); expect(res.status).toEqual(401);
expect(body).toMatchSnapshot(); expect(body).toMatchSnapshot();

View File

@@ -11,6 +11,7 @@ import {
NotFoundError, NotFoundError,
InvalidRequestError, InvalidRequestError,
AuthorizationError, AuthorizationError,
AuthenticationError,
} from "@server/errors"; } from "@server/errors";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { import {
@@ -386,7 +387,7 @@ async function loadDocument({
}: { }: {
id?: string; id?: string;
shareId?: string; shareId?: string;
user: User; user?: User;
}): Promise<{ }): Promise<{
document: Document; document: Document;
share?: Share; share?: Share;
@@ -396,6 +397,10 @@ async function loadDocument({
let collection; let collection;
let share; let share;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
}
if (shareId) { if (shareId) {
share = await Share.findOne({ share = await Share.findOne({
where: { where: {
@@ -454,7 +459,7 @@ async function loadDocument({
// If the user has access to read the document, we can just update // If the user has access to read the document, we can just update
// the last access date and return the document without additional checks. // the last access date and return the document without additional checks.
const canReadDocument = can(user, "read", document); const canReadDocument = user && can(user, "read", document);
if (canReadDocument) { if (canReadDocument) {
await share.update({ await share.update({
@@ -519,9 +524,9 @@ async function loadDocument({
if (document.deletedAt) { if (document.deletedAt) {
// don't send data if user cannot restore deleted doc // don't send data if user cannot restore deleted doc
authorize(user, "restore", document); user && authorize(user, "restore", document);
} else { } else {
authorize(user, "read", document); user && authorize(user, "read", document);
} }
collection = document.collection; collection = document.collection;
@@ -739,82 +744,133 @@ router.post("documents.search_titles", auth(), pagination(), async (ctx) => {
}; };
}); });
router.post("documents.search", auth(), pagination(), async (ctx) => { router.post(
const { "documents.search",
query, auth({
includeArchived, required: false,
includeDrafts, }),
collectionId, pagination(),
userId, async (ctx) => {
dateFilter, const {
} = ctx.body;
const { offset, limit } = ctx.state.pagination;
const { user } = ctx.state;
assertNotEmpty(query, "query is required");
if (collectionId) {
assertUuid(collectionId, "collectionId must be a UUID");
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
}
let collaboratorIds = undefined;
if (userId) {
assertUuid(userId, "userId must be a UUID");
collaboratorIds = [userId];
}
if (dateFilter) {
assertIn(
dateFilter,
["day", "week", "month", "year"],
"dateFilter must be one of day,week,month,year"
);
}
const { results, totalCount } = await Document.searchForUser(user, query, {
includeArchived: includeArchived === "true",
includeDrafts: includeDrafts === "true",
collaboratorIds,
collectionId,
dateFilter,
offset,
limit,
});
const documents = results.map((result) => result.document);
const data = await Promise.all(
results.map(async (result) => {
const document = await presentDocument(result.document);
return { ...result, document };
})
);
// When requesting subsequent pages of search results we don't want to record
// duplicate search query records
if (offset === 0) {
SearchQuery.create({
userId: user.id,
teamId: user.teamId,
source: ctx.state.authType,
query, query,
results: totalCount, includeArchived,
}); includeDrafts,
collectionId,
userId,
dateFilter,
shareId,
} = ctx.body;
assertNotEmpty(query, "query is required");
const { offset, limit } = ctx.state.pagination;
const snippetMinWords = parseInt(ctx.body.snippetMinWords || 20, 10);
const snippetMaxWords = parseInt(ctx.body.snippetMaxWords || 30, 10);
// this typing is a bit ugly, would be better to use a type like ContextWithState
// but that doesn't adequately handle cases when auth is optional
const { user }: { user: User | undefined } = ctx.state;
let teamId;
let response;
if (shareId) {
const { share, document } = await loadDocument({
shareId,
user,
});
if (!share?.includeChildDocuments) {
throw InvalidRequestError("Child documents cannot be searched");
}
teamId = share.teamId;
const team = await Team.findByPk(teamId);
invariant(team, "Share must belong to a team");
response = await Document.searchForTeam(team, query, {
includeArchived: includeArchived === "true",
includeDrafts: includeDrafts === "true",
collectionId: document.collectionId,
share,
dateFilter,
offset,
limit,
snippetMinWords,
snippetMaxWords,
});
} else {
if (!user) {
throw AuthenticationError("Authentication error");
}
teamId = user.teamId;
if (collectionId) {
assertUuid(collectionId, "collectionId must be a UUID");
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
}
let collaboratorIds = undefined;
if (userId) {
assertUuid(userId, "userId must be a UUID");
collaboratorIds = [userId];
}
if (dateFilter) {
assertIn(
dateFilter,
["day", "week", "month", "year"],
"dateFilter must be one of day,week,month,year"
);
}
response = await Document.searchForUser(user, query, {
includeArchived: includeArchived === "true",
includeDrafts: includeDrafts === "true",
collaboratorIds,
collectionId,
dateFilter,
offset,
limit,
snippetMinWords,
snippetMaxWords,
});
}
const { results, totalCount } = response;
const documents = results.map((result) => result.document);
const data = await Promise.all(
results.map(async (result) => {
const document = await presentDocument(result.document);
return { ...result, document };
})
);
// When requesting subsequent pages of search results we don't want to record
// duplicate search query records
if (offset === 0) {
SearchQuery.create({
userId: user?.id,
teamId,
shareId,
source: ctx.state.authType || "app", // we'll consider anything that isn't "api" to be "app"
query,
results: totalCount,
});
}
ctx.body = {
pagination: ctx.state.pagination,
data,
policies: user ? presentPolicies(user, documents) : null,
};
} }
);
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
});
// Deprecated use stars.create instead // Deprecated use stars.create instead
router.post("documents.star", auth(), async (ctx) => { router.post("documents.star", auth(), async (ctx) => {

View File

@@ -136,6 +136,8 @@
"Dismiss": "Dismiss", "Dismiss": "Dismiss",
"Back": "Back", "Back": "Back",
"Documents": "Documents", "Documents": "Documents",
"Results": "Results",
"No results for {{query}}": "No results for {{query}}",
"Logo": "Logo", "Logo": "Logo",
"Document archived": "Document archived", "Document archived": "Document archived",
"Move document": "Move document", "Move document": "Move document",

View File

@@ -14,6 +14,7 @@ const colors = {
smokeLight: "#F9FBFC", smokeLight: "#F9FBFC",
smokeDark: "#E8EBED", smokeDark: "#E8EBED",
white: "#FFF", white: "#FFF",
white05: "rgba(255, 255, 255, 0.05)",
white10: "rgba(255, 255, 255, 0.1)", white10: "rgba(255, 255, 255, 0.1)",
white50: "rgba(255, 255, 255, 0.5)", white50: "rgba(255, 255, 255, 0.5)",
white75: "rgba(255, 255, 255, 0.75)", white75: "rgba(255, 255, 255, 0.75)",
@@ -169,7 +170,7 @@ export const dark = {
placeholder: colors.slateDark, placeholder: colors.slateDark,
sidebarBackground: colors.veryDarkBlue, sidebarBackground: colors.veryDarkBlue,
sidebarActiveBackground: lighten(0.02, colors.almostBlack), sidebarActiveBackground: lighten(0.02, colors.almostBlack),
sidebarControlHoverBackground: "rgba(255,255,255,0.1)", sidebarControlHoverBackground: colors.white10,
sidebarDraftBorder: darken("0.35", colors.slate), sidebarDraftBorder: darken("0.35", colors.slate),
sidebarText: colors.slate, sidebarText: colors.slate,
backdrop: "rgba(255, 255, 255, 0.3)", backdrop: "rgba(255, 255, 255, 0.3)",
@@ -188,7 +189,7 @@ export const dark = {
titleBarDivider: darken(0.4, colors.slate), titleBarDivider: darken(0.4, colors.slate),
inputBorder: colors.slateDark, inputBorder: colors.slateDark,
inputBorderFocused: colors.slate, inputBorderFocused: colors.slate,
listItemHoverBackground: colors.black50, listItemHoverBackground: colors.white10,
toolbarHoverBackground: colors.slate, toolbarHoverBackground: colors.slate,
toolbarBackground: colors.white, toolbarBackground: colors.white,
toolbarInput: colors.black10, toolbarInput: colors.black10,