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:
@@ -15,6 +15,10 @@ export const Action = styled(Flex)`
|
||||
color: ${props => props.theme.text};
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Separator = styled.div`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<Wrapper justify="flex-start" align="center">
|
||||
{isTemplate && (
|
||||
<React.Fragment>
|
||||
<CollectionName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
<span>Templates</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</React.Fragment>
|
||||
)}
|
||||
{isDraft && (
|
||||
<React.Fragment>
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
<span>Drafts</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</React.Fragment>
|
||||
)}
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
<span>{collection.name}</span>
|
||||
|
||||
@@ -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;"};
|
||||
|
||||
@@ -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\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>
|
||||
)}
|
||||
<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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @flow
|
||||
export { default as DropdownMenu } from "./DropdownMenu";
|
||||
export { default as DropdownMenu, Header } from "./DropdownMenu";
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -82,7 +82,7 @@ class AccountMenu extends React.Component<Props> {
|
||||
style={{
|
||||
left: 170,
|
||||
position: "relative",
|
||||
top: -34,
|
||||
top: -40,
|
||||
}}
|
||||
label={
|
||||
<DropdownMenuItem>
|
||||
|
||||
@@ -9,10 +9,13 @@ import UiStore from "stores/UiStore";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionStore from "stores/CollectionsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import Modal from "components/Modal";
|
||||
import DocumentDelete from "scenes/DocumentDelete";
|
||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||
import {
|
||||
documentUrl,
|
||||
documentMoveUrl,
|
||||
documentEditUrl,
|
||||
editDocumentUrl,
|
||||
documentHistoryUrl,
|
||||
newDocumentUrl,
|
||||
} from "utils/routeHelpers";
|
||||
@@ -37,6 +40,8 @@ type Props = {
|
||||
@observer
|
||||
class DocumentMenu extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
@observable showDeleteModal: boolean = false;
|
||||
@observable showTemplateModal: boolean = false;
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
@@ -44,12 +49,13 @@ class DocumentMenu extends React.Component<Props> {
|
||||
|
||||
handleNewChild = (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
handleDelete = (ev: SyntheticEvent<>) => {
|
||||
const { document } = this.props;
|
||||
this.props.ui.setActiveModal("document-delete", { document });
|
||||
this.showDeleteModal = true;
|
||||
};
|
||||
|
||||
handleDocumentHistory = () => {
|
||||
@@ -65,7 +71,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
handleEdit = (ev: SyntheticEvent<>) => {
|
||||
this.redirectTo = documentEditUrl(this.props.document);
|
||||
this.redirectTo = editDocumentUrl(this.props.document);
|
||||
};
|
||||
|
||||
handleDuplicate = async (ev: SyntheticEvent<>) => {
|
||||
@@ -76,6 +82,18 @@ class DocumentMenu extends React.Component<Props> {
|
||||
this.props.ui.showToast("Document duplicated");
|
||||
};
|
||||
|
||||
handleOpenTemplateModal = () => {
|
||||
this.showTemplateModal = true;
|
||||
};
|
||||
|
||||
handleCloseTemplateModal = () => {
|
||||
this.showTemplateModal = false;
|
||||
};
|
||||
|
||||
handleCloseDeleteModal = () => {
|
||||
this.showDeleteModal = false;
|
||||
};
|
||||
|
||||
handleArchive = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.archive();
|
||||
this.props.ui.showToast("Document archived");
|
||||
@@ -135,6 +153,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DropdownMenu
|
||||
className={className}
|
||||
position={position}
|
||||
@@ -190,14 +209,8 @@ class DocumentMenu extends React.Component<Props> {
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{canViewHistory && (
|
||||
<React.Fragment>
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
||||
Document history
|
||||
</DropdownMenuItem>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{!can.restore && <hr />}
|
||||
|
||||
{can.createChildDocument && (
|
||||
<DropdownMenuItem
|
||||
onClick={this.handleNewChild}
|
||||
@@ -206,6 +219,12 @@ class DocumentMenu extends React.Component<Props> {
|
||||
New nested document
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.update &&
|
||||
!document.isTemplate && (
|
||||
<DropdownMenuItem onClick={this.handleOpenTemplateModal}>
|
||||
Create template…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.update && (
|
||||
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
|
||||
)}
|
||||
@@ -228,6 +247,13 @@ class DocumentMenu extends React.Component<Props> {
|
||||
<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
|
||||
@@ -237,6 +263,27 @@ class DocumentMenu extends React.Component<Props> {
|
||||
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
<Modal
|
||||
title={`Delete ${this.props.document.noun}`}
|
||||
onRequestClose={this.handleCloseDeleteModal}
|
||||
isOpen={this.showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={this.props.document}
|
||||
onSubmit={this.handleCloseDeleteModal}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Create template"
|
||||
onRequestClose={this.handleCloseTemplateModal}
|
||||
isOpen={this.showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={this.props.document}
|
||||
onSubmit={this.handleCloseTemplateModal}
|
||||
/>
|
||||
</Modal>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ class NewChildDocumentMenu extends React.Component<Props> {
|
||||
|
||||
handleNewChild = () => {
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
parentDocumentId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -7,13 +7,19 @@ import { PlusIcon } from "outline-icons";
|
||||
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
Header,
|
||||
} from "components/DropdownMenu";
|
||||
import Button from "components/Button";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
documents: DocumentsStore,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
};
|
||||
@@ -26,8 +32,8 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewDocument = (collectionId: string) => {
|
||||
this.redirectTo = newDocumentUrl(collectionId);
|
||||
handleNewDocument = (collectionId: string, options) => {
|
||||
this.redirectTo = newDocumentUrl(collectionId, options);
|
||||
};
|
||||
|
||||
onOpen = () => {
|
||||
@@ -41,21 +47,22 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { collections, policies, label, ...rest } = this.props;
|
||||
const { collections, documents, policies, label, ...rest } = this.props;
|
||||
const singleCollection = collections.orderedData.length === 1;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
label || (
|
||||
<Button icon={<PlusIcon />} small>
|
||||
New doc
|
||||
New doc{singleCollection ? "" : "…"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
onOpen={this.onOpen}
|
||||
{...rest}
|
||||
>
|
||||
<DropdownMenuItem disabled>Choose a collection…</DropdownMenuItem>
|
||||
<Header>Choose a collection</Header>
|
||||
{collections.orderedData.map(collection => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
@@ -65,7 +72,7 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
onClick={() => this.handleNewDocument(collection.id)}
|
||||
disabled={!can.update}
|
||||
>
|
||||
<CollectionIcon collection={collection} /> {collection.name}
|
||||
<CollectionIcon collection={collection} /> {collection.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
@@ -74,4 +81,4 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("collections", "policies")(NewDocumentMenu);
|
||||
export default inject("collections", "documents", "policies")(NewDocumentMenu);
|
||||
|
||||
74
app/menus/NewTemplateMenu.js
Normal file
74
app/menus/NewTemplateMenu.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
Header,
|
||||
} from "components/DropdownMenu";
|
||||
import Button from "components/Button";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
collections: CollectionsStore,
|
||||
policies: PoliciesStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class NewTemplateMenu extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
componentDidUpdate() {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
|
||||
handleNewDocument = (collectionId: string) => {
|
||||
this.redirectTo = newDocumentUrl(collectionId, {
|
||||
template: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { collections, policies, label, ...rest } = this.props;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
label={
|
||||
label || (
|
||||
<Button icon={<PlusIcon />} small>
|
||||
New template…
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
<Header>Choose a collection</Header>
|
||||
{collections.orderedData.map(collection => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={collection.id}
|
||||
onClick={() => this.handleNewDocument(collection.id)}
|
||||
disabled={!can.update}
|
||||
>
|
||||
<CollectionIcon collection={collection} /> {collection.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default inject("collections", "policies")(NewTemplateMenu);
|
||||
58
app/menus/TemplatesMenu.js
Normal file
58
app/menus/TemplatesMenu.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import styled from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
|
||||
type Props = {
|
||||
document: Document,
|
||||
documents: DocumentsStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
class TemplatesMenu extends React.Component<Props> {
|
||||
render() {
|
||||
const { documents, document, ...rest } = this.props;
|
||||
const templates = documents.templatesInCollection(document.collectionId);
|
||||
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
position="left"
|
||||
label={
|
||||
<Button disclosure neutral>
|
||||
Templates
|
||||
</Button>
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{templates.map(template => (
|
||||
<DropdownMenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
>
|
||||
<DocumentIcon />
|
||||
<div>
|
||||
<strong>{template.titleWithDefault}</strong>
|
||||
<br />
|
||||
<Author>By {template.createdBy.name}</Author>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Author = styled.div`
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
export default inject("documents")(TemplatesMenu);
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class Event extends BaseModel {
|
||||
email: string,
|
||||
title: string,
|
||||
published: boolean,
|
||||
templateId: string,
|
||||
};
|
||||
|
||||
get model() {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
61
app/scenes/DocumentTemplatize.js
Normal file
61
app/scenes/DocumentTemplatize.js
Normal 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));
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}</Link> document
|
||||
<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 || "Untitled"}
|
||||
</Link>{" "}
|
||||
document
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
70
app/scenes/Templates.js
Normal file
70
app/scenes/Templates.js
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
server/migrations/20200727051157-add-templates.js
Normal file
20
server/migrations/20200727051157-add-templates.js
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -71,6 +71,7 @@ Event.AUDIT_EVENTS = [
|
||||
"users.suspend",
|
||||
"users.activate",
|
||||
"users.delete",
|
||||
"documents.create",
|
||||
"documents.publish",
|
||||
"documents.update",
|
||||
"documents.archive",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
18
yarn.lock
18
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"
|
||||
|
||||
Reference in New Issue
Block a user