Compare commits

...

66 Commits

Author SHA1 Message Date
Saumya Pandey 8a1e2eb751 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-10-08 09:29:29 +05:30
Saumya Pandey 584377e7de check for collaborativeEditing 2021-10-07 23:14:42 +05:30
Saumya Pandey b90ed11c5b Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-10-07 23:13:36 +05:30
Saumya Pandey 7fc450729f Use less than or equal 2021-09-28 08:50:36 +05:30
Saumya Pandey 193b027a52 Even better 2021-09-28 08:49:48 +05:30
Saumya Pandey 26466a7342 Restructure ternary operator 2021-09-28 01:47:18 +05:30
Saumya Pandey f4f3588039 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-09-28 01:15:40 +05:30
Tom Moor 576907fdc1 merge 2021-09-19 13:44:09 -07:00
Tom Moor f56a75d2ae Merge branch 'main' into fix/2122 2021-09-19 13:43:34 -07:00
Saumya Pandey 5d2ccb5821 Add animation 2021-09-19 11:37:02 +05:30
Saumya Pandey 2905306c28 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-30 15:39:57 +05:30
Saumya Pandey 88bc1aae89 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-30 15:37:20 +05:30
Saumya Pandey cd4f76270c Create useListDocumentPath hook 2021-08-30 15:31:39 +05:30
Saumya Pandey 90d0309b33 Create DocumentPathList to rows computation 2021-08-29 16:27:47 +05:30
Saumya Pandey 30a98df712 Pass selected value as a prop 2021-08-29 15:07:27 +05:30
Saumya Pandey c40caea4ab Minor tweaks 2021-08-28 15:31:03 +05:30
Saumya Pandey c8e16e8de0 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-27 03:17:37 +05:30
Saumya Pandey 0558049483 Update app/components/NewDocumentButton.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-27 03:17:20 +05:30
Saumya Pandey e96e293988 Use InputSearch 2021-08-27 03:16:35 +05:30
Saumya Pandey a628735b2c Reorder star at the end 2021-08-27 03:01:39 +05:30
Saumya Pandey b785da6159 Convert to functional component 2021-08-27 02:44:24 +05:30
Saumya Pandey e5eb134ae6 Use newDocumentUrl helper 2021-08-27 01:44:51 +05:30
Saumya Pandey 8efd31f1d2 Don't show collections that can't be update by user 2021-08-27 01:42:30 +05:30
Saumya Pandey ac64725964 Add policy check when publishing draft 2021-08-27 01:00:25 +05:30
Saumya Pandey 4fbb5037c5 Authorize for collection publish 2021-08-27 00:25:12 +05:30
Saumya Pandey 78e23026e6 Use hasCollection instead of can.share 2021-08-27 00:16:16 +05:30
Saumya Pandey 21c7d93131 Merge branch 'fix/2122' of https://github.com/outline/outline into fix/2122 2021-08-27 00:11:31 +05:30
Saumya Pandey f76d72ef0a Update app/components/DocumentBreadcrumb.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-27 00:10:10 +05:30
Saumya Pandey 733490f536 Add return type 2021-08-27 00:01:51 +05:30
Saumya Pandey faa67a7403 Remove computedCollectionId 2021-08-27 00:01:41 +05:30
Saumya Pandey 900ee7ada0 Update app/menus/DocumentMenu.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:50:27 +05:30
Saumya Pandey c33515686d Update app/components/DocumentMeta.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:46:51 +05:30
Saumya Pandey e619bce571 Update app/components/DocumentListItem.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:42:20 +05:30
Saumya Pandey c9f0e3a5e6 Update app/components/DocumentListItem.js
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-08-26 22:41:17 +05:30
Saumya Pandey e5981d45bf Highlight the selected path with primary color 2021-08-25 20:03:38 +05:30
Saumya Pandey eb408b0e82 Rename method 2021-08-25 19:40:18 +05:30
Saumya Pandey ecdca2b8ec Extract dialog to it's own component 2021-08-25 19:25:12 +05:30
Saumya Pandey 20429847c1 Ip from request obj 2021-08-25 18:25:44 +05:30
Saumya Pandey 6d174fe6d6 Fix typo and add translation 2021-08-25 18:21:32 +05:30
Saumya Pandey 3065296a19 Move events inside documentUpdater 2021-08-25 18:19:24 +05:30
Saumya Pandey e0f9f33c81 Remove unnecessary code 2021-08-25 02:32:25 +05:30
Saumya Pandey 2a732fad09 Stop share.create request 2021-08-25 01:01:11 +05:30
Saumya Pandey be04e2abef Add space 2021-08-24 23:41:31 +05:30
Saumya Pandey cec8e375bf Delete NewDocumentMenu 2021-08-24 23:37:50 +05:30
Saumya Pandey 854e4d6af7 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-24 23:37:05 +05:30
Saumya Pandey 98e6bf6504 Replace NewDocumentMenu with NewDocumentButton 2021-08-24 23:30:23 +05:30
Saumya Pandey e5bbd7db1d Update position 2021-08-24 23:21:05 +05:30
Saumya Pandey 2fe7370252 Add checkbox and publish btn 2021-08-24 18:19:23 +05:30
Saumya Pandey cbf6aef0eb Minor tweaks 2021-08-21 01:32:56 +05:30
Saumya Pandey aa2980a941 Add tests on drafts doc 2021-08-21 01:32:48 +05:30
Saumya Pandey 7ffe2a90a5 Make modal mobile friendly 2021-08-12 03:49:59 +05:30
Saumya Pandey bbb41a9430 Merge branch 'main' of https://github.com/outline/outline into fix/2122 2021-08-12 03:07:31 +05:30
Saumya Pandey 3cfca47978 Use star and unstar policies 2021-08-12 03:05:24 +05:30
Saumya Pandey 35c6b64077 Update unarchive policy to not assert for collection 2021-08-12 02:55:27 +05:30
Saumya Pandey 0095830000 Update star policy 2021-08-12 02:44:01 +05:30
Saumya Pandey ab5b3e151a Don't move archive 2021-08-12 02:35:51 +05:30
Saumya Pandey 65e5030efd Fix bug in test 2021-08-12 01:20:38 +05:30
Saumya Pandey e16bfd83ef Add modal to publish flow 2021-08-11 18:21:16 +05:30
Saumya Pandey d98ec86307 Create documentUpdater command 2021-08-11 17:06:27 +05:30
Saumya Pandey 3caeb8ba19 Create choose collection modal 2021-08-11 01:16:16 +05:30
Saumya Pandey ef8be0dd0b Refactor PathToDocument 2021-08-11 01:16:01 +05:30
Saumya Pandey 44f3ef869a Use documents.update endpoint 2021-08-10 03:35:37 +05:30
Saumya Pandey c43157a2fa Quick filteroptions to choose collection 2021-08-07 01:06:29 +05:30
Saumya Pandey b7f7af2480 Use computedCollectionId 2021-08-06 23:32:14 +05:30
Saumya Pandey fd71f82ec9 Create draft without collection 2021-08-06 23:31:49 +05:30
Saumya Pandey 4e29b14426 Updates on server to support drafts with no collection 2021-08-06 23:18:53 +05:30
38 changed files with 883 additions and 432 deletions
+11 -5
View File
@@ -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") {
+4 -3
View File
@@ -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 />
+10 -9
View File
@@ -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}
+3 -1
View File
@@ -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;
+19
View File
@@ -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;
+41 -49
View File
@@ -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} />
)}
&nbsp;
{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);
+140
View File
@@ -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,
};
}
+1 -1
View File
@@ -180,7 +180,7 @@ function CollectionMenu({
]
);
if (!items.length) {
if (!items.some((item) => item.visible)) {
return null;
}
+9 -5
View File
@@ -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 />,
},
{
+7 -3
View File
@@ -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,
}),
},
-77
View File
@@ -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);
+3 -1
View File
@@ -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,
});
}
+1
View File
@@ -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} />
+17 -2
View File
@@ -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)
)
);
+19 -4
View File
@@ -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;
+1 -1
View File
@@ -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)
: [];
+1 -1
View File
@@ -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
View File
@@ -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>
);
+4 -3
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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>
</>
}
+2 -2
View File
@@ -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 />
)}
&nbsp;&nbsp;
<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 [
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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 || "");
}
}
+3 -1
View File
@@ -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
+4 -4
View File
@@ -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
+2 -1
View File
@@ -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 -1
View File
@@ -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";
+106 -62
View File
@@ -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,
},
});
}
+17
View File
@@ -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) => {
+15 -6
View File
@@ -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
View File
@@ -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({
+53 -2
View File
@@ -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();
+11 -4
View File
@@ -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,
+7 -6
View File
@@ -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 dont have permission to access the document": "Sorry, it looks like you dont 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",
"Couldnt create the document, try again?": "Couldnt 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.",