Merge pull request #1563 from outline/release-0.48.0

release: v48.0.0
This commit is contained in:
Tom Moor
2020-09-26 17:09:40 -07:00
committed by GitHub
67 changed files with 871 additions and 226 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ npm-debug.log
stats.json
.DS_Store
fakes3/*
.idea

View File

@@ -4,9 +4,10 @@ import styled from "styled-components";
const Badge = styled.span`
margin-left: 10px;
padding: 2px 6px 3px;
background-color: ${({ primary, theme }) =>
primary ? theme.primary : theme.textTertiary};
color: ${({ primary, theme }) => (primary ? theme.white : theme.background)};
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
border-radius: 4px;
font-size: 11px;
font-weight: 500;

View File

@@ -18,8 +18,7 @@ const Container = styled(Flex)`
`;
const Modified = styled.span`
color: ${(props) =>
props.highlight ? props.theme.text : props.theme.textTertiary};
color: ${(props) => props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
@@ -28,6 +27,7 @@ type Props = {
auth: AuthStore,
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
document: Document,
children: React.Node,
to?: string,
@@ -38,6 +38,7 @@ function DocumentMeta({
collections,
showPublished,
showCollection,
showLastViewed,
document,
children,
to,
@@ -52,6 +53,7 @@ function DocumentMeta({
archivedAt,
deletedAt,
isDraft,
lastViewedAt,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -65,37 +67,37 @@ function DocumentMeta({
if (deletedAt) {
content = (
<span>
deleted <Time dateTime={deletedAt} /> ago
deleted <Time dateTime={deletedAt} addSuffix />
</span>
);
} else if (archivedAt) {
content = (
<span>
archived <Time dateTime={archivedAt} /> ago
archived <Time dateTime={archivedAt} addSuffix />
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
created <Time dateTime={updatedAt} /> ago
created <Time dateTime={updatedAt} addSuffix />
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
published <Time dateTime={publishedAt} /> ago
published <Time dateTime={publishedAt} addSuffix />
</span>
);
} else if (isDraft) {
content = (
<span>
saved <Time dateTime={updatedAt} /> ago
saved <Time dateTime={updatedAt} addSuffix />
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
updated <Time dateTime={updatedAt} /> ago
updated <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
@@ -103,6 +105,25 @@ function DocumentMeta({
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
return null;
}
if (!lastViewedAt) {
return (
<>
&nbsp;<Modified highlight>Never viewed</Modified>
</>
);
}
return (
<span>
&nbsp;Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
</span>
);
};
return (
<Container align="center" {...rest}>
{updatedByMe ? "You" : updatedBy.name}&nbsp;
@@ -115,6 +136,7 @@ function DocumentMeta({
</strong>
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
);

View File

@@ -1,8 +1,9 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { StarredIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { Link, withRouter, type RouterHistory } from "react-router-dom";
import { Link, Redirect } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Badge from "components/Badge";
@@ -15,7 +16,6 @@ import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
document: Document,
highlight?: ?string,
context?: ?string,
@@ -30,6 +30,8 @@ const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@observer
class DocumentPreview extends React.Component<Props> {
@observable redirectTo: ?string;
handleStar = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
@@ -48,17 +50,15 @@ class DocumentPreview extends React.Component<Props> {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
};
handleNewFromTemplate = (event) => {
handleNewFromTemplate = (event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
const { document } = this.props;
this.props.history.push(
newDocumentUrl(document.collectionId, {
templateId: document.id,
})
);
this.redirectTo = newDocumentUrl(document.collectionId, {
templateId: document.id,
});
};
render() {
@@ -73,6 +73,10 @@ class DocumentPreview extends React.Component<Props> {
context,
} = this.props;
if (this.redirectTo) {
return <Redirect to={this.redirectTo} push />;
}
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
@@ -86,6 +90,7 @@ class DocumentPreview extends React.Component<Props> {
>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && <Badge yellow>New</Badge>}
{!document.isDraft &&
!document.isArchived &&
!document.isTemplate && (
@@ -133,6 +138,7 @@ class DocumentPreview extends React.Component<Props> {
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</DocumentLink>
);
@@ -228,4 +234,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default withRouter(DocumentPreview);
export default DocumentPreview;

View File

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

View File

@@ -12,6 +12,7 @@ import { searchUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
theme: Object,
source: string,
placeholder?: string,
collectionId?: string,
};
@@ -33,7 +34,10 @@ class InputSearch extends React.Component<Props> {
handleSearchInput = (ev) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, this.props.collectionId)
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
};

View File

@@ -24,6 +24,8 @@ type Props = {
dateTime: string,
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
};
class Time extends React.Component<Props> {
@@ -40,6 +42,18 @@ class Time extends React.Component<Props> {
}
render() {
const { shorten, addSuffix } = this.props;
let content = distanceInWordsToNow(this.props.dateTime, {
addSuffix,
});
if (shorten) {
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
return (
<Tooltip
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
@@ -47,7 +61,7 @@ class Time extends React.Component<Props> {
placement="bottom"
>
<time dateTime={this.props.dateTime}>
{this.props.children || distanceInWordsToNow(this.props.dateTime)}
{this.props.children || content}
</time>
</Tooltip>
);

View File

@@ -21,6 +21,7 @@ export default class Abstract extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://app.goabstract.com/embed/${shareId}`}
title={`Abstract (${shareId})`}
/>

View File

@@ -20,6 +20,7 @@ export default class Airtable extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://airtable.com/embed/${shareId}`}
title={`Airtable (${shareId})`}
border

View File

@@ -17,6 +17,12 @@ export default class ClickUp extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="ClickUp Embed" />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="ClickUp Embed"
/>
);
}
}

View File

@@ -17,6 +17,6 @@ export default class Codepen extends React.Component<Props> {
render() {
const normalizedUrl = this.props.attrs.href.replace(/\/pen\//, "/embed/");
return <Frame src={normalizedUrl} title="Codepen Embed" />;
return <Frame {...this.props} src={normalizedUrl} title="Codepen Embed" />;
}
}

View File

@@ -19,6 +19,7 @@ export default class Figma extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={`https://www.figma.com/embed?embed_host=outline&url=${this.props.attrs.href}`}
title="Figma Embed"
border

View File

@@ -15,6 +15,13 @@ export default class Framer extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="Framer Embed" border />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Framer Embed"
border
/>
);
}
}

View File

@@ -6,6 +6,7 @@ const URL_REGEX = new RegExp(
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -48,6 +49,7 @@ class Gist extends React.Component<Props> {
return (
<iframe
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
ref={this.updateIframeContent}
type="text/html"
frameBorder="0"

View File

@@ -17,6 +17,7 @@ export default class GoogleDocs extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img

View File

@@ -17,6 +17,7 @@ export default class GoogleSlides extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img

View File

@@ -17,6 +17,7 @@ export default class GoogleSlides extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href
.replace("/edit", "/preview")
.replace("/pub", "/embed")}

View File

@@ -12,6 +12,7 @@ const IMAGE_REGEX = new RegExp(
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -25,6 +26,7 @@ export default class InVision extends React.Component<Props> {
if (IMAGE_REGEX.test(this.props.attrs.href)) {
return (
<ImageZoom
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
image={{
src: this.props.attrs.href,
alt: "InVision Embed",
@@ -37,6 +39,12 @@ export default class InVision extends React.Component<Props> {
/>
);
}
return <Frame src={this.props.attrs.href} title="InVision Embed" />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="InVision Embed"
/>
);
}
}

View File

@@ -17,6 +17,6 @@ export default class Loom extends React.Component<Props> {
render() {
const normalizedUrl = this.props.attrs.href.replace("share", "embed");
return <Frame src={normalizedUrl} title="Loom Embed" />;
return <Frame {...this.props} src={normalizedUrl} title="Loom Embed" />;
}
}

View File

@@ -20,6 +20,7 @@ export default class Lucidchart extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://lucidchart.com/documents/embeddedchart/${chartId}`}
title="Lucidchart Embed"
/>

View File

@@ -15,6 +15,13 @@ export default class Marvel extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="Marvel Embed" border />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Marvel Embed"
border
/>
);
}
}

View File

@@ -21,6 +21,7 @@ export default class Mindmeister extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://www.mindmeister.com/maps/public_map_shell/${chartId}`}
title="Mindmeister Embed"
border

View File

@@ -20,6 +20,7 @@ export default class RealtimeBoard extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://realtimeboard.com/app/embed/${boardId}`}
title={`RealtimeBoard (${boardId})`}
/>

View File

@@ -21,7 +21,11 @@ export default class ModeAnalytics extends React.Component<Props> {
const normalizedUrl = this.props.attrs.href.replace(/\/embed$/, "");
return (
<Frame src={`${normalizedUrl}/embed`} title="Mode Analytics Embed" />
<Frame
{...this.props}
src={`${normalizedUrl}/embed`}
title="Mode Analytics Embed"
/>
);
}
}

View File

@@ -17,6 +17,8 @@ export default class Prezi extends React.Component<Props> {
render() {
const url = this.props.attrs.href.replace(/\/embed$/, "");
return <Frame src={`${url}/embed`} title="Prezi Embed" border />;
return (
<Frame {...this.props} src={`${url}/embed`} title="Prezi Embed" border />
);
}
}

View File

@@ -27,6 +27,7 @@ export default class Spotify extends React.Component<Props> {
return (
<Frame
{...this.props}
width="300px"
height="380px"
src={`https://open.spotify.com/embed${normalizedPath}`}

View File

@@ -31,6 +31,7 @@ export default class Trello extends React.Component<Props> {
return (
<Frame
{...this.props}
width="248px"
height="185px"
src={`https://trello.com/embed/board?id=${objectId}`}

View File

@@ -17,6 +17,12 @@ export default class Typeform extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="Typeform Embed" />;
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Typeform Embed"
/>
);
}
}

View File

@@ -20,6 +20,7 @@ export default class Vimeo extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://player.vimeo.com/video/${videoId}?byline=0`}
title={`Vimeo Embed (${videoId})`}
/>

View File

@@ -5,6 +5,7 @@ import Frame from "./components/Frame";
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -20,6 +21,7 @@ export default class YouTube extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`}
title={`YouTube (${videoId})`}
/>

View File

@@ -12,6 +12,7 @@ type Props = {
title?: string,
icon?: React.Node,
canonicalUrl?: string,
isSelected?: boolean,
width?: string,
height?: string,
};
@@ -48,13 +49,19 @@ class Frame extends React.Component<PropsWithRef> {
icon,
title,
canonicalUrl,
...rest
isSelected,
src,
} = this.props;
const Component = border ? StyledIframe : "iframe";
const withBar = !!(icon || canonicalUrl);
return (
<Rounded width={width} height={height} withBar={withBar}>
<Rounded
width={width}
height={height}
withBar={withBar}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
{this.isLoaded && (
<Component
ref={forwardedRef}
@@ -66,8 +73,8 @@ class Frame extends React.Component<PropsWithRef> {
frameBorder="0"
title="embed"
loading="lazy"
src={src}
allowFullScreen
{...rest}
/>
)}
{withBar && (

View File

@@ -1,5 +1,6 @@
// @flow
import addDays from "date-fns/add_days";
import differenceInDays from "date-fns/difference_in_days";
import invariant from "invariant";
import { action, computed, observable, set } from "mobx";
import parseTitle from "shared/utils/parseTitle";
@@ -7,6 +8,7 @@ import unescape from "shared/utils/unescape";
import DocumentsStore from "stores/DocumentsStore";
import BaseModel from "models/BaseModel";
import User from "models/User";
import View from "./View";
type SaveOptions = {
publish?: boolean,
@@ -19,11 +21,11 @@ export default class Document extends BaseModel {
@observable isSaving: boolean = false;
@observable embedsDisabled: boolean = false;
@observable injectTemplate: boolean = false;
@observable lastViewedAt: ?string;
store: DocumentsStore;
collaborators: User[];
collectionId: string;
lastViewedAt: ?string;
createdAt: string;
createdBy: User;
updatedAt: string;
@@ -47,7 +49,7 @@ export default class Document extends BaseModel {
constructor(fields: Object, store: DocumentsStore) {
super(fields, store);
if (this.isNew && this.isFromTemplate) {
if (this.isNewDocument && this.isFromTemplate) {
this.title = "";
}
}
@@ -72,6 +74,14 @@ export default class Document extends BaseModel {
return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt;
}
@computed
get isNew(): boolean {
return (
!this.lastViewedAt &&
differenceInDays(new Date(), new Date(this.createdAt)) < 14
);
}
@computed
get isStarred(): boolean {
return !!this.store.starredIds.get(this.id);
@@ -112,7 +122,7 @@ export default class Document extends BaseModel {
}
@computed
get isNew(): boolean {
get isNewDocument(): boolean {
return this.createdAt === this.updatedAt;
}
@@ -199,6 +209,11 @@ export default class Document extends BaseModel {
return this.store.rootStore.views.create({ documentId: this.id });
};
@action
updateLastViewed = (view: View) => {
this.lastViewedAt = view.lastViewedAt;
};
@action
templatize = async () => {
return this.store.templatize(this.id);

View File

@@ -140,6 +140,7 @@ class CollectionScene extends React.Component<Props> {
<>
<Action>
<InputSearch
source="collection"
placeholder="Search in collection…"
collectionId={match.params.id}
/>

View File

@@ -67,7 +67,7 @@ class Dashboard extends React.Component<Props> {
</Switch>
<Actions align="center" justify="flex-end">
<Action>
<InputSearch />
<InputSearch source="dashboard" />
</Action>
<Action>
<NewDocumentMenu />

View File

@@ -1,5 +1,7 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import invariant from "invariant";
import { deburr, sortBy } from "lodash";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
@@ -77,9 +79,13 @@ class DataLoader extends React.Component<Props> {
const slug = parseDocumentSlug(term);
try {
const document = await this.props.documents.fetch(slug);
const time = distanceInWordsToNow(document.updatedAt, {
addSuffix: true,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
},
];
@@ -92,14 +98,26 @@ class DataLoader extends React.Component<Props> {
}
// default search for anything that doesn't look like a URL
const results = await this.props.documents.search(term);
const results = await this.props.documents.searchTitles(term);
return results
.filter((result) => result.document.title)
.map((result) => ({
title: result.document.title,
url: result.document.url,
}));
return sortBy(
results.map((document) => {
const time = distanceInWordsToNow(document.updatedAt, {
addSuffix: true,
});
return {
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
};
}),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
};
onCreateLink = async (title: string) => {

View File

@@ -21,7 +21,7 @@ type Props = {
isDraft: boolean,
isShare: boolean,
readOnly?: boolean,
onSave: () => mixed,
onSave: ({ publish?: boolean, done?: boolean, autosave?: boolean }) => mixed,
innerRef: { current: any },
};
@@ -50,8 +50,13 @@ class DocumentEditor extends React.Component<Props> {
};
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
if (event.key === "Enter" && !event.metaKey) {
if (event.key === "Enter") {
event.preventDefault();
if (event.metaKey) {
this.props.onSave({ publish: true, done: true });
return;
}
this.insertParagraph();
this.focusAtStart();
return;
@@ -63,7 +68,7 @@ class DocumentEditor extends React.Component<Props> {
}
if (event.key === "s" && event.metaKey) {
event.preventDefault();
this.props.onSave();
this.props.onSave({});
return;
}
};

View File

@@ -15,9 +15,10 @@ class MarkAsViewed extends React.Component<Props> {
componentDidMount() {
const { document } = this.props;
this.viewTimeout = setTimeout(() => {
this.viewTimeout = setTimeout(async () => {
if (document.publishedAt) {
document.view();
const view = await document.view();
document.updateLastViewed(view);
}
}, MARK_AS_VIEWED_AFTER);
}

View File

@@ -37,7 +37,7 @@ class Drafts extends React.Component<Props> {
<Actions align="center" justify="flex-end">
<Action>
<InputSearch />
<InputSearch source="drafts" />
</Action>
<Action>
<NewDocumentMenu />

View File

@@ -1,5 +1,6 @@
// @flow
import * as React from "react";
import { Link } from "react-router-dom";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
import PageTitle from "components/PageTitle";
@@ -10,8 +11,8 @@ const Error404 = () => {
<PageTitle title="Not Found" />
<h1>Not found</h1>
<Empty>
We were unable to find the page youre looking for. Go to the&nbsp;
<a href="/">homepage</a>?
We were unable to find the page youre looking for. Go to the{" "}
<Link to="/home">homepage</Link>?
</Empty>
</CenteredContent>
);

View File

@@ -49,13 +49,14 @@ type Props = {
@observer
class Search extends React.Component<Props> {
firstDocument: ?React.Component<typeof DocumentPreview>;
lastQuery: string = "";
@observable
query: string = decodeURIComponent(this.props.match.params.term || "");
@observable params: URLSearchParams = new URLSearchParams();
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable isFetching: boolean = false;
@observable isLoading: boolean = false;
@observable pinToTop: boolean = !!this.props.match.params.term;
componentDidMount() {
@@ -81,14 +82,17 @@ class Search extends React.Component<Props> {
}
handleKeyDown = (ev) => {
// Escape
if (ev.which === 27) {
ev.preventDefault();
this.goBack();
if (ev.key === "Enter") {
this.fetchResults();
return;
}
// Down
if (ev.which === 40) {
if (ev.key === "Escape") {
ev.preventDefault();
return this.goBack();
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
if (this.firstDocument) {
const element = ReactDOM.findDOMNode(this.firstDocument);
@@ -103,7 +107,7 @@ class Search extends React.Component<Props> {
this.allowLoadMore = true;
// To prevent "no results" showing before debounce kicks in
this.isFetching = true;
this.isLoading = true;
this.fetchResultsDebounced();
};
@@ -115,7 +119,7 @@ class Search extends React.Component<Props> {
this.allowLoadMore = true;
// To prevent "no results" showing before debounce kicks in
this.isFetching = !!this.query;
this.isLoading = !!this.query;
this.fetchResultsDebounced();
};
@@ -174,7 +178,7 @@ class Search extends React.Component<Props> {
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
if (!this.allowLoadMore || this.isLoading) return;
// Fetch more results
await this.fetchResults();
@@ -183,7 +187,14 @@ class Search extends React.Component<Props> {
@action
fetchResults = async () => {
if (this.query) {
this.isFetching = true;
// we just requested this thing no need to try again
if (this.lastQuery === this.query) {
this.isLoading = false;
return;
}
this.isLoading = true;
this.lastQuery = this.query;
try {
const results = await this.props.documents.search(this.query, {
@@ -203,15 +214,19 @@ class Search extends React.Component<Props> {
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
} catch (err) {
this.lastQuery = "";
throw err;
} finally {
this.isFetching = false;
this.isLoading = false;
}
} else {
this.pinToTop = false;
this.lastQuery = this.query;
}
};
fetchResultsDebounced = debounce(this.fetchResults, 350, {
fetchResultsDebounced = debounce(this.fetchResults, 500, {
leading: false,
trailing: true,
});
@@ -231,14 +246,14 @@ class Search extends React.Component<Props> {
render() {
const { documents, notFound, location } = this.props;
const results = documents.searchResults(this.query);
const showEmpty = !this.isFetching && this.query && results.length === 0;
const showEmpty = !this.isLoading && this.query && results.length === 0;
const showShortcutTip =
!this.pinToTop && location.state && location.state.fromMenu;
return (
<Container auto>
<PageTitle title={this.title} />
{this.isFetching && <LoadingIndicator />}
{this.isLoading && <LoadingIndicator />}
{notFound && (
<div>
<h1>Not Found</h1>

View File

@@ -49,7 +49,7 @@ class Starred extends React.Component<Props> {
<Actions align="center" justify="flex-end">
<Action>
<InputSearch />
<InputSearch source="starred" />
</Action>
<Action>
<NewDocumentMenu />

View File

@@ -3,6 +3,7 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
@@ -10,6 +11,7 @@ import Modal from "components/Modal";
type Props = {
auth: AuthStore,
ui: UiStore,
onRequestClose: () => void,
};
@@ -24,6 +26,9 @@ class UserDelete extends React.Component<Props> {
try {
await this.props.auth.deleteUser();
this.props.auth.logout();
} catch (error) {
this.props.ui.showToast(error.message);
throw error;
} finally {
this.isDeleting = false;
}
@@ -56,4 +61,4 @@ class UserDelete extends React.Component<Props> {
}
}
export default inject("auth")(UserDelete);
export default inject("auth", "ui")(UserDelete);

View File

@@ -114,7 +114,7 @@ export default class BaseStore<T: BaseModel> {
}
@action
async delete(item: T, options?: Object = {}) {
async delete(item: T, options: Object = {}) {
if (!this.actions.includes("delete")) {
throw new Error(`Cannot delete ${this.modelName}`);
}
@@ -132,7 +132,7 @@ export default class BaseStore<T: BaseModel> {
}
@action
async fetch(id: string, options?: Object = {}): Promise<*> {
async fetch(id: string, options: Object = {}): Promise<*> {
if (!this.actions.includes("info")) {
throw new Error(`Cannot fetch ${this.modelName}`);
}

View File

@@ -1,15 +1,6 @@
// @flow
import invariant from "invariant";
import {
without,
map,
find,
orderBy,
filter,
compact,
omitBy,
uniq,
} from "lodash";
import { find, orderBy, filter, compact, omitBy } from "lodash";
import { observable, action, computed, runInAction } from "mobx";
import naturalSort from "shared/utils/naturalSort";
import BaseStore from "stores/BaseStore";
@@ -23,7 +14,6 @@ type ImportOptions = {
};
export default class DocumentsStore extends BaseStore<Document> {
@observable recentlyViewedIds: string[] = [];
@observable searchCache: Map<string, SearchResult[]> = new Map();
@observable starredIds: Map<string, boolean> = new Map();
@observable backlinks: Map<string, string[]> = new Map();
@@ -50,8 +40,8 @@ export default class DocumentsStore extends BaseStore<Document> {
@computed
get recentlyViewed(): Document[] {
return orderBy(
compact(this.recentlyViewedIds.map((id) => this.data.get(id))),
"updatedAt",
filter(this.all, (d) => d.lastViewedAt),
"lastViewedAt",
"desc"
);
}
@@ -299,15 +289,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@action
fetchRecentlyViewed = async (options: ?PaginationParams): Promise<*> => {
const data = await this.fetchNamedPage("viewed", options);
runInAction("DocumentsStore#fetchRecentlyViewed", () => {
// $FlowFixMe
this.recentlyViewedIds.replace(
uniq(this.recentlyViewedIds.concat(map(data, "id")))
);
});
return data;
return this.fetchNamedPage("viewed", options);
};
@action
@@ -330,12 +312,25 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.fetchNamedPage("list", options);
};
@action
searchTitles = async (query: string, options: PaginationParams = {}) => {
const res = await client.get("/documents.search_titles", {
query,
...options,
});
invariant(res && res.data, "Search response should be available");
// add the documents and associated policies to the store
res.data.forEach(this.add);
this.addPolicies(res.policies);
return res.data;
};
@action
search = async (
query: string,
options: PaginationParams = {}
): Promise<SearchResult[]> => {
// $FlowFixMe
const compactedOptions = omitBy(options, (o) => !o);
const res = await client.get("/documents.search", {
...compactedOptions,
@@ -399,7 +394,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@action
fetch = async (
id: string,
options?: FetchOptions = {}
options: FetchOptions = {}
): Promise<?Document> => {
if (!options.prefetch) this.isFetching = true;
@@ -482,7 +477,7 @@ export default class DocumentsStore extends BaseStore<Document> {
{ key: "title", value: title },
{ key: "publish", value: options.publish },
{ key: "file", value: file },
].map((info) => {
].forEach((info) => {
if (typeof info.value === "string" && info.value) {
formData.append(info.key, info.value);
}
@@ -541,10 +536,6 @@ export default class DocumentsStore extends BaseStore<Document> {
async delete(document: Document) {
await super.delete(document);
runInAction(() => {
this.recentlyViewedIds = without(this.recentlyViewedIds, document.id);
});
// check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too.
const share = this.rootStore.shares.getByDocumentId(document.id);

View File

@@ -63,14 +63,22 @@ export function newDocumentUrl(
return `/collections/${collectionId}/new?${queryString.stringify(params)}`;
}
export function searchUrl(query?: string, collectionId?: string): string {
let route = "/search";
if (query) route += `/${encodeURIComponent(query)}`;
if (collectionId) {
route += `?collectionId=${collectionId}`;
export function searchUrl(
query?: string,
params?: {
collectionId?: string,
ref?: string,
}
return route;
): string {
let search = queryString.stringify(params);
let route = "/search";
if (query) {
route += `/${encodeURIComponent(query)}`;
}
search = search ? `?${search}` : "";
return `${route}${search}`;
}
export function notFoundUrl(): string {

View File

@@ -140,7 +140,7 @@
"react-portal": "^4.0.0",
"react-router-dom": "^5.1.2",
"react-waypoint": "^9.0.2",
"rich-markdown-editor": "^11.0.0-4",
"rich-markdown-editor": "^11.0.0-9",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",
@@ -195,4 +195,4 @@
"js-yaml": "^3.13.1"
},
"version": "0.47.1"
}
}

View File

@@ -11,6 +11,7 @@ import {
Document,
Event,
Revision,
SearchQuery,
Share,
Star,
User,
@@ -98,10 +99,12 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
// add the users starred state to the response by default
const starredScope = { method: ["withStarred", user.id] };
const collectionScope = { method: ["withCollection", user.id] };
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope(
"defaultScope",
starredScope,
collectionScope
collectionScope,
viewScope
).findAll({
where,
order: [[sort, direction]],
@@ -137,10 +140,12 @@ router.post("documents.pinned", auth(), pagination(), async (ctx) => {
const starredScope = { method: ["withStarred", user.id] };
const collectionScope = { method: ["withCollection", user.id] };
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope(
"defaultScope",
starredScope,
collectionScope
collectionScope,
viewScope
).findAll({
where: {
teamId: user.teamId,
@@ -176,9 +181,11 @@ router.post("documents.archived", auth(), pagination(), async (ctx) => {
const collectionIds = await user.collectionIds();
const collectionScope = { method: ["withCollection", user.id] };
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope(
"defaultScope",
collectionScope
collectionScope,
viewScope
).findAll({
where: {
teamId: user.teamId,
@@ -214,7 +221,8 @@ router.post("documents.deleted", auth(), pagination(), async (ctx) => {
const collectionIds = await user.collectionIds({ paranoid: false });
const collectionScope = { method: ["withCollection", user.id] };
const documents = await Document.scope(collectionScope).findAll({
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope(collectionScope, viewScope).findAll({
where: {
teamId: user.teamId,
collectionId: collectionIds,
@@ -276,7 +284,11 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
limit: ctx.state.pagination.limit,
});
const documents = views.map((view) => view.document);
const documents = views.map((view) => {
const document = view.document;
document.views = [view];
return document;
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
@@ -349,9 +361,11 @@ router.post("documents.drafts", auth(), pagination(), async (ctx) => {
const collectionIds = await user.collectionIds();
const collectionScope = { method: ["withCollection", user.id] };
const viewScope = { method: ["withViews", user.id] };
const documents = await Document.scope(
"defaultScope",
collectionScope
collectionScope,
viewScope
).findAll({
where: {
userId: user.id,
@@ -537,6 +551,52 @@ router.post("documents.restore", auth(), async (ctx) => {
};
});
router.post("documents.search_titles", auth(), pagination(), async (ctx) => {
const { query } = ctx.body;
const { offset, limit } = ctx.state.pagination;
const user = ctx.state.user;
ctx.assertPresent(query, "query is required");
const collectionIds = await user.collectionIds();
const documents = await Document.scope(
{
method: ["withViews", user.id],
},
{
method: ["withCollection", user.id],
}
).findAll({
where: {
title: {
[Op.iLike]: `%${query}%`,
},
collectionId: collectionIds,
archivedAt: {
[Op.eq]: null,
},
},
order: [["updatedAt", "DESC"]],
include: [
{ model: User, as: "createdBy", paranoid: false },
{ model: User, as: "updatedBy", paranoid: false },
],
offset,
limit,
});
const policies = presentPolicies(user, documents);
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
});
router.post("documents.search", auth(), pagination(), async (ctx) => {
const {
query,
@@ -573,7 +633,7 @@ router.post("documents.search", auth(), pagination(), async (ctx) => {
);
}
const results = await Document.searchForUser(user, query, {
const { results, totalCount } = await Document.searchForUser(user, query, {
includeArchived: includeArchived === "true",
includeDrafts: includeDrafts === "true",
collaboratorIds,
@@ -591,6 +651,18 @@ router.post("documents.search", auth(), pagination(), async (ctx) => {
})
);
// 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,
results: totalCount,
});
}
const policies = presentPolicies(user, documents);
ctx.body = {

View File

@@ -8,6 +8,7 @@ import {
Revision,
Backlink,
CollectionUser,
SearchQuery,
} from "../models";
import {
buildShare,
@@ -627,6 +628,56 @@ describe("#documents.drafts", () => {
});
});
describe("#documents.search_titles", () => {
it("should return case insensitive results for partial query", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
title: "Super secret",
});
const res = await server.post("/api/documents.search_titles", {
body: { token: user.getJwtToken(), query: "SECRET" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(document.id);
});
it("should not include archived or deleted documents", async () => {
const user = await buildUser();
await buildDocument({
userId: user.id,
teamId: user.teamId,
title: "Super secret",
archivedAt: new Date(),
});
await buildDocument({
userId: user.id,
teamId: user.teamId,
title: "Super secret",
deletedAt: new Date(),
});
const res = await server.post("/api/documents.search_titles", {
body: { token: user.getJwtToken(), query: "SECRET" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/documents.search_titles");
expect(res.status).toEqual(401);
});
});
describe("#documents.search", () => {
it("should return results", async () => {
const { user } = await seed();
@@ -954,6 +1005,25 @@ describe("#documents.search", () => {
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should save search term, hits and source", async (done) => {
const { user } = await seed();
await server.post("/api/documents.search", {
body: { token: user.getJwtToken(), query: "my term" },
});
// setTimeout is needed here because SearchQuery is saved asynchronously
// in order to not slow down the response time.
setTimeout(async () => {
const searchQuery = await SearchQuery.findAll({
where: { query: "my term" },
});
expect(searchQuery.length).toBe(1);
expect(searchQuery[0].results).toBe(0);
expect(searchQuery[0].source).toBe("app");
done();
}, 100);
});
});
describe("#documents.archived", () => {

View File

@@ -2,7 +2,14 @@
import Router from "koa-router";
import { escapeRegExp } from "lodash";
import { AuthenticationError, InvalidRequestError } from "../errors";
import { Authentication, Document, User, Team, Collection } from "../models";
import {
Authentication,
Document,
User,
Team,
Collection,
SearchQuery,
} from "../models";
import { presentSlackAttachment } from "../presenters";
import * as Slack from "../slack";
const router = new Router();
@@ -146,10 +153,18 @@ router.post("hooks.slack", async (ctx) => {
const options = {
limit: 5,
};
const results = user
const { results, totalCount } = user
? await Document.searchForUser(user, text, options)
: await Document.searchForTeam(team, text, options);
SearchQuery.create({
userId: user ? user.id : null,
teamId: team.id,
source: "slack",
query: text,
results: totalCount,
});
if (results.length) {
const attachments = [];
for (const result of results) {

View File

@@ -1,7 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "../app";
import { Authentication } from "../models";
import { Authentication, SearchQuery } from "../models";
import * as Slack from "../slack";
import { buildDocument } from "../test/factories";
import { flushdb, seed } from "../test/support";
@@ -132,6 +132,30 @@ describe("#hooks.slack", () => {
);
});
it("should save search term, hits and source", async (done) => {
const { user, team } = await seed();
await server.post("/api/hooks.slack", {
body: {
token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.serviceId,
team_id: team.slackId,
text: "contains",
},
});
// setTimeout is needed here because SearchQuery is saved asynchronously
// in order to not slow down the response time.
setTimeout(async () => {
const searchQuery = await SearchQuery.findAll({
where: { query: "contains" },
});
expect(searchQuery.length).toBe(1);
expect(searchQuery[0].results).toBe(0);
expect(searchQuery[0].source).toBe("slack");
done();
}, 100);
});
it("should respond with help content for help keyword", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {

View File

@@ -5,6 +5,7 @@ import mammoth from "mammoth";
import TurndownService from "turndown";
import uuid from "uuid";
import parseTitle from "../../shared/utils/parseTitle";
import { InvalidRequestError } from "../errors";
import { Attachment, Event, User } from "../models";
import dataURItoBuffer from "../utils/dataURItoBuffer";
import parseImages from "../utils/parseImages";
@@ -66,6 +67,9 @@ export default async function documentImporter({
ip: string,
}): Promise<{ text: string, title: string }> {
const fileInfo = importMapping.filter((item) => item.type === file.type)[0];
if (!fileInfo) {
throw new InvalidRequestError(`File type ${file.type} not supported`);
}
let title = file.name.replace(/\.[^/.]+$/, "");
let text = await fileInfo.getMarkdown(file);

View File

@@ -74,4 +74,27 @@ describe("documentImporter", () => {
expect(response.text).toContain("This is a test paragraph");
expect(response.title).toEqual("Heading 1");
});
it("should error with unknown file type", async () => {
const user = await buildUser();
const name = "markdown.md";
const file = new File({
name,
type: "executable/zip",
path: path.resolve(__dirname, "..", "test", "fixtures", name),
});
let error;
try {
await documentImporter({
user,
file,
ip,
});
} catch (err) {
error = err.message;
}
expect(error).toEqual("File type executable/zip not supported");
});
});

View File

@@ -2,14 +2,17 @@
import addMinutes from "date-fns/add_minutes";
import addMonths from "date-fns/add_months";
import JWT from "jsonwebtoken";
import { type Context } from "koa";
import { AuthenticationError, UserSuspendedError } from "../errors";
import { User, ApiKey } from "../models";
import { User, Team, ApiKey } from "../models";
import type { ContextWithState } from "../types";
import { getCookieDomain } from "../utils/domains";
import { getUserForJWT } from "../utils/jwt";
export default function auth(options?: { required?: boolean } = {}) {
return async function authMiddleware(ctx: Context, next: () => Promise<*>) {
return async function authMiddleware(
ctx: ContextWithState,
next: () => Promise<mixed>
) {
let token;
const authorizationHeader = ctx.request.get("authorization");
@@ -27,7 +30,6 @@ export default function auth(options?: { required?: boolean } = {}) {
`Bad Authorization header format. Format is "Authorization: Bearer <token>"`
);
}
// $FlowFixMe
} else if (ctx.body && ctx.body.token) {
token = ctx.body.token;
} else if (ctx.request.query.token) {
@@ -43,7 +45,8 @@ export default function auth(options?: { required?: boolean } = {}) {
let user;
if (token) {
if (String(token).match(/^[\w]{38}$/)) {
// API key
ctx.state.authType = "api";
let apiKey;
try {
apiKey = await ApiKey.findOne({
@@ -51,18 +54,22 @@ export default function auth(options?: { required?: boolean } = {}) {
secret: token,
},
});
} catch (e) {
} catch (err) {
throw new AuthenticationError("Invalid API key");
}
if (!apiKey) throw new AuthenticationError("Invalid API key");
if (!apiKey) {
throw new AuthenticationError("Invalid API key");
}
user = await User.findByPk(apiKey.userId);
if (!user) throw new AuthenticationError("Invalid API key");
if (!user) {
throw new AuthenticationError("Invalid API key");
}
} else {
/* $FlowFixMeNowPlease This comment suppresses an error found when upgrading
* flow-bin@0.104.0. To view the error, delete this comment and run Flow. */
user = await getUserForJWT(token);
ctx.state.authType = "app";
user = await getUserForJWT(String(token));
}
if (user.isSuspended) {
@@ -76,21 +83,16 @@ export default function auth(options?: { required?: boolean } = {}) {
// not awaiting the promise here so that the request is not blocked
user.updateActiveAt(ctx.request.ip);
/* $FlowFixMeNowPlease This comment suppresses an error found when upgrading
* flow-bin@0.104.0. To view the error, delete this comment and run Flow. */
ctx.state.token = token;
/* $FlowFixMeNowPlease This comment suppresses an error found when upgrading
* flow-bin@0.104.0. To view the error, delete this comment and run Flow. */
ctx.state.token = String(token);
ctx.state.user = user;
if (!ctx.cache) ctx.cache = {};
/* $FlowFixMeNowPlease This comment suppresses an error found when upgrading
* flow-bin@0.104.0. To view the error, delete this comment and run Flow. */
ctx.cache[user.id] = user;
}
ctx.signIn = async (user, team, service, isFirstSignin = false) => {
ctx.signIn = async (
user: User,
team: Team,
service,
isFirstSignin = false
) => {
if (user.isSuspended) {
return ctx.redirect("/?notice=suspended");
}

View File

@@ -0,0 +1,43 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("search_queries", {
id: {
allowNull: false,
primaryKey: true,
type: Sequelize.UUID,
},
userId: {
type: Sequelize.UUID,
references: {
model: "users",
},
},
teamId: {
type: Sequelize.UUID,
references: {
model: "teams",
},
},
source: {
type: Sequelize.ENUM("slack", "app", "api"),
allowNull: false,
},
query: {
type: Sequelize.STRING,
allowNull: false,
},
results: {
type: Sequelize.INTEGER,
allowNull: false,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("search_queries");
},
};

View File

@@ -0,0 +1,19 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addIndex("search_queries", ["teamId"]);
await queryInterface.addIndex("search_queries", ["userId"]);
await queryInterface.addIndex("search_queries", ["createdAt"]);
await queryInterface.addIndex("users", ["teamId"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex("search_queries", ["teamId"]);
await queryInterface.removeIndex("search_queries", ["userId"]);
await queryInterface.removeIndex("search_queries", ["createdAt"]);
await queryInterface.removeIndex("users", ["teamId"]);
}
};

View File

@@ -207,11 +207,15 @@ Document.associate = (models) => {
{ model: models.User, as: "updatedBy", paranoid: false },
],
});
Document.addScope("withViews", (userId) => ({
include: [
{ model: models.View, as: "views", where: { userId }, required: false },
],
}));
Document.addScope("withViews", (userId) => {
if (!userId) return {};
return {
include: [
{ model: models.View, as: "views", where: { userId }, required: false },
],
};
});
Document.addScope("withStarred", (userId) => ({
include: [
{ model: models.Star, as: "starred", where: { userId }, required: false },
@@ -222,9 +226,15 @@ Document.associate = (models) => {
Document.findByPk = async function (id, options = {}) {
// allow default preloading of collection membership if `userId` is passed in find options
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope("withUnpublished", {
method: ["withCollection", options.userId],
});
const scope = this.scope(
"withUnpublished",
{
method: ["withCollection", options.userId],
},
{
method: ["withViews", options.userId],
}
);
if (isUUID(id)) {
return scope.findOne({
@@ -241,10 +251,13 @@ Document.findByPk = async function (id, options = {}) {
}
};
type SearchResult = {
ranking: number,
context: string,
document: Document,
type SearchResponse = {
results: {
ranking: number,
context: string,
document: Document,
}[],
totalCount: number,
};
type SearchOptions = {
@@ -267,7 +280,7 @@ Document.searchForTeam = async (
team,
query,
options: SearchOptions = {}
): Promise<SearchResult[]> => {
): Promise<SearchResponse> => {
const limit = options.limit || 15;
const offset = options.offset || 0;
const wildcardQuery = `${escape(query)}:*`;
@@ -275,21 +288,25 @@ Document.searchForTeam = async (
// If the team has access no public collections then shortcircuit the rest of this
if (!collectionIds.length) {
return [];
return { results: [], totalCount: 0 };
}
// Build the SQL query to get documentIds, ranking, and search term context
const sql = `
const whereClause = `
"searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"collectionId" IN(:collectionIds) AND
"deletedAt" IS NULL AND
"publishedAt" IS NOT NULL
`;
const selectSql = `
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
"deletedAt" IS NULL AND
"publishedAt" IS NOT NULL
WHERE ${whereClause}
ORDER BY
"searchRanking" DESC,
"updatedAt" DESC
@@ -297,17 +314,34 @@ Document.searchForTeam = async (
OFFSET :offset;
`;
const results = await sequelize.query(sql, {
const countSql = `
SELECT COUNT(id)
FROM documents
WHERE ${whereClause}
`;
const queryReplacements = {
teamId: team.id,
query: wildcardQuery,
collectionIds,
};
const resultsQuery = sequelize.query(selectSql, {
type: sequelize.QueryTypes.SELECT,
replacements: {
teamId: team.id,
query: wildcardQuery,
...queryReplacements,
limit,
offset,
collectionIds,
},
});
const countQuery = sequelize.query(countSql, {
type: sequelize.QueryTypes.SELECT,
replacements: queryReplacements,
});
const [results, [{ count }]] = await Promise.all([resultsQuery, countQuery]);
// Final query to get associated document data
const documents = await Document.findAll({
where: {
@@ -320,20 +354,23 @@ Document.searchForTeam = async (
],
});
return map(results, (result) => ({
ranking: result.searchRanking,
context: removeMarkdown(unescape(result.searchContext), {
stripHTML: false,
}),
document: find(documents, { id: result.id }),
}));
return {
results: map(results, (result) => ({
ranking: result.searchRanking,
context: removeMarkdown(unescape(result.searchContext), {
stripHTML: false,
}),
document: find(documents, { id: result.id }),
})),
totalCount: count,
};
};
Document.searchForUser = async (
user,
query,
options: SearchOptions = {}
): Promise<SearchResult[]> => {
): Promise<SearchResponse> => {
const limit = options.limit || 15;
const offset = options.offset || 0;
const wildcardQuery = `${escape(query)}:*`;
@@ -350,7 +387,7 @@ Document.searchForUser = async (
// If the user has access to no collections then shortcircuit the rest of this
if (!collectionIds.length) {
return [];
return { results: [], totalCount: 0 };
}
let dateFilter;
@@ -359,13 +396,8 @@ Document.searchForUser = async (
}
// 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
const whereClause = `
"searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"collectionId" IN(:collectionIds) AND
${
@@ -383,27 +415,52 @@ Document.searchForUser = async (
? '("publishedAt" IS NOT NULL OR "createdById" = :userId)'
: '"publishedAt" IS NOT NULL'
}
`;
const selectSql = `
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 ${whereClause}
ORDER BY
"searchRanking" DESC,
"updatedAt" DESC
LIMIT :limit
OFFSET :offset;
`;
`;
const results = await sequelize.query(sql, {
const countSql = `
SELECT COUNT(id)
FROM documents
WHERE ${whereClause}
`;
const queryReplacements = {
teamId: user.teamId,
userId: user.id,
collaboratorIds: options.collaboratorIds,
query: wildcardQuery,
collectionIds,
dateFilter,
};
const resultsQuery = sequelize.query(selectSql, {
type: sequelize.QueryTypes.SELECT,
replacements: {
teamId: user.teamId,
userId: user.id,
collaboratorIds: options.collaboratorIds,
query: wildcardQuery,
...queryReplacements,
limit,
offset,
collectionIds,
dateFilter,
},
});
const countQuery = sequelize.query(countSql, {
type: sequelize.QueryTypes.SELECT,
replacements: queryReplacements,
});
const [results, [{ count }]] = await Promise.all([resultsQuery, countQuery]);
// Final query to get associated document data
const documents = await Document.scope(
{
@@ -422,13 +479,16 @@ Document.searchForUser = async (
],
});
return map(results, (result) => ({
ranking: result.searchRanking,
context: removeMarkdown(unescape(result.searchContext), {
stripHTML: false,
}),
document: find(documents, { id: result.id }),
}));
return {
results: map(results, (result) => ({
ranking: result.searchRanking,
context: removeMarkdown(unescape(result.searchContext), {
stripHTML: false,
}),
document: find(documents, { id: result.id }),
})),
totalCount: count,
};
};
// Hooks

View File

@@ -201,7 +201,7 @@ describe("#searchForTeam", () => {
title: "test",
});
const results = await Document.searchForTeam(team, "test");
const { results } = await Document.searchForTeam(team, "test");
expect(results.length).toBe(1);
expect(results[0].document.id).toBe(document.id);
});
@@ -218,15 +218,85 @@ describe("#searchForTeam", () => {
title: "test",
});
const results = await Document.searchForTeam(team, "test");
const { results } = await Document.searchForTeam(team, "test");
expect(results.length).toBe(0);
});
test("should handle no collections", async () => {
const team = await buildTeam();
const results = await Document.searchForTeam(team, "test");
const { results } = await Document.searchForTeam(team, "test");
expect(results.length).toBe(0);
});
test("should return the total count of search results", async () => {
const team = await buildTeam();
const collection = await buildCollection({ teamId: team.id });
await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test number 1",
});
await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test number 2",
});
const { totalCount } = await Document.searchForTeam(team, "test");
expect(totalCount).toBe("2");
});
});
describe("#searchForUser", () => {
test("should return search results from collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const document = await buildDocument({
userId: user.id,
teamId: team.id,
collectionId: collection.id,
title: "test",
});
const { results } = await Document.searchForUser(user, "test");
expect(results.length).toBe(1);
expect(results[0].document.id).toBe(document.id);
});
test("should handle no collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const { results } = await Document.searchForUser(user, "test");
expect(results.length).toBe(0);
});
test("should return the total count of search results", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
await buildDocument({
userId: user.id,
teamId: team.id,
collectionId: collection.id,
title: "test number 1",
});
await buildDocument({
userId: user.id,
teamId: team.id,
collectionId: collection.id,
title: "test number 2",
});
const { totalCount } = await Document.searchForUser(user, "test");
expect(totalCount).toBe("2");
});
});
describe("#delete", () => {

View File

@@ -0,0 +1,42 @@
// @flow
import { DataTypes, sequelize } from "../sequelize";
const SearchQuery = sequelize.define(
"search_queries",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
source: {
type: DataTypes.ENUM("slack", "app", "api"),
allowNull: false,
},
query: {
type: DataTypes.STRING,
allowNull: false,
},
results: {
type: DataTypes.NUMBER,
allowNull: false,
},
},
{
timestamps: true,
updatedAt: false,
}
);
SearchQuery.associate = (models) => {
SearchQuery.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
SearchQuery.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
};
export default SearchQuery;

View File

@@ -2,7 +2,7 @@
import subMilliseconds from "date-fns/sub_milliseconds";
import { USER_PRESENCE_INTERVAL } from "../../shared/constants";
import { User } from "../models";
import { Op, DataTypes, sequelize } from "../sequelize";
import { DataTypes, Op, sequelize } from "../sequelize";
const View = sequelize.define(
"view",

View File

@@ -14,6 +14,7 @@ import Integration from "./Integration";
import Notification from "./Notification";
import NotificationSetting from "./NotificationSetting";
import Revision from "./Revision";
import SearchQuery from "./SearchQuery";
import Share from "./Share";
import Star from "./Star";
import Team from "./Team";
@@ -36,6 +37,7 @@ const models = {
Notification,
NotificationSetting,
Revision,
SearchQuery,
Share,
Star,
Team,
@@ -66,6 +68,7 @@ export {
Notification,
NotificationSetting,
Revision,
SearchQuery,
Share,
Star,
Team,

View File

@@ -1,6 +1,6 @@
// @flow
import { takeRight } from "lodash";
import { User, Document, Attachment } from "../models";
import { Attachment, Document, User } from "../models";
import { getSignedImageUrl } from "../utils/s3";
import presentUser from "./user";
@@ -62,8 +62,13 @@ export default async function present(document: Document, options: ?Options) {
pinned: undefined,
collectionId: undefined,
parentDocumentId: undefined,
lastViewedAt: undefined,
};
if (!!document.views && document.views.length > 0) {
data.lastViewedAt = document.views[0].updatedAt;
}
if (!options.isPublic) {
data.pinned = !!document.pinnedById;
data.collectionId = document.collectionId;

View File

@@ -37,6 +37,22 @@ const readIndexFile = async (ctx) => {
});
};
const renderApp = async (ctx, next) => {
if (ctx.request.path === "/realtime/") {
return next();
}
const page = await readIndexFile(ctx);
const env = `
window.env = ${JSON.stringify(environment)};
`;
ctx.body = page
.toString()
.replace(/\/\/inject-env\/\//g, env)
.replace(/\/\/inject-sentry-dsn\/\//g, process.env.SENTRY_DSN || "")
.replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || "");
};
// serve static assets
koa.use(
serve(path.resolve(__dirname, "../../public"), {
@@ -65,23 +81,14 @@ router.get("/opensearch.xml", (ctx) => {
ctx.body = opensearchResponse();
});
// catch all for application
router.get("*", async (ctx, next) => {
if (ctx.request.path === "/realtime/") {
return next();
}
const page = await readIndexFile(ctx);
const env = `
window.env = ${JSON.stringify(environment)};
`;
ctx.body = page
.toString()
.replace(/\/\/inject-env\/\//g, env)
.replace(/\/\/inject-sentry-dsn\/\//g, process.env.SENTRY_DSN || "")
.replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || "");
router.get("/share/*", (ctx, next) => {
ctx.remove("X-Frame-Options");
return renderApp(ctx, next);
});
// catch all for application
router.get("*", renderApp);
// middleware
koa.use(apexRedirect());
koa.use(router.routes());

View File

@@ -62,7 +62,7 @@
});
}
if (window.localStorage.getItem("theme") === "dark") {
if (window.localStorage && window.localStorage.getItem("theme") === "dark") {
window.document.querySelector("#root").style.background = "#111319";
}
</script>

12
server/types.js Normal file
View File

@@ -0,0 +1,12 @@
// @flow
import { type Context } from "koa";
import { User } from "./models";
export type ContextWithState = {|
...$Exact<Context>,
state: {
user: User,
token: string,
authType: "app" | "api",
},
|};

View File

@@ -18,7 +18,7 @@ function getJWTPayload(token) {
return payload;
}
export async function getUserForJWT(token: string) {
export async function getUserForJWT(token: string): Promise<User> {
const payload = getJWTPayload(token);
const user = await User.findByPk(payload.id);
@@ -31,7 +31,7 @@ export async function getUserForJWT(token: string) {
return user;
}
export async function getUserForEmailSigninToken(token: string) {
export async function getUserForEmailSigninToken(token: string): Promise<User> {
const payload = getJWTPayload(token);
// check the token is within it's expiration time

View File

@@ -26,6 +26,7 @@ const colors = {
yellow: "#FBCA04",
warmGrey: "#EDF2F7",
searchHighlight: "#FDEA9B",
danger: "#ff476f",
warning: "#f08a24",
success: "#2f3336",
@@ -138,6 +139,7 @@ export const light = {
listItemHoverBackground: colors.warmGrey,
toolbarHoverBackground: colors.black,
toolbarBackground: colors.lightBlack,
toolbarInput: colors.white10,
toolbarItem: colors.white,
@@ -192,6 +194,7 @@ export const dark = {
listItemHoverBackground: colors.black50,
toolbarHoverBackground: colors.slate,
toolbarBackground: colors.white,
toolbarInput: colors.black10,
toolbarItem: colors.lightBlack,

View File

@@ -9887,10 +9887,10 @@ retry-as-promised@^3.2.0:
dependencies:
any-promise "^1.3.0"
rich-markdown-editor@^11.0.0-4:
version "11.0.0-4"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.0.0-4.tgz#b65f5b03502d70a2b2bbea5c916c23b071f4bab6"
integrity sha512-+llzd8Plxzsc/jJ8RwtMSV5QIpxpZdM5nQejG/SLe/lfqHNOFNnIiOszSPERIcULLxsLdMT5Ajz+Yr5PXPicOQ==
rich-markdown-editor@^11.0.0-9:
version "11.0.0-9"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.0.0-9.tgz#a7a4bfa09fca3cdf3168027e11fd9af46c708680"
integrity sha512-B1q6VbRF/6yjHsYMQEXjgjwPJCSU3mNEmLGsJOF+PZACII5ojg8bV51jGd4W1rTvbIzqnLK4iPWlAbn+hrMtXw==
dependencies:
copy-to-clipboard "^3.0.8"
lodash "^4.17.11"