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

@@ -15,6 +15,10 @@ export const Action = styled(Flex)`
color: ${props => props.theme.text}; color: ${props => props.theme.text};
height: 24px; height: 24px;
} }
&:empty {
display: none;
}
`; `;
export const Separator = styled.div` export const Separator = styled.div`

View File

@@ -4,9 +4,9 @@ import styled from "styled-components";
const Badge = styled.span` const Badge = styled.span`
margin-left: 10px; margin-left: 10px;
padding: 2px 6px 3px; padding: 2px 6px 3px;
background-color: ${({ admin, theme }) => background-color: ${({ primary, theme }) =>
admin ? theme.primary : theme.textTertiary}; primary ? theme.primary : theme.textTertiary};
color: ${({ admin, theme }) => (admin ? theme.white : theme.background)}; color: ${({ primary, theme }) => (primary ? theme.white : theme.background)};
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;

View File

@@ -4,7 +4,13 @@ import { observer, inject } from "mobx-react";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import styled from "styled-components"; import styled from "styled-components";
import { Link } from "react-router-dom"; 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 Document from "models/Document";
import CollectionsStore from "stores/CollectionsStore"; 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 isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined; const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : []; const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return ( return (
<Wrapper justify="flex-start" align="center"> <Wrapper justify="flex-start" align="center">
{isTemplate && (
<React.Fragment>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</React.Fragment>
)}
{isDraft && (
<React.Fragment>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</React.Fragment>
)}
<CollectionName to={collectionUrl(collection.id)}> <CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />&nbsp; <CollectionIcon collection={collection} expanded />&nbsp;
<span>{collection.name}</span> <span>{collection.name}</span>

View File

@@ -113,6 +113,7 @@ export const Inner = styled.span`
line-height: ${props => (props.hasIcon ? 24 : 32)}px; line-height: ${props => (props.hasIcon ? 24 : 32)}px;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 30px;
${props => props.hasIcon && props.hasText && "padding-left: 4px;"}; ${props => props.hasIcon && props.hasText && "padding-left: 4px;"};
${props => props.hasIcon && !props.hasText && "padding: 0 4px;"}; ${props => props.hasIcon && !props.hasText && "padding: 0 4px;"};

View File

@@ -2,15 +2,17 @@
import * as React from "react"; import * as React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { StarredIcon } from "outline-icons"; import { StarredIcon, PlusIcon } from "outline-icons";
import styled, { withTheme } from "styled-components"; import styled, { withTheme } from "styled-components";
import Flex from "components/Flex"; import Flex from "components/Flex";
import Badge from "components/Badge"; import Badge from "components/Badge";
import Button from "components/Button";
import Tooltip from "components/Tooltip"; import Tooltip from "components/Tooltip";
import Highlight from "components/Highlight"; import Highlight from "components/Highlight";
import PublishingInfo from "components/PublishingInfo"; import PublishingInfo from "components/PublishingInfo";
import DocumentMenu from "menus/DocumentMenu"; import DocumentMenu from "menus/DocumentMenu";
import Document from "models/Document"; import Document from "models/Document";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = { type Props = {
document: Document, document: Document,
@@ -20,8 +22,117 @@ type Props = {
showPublished?: boolean, showPublished?: boolean,
showPin?: boolean, showPin?: boolean,
showDraft?: boolean, showDraft?: boolean,
showTemplate?: boolean,
}; };
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@observer
class DocumentPreview extends React.Component<Props> {
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\b[^>]*>(.*?)<\/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 (
<DocumentLink
to={{
pathname: document.url,
state: { title: document.titleWithDefault },
}}
{...rest}
>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{!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>
)}&nbsp;
<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 }) => ( const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
<StarredIcon color={theme.text} {...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; position: absolute;
right: 16px; right: 16px;
top: 50%; top: 50%;
@@ -54,7 +166,7 @@ const DocumentLink = styled(Link)`
overflow: hidden; overflow: hidden;
position: relative; position: relative;
${StyledDocumentMenu} { ${SecondaryActions} {
opacity: 0; opacity: 0;
} }
@@ -64,7 +176,11 @@ const DocumentLink = styled(Link)`
background: ${props => props.theme.listItemHoverBackground}; background: ${props => props.theme.listItemHoverBackground};
outline: none; outline: none;
${StyledStar}, ${StyledDocumentMenu} { ${SecondaryActions} {
opacity: 1;
}
${StyledStar} {
opacity: 0.5; opacity: 0.5;
&:hover { &:hover {
@@ -106,91 +222,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em; 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; export default DocumentPreview;

View File

@@ -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; export default DropdownMenu;

View File

@@ -42,7 +42,7 @@ const MenuItem = styled.a`
margin: 0; margin: 0;
padding: 6px 12px; padding: 6px 12px;
width: 100%; width: 100%;
height: 32px; min-height: 32px;
color: ${props => color: ${props =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary}; props.disabled ? props.theme.textTertiary : props.theme.textSecondary};

View File

@@ -1,3 +1,3 @@
// @flow // @flow
export { default as DropdownMenu } from "./DropdownMenu"; export { default as DropdownMenu, Header } from "./DropdownMenu";
export { default as DropdownMenuItem } from "./DropdownMenuItem"; export { default as DropdownMenuItem } from "./DropdownMenuItem";

View File

@@ -1,20 +1,8 @@
// @flow // @flow
import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
type Props = { const Empty = styled.p`
children: React.Node, color: ${props => props.theme.textTertiary};
};
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;
`; `;
export default Empty; export default Empty;

View File

@@ -26,7 +26,7 @@ function HoverPreviewDocument({ url, documents, children }: Props) {
return children( return children(
<Content to={document.url}> <Content to={document.url}>
<Heading>{document.title}</Heading> <Heading>{document.titleWithDefault}</Heading>
<DocumentMeta isDraft={document.isDraft} document={document} /> <DocumentMeta isDraft={document.isDraft} document={document} />
<Editor <Editor

View File

@@ -7,7 +7,6 @@ import CollectionNew from "scenes/CollectionNew";
import CollectionEdit from "scenes/CollectionEdit"; import CollectionEdit from "scenes/CollectionEdit";
import CollectionDelete from "scenes/CollectionDelete"; import CollectionDelete from "scenes/CollectionDelete";
import CollectionExport from "scenes/CollectionExport"; import CollectionExport from "scenes/CollectionExport";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentShare from "scenes/DocumentShare"; import DocumentShare from "scenes/DocumentShare";
type Props = { type Props = {
@@ -51,9 +50,6 @@ class Modals extends React.Component<Props> {
<Modal name="document-share" title="Share document"> <Modal name="document-share" title="Share document">
<DocumentShare onSubmit={this.handleClose} /> <DocumentShare onSubmit={this.handleClose} />
</Modal> </Modal>
<Modal name="document-delete" title="Delete document">
<DocumentDelete onSubmit={this.handleClose} />
</Modal>
</span> </span>
); );
} }

View File

@@ -8,6 +8,7 @@ import {
EditIcon, EditIcon,
SearchIcon, SearchIcon,
StarredIcon, StarredIcon,
ShapesIcon,
TrashIcon, TrashIcon,
PlusIcon, PlusIcon,
} from "outline-icons"; } from "outline-icons";
@@ -43,6 +44,7 @@ class MainSidebar extends React.Component<Props> {
componentDidMount() { componentDidMount() {
this.props.documents.fetchDrafts(); this.props.documents.fetchDrafts();
this.props.documents.fetchTemplates();
} }
handleCreateCollection = (ev: SyntheticEvent<>) => { handleCreateCollection = (ev: SyntheticEvent<>) => {
@@ -103,6 +105,15 @@ class MainSidebar extends React.Component<Props> {
exact={false} exact={false}
label="Starred" label="Starred"
/> />
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label="Templates"
active={
documents.active ? documents.active.template : undefined
}
/>
<SidebarLink <SidebarLink
to="/drafts" to="/drafts"
icon={<EditIcon color="currentColor" />} icon={<EditIcon color="currentColor" />}
@@ -116,7 +127,8 @@ class MainSidebar extends React.Component<Props> {
active={ active={
documents.active documents.active
? !documents.active.publishedAt && ? !documents.active.publishedAt &&
!documents.active.isDeleted !documents.active.isDeleted &&
!documents.active.isTemplate
: undefined : undefined
} }
/> />

View File

@@ -10,7 +10,7 @@ type Props = {
const StyledNavLink = styled(NavLink)` const StyledNavLink = styled(NavLink)`
position: relative; position: relative;
top: 1px; bottom: -1px;
display: inline-block; display: inline-block;
font-weight: 500; font-weight: 500;
@@ -33,14 +33,14 @@ const StyledNavLink = styled(NavLink)`
} }
`; `;
function Tab(props: Props) { function Tab({ theme, ...rest }: Props) {
const activeStyle = { const activeStyle = {
paddingBottom: "5px", paddingBottom: "5px",
borderBottom: `3px solid ${props.theme.textSecondary}`, borderBottom: `3px solid ${theme.textSecondary}`,
color: props.theme.textSecondary, color: theme.textSecondary,
}; };
return <StyledNavLink {...props} activeStyle={activeStyle} />; return <StyledNavLink {...rest} activeStyle={activeStyle} />;
} }
export default withTheme(Tab); export default withTheme(Tab);

View File

@@ -2,6 +2,7 @@
import styled from "styled-components"; import styled from "styled-components";
const Tabs = styled.nav` const Tabs = styled.nav`
position: relative;
border-bottom: 1px solid ${props => props.theme.divider}; border-bottom: 1px solid ${props => props.theme.divider};
margin-top: 22px; margin-top: 22px;
margin-bottom: 12px; margin-bottom: 12px;

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
import { action, set, observable, computed } from "mobx"; import { action, set, observable, computed } from "mobx";
import addDays from "date-fns/add_days"; import addDays from "date-fns/add_days";
import invariant from "invariant"; import invariant from "invariant";
import { client } from "utils/ApiClient";
import parseTitle from "shared/utils/parseTitle"; import parseTitle from "shared/utils/parseTitle";
import unescape from "shared/utils/unescape"; import unescape from "shared/utils/unescape";
import BaseModel from "models/BaseModel"; import BaseModel from "models/BaseModel";
@@ -20,6 +19,7 @@ type SaveOptions = {
export default class Document extends BaseModel { export default class Document extends BaseModel {
@observable isSaving: boolean = false; @observable isSaving: boolean = false;
@observable embedsDisabled: boolean = false; @observable embedsDisabled: boolean = false;
@observable injectTemplate: boolean = false;
store: DocumentsStore; store: DocumentsStore;
collaborators: User[]; collaborators: User[];
@@ -35,6 +35,8 @@ export default class Document extends BaseModel {
text: string; text: string;
title: string; title: string;
emoji: string; emoji: string;
template: boolean;
templateId: ?string;
parentDocumentId: ?string; parentDocumentId: ?string;
publishedAt: ?string; publishedAt: ?string;
archivedAt: string; archivedAt: string;
@@ -43,11 +45,24 @@ export default class Document extends BaseModel {
urlId: string; urlId: string;
revision: number; revision: number;
constructor(fields: Object, store: DocumentsStore) {
super(fields, store);
if (this.isNew && this.isFromTemplate) {
this.title = "";
}
}
get emoji() { get emoji() {
const { emoji } = parseTitle(this.title); const { emoji } = parseTitle(this.title);
return emoji; return emoji;
} }
@computed
get noun(): string {
return this.template ? "template" : "document";
}
@computed @computed
get isOnlyTitle(): boolean { get isOnlyTitle(): boolean {
return !this.text.trim(); return !this.text.trim();
@@ -73,11 +88,21 @@ export default class Document extends BaseModel {
return !!this.deletedAt; return !!this.deletedAt;
} }
@computed
get isTemplate(): boolean {
return !!this.template;
}
@computed @computed
get isDraft(): boolean { get isDraft(): boolean {
return !this.publishedAt; return !this.publishedAt;
} }
@computed
get titleWithDefault(): string {
return this.title || "Untitled";
}
@computed @computed
get permanentlyDeletedAt(): ?string { get permanentlyDeletedAt(): ?string {
if (!this.deletedAt) { if (!this.deletedAt) {
@@ -87,6 +112,21 @@ export default class Document extends BaseModel {
return addDays(new Date(this.deletedAt), 30).toString(); 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 @action
share = async () => { share = async () => {
return this.store.rootStore.shares.create({ documentId: this.id }); return this.store.rootStore.shares.create({ documentId: this.id });
@@ -157,10 +197,16 @@ export default class Document extends BaseModel {
}; };
@action @action
fetch = async () => { templatize = async () => {
const res = await client.post("/documents.info", { id: this.id }); return this.store.templatize(this.id);
invariant(res && res.data, "Data should be available"); };
this.updateFromJson(res.data);
@action
updateFromTemplate = async (template: Document) => {
this.templateId = template.id;
this.title = template.title;
this.text = template.text;
this.injectTemplate = true;
}; };
@action @action
@@ -186,6 +232,7 @@ export default class Document extends BaseModel {
id: this.id, id: this.id,
title: this.title, title: this.title,
text: this.text, text: this.text,
templateId: this.templateId,
lastRevision: options.lastRevision, lastRevision: options.lastRevision,
...options, ...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 // Firefox support requires the anchor tag be in the DOM to trigger the dl
if (document.body) document.body.appendChild(a); if (document.body) document.body.appendChild(a);
a.href = url; a.href = url;
a.download = `${this.title || "Untitled"}.md`; a.download = `${this.titleWithDefault}.md`;
a.click(); a.click();
}; };
} }

View File

@@ -18,6 +18,7 @@ class Event extends BaseModel {
email: string, email: string,
title: string, title: string,
published: boolean, published: boolean,
templateId: string,
}; };
get model() { get model() {

View File

@@ -4,6 +4,7 @@ import { Switch, Route, Redirect } from "react-router-dom";
import Login from "scenes/Login"; import Login from "scenes/Login";
import Dashboard from "scenes/Dashboard"; import Dashboard from "scenes/Dashboard";
import Starred from "scenes/Starred"; import Starred from "scenes/Starred";
import Templates from "scenes/Templates";
import Drafts from "scenes/Drafts"; import Drafts from "scenes/Drafts";
import Archive from "scenes/Archive"; import Archive from "scenes/Archive";
import Trash from "scenes/Trash"; import Trash from "scenes/Trash";
@@ -50,6 +51,8 @@ export default function Routes() {
<Route path="/home" component={Dashboard} /> <Route path="/home" component={Dashboard} />
<Route exact path="/starred" component={Starred} /> <Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} /> <Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} /> <Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} /> <Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} /> <Route exact path="/trash" component={Trash} />

View File

@@ -29,6 +29,7 @@ class Archive extends React.Component<Props> {
heading={<Subheading>Documents</Subheading>} heading={<Subheading>Documents</Subheading>}
empty={<Empty>The document archive is empty at the moment.</Empty>} empty={<Empty>The document archive is empty at the moment.</Empty>}
showCollection showCollection
showTemplate
/> />
</CenteredContent> </CenteredContent>
); );

View File

@@ -46,7 +46,7 @@ const MemberListItem = ({
"Never signed in" "Never signed in"
)} )}
{!user.lastActiveAt && <Badge>Invited</Badge>} {!user.lastActiveAt && <Badge>Invited</Badge>}
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</React.Fragment> </React.Fragment>
} }
image={<Avatar src={user.avatarUrl} size={40} />} image={<Avatar src={user.avatarUrl} size={40} />}

View File

@@ -29,7 +29,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
"Never signed in" "Never signed in"
)} )}
{!user.lastActiveAt && <Badge>Invited</Badge>} {!user.lastActiveAt && <Badge>Invited</Badge>}
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</React.Fragment> </React.Fragment>
} }
actions={ actions={

View File

@@ -8,12 +8,13 @@ import { observer, inject } from "mobx-react";
import { Prompt, Route, withRouter } from "react-router-dom"; import { Prompt, Route, withRouter } from "react-router-dom";
import type { Location, RouterHistory } from "react-router-dom"; import type { Location, RouterHistory } from "react-router-dom";
import keydown from "react-keydown"; import keydown from "react-keydown";
import { InputIcon } from "outline-icons";
import Flex from "components/Flex"; import Flex from "components/Flex";
import { import {
collectionUrl, collectionUrl,
documentMoveUrl, documentMoveUrl,
documentHistoryUrl, documentHistoryUrl,
documentEditUrl, editDocumentUrl,
documentUrl, documentUrl,
} from "utils/routeHelpers"; } from "utils/routeHelpers";
import { emojiToUrl } from "utils/emoji"; import { emojiToUrl } from "utils/emoji";
@@ -68,8 +69,6 @@ type Props = {
@observer @observer
class DocumentScene extends React.Component<Props> { class DocumentScene extends React.Component<Props> {
@observable editor: ?any; @observable editor: ?any;
getEditorText: () => string = () => this.props.document.text;
@observable editorComponent = EditorImport; @observable editorComponent = EditorImport;
@observable isUploading: boolean = false; @observable isUploading: boolean = false;
@observable isSaving: boolean = false; @observable isSaving: boolean = false;
@@ -79,6 +78,7 @@ class DocumentScene extends React.Component<Props> {
@observable moveModalOpen: boolean = false; @observable moveModalOpen: boolean = false;
@observable lastRevision: number; @observable lastRevision: number;
@observable title: string; @observable title: string;
getEditorText: () => string = () => this.props.document.text;
constructor(props) { constructor(props) {
super(); 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(); this.updateBackground();
} }
@@ -143,7 +149,7 @@ class DocumentScene extends React.Component<Props> {
const { document, abilities } = this.props; const { document, abilities } = this.props;
if (abilities.update) { 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.history.push(savedDocument.url);
this.props.ui.setActiveDocument(savedDocument); this.props.ui.setActiveDocument(savedDocument);
} else if (isNew) { } else if (isNew) {
this.props.history.push(documentEditUrl(savedDocument)); this.props.history.push(editDocumentUrl(savedDocument));
this.props.ui.setActiveDocument(savedDocument); this.props.ui.setActiveDocument(savedDocument);
} }
} catch (err) { } catch (err) {
@@ -342,6 +348,7 @@ class DocumentScene extends React.Component<Props> {
} }
const value = revision ? revision.text : document.text; const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate;
const disableEmbeds = const disableEmbeds =
(team && team.documentEmbeds === false) || document.embedsDisabled; (team && team.documentEmbeds === false) || document.embedsDisabled;
@@ -360,7 +367,7 @@ class DocumentScene extends React.Component<Props> {
)} )}
/> />
<PageTitle <PageTitle
title={document.title.replace(document.emoji, "") || "Untitled"} title={document.titleWithDefault.replace(document.emoji, "")}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined} favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/> />
{(this.isUploading || this.isSaving) && <LoadingIndicator />} {(this.isUploading || this.isSaving) && <LoadingIndicator />}
@@ -400,6 +407,15 @@ class DocumentScene extends React.Component<Props> {
column column
auto auto
> >
{document.isTemplate &&
!readOnly && (
<Notice muted>
Youre 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.archivedAt &&
!document.deletedAt && ( !document.deletedAt && (
<Notice muted> <Notice muted>
@@ -414,7 +430,7 @@ class DocumentScene extends React.Component<Props> {
{document.permanentlyDeletedAt && ( {document.permanentlyDeletedAt && (
<React.Fragment> <React.Fragment>
<br /> <br />
This document will be permanently deleted in{" "} This {document.noun} will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} /> unless <Time dateTime={document.permanentlyDeletedAt} /> unless
restored. restored.
</React.Fragment> </React.Fragment>
@@ -437,7 +453,8 @@ class DocumentScene extends React.Component<Props> {
}} }}
isShare={isShare} isShare={isShare}
isDraft={document.isDraft} isDraft={document.isDraft}
key={disableEmbeds ? "embeds-disabled" : "embeds-enabled"} template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
title={revision ? revision.title : this.title} title={revision ? revision.title : this.title}
document={document} document={document}
value={readOnly ? value : undefined} 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)` const Background = styled(Container)`
background: ${props => props.theme.background}; background: ${props => props.theme.background};
transition: ${props => props.theme.backgroundTransition}; transition: ${props => props.theme.backgroundTransition};

View File

@@ -80,8 +80,8 @@ class DocumentEditor extends React.Component<Props> {
type="text" type="text"
onChange={onChangeTitle} onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown} onKeyDown={this.handleTitleKeyDown}
placeholder="Start with a title…" placeholder={document.placeholder}
value={!title && readOnly ? "Untitled" : title} value={!title && readOnly ? document.titleWithDefault : title}
style={startsWithEmojiAndSpace ? { marginLeft: "-1.2em" } : undefined} style={startsWithEmojiAndSpace ? { marginLeft: "-1.2em" } : undefined}
readOnlyWriteCheckboxes readOnlyWriteCheckboxes
readOnly={readOnly} readOnly={readOnly}

View File

@@ -15,11 +15,12 @@ import {
import { transparentize, darken } from "polished"; import { transparentize, darken } from "polished";
import Document from "models/Document"; import Document from "models/Document";
import AuthStore from "stores/AuthStore"; import AuthStore from "stores/AuthStore";
import { documentEditUrl } from "utils/routeHelpers"; import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
import { meta } from "utils/keyboard"; import { meta } from "utils/keyboard";
import Flex from "components/Flex"; import Flex from "components/Flex";
import Breadcrumb, { Slash } from "components/Breadcrumb"; import Breadcrumb, { Slash } from "components/Breadcrumb";
import TemplatesMenu from "menus/TemplatesMenu";
import DocumentMenu from "menus/DocumentMenu"; import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu"; import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import DocumentShare from "scenes/DocumentShare"; import DocumentShare from "scenes/DocumentShare";
@@ -76,7 +77,15 @@ class Header extends React.Component<Props> {
handleScroll = throttle(this.updateIsScrolled, 50); handleScroll = throttle(this.updateIsScrolled, 50);
handleEdit = () => { 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 = () => { handleSave = () => {
@@ -125,6 +134,8 @@ class Header extends React.Component<Props> {
const share = shares.getByDocumentId(document.id); const share = shares.getByDocumentId(document.id);
const isPubliclyShared = share && share.published; const isPubliclyShared = share && share.published;
const isNew = document.isNew;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id); const can = policies.abilities(document.id);
const canShareDocuments = auth.team && auth.team.sharing && can.share; const canShareDocuments = auth.team && auth.team.sharing && can.share;
const canToggleEmbeds = auth.team && auth.team.documentEmbeds; const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
@@ -196,6 +207,13 @@ class Header extends React.Component<Props> {
currentUserId={auth.user ? auth.user.id : undefined} currentUserId={auth.user ? auth.user.id : undefined}
/> />
</Fade> </Fade>
{isEditing &&
!isTemplate &&
isNew && (
<Action>
<TemplatesMenu document={document} />
</Action>
)}
{!isEditing && {!isEditing &&
canShareDocuments && ( canShareDocuments && (
<Action> <Action>
@@ -245,31 +263,10 @@ class Header extends React.Component<Props> {
</Action> </Action>
</React.Fragment> </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 && ( {canEdit && (
<Action> <Action>
<Tooltip <Tooltip
tooltip="Edit document" tooltip={`Edit ${document.noun}`}
shortcut="e" shortcut="e"
delay={500} delay={500}
placement="bottom" placement="bottom"
@@ -305,6 +302,42 @@ class Header extends React.Component<Props> {
/> />
</Action> </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 && ( {!isEditing && (
<React.Fragment> <React.Fragment>
<Separator /> <Separator />

View File

@@ -50,14 +50,16 @@ class DocumentDelete extends React.Component<Props> {
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<HelpText> <HelpText>
Are you sure about that? Deleting the{" "} Are you sure about that? Deleting the{" "}
<strong>{document.title}</strong> document will delete all of its <strong>{document.titleWithDefault}</strong> {document.noun} will
history, and any nested documents. delete all of its history{document.isTemplate
? ""
: ", and any nested documents"}.
</HelpText> </HelpText>
{!document.isDraft && {!document.isDraft &&
!document.isArchived && ( !document.isArchived && (
<HelpText> <HelpText>
If youd like the option of referencing or restoring this If youd like the option of referencing or restoring this{" "}
document in the future, consider archiving it instead. {document.noun} in the future, consider archiving it instead.
</HelpText> </HelpText>
)} )}
<Button type="submit" danger> <Button type="submit" danger>

View File

@@ -1,13 +1,14 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import { inject } from "mobx-react"; import { inject } from "mobx-react";
import queryString from "query-string";
import type { RouterHistory, Location } from "react-router-dom"; import type { RouterHistory, Location } from "react-router-dom";
import Flex from "components/Flex"; import Flex from "components/Flex";
import CenteredContent from "components/CenteredContent"; import CenteredContent from "components/CenteredContent";
import LoadingPlaceholder from "components/LoadingPlaceholder"; import LoadingPlaceholder from "components/LoadingPlaceholder";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore"; import UiStore from "stores/UiStore";
import { documentEditUrl } from "utils/routeHelpers"; import { editDocumentUrl } from "utils/routeHelpers";
type Props = { type Props = {
history: RouterHistory, history: RouterHistory,
@@ -19,16 +20,18 @@ type Props = {
class DocumentNew extends React.Component<Props> { class DocumentNew extends React.Component<Props> {
async componentDidMount() { async componentDidMount() {
const params = queryString.parse(this.props.location.search);
try { try {
const document = await this.props.documents.create({ const document = await this.props.documents.create({
collectionId: this.props.match.params.id, collectionId: this.props.match.params.id,
parentDocumentId: new URLSearchParams(this.props.location.search).get( parentDocumentId: params.parentDocumentId,
"parentDocumentId" templateId: params.templateId,
), template: params.template,
title: "", title: "",
text: "", text: "",
}); });
this.props.history.replace(documentEditUrl(document)); this.props.history.replace(editDocumentUrl(document));
} catch (err) { } catch (err) {
this.props.ui.showToast("Couldnt create the document, try again?"); this.props.ui.showToast("Couldnt create the document, try again?");
this.props.history.goBack(); this.props.history.goBack();

View File

@@ -64,19 +64,21 @@ class DocumentShare extends React.Component<Props> {
const { document, policies, shares, onSubmit } = this.props; const { document, policies, shares, onSubmit } = this.props;
const share = shares.getByDocumentId(document.id); const share = shares.getByDocumentId(document.id);
const can = policies.abilities(share ? share.id : ""); const can = policies.abilities(share ? share.id : "");
const canPublish = can.update && !document.isTemplate;
return ( return (
<div> <div>
<HelpText> <HelpText>
The link below provides a read-only version of the document{" "} The link below provides a read-only version of the document{" "}
<strong>{document.title}</strong>.{" "} <strong>{document.titleWithDefault}</strong>.{" "}
{can.update && {canPublish
"You can optionally make it accessible to anyone with the link."}{" "} ? "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}> <Link to="/settings/shares" onClick={onSubmit}>
Manage all share links Manage all share links
</Link>. </Link>.
</HelpText> </HelpText>
{can.update && ( {canPublish && (
<React.Fragment> <React.Fragment>
<Switch <Switch
id="published" id="published"

View File

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

View File

@@ -36,7 +36,7 @@ const GroupMemberListItem = ({
"Never signed in" "Never signed in"
)} )}
{!user.lastActiveAt && <Badge>Invited</Badge>} {!user.lastActiveAt && <Badge>Invited</Badge>}
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</React.Fragment> </React.Fragment>
} }
image={<Avatar src={user.avatarUrl} size={40} />} image={<Avatar src={user.avatarUrl} size={40} />}

View File

@@ -29,7 +29,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
"Never signed in" "Never signed in"
)} )}
{!user.lastActiveAt && <Badge>Invited</Badge>} {!user.lastActiveAt && <Badge>Invited</Badge>}
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</React.Fragment> </React.Fragment>
} }
actions={ actions={

View File

@@ -143,10 +143,29 @@ const description = event => {
</React.Fragment> </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 ( return (
<React.Fragment> <React.Fragment>
{capitalize(event.verbPastTense)} the{" "} {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> </React.Fragment>
); );
} }

View File

@@ -58,7 +58,7 @@ class UserListItem extends React.Component<Props> {
) : ( ) : (
"Invited" "Invited"
)} )}
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>} {user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
{user.isSuspended && <Badge>Suspended</Badge>} {user.isSuspended && <Badge>Suspended</Badge>}
</React.Fragment> </React.Fragment>
} }

70
app/scenes/Templates.js Normal file
View File

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

View File

@@ -29,6 +29,7 @@ class Trash extends React.Component<Props> {
heading={<Subheading>Documents</Subheading>} heading={<Subheading>Documents</Subheading>}
empty={<Empty>Trash is empty at the moment.</Empty>} empty={<Empty>Trash is empty at the moment.</Empty>}
showCollection showCollection
showTemplate
/> />
</CenteredContent> </CenteredContent>
); );

View File

@@ -32,7 +32,10 @@ export default class DocumentsStore extends BaseStore<Document> {
@computed @computed
get all(): Document[] { get all(): Document[] {
return filter(this.orderedData, d => !d.archivedAt && !d.deletedAt); return filter(
this.orderedData,
d => !d.archivedAt && !d.deletedAt && !d.template
);
} }
@computed @computed
@@ -49,6 +52,17 @@ export default class DocumentsStore extends BaseStore<Document> {
return orderBy(this.all, "updatedAt", "desc"); 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[] { createdByUser(userId: string): Document[] {
return orderBy( return orderBy(
filter(this.all, d => d.createdBy.id === userId), 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); 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[] { pinnedInCollection(collectionId: string): Document[] {
return filter( return filter(
this.recentlyUpdatedInCollection(collectionId), this.recentlyUpdatedInCollection(collectionId),
@@ -100,9 +129,8 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.searchCache.get(query) || []; return this.searchCache.get(query) || [];
} }
@computed
get starred(): Document[] { get starred(): Document[] {
return filter(this.all, d => d.isStarred); return orderBy(filter(this.all, d => d.isStarred), "updatedAt", "desc");
} }
@computed @computed
@@ -126,6 +154,11 @@ export default class DocumentsStore extends BaseStore<Document> {
return naturalSort(this.starred, "title"); return naturalSort(this.starred, "title");
} }
@computed
get templatesAlphabetical(): Document[] {
return naturalSort(this.templates, "title");
}
@computed @computed
get drafts(): Document[] { get drafts(): Document[] {
return filter( return filter(
@@ -211,6 +244,11 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.fetchNamedPage("list", options); return this.fetchNamedPage("list", options);
}; };
@action
fetchTemplates = async (options: ?PaginationParams): Promise<*> => {
return this.fetchNamedPage("list", { ...options, template: true });
};
@action @action
fetchAlphabetical = async (options: ?PaginationParams): Promise<*> => { fetchAlphabetical = async (options: ?PaginationParams): Promise<*> => {
return this.fetchNamedPage("list", { 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 @action
fetch = async ( fetch = async (
id: string, id: string,
@@ -377,6 +433,7 @@ export default class DocumentsStore extends BaseStore<Document> {
publish: !!document.publishedAt, publish: !!document.publishedAt,
parentDocumentId: document.parentDocumentId, parentDocumentId: document.parentDocumentId,
collectionId: document.collectionId, collectionId: document.collectionId,
template: document.template,
title: `${document.title} (duplicate)`, title: `${document.title} (duplicate)`,
text: document.text, text: document.text,
}); });

View File

@@ -84,7 +84,12 @@ class UiStore {
setActiveDocument = (document: Document): void => { setActiveDocument = (document: Document): void => {
this.activeDocumentId = document.id; this.activeDocumentId = document.id;
if (document.publishedAt && !document.isArchived && !document.isDeleted) { if (
document.publishedAt &&
!document.isArchived &&
!document.isDeleted &&
!document.isTemplate
) {
this.activeCollectionId = document.collectionId; this.activeCollectionId = document.collectionId;
} }
}; };

View File

@@ -1,4 +1,5 @@
// @flow // @flow
import queryString from "query-string";
import Document from "models/Document"; import Document from "models/Document";
export function homeUrl(): string { export function homeUrl(): string {
@@ -23,7 +24,7 @@ export function documentUrl(doc: Document): string {
return doc.url; return doc.url;
} }
export function documentEditUrl(doc: Document): string { export function editDocumentUrl(doc: Document): string {
return `${doc.url}/edit`; return `${doc.url}/edit`;
} }
@@ -53,15 +54,13 @@ export function updateDocumentUrl(oldUrl: string, newUrl: string): string {
export function newDocumentUrl( export function newDocumentUrl(
collectionId: string, collectionId: string,
parentDocumentId?: string params?: {
): string { parentDocumentId?: string,
let route = `/collections/${collectionId}/new`; templateId?: string,
template?: boolean,
if (parentDocumentId) {
route += `?parentDocumentId=${parentDocumentId}`;
} }
): string {
return route; return `/collections/${collectionId}/new?${queryString.stringify(params)}`;
} }
export function searchUrl(query?: string, collectionId?: string): string { export function searchUrl(query?: string, collectionId?: string): string {

View File

@@ -121,7 +121,7 @@
"mobx-react": "^5.4.2", "mobx-react": "^5.4.2",
"natural-sort": "^1.0.0", "natural-sort": "^1.0.0",
"nodemailer": "^4.4.0", "nodemailer": "^4.4.0",
"outline-icons": "^1.20.0", "outline-icons": "^1.21.0-6",
"oy-vey": "^0.10.0", "oy-vey": "^0.10.0",
"pg": "^6.1.5", "pg": "^6.1.5",
"pg-hstore": "2.3.2", "pg-hstore": "2.3.2",
@@ -141,7 +141,7 @@
"react-portal": "^4.0.0", "react-portal": "^4.0.0",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-waypoint": "^9.0.2", "react-waypoint": "^9.0.2",
"rich-markdown-editor": "^10.4.0", "rich-markdown-editor": "^10.5.0-1",
"semver": "^7.3.2", "semver": "^7.3.2",
"sequelize": "^5.21.1", "sequelize": "^5.21.1",
"sequelize-cli": "^5.5.0", "sequelize-cli": "^5.5.0",

View File

@@ -29,7 +29,12 @@ const { authorize, cannot } = policy;
const router = new Router(); const router = new Router();
router.post("documents.list", auth(), pagination(), async ctx => { 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 // collection and user are here for backwards compatibility
const collectionId = ctx.body.collectionId || ctx.body.collection; 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; const user = ctx.state.user;
let where = { teamId: user.teamId }; 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 // 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 // exist in the team then nothing will be returned, so no need to check auth
if (createdById) { if (createdById) {
@@ -682,6 +691,8 @@ router.post("documents.create", auth(), async ctx => {
publish, publish,
collectionId, collectionId,
parentDocumentId, parentDocumentId,
templateId,
template,
index, index,
} = ctx.body; } = ctx.body;
const editorVersion = ctx.headers["x-editor-version"]; const editorVersion = ctx.headers["x-editor-version"];
@@ -717,6 +728,12 @@ router.post("documents.create", auth(), async ctx => {
authorize(user, "read", parentDocument, { collection }); 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({ let document = await Document.create({
parentDocumentId, parentDocumentId,
editorVersion, editorVersion,
@@ -725,8 +742,10 @@ router.post("documents.create", auth(), async ctx => {
userId: user.id, userId: user.id,
lastModifiedById: user.id, lastModifiedById: user.id,
createdById: user.id, createdById: user.id,
title, template,
text, templateId: templateDocument ? templateDocument.id : undefined,
title: templateDocument ? templateDocument.title : title,
text: templateDocument ? templateDocument.text : text,
}); });
await Event.create({ await Event.create({
@@ -735,7 +754,7 @@ router.post("documents.create", auth(), async ctx => {
collectionId: document.collectionId, collectionId: document.collectionId,
teamId: document.teamId, teamId: document.teamId,
actorId: user.id, actorId: user.id,
data: { title: document.title }, data: { title: document.title, templateId },
ip: ctx.request.ip, 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 => { router.post("documents.update", auth(), async ctx => {
const { const {
id, id,
@@ -776,6 +835,7 @@ router.post("documents.update", auth(), async ctx => {
autosave, autosave,
done, done,
lastRevision, lastRevision,
templateId,
append, append,
} = ctx.body; } = ctx.body;
const editorVersion = ctx.headers["x-editor-version"]; const editorVersion = ctx.headers["x-editor-version"];
@@ -795,6 +855,7 @@ router.post("documents.update", auth(), async ctx => {
// Update document // Update document
if (title) document.title = title; if (title) document.title = title;
if (editorVersion) document.editorVersion = editorVersion; if (editorVersion) document.editorVersion = editorVersion;
if (templateId) document.templateId = templateId;
if (append) { if (append) {
document.text += text; document.text += text;

View File

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

View File

@@ -98,6 +98,7 @@ const Document = sequelize.define(
}, },
}, },
version: DataTypes.SMALLINT, version: DataTypes.SMALLINT,
template: DataTypes.BOOLEAN,
editorVersion: DataTypes.STRING, editorVersion: DataTypes.STRING,
text: DataTypes.TEXT, text: DataTypes.TEXT,
@@ -142,6 +143,10 @@ Document.associate = models => {
as: "team", as: "team",
foreignKey: "teamId", foreignKey: "teamId",
}); });
Document.belongsTo(models.Document, {
as: "document",
foreignKey: "templateId",
});
Document.belongsTo(models.User, { Document.belongsTo(models.User, {
as: "createdBy", as: "createdBy",
foreignKey: "createdById", foreignKey: "createdById",
@@ -431,20 +436,28 @@ Document.searchForUser = async (
// Hooks // Hooks
Document.addHook("beforeSave", async model => { Document.addHook("beforeSave", async model => {
if (!model.publishedAt) return; if (!model.publishedAt || model.template) {
return;
}
const collection = await Collection.findByPk(model.collectionId); const collection = await Collection.findByPk(model.collectionId);
if (!collection || collection.type !== "atlas") return; if (!collection || collection.type !== "atlas") {
return;
}
await collection.updateDocument(model); await collection.updateDocument(model);
model.collection = collection; model.collection = collection;
}); });
Document.addHook("afterCreate", async model => { Document.addHook("afterCreate", async model => {
if (!model.publishedAt) return; if (!model.publishedAt || model.template) {
return;
}
const collection = await Collection.findByPk(model.collectionId); const collection = await Collection.findByPk(model.collectionId);
if (!collection || collection.type !== "atlas") return; if (!collection || collection.type !== "atlas") {
return;
}
await collection.addDocumentToStructure(model); await collection.addDocumentToStructure(model);
model.collection = collection; model.collection = collection;

View File

@@ -71,6 +71,7 @@ Event.AUDIT_EVENTS = [
"users.suspend", "users.suspend",
"users.activate", "users.activate",
"users.delete", "users.delete",
"documents.create",
"documents.publish", "documents.publish",
"documents.update", "documents.update",
"documents.archive", "documents.archive",

View File

@@ -31,6 +31,7 @@ allow(User, ["share"], Document, (user, document) => {
allow(User, ["star", "unstar"], Document, (user, document) => { allow(User, ["star", "unstar"], Document, (user, document) => {
if (document.archivedAt) return false; if (document.archivedAt) return false;
if (document.deletedAt) return false; if (document.deletedAt) return false;
if (document.template) return false;
if (!document.publishedAt) return false; if (!document.publishedAt) return false;
invariant( invariant(
@@ -58,6 +59,7 @@ allow(User, "update", Document, (user, document) => {
allow(User, "createChildDocument", Document, (user, document) => { allow(User, "createChildDocument", Document, (user, document) => {
if (document.archivedAt) return false; if (document.archivedAt) return false;
if (document.archivedAt) return false; if (document.archivedAt) return false;
if (document.template) return false;
if (!document.publishedAt) return false; if (!document.publishedAt) return false;
invariant( invariant(
@@ -72,6 +74,7 @@ allow(User, "createChildDocument", Document, (user, document) => {
allow(User, ["move", "pin", "unpin"], Document, (user, document) => { allow(User, ["move", "pin", "unpin"], Document, (user, document) => {
if (document.archivedAt) return false; if (document.archivedAt) return false;
if (document.deletedAt) return false; if (document.deletedAt) return false;
if (document.template) return false;
if (!document.publishedAt) return false; if (!document.publishedAt) return false;
invariant( invariant(

View File

@@ -54,6 +54,8 @@ export default async function present(document: Document, options: ?Options) {
archivedAt: document.archivedAt, archivedAt: document.archivedAt,
deletedAt: document.deletedAt, deletedAt: document.deletedAt,
teamId: document.teamId, teamId: document.teamId,
template: document.template,
templateId: document.templateId,
collaborators: [], collaborators: [],
starred: document.starred ? !!document.starred.length : undefined, starred: document.starred ? !!document.starred.length : undefined,
revision: document.revisionCount, revision: document.revisionCount,

View File

@@ -112,7 +112,7 @@ export const light = {
text: colors.almostBlack, text: colors.almostBlack,
textSecondary: colors.slateDark, textSecondary: colors.slateDark,
textTertiary: colors.slate, textTertiary: colors.slate,
placeholder: "#B1BECC", placeholder: "#a2b2c3",
sidebarBackground: colors.warmGrey, sidebarBackground: colors.warmGrey,
sidebarItemBackground: colors.black10, sidebarItemBackground: colors.black10,

View File

@@ -7171,10 +7171,10 @@ osenv@^0.1.4:
os-homedir "^1.0.0" os-homedir "^1.0.0"
os-tmpdir "^1.0.0" os-tmpdir "^1.0.0"
outline-icons@^1.19.1, outline-icons@^1.20.0: outline-icons@^1.21.0-3, outline-icons@^1.21.0-6:
version "1.20.0" version "1.21.0-6"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.20.0.tgz#7d3814fade75ecd78492c9d9779183aed51502d8" resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.21.0-6.tgz#bbf63fe6cc88ca2fe391e6ba6cc8b62a948607e3"
integrity sha512-YZJyqxl47zgHS3QAsznP18TBOgM4UdbeKoU6Am+UlgqUdXZi5VC1NbR/NwLBflgOs490DjF2EVTcRbYbN2ZMLg== integrity sha512-iEVK2zTEZ3sLFLsko/V6z3AEiM2EAjEUyLIOzAT2cqRglIbaIWdyitotKVMb2hWZo66bSvHxA/Rdvv51sw5RhA==
oy-vey@^0.10.0: oy-vey@^0.10.0:
version "0.10.0" version "0.10.0"
@@ -8604,16 +8604,16 @@ retry-as-promised@^3.2.0:
dependencies: dependencies:
any-promise "^1.3.0" any-promise "^1.3.0"
rich-markdown-editor@^10.4.0: rich-markdown-editor@^10.5.0-1:
version "10.4.0" version "10.5.0-1"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-10.4.0.tgz#0a31401cf3f3356c0f365607fba0fd28a8b05da4" resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-10.5.0-1.tgz#07a06941c65542da18c8b90c5de292ee052bc8aa"
integrity sha512-vLghzLnat13TE7xbY2vOENoH3iifO8Z+J8FQ0W2SP4wcL6pqwgMvDSsmVKuEEbh5E9zRwAcsqFybH985Zqg1tQ== integrity sha512-ixMMHn395vmhBCSUOmN6qZrBgISukp/lR6qabuJK0CigBhXxKrFnyLjruZJummB/pzhJKwVJJYFYfnJwBRUj8g==
dependencies: dependencies:
copy-to-clipboard "^3.0.8" copy-to-clipboard "^3.0.8"
lodash "^4.17.11" lodash "^4.17.11"
markdown-it-container "^3.0.0" markdown-it-container "^3.0.0"
markdown-it-mark "^3.0.0" markdown-it-mark "^3.0.0"
outline-icons "^1.19.1" outline-icons "^1.21.0-3"
prismjs "^1.19.0" prismjs "^1.19.0"
prosemirror-commands "^1.1.4" prosemirror-commands "^1.1.4"
prosemirror-dropcursor "^1.3.2" prosemirror-dropcursor "^1.3.2"