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:
@@ -82,7 +82,7 @@ class AccountMenu extends React.Component<Props> {
|
||||
style={{
|
||||
left: 170,
|
||||
position: "relative",
|
||||
top: -34,
|
||||
top: -40,
|
||||
}}
|
||||
label={
|
||||
<DropdownMenuItem>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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} /> {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);
|
||||
|
||||
74
app/menus/NewTemplateMenu.js
Normal file
74
app/menus/NewTemplateMenu.js
Normal 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} /> {collection.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("collections", "policies")(NewTemplateMenu);
|
||||
58
app/menus/TemplatesMenu.js
Normal file
58
app/menus/TemplatesMenu.js
Normal 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);
|
||||
Reference in New Issue
Block a user