feat: Templates (#1399)

* Migrations
* New from template
* fix: Don't allow public share of template
* chore: Template badges
* fix: Collection active
* feat: New doc button on template list item
* feat: New template menu
* fix: Sorting
* feat: Templates onboarding notice
* fix: New doc button showing on archived/deleted templates
This commit is contained in:
Tom Moor
2020-08-08 15:18:37 -07:00
committed by GitHub
parent 59c24aba7c
commit 869fc086d6
51 changed files with 1007 additions and 327 deletions

View File

@@ -82,7 +82,7 @@ class AccountMenu extends React.Component<Props> {
style={{
left: 170,
position: "relative",
top: -34,
top: -40,
}}
label={
<DropdownMenuItem>

View File

@@ -9,10 +9,13 @@ import UiStore from "stores/UiStore";
import AuthStore from "stores/AuthStore";
import CollectionStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import Modal from "components/Modal";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import {
documentUrl,
documentMoveUrl,
documentEditUrl,
editDocumentUrl,
documentHistoryUrl,
newDocumentUrl,
} from "utils/routeHelpers";
@@ -37,6 +40,8 @@ type Props = {
@observer
class DocumentMenu extends React.Component<Props> {
@observable redirectTo: ?string;
@observable showDeleteModal: boolean = false;
@observable showTemplateModal: boolean = false;
componentDidUpdate() {
this.redirectTo = undefined;
@@ -44,12 +49,13 @@ class DocumentMenu extends React.Component<Props> {
handleNewChild = (ev: SyntheticEvent<>) => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
this.redirectTo = newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
});
};
handleDelete = (ev: SyntheticEvent<>) => {
const { document } = this.props;
this.props.ui.setActiveModal("document-delete", { document });
this.showDeleteModal = true;
};
handleDocumentHistory = () => {
@@ -65,7 +71,7 @@ class DocumentMenu extends React.Component<Props> {
};
handleEdit = (ev: SyntheticEvent<>) => {
this.redirectTo = documentEditUrl(this.props.document);
this.redirectTo = editDocumentUrl(this.props.document);
};
handleDuplicate = async (ev: SyntheticEvent<>) => {
@@ -76,6 +82,18 @@ class DocumentMenu extends React.Component<Props> {
this.props.ui.showToast("Document duplicated");
};
handleOpenTemplateModal = () => {
this.showTemplateModal = true;
};
handleCloseTemplateModal = () => {
this.showTemplateModal = false;
};
handleCloseDeleteModal = () => {
this.showDeleteModal = false;
};
handleArchive = async (ev: SyntheticEvent<>) => {
await this.props.document.archive();
this.props.ui.showToast("Document archived");
@@ -135,108 +153,137 @@ class DocumentMenu extends React.Component<Props> {
const canViewHistory = can.read && !can.restore;
return (
<DropdownMenu
className={className}
position={position}
onOpen={onOpen}
onClose={onClose}
>
{(can.unarchive || can.restore) && (
<DropdownMenuItem onClick={this.handleRestore}>
Restore
</DropdownMenuItem>
)}
{showPin &&
(document.pinned
? can.unpin && (
<DropdownMenuItem onClick={this.handleUnpin}>
Unpin
<React.Fragment>
<DropdownMenu
className={className}
position={position}
onOpen={onOpen}
onClose={onClose}
>
{(can.unarchive || can.restore) && (
<DropdownMenuItem onClick={this.handleRestore}>
Restore
</DropdownMenuItem>
)}
{showPin &&
(document.pinned
? can.unpin && (
<DropdownMenuItem onClick={this.handleUnpin}>
Unpin
</DropdownMenuItem>
)
: can.pin && (
<DropdownMenuItem onClick={this.handlePin}>
Pin to collection
</DropdownMenuItem>
))}
{document.isStarred
? can.unstar && (
<DropdownMenuItem onClick={this.handleUnstar}>
Unstar
</DropdownMenuItem>
)
: can.pin && (
<DropdownMenuItem onClick={this.handlePin}>
Pin to collection
: can.star && (
<DropdownMenuItem onClick={this.handleStar}>
Star
</DropdownMenuItem>
))}
{document.isStarred
? can.unstar && (
<DropdownMenuItem onClick={this.handleUnstar}>
Unstar
</DropdownMenuItem>
)
: can.star && (
<DropdownMenuItem onClick={this.handleStar}>
Star
</DropdownMenuItem>
)}
{canShareDocuments && (
<DropdownMenuItem
onClick={this.handleShareLink}
title="Create a public share link"
>
Share link
</DropdownMenuItem>
)}
{showToggleEmbeds && (
<React.Fragment>
{document.embedsDisabled ? (
<DropdownMenuItem onClick={document.enableEmbeds}>
Enable embeds
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={document.disableEmbeds}>
Disable embeds
</DropdownMenuItem>
)}
</React.Fragment>
)}
{canViewHistory && (
<React.Fragment>
<hr />
<DropdownMenuItem onClick={this.handleDocumentHistory}>
Document history
)}
{canShareDocuments && (
<DropdownMenuItem
onClick={this.handleShareLink}
title="Create a public share link"
>
Share link
</DropdownMenuItem>
</React.Fragment>
)}
{can.createChildDocument && (
<DropdownMenuItem
onClick={this.handleNewChild}
title="Create a nested document inside the current document"
>
New nested document
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleDuplicate}>
Duplicate
</DropdownMenuItem>
)}
{can.archive && (
<DropdownMenuItem onClick={this.handleArchive}>
Archive
</DropdownMenuItem>
)}
{can.delete && (
<DropdownMenuItem onClick={this.handleDelete}>
Delete
</DropdownMenuItem>
)}
{can.move && (
<DropdownMenuItem onClick={this.handleMove}>Move</DropdownMenuItem>
)}
<hr />
{can.download && (
<DropdownMenuItem onClick={this.handleExport}>
Download
</DropdownMenuItem>
)}
{showPrint && (
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
)}
</DropdownMenu>
)}
{showToggleEmbeds && (
<React.Fragment>
{document.embedsDisabled ? (
<DropdownMenuItem onClick={document.enableEmbeds}>
Enable embeds
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={document.disableEmbeds}>
Disable embeds
</DropdownMenuItem>
)}
</React.Fragment>
)}
{!can.restore && <hr />}
{can.createChildDocument && (
<DropdownMenuItem
onClick={this.handleNewChild}
title="Create a nested document inside the current document"
>
New nested document
</DropdownMenuItem>
)}
{can.update &&
!document.isTemplate && (
<DropdownMenuItem onClick={this.handleOpenTemplateModal}>
Create template
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleDuplicate}>
Duplicate
</DropdownMenuItem>
)}
{can.archive && (
<DropdownMenuItem onClick={this.handleArchive}>
Archive
</DropdownMenuItem>
)}
{can.delete && (
<DropdownMenuItem onClick={this.handleDelete}>
Delete
</DropdownMenuItem>
)}
{can.move && (
<DropdownMenuItem onClick={this.handleMove}>Move</DropdownMenuItem>
)}
<hr />
{canViewHistory && (
<React.Fragment>
<DropdownMenuItem onClick={this.handleDocumentHistory}>
History
</DropdownMenuItem>
</React.Fragment>
)}
{can.download && (
<DropdownMenuItem onClick={this.handleExport}>
Download
</DropdownMenuItem>
)}
{showPrint && (
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
)}
</DropdownMenu>
<Modal
title={`Delete ${this.props.document.noun}`}
onRequestClose={this.handleCloseDeleteModal}
isOpen={this.showDeleteModal}
>
<DocumentDelete
document={this.props.document}
onSubmit={this.handleCloseDeleteModal}
/>
</Modal>
<Modal
title="Create template"
onRequestClose={this.handleCloseTemplateModal}
isOpen={this.showTemplateModal}
>
<DocumentTemplatize
document={this.props.document}
onSubmit={this.handleCloseTemplateModal}
/>
</Modal>
</React.Fragment>
);
}
}

View File

@@ -31,7 +31,9 @@ class NewChildDocumentMenu extends React.Component<Props> {
handleNewChild = () => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
this.redirectTo = newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
});
};
render() {

View File

@@ -7,13 +7,19 @@ import { PlusIcon } from "outline-icons";
import { newDocumentUrl } from "utils/routeHelpers";
import CollectionsStore from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
type Props = {
label?: React.Node,
documents: DocumentsStore,
collections: CollectionsStore,
policies: PoliciesStore,
};
@@ -26,8 +32,8 @@ class NewDocumentMenu extends React.Component<Props> {
this.redirectTo = undefined;
}
handleNewDocument = (collectionId: string) => {
this.redirectTo = newDocumentUrl(collectionId);
handleNewDocument = (collectionId: string, options) => {
this.redirectTo = newDocumentUrl(collectionId, options);
};
onOpen = () => {
@@ -41,21 +47,22 @@ class NewDocumentMenu extends React.Component<Props> {
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { collections, policies, label, ...rest } = this.props;
const { collections, documents, policies, label, ...rest } = this.props;
const singleCollection = collections.orderedData.length === 1;
return (
<DropdownMenu
label={
label || (
<Button icon={<PlusIcon />} small>
New doc
New doc{singleCollection ? "" : "…"}
</Button>
)
}
onOpen={this.onOpen}
{...rest}
>
<DropdownMenuItem disabled>Choose a collection</DropdownMenuItem>
<Header>Choose a collection</Header>
{collections.orderedData.map(collection => {
const can = policies.abilities(collection.id);
@@ -65,7 +72,7 @@ class NewDocumentMenu extends React.Component<Props> {
onClick={() => this.handleNewDocument(collection.id)}
disabled={!can.update}
>
<CollectionIcon collection={collection} /> {collection.name}
<CollectionIcon collection={collection} />&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
@@ -74,4 +81,4 @@ class NewDocumentMenu extends React.Component<Props> {
}
}
export default inject("collections", "policies")(NewDocumentMenu);
export default inject("collections", "documents", "policies")(NewDocumentMenu);

View File

@@ -0,0 +1,74 @@
// @flow
import * as React from "react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { Redirect } from "react-router-dom";
import { PlusIcon } from "outline-icons";
import { newDocumentUrl } from "utils/routeHelpers";
import CollectionsStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
type Props = {
label?: React.Node,
collections: CollectionsStore,
policies: PoliciesStore,
};
@observer
class NewTemplateMenu extends React.Component<Props> {
@observable redirectTo: ?string;
componentDidUpdate() {
this.redirectTo = undefined;
}
handleNewDocument = (collectionId: string) => {
this.redirectTo = newDocumentUrl(collectionId, {
template: true,
});
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { collections, policies, label, ...rest } = this.props;
return (
<DropdownMenu
label={
label || (
<Button icon={<PlusIcon />} small>
New template
</Button>
)
}
{...rest}
>
<Header>Choose a collection</Header>
{collections.orderedData.map(collection => {
const can = policies.abilities(collection.id);
return (
<DropdownMenuItem
key={collection.id}
onClick={() => this.handleNewDocument(collection.id)}
disabled={!can.update}
>
<CollectionIcon collection={collection} />&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
}
export default inject("collections", "policies")(NewTemplateMenu);

View File

@@ -0,0 +1,58 @@
// @flow
import * as React from "react";
import { observer, inject } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import styled from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import Document from "models/Document";
import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
type Props = {
document: Document,
documents: DocumentsStore,
};
@observer
class TemplatesMenu extends React.Component<Props> {
render() {
const { documents, document, ...rest } = this.props;
const templates = documents.templatesInCollection(document.collectionId);
if (!templates.length) {
return null;
}
return (
<DropdownMenu
position="left"
label={
<Button disclosure neutral>
Templates
</Button>
}
{...rest}
>
{templates.map(template => (
<DropdownMenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
>
<DocumentIcon />
<div>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>By {template.createdBy.name}</Author>
</div>
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
}
const Author = styled.div`
font-size: 13px;
`;
export default inject("documents")(TemplatesMenu);