feat: Templates (#1399)

* Migrations
* New from template
* fix: Don't allow public share of template
* chore: Template badges
* fix: Collection active
* feat: New doc button on template list item
* feat: New template menu
* fix: Sorting
* feat: Templates onboarding notice
* fix: New doc button showing on archived/deleted templates
This commit is contained in:
Tom Moor
2020-08-08 15:18:37 -07:00
committed by GitHub
parent 59c24aba7c
commit 869fc086d6
51 changed files with 1007 additions and 327 deletions

View File

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

View File

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

View File

@@ -4,7 +4,13 @@ import { observer, inject } from "mobx-react";
import breakpoint from "styled-components-breakpoint";
import styled from "styled-components";
import { Link } from "react-router-dom";
import { PadlockIcon, GoToIcon, MoreIcon } from "outline-icons";
import {
PadlockIcon,
GoToIcon,
MoreIcon,
ShapesIcon,
EditIcon,
} from "outline-icons";
import Document from "models/Document";
import CollectionsStore from "stores/CollectionsStore";
@@ -44,12 +50,32 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
);
}
const isTemplate = document.isTemplate;
const isDraft = !document.publishedAt && !isTemplate;
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Wrapper justify="flex-start" align="center">
{isTemplate && (
<React.Fragment>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</React.Fragment>
)}
{isDraft && (
<React.Fragment>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</React.Fragment>
)}
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />&nbsp;
<span>{collection.name}</span>

View File

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

View File

@@ -2,15 +2,17 @@
import * as React from "react";
import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { StarredIcon } from "outline-icons";
import { StarredIcon, PlusIcon } from "outline-icons";
import styled, { withTheme } from "styled-components";
import Flex from "components/Flex";
import Badge from "components/Badge";
import Button from "components/Button";
import Tooltip from "components/Tooltip";
import Highlight from "components/Highlight";
import PublishingInfo from "components/PublishingInfo";
import DocumentMenu from "menus/DocumentMenu";
import Document from "models/Document";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
document: Document,
@@ -20,8 +22,117 @@ type Props = {
showPublished?: boolean,
showPin?: boolean,
showDraft?: boolean,
showTemplate?: boolean,
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@observer
class DocumentPreview extends React.Component<Props> {
handleStar = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.props.document.star();
};
handleUnstar = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.props.document.unstar();
};
replaceResultMarks = (tag: string) => {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
};
render() {
const {
document,
showCollection,
showPublished,
showPin,
showDraft = true,
showTemplate,
highlight,
context,
...rest
} = this.props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
return (
<DocumentLink
to={{
pathname: document.url,
state: { title: document.titleWithDefault },
}}
{...rest}
>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{!document.isDraft &&
!document.isArchived &&
!document.isTemplate && (
<Actions>
{document.isStarred ? (
<StyledStar onClick={this.handleUnstar} solid />
) : (
<StyledStar onClick={this.handleStar} />
)}
</Actions>
)}
{document.isDraft &&
showDraft && (
<Tooltip
tooltip="Only visible to you"
delay={500}
placement="top"
>
<Badge>Draft</Badge>
</Tooltip>
)}
{document.isTemplate &&
showTemplate && <Badge primary>Template</Badge>}
<SecondaryActions>
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted && (
<Button
as={Link}
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
New doc
</Button>
)}&nbsp;
<DocumentMenu document={document} showPin={showPin} />
</SecondaryActions>
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={this.replaceResultMarks}
/>
)}
<PublishingInfo
document={document}
showCollection={showCollection}
showPublished={showPublished}
/>
</DocumentLink>
);
}
}
const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
<StarredIcon color={theme.text} {...props} />
))`
@@ -37,7 +148,8 @@ const StyledStar = withTheme(styled(({ solid, theme, ...props }) => (
}
`);
const StyledDocumentMenu = styled(DocumentMenu)`
const SecondaryActions = styled(Flex)`
align-items: center;
position: absolute;
right: 16px;
top: 50%;
@@ -54,7 +166,7 @@ const DocumentLink = styled(Link)`
overflow: hidden;
position: relative;
${StyledDocumentMenu} {
${SecondaryActions} {
opacity: 0;
}
@@ -64,7 +176,11 @@ const DocumentLink = styled(Link)`
background: ${props => props.theme.listItemHoverBackground};
outline: none;
${StyledStar}, ${StyledDocumentMenu} {
${SecondaryActions} {
opacity: 1;
}
${StyledStar} {
opacity: 0.5;
&:hover {
@@ -106,91 +222,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@observer
class DocumentPreview extends React.Component<Props> {
star = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.props.document.star();
};
unstar = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.props.document.unstar();
};
replaceResultMarks = (tag: string) => {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
};
render() {
const {
document,
showCollection,
showPublished,
showPin,
showDraft = true,
highlight,
context,
...rest
} = this.props;
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
return (
<DocumentLink
to={{
pathname: document.url,
state: { title: document.title },
}}
{...rest}
>
<Heading>
<Title text={document.title || "Untitled"} highlight={highlight} />
{!document.isDraft &&
!document.isArchived && (
<Actions>
{document.isStarred ? (
<StyledStar onClick={this.unstar} solid />
) : (
<StyledStar onClick={this.star} />
)}
</Actions>
)}
{document.isDraft &&
showDraft && (
<Tooltip
tooltip="Only visible to you"
delay={500}
placement="top"
>
<Badge>Draft</Badge>
</Tooltip>
)}
<StyledDocumentMenu document={document} showPin={showPin} />
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={this.replaceResultMarks}
/>
)}
<PublishingInfo
document={document}
showCollection={showCollection}
showPublished={showPublished}
/>
</DocumentLink>
);
}
}
export default DocumentPreview;

View File

@@ -274,4 +274,13 @@ const Menu = styled.div`
}
`;
export const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${props => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default DropdownMenu;

View File

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

View File

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

View File

@@ -1,20 +1,8 @@
// @flow
import * as React from "react";
import styled from "styled-components";
type Props = {
children: React.Node,
};
const Empty = (props: Props) => {
const { children, ...rest } = props;
return <Container {...rest}>{children}</Container>;
};
const Container = styled.div`
display: flex;
color: ${props => props.theme.slate};
text-align: center;
const Empty = styled.p`
color: ${props => props.theme.textTertiary};
`;
export default Empty;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,10 +9,13 @@ import UiStore from "stores/UiStore";
import AuthStore from "stores/AuthStore";
import CollectionStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import Modal from "components/Modal";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import {
documentUrl,
documentMoveUrl,
documentEditUrl,
editDocumentUrl,
documentHistoryUrl,
newDocumentUrl,
} from "utils/routeHelpers";
@@ -37,6 +40,8 @@ type Props = {
@observer
class DocumentMenu extends React.Component<Props> {
@observable redirectTo: ?string;
@observable showDeleteModal: boolean = false;
@observable showTemplateModal: boolean = false;
componentDidUpdate() {
this.redirectTo = undefined;
@@ -44,12 +49,13 @@ class DocumentMenu extends React.Component<Props> {
handleNewChild = (ev: SyntheticEvent<>) => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
this.redirectTo = newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
});
};
handleDelete = (ev: SyntheticEvent<>) => {
const { document } = this.props;
this.props.ui.setActiveModal("document-delete", { document });
this.showDeleteModal = true;
};
handleDocumentHistory = () => {
@@ -65,7 +71,7 @@ class DocumentMenu extends React.Component<Props> {
};
handleEdit = (ev: SyntheticEvent<>) => {
this.redirectTo = documentEditUrl(this.props.document);
this.redirectTo = editDocumentUrl(this.props.document);
};
handleDuplicate = async (ev: SyntheticEvent<>) => {
@@ -76,6 +82,18 @@ class DocumentMenu extends React.Component<Props> {
this.props.ui.showToast("Document duplicated");
};
handleOpenTemplateModal = () => {
this.showTemplateModal = true;
};
handleCloseTemplateModal = () => {
this.showTemplateModal = false;
};
handleCloseDeleteModal = () => {
this.showDeleteModal = false;
};
handleArchive = async (ev: SyntheticEvent<>) => {
await this.props.document.archive();
this.props.ui.showToast("Document archived");
@@ -135,6 +153,7 @@ class DocumentMenu extends React.Component<Props> {
const canViewHistory = can.read && !can.restore;
return (
<React.Fragment>
<DropdownMenu
className={className}
position={position}
@@ -190,14 +209,8 @@ class DocumentMenu extends React.Component<Props> {
)}
</React.Fragment>
)}
{canViewHistory && (
<React.Fragment>
<hr />
<DropdownMenuItem onClick={this.handleDocumentHistory}>
Document history
</DropdownMenuItem>
</React.Fragment>
)}
{!can.restore && <hr />}
{can.createChildDocument && (
<DropdownMenuItem
onClick={this.handleNewChild}
@@ -206,6 +219,12 @@ class DocumentMenu extends React.Component<Props> {
New nested document
</DropdownMenuItem>
)}
{can.update &&
!document.isTemplate && (
<DropdownMenuItem onClick={this.handleOpenTemplateModal}>
Create template
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
)}
@@ -228,6 +247,13 @@ class DocumentMenu extends React.Component<Props> {
<DropdownMenuItem onClick={this.handleMove}>Move</DropdownMenuItem>
)}
<hr />
{canViewHistory && (
<React.Fragment>
<DropdownMenuItem onClick={this.handleDocumentHistory}>
History
</DropdownMenuItem>
</React.Fragment>
)}
{can.download && (
<DropdownMenuItem onClick={this.handleExport}>
Download
@@ -237,6 +263,27 @@ class DocumentMenu extends React.Component<Props> {
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
)}
</DropdownMenu>
<Modal
title={`Delete ${this.props.document.noun}`}
onRequestClose={this.handleCloseDeleteModal}
isOpen={this.showDeleteModal}
>
<DocumentDelete
document={this.props.document}
onSubmit={this.handleCloseDeleteModal}
/>
</Modal>
<Modal
title="Create template"
onRequestClose={this.handleCloseTemplateModal}
isOpen={this.showTemplateModal}
>
<DocumentTemplatize
document={this.props.document}
onSubmit={this.handleCloseTemplateModal}
/>
</Modal>
</React.Fragment>
);
}
}

View File

@@ -31,7 +31,9 @@ class NewChildDocumentMenu extends React.Component<Props> {
handleNewChild = () => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
this.redirectTo = newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
});
};
render() {

View File

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

View File

@@ -0,0 +1,74 @@
// @flow
import * as React from "react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { Redirect } from "react-router-dom";
import { PlusIcon } from "outline-icons";
import { newDocumentUrl } from "utils/routeHelpers";
import CollectionsStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
type Props = {
label?: React.Node,
collections: CollectionsStore,
policies: PoliciesStore,
};
@observer
class NewTemplateMenu extends React.Component<Props> {
@observable redirectTo: ?string;
componentDidUpdate() {
this.redirectTo = undefined;
}
handleNewDocument = (collectionId: string) => {
this.redirectTo = newDocumentUrl(collectionId, {
template: true,
});
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { collections, policies, label, ...rest } = this.props;
return (
<DropdownMenu
label={
label || (
<Button icon={<PlusIcon />} small>
New template
</Button>
)
}
{...rest}
>
<Header>Choose a collection</Header>
{collections.orderedData.map(collection => {
const can = policies.abilities(collection.id);
return (
<DropdownMenuItem
key={collection.id}
onClick={() => this.handleNewDocument(collection.id)}
disabled={!can.update}
>
<CollectionIcon collection={collection} />&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
}
export default inject("collections", "policies")(NewTemplateMenu);

View File

@@ -0,0 +1,58 @@
// @flow
import * as React from "react";
import { observer, inject } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import styled from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import Document from "models/Document";
import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
type Props = {
document: Document,
documents: DocumentsStore,
};
@observer
class TemplatesMenu extends React.Component<Props> {
render() {
const { documents, document, ...rest } = this.props;
const templates = documents.templatesInCollection(document.collectionId);
if (!templates.length) {
return null;
}
return (
<DropdownMenu
position="left"
label={
<Button disclosure neutral>
Templates
</Button>
}
{...rest}
>
{templates.map(template => (
<DropdownMenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
>
<DocumentIcon />
<div>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>By {template.createdBy.name}</Author>
</div>
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
}
const Author = styled.div`
font-size: 13px;
`;
export default inject("documents")(TemplatesMenu);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,11 +15,12 @@ import {
import { transparentize, darken } from "polished";
import Document from "models/Document";
import AuthStore from "stores/AuthStore";
import { documentEditUrl } from "utils/routeHelpers";
import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers";
import { meta } from "utils/keyboard";
import Flex from "components/Flex";
import Breadcrumb, { Slash } from "components/Breadcrumb";
import TemplatesMenu from "menus/TemplatesMenu";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import DocumentShare from "scenes/DocumentShare";
@@ -76,7 +77,15 @@ class Header extends React.Component<Props> {
handleScroll = throttle(this.updateIsScrolled, 50);
handleEdit = () => {
this.redirectTo = documentEditUrl(this.props.document);
this.redirectTo = editDocumentUrl(this.props.document);
};
handleNewFromTemplate = () => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
templateId: document.id,
});
};
handleSave = () => {
@@ -125,6 +134,8 @@ class Header extends React.Component<Props> {
const share = shares.getByDocumentId(document.id);
const isPubliclyShared = share && share.published;
const isNew = document.isNew;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canShareDocuments = auth.team && auth.team.sharing && can.share;
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
@@ -196,6 +207,13 @@ class Header extends React.Component<Props> {
currentUserId={auth.user ? auth.user.id : undefined}
/>
</Fade>
{isEditing &&
!isTemplate &&
isNew && (
<Action>
<TemplatesMenu document={document} />
</Action>
)}
{!isEditing &&
canShareDocuments && (
<Action>
@@ -245,31 +263,10 @@ class Header extends React.Component<Props> {
</Action>
</React.Fragment>
)}
{can.update &&
isDraft &&
!isRevision && (
<Action>
<Tooltip
tooltip="Publish"
shortcut={`${meta}+shift+p`}
delay={500}
placement="bottom"
>
<Button
onClick={this.handlePublish}
title="Publish document"
disabled={publishingIsDisabled}
small
>
{isPublishing ? "Publishing…" : "Publish"}
</Button>
</Tooltip>
</Action>
)}
{canEdit && (
<Action>
<Tooltip
tooltip="Edit document"
tooltip={`Edit ${document.noun}`}
shortcut="e"
delay={500}
placement="bottom"
@@ -305,6 +302,42 @@ class Header extends React.Component<Props> {
/>
</Action>
)}
{canEdit &&
isTemplate &&
!isDraft &&
!isRevision && (
<Action>
<Button
icon={<PlusIcon />}
onClick={this.handleNewFromTemplate}
primary
small
>
New from template
</Button>
</Action>
)}
{can.update &&
isDraft &&
!isRevision && (
<Action>
<Tooltip
tooltip="Publish"
shortcut={`${meta}+shift+p`}
delay={500}
placement="bottom"
>
<Button
onClick={this.handlePublish}
title="Publish document"
disabled={publishingIsDisabled}
small
>
{isPublishing ? "Publishing…" : "Publish"}
</Button>
</Tooltip>
</Action>
)}
{!isEditing && (
<React.Fragment>
<Separator />

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
// @flow
import * as React from "react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { withRouter, type RouterHistory } from "react-router-dom";
import { documentUrl } from "utils/routeHelpers";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Document from "models/Document";
import UiStore from "stores/UiStore";
type Props = {
ui: UiStore,
document: Document,
history: RouterHistory,
onSubmit: () => void,
};
@observer
class DocumentTemplatize extends React.Component<Props> {
@observable isSaving: boolean;
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
try {
const template = await this.props.document.templatize();
this.props.history.push(documentUrl(template));
this.props.ui.showToast("Template created, go ahead and customize it");
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message);
} finally {
this.isSaving = false;
}
};
render() {
const { document } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Creating a template from{" "}
<strong>{document.titleWithDefault}</strong> is a non-destructive
action we'll make a copy of the document and turn it into a
template that can be used as a starting point for new documents.
</HelpText>
<Button type="submit">
{this.isSaving ? "Creating…" : "Create template"}
</Button>
</form>
</Flex>
);
}
}
export default inject("ui")(withRouter(DocumentTemplatize));

View File

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

View File

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

View File

@@ -143,10 +143,29 @@ const description = event => {
</React.Fragment>
);
}
if (event.name === "documents.create") {
return (
<React.Fragment>
{capitalize(event.verbPastTense)} the{" "}
<Link to={`/doc/${event.documentId}`}>{event.data.title}</Link> document
<Link to={`/doc/${event.documentId}`}>
{event.data.title || "Untitled"}
</Link>{" "}
document{" "}
{event.data.templateId && (
<React.Fragment>
from a <Link to={`/doc/${event.data.templateId}`}>template</Link>
</React.Fragment>
)}
</React.Fragment>
);
}
return (
<React.Fragment>
{capitalize(event.verbPastTense)} the{" "}
<Link to={`/doc/${event.documentId}`}>
{event.data.title || "Untitled"}
</Link>{" "}
document
</React.Fragment>
);
}

View File

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

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

@@ -0,0 +1,70 @@
// @flow
import * as React from "react";
import { observer, inject } from "mobx-react";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
import PageTitle from "components/PageTitle";
import Heading from "components/Heading";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import Tabs from "components/Tabs";
import Tab from "components/Tab";
import NewTemplateMenu from "menus/NewTemplateMenu";
import Actions, { Action } from "components/Actions";
import DocumentsStore from "stores/DocumentsStore";
type Props = {
documents: DocumentsStore,
match: Object,
};
@observer
class Templates extends React.Component<Props> {
render() {
const {
fetchTemplates,
templates,
templatesAlphabetical,
} = this.props.documents;
const { sort } = this.props.match.params;
return (
<CenteredContent column auto>
<PageTitle title="Templates" />
<Heading>Templates</Heading>
<PaginatedDocumentList
heading={
<Tabs>
<Tab to="/templates" exact>
Recently Updated
</Tab>
<Tab to="/templates/alphabetical" exact>
Alphabetical
</Tab>
</Tabs>
}
empty={
<Empty>
There are no templates just yet. You can create templates to help
your team create consistent and accurate documentation.
</Empty>
}
fetch={fetchTemplates}
documents={
sort === "alphabetical" ? templatesAlphabetical : templates
}
showCollection
showDraft
/>
<Actions align="center" justify="flex-end">
<Action>
<NewTemplateMenu />
</Action>
</Actions>
</CenteredContent>
);
}
}
export default inject("documents")(Templates);

View File

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

View File

@@ -32,7 +32,10 @@ export default class DocumentsStore extends BaseStore<Document> {
@computed
get all(): Document[] {
return filter(this.orderedData, d => !d.archivedAt && !d.deletedAt);
return filter(
this.orderedData,
d => !d.archivedAt && !d.deletedAt && !d.template
);
}
@computed
@@ -49,6 +52,17 @@ export default class DocumentsStore extends BaseStore<Document> {
return orderBy(this.all, "updatedAt", "desc");
}
get templates(): Document[] {
return orderBy(
filter(
this.orderedData,
d => !d.archivedAt && !d.deletedAt && d.template
),
"updatedAt",
"desc"
);
}
createdByUser(userId: string): Document[] {
return orderBy(
filter(this.all, d => d.createdBy.id === userId),
@@ -61,6 +75,21 @@ export default class DocumentsStore extends BaseStore<Document> {
return filter(this.all, document => document.collectionId === collectionId);
}
templatesInCollection(collectionId: string): Document[] {
return orderBy(
filter(
this.orderedData,
d =>
!d.archivedAt &&
!d.deletedAt &&
d.template === true &&
d.collectionId === collectionId
),
"updatedAt",
"desc"
);
}
pinnedInCollection(collectionId: string): Document[] {
return filter(
this.recentlyUpdatedInCollection(collectionId),
@@ -100,9 +129,8 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.searchCache.get(query) || [];
}
@computed
get starred(): Document[] {
return filter(this.all, d => d.isStarred);
return orderBy(filter(this.all, d => d.isStarred), "updatedAt", "desc");
}
@computed
@@ -126,6 +154,11 @@ export default class DocumentsStore extends BaseStore<Document> {
return naturalSort(this.starred, "title");
}
@computed
get templatesAlphabetical(): Document[] {
return naturalSort(this.templates, "title");
}
@computed
get drafts(): Document[] {
return filter(
@@ -211,6 +244,11 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.fetchNamedPage("list", options);
};
@action
fetchTemplates = async (options: ?PaginationParams): Promise<*> => {
return this.fetchNamedPage("list", { ...options, template: true });
};
@action
fetchAlphabetical = async (options: ?PaginationParams): Promise<*> => {
return this.fetchNamedPage("list", {
@@ -321,6 +359,24 @@ export default class DocumentsStore extends BaseStore<Document> {
}
};
@action
templatize = async (id: string): Promise<?Document> => {
const doc: ?Document = this.data.get(id);
invariant(doc, "Document should exist");
if (doc.template) {
return;
}
const res = await client.post("/documents.templatize", { id });
invariant(res && res.data, "Document not available");
this.addPolicies(res.policies);
this.add(res.data);
return this.data.get(res.data.id);
};
@action
fetch = async (
id: string,
@@ -377,6 +433,7 @@ export default class DocumentsStore extends BaseStore<Document> {
publish: !!document.publishedAt,
parentDocumentId: document.parentDocumentId,
collectionId: document.collectionId,
template: document.template,
title: `${document.title} (duplicate)`,
text: document.text,
});

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,12 @@ const { authorize, cannot } = policy;
const router = new Router();
router.post("documents.list", auth(), pagination(), async ctx => {
const { sort = "updatedAt", backlinkDocumentId, parentDocumentId } = ctx.body;
const {
sort = "updatedAt",
template,
backlinkDocumentId,
parentDocumentId,
} = ctx.body;
// collection and user are here for backwards compatibility
const collectionId = ctx.body.collectionId || ctx.body.collection;
@@ -41,6 +46,10 @@ router.post("documents.list", auth(), pagination(), async ctx => {
const user = ctx.state.user;
let where = { teamId: user.teamId };
if (template) {
where = { ...where, template: true };
}
// if a specific user is passed then add to filters. If the user doesn't
// exist in the team then nothing will be returned, so no need to check auth
if (createdById) {
@@ -682,6 +691,8 @@ router.post("documents.create", auth(), async ctx => {
publish,
collectionId,
parentDocumentId,
templateId,
template,
index,
} = ctx.body;
const editorVersion = ctx.headers["x-editor-version"];
@@ -717,6 +728,12 @@ router.post("documents.create", auth(), async ctx => {
authorize(user, "read", parentDocument, { collection });
}
let templateDocument;
if (templateId) {
templateDocument = await Document.findByPk(templateId, { userId: user.id });
authorize(user, "read", templateDocument);
}
let document = await Document.create({
parentDocumentId,
editorVersion,
@@ -725,8 +742,10 @@ router.post("documents.create", auth(), async ctx => {
userId: user.id,
lastModifiedById: user.id,
createdById: user.id,
title,
text,
template,
templateId: templateDocument ? templateDocument.id : undefined,
title: templateDocument ? templateDocument.title : title,
text: templateDocument ? templateDocument.text : text,
});
await Event.create({
@@ -735,7 +754,7 @@ router.post("documents.create", auth(), async ctx => {
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
data: { title: document.title, templateId },
ip: ctx.request.ip,
});
@@ -767,6 +786,46 @@ router.post("documents.create", auth(), async ctx => {
};
});
router.post("documents.templatize", auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, "id is required");
const user = ctx.state.user;
const original = await Document.findByPk(id, { userId: user.id });
authorize(user, "update", original);
let document = await Document.create({
editorVersion: original.editorVersion,
collectionId: original.collectionId,
teamId: original.teamId,
userId: user.id,
publishedAt: new Date(),
lastModifiedById: user.id,
createdById: user.id,
template: true,
title: original.title,
text: original.text,
});
await Event.create({
name: "documents.create",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title, template: true },
ip: ctx.request.ip,
});
// reload to get all of the data needed to present (user, collection etc)
document = await Document.findByPk(document.id, { userId: user.id });
ctx.body = {
data: await presentDocument(document),
policies: presentPolicies(user, [document]),
};
});
router.post("documents.update", auth(), async ctx => {
const {
id,
@@ -776,6 +835,7 @@ router.post("documents.update", auth(), async ctx => {
autosave,
done,
lastRevision,
templateId,
append,
} = ctx.body;
const editorVersion = ctx.headers["x-editor-version"];
@@ -795,6 +855,7 @@ router.post("documents.update", auth(), async ctx => {
// Update document
if (title) document.title = title;
if (editorVersion) document.editorVersion = editorVersion;
if (templateId) document.templateId = templateId;
if (append) {
document.text += text;

View File

@@ -0,0 +1,20 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'template', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
await queryInterface.addColumn('documents', 'templateId', {
type: Sequelize.UUID,
allowNull: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'templateId');
await queryInterface.removeColumn('documents', 'template');
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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