Document Archive (#921)
* WIP: Archive * WIP * Finishing up archive endpoints * WIP * Update docs * Flow * Stash * Add toast message confirmations * Redirect handling, fixed publishhing info for archived docs * Redirect to collection instead of home, remove unused pub info * Account for deleted parent * Trash -> Archive Allow reading of archived docs * Dont overload deletedAt * Fixes * 💚 * ParentDocumentId wipe for unarchived sub docs * Fix: CMD+S exits editing Fix: Duplicate user name on published but unedited docs * Improve jank on paginated lists * Prevent editing when archived * 💚 Separate lint / flow steps
This commit is contained in:
@@ -34,4 +34,7 @@ jobs:
|
|||||||
command: yarn test
|
command: yarn test
|
||||||
- run:
|
- run:
|
||||||
name: lint
|
name: lint
|
||||||
command: yarn lint
|
command: yarn lint
|
||||||
|
- run:
|
||||||
|
name: flow
|
||||||
|
command: yarn flow
|
||||||
@@ -1 +1 @@
|
|||||||
yarn lint:flow
|
yarn flow
|
||||||
@@ -52,7 +52,8 @@ const RealButton = styled.button`
|
|||||||
`} ${props =>
|
`} ${props =>
|
||||||
props.danger &&
|
props.danger &&
|
||||||
`
|
`
|
||||||
background: ${props.theme.danger};
|
background: ${props.theme.danger};
|
||||||
|
color: ${props.theme.white};
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${darken(0.05, props.theme.danger)};
|
background: ${darken(0.05, props.theme.danger)};
|
||||||
|
|||||||
@@ -6,17 +6,10 @@ import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documents: Document[],
|
documents: Document[],
|
||||||
showCollection?: boolean,
|
|
||||||
showPublished?: boolean,
|
|
||||||
limit?: number,
|
limit?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentList({
|
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||||
limit,
|
|
||||||
showCollection,
|
|
||||||
showPublished,
|
|
||||||
documents,
|
|
||||||
}: Props) {
|
|
||||||
const items = limit ? documents.splice(0, limit) : documents;
|
const items = limit ? documents.splice(0, limit) : documents;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -25,12 +18,7 @@ export default function DocumentList({
|
|||||||
defaultActiveChildIndex={0}
|
defaultActiveChildIndex={0}
|
||||||
>
|
>
|
||||||
{items.map(document => (
|
{items.map(document => (
|
||||||
<DocumentPreview
|
<DocumentPreview key={document.id} document={document} {...rest} />
|
||||||
key={document.id}
|
|
||||||
document={document}
|
|
||||||
showCollection={showCollection}
|
|
||||||
showPublished={showPublished}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</ArrowKeyNavigation>
|
</ArrowKeyNavigation>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Props = {
|
|||||||
context?: ?string,
|
context?: ?string,
|
||||||
showCollection?: boolean,
|
showCollection?: boolean,
|
||||||
showPublished?: boolean,
|
showPublished?: boolean,
|
||||||
|
link?: boolean,
|
||||||
ref?: *,
|
ref?: *,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +139,7 @@ class DocumentPreview extends React.Component<Props> {
|
|||||||
showPublished,
|
showPublished,
|
||||||
highlight,
|
highlight,
|
||||||
context,
|
context,
|
||||||
|
link,
|
||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
@@ -147,23 +149,29 @@ class DocumentPreview extends React.Component<Props> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentLink
|
<DocumentLink
|
||||||
to={{
|
as={link === false ? 'div' : undefined}
|
||||||
pathname: document.url,
|
to={
|
||||||
state: { title: document.title },
|
link === false
|
||||||
}}
|
? undefined
|
||||||
|
: {
|
||||||
|
pathname: document.url,
|
||||||
|
state: { title: document.title },
|
||||||
|
}
|
||||||
|
}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Heading>
|
<Heading>
|
||||||
<Title text={document.title} highlight={highlight} />
|
<Title text={document.title} highlight={highlight} />
|
||||||
{!document.isDraft && (
|
{!document.isDraft &&
|
||||||
<Actions>
|
!document.isArchived && (
|
||||||
{document.starred ? (
|
<Actions>
|
||||||
<StyledStar onClick={this.unstar} solid />
|
{document.starred ? (
|
||||||
) : (
|
<StyledStar onClick={this.unstar} solid />
|
||||||
<StyledStar onClick={this.star} />
|
) : (
|
||||||
)}
|
<StyledStar onClick={this.star} />
|
||||||
</Actions>
|
)}
|
||||||
)}
|
</Actions>
|
||||||
|
)}
|
||||||
<StyledDocumentMenu document={document} />
|
<StyledDocumentMenu document={document} />
|
||||||
</Heading>
|
</Heading>
|
||||||
{!queryIsInTitle && (
|
{!queryIsInTitle && (
|
||||||
|
|||||||
@@ -30,30 +30,49 @@ function PublishingInfo({ collection, showPublished, document }: Props) {
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
updatedBy,
|
updatedBy,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
|
archivedAt,
|
||||||
|
deletedAt,
|
||||||
isDraft,
|
isDraft,
|
||||||
} = document;
|
} = document;
|
||||||
const neverUpdated = publishedAt === updatedAt;
|
const neverUpdated = publishedAt === updatedAt;
|
||||||
|
let content;
|
||||||
|
|
||||||
|
if (deletedAt) {
|
||||||
|
content = (
|
||||||
|
<span>
|
||||||
|
deleted <Time dateTime={deletedAt} /> ago
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (archivedAt) {
|
||||||
|
content = (
|
||||||
|
<span>
|
||||||
|
archived <Time dateTime={archivedAt} /> ago
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (publishedAt && (neverUpdated || showPublished)) {
|
||||||
|
content = (
|
||||||
|
<span>
|
||||||
|
published <Time dateTime={publishedAt} /> ago
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (isDraft) {
|
||||||
|
content = (
|
||||||
|
<span>
|
||||||
|
saved <Time dateTime={updatedAt} /> ago
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<Modified highlight={modifiedSinceViewed}>
|
||||||
|
updated <Time dateTime={updatedAt} /> ago
|
||||||
|
</Modified>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container align="center">
|
<Container align="center">
|
||||||
{publishedAt && (neverUpdated || showPublished) ? (
|
{updatedBy.name}
|
||||||
<span>
|
{content}
|
||||||
{updatedBy.name} published <Time dateTime={publishedAt} /> ago
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<React.Fragment>
|
|
||||||
{updatedBy.name}
|
|
||||||
{isDraft ? (
|
|
||||||
<span>
|
|
||||||
saved <Time dateTime={updatedAt} /> ago
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Modified highlight={modifiedSinceViewed}>
|
|
||||||
updated <Time dateTime={updatedAt} /> ago
|
|
||||||
</Modified>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
{collection && (
|
{collection && (
|
||||||
<span>
|
<span>
|
||||||
in <strong>{isDraft ? 'Drafts' : collection.name}</strong>
|
in <strong>{isDraft ? 'Drafts' : collection.name}</strong>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import keydown from 'react-keydown';
|
|||||||
import Analytics from 'components/Analytics';
|
import Analytics from 'components/Analytics';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import {
|
import {
|
||||||
documentEditUrl,
|
|
||||||
homeUrl,
|
homeUrl,
|
||||||
searchUrl,
|
searchUrl,
|
||||||
matchDocumentSlug as slug,
|
matchDocumentSlug as slug,
|
||||||
@@ -72,16 +71,6 @@ class Layout extends React.Component<Props> {
|
|||||||
this.redirectTo = homeUrl();
|
this.redirectTo = homeUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
@keydown('e')
|
|
||||||
goToEdit(ev) {
|
|
||||||
const activeDocument = this.props.documents.active;
|
|
||||||
if (!activeDocument) return;
|
|
||||||
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
this.redirectTo = documentEditUrl(activeDocument);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keydown('shift+/')
|
@keydown('shift+/')
|
||||||
openKeyboardShortcuts() {
|
openKeyboardShortcuts() {
|
||||||
this.props.ui.setActiveModal('keyboard-shortcuts');
|
this.props.ui.setActiveModal('keyboard-shortcuts');
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import DocumentList from 'components/DocumentList';
|
|||||||
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
showCollection?: boolean,
|
|
||||||
showPublished?: boolean,
|
|
||||||
documents: Document[],
|
documents: Document[],
|
||||||
fetch: (options: ?Object) => Promise<*>,
|
fetch: (options: ?Object) => Promise<*>,
|
||||||
options?: Object,
|
options?: Object,
|
||||||
|
heading?: React.Node,
|
||||||
empty?: React.Node,
|
empty?: React.Node,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,25 +65,25 @@ class PaginatedDocumentList extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { showCollection, showPublished, empty, documents } = this.props;
|
const { empty, heading, documents, fetch, options, ...rest } = this.props;
|
||||||
|
const showLoading = !this.isLoaded && this.isFetching && !documents.length;
|
||||||
|
const showEmpty = this.isLoaded && !documents.length;
|
||||||
|
|
||||||
return this.isLoaded || documents.length ? (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{documents.length ? (
|
{showEmpty ? (
|
||||||
<DocumentList
|
|
||||||
documents={documents}
|
|
||||||
showCollection={showCollection}
|
|
||||||
showPublished={showPublished}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
empty
|
empty
|
||||||
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
{heading}
|
||||||
|
<DocumentList documents={documents} {...rest} />
|
||||||
|
{this.allowLoadMore && (
|
||||||
|
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
{this.allowLoadMore && (
|
{showLoading && <ListPlaceholder count={5} />}
|
||||||
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
) : (
|
|
||||||
<ListPlaceholder count={5} />
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import { HomeIcon, EditIcon, SearchIcon, StarredIcon } from 'outline-icons';
|
import {
|
||||||
|
ArchiveIcon,
|
||||||
|
HomeIcon,
|
||||||
|
EditIcon,
|
||||||
|
SearchIcon,
|
||||||
|
StarredIcon,
|
||||||
|
} from 'outline-icons';
|
||||||
|
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import AccountMenu from 'menus/AccountMenu';
|
import AccountMenu from 'menus/AccountMenu';
|
||||||
@@ -94,6 +100,17 @@ class MainSidebar extends React.Component<Props> {
|
|||||||
<Section>
|
<Section>
|
||||||
<Collections onCreateCollection={this.handleCreateCollection} />
|
<Collections onCreateCollection={this.handleCreateCollection} />
|
||||||
</Section>
|
</Section>
|
||||||
|
<Section>
|
||||||
|
<SidebarLink
|
||||||
|
to="/archive"
|
||||||
|
icon={<ArchiveIcon />}
|
||||||
|
exact={false}
|
||||||
|
label="Archive"
|
||||||
|
active={
|
||||||
|
documents.active ? documents.active.isArchived : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
</Scrollable>
|
</Scrollable>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
import { observable } from 'mobx';
|
|
||||||
import { inject, observer } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import { MoonIcon } from 'outline-icons';
|
import { MoonIcon } from 'outline-icons';
|
||||||
import styled, { withTheme } from 'styled-components';
|
import styled, { withTheme } from 'styled-components';
|
||||||
@@ -15,6 +13,7 @@ import {
|
|||||||
githubIssuesUrl,
|
githubIssuesUrl,
|
||||||
mailToUrl,
|
mailToUrl,
|
||||||
spectrumUrl,
|
spectrumUrl,
|
||||||
|
settings,
|
||||||
} from '../../shared/utils/routeHelpers';
|
} from '../../shared/utils/routeHelpers';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -26,26 +25,15 @@ type Props = {
|
|||||||
|
|
||||||
@observer
|
@observer
|
||||||
class AccountMenu extends React.Component<Props> {
|
class AccountMenu extends React.Component<Props> {
|
||||||
@observable redirectTo: ?string;
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
this.redirectTo = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleOpenKeyboardShortcuts = () => {
|
handleOpenKeyboardShortcuts = () => {
|
||||||
this.props.ui.setActiveModal('keyboard-shortcuts');
|
this.props.ui.setActiveModal('keyboard-shortcuts');
|
||||||
};
|
};
|
||||||
|
|
||||||
handleOpenSettings = () => {
|
|
||||||
this.redirectTo = '/settings';
|
|
||||||
};
|
|
||||||
|
|
||||||
handleLogout = () => {
|
handleLogout = () => {
|
||||||
this.props.auth.logout();
|
this.props.auth.logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
|
||||||
const { ui, theme } = this.props;
|
const { ui, theme } = this.props;
|
||||||
const isLightTheme = ui.theme === 'light';
|
const isLightTheme = ui.theme === 'light';
|
||||||
|
|
||||||
@@ -54,9 +42,7 @@ class AccountMenu extends React.Component<Props> {
|
|||||||
style={{ marginRight: 10, marginTop: -10 }}
|
style={{ marginRight: 10, marginTop: -10 }}
|
||||||
label={this.props.label}
|
label={this.props.label}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={this.handleOpenSettings}>
|
<DropdownMenuItem href={settings()}>Settings</DropdownMenuItem>
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
|
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}>
|
||||||
Keyboard shortcuts
|
Keyboard shortcuts
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -77,8 +63,6 @@ class AccountMenu extends React.Component<Props> {
|
|||||||
Report a bug
|
Report a bug
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<hr />
|
<hr />
|
||||||
<DropdownMenuItem onClick={this.handleLogout}>Logout</DropdownMenuItem>
|
|
||||||
<hr />
|
|
||||||
<DropdownMenuItem onClick={ui.toggleDarkMode}>
|
<DropdownMenuItem onClick={ui.toggleDarkMode}>
|
||||||
<NightMode justify="space-between">
|
<NightMode justify="space-between">
|
||||||
Night Mode{' '}
|
Night Mode{' '}
|
||||||
@@ -87,6 +71,8 @@ class AccountMenu extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
</NightMode>
|
</NightMode>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<hr />
|
||||||
|
<DropdownMenuItem onClick={this.handleLogout}>Logout</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,20 @@ class DocumentMenu extends React.Component<Props> {
|
|||||||
|
|
||||||
handleDuplicate = async (ev: SyntheticEvent<*>) => {
|
handleDuplicate = async (ev: SyntheticEvent<*>) => {
|
||||||
const duped = await this.props.document.duplicate();
|
const duped = await this.props.document.duplicate();
|
||||||
|
|
||||||
|
// when duplicating, go straight to the duplicated document content
|
||||||
this.redirectTo = duped.url;
|
this.redirectTo = duped.url;
|
||||||
|
this.props.ui.showToast('Document duplicated');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleArchive = async (ev: SyntheticEvent<*>) => {
|
||||||
|
await this.props.document.archive();
|
||||||
|
this.props.ui.showToast('Document archived');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRestore = async (ev: SyntheticEvent<*>) => {
|
||||||
|
await this.props.document.restore();
|
||||||
|
this.props.ui.showToast('Document restored');
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePin = (ev: SyntheticEvent<*>) => {
|
handlePin = (ev: SyntheticEvent<*>) => {
|
||||||
@@ -87,9 +100,22 @@ class DocumentMenu extends React.Component<Props> {
|
|||||||
const { document, label, className, showPrint, auth } = this.props;
|
const { document, label, className, showPrint, auth } = this.props;
|
||||||
const canShareDocuments = auth.team && auth.team.sharing;
|
const canShareDocuments = auth.team && auth.team.sharing;
|
||||||
|
|
||||||
|
if (document.isArchived) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu label={label || <MoreIcon />} className={className}>
|
||||||
|
<DropdownMenuItem onClick={this.handleRestore}>
|
||||||
|
Restore
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={this.handleDelete}>
|
||||||
|
Delete…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu label={label || <MoreIcon />} className={className}>
|
<DropdownMenu label={label || <MoreIcon />} className={className}>
|
||||||
{!document.isDraft && (
|
{!document.isDraft ? (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{document.pinned ? (
|
{document.pinned ? (
|
||||||
<DropdownMenuItem onClick={this.handleUnpin}>
|
<DropdownMenuItem onClick={this.handleUnpin}>
|
||||||
@@ -128,10 +154,19 @@ class DocumentMenu extends React.Component<Props> {
|
|||||||
<DropdownMenuItem onClick={this.handleDuplicate}>
|
<DropdownMenuItem onClick={this.handleDuplicate}>
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={this.handleArchive}>
|
||||||
|
Archive
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={this.handleDelete}>
|
||||||
|
Delete…
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
<DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={this.handleDelete}>
|
||||||
|
Delete…
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={this.handleDelete}>Delete…</DropdownMenuItem>
|
|
||||||
<hr />
|
<hr />
|
||||||
<DropdownMenuItem onClick={this.handleExport}>
|
<DropdownMenuItem onClick={this.handleExport}>
|
||||||
Download
|
Download
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ class NewChildDocumentMenu extends React.Component<Props> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu label={label || <MoreIcon />} {...rest}>
|
<DropdownMenu label={label || <MoreIcon />} {...rest}>
|
||||||
<DropdownMenuItem onClick={this.handleNewChild}>
|
|
||||||
New child document
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={this.handleNewDocument}>
|
<DropdownMenuItem onClick={this.handleNewDocument}>
|
||||||
<span>
|
<span>
|
||||||
New document in <strong>{collection.name}</strong>
|
New document in <strong>{collection.name}</strong>
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={this.handleNewChild}>
|
||||||
|
New child document
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default class Document extends BaseModel {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
parentDocument: ?string;
|
parentDocument: ?string;
|
||||||
publishedAt: ?string;
|
publishedAt: ?string;
|
||||||
|
archivedAt: string;
|
||||||
|
deletedAt: ?string;
|
||||||
url: string;
|
url: string;
|
||||||
urlId: string;
|
urlId: string;
|
||||||
shareUrl: ?string;
|
shareUrl: ?string;
|
||||||
@@ -78,6 +80,16 @@ export default class Document extends BaseModel {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isArchived(): boolean {
|
||||||
|
return !!this.archivedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isDeleted(): boolean {
|
||||||
|
return !!this.deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get isDraft(): boolean {
|
get isDraft(): boolean {
|
||||||
return !this.publishedAt;
|
return !this.publishedAt;
|
||||||
@@ -115,7 +127,11 @@ export default class Document extends BaseModel {
|
|||||||
this.updateTitle();
|
this.updateTitle();
|
||||||
};
|
};
|
||||||
|
|
||||||
restore = (revision: Revision) => {
|
archive = () => {
|
||||||
|
return this.store.archive(this);
|
||||||
|
};
|
||||||
|
|
||||||
|
restore = (revision: ?Revision) => {
|
||||||
return this.store.restore(this, revision);
|
return this.store.restore(this, revision);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Home from 'scenes/Home';
|
|||||||
import Dashboard from 'scenes/Dashboard';
|
import Dashboard from 'scenes/Dashboard';
|
||||||
import Starred from 'scenes/Starred';
|
import Starred from 'scenes/Starred';
|
||||||
import Drafts from 'scenes/Drafts';
|
import Drafts from 'scenes/Drafts';
|
||||||
|
import Archive from 'scenes/Archive';
|
||||||
import Collection from 'scenes/Collection';
|
import Collection from 'scenes/Collection';
|
||||||
import Document from 'scenes/Document';
|
import Document from 'scenes/Document';
|
||||||
import KeyedDocument from 'scenes/Document/KeyedDocument';
|
import KeyedDocument from 'scenes/Document/KeyedDocument';
|
||||||
@@ -45,6 +46,7 @@ export default function Routes() {
|
|||||||
<Route exact path="/starred" component={Starred} />
|
<Route exact path="/starred" component={Starred} />
|
||||||
<Route exact path="/starred/:sort" component={Starred} />
|
<Route exact path="/starred/:sort" component={Starred} />
|
||||||
<Route exact path="/drafts" component={Drafts} />
|
<Route exact path="/drafts" component={Drafts} />
|
||||||
|
<Route exact path="/archive" component={Archive} />
|
||||||
<Route exact path="/settings" component={Settings} />
|
<Route exact path="/settings" component={Settings} />
|
||||||
<Route exact path="/settings/details" component={Details} />
|
<Route exact path="/settings/details" component={Details} />
|
||||||
<Route exact path="/settings/security" component={Security} />
|
<Route exact path="/settings/security" component={Security} />
|
||||||
|
|||||||
38
app/scenes/Archive.js
Normal file
38
app/scenes/Archive.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observer, inject } from 'mobx-react';
|
||||||
|
|
||||||
|
import CenteredContent from 'components/CenteredContent';
|
||||||
|
import Empty from 'components/Empty';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
import Heading from 'components/Heading';
|
||||||
|
import PaginatedDocumentList from 'components/PaginatedDocumentList';
|
||||||
|
import Subheading from 'components/Subheading';
|
||||||
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documents: DocumentsStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class Archive extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { documents } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredContent column auto>
|
||||||
|
<PageTitle title="Archive" />
|
||||||
|
<Heading>Archive</Heading>
|
||||||
|
<PaginatedDocumentList
|
||||||
|
documents={documents.archived}
|
||||||
|
fetch={documents.fetchArchived}
|
||||||
|
heading={<Subheading>Documents</Subheading>}
|
||||||
|
empty={<Empty>The document archive is empty at the moment.</Empty>}
|
||||||
|
showCollection
|
||||||
|
/>
|
||||||
|
</CenteredContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('documents')(Archive);
|
||||||
@@ -27,6 +27,8 @@ import LoadingPlaceholder from 'components/LoadingPlaceholder';
|
|||||||
import LoadingIndicator from 'components/LoadingIndicator';
|
import LoadingIndicator from 'components/LoadingIndicator';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
|
import Notice from 'shared/components/Notice';
|
||||||
|
import Time from 'shared/components/Time';
|
||||||
import Search from 'scenes/Search';
|
import Search from 'scenes/Search';
|
||||||
import Error404 from 'scenes/Error404';
|
import Error404 from 'scenes/Error404';
|
||||||
import ErrorOffline from 'scenes/ErrorOffline';
|
import ErrorOffline from 'scenes/ErrorOffline';
|
||||||
@@ -98,7 +100,19 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
@keydown('m')
|
@keydown('m')
|
||||||
goToMove(ev) {
|
goToMove(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (this.document) this.props.history.push(documentMoveUrl(this.document));
|
|
||||||
|
if (this.document && !this.document.isArchived) {
|
||||||
|
this.props.history.push(documentMoveUrl(this.document));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keydown('e')
|
||||||
|
goToEdit(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (this.document && !this.document.isArchived) {
|
||||||
|
this.props.history.push(documentEditUrl(this.document));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keydown('esc')
|
@keydown('esc')
|
||||||
@@ -156,6 +170,10 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
if (document) {
|
if (document) {
|
||||||
this.props.ui.setActiveDocument(document);
|
this.props.ui.setActiveDocument(document);
|
||||||
|
|
||||||
|
if (document.isArchived && this.isEditing) {
|
||||||
|
return this.goToDocumentCanonical();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.props.auth.user && !shareId) {
|
if (this.props.auth.user && !shareId) {
|
||||||
if (!this.isEditing && document.publishedAt) {
|
if (!this.isEditing && document.publishedAt) {
|
||||||
this.viewTimeout = setTimeout(document.view, MARK_AS_VIEWED_AFTER);
|
this.viewTimeout = setTimeout(document.view, MARK_AS_VIEWED_AFTER);
|
||||||
@@ -200,10 +218,6 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
handleCloseMoveModal = () => (this.moveModalOpen = false);
|
handleCloseMoveModal = () => (this.moveModalOpen = false);
|
||||||
handleOpenMoveModal = () => (this.moveModalOpen = true);
|
handleOpenMoveModal = () => (this.moveModalOpen = true);
|
||||||
|
|
||||||
onSaveAndExit = () => {
|
|
||||||
this.onSave({ done: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
onSave = async (
|
onSave = async (
|
||||||
options: { done?: boolean, publish?: boolean, autosave?: boolean } = {}
|
options: { done?: boolean, publish?: boolean, autosave?: boolean } = {}
|
||||||
) => {
|
) => {
|
||||||
@@ -366,7 +380,13 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MaxWidth column auto>
|
<MaxWidth archived={document.isArchived} column auto>
|
||||||
|
{document.archivedAt && (
|
||||||
|
<Notice muted>
|
||||||
|
Archived by {document.updatedBy.name}{' '}
|
||||||
|
<Time dateTime={document.archivedAt} /> ago
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
<Editor
|
<Editor
|
||||||
id={document.id}
|
id={document.id}
|
||||||
key={embedsDisabled ? 'embeds-disabled' : 'embeds-enabled'}
|
key={embedsDisabled ? 'embeds-disabled' : 'embeds-enabled'}
|
||||||
@@ -377,9 +397,9 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
onImageUploadStop={this.onImageUploadStop}
|
onImageUploadStop={this.onImageUploadStop}
|
||||||
onSearchLink={this.onSearchLink}
|
onSearchLink={this.onSearchLink}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onSave={this.onSaveAndExit}
|
onSave={this.onSave}
|
||||||
onCancel={this.onDiscard}
|
onCancel={this.onDiscard}
|
||||||
readOnly={!this.isEditing}
|
readOnly={!this.isEditing || document.isArchived}
|
||||||
toc={!revision}
|
toc={!revision}
|
||||||
ui={this.props.ui}
|
ui={this.props.ui}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
@@ -394,6 +414,8 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MaxWidth = styled(Flex)`
|
const MaxWidth = styled(Flex)`
|
||||||
|
${props =>
|
||||||
|
props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import NewChildDocumentMenu from 'menus/NewChildDocumentMenu';
|
|||||||
import DocumentShare from 'scenes/DocumentShare';
|
import DocumentShare from 'scenes/DocumentShare';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
import Modal from 'components/Modal';
|
import Modal from 'components/Modal';
|
||||||
|
import Badge from 'components/Badge';
|
||||||
import Collaborators from 'components/Collaborators';
|
import Collaborators from 'components/Collaborators';
|
||||||
import { Action, Separator } from 'components/Actions';
|
import { Action, Separator } from 'components/Actions';
|
||||||
|
|
||||||
@@ -100,8 +101,10 @@ class Header extends React.Component<Props> {
|
|||||||
savingIsDisabled,
|
savingIsDisabled,
|
||||||
auth,
|
auth,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const canShareDocuments = auth.team && auth.team.sharing;
|
const canShareDocuments =
|
||||||
|
auth.team && auth.team.sharing && !document.isArchived;
|
||||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||||
|
const canEdit = !document.isArchived && !isEditing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Actions
|
<Actions
|
||||||
@@ -123,7 +126,7 @@ class Header extends React.Component<Props> {
|
|||||||
</Modal>
|
</Modal>
|
||||||
<Breadcrumb document={document} />
|
<Breadcrumb document={document} />
|
||||||
<Title isHidden={!this.isScrolled} onClick={this.handleClickTitle}>
|
<Title isHidden={!this.isScrolled} onClick={this.handleClickTitle}>
|
||||||
{document.title}
|
{document.title} {document.isArchived && <Badge>Archived</Badge>}
|
||||||
</Title>
|
</Title>
|
||||||
<Wrapper align="center" justify="flex-end">
|
<Wrapper align="center" justify="flex-end">
|
||||||
{!isDraft && !isEditing && <Collaborators document={document} />}
|
{!isDraft && !isEditing && <Collaborators document={document} />}
|
||||||
@@ -175,7 +178,7 @@ class Header extends React.Component<Props> {
|
|||||||
</Button>
|
</Button>
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
{!isEditing && (
|
{canEdit && (
|
||||||
<Action>
|
<Action>
|
||||||
<Button onClick={this.handleEdit} neutral small>
|
<Button onClick={this.handleEdit} neutral small>
|
||||||
Edit
|
Edit
|
||||||
@@ -191,7 +194,7 @@ class Header extends React.Component<Props> {
|
|||||||
/>
|
/>
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
{!isEditing &&
|
{canEdit &&
|
||||||
!isDraft && (
|
!isDraft && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -249,6 +252,7 @@ const Title = styled.div`
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -260,7 +264,7 @@ const Title = styled.div`
|
|||||||
width: 0;
|
width: 0;
|
||||||
|
|
||||||
${breakpoint('tablet')`
|
${breakpoint('tablet')`
|
||||||
display: block;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ class DocumentDelete extends React.Component<Props> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.props.document.delete();
|
await this.props.document.delete();
|
||||||
this.props.history.push(collection.url);
|
if (this.props.ui.activeDocumentId === this.props.document.id) {
|
||||||
|
this.props.history.push(collection.url);
|
||||||
|
}
|
||||||
this.props.onSubmit();
|
this.props.onSubmit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.props.ui.showToast(err.message);
|
this.props.ui.showToast(err.message);
|
||||||
@@ -46,9 +48,16 @@ class DocumentDelete extends React.Component<Props> {
|
|||||||
<form onSubmit={this.handleSubmit}>
|
<form onSubmit={this.handleSubmit}>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Are you sure about that? Deleting the{' '}
|
Are you sure about that? Deleting the{' '}
|
||||||
<strong>{document.title}</strong> document is permanent, will delete
|
<strong>{document.title}</strong> document is permanent, and will
|
||||||
all of its history, and any child documents.
|
delete all of its history, and any child documents.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
{!document.isDraft &&
|
||||||
|
!document.isArchived && (
|
||||||
|
<HelpText>
|
||||||
|
If you’d like the option of referencing or restoring this
|
||||||
|
document in the future, consider archiving it instead.
|
||||||
|
</HelpText>
|
||||||
|
)}
|
||||||
<Button type="submit" danger>
|
<Button type="submit" danger>
|
||||||
{this.isDeleting ? 'Deleting…' : 'I’m sure – Delete'}
|
{this.isDeleting ? 'Deleting…' : 'I’m sure – Delete'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ListPlaceholder } from 'components/LoadingPlaceholder';
|
|||||||
import Empty from 'components/Empty';
|
import Empty from 'components/Empty';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import DocumentList from 'components/DocumentList';
|
import DocumentList from 'components/DocumentList';
|
||||||
|
import Subheading from 'components/Subheading';
|
||||||
import NewDocumentMenu from 'menus/NewDocumentMenu';
|
import NewDocumentMenu from 'menus/NewDocumentMenu';
|
||||||
import Actions, { Action } from 'components/Actions';
|
import Actions, { Action } from 'components/Actions';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
@@ -33,8 +34,14 @@ class Drafts extends React.Component<Props> {
|
|||||||
<PageTitle title="Drafts" />
|
<PageTitle title="Drafts" />
|
||||||
<Heading>Drafts</Heading>
|
<Heading>Drafts</Heading>
|
||||||
{showLoading && <ListPlaceholder />}
|
{showLoading && <ListPlaceholder />}
|
||||||
{showEmpty && <Empty>You’ve not got any drafts at the moment.</Empty>}
|
{showEmpty ? (
|
||||||
<DocumentList documents={drafts} showCollection />
|
<Empty>You’ve not got any drafts at the moment.</Empty>
|
||||||
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
<Subheading>Documents</Subheading>
|
||||||
|
<DocumentList documents={drafts} showCollection />
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
<Actions align="center" justify="flex-end">
|
<Actions align="center" justify="flex-end">
|
||||||
<Action>
|
<Action>
|
||||||
<NewDocumentMenu label={<NewDocumentIcon />} />
|
<NewDocumentMenu label={<NewDocumentIcon />} />
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ class UserProfile extends React.Component<Props> {
|
|||||||
</Edit>
|
</Edit>
|
||||||
)}
|
)}
|
||||||
</Meta>
|
</Meta>
|
||||||
<Subheading>Recently updated</Subheading>
|
|
||||||
<PaginatedDocumentList
|
<PaginatedDocumentList
|
||||||
documents={documents.createdByUser(user.id)}
|
documents={documents.createdByUser(user.id)}
|
||||||
fetch={documents.fetchOwned}
|
fetch={documents.fetchOwned}
|
||||||
options={{ user: user.id }}
|
options={{ user: user.id }}
|
||||||
|
heading={<Subheading>Recently updated</Subheading>}
|
||||||
empty={
|
empty={
|
||||||
<HelpText>{user.name} hasn’t updated any documents yet.</HelpText>
|
<HelpText>{user.name} hasn’t updated any documents yet.</HelpText>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get recentlyViewed(): * {
|
get all(): Document[] {
|
||||||
|
return filter(this.orderedData, d => !d.archivedAt && !d.deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get recentlyViewed(): Document[] {
|
||||||
return orderBy(
|
return orderBy(
|
||||||
compact(this.recentlyViewedIds.map(id => this.data.get(id))),
|
compact(this.recentlyViewedIds.map(id => this.data.get(id))),
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
@@ -29,16 +34,13 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get recentlyUpdated(): * {
|
get recentlyUpdated(): Document[] {
|
||||||
return orderBy(Array.from(this.data.values()), 'updatedAt', 'desc');
|
return orderBy(this.all, 'updatedAt', 'desc');
|
||||||
}
|
}
|
||||||
|
|
||||||
createdByUser(userId: string): * {
|
createdByUser(userId: string): * {
|
||||||
return orderBy(
|
return orderBy(
|
||||||
filter(
|
filter(this.all, d => d.createdBy.id === userId),
|
||||||
Array.from(this.data.values()),
|
|
||||||
document => document.createdBy.id === userId
|
|
||||||
),
|
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
'desc'
|
'desc'
|
||||||
);
|
);
|
||||||
@@ -53,7 +55,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
|
|
||||||
publishedInCollection(collectionId: string): Document[] {
|
publishedInCollection(collectionId: string): Document[] {
|
||||||
return filter(
|
return filter(
|
||||||
Array.from(this.data.values()),
|
this.all,
|
||||||
document =>
|
document =>
|
||||||
document.collectionId === collectionId && !!document.publishedAt
|
document.collectionId === collectionId && !!document.publishedAt
|
||||||
);
|
);
|
||||||
@@ -93,7 +95,15 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
|
|
||||||
@computed
|
@computed
|
||||||
get starred(): Document[] {
|
get starred(): Document[] {
|
||||||
return filter(this.orderedData, d => d.starred);
|
return filter(this.all, d => d.starred);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get archived(): Document[] {
|
||||||
|
return filter(
|
||||||
|
orderBy(this.orderedData, 'archivedAt', 'desc'),
|
||||||
|
d => d.archivedAt
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
@@ -104,7 +114,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
@computed
|
@computed
|
||||||
get drafts(): Document[] {
|
get drafts(): Document[] {
|
||||||
return filter(
|
return filter(
|
||||||
orderBy(Array.from(this.data.values()), 'updatedAt', 'desc'),
|
orderBy(this.all, 'updatedAt', 'desc'),
|
||||||
doc => !doc.publishedAt
|
doc => !doc.publishedAt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,6 +147,11 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
fetchArchived = async (options: ?PaginationParams): Promise<*> => {
|
||||||
|
return this.fetchNamedPage('archived', options);
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
fetchRecentlyUpdated = async (options: ?PaginationParams): Promise<*> => {
|
fetchRecentlyUpdated = async (options: ?PaginationParams): Promise<*> => {
|
||||||
return this.fetchNamedPage('list', options);
|
return this.fetchNamedPage('list', options);
|
||||||
@@ -331,15 +346,32 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
restore = async (document: Document, revision: Revision) => {
|
archive = async (document: Document) => {
|
||||||
|
const res = await client.post('/documents.archive', {
|
||||||
|
id: document.id,
|
||||||
|
});
|
||||||
|
runInAction('Document#archive', () => {
|
||||||
|
invariant(res && res.data, 'Data should be available');
|
||||||
|
document.updateFromJson(res.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const collection = this.getCollectionForDocument(document);
|
||||||
|
if (collection) collection.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
restore = async (document: Document, revision?: Revision) => {
|
||||||
const res = await client.post('/documents.restore', {
|
const res = await client.post('/documents.restore', {
|
||||||
id: document.id,
|
id: document.id,
|
||||||
revisionId: revision.id,
|
revisionId: revision ? revision.id : undefined,
|
||||||
});
|
});
|
||||||
runInAction('Document#restore', () => {
|
runInAction('Document#restore', () => {
|
||||||
invariant(res && res.data, 'Data should be available');
|
invariant(res && res.data, 'Data should be available');
|
||||||
document.updateFromJson(res.data);
|
document.updateFromJson(res.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const collection = this.getCollectionForDocument(document);
|
||||||
|
if (collection) collection.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
pin = (document: Document) => {
|
pin = (document: Document) => {
|
||||||
@@ -359,7 +391,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
getByUrl = (url: string = ''): ?Document => {
|
getByUrl = (url: string = ''): ?Document => {
|
||||||
return find(Array.from(this.data.values()), doc => url.endsWith(doc.urlId));
|
return find(this.orderedData, doc => url.endsWith(doc.urlId));
|
||||||
};
|
};
|
||||||
|
|
||||||
getCollectionForDocument(document: Document) {
|
getCollectionForDocument(document: Document) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class UiStore {
|
|||||||
setActiveDocument = (document: Document): void => {
|
setActiveDocument = (document: Document): void => {
|
||||||
this.activeDocumentId = document.id;
|
this.activeDocumentId = document.id;
|
||||||
|
|
||||||
if (document.publishedAt) {
|
if (document.publishedAt && !document.isArchived && !document.isDeleted) {
|
||||||
this.activeCollectionId = document.collectionId;
|
this.activeCollectionId = document.collectionId;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis
|
image: redis
|
||||||
ports:
|
ports:
|
||||||
- "6389:6379"
|
- "6380:6379"
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres
|
image: postgres
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5434:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: user
|
POSTGRES_USER: user
|
||||||
POSTGRES_PASSWORD: pass
|
POSTGRES_PASSWORD: pass
|
||||||
|
|||||||
23
flow-typed/npm/koa_v2.x.x.js
vendored
23
flow-typed/npm/koa_v2.x.x.js
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
// @flow
|
||||||
// flow-typed signature: 225656ba2479b8c1dd8b10776913e73f
|
// flow-typed signature: 225656ba2479b8c1dd8b10776913e73f
|
||||||
// flow-typed version: b7d0245d00/koa_v2.x.x/flow_>=v0.47.x
|
// flow-typed version: b7d0245d00/koa_v2.x.x/flow_>=v0.47.x
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ declare module 'koa' {
|
|||||||
url: string,
|
url: string,
|
||||||
header: SimpleHeader,
|
header: SimpleHeader,
|
||||||
};
|
};
|
||||||
declare type RequestInspect = void | RequestJSON;
|
declare type RequestInspect = void | RequestJSON;
|
||||||
declare type Request = {
|
declare type Request = {
|
||||||
app: Application,
|
app: Application,
|
||||||
req: http$IncomingMessage,
|
req: http$IncomingMessage,
|
||||||
@@ -75,13 +76,13 @@ declare module 'koa' {
|
|||||||
type: string,
|
type: string,
|
||||||
url: string,
|
url: string,
|
||||||
|
|
||||||
charset: string | void,
|
charset: string | void,
|
||||||
length: number | void,
|
length: number | void,
|
||||||
|
|
||||||
// Those functions comes from https://github.com/jshttp/accepts/blob/master/index.js
|
// Those functions comes from https://github.com/jshttp/accepts/blob/master/index.js
|
||||||
// request.js$L445
|
// request.js$L445
|
||||||
// https://github.com/jshttp/accepts/blob/master/test/type.js
|
// https://github.com/jshttp/accepts/blob/master/test/type.js
|
||||||
accepts: ((args: string[]) => string | false) &
|
accepts: ((args: string[]) => string | false) &
|
||||||
// ToDo: There is an issue https://github.com/facebook/flow/issues/3009
|
// ToDo: There is an issue https://github.com/facebook/flow/issues/3009
|
||||||
// if you meet some error here, temporarily add an additional annotation
|
// if you meet some error here, temporarily add an additional annotation
|
||||||
// like: `request.accepts((['json', 'text']:Array<string>))` to fix it.
|
// like: `request.accepts((['json', 'text']:Array<string>))` to fix it.
|
||||||
@@ -90,7 +91,7 @@ declare module 'koa' {
|
|||||||
|
|
||||||
// https://github.com/jshttp/accepts/blob/master/index.js#L153
|
// https://github.com/jshttp/accepts/blob/master/index.js#L153
|
||||||
// https://github.com/jshttp/accepts/blob/master/test/charset.js
|
// https://github.com/jshttp/accepts/blob/master/test/charset.js
|
||||||
acceptsCharsets: ((args: string[]) => buffer$Encoding | false) &
|
acceptsCharsets: ((args: string[]) => buffer$Encoding | false) &
|
||||||
// ToDo: https://github.com/facebook/flow/issues/3009
|
// ToDo: https://github.com/facebook/flow/issues/3009
|
||||||
// if you meet some error here, see L70.
|
// if you meet some error here, see L70.
|
||||||
((arg: string, ...args: string[]) => buffer$Encoding | false) &
|
((arg: string, ...args: string[]) => buffer$Encoding | false) &
|
||||||
@@ -98,7 +99,7 @@ declare module 'koa' {
|
|||||||
|
|
||||||
// https://github.com/jshttp/accepts/blob/master/index.js#L119
|
// https://github.com/jshttp/accepts/blob/master/index.js#L119
|
||||||
// https://github.com/jshttp/accepts/blob/master/test/encoding.js
|
// https://github.com/jshttp/accepts/blob/master/test/encoding.js
|
||||||
acceptsEncodings: ((args: string[]) => string | false) &
|
acceptsEncodings: ((args: string[]) => string | false) &
|
||||||
// ToDo: https://github.com/facebook/flow/issues/3009
|
// ToDo: https://github.com/facebook/flow/issues/3009
|
||||||
// if you meet some error here, see L70.
|
// if you meet some error here, see L70.
|
||||||
((arg: string, ...args: string[]) => string | false) &
|
((arg: string, ...args: string[]) => string | false) &
|
||||||
@@ -121,7 +122,7 @@ declare module 'koa' {
|
|||||||
* If there is no content type, `false` is returned.
|
* If there is no content type, `false` is returned.
|
||||||
* Otherwise, it returns the first `type` that matches.
|
* Otherwise, it returns the first `type` that matches.
|
||||||
*/
|
*/
|
||||||
is: ((args: string[]) => null | false | string) &
|
is: ((args: string[]) => null | false | string) &
|
||||||
((arg: string, ...args: string[]) => null | false | string) &
|
((arg: string, ...args: string[]) => null | false | string) &
|
||||||
(() => string), // should return the mime type
|
(() => string), // should return the mime type
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ declare module 'koa' {
|
|||||||
writable: boolean,
|
writable: boolean,
|
||||||
|
|
||||||
// charset: string, // doesn't find in response.js
|
// charset: string, // doesn't find in response.js
|
||||||
length: number | void,
|
length: number | void,
|
||||||
|
|
||||||
append: (field: string, val: string | string[]) => void,
|
append: (field: string, val: string | string[]) => void,
|
||||||
attachment: (filename?: string) => void,
|
attachment: (filename?: string) => void,
|
||||||
@@ -178,7 +179,7 @@ declare module 'koa' {
|
|||||||
redirect: (url: string, alt?: string) => void,
|
redirect: (url: string, alt?: string) => void,
|
||||||
remove: (field: string) => void,
|
remove: (field: string) => void,
|
||||||
// https://github.com/koajs/koa/blob/v2.x/lib/response.js#L418
|
// https://github.com/koajs/koa/blob/v2.x/lib/response.js#L418
|
||||||
set: ((field: string, val: string | string[]) => void) &
|
set: ((field: string, val: string | string[]) => void) &
|
||||||
((field: { [key: string]: string | string[] }) => void),
|
((field: { [key: string]: string | string[] }) => void),
|
||||||
|
|
||||||
vary: (field: string) => void,
|
vary: (field: string) => void,
|
||||||
@@ -217,7 +218,7 @@ declare module 'koa' {
|
|||||||
name: string,
|
name: string,
|
||||||
value: string,
|
value: string,
|
||||||
options?: CookiesSetOptions
|
options?: CookiesSetOptions
|
||||||
) => Context) &
|
) => Context) &
|
||||||
// delete cookie (an outbound header with an expired date is used.)
|
// delete cookie (an outbound header with an expired date is used.)
|
||||||
((name: string) => Context),
|
((name: string) => Context),
|
||||||
};
|
};
|
||||||
@@ -320,7 +321,7 @@ declare module 'koa' {
|
|||||||
res: http$ServerResponse
|
res: http$ServerResponse
|
||||||
) => void;
|
) => void;
|
||||||
env: string;
|
env: string;
|
||||||
keys?: Array<string> | Object; // https://github.com/crypto-utils/keygrip
|
keys?: Array<string> | Object; // https://github.com/crypto-utils/keygrip
|
||||||
middleware: Array<Middleware>;
|
middleware: Array<Middleware>;
|
||||||
proxy: boolean; // when true proxy header fields will be trusted
|
proxy: boolean; // when true proxy header fields will be trusted
|
||||||
request: Request;
|
request: Request;
|
||||||
|
|||||||
21
flow-typed/npm/slug_v0.9.x.js
vendored
21
flow-typed/npm/slug_v0.9.x.js
vendored
@@ -1,7 +1,8 @@
|
|||||||
|
// @flow
|
||||||
// flow-typed signature: c499686d8ed4b3da5bd13133389c6088
|
// flow-typed signature: c499686d8ed4b3da5bd13133389c6088
|
||||||
// flow-typed version: b43dff3e0e/slug_v0.9.x/flow_>=v0.25.x
|
// flow-typed version: b43dff3e0e/slug_v0.9.x/flow_>=v0.25.x
|
||||||
|
|
||||||
type SlugMode = 'rfc3986' | 'pretty'
|
type SlugMode = 'rfc3986' | 'pretty';
|
||||||
|
|
||||||
declare module 'slug' {
|
declare module 'slug' {
|
||||||
declare type SlugOptions = {
|
declare type SlugOptions = {
|
||||||
@@ -12,14 +13,14 @@ declare module 'slug' {
|
|||||||
remove?: ?RegExp,
|
remove?: ?RegExp,
|
||||||
lower?: boolean,
|
lower?: boolean,
|
||||||
symbols?: boolean,
|
symbols?: boolean,
|
||||||
}
|
};
|
||||||
declare module.exports: {
|
declare module.exports: {
|
||||||
(input: string, optionOrReplacement?: string | SlugOptions): string,
|
(input: string, optionOrReplacement?: string | SlugOptions): string,
|
||||||
defaults: {
|
defaults: {
|
||||||
mode: 'pretty',
|
mode: 'pretty' | 'rfc3986',
|
||||||
charmap: { [key: string]: string },
|
charmap: { [key: string]: string },
|
||||||
multicharmap: { [key: string]: string },
|
multicharmap: { [key: string]: string },
|
||||||
modes: { [key: SlugMode]: SlugOptions }
|
modes: { [key: SlugMode]: SlugOptions },
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@
|
|||||||
"build": "npm run clean && npm run build:webpack",
|
"build": "npm run clean && npm run build:webpack",
|
||||||
"start": "NODE_ENV=production node index.js",
|
"start": "NODE_ENV=production node index.js",
|
||||||
"dev": "NODE_ENV=development nodemon --watch server index.js",
|
"dev": "NODE_ENV=development nodemon --watch server index.js",
|
||||||
"lint": "npm run lint:flow && npm run lint:js",
|
"lint": "eslint app server",
|
||||||
"lint:js": "eslint app server",
|
"flow": "flow",
|
||||||
"lint:flow": "flow",
|
|
||||||
"deploy": "git push heroku master",
|
"deploy": "git push heroku master",
|
||||||
"heroku-postbuild": "npm run build && npm run sequelize:migrate",
|
"heroku-postbuild": "npm run build && npm run sequelize:migrate",
|
||||||
"sequelize:create-migration": "sequelize migration:create",
|
"sequelize:create-migration": "sequelize migration:create",
|
||||||
@@ -121,7 +120,7 @@
|
|||||||
"mobx-react": "^5.4.2",
|
"mobx-react": "^5.4.2",
|
||||||
"natural-sort": "^1.0.0",
|
"natural-sort": "^1.0.0",
|
||||||
"nodemailer": "^4.4.0",
|
"nodemailer": "^4.4.0",
|
||||||
"outline-icons": "^1.7.0",
|
"outline-icons": "^1.8.0-0",
|
||||||
"oy-vey": "^0.10.0",
|
"oy-vey": "^0.10.0",
|
||||||
"pg": "^6.1.5",
|
"pg": "^6.1.5",
|
||||||
"pg-hstore": "2.3.2",
|
"pg-hstore": "2.3.2",
|
||||||
|
|||||||
@@ -100,6 +100,38 @@ router.post('documents.pinned', auth(), pagination(), async ctx => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('documents.archived', auth(), pagination(), async ctx => {
|
||||||
|
const { sort = 'updatedAt' } = ctx.body;
|
||||||
|
let direction = ctx.body.direction;
|
||||||
|
if (direction !== 'ASC') direction = 'DESC';
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const collectionIds = await user.collectionIds();
|
||||||
|
|
||||||
|
const documents = await Document.findAll({
|
||||||
|
where: {
|
||||||
|
teamId: user.teamId,
|
||||||
|
collectionId: collectionIds,
|
||||||
|
archivedAt: {
|
||||||
|
// $FlowFixMe
|
||||||
|
[Op.ne]: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: [[sort, direction]],
|
||||||
|
offset: ctx.state.pagination.offset,
|
||||||
|
limit: ctx.state.pagination.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await Promise.all(
|
||||||
|
documents.map(document => presentDocument(ctx, document))
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: ctx.state.pagination,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
router.post('documents.viewed', auth(), pagination(), async ctx => {
|
router.post('documents.viewed', auth(), pagination(), async ctx => {
|
||||||
let { sort = 'updatedAt', direction } = ctx.body;
|
let { sort = 'updatedAt', direction } = ctx.body;
|
||||||
if (direction !== 'ASC') direction = 'DESC';
|
if (direction !== 'ASC') direction = 'DESC';
|
||||||
@@ -235,7 +267,7 @@ router.post('documents.info', auth({ required: false }), async ctx => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (!share) {
|
if (!share || share.document.archivedAt) {
|
||||||
throw new InvalidRequestError('Document could not be found for shareId');
|
throw new InvalidRequestError('Document could not be found for shareId');
|
||||||
}
|
}
|
||||||
document = share.document;
|
document = share.document;
|
||||||
@@ -300,18 +332,29 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
|
|||||||
router.post('documents.restore', auth(), async ctx => {
|
router.post('documents.restore', auth(), async ctx => {
|
||||||
const { id, revisionId } = ctx.body;
|
const { id, revisionId } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
ctx.assertPresent(revisionId, 'revisionId is required');
|
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const document = await Document.findById(id);
|
const document = await Document.findById(id);
|
||||||
authorize(user, 'update', document);
|
|
||||||
|
|
||||||
const revision = await Revision.findById(revisionId);
|
if (document.archivedAt) {
|
||||||
authorize(document, 'restore', revision);
|
authorize(user, 'unarchive', document);
|
||||||
|
|
||||||
document.text = revision.text;
|
// restore a previously archived document
|
||||||
document.title = revision.title;
|
await document.unarchive(user.id);
|
||||||
await document.save();
|
|
||||||
|
// restore a document to a specific revision
|
||||||
|
} else if (revisionId) {
|
||||||
|
authorize(user, 'update', document);
|
||||||
|
|
||||||
|
const revision = await Revision.findById(revisionId);
|
||||||
|
authorize(document, 'restore', revision);
|
||||||
|
|
||||||
|
document.text = revision.text;
|
||||||
|
document.title = revision.title;
|
||||||
|
await document.save();
|
||||||
|
} else {
|
||||||
|
ctx.assertPresent(revisionId, 'revisionId is required');
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(ctx, document),
|
||||||
@@ -530,20 +573,30 @@ router.post('documents.move', auth(), async ctx => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('documents.archive', auth(), async ctx => {
|
||||||
|
const { id } = ctx.body;
|
||||||
|
ctx.assertPresent(id, 'id is required');
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const document = await Document.findById(id);
|
||||||
|
authorize(user, 'archive', document);
|
||||||
|
|
||||||
|
await document.archive(user.id);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: await presentDocument(ctx, document),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
router.post('documents.delete', auth(), async ctx => {
|
router.post('documents.delete', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
const document = await Document.findById(id);
|
const document = await Document.findById(id);
|
||||||
authorize(ctx.state.user, 'delete', document);
|
authorize(user, 'delete', document);
|
||||||
|
|
||||||
const collection = document.collection;
|
await document.delete();
|
||||||
if (collection && collection.type === 'atlas') {
|
|
||||||
// Delete document and all of its children
|
|
||||||
await collection.removeDocument(document);
|
|
||||||
}
|
|
||||||
|
|
||||||
await document.destroy();
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ describe('#documents.info', async () => {
|
|||||||
expect(body.data.id).toEqual(document.id);
|
expect(body.data.id).toEqual(document.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return archived document', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
await document.archive(user.id);
|
||||||
|
const res = await server.post('/api/documents.info', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.id).toEqual(document.id);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not return published document in collection not a member of', async () => {
|
it('should not return published document in collection not a member of', async () => {
|
||||||
const { user, document, collection } = await seed();
|
const { user, document, collection } = await seed();
|
||||||
collection.private = true;
|
collection.private = true;
|
||||||
@@ -86,6 +98,20 @@ describe('#documents.info', async () => {
|
|||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not return document from archived shareId', async () => {
|
||||||
|
const { document, user } = await seed();
|
||||||
|
const share = await buildShare({
|
||||||
|
documentId: document.id,
|
||||||
|
teamId: document.teamId,
|
||||||
|
});
|
||||||
|
await document.archive(user.id);
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.info', {
|
||||||
|
body: { shareId: share.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return document from shareId with token', async () => {
|
it('should return document from shareId with token', async () => {
|
||||||
const { user, document, collection } = await seed();
|
const { user, document, collection } = await seed();
|
||||||
const share = await buildShare({
|
const share = await buildShare({
|
||||||
@@ -420,6 +446,24 @@ describe('#documents.search', async () => {
|
|||||||
expect(body.data.length).toEqual(0);
|
expect(body.data.length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not return archived documents', async () => {
|
||||||
|
const { user } = await seed();
|
||||||
|
const document = await buildDocument({
|
||||||
|
title: 'search term',
|
||||||
|
text: 'search term',
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
await document.archive(user.id);
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.search', {
|
||||||
|
body: { token: user.getJwtToken(), query: 'search term' },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not return documents in private collections not a member of', async () => {
|
it('should not return documents in private collections not a member of', async () => {
|
||||||
const { user } = await seed();
|
const { user } = await seed();
|
||||||
const collection = await buildCollection({ private: true });
|
const collection = await buildCollection({ private: true });
|
||||||
@@ -449,6 +493,66 @@ describe('#documents.search', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#documents.archived', async () => {
|
||||||
|
it('should return archived documents', async () => {
|
||||||
|
const { user } = await seed();
|
||||||
|
const document = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
await document.archive(user.id);
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.archived', {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return deleted documents', async () => {
|
||||||
|
const { user } = await seed();
|
||||||
|
const document = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
await document.delete();
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.archived', {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return documents in private collections not a member of', async () => {
|
||||||
|
const { user } = await seed();
|
||||||
|
const collection = await buildCollection({ private: true });
|
||||||
|
|
||||||
|
const document = await buildDocument({
|
||||||
|
teamId: user.teamId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
});
|
||||||
|
await document.archive(user.id);
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.archived', {
|
||||||
|
body: { token: user.getJwtToken() },
|
||||||
|
});
|
||||||
|
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.archived');
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#documents.viewed', async () => {
|
describe('#documents.viewed', async () => {
|
||||||
it('should return empty result if no views', async () => {
|
it('should return empty result if no views', async () => {
|
||||||
const { user } = await seed();
|
const { user } = await seed();
|
||||||
@@ -577,7 +681,37 @@ describe('#documents.pin', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#documents.restore', async () => {
|
describe('#documents.restore', () => {
|
||||||
|
it('should allow restore of archived documents', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
await document.archive(user.id);
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.restore', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.archivedAt).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore archived when previous parent is archived', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const childDocument = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
parentDocumentId: document.id,
|
||||||
|
});
|
||||||
|
await childDocument.archive(user.id);
|
||||||
|
await document.archive(user.id);
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.restore', {
|
||||||
|
body: { token: user.getJwtToken(), id: childDocument.id },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.parentDocumentId).toEqual(undefined);
|
||||||
|
expect(body.data.archivedAt).toEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
it('should restore the document to a previous version', async () => {
|
it('should restore the document to a previous version', async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
const revision = await Revision.findOne({
|
const revision = await Revision.findOne({
|
||||||
@@ -855,6 +989,22 @@ describe('#documents.update', async () => {
|
|||||||
expect(body.data.collection.documents[0].title).toBe('Updated title');
|
expect(body.data.collection.documents[0].title).toBe('Updated title');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not edit archived document', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
await document.archive();
|
||||||
|
|
||||||
|
const res = await server.post('/api/documents.update', {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: document.id,
|
||||||
|
title: 'Updated title',
|
||||||
|
text: 'Updated text',
|
||||||
|
lastRevision: document.revision,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not create new version when autosave=true', async () => {
|
it('should not create new version when autosave=true', async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
|
|
||||||
@@ -974,6 +1124,24 @@ describe('#documents.update', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#documents.archive', async () => {
|
||||||
|
it('should allow archiving document', async () => {
|
||||||
|
const { user, document } = await seed();
|
||||||
|
const res = await server.post('/api/documents.archive', {
|
||||||
|
body: { token: user.getJwtToken(), id: document.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const { document } = await seed();
|
||||||
|
const res = await server.post('/api/documents.archive', {
|
||||||
|
body: { id: document.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#documents.delete', async () => {
|
describe('#documents.delete', async () => {
|
||||||
it('should allow deleting document', async () => {
|
it('should allow deleting document', async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
|
|||||||
11
server/migrations/20190404035736-add-archive.js
Normal file
11
server/migrations/20190404035736-add-archive.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('documents', 'archivedAt', {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('documents', 'archivedAt');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import _ from 'lodash';
|
import { find, remove } from 'lodash';
|
||||||
import slug from 'slug';
|
import slug from 'slug';
|
||||||
import randomstring from 'randomstring';
|
import randomstring from 'randomstring';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
@@ -7,14 +7,10 @@ import { asyncLock } from '../redis';
|
|||||||
import events from '../events';
|
import events from '../events';
|
||||||
import Document from './Document';
|
import Document from './Document';
|
||||||
import CollectionUser from './CollectionUser';
|
import CollectionUser from './CollectionUser';
|
||||||
import Event from './Event';
|
|
||||||
import { welcomeMessage } from '../utils/onboarding';
|
import { welcomeMessage } from '../utils/onboarding';
|
||||||
|
|
||||||
// $FlowIssue invalid flow-typed
|
|
||||||
slug.defaults.mode = 'rfc3986';
|
slug.defaults.mode = 'rfc3986';
|
||||||
|
|
||||||
const allowedCollectionTypes = [['atlas', 'journal']];
|
|
||||||
|
|
||||||
const Collection = sequelize.define(
|
const Collection = sequelize.define(
|
||||||
'collection',
|
'collection',
|
||||||
{
|
{
|
||||||
@@ -30,7 +26,7 @@ const Collection = sequelize.define(
|
|||||||
private: DataTypes.BOOLEAN,
|
private: DataTypes.BOOLEAN,
|
||||||
type: {
|
type: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
validate: { isIn: allowedCollectionTypes },
|
validate: { isIn: [['atlas', 'journal']] },
|
||||||
},
|
},
|
||||||
|
|
||||||
/* type: atlas */
|
/* type: atlas */
|
||||||
@@ -40,10 +36,10 @@ const Collection = sequelize.define(
|
|||||||
tableName: 'collections',
|
tableName: 'collections',
|
||||||
paranoid: true,
|
paranoid: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeValidate: collection => {
|
beforeValidate: (collection: Collection) => {
|
||||||
collection.urlId = collection.urlId || randomstring.generate(10);
|
collection.urlId = collection.urlId || randomstring.generate(10);
|
||||||
},
|
},
|
||||||
afterCreate: async collection => {
|
afterCreate: async (collection: Collection) => {
|
||||||
const team = await collection.getTeam();
|
const team = await collection.getTeam();
|
||||||
const collections = await team.getCollections();
|
const collections = await team.getCollections();
|
||||||
|
|
||||||
@@ -115,7 +111,7 @@ Collection.associate = models => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Collection.addHook('afterDestroy', async model => {
|
Collection.addHook('afterDestroy', async (model: Collection) => {
|
||||||
await Document.destroy({
|
await Document.destroy({
|
||||||
where: {
|
where: {
|
||||||
collectionId: model.id,
|
collectionId: model.id,
|
||||||
@@ -123,19 +119,19 @@ Collection.addHook('afterDestroy', async model => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Collection.addHook('afterCreate', model =>
|
Collection.addHook('afterCreate', (model: Collection) =>
|
||||||
events.add({ name: 'collections.create', model })
|
events.add({ name: 'collections.create', model })
|
||||||
);
|
);
|
||||||
|
|
||||||
Collection.addHook('afterDestroy', model =>
|
Collection.addHook('afterDestroy', (model: Collection) =>
|
||||||
events.add({ name: 'collections.delete', model })
|
events.add({ name: 'collections.delete', model })
|
||||||
);
|
);
|
||||||
|
|
||||||
Collection.addHook('afterUpdate', model =>
|
Collection.addHook('afterUpdate', (model: Collection) =>
|
||||||
events.add({ name: 'collections.update', model })
|
events.add({ name: 'collections.update', model })
|
||||||
);
|
);
|
||||||
|
|
||||||
Collection.addHook('afterCreate', (model, options) => {
|
Collection.addHook('afterCreate', (model: Collection, options) => {
|
||||||
if (model.private) {
|
if (model.private) {
|
||||||
return CollectionUser.findOrCreate({
|
return CollectionUser.findOrCreate({
|
||||||
where: {
|
where: {
|
||||||
@@ -154,23 +150,16 @@ Collection.addHook('afterCreate', (model, options) => {
|
|||||||
// Instance methods
|
// Instance methods
|
||||||
|
|
||||||
Collection.prototype.addDocumentToStructure = async function(
|
Collection.prototype.addDocumentToStructure = async function(
|
||||||
document,
|
document: Document,
|
||||||
index,
|
index: number,
|
||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
if (!this.documentStructure) return;
|
if (!this.documentStructure) return;
|
||||||
const existingData = {
|
|
||||||
old: this.documentStructure,
|
|
||||||
documentId: document,
|
|
||||||
parentDocumentId: document.parentDocumentId,
|
|
||||||
index,
|
|
||||||
};
|
|
||||||
|
|
||||||
// documentStructure can only be updated by one request at the time
|
// documentStructure can only be updated by one request at a time
|
||||||
const unlock = await asyncLock(`collection-${this.id}`);
|
const unlock = await asyncLock(`collection-${this.id}`);
|
||||||
|
|
||||||
// If moving existing document with children, use existing structure to
|
// If moving existing document with children, use existing structure
|
||||||
// keep everything in shape and not loose documents
|
|
||||||
const documentJson = {
|
const documentJson = {
|
||||||
...document.toJSON(),
|
...document.toJSON(),
|
||||||
...options.documentJson,
|
...options.documentJson,
|
||||||
@@ -206,18 +195,7 @@ Collection.prototype.addDocumentToStructure = async function(
|
|||||||
|
|
||||||
// Sequelize doesn't seem to set the value with splice on JSONB field
|
// Sequelize doesn't seem to set the value with splice on JSONB field
|
||||||
this.documentStructure = this.documentStructure;
|
this.documentStructure = this.documentStructure;
|
||||||
await this.save();
|
await this.save(options);
|
||||||
|
|
||||||
await Event.create({
|
|
||||||
name: 'Collection#addDocumentToStructure',
|
|
||||||
data: {
|
|
||||||
...existingData,
|
|
||||||
new: this.documentStructure,
|
|
||||||
},
|
|
||||||
collectionId: this.id,
|
|
||||||
teamId: this.teamId,
|
|
||||||
});
|
|
||||||
|
|
||||||
unlock();
|
unlock();
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -226,7 +204,9 @@ Collection.prototype.addDocumentToStructure = async function(
|
|||||||
/**
|
/**
|
||||||
* Update document's title and url in the documentStructure
|
* Update document's title and url in the documentStructure
|
||||||
*/
|
*/
|
||||||
Collection.prototype.updateDocument = async function(updatedDocument) {
|
Collection.prototype.updateDocument = async function(
|
||||||
|
updatedDocument: Document
|
||||||
|
) {
|
||||||
if (!this.documentStructure) return;
|
if (!this.documentStructure) return;
|
||||||
|
|
||||||
// documentStructure can only be updated by one request at the time
|
// documentStructure can only be updated by one request at the time
|
||||||
@@ -261,98 +241,56 @@ Collection.prototype.updateDocument = async function(updatedDocument) {
|
|||||||
Collection.prototype.moveDocument = async function(document, index) {
|
Collection.prototype.moveDocument = async function(document, index) {
|
||||||
if (!this.documentStructure) return;
|
if (!this.documentStructure) return;
|
||||||
|
|
||||||
const documentJson = await this.removeDocument(document, {
|
const documentJson = await this.removeDocumentInStructure(document);
|
||||||
deleteDocument: false,
|
|
||||||
});
|
|
||||||
await this.addDocumentToStructure(document, index, { documentJson });
|
await this.addDocumentToStructure(document, index, { documentJson });
|
||||||
|
|
||||||
return this;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeleteDocumentOptions = {
|
Collection.prototype.deleteDocument = async function(document) {
|
||||||
deleteDocument: boolean,
|
await this.removeDocumentInStructure(document, { save: true });
|
||||||
|
await document.deleteWithChildren();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
Collection.prototype.removeDocumentInStructure = async function(
|
||||||
* removeDocument is used for both deleting documents (deleteDocument: true)
|
|
||||||
* and removing them temporarily from the structure while they are being moved
|
|
||||||
* (deleteDocument: false).
|
|
||||||
*/
|
|
||||||
Collection.prototype.removeDocument = async function(
|
|
||||||
document,
|
document,
|
||||||
options: DeleteDocumentOptions = { deleteDocument: true }
|
options?: { save?: boolean }
|
||||||
) {
|
) {
|
||||||
if (!this.documentStructure) return;
|
if (!this.documentStructure) return;
|
||||||
|
|
||||||
let returnValue;
|
let returnValue;
|
||||||
|
let unlock;
|
||||||
|
|
||||||
// documentStructure can only be updated by one request at the time
|
if (options && options.save) {
|
||||||
const unlock = await asyncLock('testLock');
|
// documentStructure can only be updated by one request at the time
|
||||||
|
unlock = await asyncLock(`collection-${this.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
const existingData = {
|
const removeFromChildren = async (children, id) => {
|
||||||
old: this.documentStructure,
|
|
||||||
documentId: document,
|
|
||||||
parentDocumentId: document.parentDocumentId,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to destroy all child documents for a document
|
|
||||||
const deleteChildren = async documentId => {
|
|
||||||
const childDocuments = await Document.findAll({
|
|
||||||
where: { parentDocumentId: documentId },
|
|
||||||
});
|
|
||||||
childDocuments.forEach(async child => {
|
|
||||||
await deleteChildren(child.id);
|
|
||||||
await child.destroy();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prune, and destroy if needed, from the document structure
|
|
||||||
const deleteFromChildren = async (children, id) => {
|
|
||||||
children = await Promise.all(
|
children = await Promise.all(
|
||||||
children.map(async childDocument => {
|
children.map(async childDocument => {
|
||||||
return {
|
return {
|
||||||
...childDocument,
|
...childDocument,
|
||||||
children: await deleteFromChildren(childDocument.children, id),
|
children: await removeFromChildren(childDocument.children, id),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const match = _.find(children, { id });
|
const match = find(children, { id });
|
||||||
if (match) {
|
if (match) {
|
||||||
if (!options.deleteDocument && !returnValue) returnValue = match;
|
if (!returnValue) returnValue = match;
|
||||||
_.remove(children, { id });
|
remove(children, { id });
|
||||||
|
|
||||||
if (options.deleteDocument) {
|
|
||||||
const childDocument = await Document.findById(id);
|
|
||||||
// Delete the actual document
|
|
||||||
if (childDocument) await childDocument.destroy();
|
|
||||||
// Delete all child documents
|
|
||||||
await deleteChildren(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
this.documentStructure = await deleteFromChildren(
|
this.documentStructure = await removeFromChildren(
|
||||||
this.documentStructure,
|
this.documentStructure,
|
||||||
document.id
|
document.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (options.deleteDocument) await this.save();
|
if (options && options.save) {
|
||||||
|
await this.save(options);
|
||||||
await Event.create({
|
if (unlock) await unlock();
|
||||||
name: 'Collection#removeDocument',
|
}
|
||||||
data: {
|
|
||||||
...existingData,
|
|
||||||
new: this.documentStructure,
|
|
||||||
},
|
|
||||||
collectionId: this.id,
|
|
||||||
teamId: this.teamId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await unlock();
|
|
||||||
|
|
||||||
return returnValue;
|
return returnValue;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -156,8 +156,6 @@ describe('#moveDocument', () => {
|
|||||||
|
|
||||||
test('should move a document with children', async () => {
|
test('should move a document with children', async () => {
|
||||||
const { collection, document } = await seed();
|
const { collection, document } = await seed();
|
||||||
|
|
||||||
// Add a child for testing
|
|
||||||
const newDocument = await Document.create({
|
const newDocument = await Document.create({
|
||||||
parentDocumentId: document.id,
|
parentDocumentId: document.id,
|
||||||
collectionId: collection.id,
|
collectionId: collection.id,
|
||||||
@@ -182,14 +180,14 @@ describe('#removeDocument', () => {
|
|||||||
const { collection, document } = await seed();
|
const { collection, document } = await seed();
|
||||||
jest.spyOn(collection, 'save');
|
jest.spyOn(collection, 'save');
|
||||||
|
|
||||||
await collection.removeDocument(document);
|
await collection.deleteDocument(document);
|
||||||
expect(collection.save).toBeCalled();
|
expect(collection.save).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove documents from root', async () => {
|
test('should remove documents from root', async () => {
|
||||||
const { collection, document } = await seed();
|
const { collection, document } = await seed();
|
||||||
|
|
||||||
await collection.removeDocument(document);
|
await collection.deleteDocument(document);
|
||||||
expect(collection.documentStructure.length).toBe(1);
|
expect(collection.documentStructure.length).toBe(1);
|
||||||
|
|
||||||
// Verify that the document was removed
|
// Verify that the document was removed
|
||||||
@@ -219,7 +217,7 @@ describe('#removeDocument', () => {
|
|||||||
expect(collection.documentStructure[1].children.length).toBe(1);
|
expect(collection.documentStructure[1].children.length).toBe(1);
|
||||||
|
|
||||||
// Remove the document
|
// Remove the document
|
||||||
await collection.removeDocument(document);
|
await collection.deleteDocument(document);
|
||||||
expect(collection.documentStructure.length).toBe(1);
|
expect(collection.documentStructure.length).toBe(1);
|
||||||
const collectionDocuments = await Document.findAndCountAll({
|
const collectionDocuments = await Document.findAndCountAll({
|
||||||
where: {
|
where: {
|
||||||
@@ -249,7 +247,7 @@ describe('#removeDocument', () => {
|
|||||||
expect(collection.documentStructure[1].children.length).toBe(1);
|
expect(collection.documentStructure[1].children.length).toBe(1);
|
||||||
|
|
||||||
// Remove the document
|
// Remove the document
|
||||||
await collection.removeDocument(newDocument);
|
await collection.deleteDocument(newDocument);
|
||||||
|
|
||||||
expect(collection.documentStructure.length).toBe(2);
|
expect(collection.documentStructure.length).toBe(2);
|
||||||
expect(collection.documentStructure[0].children.length).toBe(0);
|
expect(collection.documentStructure[0].children.length).toBe(0);
|
||||||
@@ -268,9 +266,7 @@ describe('#removeDocument', () => {
|
|||||||
const { collection, document } = await seed();
|
const { collection, document } = await seed();
|
||||||
jest.spyOn(collection, 'save');
|
jest.spyOn(collection, 'save');
|
||||||
|
|
||||||
const removedNode = await collection.removeDocument(document, {
|
const removedNode = await collection.removeDocumentInStructure(document);
|
||||||
deleteDocument: false,
|
|
||||||
});
|
|
||||||
expect(collection.documentStructure.length).toBe(1);
|
expect(collection.documentStructure.length).toBe(1);
|
||||||
expect(destroyMock).not.toBeCalled();
|
expect(destroyMock).not.toBeCalled();
|
||||||
expect(collection.save).not.toBeCalled();
|
expect(collection.save).not.toBeCalled();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { map, find, compact, uniq } from 'lodash';
|
|||||||
import randomstring from 'randomstring';
|
import randomstring from 'randomstring';
|
||||||
import MarkdownSerializer from 'slate-md-serializer';
|
import MarkdownSerializer from 'slate-md-serializer';
|
||||||
import Plain from 'slate-plain-serializer';
|
import Plain from 'slate-plain-serializer';
|
||||||
import Sequelize from 'sequelize';
|
import Sequelize, { type Transaction } from 'sequelize';
|
||||||
import removeMarkdown from '@tommoor/remove-markdown';
|
import removeMarkdown from '@tommoor/remove-markdown';
|
||||||
|
|
||||||
import isUUID from 'validator/lib/isUUID';
|
import isUUID from 'validator/lib/isUUID';
|
||||||
@@ -91,6 +91,7 @@ const Document = sequelize.define(
|
|||||||
},
|
},
|
||||||
text: DataTypes.TEXT,
|
text: DataTypes.TEXT,
|
||||||
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
|
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
|
||||||
|
archivedAt: DataTypes.DATE,
|
||||||
publishedAt: DataTypes.DATE,
|
publishedAt: DataTypes.DATE,
|
||||||
parentDocumentId: DataTypes.UUID,
|
parentDocumentId: DataTypes.UUID,
|
||||||
collaboratorIds: DataTypes.ARRAY(DataTypes.UUID),
|
collaboratorIds: DataTypes.ARRAY(DataTypes.UUID),
|
||||||
@@ -183,18 +184,20 @@ Document.associate = models => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
Document.findById = async id => {
|
Document.findById = async (id, options) => {
|
||||||
const scope = Document.scope('withUnpublished');
|
const scope = Document.scope('withUnpublished');
|
||||||
|
|
||||||
if (isUUID(id)) {
|
if (isUUID(id)) {
|
||||||
return scope.findOne({
|
return scope.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
} else if (id.match(URL_REGEX)) {
|
} else if (id.match(URL_REGEX)) {
|
||||||
return scope.findOne({
|
return scope.findOne({
|
||||||
where: {
|
where: {
|
||||||
urlId: id.match(URL_REGEX)[1],
|
urlId: id.match(URL_REGEX)[1],
|
||||||
},
|
},
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -222,6 +225,7 @@ Document.searchForUser = async (
|
|||||||
FROM documents
|
FROM documents
|
||||||
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
||||||
"collectionId" IN(:collectionIds) AND
|
"collectionId" IN(:collectionIds) AND
|
||||||
|
"archivedAt" IS NULL AND
|
||||||
"deletedAt" IS NULL AND
|
"deletedAt" IS NULL AND
|
||||||
("publishedAt" IS NOT NULL OR "createdById" = '${user.id}')
|
("publishedAt" IS NOT NULL OR "createdById" = '${user.id}')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -271,7 +275,7 @@ Document.addHook('beforeSave', async model => {
|
|||||||
if (!model.publishedAt) return;
|
if (!model.publishedAt) return;
|
||||||
|
|
||||||
const collection = await Collection.findById(model.collectionId);
|
const collection = await Collection.findById(model.collectionId);
|
||||||
if (collection.type !== 'atlas') return;
|
if (!collection || collection.type !== 'atlas') return;
|
||||||
|
|
||||||
await collection.updateDocument(model);
|
await collection.updateDocument(model);
|
||||||
model.collection = collection;
|
model.collection = collection;
|
||||||
@@ -281,7 +285,7 @@ Document.addHook('afterCreate', async model => {
|
|||||||
if (!model.publishedAt) return;
|
if (!model.publishedAt) return;
|
||||||
|
|
||||||
const collection = await Collection.findById(model.collectionId);
|
const collection = await Collection.findById(model.collectionId);
|
||||||
if (collection.type !== 'atlas') return;
|
if (!collection || collection.type !== 'atlas') return;
|
||||||
|
|
||||||
await collection.addDocumentToStructure(model);
|
await collection.addDocumentToStructure(model);
|
||||||
model.collection = collection;
|
model.collection = collection;
|
||||||
@@ -296,6 +300,48 @@ Document.addHook('afterDestroy', model =>
|
|||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
|
|
||||||
|
// Note: This method marks the document and it's children as deleted
|
||||||
|
// in the database, it does not permanantly delete them OR remove
|
||||||
|
// from the collection structure.
|
||||||
|
Document.prototype.deleteWithChildren = async function(options) {
|
||||||
|
// Helper to destroy all child documents for a document
|
||||||
|
const loopChildren = async (documentId, opts) => {
|
||||||
|
const childDocuments = await Document.findAll({
|
||||||
|
where: { parentDocumentId: documentId },
|
||||||
|
});
|
||||||
|
childDocuments.forEach(async child => {
|
||||||
|
await loopChildren(child.id, opts);
|
||||||
|
await child.destroy(opts);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await loopChildren(this.id, options);
|
||||||
|
await this.destroy(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
Document.prototype.archiveWithChildren = async function(userId, options) {
|
||||||
|
const archivedAt = new Date();
|
||||||
|
|
||||||
|
// Helper to archive all child documents for a document
|
||||||
|
const archiveChildren = async parentDocumentId => {
|
||||||
|
const childDocuments = await Document.findAll({
|
||||||
|
where: { parentDocumentId },
|
||||||
|
});
|
||||||
|
childDocuments.forEach(async child => {
|
||||||
|
await archiveChildren(child.id);
|
||||||
|
|
||||||
|
child.archivedAt = archivedAt;
|
||||||
|
child.lastModifiedById = userId;
|
||||||
|
await child.save(options);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await archiveChildren(this.id);
|
||||||
|
this.archivedAt = archivedAt;
|
||||||
|
this.lastModifiedById = userId;
|
||||||
|
return this.save(options);
|
||||||
|
};
|
||||||
|
|
||||||
Document.prototype.publish = async function() {
|
Document.prototype.publish = async function() {
|
||||||
if (this.publishedAt) return this.save();
|
if (this.publishedAt) return this.save();
|
||||||
|
|
||||||
@@ -312,6 +358,74 @@ Document.prototype.publish = async function() {
|
|||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Moves a document from being visible to the team within a collection
|
||||||
|
// to the archived area, where it can be subsequently restored.
|
||||||
|
Document.prototype.archive = async function(userId) {
|
||||||
|
// archive any children and remove from the document structure
|
||||||
|
const collection = await this.getCollection();
|
||||||
|
await collection.removeDocumentInStructure(this, { save: true });
|
||||||
|
this.collection = collection;
|
||||||
|
|
||||||
|
this.archivedAt = new Date();
|
||||||
|
this.lastModifiedById = userId;
|
||||||
|
await this.save();
|
||||||
|
await this.archiveWithChildren(userId);
|
||||||
|
|
||||||
|
events.add({ name: 'documents.archive', model: this });
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore an archived document back to being visible to the team
|
||||||
|
Document.prototype.unarchive = async function(userId) {
|
||||||
|
const collection = await this.getCollection();
|
||||||
|
|
||||||
|
// check to see if the documents parent hasn't been archived also
|
||||||
|
// If it has then restore the document to the collection root.
|
||||||
|
if (this.parentDocumentId) {
|
||||||
|
const parent = await Document.findOne({
|
||||||
|
where: {
|
||||||
|
id: this.parentDocumentId,
|
||||||
|
archivedAt: {
|
||||||
|
// $FlowFixMe
|
||||||
|
[Op.eq]: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!parent) this.parentDocumentId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await collection.addDocumentToStructure(this);
|
||||||
|
this.collection = collection;
|
||||||
|
|
||||||
|
this.archivedAt = null;
|
||||||
|
this.lastModifiedById = userId;
|
||||||
|
await this.save();
|
||||||
|
|
||||||
|
events.add({ name: 'documents.unarchive', model: this });
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete a document, archived or otherwise.
|
||||||
|
Document.prototype.delete = function(options) {
|
||||||
|
return sequelize.transaction(async (transaction: Transaction): Promise<*> => {
|
||||||
|
if (!this.archivedAt) {
|
||||||
|
// delete any children and remove from the document structure
|
||||||
|
const collection = await this.getCollection();
|
||||||
|
if (collection) await collection.deleteDocument(this, { transaction });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Revision.destroy({
|
||||||
|
where: { documentId: this.id },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.destroy({ transaction, ...options });
|
||||||
|
|
||||||
|
events.add({ name: 'documents.delete', model: this });
|
||||||
|
return this;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
Document.prototype.getTimestamp = function() {
|
Document.prototype.getTimestamp = function() {
|
||||||
return Math.round(new Date(this.updatedAt).getTime() / 1000);
|
return Math.round(new Date(this.updatedAt).getTime() / 1000);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -370,9 +370,23 @@ export default function Pricing() {
|
|||||||
</Arguments>
|
</Arguments>
|
||||||
</Method>
|
</Method>
|
||||||
|
|
||||||
|
<Method method="documents.archive" label="Archive a document">
|
||||||
|
<Description>
|
||||||
|
Archive a document and all of its child documents, if any.
|
||||||
|
</Description>
|
||||||
|
<Arguments>
|
||||||
|
<Argument
|
||||||
|
id="id"
|
||||||
|
description="Document ID or URI identifier"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Arguments>
|
||||||
|
</Method>
|
||||||
|
|
||||||
<Method method="documents.delete" label="Delete a document">
|
<Method method="documents.delete" label="Delete a document">
|
||||||
<Description>
|
<Description>
|
||||||
Delete a document and all of its child documents if any.
|
Permanantly delete a document and all of its child documents, if
|
||||||
|
any.
|
||||||
</Description>
|
</Description>
|
||||||
<Arguments>
|
<Arguments>
|
||||||
<Argument
|
<Argument
|
||||||
@@ -403,7 +417,8 @@ export default function Pricing() {
|
|||||||
>
|
>
|
||||||
<Description>
|
<Description>
|
||||||
Restores a document to a previous revision by creating a new
|
Restores a document to a previous revision by creating a new
|
||||||
revision with the contents of the given revisionId.
|
revision with the contents of the given revisionId or restores an
|
||||||
|
archived document if no revisionId is passed.
|
||||||
</Description>
|
</Description>
|
||||||
<Arguments>
|
<Arguments>
|
||||||
<Argument
|
<Argument
|
||||||
@@ -414,7 +429,6 @@ export default function Pricing() {
|
|||||||
<Argument
|
<Argument
|
||||||
id="revisionId"
|
id="revisionId"
|
||||||
description="Revision ID to restore to"
|
description="Revision ID to restore to"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</Arguments>
|
</Arguments>
|
||||||
</Method>
|
</Method>
|
||||||
|
|||||||
@@ -6,18 +6,40 @@ const { allow, cannot } = policy;
|
|||||||
|
|
||||||
allow(User, 'create', Document);
|
allow(User, 'create', Document);
|
||||||
|
|
||||||
allow(
|
allow(User, ['read', 'delete'], Document, (user, document) => {
|
||||||
User,
|
if (document.collection) {
|
||||||
['read', 'update', 'delete', 'share'],
|
if (cannot(user, 'read', document.collection)) return false;
|
||||||
Document,
|
|
||||||
(user, document) => {
|
|
||||||
if (document.collection) {
|
|
||||||
if (cannot(user, 'read', document.collection)) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.teamId === document.teamId;
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, ['update', 'share'], Document, (user, document) => {
|
||||||
|
if (document.collection) {
|
||||||
|
if (cannot(user, 'read', document.collection)) return false;
|
||||||
|
}
|
||||||
|
if (document.archivedAt) return false;
|
||||||
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, 'archive', Document, (user, document) => {
|
||||||
|
if (document.collection) {
|
||||||
|
if (cannot(user, 'read', document.collection)) return false;
|
||||||
|
}
|
||||||
|
if (!document.publishedAt) return false;
|
||||||
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
|
allow(User, 'unarchive', Document, (user, document) => {
|
||||||
|
if (document.collection) {
|
||||||
|
if (cannot(user, 'read', document.collection)) return false;
|
||||||
|
}
|
||||||
|
if (!document.archivedAt) return false;
|
||||||
|
|
||||||
|
return user.teamId === document.teamId;
|
||||||
|
});
|
||||||
|
|
||||||
allow(
|
allow(
|
||||||
Document,
|
Document,
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ async function present(ctx: Object, document: Document, options: ?Options) {
|
|||||||
updatedAt: document.updatedAt,
|
updatedAt: document.updatedAt,
|
||||||
updatedBy: undefined,
|
updatedBy: undefined,
|
||||||
publishedAt: document.publishedAt,
|
publishedAt: document.publishedAt,
|
||||||
|
archivedAt: document.archivedAt,
|
||||||
|
deletedAt: document.deletedAt,
|
||||||
team: document.teamId,
|
team: document.teamId,
|
||||||
collaborators: [],
|
collaborators: [],
|
||||||
starred: !!(document.starred && document.starred.length),
|
starred: !!(document.starred && document.starred.length),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default (
|
|||||||
ctx: Object,
|
ctx: Object,
|
||||||
user: User,
|
user: User,
|
||||||
options: Options = {}
|
options: Options = {}
|
||||||
): UserPresentation => {
|
): ?UserPresentation => {
|
||||||
const userData = {};
|
const userData = {};
|
||||||
userData.id = user.id;
|
userData.id = user.id;
|
||||||
userData.createdAt = user.createdAt;
|
userData.createdAt = user.createdAt;
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
const Notice = styled.p`
|
const Notice = styled.p`
|
||||||
background: #ffd95c;
|
background: ${props =>
|
||||||
color: hsla(46, 100%, 20%, 1);
|
props.muted ? props.theme.sidebarBackground : props.theme.yellow};
|
||||||
padding: 10px;
|
color: ${props =>
|
||||||
|
props.muted ? props.theme.sidebarText : 'hsla(46, 100%, 20%, 1)'};
|
||||||
|
padding: 10px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -84,3 +84,7 @@ export function integrations(): string {
|
|||||||
export function privacy(): string {
|
export function privacy(): string {
|
||||||
return `${process.env.URL}/privacy`;
|
return `${process.env.URL}/privacy`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function settings(): string {
|
||||||
|
return `${process.env.URL}/settings`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6332,9 +6332,9 @@ outline-icons@^1.6.0:
|
|||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.6.0.tgz#6c7897d354e6bd77ca5498cd3a989b8cb9482574"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.6.0.tgz#6c7897d354e6bd77ca5498cd3a989b8cb9482574"
|
||||||
|
|
||||||
outline-icons@^1.7.0:
|
outline-icons@^1.8.0-0:
|
||||||
version "1.7.0"
|
version "1.8.0-0"
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.7.0.tgz#093f2f18c80bf5577bc31a6ff41460f2feb76fb7"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.8.0-0.tgz#a3499cc0837626541e6bc00c2bfed7279d1c8bb3"
|
||||||
|
|
||||||
oy-vey@^0.10.0:
|
oy-vey@^0.10.0:
|
||||||
version "0.10.0"
|
version "0.10.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user