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};
|
color: ${props => props.theme.text};
|
||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Separator = styled.div`
|
export const Separator = styled.div`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
<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)}>
|
<CollectionName to={collectionUrl(collection.id)}>
|
||||||
<CollectionIcon collection={collection} expanded />
|
<CollectionIcon collection={collection} expanded />
|
||||||
<span>{collection.name}</span>
|
<span>{collection.name}</span>
|
||||||
|
|||||||
@@ -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;"};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
<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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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} /> {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);
|
||||||
|
|||||||
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 { 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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
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.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};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 you’d like the option of referencing or restoring this
|
If you’d 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>
|
||||||
|
|||||||
@@ -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("Couldn’t create the document, try again?");
|
this.props.ui.showToast("Couldn’t create the document, try again?");
|
||||||
this.props.history.goBack();
|
this.props.history.goBack();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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"
|
"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} />}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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>}
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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,
|
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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
18
yarn.lock
18
yarn.lock
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user