diff --git a/app/components/Actions.js b/app/components/Actions.js index 2905cd035..dd662324c 100644 --- a/app/components/Actions.js +++ b/app/components/Actions.js @@ -15,6 +15,10 @@ export const Action = styled(Flex)` color: ${props => props.theme.text}; height: 24px; } + + &:empty { + display: none; + } `; export const Separator = styled.div` diff --git a/app/components/Badge.js b/app/components/Badge.js index d79082e46..646fede8d 100644 --- a/app/components/Badge.js +++ b/app/components/Badge.js @@ -4,9 +4,9 @@ import styled from "styled-components"; const Badge = styled.span` margin-left: 10px; padding: 2px 6px 3px; - background-color: ${({ admin, theme }) => - admin ? theme.primary : theme.textTertiary}; - color: ${({ admin, theme }) => (admin ? theme.white : theme.background)}; + background-color: ${({ primary, theme }) => + primary ? theme.primary : theme.textTertiary}; + color: ${({ primary, theme }) => (primary ? theme.white : theme.background)}; border-radius: 4px; font-size: 11px; font-weight: 500; diff --git a/app/components/Breadcrumb.js b/app/components/Breadcrumb.js index dc9c0c8d8..6965e5020 100644 --- a/app/components/Breadcrumb.js +++ b/app/components/Breadcrumb.js @@ -4,7 +4,13 @@ import { observer, inject } from "mobx-react"; import breakpoint from "styled-components-breakpoint"; import styled from "styled-components"; import { Link } from "react-router-dom"; -import { PadlockIcon, GoToIcon, MoreIcon } from "outline-icons"; +import { + PadlockIcon, + GoToIcon, + MoreIcon, + ShapesIcon, + EditIcon, +} from "outline-icons"; import Document from "models/Document"; import CollectionsStore from "stores/CollectionsStore"; @@ -44,12 +50,32 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => { ); } + const isTemplate = document.isTemplate; + const isDraft = !document.publishedAt && !isTemplate; const isNestedDocument = path.length > 1; const lastPath = path.length ? path[path.length - 1] : undefined; const menuPath = isNestedDocument ? path.slice(0, -1) : []; return ( + {isTemplate && ( + + +   + Templates + + + + )} + {isDraft && ( + + +   + Drafts + + + + )}   {collection.name} diff --git a/app/components/Button.js b/app/components/Button.js index 146c0beae..ef5c0128a 100644 --- a/app/components/Button.js +++ b/app/components/Button.js @@ -113,6 +113,7 @@ export const Inner = styled.span` line-height: ${props => (props.hasIcon ? 24 : 32)}px; justify-content: center; align-items: center; + min-height: 30px; ${props => props.hasIcon && props.hasText && "padding-left: 4px;"}; ${props => props.hasIcon && !props.hasText && "padding: 0 4px;"}; diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 00ae796c3..2608b6a59 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -2,15 +2,17 @@ import * as React from "react"; import { observer } from "mobx-react"; import { Link } from "react-router-dom"; -import { StarredIcon } from "outline-icons"; +import { StarredIcon, PlusIcon } from "outline-icons"; import styled, { withTheme } from "styled-components"; import Flex from "components/Flex"; import Badge from "components/Badge"; +import Button from "components/Button"; import Tooltip from "components/Tooltip"; import Highlight from "components/Highlight"; import PublishingInfo from "components/PublishingInfo"; import DocumentMenu from "menus/DocumentMenu"; import Document from "models/Document"; +import { newDocumentUrl } from "utils/routeHelpers"; type Props = { document: Document, @@ -20,8 +22,117 @@ type Props = { showPublished?: boolean, showPin?: boolean, showDraft?: boolean, + showTemplate?: boolean, }; +const SEARCH_RESULT_REGEX = /]*>(.*?)<\/b>/gi; + +@observer +class DocumentPreview extends React.Component { + handleStar = (ev: SyntheticEvent<>) => { + ev.preventDefault(); + ev.stopPropagation(); + this.props.document.star(); + }; + + handleUnstar = (ev: SyntheticEvent<>) => { + ev.preventDefault(); + ev.stopPropagation(); + this.props.document.unstar(); + }; + + replaceResultMarks = (tag: string) => { + // don't use SEARCH_RESULT_REGEX here as it causes + // an infinite loop to trigger a regex inside it's own callback + return tag.replace(/]*>(.*?)<\/b>/gi, "$1"); + }; + + render() { + const { + document, + showCollection, + showPublished, + showPin, + showDraft = true, + showTemplate, + highlight, + context, + ...rest + } = this.props; + + const queryIsInTitle = + !!highlight && + !!document.title.toLowerCase().includes(highlight.toLowerCase()); + + return ( + + + + {!document.isDraft && + !document.isArchived && + !document.isTemplate && ( + <Actions> + {document.isStarred ? ( + <StyledStar onClick={this.handleUnstar} solid /> + ) : ( + <StyledStar onClick={this.handleStar} /> + )} + </Actions> + )} + {document.isDraft && + showDraft && ( + <Tooltip + tooltip="Only visible to you" + delay={500} + placement="top" + > + <Badge>Draft</Badge> + </Tooltip> + )} + {document.isTemplate && + showTemplate && <Badge primary>Template</Badge>} + <SecondaryActions> + {document.isTemplate && + !document.isArchived && + !document.isDeleted && ( + <Button + as={Link} + to={newDocumentUrl(document.collectionId, { + templateId: document.id, + })} + icon={<PlusIcon />} + neutral + > + New doc + </Button> + )}  + <DocumentMenu document={document} showPin={showPin} /> + </SecondaryActions> + </Heading> + + {!queryIsInTitle && ( + <ResultContext + text={context} + highlight={highlight ? SEARCH_RESULT_REGEX : undefined} + processResult={this.replaceResultMarks} + /> + )} + <PublishingInfo + document={document} + showCollection={showCollection} + showPublished={showPublished} + /> + </DocumentLink> + ); + } +} + const StyledStar = withTheme(styled(({ solid, theme, ...props }) => ( <StarredIcon color={theme.text} {...props} /> ))` @@ -37,7 +148,8 @@ const StyledStar = withTheme(styled(({ solid, theme, ...props }) => ( } `); -const StyledDocumentMenu = styled(DocumentMenu)` +const SecondaryActions = styled(Flex)` + align-items: center; position: absolute; right: 16px; top: 50%; @@ -54,7 +166,7 @@ const DocumentLink = styled(Link)` overflow: hidden; position: relative; - ${StyledDocumentMenu} { + ${SecondaryActions} { opacity: 0; } @@ -64,7 +176,11 @@ const DocumentLink = styled(Link)` background: ${props => props.theme.listItemHoverBackground}; outline: none; - ${StyledStar}, ${StyledDocumentMenu} { + ${SecondaryActions} { + opacity: 1; + } + + ${StyledStar} { opacity: 0.5; &:hover { @@ -106,91 +222,4 @@ const ResultContext = styled(Highlight)` margin-bottom: 0.25em; `; -const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi; - -@observer -class DocumentPreview extends React.Component<Props> { - star = (ev: SyntheticEvent<>) => { - ev.preventDefault(); - ev.stopPropagation(); - this.props.document.star(); - }; - - unstar = (ev: SyntheticEvent<>) => { - ev.preventDefault(); - ev.stopPropagation(); - this.props.document.unstar(); - }; - - replaceResultMarks = (tag: string) => { - // don't use SEARCH_RESULT_REGEX here as it causes - // an infinite loop to trigger a regex inside it's own callback - return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1"); - }; - - render() { - const { - document, - showCollection, - showPublished, - showPin, - showDraft = true, - highlight, - context, - ...rest - } = this.props; - - const queryIsInTitle = - !!highlight && - !!document.title.toLowerCase().includes(highlight.toLowerCase()); - - return ( - <DocumentLink - to={{ - pathname: document.url, - state: { title: document.title }, - }} - {...rest} - > - <Heading> - <Title text={document.title || "Untitled"} highlight={highlight} /> - {!document.isDraft && - !document.isArchived && ( - <Actions> - {document.isStarred ? ( - <StyledStar onClick={this.unstar} solid /> - ) : ( - <StyledStar onClick={this.star} /> - )} - </Actions> - )} - {document.isDraft && - showDraft && ( - <Tooltip - tooltip="Only visible to you" - delay={500} - placement="top" - > - <Badge>Draft</Badge> - </Tooltip> - )} - <StyledDocumentMenu document={document} showPin={showPin} /> - </Heading> - {!queryIsInTitle && ( - <ResultContext - text={context} - highlight={highlight ? SEARCH_RESULT_REGEX : undefined} - processResult={this.replaceResultMarks} - /> - )} - <PublishingInfo - document={document} - showCollection={showCollection} - showPublished={showPublished} - /> - </DocumentLink> - ); - } -} - export default DocumentPreview; diff --git a/app/components/DropdownMenu/DropdownMenu.js b/app/components/DropdownMenu/DropdownMenu.js index a8addbf94..cd5fb7dfa 100644 --- a/app/components/DropdownMenu/DropdownMenu.js +++ b/app/components/DropdownMenu/DropdownMenu.js @@ -274,4 +274,13 @@ const Menu = styled.div` } `; +export const Header = styled.h3` + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: ${props => props.theme.sidebarText}; + letter-spacing: 0.04em; + margin: 1em 12px 0.5em; +`; + export default DropdownMenu; diff --git a/app/components/DropdownMenu/DropdownMenuItem.js b/app/components/DropdownMenu/DropdownMenuItem.js index 0eca7a9da..a825408a1 100644 --- a/app/components/DropdownMenu/DropdownMenuItem.js +++ b/app/components/DropdownMenu/DropdownMenuItem.js @@ -42,7 +42,7 @@ const MenuItem = styled.a` margin: 0; padding: 6px 12px; width: 100%; - height: 32px; + min-height: 32px; color: ${props => props.disabled ? props.theme.textTertiary : props.theme.textSecondary}; diff --git a/app/components/DropdownMenu/index.js b/app/components/DropdownMenu/index.js index b1fa231ac..38b1b84e7 100644 --- a/app/components/DropdownMenu/index.js +++ b/app/components/DropdownMenu/index.js @@ -1,3 +1,3 @@ // @flow -export { default as DropdownMenu } from "./DropdownMenu"; +export { default as DropdownMenu, Header } from "./DropdownMenu"; export { default as DropdownMenuItem } from "./DropdownMenuItem"; diff --git a/app/components/Empty.js b/app/components/Empty.js index 4f78753ac..eed3945e6 100644 --- a/app/components/Empty.js +++ b/app/components/Empty.js @@ -1,20 +1,8 @@ // @flow -import * as React from "react"; import styled from "styled-components"; -type Props = { - children: React.Node, -}; - -const Empty = (props: Props) => { - const { children, ...rest } = props; - return <Container {...rest}>{children}</Container>; -}; - -const Container = styled.div` - display: flex; - color: ${props => props.theme.slate}; - text-align: center; +const Empty = styled.p` + color: ${props => props.theme.textTertiary}; `; export default Empty; diff --git a/app/components/HoverPreviewDocument.js b/app/components/HoverPreviewDocument.js index 297504387..8fee8b136 100644 --- a/app/components/HoverPreviewDocument.js +++ b/app/components/HoverPreviewDocument.js @@ -26,7 +26,7 @@ function HoverPreviewDocument({ url, documents, children }: Props) { return children( <Content to={document.url}> - <Heading>{document.title}</Heading> + <Heading>{document.titleWithDefault}</Heading> <DocumentMeta isDraft={document.isDraft} document={document} /> <Editor diff --git a/app/components/Modals.js b/app/components/Modals.js index 67be2c4e1..8b08e830d 100644 --- a/app/components/Modals.js +++ b/app/components/Modals.js @@ -7,7 +7,6 @@ import CollectionNew from "scenes/CollectionNew"; import CollectionEdit from "scenes/CollectionEdit"; import CollectionDelete from "scenes/CollectionDelete"; import CollectionExport from "scenes/CollectionExport"; -import DocumentDelete from "scenes/DocumentDelete"; import DocumentShare from "scenes/DocumentShare"; type Props = { @@ -51,9 +50,6 @@ class Modals extends React.Component<Props> { <Modal name="document-share" title="Share document"> <DocumentShare onSubmit={this.handleClose} /> </Modal> - <Modal name="document-delete" title="Delete document"> - <DocumentDelete onSubmit={this.handleClose} /> - </Modal> </span> ); } diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index 9306ca2d6..f9baa5912 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -8,6 +8,7 @@ import { EditIcon, SearchIcon, StarredIcon, + ShapesIcon, TrashIcon, PlusIcon, } from "outline-icons"; @@ -43,6 +44,7 @@ class MainSidebar extends React.Component<Props> { componentDidMount() { this.props.documents.fetchDrafts(); + this.props.documents.fetchTemplates(); } handleCreateCollection = (ev: SyntheticEvent<>) => { @@ -103,6 +105,15 @@ class MainSidebar extends React.Component<Props> { exact={false} label="Starred" /> + <SidebarLink + to="/templates" + icon={<ShapesIcon color="currentColor" />} + exact={false} + label="Templates" + active={ + documents.active ? documents.active.template : undefined + } + /> <SidebarLink to="/drafts" icon={<EditIcon color="currentColor" />} @@ -116,7 +127,8 @@ class MainSidebar extends React.Component<Props> { active={ documents.active ? !documents.active.publishedAt && - !documents.active.isDeleted + !documents.active.isDeleted && + !documents.active.isTemplate : undefined } /> diff --git a/app/components/Tab.js b/app/components/Tab.js index a4eea9cb8..0da504092 100644 --- a/app/components/Tab.js +++ b/app/components/Tab.js @@ -10,7 +10,7 @@ type Props = { const StyledNavLink = styled(NavLink)` position: relative; - top: 1px; + bottom: -1px; display: inline-block; font-weight: 500; @@ -33,14 +33,14 @@ const StyledNavLink = styled(NavLink)` } `; -function Tab(props: Props) { +function Tab({ theme, ...rest }: Props) { const activeStyle = { paddingBottom: "5px", - borderBottom: `3px solid ${props.theme.textSecondary}`, - color: props.theme.textSecondary, + borderBottom: `3px solid ${theme.textSecondary}`, + color: theme.textSecondary, }; - return <StyledNavLink {...props} activeStyle={activeStyle} />; + return <StyledNavLink {...rest} activeStyle={activeStyle} />; } export default withTheme(Tab); diff --git a/app/components/Tabs.js b/app/components/Tabs.js index e61c3fed5..3e0606cd5 100644 --- a/app/components/Tabs.js +++ b/app/components/Tabs.js @@ -2,6 +2,7 @@ import styled from "styled-components"; const Tabs = styled.nav` + position: relative; border-bottom: 1px solid ${props => props.theme.divider}; margin-top: 22px; margin-bottom: 12px; diff --git a/app/menus/AccountMenu.js b/app/menus/AccountMenu.js index 6c25350ff..b79f38ac4 100644 --- a/app/menus/AccountMenu.js +++ b/app/menus/AccountMenu.js @@ -82,7 +82,7 @@ class AccountMenu extends React.Component<Props> { style={{ left: 170, position: "relative", - top: -34, + top: -40, }} label={ <DropdownMenuItem> diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index cea44ba51..cc9e7e8be 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -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> ); } } diff --git a/app/menus/NewChildDocumentMenu.js b/app/menus/NewChildDocumentMenu.js index 34d556aa7..52e1c8f8f 100644 --- a/app/menus/NewChildDocumentMenu.js +++ b/app/menus/NewChildDocumentMenu.js @@ -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() { diff --git a/app/menus/NewDocumentMenu.js b/app/menus/NewDocumentMenu.js index 9d470f257..46f23fe02 100644 --- a/app/menus/NewDocumentMenu.js +++ b/app/menus/NewDocumentMenu.js @@ -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); diff --git a/app/menus/NewTemplateMenu.js b/app/menus/NewTemplateMenu.js new file mode 100644 index 000000000..ac26af4a1 --- /dev/null +++ b/app/menus/NewTemplateMenu.js @@ -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); diff --git a/app/menus/TemplatesMenu.js b/app/menus/TemplatesMenu.js new file mode 100644 index 000000000..3234df9e9 --- /dev/null +++ b/app/menus/TemplatesMenu.js @@ -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); diff --git a/app/models/Document.js b/app/models/Document.js index 3fad9766f..02ecdc2c3 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -2,7 +2,6 @@ import { action, set, observable, computed } from "mobx"; import addDays from "date-fns/add_days"; import invariant from "invariant"; -import { client } from "utils/ApiClient"; import parseTitle from "shared/utils/parseTitle"; import unescape from "shared/utils/unescape"; import BaseModel from "models/BaseModel"; @@ -20,6 +19,7 @@ type SaveOptions = { export default class Document extends BaseModel { @observable isSaving: boolean = false; @observable embedsDisabled: boolean = false; + @observable injectTemplate: boolean = false; store: DocumentsStore; collaborators: User[]; @@ -35,6 +35,8 @@ export default class Document extends BaseModel { text: string; title: string; emoji: string; + template: boolean; + templateId: ?string; parentDocumentId: ?string; publishedAt: ?string; archivedAt: string; @@ -43,11 +45,24 @@ export default class Document extends BaseModel { urlId: string; revision: number; + constructor(fields: Object, store: DocumentsStore) { + super(fields, store); + + if (this.isNew && this.isFromTemplate) { + this.title = ""; + } + } + get emoji() { const { emoji } = parseTitle(this.title); return emoji; } + @computed + get noun(): string { + return this.template ? "template" : "document"; + } + @computed get isOnlyTitle(): boolean { return !this.text.trim(); @@ -73,11 +88,21 @@ export default class Document extends BaseModel { return !!this.deletedAt; } + @computed + get isTemplate(): boolean { + return !!this.template; + } + @computed get isDraft(): boolean { return !this.publishedAt; } + @computed + get titleWithDefault(): string { + return this.title || "Untitled"; + } + @computed get permanentlyDeletedAt(): ?string { if (!this.deletedAt) { @@ -87,6 +112,21 @@ export default class Document extends BaseModel { return addDays(new Date(this.deletedAt), 30).toString(); } + @computed + get isNew(): boolean { + return this.createdAt === this.updatedAt; + } + + @computed + get isFromTemplate(): boolean { + return !!this.templateId; + } + + @computed + get placeholder(): ?string { + return this.isTemplate ? "Start your template…" : "Start with a title…"; + } + @action share = async () => { return this.store.rootStore.shares.create({ documentId: this.id }); @@ -157,10 +197,16 @@ export default class Document extends BaseModel { }; @action - fetch = async () => { - const res = await client.post("/documents.info", { id: this.id }); - invariant(res && res.data, "Data should be available"); - this.updateFromJson(res.data); + templatize = async () => { + return this.store.templatize(this.id); + }; + + @action + updateFromTemplate = async (template: Document) => { + this.templateId = template.id; + this.title = template.title; + this.text = template.text; + this.injectTemplate = true; }; @action @@ -186,6 +232,7 @@ export default class Document extends BaseModel { id: this.id, title: this.title, text: this.text, + templateId: this.templateId, lastRevision: options.lastRevision, ...options, }); @@ -229,7 +276,7 @@ export default class Document extends BaseModel { // Firefox support requires the anchor tag be in the DOM to trigger the dl if (document.body) document.body.appendChild(a); a.href = url; - a.download = `${this.title || "Untitled"}.md`; + a.download = `${this.titleWithDefault}.md`; a.click(); }; } diff --git a/app/models/Event.js b/app/models/Event.js index 8366a8011..d9e69b0d0 100644 --- a/app/models/Event.js +++ b/app/models/Event.js @@ -18,6 +18,7 @@ class Event extends BaseModel { email: string, title: string, published: boolean, + templateId: string, }; get model() { diff --git a/app/routes.js b/app/routes.js index a9fe169c0..dde9b634e 100644 --- a/app/routes.js +++ b/app/routes.js @@ -4,6 +4,7 @@ import { Switch, Route, Redirect } from "react-router-dom"; import Login from "scenes/Login"; import Dashboard from "scenes/Dashboard"; import Starred from "scenes/Starred"; +import Templates from "scenes/Templates"; import Drafts from "scenes/Drafts"; import Archive from "scenes/Archive"; import Trash from "scenes/Trash"; @@ -50,6 +51,8 @@ export default function Routes() { <Route path="/home" component={Dashboard} /> <Route exact path="/starred" component={Starred} /> <Route exact path="/starred/:sort" component={Starred} /> + <Route exact path="/templates" component={Templates} /> + <Route exact path="/templates/:sort" component={Templates} /> <Route exact path="/drafts" component={Drafts} /> <Route exact path="/archive" component={Archive} /> <Route exact path="/trash" component={Trash} /> diff --git a/app/scenes/Archive.js b/app/scenes/Archive.js index 92e81f68d..c59c9f6ad 100644 --- a/app/scenes/Archive.js +++ b/app/scenes/Archive.js @@ -29,6 +29,7 @@ class Archive extends React.Component<Props> { heading={<Subheading>Documents</Subheading>} empty={<Empty>The document archive is empty at the moment.</Empty>} showCollection + showTemplate /> </CenteredContent> ); diff --git a/app/scenes/CollectionMembers/components/MemberListItem.js b/app/scenes/CollectionMembers/components/MemberListItem.js index 37b31d750..d4aa141d3 100644 --- a/app/scenes/CollectionMembers/components/MemberListItem.js +++ b/app/scenes/CollectionMembers/components/MemberListItem.js @@ -46,7 +46,7 @@ const MemberListItem = ({ "Never signed in" )} {!user.lastActiveAt && <Badge>Invited</Badge>} - {user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} + {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>} </React.Fragment> } image={<Avatar src={user.avatarUrl} size={40} />} diff --git a/app/scenes/CollectionMembers/components/UserListItem.js b/app/scenes/CollectionMembers/components/UserListItem.js index c064ab42c..a0f878ecc 100644 --- a/app/scenes/CollectionMembers/components/UserListItem.js +++ b/app/scenes/CollectionMembers/components/UserListItem.js @@ -29,7 +29,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => { "Never signed in" )} {!user.lastActiveAt && <Badge>Invited</Badge>} - {user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} + {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>} </React.Fragment> } actions={ diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index 734200f09..3cbc943f9 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -8,12 +8,13 @@ import { observer, inject } from "mobx-react"; import { Prompt, Route, withRouter } from "react-router-dom"; import type { Location, RouterHistory } from "react-router-dom"; import keydown from "react-keydown"; +import { InputIcon } from "outline-icons"; import Flex from "components/Flex"; import { collectionUrl, documentMoveUrl, documentHistoryUrl, - documentEditUrl, + editDocumentUrl, documentUrl, } from "utils/routeHelpers"; import { emojiToUrl } from "utils/emoji"; @@ -68,8 +69,6 @@ type Props = { @observer class DocumentScene extends React.Component<Props> { @observable editor: ?any; - getEditorText: () => string = () => this.props.document.text; - @observable editorComponent = EditorImport; @observable isUploading: boolean = false; @observable isSaving: boolean = false; @@ -79,6 +78,7 @@ class DocumentScene extends React.Component<Props> { @observable moveModalOpen: boolean = false; @observable lastRevision: number; @observable title: string; + getEditorText: () => string = () => this.props.document.text; constructor(props) { super(); @@ -114,6 +114,12 @@ class DocumentScene extends React.Component<Props> { } } + if (document.injectTemplate) { + this.isDirty = true; + this.title = document.title; + document.injectTemplate = false; + } + this.updateBackground(); } @@ -143,7 +149,7 @@ class DocumentScene extends React.Component<Props> { const { document, abilities } = this.props; if (abilities.update) { - this.props.history.push(documentEditUrl(document)); + this.props.history.push(editDocumentUrl(document)); } } @@ -256,7 +262,7 @@ class DocumentScene extends React.Component<Props> { this.props.history.push(savedDocument.url); this.props.ui.setActiveDocument(savedDocument); } else if (isNew) { - this.props.history.push(documentEditUrl(savedDocument)); + this.props.history.push(editDocumentUrl(savedDocument)); this.props.ui.setActiveDocument(savedDocument); } } catch (err) { @@ -342,6 +348,7 @@ class DocumentScene extends React.Component<Props> { } const value = revision ? revision.text : document.text; + const injectTemplate = document.injectTemplate; const disableEmbeds = (team && team.documentEmbeds === false) || document.embedsDisabled; @@ -360,7 +367,7 @@ class DocumentScene extends React.Component<Props> { )} /> <PageTitle - title={document.title.replace(document.emoji, "") || "Untitled"} + title={document.titleWithDefault.replace(document.emoji, "")} favicon={document.emoji ? emojiToUrl(document.emoji) : undefined} /> {(this.isUploading || this.isSaving) && <LoadingIndicator />} @@ -400,6 +407,15 @@ class DocumentScene extends React.Component<Props> { column auto > + {document.isTemplate && + !readOnly && ( + <Notice muted> + You’re editing a template. Highlight some text and use the{" "} + <PlaceholderIcon color="currentColor" /> control to add + placeholders that can be filled out when creating new + documents from this template. + </Notice> + )} {document.archivedAt && !document.deletedAt && ( <Notice muted> @@ -414,7 +430,7 @@ class DocumentScene extends React.Component<Props> { {document.permanentlyDeletedAt && ( <React.Fragment> <br /> - This document will be permanently deleted in{" "} + This {document.noun} will be permanently deleted in{" "} <Time dateTime={document.permanentlyDeletedAt} /> unless restored. </React.Fragment> @@ -437,7 +453,8 @@ class DocumentScene extends React.Component<Props> { }} isShare={isShare} isDraft={document.isDraft} - key={disableEmbeds ? "embeds-disabled" : "embeds-enabled"} + template={document.isTemplate} + key={[injectTemplate, disableEmbeds].join("-")} title={revision ? revision.title : this.title} document={document} value={readOnly ? value : undefined} @@ -476,6 +493,11 @@ class DocumentScene extends React.Component<Props> { } } +const PlaceholderIcon = styled(InputIcon)` + position: relative; + top: 6px; +`; + const Background = styled(Container)` background: ${props => props.theme.background}; transition: ${props => props.theme.backgroundTransition}; diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 92a89c69f..59c909c07 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -80,8 +80,8 @@ class DocumentEditor extends React.Component<Props> { type="text" onChange={onChangeTitle} onKeyDown={this.handleTitleKeyDown} - placeholder="Start with a title…" - value={!title && readOnly ? "Untitled" : title} + placeholder={document.placeholder} + value={!title && readOnly ? document.titleWithDefault : title} style={startsWithEmojiAndSpace ? { marginLeft: "-1.2em" } : undefined} readOnlyWriteCheckboxes readOnly={readOnly} diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index 2a20c9fcb..9278283d6 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -15,11 +15,12 @@ import { import { transparentize, darken } from "polished"; import Document from "models/Document"; import AuthStore from "stores/AuthStore"; -import { documentEditUrl } from "utils/routeHelpers"; +import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers"; import { meta } from "utils/keyboard"; import Flex from "components/Flex"; import Breadcrumb, { Slash } from "components/Breadcrumb"; +import TemplatesMenu from "menus/TemplatesMenu"; import DocumentMenu from "menus/DocumentMenu"; import NewChildDocumentMenu from "menus/NewChildDocumentMenu"; import DocumentShare from "scenes/DocumentShare"; @@ -76,7 +77,15 @@ class Header extends React.Component<Props> { handleScroll = throttle(this.updateIsScrolled, 50); handleEdit = () => { - this.redirectTo = documentEditUrl(this.props.document); + this.redirectTo = editDocumentUrl(this.props.document); + }; + + handleNewFromTemplate = () => { + const { document } = this.props; + + this.redirectTo = newDocumentUrl(document.collectionId, { + templateId: document.id, + }); }; handleSave = () => { @@ -125,6 +134,8 @@ class Header extends React.Component<Props> { const share = shares.getByDocumentId(document.id); const isPubliclyShared = share && share.published; + const isNew = document.isNew; + const isTemplate = document.isTemplate; const can = policies.abilities(document.id); const canShareDocuments = auth.team && auth.team.sharing && can.share; const canToggleEmbeds = auth.team && auth.team.documentEmbeds; @@ -196,6 +207,13 @@ class Header extends React.Component<Props> { currentUserId={auth.user ? auth.user.id : undefined} /> </Fade> + {isEditing && + !isTemplate && + isNew && ( + <Action> + <TemplatesMenu document={document} /> + </Action> + )} {!isEditing && canShareDocuments && ( <Action> @@ -245,31 +263,10 @@ class Header extends React.Component<Props> { </Action> </React.Fragment> )} - {can.update && - isDraft && - !isRevision && ( - <Action> - <Tooltip - tooltip="Publish" - shortcut={`${meta}+shift+p`} - delay={500} - placement="bottom" - > - <Button - onClick={this.handlePublish} - title="Publish document" - disabled={publishingIsDisabled} - small - > - {isPublishing ? "Publishing…" : "Publish"} - </Button> - </Tooltip> - </Action> - )} {canEdit && ( <Action> <Tooltip - tooltip="Edit document" + tooltip={`Edit ${document.noun}`} shortcut="e" delay={500} placement="bottom" @@ -305,6 +302,42 @@ class Header extends React.Component<Props> { /> </Action> )} + {canEdit && + isTemplate && + !isDraft && + !isRevision && ( + <Action> + <Button + icon={<PlusIcon />} + onClick={this.handleNewFromTemplate} + primary + small + > + New from template + </Button> + </Action> + )} + {can.update && + isDraft && + !isRevision && ( + <Action> + <Tooltip + tooltip="Publish" + shortcut={`${meta}+shift+p`} + delay={500} + placement="bottom" + > + <Button + onClick={this.handlePublish} + title="Publish document" + disabled={publishingIsDisabled} + small + > + {isPublishing ? "Publishing…" : "Publish"} + </Button> + </Tooltip> + </Action> + )} {!isEditing && ( <React.Fragment> <Separator /> diff --git a/app/scenes/DocumentDelete.js b/app/scenes/DocumentDelete.js index 594c85aff..203bf9491 100644 --- a/app/scenes/DocumentDelete.js +++ b/app/scenes/DocumentDelete.js @@ -50,14 +50,16 @@ class DocumentDelete extends React.Component<Props> { <form onSubmit={this.handleSubmit}> <HelpText> Are you sure about that? Deleting the{" "} - <strong>{document.title}</strong> document will delete all of its - history, and any nested documents. + <strong>{document.titleWithDefault}</strong> {document.noun} will + delete all of its history{document.isTemplate + ? "" + : ", and any nested documents"}. </HelpText> {!document.isDraft && !document.isArchived && ( <HelpText> - If you’d like the option of referencing or restoring this - document in the future, consider archiving it instead. + If you’d like the option of referencing or restoring this{" "} + {document.noun} in the future, consider archiving it instead. </HelpText> )} <Button type="submit" danger> diff --git a/app/scenes/DocumentNew.js b/app/scenes/DocumentNew.js index 1664147c8..ed6848a74 100644 --- a/app/scenes/DocumentNew.js +++ b/app/scenes/DocumentNew.js @@ -1,13 +1,14 @@ // @flow import * as React from "react"; import { inject } from "mobx-react"; +import queryString from "query-string"; import type { RouterHistory, Location } from "react-router-dom"; import Flex from "components/Flex"; import CenteredContent from "components/CenteredContent"; import LoadingPlaceholder from "components/LoadingPlaceholder"; import DocumentsStore from "stores/DocumentsStore"; import UiStore from "stores/UiStore"; -import { documentEditUrl } from "utils/routeHelpers"; +import { editDocumentUrl } from "utils/routeHelpers"; type Props = { history: RouterHistory, @@ -19,16 +20,18 @@ type Props = { class DocumentNew extends React.Component<Props> { async componentDidMount() { + const params = queryString.parse(this.props.location.search); + try { const document = await this.props.documents.create({ collectionId: this.props.match.params.id, - parentDocumentId: new URLSearchParams(this.props.location.search).get( - "parentDocumentId" - ), + parentDocumentId: params.parentDocumentId, + templateId: params.templateId, + template: params.template, title: "", text: "", }); - this.props.history.replace(documentEditUrl(document)); + this.props.history.replace(editDocumentUrl(document)); } catch (err) { this.props.ui.showToast("Couldn’t create the document, try again?"); this.props.history.goBack(); diff --git a/app/scenes/DocumentShare.js b/app/scenes/DocumentShare.js index e77232880..ab2f22dfb 100644 --- a/app/scenes/DocumentShare.js +++ b/app/scenes/DocumentShare.js @@ -64,19 +64,21 @@ class DocumentShare extends React.Component<Props> { const { document, policies, shares, onSubmit } = this.props; const share = shares.getByDocumentId(document.id); const can = policies.abilities(share ? share.id : ""); + const canPublish = can.update && !document.isTemplate; return ( <div> <HelpText> The link below provides a read-only version of the document{" "} - <strong>{document.title}</strong>.{" "} - {can.update && - "You can optionally make it accessible to anyone with the link."}{" "} + <strong>{document.titleWithDefault}</strong>.{" "} + {canPublish + ? "You can optionally make it accessible to anyone with the link." + : "It is only viewable by those that already have access to the collection."}{" "} <Link to="/settings/shares" onClick={onSubmit}> Manage all share links </Link>. </HelpText> - {can.update && ( + {canPublish && ( <React.Fragment> <Switch id="published" diff --git a/app/scenes/DocumentTemplatize.js b/app/scenes/DocumentTemplatize.js new file mode 100644 index 000000000..19a849d8f --- /dev/null +++ b/app/scenes/DocumentTemplatize.js @@ -0,0 +1,61 @@ +// @flow +import * as React from "react"; +import { observable } from "mobx"; +import { inject, observer } from "mobx-react"; +import { withRouter, type RouterHistory } from "react-router-dom"; +import { documentUrl } from "utils/routeHelpers"; +import Button from "components/Button"; +import Flex from "components/Flex"; +import HelpText from "components/HelpText"; +import Document from "models/Document"; +import UiStore from "stores/UiStore"; + +type Props = { + ui: UiStore, + document: Document, + history: RouterHistory, + onSubmit: () => void, +}; + +@observer +class DocumentTemplatize extends React.Component<Props> { + @observable isSaving: boolean; + + handleSubmit = async (ev: SyntheticEvent<>) => { + ev.preventDefault(); + this.isSaving = true; + + try { + const template = await this.props.document.templatize(); + this.props.history.push(documentUrl(template)); + this.props.ui.showToast("Template created, go ahead and customize it"); + this.props.onSubmit(); + } catch (err) { + this.props.ui.showToast(err.message); + } finally { + this.isSaving = false; + } + }; + + render() { + const { document } = this.props; + + return ( + <Flex column> + <form onSubmit={this.handleSubmit}> + <HelpText> + Creating a template from{" "} + <strong>{document.titleWithDefault}</strong> is a non-destructive + action – we'll make a copy of the document and turn it into a + template that can be used as a starting point for new documents. + </HelpText> + <Button type="submit"> + {this.isSaving ? "Creating…" : "Create template"} + </Button> + </form> + </Flex> + ); + } +} + +export default inject("ui")(withRouter(DocumentTemplatize)); diff --git a/app/scenes/GroupMembers/components/GroupMemberListItem.js b/app/scenes/GroupMembers/components/GroupMemberListItem.js index 8e3471d8a..896c9cd5a 100644 --- a/app/scenes/GroupMembers/components/GroupMemberListItem.js +++ b/app/scenes/GroupMembers/components/GroupMemberListItem.js @@ -36,7 +36,7 @@ const GroupMemberListItem = ({ "Never signed in" )} {!user.lastActiveAt && <Badge>Invited</Badge>} - {user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} + {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>} </React.Fragment> } image={<Avatar src={user.avatarUrl} size={40} />} diff --git a/app/scenes/GroupMembers/components/UserListItem.js b/app/scenes/GroupMembers/components/UserListItem.js index e99a4027c..7dd0b73fb 100644 --- a/app/scenes/GroupMembers/components/UserListItem.js +++ b/app/scenes/GroupMembers/components/UserListItem.js @@ -29,7 +29,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => { "Never signed in" )} {!user.lastActiveAt && <Badge>Invited</Badge>} - {user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} + {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>} </React.Fragment> } actions={ diff --git a/app/scenes/Settings/components/EventListItem.js b/app/scenes/Settings/components/EventListItem.js index ceed25e72..5cc32f8fc 100644 --- a/app/scenes/Settings/components/EventListItem.js +++ b/app/scenes/Settings/components/EventListItem.js @@ -143,10 +143,29 @@ const description = event => { </React.Fragment> ); } + if (event.name === "documents.create") { + return ( + <React.Fragment> + {capitalize(event.verbPastTense)} the{" "} + <Link to={`/doc/${event.documentId}`}> + {event.data.title || "Untitled"} + </Link>{" "} + document{" "} + {event.data.templateId && ( + <React.Fragment> + from a <Link to={`/doc/${event.data.templateId}`}>template</Link> + </React.Fragment> + )} + </React.Fragment> + ); + } return ( <React.Fragment> {capitalize(event.verbPastTense)} the{" "} - <Link to={`/doc/${event.documentId}`}>{event.data.title}</Link> document + <Link to={`/doc/${event.documentId}`}> + {event.data.title || "Untitled"} + </Link>{" "} + document </React.Fragment> ); } diff --git a/app/scenes/Settings/components/UserListItem.js b/app/scenes/Settings/components/UserListItem.js index 485bc21cf..be8908f52 100644 --- a/app/scenes/Settings/components/UserListItem.js +++ b/app/scenes/Settings/components/UserListItem.js @@ -58,7 +58,7 @@ class UserListItem extends React.Component<Props> { ) : ( "Invited" )} - {user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} + {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>} {user.isSuspended && <Badge>Suspended</Badge>} </React.Fragment> } diff --git a/app/scenes/Templates.js b/app/scenes/Templates.js new file mode 100644 index 000000000..baa41d04c --- /dev/null +++ b/app/scenes/Templates.js @@ -0,0 +1,70 @@ +// @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 Tabs from "components/Tabs"; +import Tab from "components/Tab"; +import NewTemplateMenu from "menus/NewTemplateMenu"; +import Actions, { Action } from "components/Actions"; +import DocumentsStore from "stores/DocumentsStore"; + +type Props = { + documents: DocumentsStore, + match: Object, +}; + +@observer +class Templates extends React.Component<Props> { + render() { + const { + fetchTemplates, + templates, + templatesAlphabetical, + } = this.props.documents; + const { sort } = this.props.match.params; + + return ( + <CenteredContent column auto> + <PageTitle title="Templates" /> + <Heading>Templates</Heading> + <PaginatedDocumentList + heading={ + <Tabs> + <Tab to="/templates" exact> + Recently Updated + </Tab> + <Tab to="/templates/alphabetical" exact> + Alphabetical + </Tab> + </Tabs> + } + empty={ + <Empty> + There are no templates just yet. You can create templates to help + your team create consistent and accurate documentation. + </Empty> + } + fetch={fetchTemplates} + documents={ + sort === "alphabetical" ? templatesAlphabetical : templates + } + showCollection + showDraft + /> + + <Actions align="center" justify="flex-end"> + <Action> + <NewTemplateMenu /> + </Action> + </Actions> + </CenteredContent> + ); + } +} + +export default inject("documents")(Templates); diff --git a/app/scenes/Trash.js b/app/scenes/Trash.js index 31ecd60db..72c09c9a6 100644 --- a/app/scenes/Trash.js +++ b/app/scenes/Trash.js @@ -29,6 +29,7 @@ class Trash extends React.Component<Props> { heading={<Subheading>Documents</Subheading>} empty={<Empty>Trash is empty at the moment.</Empty>} showCollection + showTemplate /> </CenteredContent> ); diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 7df10109c..c70d8ba9d 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -32,7 +32,10 @@ export default class DocumentsStore extends BaseStore<Document> { @computed get all(): Document[] { - return filter(this.orderedData, d => !d.archivedAt && !d.deletedAt); + return filter( + this.orderedData, + d => !d.archivedAt && !d.deletedAt && !d.template + ); } @computed @@ -49,6 +52,17 @@ export default class DocumentsStore extends BaseStore<Document> { return orderBy(this.all, "updatedAt", "desc"); } + get templates(): Document[] { + return orderBy( + filter( + this.orderedData, + d => !d.archivedAt && !d.deletedAt && d.template + ), + "updatedAt", + "desc" + ); + } + createdByUser(userId: string): Document[] { return orderBy( filter(this.all, d => d.createdBy.id === userId), @@ -61,6 +75,21 @@ export default class DocumentsStore extends BaseStore<Document> { return filter(this.all, document => document.collectionId === collectionId); } + templatesInCollection(collectionId: string): Document[] { + return orderBy( + filter( + this.orderedData, + d => + !d.archivedAt && + !d.deletedAt && + d.template === true && + d.collectionId === collectionId + ), + "updatedAt", + "desc" + ); + } + pinnedInCollection(collectionId: string): Document[] { return filter( this.recentlyUpdatedInCollection(collectionId), @@ -100,9 +129,8 @@ export default class DocumentsStore extends BaseStore<Document> { return this.searchCache.get(query) || []; } - @computed get starred(): Document[] { - return filter(this.all, d => d.isStarred); + return orderBy(filter(this.all, d => d.isStarred), "updatedAt", "desc"); } @computed @@ -126,6 +154,11 @@ export default class DocumentsStore extends BaseStore<Document> { return naturalSort(this.starred, "title"); } + @computed + get templatesAlphabetical(): Document[] { + return naturalSort(this.templates, "title"); + } + @computed get drafts(): Document[] { return filter( @@ -211,6 +244,11 @@ export default class DocumentsStore extends BaseStore<Document> { return this.fetchNamedPage("list", options); }; + @action + fetchTemplates = async (options: ?PaginationParams): Promise<*> => { + return this.fetchNamedPage("list", { ...options, template: true }); + }; + @action fetchAlphabetical = async (options: ?PaginationParams): Promise<*> => { return this.fetchNamedPage("list", { @@ -321,6 +359,24 @@ export default class DocumentsStore extends BaseStore<Document> { } }; + @action + templatize = async (id: string): Promise<?Document> => { + const doc: ?Document = this.data.get(id); + invariant(doc, "Document should exist"); + + if (doc.template) { + return; + } + + const res = await client.post("/documents.templatize", { id }); + invariant(res && res.data, "Document not available"); + + this.addPolicies(res.policies); + this.add(res.data); + + return this.data.get(res.data.id); + }; + @action fetch = async ( id: string, @@ -377,6 +433,7 @@ export default class DocumentsStore extends BaseStore<Document> { publish: !!document.publishedAt, parentDocumentId: document.parentDocumentId, collectionId: document.collectionId, + template: document.template, title: `${document.title} (duplicate)`, text: document.text, }); diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index 07fb96e39..f2f33af1b 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -84,7 +84,12 @@ class UiStore { setActiveDocument = (document: Document): void => { this.activeDocumentId = document.id; - if (document.publishedAt && !document.isArchived && !document.isDeleted) { + if ( + document.publishedAt && + !document.isArchived && + !document.isDeleted && + !document.isTemplate + ) { this.activeCollectionId = document.collectionId; } }; diff --git a/app/utils/routeHelpers.js b/app/utils/routeHelpers.js index fe9d2b689..1d7c402a2 100644 --- a/app/utils/routeHelpers.js +++ b/app/utils/routeHelpers.js @@ -1,4 +1,5 @@ // @flow +import queryString from "query-string"; import Document from "models/Document"; export function homeUrl(): string { @@ -23,7 +24,7 @@ export function documentUrl(doc: Document): string { return doc.url; } -export function documentEditUrl(doc: Document): string { +export function editDocumentUrl(doc: Document): string { return `${doc.url}/edit`; } @@ -53,15 +54,13 @@ export function updateDocumentUrl(oldUrl: string, newUrl: string): string { export function newDocumentUrl( collectionId: string, - parentDocumentId?: string -): string { - let route = `/collections/${collectionId}/new`; - - if (parentDocumentId) { - route += `?parentDocumentId=${parentDocumentId}`; + params?: { + parentDocumentId?: string, + templateId?: string, + template?: boolean, } - - return route; +): string { + return `/collections/${collectionId}/new?${queryString.stringify(params)}`; } export function searchUrl(query?: string, collectionId?: string): string { diff --git a/package.json b/package.json index 2fd888b39..73c9680f9 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "mobx-react": "^5.4.2", "natural-sort": "^1.0.0", "nodemailer": "^4.4.0", - "outline-icons": "^1.20.0", + "outline-icons": "^1.21.0-6", "oy-vey": "^0.10.0", "pg": "^6.1.5", "pg-hstore": "2.3.2", @@ -141,7 +141,7 @@ "react-portal": "^4.0.0", "react-router-dom": "^5.1.2", "react-waypoint": "^9.0.2", - "rich-markdown-editor": "^10.4.0", + "rich-markdown-editor": "^10.5.0-1", "semver": "^7.3.2", "sequelize": "^5.21.1", "sequelize-cli": "^5.5.0", diff --git a/server/api/documents.js b/server/api/documents.js index c1cd4e7d6..aa0f5b68e 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -29,7 +29,12 @@ const { authorize, cannot } = policy; const router = new Router(); router.post("documents.list", auth(), pagination(), async ctx => { - const { sort = "updatedAt", backlinkDocumentId, parentDocumentId } = ctx.body; + const { + sort = "updatedAt", + template, + backlinkDocumentId, + parentDocumentId, + } = ctx.body; // collection and user are here for backwards compatibility const collectionId = ctx.body.collectionId || ctx.body.collection; @@ -41,6 +46,10 @@ router.post("documents.list", auth(), pagination(), async ctx => { const user = ctx.state.user; let where = { teamId: user.teamId }; + if (template) { + where = { ...where, template: true }; + } + // if a specific user is passed then add to filters. If the user doesn't // exist in the team then nothing will be returned, so no need to check auth if (createdById) { @@ -682,6 +691,8 @@ router.post("documents.create", auth(), async ctx => { publish, collectionId, parentDocumentId, + templateId, + template, index, } = ctx.body; const editorVersion = ctx.headers["x-editor-version"]; @@ -717,6 +728,12 @@ router.post("documents.create", auth(), async ctx => { authorize(user, "read", parentDocument, { collection }); } + let templateDocument; + if (templateId) { + templateDocument = await Document.findByPk(templateId, { userId: user.id }); + authorize(user, "read", templateDocument); + } + let document = await Document.create({ parentDocumentId, editorVersion, @@ -725,8 +742,10 @@ router.post("documents.create", auth(), async ctx => { userId: user.id, lastModifiedById: user.id, createdById: user.id, - title, - text, + template, + templateId: templateDocument ? templateDocument.id : undefined, + title: templateDocument ? templateDocument.title : title, + text: templateDocument ? templateDocument.text : text, }); await Event.create({ @@ -735,7 +754,7 @@ router.post("documents.create", auth(), async ctx => { collectionId: document.collectionId, teamId: document.teamId, actorId: user.id, - data: { title: document.title }, + data: { title: document.title, templateId }, ip: ctx.request.ip, }); @@ -767,6 +786,46 @@ router.post("documents.create", auth(), async ctx => { }; }); +router.post("documents.templatize", auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, "id is required"); + + const user = ctx.state.user; + const original = await Document.findByPk(id, { userId: user.id }); + authorize(user, "update", original); + + let document = await Document.create({ + editorVersion: original.editorVersion, + collectionId: original.collectionId, + teamId: original.teamId, + userId: user.id, + publishedAt: new Date(), + lastModifiedById: user.id, + createdById: user.id, + template: true, + title: original.title, + text: original.text, + }); + + await Event.create({ + name: "documents.create", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { title: document.title, template: true }, + ip: ctx.request.ip, + }); + + // reload to get all of the data needed to present (user, collection etc) + document = await Document.findByPk(document.id, { userId: user.id }); + + ctx.body = { + data: await presentDocument(document), + policies: presentPolicies(user, [document]), + }; +}); + router.post("documents.update", auth(), async ctx => { const { id, @@ -776,6 +835,7 @@ router.post("documents.update", auth(), async ctx => { autosave, done, lastRevision, + templateId, append, } = ctx.body; const editorVersion = ctx.headers["x-editor-version"]; @@ -795,6 +855,7 @@ router.post("documents.update", auth(), async ctx => { // Update document if (title) document.title = title; if (editorVersion) document.editorVersion = editorVersion; + if (templateId) document.templateId = templateId; if (append) { document.text += text; diff --git a/server/migrations/20200727051157-add-templates.js b/server/migrations/20200727051157-add-templates.js new file mode 100644 index 000000000..559bbbb1f --- /dev/null +++ b/server/migrations/20200727051157-add-templates.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('documents', 'template', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }); + await queryInterface.addColumn('documents', 'templateId', { + type: Sequelize.UUID, + allowNull: true + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('documents', 'templateId'); + await queryInterface.removeColumn('documents', 'template'); + } +}; \ No newline at end of file diff --git a/server/models/Document.js b/server/models/Document.js index f2330c24f..f2ce5bf21 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -98,6 +98,7 @@ const Document = sequelize.define( }, }, version: DataTypes.SMALLINT, + template: DataTypes.BOOLEAN, editorVersion: DataTypes.STRING, text: DataTypes.TEXT, @@ -142,6 +143,10 @@ Document.associate = models => { as: "team", foreignKey: "teamId", }); + Document.belongsTo(models.Document, { + as: "document", + foreignKey: "templateId", + }); Document.belongsTo(models.User, { as: "createdBy", foreignKey: "createdById", @@ -431,20 +436,28 @@ Document.searchForUser = async ( // Hooks Document.addHook("beforeSave", async model => { - if (!model.publishedAt) return; + if (!model.publishedAt || model.template) { + return; + } const collection = await Collection.findByPk(model.collectionId); - if (!collection || collection.type !== "atlas") return; + if (!collection || collection.type !== "atlas") { + return; + } await collection.updateDocument(model); model.collection = collection; }); Document.addHook("afterCreate", async model => { - if (!model.publishedAt) return; + if (!model.publishedAt || model.template) { + return; + } const collection = await Collection.findByPk(model.collectionId); - if (!collection || collection.type !== "atlas") return; + if (!collection || collection.type !== "atlas") { + return; + } await collection.addDocumentToStructure(model); model.collection = collection; diff --git a/server/models/Event.js b/server/models/Event.js index 73f7e3b66..d9721abb0 100644 --- a/server/models/Event.js +++ b/server/models/Event.js @@ -71,6 +71,7 @@ Event.AUDIT_EVENTS = [ "users.suspend", "users.activate", "users.delete", + "documents.create", "documents.publish", "documents.update", "documents.archive", diff --git a/server/policies/document.js b/server/policies/document.js index 5b912c4b4..dc68f21e1 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -31,6 +31,7 @@ allow(User, ["share"], Document, (user, document) => { allow(User, ["star", "unstar"], Document, (user, document) => { if (document.archivedAt) return false; if (document.deletedAt) return false; + if (document.template) return false; if (!document.publishedAt) return false; invariant( @@ -58,6 +59,7 @@ allow(User, "update", Document, (user, document) => { allow(User, "createChildDocument", Document, (user, document) => { if (document.archivedAt) return false; if (document.archivedAt) return false; + if (document.template) return false; if (!document.publishedAt) return false; invariant( @@ -72,6 +74,7 @@ allow(User, "createChildDocument", Document, (user, document) => { allow(User, ["move", "pin", "unpin"], Document, (user, document) => { if (document.archivedAt) return false; if (document.deletedAt) return false; + if (document.template) return false; if (!document.publishedAt) return false; invariant( diff --git a/server/presenters/document.js b/server/presenters/document.js index 82b3c6d77..89568957d 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -54,6 +54,8 @@ export default async function present(document: Document, options: ?Options) { archivedAt: document.archivedAt, deletedAt: document.deletedAt, teamId: document.teamId, + template: document.template, + templateId: document.templateId, collaborators: [], starred: document.starred ? !!document.starred.length : undefined, revision: document.revisionCount, diff --git a/shared/styles/theme.js b/shared/styles/theme.js index 7b300cea0..1ac4d3ff3 100644 --- a/shared/styles/theme.js +++ b/shared/styles/theme.js @@ -112,7 +112,7 @@ export const light = { text: colors.almostBlack, textSecondary: colors.slateDark, textTertiary: colors.slate, - placeholder: "#B1BECC", + placeholder: "#a2b2c3", sidebarBackground: colors.warmGrey, sidebarItemBackground: colors.black10, diff --git a/yarn.lock b/yarn.lock index a16810c3d..c453536c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7171,10 +7171,10 @@ osenv@^0.1.4: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -outline-icons@^1.19.1, outline-icons@^1.20.0: - version "1.20.0" - resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.20.0.tgz#7d3814fade75ecd78492c9d9779183aed51502d8" - integrity sha512-YZJyqxl47zgHS3QAsznP18TBOgM4UdbeKoU6Am+UlgqUdXZi5VC1NbR/NwLBflgOs490DjF2EVTcRbYbN2ZMLg== +outline-icons@^1.21.0-3, outline-icons@^1.21.0-6: + version "1.21.0-6" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.21.0-6.tgz#bbf63fe6cc88ca2fe391e6ba6cc8b62a948607e3" + integrity sha512-iEVK2zTEZ3sLFLsko/V6z3AEiM2EAjEUyLIOzAT2cqRglIbaIWdyitotKVMb2hWZo66bSvHxA/Rdvv51sw5RhA== oy-vey@^0.10.0: version "0.10.0" @@ -8604,16 +8604,16 @@ retry-as-promised@^3.2.0: dependencies: any-promise "^1.3.0" -rich-markdown-editor@^10.4.0: - version "10.4.0" - resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-10.4.0.tgz#0a31401cf3f3356c0f365607fba0fd28a8b05da4" - integrity sha512-vLghzLnat13TE7xbY2vOENoH3iifO8Z+J8FQ0W2SP4wcL6pqwgMvDSsmVKuEEbh5E9zRwAcsqFybH985Zqg1tQ== +rich-markdown-editor@^10.5.0-1: + version "10.5.0-1" + resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-10.5.0-1.tgz#07a06941c65542da18c8b90c5de292ee052bc8aa" + integrity sha512-ixMMHn395vmhBCSUOmN6qZrBgISukp/lR6qabuJK0CigBhXxKrFnyLjruZJummB/pzhJKwVJJYFYfnJwBRUj8g== dependencies: copy-to-clipboard "^3.0.8" lodash "^4.17.11" markdown-it-container "^3.0.0" markdown-it-mark "^3.0.0" - outline-icons "^1.19.1" + outline-icons "^1.21.0-3" prismjs "^1.19.0" prosemirror-commands "^1.1.4" prosemirror-dropcursor "^1.3.2"