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:
Tom Moor
2019-04-06 16:20:27 -07:00
committed by GitHub
parent 76957865bb
commit 642c11ff7d
39 changed files with 811 additions and 311 deletions

View File

@@ -35,3 +35,6 @@ jobs:
- run: - run:
name: lint name: lint
command: yarn lint command: yarn lint
- run:
name: flow
command: yarn flow

View File

@@ -1 +1 @@
yarn lint:flow yarn flow

View File

@@ -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)};

View File

@@ -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>
); );

View File

@@ -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 && (

View File

@@ -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>
&nbsp;deleted <Time dateTime={deletedAt} /> ago
</span>
);
} else if (archivedAt) {
content = (
<span>
&nbsp;archived <Time dateTime={archivedAt} /> ago
</span>
);
} else if (publishedAt && (neverUpdated || showPublished)) {
content = (
<span>
&nbsp;published <Time dateTime={publishedAt} /> ago
</span>
);
} else if (isDraft) {
content = (
<span>
&nbsp;saved <Time dateTime={updatedAt} /> ago
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
&nbsp;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>
&nbsp;saved <Time dateTime={updatedAt} /> ago
</span>
) : (
<Modified highlight={modifiedSinceViewed}>
&nbsp;updated <Time dateTime={updatedAt} /> ago
</Modified>
)}
</React.Fragment>
)}
{collection && ( {collection && (
<span> <span>
&nbsp;in <strong>{isDraft ? 'Drafts' : collection.name}</strong> &nbsp;in <strong>{isDraft ? 'Drafts' : collection.name}</strong>

View File

@@ -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');

View File

@@ -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} />
); );
} }
} }

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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

View File

@@ -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>
); );
} }

View File

@@ -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);
}; };

View File

@@ -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
View 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);

View File

@@ -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%;

View File

@@ -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;
`}; `};
`; `;

View File

@@ -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 youd 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…' : 'Im sure  Delete'} {this.isDeleting ? 'Deleting…' : 'Im sure  Delete'}
</Button> </Button>

View File

@@ -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>Youve not got any drafts at the moment.</Empty>} {showEmpty ? (
<DocumentList documents={drafts} showCollection /> <Empty>Youve 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 />} />

View File

@@ -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} hasnt updated any documents yet.</HelpText> <HelpText>{user.name} hasnt updated any documents yet.</HelpText>
} }

View File

@@ -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) {

View File

@@ -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;
} }
}; };

View File

@@ -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

View File

@@ -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;

View File

@@ -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 },
} },
} };
} }

View File

@@ -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",

View File

@@ -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,

View File

@@ -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();

View 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');
}
}

View File

@@ -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;
}; };

View File

@@ -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();

View File

@@ -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);
}; };

View File

@@ -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>

View File

@@ -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,

View File

@@ -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),

View File

@@ -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;

View File

@@ -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;
`; `;

View File

@@ -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`;
}

View File

@@ -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"