mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a1e2eb751 | |||
| 584377e7de | |||
| b90ed11c5b | |||
| 7fc450729f | |||
| 193b027a52 | |||
| 26466a7342 | |||
| f4f3588039 | |||
| 576907fdc1 | |||
| f56a75d2ae | |||
| 5d2ccb5821 | |||
| 2905306c28 | |||
| 88bc1aae89 | |||
| cd4f76270c | |||
| 90d0309b33 | |||
| 30a98df712 | |||
| c40caea4ab | |||
| c8e16e8de0 | |||
| 0558049483 | |||
| e96e293988 | |||
| a628735b2c | |||
| b785da6159 | |||
| e5eb134ae6 | |||
| 8efd31f1d2 | |||
| ac64725964 | |||
| 4fbb5037c5 | |||
| 78e23026e6 | |||
| 21c7d93131 | |||
| f76d72ef0a | |||
| 733490f536 | |||
| faa67a7403 | |||
| 900ee7ada0 | |||
| c33515686d | |||
| e619bce571 | |||
| c9f0e3a5e6 | |||
| e5981d45bf | |||
| eb408b0e82 | |||
| ecdca2b8ec | |||
| 20429847c1 | |||
| 6d174fe6d6 | |||
| 3065296a19 | |||
| e0f9f33c81 | |||
| 2a732fad09 | |||
| be04e2abef | |||
| cec8e375bf | |||
| 854e4d6af7 | |||
| 98e6bf6504 | |||
| e5bbd7db1d | |||
| 2fe7370252 | |||
| cbf6aef0eb | |||
| aa2980a941 | |||
| 7ffe2a90a5 | |||
| bbb41a9430 | |||
| 3cfca47978 | |||
| 35c6b64077 | |||
| 0095830000 | |||
| ab5b3e151a | |||
| 65e5030efd | |||
| e16bfd83ef | |||
| d98ec86307 | |||
| 3caeb8ba19 | |||
| ef8be0dd0b | |||
| 44f3ef869a | |||
| c43157a2fa | |||
| b7f7af2480 | |||
| fd71f82ec9 | |||
| 4e29b14426 |
@@ -11,18 +11,24 @@ type Props = {
|
||||
collection: Collection,
|
||||
expanded?: boolean,
|
||||
size?: number,
|
||||
useLuminance?: boolean,
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
|
||||
function ResolvedCollectionIcon({
|
||||
collection,
|
||||
expanded,
|
||||
size,
|
||||
useLuminance,
|
||||
}: Props) {
|
||||
const { ui } = useStores();
|
||||
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
const color =
|
||||
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
|
||||
? getLuminance(collection.color) > 0.12
|
||||
? collection.color
|
||||
: "currentColor"
|
||||
(ui.resolvedTheme === "dark" || useLuminance) &&
|
||||
collection.color !== "currentColor" &&
|
||||
getLuminance(collection.color) <= 0.12
|
||||
? "currentColor"
|
||||
: collection.color;
|
||||
|
||||
if (collection.icon && collection.icon !== "collection") {
|
||||
|
||||
@@ -61,8 +61,9 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const category = useCategory(document);
|
||||
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
let collection = collections.get(document.collectionId || "");
|
||||
|
||||
if (!collection && document.collectionId) {
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
@@ -111,7 +112,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
<>
|
||||
{collection.name}
|
||||
{collection?.name}
|
||||
{path.map((n) => (
|
||||
<React.Fragment key={n.id}>
|
||||
<SmallSlash />
|
||||
|
||||
@@ -63,10 +63,11 @@ function DocumentListItem(props: Props, ref) {
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const canStar = policies.abilities(document.id).star;
|
||||
const can = policies.abilities(currentTeam.id);
|
||||
const canCollection = policies.abilities(document.collectionId);
|
||||
const collectionPolicy = document.collectionId
|
||||
? policies.abilities(document.collectionId)
|
||||
: {};
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
@@ -89,11 +90,6 @@ function DocumentListItem(props: Props, ref) {
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isDraft && showDraft && (
|
||||
<Tooltip
|
||||
tooltip={t("Only visible to you")}
|
||||
@@ -106,6 +102,11 @@ function DocumentListItem(props: Props, ref) {
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
{!queryIsInTitle && (
|
||||
@@ -128,7 +129,7 @@ function DocumentListItem(props: Props, ref) {
|
||||
!document.isArchived &&
|
||||
!document.isDeleted &&
|
||||
can.createDocument &&
|
||||
canCollection.update && (
|
||||
collectionPolicy.update && (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
|
||||
@@ -75,7 +75,9 @@ function DocumentMeta({
|
||||
return null;
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
|
||||
let content;
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// @flow
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import Button from "components/Button";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
const NewDocumentButton = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button as={Link} to={newDocumentUrl()} icon={<PlusIcon />}>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewDocumentButton;
|
||||
@@ -13,43 +13,38 @@ type Props = {
|
||||
result: DocumentPath,
|
||||
document?: ?Document,
|
||||
collection: ?Collection,
|
||||
onSuccess?: () => void,
|
||||
selected?: boolean,
|
||||
setSelectedPath?: (DocumentPath) => void,
|
||||
style?: Object,
|
||||
ref?: (?React.ElementRef<"div">) => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
class PathToDocument extends React.Component<Props> {
|
||||
handleClick = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
const { document, result, onSuccess } = this.props;
|
||||
if (!document) return;
|
||||
const PathToDocument = ({
|
||||
result,
|
||||
collection,
|
||||
document,
|
||||
ref,
|
||||
style,
|
||||
selected,
|
||||
setSelectedPath,
|
||||
}: Props) => {
|
||||
if (!result) return <div />;
|
||||
|
||||
if (result.type === "document") {
|
||||
await document.move(result.collectionId, result.id);
|
||||
} else {
|
||||
await document.move(result.collectionId, null);
|
||||
}
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { result, collection, document, ref, style } = this.props;
|
||||
const Component = document ? ResultWrapperLink : ResultWrapper;
|
||||
|
||||
if (!result) return <div />;
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
onClick={this.handleClick}
|
||||
href=""
|
||||
style={style}
|
||||
role="option"
|
||||
selectable
|
||||
>
|
||||
{collection && <CollectionIcon collection={collection} />}
|
||||
return (
|
||||
<ResultWrapper
|
||||
ref={ref}
|
||||
onClick={() => {
|
||||
setSelectedPath && setSelectedPath(result);
|
||||
}}
|
||||
style={style}
|
||||
role="option"
|
||||
selectable
|
||||
selected={selected}
|
||||
>
|
||||
<Flex>
|
||||
{collection && (
|
||||
<CollectionIcon collection={collection} useLuminance={selected} />
|
||||
)}
|
||||
|
||||
{result.path
|
||||
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
|
||||
@@ -60,10 +55,10 @@ class PathToDocument extends React.Component<Props> {
|
||||
<StyledGoToIcon /> <Title>{document.title}</Title>
|
||||
</DocumentTitle>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
}
|
||||
</Flex>
|
||||
</ResultWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentTitle = styled(Flex)``;
|
||||
|
||||
@@ -78,23 +73,19 @@ const StyledGoToIcon = styled(GoToIcon)`
|
||||
`;
|
||||
|
||||
const ResultWrapper = styled.div`
|
||||
padding: 8px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
user-select: none;
|
||||
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => (props.selected ? props.theme.selected : "")};
|
||||
color: ${(props) => (props.selected ? "white" : props.theme.text)};
|
||||
cursor: default;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
|
||||
padding: 8px 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
${DocumentTitle} {
|
||||
display: none;
|
||||
display: ${(props) => (props.selected ? "flex" : "none")};
|
||||
}
|
||||
|
||||
svg {
|
||||
@@ -104,7 +95,8 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
background: ${(props) =>
|
||||
props.selected ? "" : props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${DocumentTitle} {
|
||||
@@ -113,4 +105,4 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
|
||||
}
|
||||
`;
|
||||
|
||||
export default PathToDocument;
|
||||
export default observer(PathToDocument);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// @flow
|
||||
import { Search } from "js-search";
|
||||
import { last } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { type DocumentPath } from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import PathToDocument from "components/PathToDocument";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
export default function useListDocumentPath(document: Document) {
|
||||
const { collections, documents, policies } = useStores();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [selectedPath, setSelectedPath] = useState<?DocumentPath>();
|
||||
|
||||
const searchIndex = useMemo(() => {
|
||||
let paths = collections.pathsToDocuments;
|
||||
|
||||
paths = paths.filter((path) => {
|
||||
if (
|
||||
(path.type === "collection" && policies.abilities(path.id).update) ||
|
||||
(path.type === "document" &&
|
||||
policies.abilities(path.collectionId).update)
|
||||
)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const index = new Search("id");
|
||||
index.addIndex("title");
|
||||
|
||||
// Build index
|
||||
const indexeableDocuments = [];
|
||||
paths.forEach((path) => {
|
||||
const doc = documents.get(path.id);
|
||||
if (!doc || !doc.isTemplate) {
|
||||
indexeableDocuments.push(path);
|
||||
}
|
||||
});
|
||||
index.addDocuments(indexeableDocuments);
|
||||
|
||||
return index;
|
||||
}, [collections.pathsToDocuments, policies, documents]);
|
||||
|
||||
const selected = useCallback(
|
||||
(result: DocumentPath) => {
|
||||
if (!selectedPath) return;
|
||||
|
||||
if (selectedPath.type === "collection" && selectedPath.id === result.id) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
selectedPath.type === "document" &&
|
||||
selectedPath.id === result.id &&
|
||||
selectedPath.collectionId === result.collectionId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[selectedPath]
|
||||
);
|
||||
|
||||
const results: DocumentPath[] = useMemo(() => {
|
||||
const onlyShowCollections = document.isTemplate;
|
||||
let results = [];
|
||||
if (collections.isLoaded) {
|
||||
if (searchTerm) {
|
||||
results = searchIndex.search(searchTerm);
|
||||
} else {
|
||||
results = searchIndex._documents;
|
||||
}
|
||||
}
|
||||
|
||||
if (onlyShowCollections) {
|
||||
results = results.filter((result) => result.type === "collection");
|
||||
} else {
|
||||
// Exclude root from search results if document is already at the root
|
||||
if (!document.parentDocumentId) {
|
||||
results = results.filter(
|
||||
(result) => result.id !== document.collectionId
|
||||
);
|
||||
}
|
||||
|
||||
// Exclude document if on the path to result, or the same result
|
||||
results = results.filter(
|
||||
(result) =>
|
||||
!result.path.map((doc) => doc.id).includes(document.id) &&
|
||||
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [
|
||||
document.isTemplate,
|
||||
document.parentDocumentId,
|
||||
document.collectionId,
|
||||
document.id,
|
||||
collections.isLoaded,
|
||||
searchTerm,
|
||||
searchIndex,
|
||||
]);
|
||||
|
||||
const row = React.useCallback(
|
||||
({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number,
|
||||
data: Array<DocumentPath>,
|
||||
style: Object,
|
||||
}) => {
|
||||
const result = data[index];
|
||||
|
||||
return (
|
||||
<PathToDocument
|
||||
key={result.url}
|
||||
result={result}
|
||||
document={document}
|
||||
collection={collections.get(result.collectionId)}
|
||||
setSelectedPath={setSelectedPath}
|
||||
style={style}
|
||||
selected={selected(result)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[collections, document, selected]
|
||||
);
|
||||
|
||||
return {
|
||||
row,
|
||||
results,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
selectedPath,
|
||||
setSelectedPath,
|
||||
};
|
||||
}
|
||||
@@ -180,7 +180,7 @@ function CollectionMenu({
|
||||
]
|
||||
);
|
||||
|
||||
if (!items.length) {
|
||||
if (!items.some((item) => item.visible)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,9 @@ function DocumentMenu({
|
||||
[document]
|
||||
);
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = policies.abilities(document.id);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const restoreItems = React.useMemo(
|
||||
@@ -272,14 +274,16 @@ function DocumentMenu({
|
||||
items={[
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: (!!collection && can.restore) || can.unarchive,
|
||||
visible: !!collection && (can.restore || can.unarchive),
|
||||
onClick: handleRestore,
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!collection && !!can.restore && restoreItems.length !== 0,
|
||||
!collection &&
|
||||
(can.restore || can.unarchive) &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
@@ -330,10 +334,10 @@ function DocumentMenu({
|
||||
},
|
||||
{
|
||||
title: t("New nested document"),
|
||||
to: newDocumentUrl(document.collectionId, {
|
||||
to: newDocumentUrl(collection?.id, {
|
||||
parentDocumentId: document.id,
|
||||
}),
|
||||
visible: !!can.createChildDocument,
|
||||
visible: !!can.createChildDocument && !!collection?.id,
|
||||
icon: <NewDocumentIcon />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,9 +18,13 @@ function NewChildDocumentMenu({ document, label }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = collections.get(document.collectionId || "");
|
||||
const collectionName = collection ? collection.name : t("collection");
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
@@ -38,11 +42,11 @@ function NewChildDocumentMenu({ document, label }: Props) {
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
to: newDocumentUrl(document.collectionId),
|
||||
to: newDocumentUrl(collection.id),
|
||||
},
|
||||
{
|
||||
title: t("New nested document"),
|
||||
to: newDocumentUrl(document.collectionId, {
|
||||
to: newDocumentUrl(collection.id, {
|
||||
parentDocumentId: document.id,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button from "components/Button";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import Header from "components/ContextMenu/Header";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
function NewDocumentMenu() {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce((filtered, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
if (can.update) {
|
||||
filtered.push({
|
||||
to: newDocumentUrl(collection.id),
|
||||
title: <CollectionName>{collection.name}</CollectionName>,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, []),
|
||||
[collections.orderedData, policies]
|
||||
);
|
||||
|
||||
if (!can.createDocument || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (items.length === 1) {
|
||||
return (
|
||||
<Button as={Link} to={items[0].to} icon={<PlusIcon />}>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button icon={<PlusIcon />} {...props} small>
|
||||
{`${t("New doc")}…`}
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("New document")}>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CollectionName = styled.div`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export default observer(NewDocumentMenu);
|
||||
@@ -24,7 +24,7 @@ export default class Document extends BaseModel {
|
||||
store: DocumentsStore;
|
||||
|
||||
collaboratorIds: string[];
|
||||
collectionId: string;
|
||||
collectionId: ?string;
|
||||
createdAt: string;
|
||||
createdBy: User;
|
||||
updatedAt: string;
|
||||
@@ -303,6 +303,8 @@ export default class Document extends BaseModel {
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
autosave: options?.autosave,
|
||||
collectionId: this.collectionId,
|
||||
parentDocumentId: this.parentDocumentId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export default function AuthenticatedRoutes() {
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import getTasks from "shared/utils/getTasks";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import ToastsStore from "stores/ToastsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
@@ -63,6 +64,7 @@ type Props = {
|
||||
theme: Theme,
|
||||
auth: AuthStore,
|
||||
ui: UiStore,
|
||||
policies: PoliciesStore,
|
||||
toasts: ToastsStore,
|
||||
t: TFunction,
|
||||
};
|
||||
@@ -221,9 +223,11 @@ class DocumentScene extends React.Component<Props> {
|
||||
done?: boolean,
|
||||
publish?: boolean,
|
||||
autosave?: boolean,
|
||||
collectionId?: string,
|
||||
parentDocumentId?: string,
|
||||
} = {}
|
||||
) => {
|
||||
const { document, auth } = this.props;
|
||||
const { document, auth, policies } = this.props;
|
||||
|
||||
// prevent saves when we are already saving
|
||||
if (document.isSaving) return;
|
||||
@@ -247,6 +251,17 @@ class DocumentScene extends React.Component<Props> {
|
||||
document.text = text;
|
||||
document.tasks = getTasks(document.text);
|
||||
|
||||
if (options.collectionId) {
|
||||
const collecionPolicies = policies.abilities(options.collectionId);
|
||||
if (!collecionPolicies.update) return;
|
||||
document.collectionId = options.collectionId;
|
||||
if (options.parentDocumentId) {
|
||||
const documentPolicies = policies.abilities(options.parentDocumentId);
|
||||
if (!documentPolicies.createChildDocument) return;
|
||||
document.parentDocumentId = options.parentDocumentId;
|
||||
}
|
||||
}
|
||||
|
||||
let isNew = !document.id;
|
||||
this.isSaving = true;
|
||||
this.isPublishing = !!options.publish;
|
||||
@@ -603,6 +618,6 @@ const MaxWidth = styled(Flex)`
|
||||
|
||||
export default withRouter(
|
||||
withTranslation()<DocumentScene>(
|
||||
inject("ui", "auth", "toasts")(DocumentScene)
|
||||
inject("ui", "auth", "toasts", "policies")(DocumentScene)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDialogState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import { Action, Separator } from "components/Actions";
|
||||
@@ -21,6 +22,7 @@ import DocumentBreadcrumb from "components/DocumentBreadcrumb";
|
||||
import Header from "components/Header";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
import PublishDialog from "./PublishDialog";
|
||||
import ShareButton from "./ShareButton";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useMobile from "hooks/useMobile";
|
||||
@@ -70,23 +72,29 @@ function DocumentHeader({
|
||||
headings,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui, policies, collections } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { ui, policies } = useStores();
|
||||
const { resolvedTheme } = ui;
|
||||
const isMobile = useMobile();
|
||||
const dialog = useDialogState({ modal: true });
|
||||
const hasCollection = !!collections.get(document.collectionId || "");
|
||||
|
||||
const handleSave = React.useCallback(() => {
|
||||
onSave({ done: true });
|
||||
}, [onSave]);
|
||||
|
||||
const handlePublish = React.useCallback(() => {
|
||||
if (!hasCollection) {
|
||||
dialog.setVisible(true);
|
||||
return;
|
||||
}
|
||||
onSave({ done: true, publish: true });
|
||||
}, [onSave]);
|
||||
}, [dialog, hasCollection, onSave]);
|
||||
|
||||
const isNew = document.isNewDocument;
|
||||
const isTemplate = document.isTemplate;
|
||||
const can = policies.abilities(document.id);
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const canToggleEmbeds = team.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
|
||||
const toc = (
|
||||
@@ -205,7 +213,7 @@ function DocumentHeader({
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && (!isMobile || !isTemplate) && (
|
||||
{!isEditing && !isMobile && !isTemplate && (
|
||||
<Action>
|
||||
<ShareButton document={document} />
|
||||
</Action>
|
||||
@@ -264,6 +272,13 @@ function DocumentHeader({
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
{can.update && isDraft && !isRevision && !hasCollection && (
|
||||
<PublishDialog
|
||||
dialog={dialog}
|
||||
document={document}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
{can.update && isDraft && !isRevision && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import { Dialog, DialogBackdrop, type DialogStateReturn } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import Divider from "components/Divider";
|
||||
import Flex from "components/Flex";
|
||||
import InputSearch from "components/InputSearch";
|
||||
import useListDocumentPath from "hooks/useListDocumentPath";
|
||||
import useToasts from "hooks/useToasts";
|
||||
import { mobileContextMenu, fadeAndSlideDown } from "styles/animations";
|
||||
|
||||
type Props = {|
|
||||
dialog: DialogStateReturn,
|
||||
document: Document,
|
||||
onSave: ({
|
||||
done?: boolean,
|
||||
publish?: boolean,
|
||||
autosave?: boolean,
|
||||
}) => void,
|
||||
|};
|
||||
|
||||
const PublishDialog = ({ dialog, document, onSave }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const {
|
||||
row,
|
||||
results,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
selectedPath,
|
||||
setSelectedPath,
|
||||
} = useListDocumentPath(document);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!dialog.visible) {
|
||||
setSelectedPath(undefined);
|
||||
}
|
||||
}, [dialog.visible, setSelectedPath]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
},
|
||||
[setSearchTerm]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event) => {
|
||||
if (event.currentTarget.value && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSearchTerm("");
|
||||
}
|
||||
},
|
||||
[setSearchTerm]
|
||||
);
|
||||
|
||||
const handlePublishFromModal = React.useCallback(
|
||||
async (selectedPath) => {
|
||||
if (!document) return;
|
||||
if (!selectedPath) {
|
||||
showToast(t("Please select a path"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPath.type === "collection") {
|
||||
await onSave({
|
||||
done: true,
|
||||
publish: true,
|
||||
collectionId: selectedPath.collectionId,
|
||||
});
|
||||
} else {
|
||||
await onSave({
|
||||
done: true,
|
||||
publish: true,
|
||||
collectionId: selectedPath.collectionId,
|
||||
parentDocumentId: selectedPath.id,
|
||||
});
|
||||
}
|
||||
dialog.setVisible(false);
|
||||
},
|
||||
[dialog, document, onSave, showToast, t]
|
||||
);
|
||||
|
||||
const data = results;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<DialogBackdrop {...dialog}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label="Choose a collection"
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
>
|
||||
<Position>
|
||||
<Content>
|
||||
<Flex align="center">
|
||||
<InputSearch
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={searchTerm}
|
||||
placeholder={`${t("Search collections & documents")}…`}
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Results>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
return (
|
||||
<Flex role="listbox" column>
|
||||
<List
|
||||
key={data.length}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={data}
|
||||
itemCount={data.length}
|
||||
itemSize={40}
|
||||
itemKey={(index, data) => data[index].id}
|
||||
>
|
||||
{row}
|
||||
</List>
|
||||
</Flex>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</Results>
|
||||
<Divider />
|
||||
<ButtonWrapper justify="flex-end">
|
||||
<Button
|
||||
disabled={!selectedPath}
|
||||
onClick={() => handlePublishFromModal(selectedPath)}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
</ButtonWrapper>
|
||||
</Content>
|
||||
</Position>
|
||||
</Dialog>
|
||||
</DialogBackdrop>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
right: 8vh;
|
||||
top: 4vh;
|
||||
animation: ${mobileContextMenu} 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed !important;
|
||||
transform: none !important;
|
||||
top: auto !important;
|
||||
right: 8px !important;
|
||||
bottom: 16px !important;
|
||||
left: 8px !important;
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
animation: ${fadeAndSlideDown} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Results = styled.div`
|
||||
padding: 8px 0;
|
||||
height: calc(93% - 52px);
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
background: ${(props) => props.theme.background};
|
||||
width: 70vw;
|
||||
max-width: 600px;
|
||||
height: 40vh;
|
||||
max-height: 500px;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: ${(props) => props.theme.menuShadow};
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
right: -2vh;
|
||||
width: 95vw;
|
||||
`};
|
||||
`;
|
||||
|
||||
const ButtonWrapper = styled(Flex)`
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
||||
export default PublishDialog;
|
||||
@@ -27,7 +27,7 @@ class References extends React.Component<Props> {
|
||||
render() {
|
||||
const { documents, collections, document } = this.props;
|
||||
const backlinks = documents.getBacklinedDocuments(document.id);
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = collections.get(document.collectionId || "");
|
||||
const children = collection
|
||||
? collection.getDocumentChildren(document.id)
|
||||
: [];
|
||||
|
||||
@@ -24,7 +24,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const [isArchiving, setArchiving] = React.useState(false);
|
||||
const { showToast } = useToasts();
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const collection = collections.get(document.collectionId || "");
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
|
||||
+20
-65
@@ -1,19 +1,17 @@
|
||||
// @flow
|
||||
import { Search } from "js-search";
|
||||
import { last } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import styled from "styled-components";
|
||||
import { type DocumentPath } from "stores/CollectionsStore";
|
||||
import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import { Outline } from "components/Input";
|
||||
import Labeled from "components/Labeled";
|
||||
import PathToDocument from "components/PathToDocument";
|
||||
import useListDocumentPath from "hooks/useListDocumentPath";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
@@ -23,62 +21,28 @@ type Props = {|
|
||||
|};
|
||||
|
||||
function DocumentMove({ document, onRequestClose }: Props) {
|
||||
const [searchTerm, setSearchTerm] = useState();
|
||||
const { collections, documents } = useStores();
|
||||
const { collections } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchIndex = useMemo(() => {
|
||||
const paths = collections.pathsToDocuments;
|
||||
const index = new Search("id");
|
||||
index.addIndex("title");
|
||||
const { row, results, setSearchTerm, selectedPath } = useListDocumentPath(
|
||||
document
|
||||
);
|
||||
|
||||
// Build index
|
||||
const indexeableDocuments = [];
|
||||
paths.forEach((path) => {
|
||||
const doc = documents.get(path.id);
|
||||
if (!doc || !doc.isTemplate) {
|
||||
indexeableDocuments.push(path);
|
||||
}
|
||||
});
|
||||
index.addDocuments(indexeableDocuments);
|
||||
const handleMove = async () => {
|
||||
if (!document) return;
|
||||
|
||||
return index;
|
||||
}, [documents, collections.pathsToDocuments]);
|
||||
|
||||
const results: DocumentPath[] = useMemo(() => {
|
||||
const onlyShowCollections = document.isTemplate;
|
||||
let results = [];
|
||||
if (collections.isLoaded) {
|
||||
if (searchTerm) {
|
||||
results = searchIndex.search(searchTerm);
|
||||
} else {
|
||||
results = searchIndex._documents;
|
||||
}
|
||||
if (!selectedPath) {
|
||||
showToast(t("Please select a path"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (onlyShowCollections) {
|
||||
results = results.filter((result) => result.type === "collection");
|
||||
if (selectedPath.type === "document") {
|
||||
await document.move(selectedPath.collectionId, selectedPath.id);
|
||||
} else {
|
||||
// Exclude root from search results if document is already at the root
|
||||
if (!document.parentDocumentId) {
|
||||
results = results.filter(
|
||||
(result) => result.id !== document.collectionId
|
||||
);
|
||||
}
|
||||
|
||||
// Exclude document if on the path to result, or the same result
|
||||
results = results.filter(
|
||||
(result) =>
|
||||
!result.path.map((doc) => doc.id).includes(document.id) &&
|
||||
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
|
||||
);
|
||||
await document.move(selectedPath.collectionId, null);
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [document, collections, searchTerm, searchIndex]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
showToast(t("Document moved"), { type: "info" });
|
||||
onRequestClose();
|
||||
};
|
||||
@@ -100,20 +64,6 @@ function DocumentMove({ document, onRequestClose }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const row = ({ index, data, style }) => {
|
||||
const result = data[index];
|
||||
|
||||
return (
|
||||
<PathToDocument
|
||||
result={result}
|
||||
document={document}
|
||||
collection={collections.get(result.collectionId)}
|
||||
onSuccess={handleSuccess}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const data = results;
|
||||
|
||||
if (!document || !collections.isLoaded) {
|
||||
@@ -160,6 +110,11 @@ function DocumentMove({ document, onRequestClose }: Props) {
|
||||
</AutoSizer>
|
||||
</Results>
|
||||
</NewLocation>
|
||||
<Flex justify="flex-end">
|
||||
<Button onClick={handleMove} disabled={!selectedPath}>
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Section>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -19,16 +19,17 @@ function DocumentNew() {
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const id = match.params.id || "";
|
||||
const id = match.params.id;
|
||||
|
||||
useEffect(() => {
|
||||
async function createDocument() {
|
||||
const params = queryString.parse(location.search);
|
||||
try {
|
||||
const collection = await collections.fetch(id);
|
||||
let collection;
|
||||
if (id) collection = await collections.fetch(id);
|
||||
|
||||
const document = await documents.create({
|
||||
collectionId: collection.id,
|
||||
collectionId: collection ? collection.id : null,
|
||||
parentDocumentId: params.parentDocumentId,
|
||||
templateId: params.templateId,
|
||||
template: params.template,
|
||||
|
||||
@@ -18,7 +18,7 @@ import InputSearchPage from "components/InputSearchPage";
|
||||
import PaginatedDocumentList from "components/PaginatedDocumentList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
import NewDocumentButton from "../components/NewDocumentButton";
|
||||
import { type LocationWithState } from "types";
|
||||
|
||||
type Props = {|
|
||||
@@ -89,7 +89,7 @@ class Drafts extends React.Component<Props> {
|
||||
<InputSearchPage source="drafts" label={t("Search documents")} />
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
<NewDocumentButton />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
|
||||
+2
-2
@@ -9,12 +9,12 @@ import Empty from "components/Empty";
|
||||
import Heading from "components/Heading";
|
||||
import InputSearchPage from "components/InputSearchPage";
|
||||
import LanguagePrompt from "components/LanguagePrompt";
|
||||
import NewDocumentButton from "components/NewDocumentButton";
|
||||
import Scene from "components/Scene";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import PaginatedDocumentList from "../components/PaginatedDocumentList";
|
||||
import useStores from "../hooks/useStores";
|
||||
import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
|
||||
function Home() {
|
||||
const { documents, ui, auth } = useStores();
|
||||
@@ -33,7 +33,7 @@ function Home() {
|
||||
<InputSearchPage source="dashboard" label={t("Search documents")} />
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
<NewDocumentButton />
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import NewDocumentButton from "components/NewDocumentButton";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import CollectionFilter from "./components/CollectionFilter";
|
||||
import DateFilter from "./components/DateFilter";
|
||||
import SearchInput from "./components/SearchInput";
|
||||
import StatusFilter from "./components/StatusFilter";
|
||||
import UserFilter from "./components/UserFilter";
|
||||
import NewDocumentMenu from "menus/NewDocumentMenu";
|
||||
import { type LocationWithState } from "types";
|
||||
import { metaDisplay } from "utils/keyboard";
|
||||
import { newDocumentUrl, searchUrl } from "utils/routeHelpers";
|
||||
@@ -343,7 +343,7 @@ class Search extends React.Component<Props> {
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
) : (
|
||||
<NewDocumentMenu />
|
||||
<NewDocumentButton />
|
||||
)}
|
||||
|
||||
<Button as={Link} to="/search" neutral>
|
||||
|
||||
@@ -16,9 +16,9 @@ function CollectionFilter(props: Props) {
|
||||
const { onSelect, collectionId } = props;
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const collectionOptions = collections.orderedData.map((user) => ({
|
||||
key: user.id,
|
||||
label: user.name,
|
||||
const collectionOptions = collections.orderedData.map((collection) => ({
|
||||
key: collection.id,
|
||||
label: collection.name,
|
||||
}));
|
||||
|
||||
return [
|
||||
|
||||
@@ -51,7 +51,7 @@ function Templates(props: Props) {
|
||||
}
|
||||
empty={
|
||||
<Empty>
|
||||
{t("There are no templates just yet.")}
|
||||
{t("There are no templates just yet.")}{" "}
|
||||
{can.createDocument &&
|
||||
t(
|
||||
"You can create templates to help your team create consistent and accurate documentation."
|
||||
|
||||
@@ -714,6 +714,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
getCollectionForDocument(document: Document) {
|
||||
return this.rootStore.collections.data.get(document.collectionId);
|
||||
return this.rootStore.collections.data.get(document.collectionId || "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ export default class SharesStore extends BaseStore<Share> {
|
||||
const document = this.rootStore.documents.get(documentId);
|
||||
if (!document) return;
|
||||
|
||||
const collection = this.rootStore.collections.get(document.collectionId);
|
||||
const collection = this.rootStore.collections.get(
|
||||
document.collectionId || ""
|
||||
);
|
||||
if (!collection) return;
|
||||
|
||||
const parentIds = collection
|
||||
|
||||
@@ -175,7 +175,7 @@ export default class UsersStore extends BaseStore<User> {
|
||||
}
|
||||
};
|
||||
|
||||
notInCollection = (collectionId: string, query: string = "") => {
|
||||
notInCollection = (collectionId: string, query: string = ""): User[] => {
|
||||
const memberships = filter(
|
||||
this.rootStore.memberships.orderedData,
|
||||
(member) => member.collectionId === collectionId
|
||||
@@ -190,7 +190,7 @@ export default class UsersStore extends BaseStore<User> {
|
||||
return queriedUsers(users, query);
|
||||
};
|
||||
|
||||
inCollection = (collectionId: string, query: string) => {
|
||||
inCollection = (collectionId: string, query: string): User[] => {
|
||||
const memberships = filter(
|
||||
this.rootStore.memberships.orderedData,
|
||||
(member) => member.collectionId === collectionId
|
||||
@@ -204,7 +204,7 @@ export default class UsersStore extends BaseStore<User> {
|
||||
return queriedUsers(users, query);
|
||||
};
|
||||
|
||||
notInGroup = (groupId: string, query: string = "") => {
|
||||
notInGroup = (groupId: string, query: string = ""): User[] => {
|
||||
const memberships = filter(
|
||||
this.rootStore.groupMemberships.orderedData,
|
||||
(member) => member.groupId === groupId
|
||||
@@ -219,7 +219,7 @@ export default class UsersStore extends BaseStore<User> {
|
||||
return queriedUsers(users, query);
|
||||
};
|
||||
|
||||
inGroup = (groupId: string, query: string) => {
|
||||
inGroup = (groupId: string, query: string): User[] => {
|
||||
const groupMemberships = filter(
|
||||
this.rootStore.groupMemberships.orderedData,
|
||||
(member) => member.groupId === groupId
|
||||
|
||||
@@ -55,13 +55,14 @@ export function updateDocumentUrl(oldUrl: string, document: Document): string {
|
||||
}
|
||||
|
||||
export function newDocumentUrl(
|
||||
collectionId: string,
|
||||
collectionId: ?string,
|
||||
params?: {
|
||||
parentDocumentId?: string,
|
||||
templateId?: string,
|
||||
template?: boolean,
|
||||
}
|
||||
): string {
|
||||
if (!collectionId) return `/doc/new?${queryString.stringify(params)}`;
|
||||
return `/collection/${collectionId}/new?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import * as Y from "yjs";
|
||||
import documentUpdater from "../commands/documentUpdater";
|
||||
import documentUpdater from "../commands/documentUpdaterCollaboration";
|
||||
import Logger from "../logging/logger";
|
||||
import { Document, User } from "../models";
|
||||
import markdownToYDoc from "./utils/markdownToYDoc";
|
||||
|
||||
@@ -1,69 +1,113 @@
|
||||
// @flow
|
||||
import { uniq } from "lodash";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { schema, serializer } from "rich-markdown-editor";
|
||||
import { yDocToProsemirrorJSON } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import { Document, Event } from "../models";
|
||||
import { Document, User, Collection, Event } from "../models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
export default async function documentUpdater({
|
||||
documentId,
|
||||
ydoc,
|
||||
userId,
|
||||
done,
|
||||
}: {
|
||||
documentId: string,
|
||||
ydoc: Y.Doc,
|
||||
userId: string,
|
||||
done?: boolean,
|
||||
}) {
|
||||
const document = await Document.findByPk(documentId);
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
|
||||
const text = serializer.serialize(node);
|
||||
export async function documentUpdater(
|
||||
document: Document,
|
||||
user: User,
|
||||
collection?: Collection,
|
||||
{
|
||||
id,
|
||||
title,
|
||||
text,
|
||||
publish,
|
||||
autosave,
|
||||
done,
|
||||
templateId,
|
||||
append,
|
||||
collectionId,
|
||||
editorVersion,
|
||||
parentDocumentId,
|
||||
}: Object,
|
||||
ip: String
|
||||
): Document {
|
||||
const previousTitle = document.title;
|
||||
if (title) document.title = title;
|
||||
if (editorVersion) document.editorVersion = editorVersion;
|
||||
if (templateId) document.templateId = templateId;
|
||||
|
||||
const isUnchanged = document.text === text;
|
||||
const hasMultiplayerState = !!document.state;
|
||||
|
||||
if (isUnchanged && hasMultiplayerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// extract collaborators from doc user data
|
||||
const pud = new Y.PermanentUserData(ydoc);
|
||||
const pudIds = Array.from(pud.clients.values());
|
||||
const existingIds = document.collaboratorIds;
|
||||
const collaboratorIds = uniq([...pudIds, ...existingIds]);
|
||||
|
||||
await Document.scope("withUnpublished").update(
|
||||
{
|
||||
text,
|
||||
state: Buffer.from(state),
|
||||
updatedAt: isUnchanged ? document.updatedAt : new Date(),
|
||||
lastModifiedById: isUnchanged ? document.lastModifiedById : userId,
|
||||
collaboratorIds,
|
||||
},
|
||||
{
|
||||
hooks: false,
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
if (!user.team?.collaborativeEditing) {
|
||||
if (append) {
|
||||
document.text += text;
|
||||
} else if (text !== undefined) {
|
||||
document.text = text;
|
||||
}
|
||||
);
|
||||
|
||||
if (isUnchanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Event.add({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: userId,
|
||||
data: {
|
||||
multiplayer: true,
|
||||
title: document.title,
|
||||
},
|
||||
});
|
||||
document.lastModifiedById = user.id;
|
||||
|
||||
let transaction;
|
||||
let updatedDocument;
|
||||
try {
|
||||
transaction = await sequelize.transaction();
|
||||
|
||||
if (publish) {
|
||||
if (!document.publishedAt && !document.collection && collectionId) {
|
||||
document.collectionId = collectionId;
|
||||
if (parentDocumentId) document.parentDocumentId = parentDocumentId;
|
||||
}
|
||||
updatedDocument = await document.publish(user.id, { transaction });
|
||||
} else {
|
||||
updatedDocument = await document.save({ autosave, transaction });
|
||||
}
|
||||
|
||||
updatedDocument.updatedBy = user;
|
||||
updatedDocument.collection = collection;
|
||||
|
||||
if (publish) {
|
||||
await Event.create(
|
||||
{
|
||||
name: "documents.publish",
|
||||
documentId: updatedDocument.id,
|
||||
collectionId: updatedDocument.collectionId,
|
||||
teamId: updatedDocument.teamId,
|
||||
actorId: user.id,
|
||||
data: { title: updatedDocument.title },
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
} else {
|
||||
await Event.create(
|
||||
{
|
||||
name: "documents.update",
|
||||
documentId: updatedDocument.id,
|
||||
collectionId: updatedDocument.collectionId,
|
||||
teamId: updatedDocument.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
autosave,
|
||||
done,
|
||||
title: updatedDocument.title,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
if (updatedDocument.title !== previousTitle) {
|
||||
Event.add({
|
||||
name: "documents.title_change",
|
||||
documentId: updatedDocument.id,
|
||||
collectionId: updatedDocument.collectionId,
|
||||
teamId: updatedDocument.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
previousTitle,
|
||||
title: updatedDocument.title,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return updatedDocument;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @flow
|
||||
import { uniq } from "lodash";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { schema, serializer } from "rich-markdown-editor";
|
||||
import { yDocToProsemirrorJSON } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import { Document, Event } from "../models";
|
||||
|
||||
export default async function documentUpdater({
|
||||
documentId,
|
||||
ydoc,
|
||||
userId,
|
||||
done,
|
||||
}: {
|
||||
documentId: string,
|
||||
ydoc: Y.Doc,
|
||||
userId: string,
|
||||
done?: boolean,
|
||||
}) {
|
||||
const document = await Document.findByPk(documentId);
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
|
||||
const text = serializer.serialize(node);
|
||||
|
||||
const isUnchanged = document.text === text;
|
||||
const hasMultiplayerState = !!document.state;
|
||||
|
||||
if (isUnchanged && hasMultiplayerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// extract collaborators from doc user data
|
||||
const pud = new Y.PermanentUserData(ydoc);
|
||||
const pudIds = Array.from(pud.clients.values());
|
||||
const existingIds = document.collaboratorIds;
|
||||
const collaboratorIds = uniq([...pudIds, ...existingIds]);
|
||||
|
||||
await Document.scope("withUnpublished").update(
|
||||
{
|
||||
text,
|
||||
state: Buffer.from(state),
|
||||
updatedAt: isUnchanged ? document.updatedAt : new Date(),
|
||||
lastModifiedById: isUnchanged ? document.lastModifiedById : userId,
|
||||
collaboratorIds,
|
||||
},
|
||||
{
|
||||
hooks: false,
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isUnchanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Event.add({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: userId,
|
||||
data: {
|
||||
multiplayer: true,
|
||||
title: document.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -198,6 +198,7 @@ Collection.associate = (models) => {
|
||||
};
|
||||
|
||||
Collection.addHook("afterDestroy", async (model: Collection) => {
|
||||
// sets deletedAt attribute of templates and normal documents
|
||||
await Document.destroy({
|
||||
where: {
|
||||
collectionId: model.id,
|
||||
@@ -206,6 +207,22 @@ Collection.addHook("afterDestroy", async (model: Collection) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// updating the drafts to have no collection
|
||||
await Document.unscoped().update(
|
||||
{
|
||||
collectionId: null,
|
||||
parentDocumentId: null,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
publishedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
collectionId: model.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Collection.addHook("afterCreate", (model: Collection, options) => {
|
||||
|
||||
@@ -36,7 +36,14 @@ allow(User, ["star", "unstar"], Document, (user, document) => {
|
||||
allow(User, "share", Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (!document.publishedAt && !document.collection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
);
|
||||
if (cannot(user, "share", document.collection)) {
|
||||
return false;
|
||||
}
|
||||
@@ -48,7 +55,12 @@ allow(User, "update", Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
if (cannot(user, "update", document.collection)) {
|
||||
// if a document has a collection and it is published, it is everything except the draft.
|
||||
if (
|
||||
document.collection &&
|
||||
document.publishedAt &&
|
||||
cannot(user, "update", document.collection)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -158,11 +170,8 @@ allow(User, "archive", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "unarchive", Document, (user, document) => {
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
);
|
||||
if (cannot(user, "update", document.collection)) return false;
|
||||
if (document.collecion && cannot(user, "update", document.collection))
|
||||
return false;
|
||||
|
||||
if (!document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
+70
-104
@@ -6,6 +6,8 @@ import documentCreator from "../../commands/documentCreator";
|
||||
import documentImporter from "../../commands/documentImporter";
|
||||
import documentMover from "../../commands/documentMover";
|
||||
import documentPermanentDeleter from "../../commands/documentPermanentDeleter";
|
||||
import { documentUpdater } from "../../commands/documentUpdater";
|
||||
|
||||
import env from "../../env";
|
||||
import {
|
||||
NotFoundError,
|
||||
@@ -32,7 +34,6 @@ import {
|
||||
presentDocument,
|
||||
presentPolicies,
|
||||
} from "../../presenters";
|
||||
import { sequelize } from "../../sequelize";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
@@ -433,13 +434,8 @@ router.post("documents.drafts", auth(), pagination(), async (ctx) => {
|
||||
authorize(user, "read", collection);
|
||||
}
|
||||
|
||||
const collectionIds = !!collectionId
|
||||
? [collectionId]
|
||||
: await user.collectionIds();
|
||||
|
||||
const whereConditions = {
|
||||
userId: user.id,
|
||||
collectionId: collectionIds,
|
||||
publishedAt: { [Op.eq]: null },
|
||||
updatedAt: undefined,
|
||||
};
|
||||
@@ -917,7 +913,7 @@ router.post("documents.star", auth(), async (ctx) => {
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
authorize(user, "read", document);
|
||||
authorize(user, "star", document);
|
||||
|
||||
await Star.findOrCreate({
|
||||
where: { documentId: document.id, userId: user.id },
|
||||
@@ -944,7 +940,7 @@ router.post("documents.unstar", auth(), async (ctx) => {
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
authorize(user, "read", document);
|
||||
authorize(user, "unstar", document);
|
||||
|
||||
await Star.destroy({
|
||||
where: { documentId: document.id, userId: user.id },
|
||||
@@ -1016,6 +1012,8 @@ router.post("documents.update", auth(), async (ctx) => {
|
||||
lastRevision,
|
||||
templateId,
|
||||
append,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
} = ctx.body;
|
||||
const editorVersion = ctx.headers["x-editor-version"];
|
||||
|
||||
@@ -1031,88 +1029,46 @@ router.post("documents.update", auth(), async (ctx) => {
|
||||
throw new InvalidRequestError("Document has changed since last revision");
|
||||
}
|
||||
|
||||
const previousTitle = document.title;
|
||||
let collection;
|
||||
|
||||
// Update document
|
||||
if (title) document.title = title;
|
||||
if (editorVersion) document.editorVersion = editorVersion;
|
||||
if (templateId) document.templateId = templateId;
|
||||
|
||||
if (!user.team?.collaborativeEditing) {
|
||||
if (append) {
|
||||
document.text += text;
|
||||
} else if (text !== undefined) {
|
||||
document.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
document.lastModifiedById = user.id;
|
||||
const { collection } = document;
|
||||
|
||||
let transaction;
|
||||
try {
|
||||
transaction = await sequelize.transaction();
|
||||
|
||||
if (publish) {
|
||||
await document.publish(user.id, { transaction });
|
||||
} else {
|
||||
await document.save({ autosave, transaction });
|
||||
}
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw err;
|
||||
//draft with no collection and want to be published
|
||||
if (!document.publishedAt && !document.collection && publish) {
|
||||
ctx.assertPresent(collectionId, "collectionId should be present");
|
||||
collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
} else {
|
||||
collection = document.collection;
|
||||
}
|
||||
|
||||
if (publish) {
|
||||
await Event.create({
|
||||
name: "documents.publish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: { title: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
} else {
|
||||
await Event.create({
|
||||
name: "documents.update",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
autosave,
|
||||
done,
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.assertPresent(collection, "Collection should be present");
|
||||
authorize(user, "publish", collection);
|
||||
}
|
||||
|
||||
if (document.title !== previousTitle) {
|
||||
Event.add({
|
||||
name: "documents.title_change",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
previousTitle,
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
document.updatedBy = user;
|
||||
document.collection = collection;
|
||||
const updatedDocument = await documentUpdater(
|
||||
document,
|
||||
user,
|
||||
collection,
|
||||
{
|
||||
id,
|
||||
title,
|
||||
text,
|
||||
publish,
|
||||
autosave,
|
||||
done,
|
||||
templateId,
|
||||
append,
|
||||
collectionId,
|
||||
editorVersion,
|
||||
parentDocumentId,
|
||||
},
|
||||
ctx.request.ip
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
data: await presentDocument(updatedDocument),
|
||||
policies: presentPolicies(user, [updatedDocument]),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1209,7 +1165,8 @@ router.post("documents.delete", auth(), async (ctx) => {
|
||||
});
|
||||
authorize(user, "permanentDelete", document);
|
||||
|
||||
await Document.update(
|
||||
//unscoping to apply on drafts documents
|
||||
await Document.unscoped().update(
|
||||
{ parentDocumentId: null },
|
||||
{
|
||||
where: {
|
||||
@@ -1361,7 +1318,9 @@ router.post("documents.create", auth(), async (ctx) => {
|
||||
} = ctx.body;
|
||||
const editorVersion = ctx.headers["x-editor-version"];
|
||||
|
||||
ctx.assertUuid(collectionId, "collectionId must be an uuid");
|
||||
if (collectionId) {
|
||||
ctx.assertUuid(collectionId, "collectionId must be an uuid");
|
||||
}
|
||||
if (parentDocumentId) {
|
||||
ctx.assertUuid(parentDocumentId, "parentDocumentId must be an uuid");
|
||||
}
|
||||
@@ -1371,31 +1330,38 @@ router.post("documents.create", auth(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "createDocument", user.team);
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findOne({
|
||||
where: {
|
||||
id: collectionId,
|
||||
teamId: user.teamId,
|
||||
},
|
||||
});
|
||||
authorize(user, "publish", collection);
|
||||
|
||||
let collection;
|
||||
let parentDocument;
|
||||
if (parentDocumentId) {
|
||||
parentDocument = await Document.findOne({
|
||||
let templateDocument;
|
||||
|
||||
if (collectionId) {
|
||||
collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findOne({
|
||||
where: {
|
||||
id: parentDocumentId,
|
||||
collectionId: collection.id,
|
||||
id: collectionId,
|
||||
teamId: user.teamId,
|
||||
},
|
||||
});
|
||||
authorize(user, "read", parentDocument, { collection });
|
||||
}
|
||||
|
||||
let templateDocument;
|
||||
if (templateId) {
|
||||
templateDocument = await Document.findByPk(templateId, { userId: user.id });
|
||||
authorize(user, "read", templateDocument);
|
||||
authorize(user, "publish", collection);
|
||||
|
||||
if (parentDocumentId) {
|
||||
parentDocument = await Document.findOne({
|
||||
where: {
|
||||
id: parentDocumentId,
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
authorize(user, "read", parentDocument, { collection });
|
||||
}
|
||||
|
||||
if (templateId) {
|
||||
templateDocument = await Document.findByPk(templateId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", templateDocument);
|
||||
}
|
||||
}
|
||||
|
||||
const document = await documentCreator({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildCollection,
|
||||
buildUser,
|
||||
buildDocument,
|
||||
buildTeam,
|
||||
} from "../../test/factories";
|
||||
import { flushdb, seed } from "../../test/support";
|
||||
const app = webService();
|
||||
@@ -902,7 +903,7 @@ describe("#documents.drafts", () => {
|
||||
});
|
||||
|
||||
it("should not return documents in private collections not a member of", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const { document, collection, admin } = await seed();
|
||||
document.publishedAt = null;
|
||||
await document.save();
|
||||
|
||||
@@ -910,7 +911,7 @@ describe("#documents.drafts", () => {
|
||||
await collection.save();
|
||||
|
||||
const res = await server.post("/api/documents.drafts", {
|
||||
body: { token: user.getJwtToken() },
|
||||
body: { token: admin.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
@@ -1985,6 +1986,24 @@ describe("#documents.create", () => {
|
||||
expect(body.data.title).toBe("new document");
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
});
|
||||
|
||||
it("should create a draft document", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
title: "new document",
|
||||
text: "hello",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toBe("new document");
|
||||
expect(body.data.collectionId).toBe(null);
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#documents.update", () => {
|
||||
@@ -2070,6 +2089,38 @@ describe("#documents.update", () => {
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
});
|
||||
|
||||
it("should publish draft document", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const document = await buildDocument(
|
||||
{ teamId: team.id, userId: user.id },
|
||||
true
|
||||
);
|
||||
|
||||
const res = await server.post("/api/documents.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
title: "Updated title",
|
||||
text: "Updated text",
|
||||
lastRevision: document.revision,
|
||||
collectionId: collection.id,
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.publishedAt).toBeTruthy();
|
||||
expect(body.data.collectionId).toBe(collection.id);
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
});
|
||||
|
||||
it("should not edit archived document", async () => {
|
||||
const { user, document } = await seed();
|
||||
await document.archive();
|
||||
|
||||
@@ -229,7 +229,10 @@ export async function buildGroupUser(overrides: Object = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildDocument(overrides: Object = {}) {
|
||||
export async function buildDocument(
|
||||
overrides: Object = {},
|
||||
draft: boolean = false
|
||||
) {
|
||||
if (!overrides.teamId) {
|
||||
const team = await buildTeam();
|
||||
overrides.teamId = team.id;
|
||||
@@ -241,8 +244,12 @@ export async function buildDocument(overrides: Object = {}) {
|
||||
}
|
||||
|
||||
if (!overrides.collectionId) {
|
||||
const collection = await buildCollection(overrides);
|
||||
overrides.collectionId = collection.id;
|
||||
if (draft) {
|
||||
overrides.collectionId = null;
|
||||
} else {
|
||||
const collection = await buildCollection(overrides);
|
||||
overrides.collectionId = collection.id;
|
||||
}
|
||||
}
|
||||
|
||||
count++;
|
||||
@@ -250,7 +257,7 @@ export async function buildDocument(overrides: Object = {}) {
|
||||
return Document.create({
|
||||
title: `Document ${count}`,
|
||||
text: "This is the text in an example document",
|
||||
publishedAt: new Date(),
|
||||
publishedAt: draft ? null : new Date(),
|
||||
lastModifiedById: overrides.userId,
|
||||
createdById: overrides.userId,
|
||||
...overrides,
|
||||
|
||||
@@ -33,15 +33,15 @@
|
||||
"Viewed": "Viewed",
|
||||
"in": "in",
|
||||
"nested document": "nested document",
|
||||
"nested document_plural": "nested documents",
|
||||
"nested document_plural": "nested document",
|
||||
"Viewed by": "Viewed by",
|
||||
"only you": "only you",
|
||||
"person": "person",
|
||||
"people": "people",
|
||||
"{{ total }} task": "{{ total }} task",
|
||||
"{{ total }} task_plural": "{{ total }} tasks",
|
||||
"{{ total }} task_plural": "{{ total }} task",
|
||||
"{{ completed }} task done": "{{ completed }} task done",
|
||||
"{{ completed }} task done_plural": "{{ completed }} tasks done",
|
||||
"{{ completed }} task done_plural": "{{ completed }} task done",
|
||||
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
|
||||
"Currently editing": "Currently editing",
|
||||
"Currently viewing": "Currently viewing",
|
||||
@@ -54,7 +54,7 @@
|
||||
"Align left": "Align left",
|
||||
"Align right": "Align right",
|
||||
"Bulleted list": "Bulleted list",
|
||||
"Todo list": "Task list",
|
||||
"Todo list": "Todo list",
|
||||
"Code block": "Code block",
|
||||
"Copied to clipboard": "Copied to clipboard",
|
||||
"Code": "Code",
|
||||
@@ -157,7 +157,7 @@
|
||||
"Settings": "Settings",
|
||||
"Invite people": "Invite people",
|
||||
"Create a collection": "Create a collection",
|
||||
"Return to App": "Back to App",
|
||||
"Return to App": "Return to App",
|
||||
"Account": "Account",
|
||||
"Profile": "Profile",
|
||||
"Notifications": "Notifications",
|
||||
@@ -358,6 +358,8 @@
|
||||
"Publishing": "Publishing",
|
||||
"Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document",
|
||||
"Nested documents": "Nested documents",
|
||||
"Please select a path": "Please select a path",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"Referenced by": "Referenced by",
|
||||
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",
|
||||
"Share": "Share",
|
||||
@@ -377,7 +379,6 @@
|
||||
"Document moved": "Document moved",
|
||||
"Current location": "Current location",
|
||||
"Choose a new location": "Choose a new location",
|
||||
"Search collections & documents": "Search collections & documents",
|
||||
"Couldn’t create the document, try again?": "Couldn’t create the document, try again?",
|
||||
"Document permanently deleted": "Document permanently deleted",
|
||||
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
|
||||
|
||||
Reference in New Issue
Block a user