diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx
index b23cdf0be0..46ac078483 100644
--- a/app/components/DocumentBreadcrumb.tsx
+++ b/app/components/DocumentBreadcrumb.tsx
@@ -1,11 +1,5 @@
import { observer } from "mobx-react";
-import {
- ArchiveIcon,
- EditIcon,
- GoToIcon,
- ShapesIcon,
- TrashIcon,
-} from "outline-icons";
+import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -43,15 +37,6 @@ function useCategory(document: Document): MenuInternalLink | null {
};
}
- if (document.isDraft) {
- return {
- type: "route",
- icon: ,
- title: t("Drafts"),
- to: "/drafts",
- };
- }
-
if (document.isTemplate) {
return {
type: "route",
@@ -90,7 +75,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const path = React.useMemo(
() => collection?.pathToDocument?.(document.id).slice(0, -1) || [],
- [collection, document.id]
+ [collection, document]
);
const items = React.useMemo(() => {
diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx
index fe0023a94a..a9a879eb58 100644
--- a/app/components/DocumentListItem.tsx
+++ b/app/components/DocumentListItem.tsx
@@ -25,7 +25,7 @@ type Props = {
document: Document;
highlight?: string | undefined;
context?: string | undefined;
- showNestedDocuments?: boolean;
+ showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
@@ -52,7 +52,7 @@ function DocumentListItem(
const {
document,
- showNestedDocuments,
+ showParentDocuments,
showCollection,
showPublished,
showPin,
@@ -89,7 +89,7 @@ function DocumentListItem(
highlight={highlight}
dir={document.dir}
/>
- {document.isNew && document.createdBy.id !== currentUser.id && (
+ {document.isBadgedNew && document.createdBy.id !== currentUser.id && (
{t("New")}
)}
{canStar && (
@@ -122,7 +122,7 @@ function DocumentListItem(
document={document}
showCollection={showCollection}
showPublished={showPublished}
- showNestedDocuments={showNestedDocuments}
+ showParentDocuments={showParentDocuments}
showLastViewed
/>
diff --git a/app/components/DocumentMeta.tsx b/app/components/DocumentMeta.tsx
index 1ec2d61ba0..0879617618 100644
--- a/app/components/DocumentMeta.tsx
+++ b/app/components/DocumentMeta.tsx
@@ -34,7 +34,7 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
- showNestedDocuments?: boolean;
+ showParentDocuments?: boolean;
document: Document;
children?: React.ReactNode;
to?: string;
@@ -44,7 +44,7 @@ function DocumentMeta({
showPublished,
showCollection,
showLastViewed,
- showNestedDocuments,
+ showParentDocuments,
document,
children,
to,
@@ -152,7 +152,7 @@ function DocumentMeta({
)}
- {showNestedDocuments && nestedDocumentsCount > 0 && (
+ {showParentDocuments && nestedDocumentsCount > 0 && (
• {nestedDocumentsCount}{" "}
{t("nested document", {
diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx
index b784811271..cd54137178 100644
--- a/app/components/Editor.tsx
+++ b/app/components/Editor.tsx
@@ -47,7 +47,7 @@ export type Props = {
readOnlyWriteCheckboxes?: boolean;
onBlur?: () => void;
onFocus?: () => void;
- onPublish?: (event: React.SyntheticEvent) => any;
+ onPublish?: (event: React.MouseEvent) => any;
onSave?: (arg0: {
done?: boolean;
autosave?: boolean;
diff --git a/app/components/PaginatedDocumentList.tsx b/app/components/PaginatedDocumentList.tsx
index 06f07b976d..9d02e694b6 100644
--- a/app/components/PaginatedDocumentList.tsx
+++ b/app/components/PaginatedDocumentList.tsx
@@ -9,7 +9,7 @@ type Props = {
options?: Record;
heading?: React.ReactNode;
empty?: React.ReactNode;
- showNestedDocuments?: boolean;
+ showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
diff --git a/app/components/Sidebar/Main.tsx b/app/components/Sidebar/Main.tsx
index 78fbeeabd5..9b2de6a652 100644
--- a/app/components/Sidebar/Main.tsx
+++ b/app/components/Sidebar/Main.tsx
@@ -101,13 +101,6 @@ function MainSidebar() {
}
- active={
- documents.active
- ? !documents.active.publishedAt &&
- !documents.active.isDeleted &&
- !documents.active.isTemplate
- : undefined
- }
/>
)}
diff --git a/app/components/Sidebar/components/CollectionLink.tsx b/app/components/Sidebar/components/CollectionLink.tsx
index 70b81a5691..dd7b611634 100644
--- a/app/components/Sidebar/components/CollectionLink.tsx
+++ b/app/components/Sidebar/components/CollectionLink.tsx
@@ -5,6 +5,7 @@ import { useDrop, useDrag } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useLocation, useHistory } from "react-router-dom";
import styled from "styled-components";
+import { sortNavigationNodes } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import DocumentReparent from "~/scenes/DocumentReparent";
@@ -25,7 +26,7 @@ type Props = {
collection: Collection;
canUpdate: boolean;
activeDocument: Document | null | undefined;
- prefetchDocument: (id: string) => Promise;
+ prefetchDocument: (id: string) => Promise;
belowCollection: Collection | void;
};
@@ -152,6 +153,31 @@ function CollectionLink({
},
});
+ const collectionDocuments = React.useMemo(() => {
+ if (
+ activeDocument?.isActive &&
+ activeDocument?.isDraft &&
+ activeDocument?.collectionId === collection.id &&
+ !activeDocument?.parentDocumentId
+ ) {
+ return sortNavigationNodes(
+ [activeDocument.asNavigationNode, ...collection.documents],
+ collection.sort
+ );
+ }
+
+ return collection.documents;
+ }, [
+ activeDocument?.isActive,
+ activeDocument?.isDraft,
+ activeDocument?.collectionId,
+ activeDocument?.parentDocumentId,
+ activeDocument?.asNavigationNode,
+ collection.documents,
+ collection.id,
+ collection.sort,
+ ]);
+
const isDraggingAnyCollection =
isDraggingAnotherCollection || isCollectionDragging;
@@ -229,9 +255,8 @@ function CollectionLink({
/>
)}
-
{expanded &&
- collection.documents.map((node, index) => (
+ collectionDocuments.map((node, index) => (
diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx
index f16fc75eb4..471fd0f967 100644
--- a/app/components/Sidebar/components/DocumentLink.tsx
+++ b/app/components/Sidebar/components/DocumentLink.tsx
@@ -4,6 +4,7 @@ import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "@shared/constants";
+import { sortNavigationNodes } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Fade from "~/components/Fade";
@@ -22,7 +23,8 @@ type Props = {
canUpdate: boolean;
collection?: Collection;
activeDocument: Document | null | undefined;
- prefetchDocument: (documentId: string) => Promise;
+ prefetchDocument: (documentId: string) => Promise;
+ isDraft?: boolean;
depth: number;
index: number;
parentId?: string;
@@ -35,6 +37,7 @@ function DocumentLink(
collection,
activeDocument,
prefetchDocument,
+ isDraft,
depth,
index,
parentId,
@@ -135,9 +138,10 @@ function DocumentLink(
}),
canDrag: () => {
return (
- policies.abilities(node.id).move ||
- policies.abilities(node.id).archive ||
- policies.abilities(node.id).delete
+ !isDraft &&
+ (policies.abilities(node.id).move ||
+ policies.abilities(node.id).archive ||
+ policies.abilities(node.id).delete)
);
},
});
@@ -216,6 +220,33 @@ function DocumentLink(
}),
});
+ const nodeChildren = React.useMemo(() => {
+ if (
+ collection &&
+ activeDocument?.isDraft &&
+ activeDocument?.isActive &&
+ activeDocument?.parentDocumentId === node.id
+ ) {
+ return sortNavigationNodes(
+ [activeDocument?.asNavigationNode, ...node.children],
+ collection.sort
+ );
+ }
+
+ return node.children;
+ }, [
+ activeDocument?.isActive,
+ activeDocument?.isDraft,
+ activeDocument?.parentDocumentId,
+ activeDocument?.asNavigationNode,
+ collection,
+ node,
+ ]);
+
+ const title =
+ (activeDocument?.id === node.id ? activeDocument.title : node.title) ||
+ t("Untitled");
+
return (
<>
@@ -244,7 +275,7 @@ function DocumentLink(
/>
)}
)}
- {expanded && !isDragging && (
- <>
- {node.children.map((childNode, index) => (
-
- ))}
- >
- )}
+ {expanded &&
+ !isDragging &&
+ nodeChildren.map((childNode, index) => (
+
+ ))}
>
);
}
diff --git a/app/components/Sidebar/components/SidebarLink.tsx b/app/components/Sidebar/components/SidebarLink.tsx
index 965dfab186..9dbaf0d609 100644
--- a/app/components/Sidebar/components/SidebarLink.tsx
+++ b/app/components/Sidebar/components/SidebarLink.tsx
@@ -1,6 +1,6 @@
import { transparentize } from "polished";
import * as React from "react";
-import styled, { useTheme } from "styled-components";
+import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "~/components/EventBoundary";
import NudeButton from "~/components/NudeButton";
@@ -25,6 +25,7 @@ type Props = Omit & {
showActions?: boolean;
active?: boolean;
isActiveDrop?: boolean;
+ isDraft?: boolean;
depth?: number;
scrollIntoViewIfNeeded?: boolean;
};
@@ -42,6 +43,7 @@ function SidebarLink(
label,
active,
isActiveDrop,
+ isDraft,
menu,
showActions,
exact,
@@ -74,6 +76,7 @@ function SidebarLink(
<>
`
}
`;
-const Link = styled(NavLink)<{ $isActiveDrop?: boolean }>`
+const Link = styled(NavLink)<{ $isActiveDrop?: boolean; $isDraft?: boolean }>`
display: flex;
position: relative;
text-overflow: ellipsis;
@@ -143,6 +146,13 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean }>`
cursor: pointer;
overflow: hidden;
+ ${(props) =>
+ props.$isDraft &&
+ css`
+ padding: 4px 14px;
+ border: 1px dashed ${props.theme.sidebarDraftBorder};
+ `}
+
svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
transition: fill 50ms;
diff --git a/app/components/Star.tsx b/app/components/Star.tsx
index 8b438c9eeb..d2e9c9c9a3 100644
--- a/app/components/Star.tsx
+++ b/app/components/Star.tsx
@@ -1,7 +1,7 @@
import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
-import styled from "styled-components";
+import styled, { useTheme } from "styled-components";
import Document from "~/models/Document";
import NudeButton from "./NudeButton";
@@ -12,6 +12,7 @@ type Props = {
function Star({ size, document, ...rest }: Props) {
const { t } = useTranslation();
+ const theme = useTheme();
const handleClick = React.useCallback(
(ev: React.MouseEvent) => {
@@ -32,25 +33,25 @@ function Star({ size, document, ...rest }: Props) {
}
return (
-
+
);
}
-const Button = styled(NudeButton)`
- color: ${(props) => props.theme.text};
-`;
-
export const AnimatedStar = styled(StarredIcon)`
flex-shrink: 0;
transition: all 100ms ease-in-out;
diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx
index 2a9d9e92b5..df8985d521 100644
--- a/app/menus/DocumentMenu.tsx
+++ b/app/menus/DocumentMenu.tsx
@@ -359,7 +359,8 @@ function DocumentMenu({
type: "button",
title: `${t("Create template")}…`,
onClick: () => setShowTemplateModal(true),
- visible: !!can.update && !document.isTemplate,
+ visible:
+ !!can.update && !document.isTemplate && !document.isDraft,
icon: ,
},
{
diff --git a/app/models/ApiKey.ts b/app/models/ApiKey.ts
index a946ecc64c..8d02e76aa8 100644
--- a/app/models/ApiKey.ts
+++ b/app/models/ApiKey.ts
@@ -1,8 +1,14 @@
+import { observable } from "mobx";
import BaseModel from "./BaseModel";
+import Field from "./decorators/Field";
class ApiKey extends BaseModel {
+ @Field
+ @observable
id: string;
+ @Field
+ @observable
name: string;
secret: string;
diff --git a/app/models/BaseModel.ts b/app/models/BaseModel.ts
index 5f34527d2d..6f0e0a97a5 100644
--- a/app/models/BaseModel.ts
+++ b/app/models/BaseModel.ts
@@ -1,4 +1,6 @@
-import { set, observable } from "mobx";
+import { pick } from "lodash";
+import { set, computed, observable } from "mobx";
+import { getFieldsForModel } from "./decorators/Field";
export default class BaseModel {
@observable
@@ -10,7 +12,7 @@ export default class BaseModel {
store: any;
constructor(fields: Record, store: any) {
- set(this, fields);
+ this.updateFromJson(fields);
this.store = store;
}
@@ -19,16 +21,28 @@ export default class BaseModel {
try {
// ensure that the id is passed if the document has one
- if (params) params = { ...params, id: this.id };
+ if (params) {
+ params = { ...params, id: this.id };
+ }
+
const model = await this.store.save(params || this.toJS());
+
// if saving is successful set the new values on the model itself
set(this, { ...params, ...model });
+
+ this.persistedAttributes = this.toJS();
+
return model;
} finally {
this.isSaving = false;
}
};
+ updateFromJson = (data: any) => {
+ set(this, data);
+ this.persistedAttributes = this.toJS();
+ };
+
fetch = (options?: any) => {
return this.store.fetch(this.id, options);
};
@@ -49,7 +63,43 @@ export default class BaseModel {
}
};
+ /**
+ * Returns a plain object representation of the model
+ *
+ * @returns {Record}
+ */
toJS = (): Record => {
- return { ...this };
+ const fields = getFieldsForModel(this);
+ return pick(this, fields) || [];
};
+
+ /**
+ * Returns a boolean indicating if the model has changed since it was last
+ * persisted to the server
+ *
+ * @returns boolean true if unsaved
+ */
+ isDirty(): boolean {
+ const attributes = this.toJS();
+
+ if (Object.keys(attributes).length === 0) {
+ console.warn("Checking dirty on model with no @Field decorators");
+ }
+
+ return (
+ JSON.stringify(this.persistedAttributes) !== JSON.stringify(attributes)
+ );
+ }
+
+ /**
+ * Returns a boolean indicating whether the model has been persisted to db
+ *
+ * @returns boolean true if the model has never been persisted
+ */
+ @computed
+ get isNew(): boolean {
+ return !this.id;
+ }
+
+ protected persistedAttributes: Partial = {};
}
diff --git a/app/models/Collection.ts b/app/models/Collection.ts
index b0ed05a80c..ec22f5dc8a 100644
--- a/app/models/Collection.ts
+++ b/app/models/Collection.ts
@@ -1,9 +1,10 @@
-import { pick, trim } from "lodash";
+import { trim } from "lodash";
import { action, computed, observable } from "mobx";
import BaseModel from "~/models/BaseModel";
import Document from "~/models/Document";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
+import Field from "./decorators/Field";
export default class Collection extends BaseModel {
@observable
@@ -12,22 +13,45 @@ export default class Collection extends BaseModel {
@observable
isLoadingUsers: boolean;
+ @Field
+ @observable
id: string;
+ @Field
+ @observable
name: string;
+ @Field
+ @observable
description: string;
+ @Field
+ @observable
icon: string;
+ @Field
+ @observable
color: string;
+ @Field
+ @observable
permission: "read" | "read_write" | void;
+ @Field
+ @observable
sharing: boolean;
+ @Field
+ @observable
index: string;
+ @Field
+ @observable
+ sort: {
+ field: string;
+ direction: "asc" | "desc";
+ };
+
documents: NavigationNode[];
createdAt: string;
@@ -36,11 +60,6 @@ export default class Collection extends BaseModel {
deletedAt: string | null | undefined;
- sort: {
- field: string;
- direction: "asc" | "desc";
- };
-
url: string;
urlId: string;
@@ -112,6 +131,7 @@ export default class Collection extends BaseModel {
pathToDocument(documentId: string) {
let path: NavigationNode[] | undefined;
+ const document = this.store.rootStore.documents.get(documentId);
const travelNodes = (
nodes: NavigationNode[],
@@ -125,6 +145,14 @@ export default class Collection extends BaseModel {
return;
}
+ if (
+ document?.parentDocumentId &&
+ node?.id === document?.parentDocumentId
+ ) {
+ path = [...newPath, document.asNavigationNode];
+ return;
+ }
+
return travelNodes(node.children, newPath);
});
};
@@ -136,20 +164,6 @@ export default class Collection extends BaseModel {
return path || [];
}
- toJS = () => {
- return pick(this, [
- "id",
- "name",
- "color",
- "description",
- "sharing",
- "icon",
- "permission",
- "sort",
- "index",
- ]);
- };
-
export = () => {
return client.get("/collections.export", {
id: this.id,
diff --git a/app/models/Document.ts b/app/models/Document.ts
index af6cbbe7bc..7f89953598 100644
--- a/app/models/Document.ts
+++ b/app/models/Document.ts
@@ -7,7 +7,9 @@ import unescape from "@shared/utils/unescape";
import DocumentsStore from "~/stores/DocumentsStore";
import BaseModel from "~/models/BaseModel";
import User from "~/models/User";
+import { NavigationNode } from "~/types";
import View from "./View";
+import Field from "./decorators/Field";
type SaveOptions = {
publish?: boolean;
@@ -28,10 +30,36 @@ export default class Document extends BaseModel {
store: DocumentsStore;
- collaboratorIds: string[];
-
+ @Field
+ @observable
collectionId: string;
+ @Field
+ @observable
+ id: string;
+
+ @Field
+ @observable
+ text: string;
+
+ @Field
+ @observable
+ title: string;
+
+ @Field
+ @observable
+ template: boolean;
+
+ @Field
+ @observable
+ templateId: string | undefined;
+
+ @Field
+ @observable
+ parentDocumentId: string | undefined;
+
+ collaboratorIds: string[];
+
createdAt: string;
createdBy: User;
@@ -40,22 +68,8 @@ export default class Document extends BaseModel {
updatedBy: User;
- id: string;
-
- team: string;
-
pinned: boolean;
- text: string;
-
- title: string;
-
- template: boolean;
-
- templateId: string | undefined;
-
- parentDocumentId: string | undefined;
-
publishedAt: string | undefined;
archivedAt: string;
@@ -76,7 +90,7 @@ export default class Document extends BaseModel {
constructor(fields: Record, store: DocumentsStore) {
super(fields, store);
- if (this.isNewDocument && this.isFromTemplate) {
+ if (this.isPersistedOnce && this.isFromTemplate) {
this.title = "";
}
}
@@ -122,7 +136,7 @@ export default class Document extends BaseModel {
}
@computed
- get isNew(): boolean {
+ get isBadgedNew(): boolean {
return (
!this.lastViewedAt &&
differenceInDays(new Date(), new Date(this.createdAt)) < 14
@@ -169,7 +183,7 @@ export default class Document extends BaseModel {
}
@computed
- get isNewDocument(): boolean {
+ get isPersistedOnce(): boolean {
return this.createdAt === this.updatedAt;
}
@@ -199,11 +213,6 @@ export default class Document extends BaseModel {
});
};
- @action
- updateFromJson = (data: Record) => {
- set(this, data);
- };
-
archive = () => {
return this.store.archive(this);
};
@@ -376,6 +385,24 @@ export default class Document extends BaseModel {
return result;
};
+ @computed
+ get isActive(): boolean {
+ return !this.isDeleted && !this.isTemplate && !this.isArchived;
+ }
+
+ @computed
+ get asNavigationNode(): NavigationNode {
+ return {
+ id: this.id,
+ title: this.title,
+ children: this.store.orderedData
+ .filter((doc) => doc.parentDocumentId === this.id)
+ .map((doc) => doc.asNavigationNode),
+ url: this.url,
+ isDraft: this.isDraft,
+ };
+ }
+
download = async () => {
// Ensure the document is upto date with latest server contents
await this.fetch();
diff --git a/app/models/Group.ts b/app/models/Group.ts
index 17adf78069..2a20f73e74 100644
--- a/app/models/Group.ts
+++ b/app/models/Group.ts
@@ -1,19 +1,19 @@
+import { observable } from "mobx";
import BaseModel from "./BaseModel";
+import Field from "./decorators/Field";
class Group extends BaseModel {
+ @Field
+ @observable
id: string;
+ @Field
+ @observable
name: string;
memberCount: number;
updatedAt: string;
-
- toJS = () => {
- return {
- name: this.name,
- };
- };
}
export default Group;
diff --git a/app/models/NotificationSetting.ts b/app/models/NotificationSetting.ts
index 59ac168501..d131969289 100644
--- a/app/models/NotificationSetting.ts
+++ b/app/models/NotificationSetting.ts
@@ -1,8 +1,14 @@
+import { observable } from "mobx";
import BaseModel from "./BaseModel";
+import Field from "./decorators/Field";
class NotificationSetting extends BaseModel {
+ @Field
+ @observable
id: string;
+ @Field
+ @observable
event: string;
}
diff --git a/app/models/Share.ts b/app/models/Share.ts
index df4d45da7e..d139857047 100644
--- a/app/models/Share.ts
+++ b/app/models/Share.ts
@@ -1,13 +1,23 @@
+import { observable } from "mobx";
import BaseModel from "./BaseModel";
import User from "./User";
+import Field from "./decorators/Field";
class Share extends BaseModel {
+ @Field
+ @observable
id: string;
- url: string;
-
+ @Field
+ @observable
published: boolean;
+ @Field
+ @observable
+ includeChildDocuments: boolean;
+
+ @Field
+ @observable
documentId: string;
documentTitle: string;
@@ -16,7 +26,7 @@ class Share extends BaseModel {
lastAccessedAt: string | null | undefined;
- includeChildDocuments: boolean;
+ url: string;
createdBy: User;
diff --git a/app/models/Team.ts b/app/models/Team.ts
index 97bb67f8ee..d8fe7167e4 100644
--- a/app/models/Team.ts
+++ b/app/models/Team.ts
@@ -1,29 +1,48 @@
-import { computed } from "mobx";
+import { computed, observable } from "mobx";
import BaseModel from "./BaseModel";
+import Field from "./decorators/Field";
class Team extends BaseModel {
+ @Field
+ @observable
id: string;
+ @Field
+ @observable
name: string;
+ @Field
+ @observable
avatarUrl: string;
+ @Field
+ @observable
sharing: boolean;
+ @Field
+ @observable
collaborativeEditing: boolean;
+ @Field
+ @observable
documentEmbeds: boolean;
+ @Field
+ @observable
guestSignin: boolean;
+ @Field
+ @observable
subdomain: string | null | undefined;
+ @Field
+ @observable
+ defaultUserRole: string;
+
domain: string | null | undefined;
url: string;
- defaultUserRole: string;
-
@computed
get signinMethods(): string {
return "SSO";
diff --git a/app/models/User.ts b/app/models/User.ts
index 67c03a103c..c22a564885 100644
--- a/app/models/User.ts
+++ b/app/models/User.ts
@@ -1,18 +1,31 @@
-import { computed } from "mobx";
+import { computed, observable } from "mobx";
import { Role } from "@shared/types";
import BaseModel from "./BaseModel";
+import Field from "./decorators/Field";
class User extends BaseModel {
- avatarUrl: string;
-
+ @Field
+ @observable
id: string;
+ @Field
+ @observable
+ avatarUrl: string;
+
+ @Field
+ @observable
name: string;
- email: string;
-
+ @Field
+ @observable
color: string;
+ @Field
+ @observable
+ language: string;
+
+ email: string;
+
isAdmin: boolean;
isViewer: boolean;
@@ -23,8 +36,6 @@ class User extends BaseModel {
createdAt: string;
- language: string;
-
@computed
get isInvited(): boolean {
return !this.lastActiveAt;
diff --git a/app/models/decorators/Field.ts b/app/models/decorators/Field.ts
new file mode 100644
index 0000000000..4dc12abc85
--- /dev/null
+++ b/app/models/decorators/Field.ts
@@ -0,0 +1,19 @@
+const fields = new Map();
+
+export const getFieldsForModel = (target: any) => {
+ return fields.get(target.constructor.name);
+};
+
+/**
+ * A decorator that records this key as a serializable field on the model.
+ * Properties decorated with @Field will be included in API requests by default.
+ *
+ * @param target
+ * @param propertyKey
+ */
+const Field = (target: any, propertyKey: keyof T) => {
+ const className = target.constructor.name;
+ fields.set(className, [...(fields.get(className) || []), propertyKey]);
+};
+
+export default Field;
diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx
index d8fcd4757c..35dc170499 100644
--- a/app/scenes/Collection.tsx
+++ b/app/scenes/Collection.tsx
@@ -377,7 +377,7 @@ function CollectionScene() {
sort: collection.sort.field,
direction: "ASC",
}}
- showNestedDocuments
+ showParentDocuments
showPin
/>
diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx
index ba9e61f0e1..8bdfe9c333 100644
--- a/app/scenes/Document/components/DataLoader.tsx
+++ b/app/scenes/Document/components/DataLoader.tsx
@@ -15,7 +15,7 @@ import withStores from "~/components/withStores";
import { NavigationNode } from "~/types";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
-import { matchDocumentEdit, updateDocumentUrl } from "~/utils/routeHelpers";
+import { matchDocumentEdit } from "~/utils/routeHelpers";
import { isInternalUrl } from "~/utils/urls";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
@@ -228,17 +228,6 @@ class DataLoader extends React.Component {
}
});
}
-
- const isMove = this.props.location.pathname.match(/move$/);
- const canRedirect = !revisionId && !isMove && !shareId;
-
- if (canRedirect) {
- const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
-
- if (this.props.location.pathname !== canonicalUrl) {
- history.replace(canonicalUrl);
- }
- }
}
};
diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx
index c751ae87c2..6c10c41b71 100644
--- a/app/scenes/Document/components/Document.tsx
+++ b/app/scenes/Document/components/Document.tsx
@@ -11,6 +11,7 @@ import {
RouteComponentProps,
StaticContext,
withRouter,
+ Redirect,
} from "react-router";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -41,6 +42,7 @@ import {
documentHistoryUrl,
editDocumentUrl,
documentUrl,
+ updateDocumentUrl,
} from "~/utils/routeHelpers";
import Container from "./Container";
import Contents from "./Contents";
@@ -52,7 +54,6 @@ import PublicReferences from "./PublicReferences";
import References from "./References";
const AUTOSAVE_DELAY = 3000;
-const IS_DIRTY_DELAY = 500;
type Props = WithTranslation &
RootStore &
@@ -74,7 +75,7 @@ type Props = WithTranslation &
@observer
class DocumentScene extends React.Component {
@observable
- editor = React.createRef();
+ editor = React.createRef();
@observable
isUploading = false;
@@ -86,7 +87,7 @@ class DocumentScene extends React.Component {
isPublishing = false;
@observable
- isDirty = false;
+ isEditorDirty = false;
@observable
isEmpty = true;
@@ -114,12 +115,6 @@ class DocumentScene extends React.Component {
this.lastRevision = document.revision;
}
- if (this.props.readOnly) {
- if (document.title !== this.title) {
- this.title = document.title;
- }
- }
-
if (
!this.props.readOnly &&
!auth.team?.collaborativeEditing &&
@@ -146,8 +141,6 @@ class DocumentScene extends React.Component {
}
replaceDocument = (template: Document | Revision) => {
- this.title = template.title;
- this.isDirty = true;
const editorRef = this.editor.current;
if (!editorRef) {
@@ -162,6 +155,8 @@ class DocumentScene extends React.Component {
.replaceSelectionWith(parser.parse(template.text))
);
+ this.isEditorDirty = true;
+
if (template instanceof Document) {
this.props.document.templateId = template.id;
}
@@ -192,8 +187,7 @@ class DocumentScene extends React.Component {
}
};
- // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
- goToMove = (ev) => {
+ goToMove = (ev: KeyboardEvent) => {
if (!this.props.readOnly) return;
ev.preventDefault();
const { document, abilities } = this.props;
@@ -203,8 +197,7 @@ class DocumentScene extends React.Component {
}
};
- // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
- goToEdit = (ev) => {
+ goToEdit = (ev: KeyboardEvent) => {
if (!this.props.readOnly) return;
ev.preventDefault();
const { document, abilities } = this.props;
@@ -214,8 +207,7 @@ class DocumentScene extends React.Component {
}
};
- // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
- goToHistory = (ev) => {
+ goToHistory = (ev: KeyboardEvent) => {
if (!this.props.readOnly) return;
if (ev.ctrlKey) return;
ev.preventDefault();
@@ -228,8 +220,7 @@ class DocumentScene extends React.Component {
}
};
- // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
- onPublish = (ev) => {
+ onPublish = (ev: React.MouseEvent | KeyboardEvent) => {
ev.preventDefault();
const { document } = this.props;
if (document.publishedAt) return;
@@ -239,8 +230,7 @@ class DocumentScene extends React.Component {
});
};
- // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ev' implicitly has an 'any' type.
- onToggleTableOfContents = (ev) => {
+ onToggleTableOfContents = (ev: KeyboardEvent) => {
if (!this.props.readOnly) return;
ev.preventDefault();
const { ui } = this.props;
@@ -265,25 +255,18 @@ class DocumentScene extends React.Component {
// get the latest version of the editor text value
const text = this.getEditorText ? this.getEditorText() : document.text;
- const title = this.title;
// prevent save before anything has been written (single hash is empty doc)
- // @ts-expect-error ts-migrate(2367) FIXME: This condition will always return 'false' since th... Remove this comment to see the full error message
- if (text.trim() === "" && title.trim === "") return;
+ if (text.trim() === "" && document.title.trim() === "") return;
+
+ document.text = text;
+ document.tasks = getTasks(document.text);
// prevent autosave if nothing has changed
- if (
- options.autosave &&
- document.text.trim() === text.trim() &&
- document.title.trim() === title.trim()
- ) {
+ if (options.autosave && !this.isEditorDirty && !document.isDirty()) {
return;
}
- document.title = title;
- document.text = text;
- document.tasks = getTasks(document.text);
- const isNew = !document.id;
this.isSaving = true;
this.isPublishing = !!options.publish;
@@ -305,13 +288,13 @@ class DocumentScene extends React.Component {
});
}
- this.isDirty = false;
+ this.isEditorDirty = false;
this.lastRevision = savedDocument.revision;
if (options.done) {
this.props.history.push(savedDocument.url);
this.props.ui.setActiveDocument(savedDocument);
- } else if (isNew) {
+ } else if (document.isNew) {
this.props.history.push(editDocumentUrl(savedDocument));
this.props.ui.setActiveDocument(savedDocument);
}
@@ -335,15 +318,13 @@ class DocumentScene extends React.Component {
updateIsDirty = () => {
const { document } = this.props;
const editorText = this.getEditorText().trim();
- const titleChanged = this.title !== document.title;
- const bodyChanged = editorText !== document.text.trim();
+ this.isEditorDirty = editorText !== document.text.trim();
// a single hash is a doc with just an empty title
this.isEmpty = (!editorText || editorText === "#") && !this.title;
- this.isDirty = bodyChanged || titleChanged;
};
- updateIsDirtyDebounced = debounce(this.updateIsDirty, IS_DIRTY_DELAY);
+ updateIsDirtyDebounced = debounce(this.updateIsDirty, 500);
onImageUploadStart = () => {
this.isUploading = true;
@@ -381,11 +362,11 @@ class DocumentScene extends React.Component {
}
};
- onChangeTitle = (value: string) => {
- this.title = value;
- this.updateIsDirtyDebounced();
+ onChangeTitle = action((value: string) => {
+ this.props.document.title = value;
+ this.updateIsDirty();
this.autosave();
- };
+ });
goBack = () => {
this.props.history.push(this.props.document.url);
@@ -420,8 +401,13 @@ class DocumentScene extends React.Component {
!revision &&
!isShare;
+ const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
+
return (
+ {this.props.location.pathname !== canonicalUrl && (
+
+ )}
@@ -468,7 +454,7 @@ class DocumentScene extends React.Component {
<>
{
)}
/>
{
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
- title={revision ? revision.title : this.title}
+ title={revision ? revision.title : document.title}
document={document}
value={readOnly ? value : undefined}
defaultValue={value}
@@ -639,11 +625,13 @@ const ReferencesWrapper = styled.div<{ isOnlyTitle?: boolean }>`
}
`;
-const MaxWidth = styled(Flex)<{
+type MaxWidthProps = {
isEditing?: boolean;
archived?: boolean;
showContents?: boolean;
-}>`
+};
+
+const MaxWidth = styled(Flex)`
${(props) =>
props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
@@ -657,7 +645,7 @@ const MaxWidth = styled(Flex)<{
${breakpoint("tablet")`
padding: 0 24px;
margin: 4px auto 12px;
- max-width: calc(48px + ${(props: any) =>
+ max-width: calc(48px + ${(props: MaxWidthProps) =>
props.showContents ? "64em" : "46em"});
`};
diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx
index 4ad16bb994..bcd462c381 100644
--- a/app/scenes/Document/components/Header.tsx
+++ b/app/scenes/Document/components/Header.tsx
@@ -90,7 +90,7 @@ function DocumentHeader({
});
}, [onSave]);
- const isNew = document.isNewDocument;
+ const isNew = document.isPersistedOnce;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canToggleEmbeds = team?.documentEmbeds;
diff --git a/app/scenes/Document/components/References.tsx b/app/scenes/Document/components/References.tsx
index e0c6cc6f28..c41f360c9a 100644
--- a/app/scenes/Document/components/References.tsx
+++ b/app/scenes/Document/components/References.tsx
@@ -27,13 +27,13 @@ function References({ document }: Props) {
? collection.getDocumentChildren(document.id)
: [];
const showBacklinks = !!backlinks.length;
- const showNestedDocuments = !!children.length;
- const isBacklinksTab = location.hash === "#backlinks" || !showNestedDocuments;
+ const showParentDocuments = !!children.length;
+ const isBacklinksTab = location.hash === "#backlinks" || !showParentDocuments;
- return showBacklinks || showNestedDocuments ? (
+ return showBacklinks || showParentDocuments ? (
- {showNestedDocuments && (
+ {showParentDocuments && (
!isBacklinksTab}>
Nested documents
diff --git a/app/scenes/Drafts.tsx b/app/scenes/Drafts.tsx
index 19e18a51c0..f3244c75f6 100644
--- a/app/scenes/Drafts.tsx
+++ b/app/scenes/Drafts.tsx
@@ -124,6 +124,7 @@ class Drafts extends React.Component {
fetch={this.props.documents.fetchDrafts}
documents={this.props.documents.drafts(options)}
options={options}
+ showParentDocuments
showCollection
/>
diff --git a/app/stores/BaseStore.ts b/app/stores/BaseStore.ts
index 5be25f98ca..f001112de6 100644
--- a/app/stores/BaseStore.ts
+++ b/app/stores/BaseStore.ts
@@ -86,7 +86,7 @@ export default class BaseStore {
const existingModel = this.data.get(item.id);
if (existingModel) {
- set(existingModel, item);
+ existingModel.updateFromJson(item);
return existingModel;
}
diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts
index 43e6a250bb..5c95fc794b 100644
--- a/app/stores/DocumentsStore.ts
+++ b/app/stores/DocumentsStore.ts
@@ -233,7 +233,7 @@ export default class DocumentsStore extends BaseStore {
};
@computed
- get active(): Document | null | undefined {
+ get active(): Document | undefined {
return this.rootStore.ui.activeDocumentId
? this.data.get(this.rootStore.ui.activeDocumentId)
: undefined;
diff --git a/app/stores/UiStore.ts b/app/stores/UiStore.ts
index 785e506fd0..55ba386697 100644
--- a/app/stores/UiStore.ts
+++ b/app/stores/UiStore.ts
@@ -122,12 +122,7 @@ class UiStore {
setActiveDocument = (document: Document): void => {
this.activeDocumentId = document.id;
- if (
- document.publishedAt &&
- !document.isArchived &&
- !document.isDeleted &&
- !document.isTemplate
- ) {
+ if (document.isActive) {
this.activeCollectionId = document.collectionId;
}
};
diff --git a/app/types.ts b/app/types.ts
index c39b21607f..e927f071f0 100644
--- a/app/types.ts
+++ b/app/types.ts
@@ -134,6 +134,7 @@ export type NavigationNode = {
title: string;
url: string;
children: NavigationNode[];
+ isDraft?: boolean;
};
// Pagination response in an API call
diff --git a/app/typings/styled-components.d.ts b/app/typings/styled-components.d.ts
index aaaf6095cf..43e78310cd 100644
--- a/app/typings/styled-components.d.ts
+++ b/app/typings/styled-components.d.ts
@@ -148,6 +148,7 @@ declare module "styled-components" {
placeholder: string;
sidebarBackground: string;
sidebarItemBackground: string;
+ sidebarDraftBorder: string;
sidebarText: string;
backdrop: string;
shadow: string;
diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts
index 2e80c58231..c655d6aa90 100644
--- a/app/utils/routeHelpers.ts
+++ b/app/utils/routeHelpers.ts
@@ -70,7 +70,10 @@ export function documentHistoryUrl(doc: Document, revisionId?: string): string {
*/
export function updateDocumentUrl(oldUrl: string, document: Document): string {
// Update url to match the current one
- return oldUrl.replace(new RegExp("/doc/[0-9a-zA-Z-_~]*"), document.url);
+ return oldUrl.replace(
+ new RegExp("/doc/([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})"),
+ document.url
+ );
}
export function newDocumentPath(
diff --git a/server/presenters/collection.ts b/server/presenters/collection.ts
index adb9b05456..a643f2bd05 100644
--- a/server/presenters/collection.ts
+++ b/server/presenters/collection.ts
@@ -1,24 +1,6 @@
-import naturalSort from "@shared/utils/naturalSort";
+import { sortNavigationNodes } from "@shared/utils/collections";
import { Collection } from "@server/models";
-type Document = {
- children: Document[];
- id: string;
- title: string;
- url: string;
-};
-
-// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'sort' implicitly has an 'any' type.
-const sortDocuments = (documents: Document[], sort): Document[] => {
- const orderedDocs = naturalSort(documents, sort.field, {
- direction: sort.direction,
- });
- return orderedDocs.map((document) => ({
- ...document,
- children: sortDocuments(document.children, sort),
- }));
-};
-
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
export default function present(collection: Collection) {
const data = {
@@ -47,11 +29,7 @@ export default function present(collection: Collection) {
};
}
- // "index" field is manually sorted and is represented by the documentStructure
- // already saved in the database, no further sort is needed
- if (data.sort.field !== "index") {
- data.documents = sortDocuments(collection.documentStructure, data.sort);
- }
+ data.documents = sortNavigationNodes(collection.documentStructure, data.sort);
return data;
}
diff --git a/server/routes/api/documents.ts b/server/routes/api/documents.ts
index e686b6f765..4785be0636 100644
--- a/server/routes/api/documents.ts
+++ b/server/routes/api/documents.ts
@@ -91,7 +91,7 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
- documentIds = collection.documentStructure
+ documentIds = (collection.documentStructure || [])
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type.
.map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
diff --git a/shared/theme.ts b/shared/theme.ts
index dddbbb3246..b5a410c7bb 100644
--- a/shared/theme.ts
+++ b/shared/theme.ts
@@ -134,6 +134,7 @@ export const light: DefaultTheme = {
placeholder: "#a2b2c3",
sidebarBackground: colors.warmGrey,
sidebarItemBackground: "#d7e0ea",
+ sidebarDraftBorder: darken("0.25", colors.warmGrey),
sidebarText: "rgb(78, 92, 110)",
backdrop: "rgba(0, 0, 0, 0.2)",
shadow: "rgba(0, 0, 0, 0.2)",
@@ -182,6 +183,7 @@ export const dark: DefaultTheme = {
placeholder: colors.slateDark,
sidebarBackground: colors.veryDarkBlue,
sidebarItemBackground: lighten(0.015, colors.almostBlack),
+ sidebarDraftBorder: darken("0.35", colors.slate),
sidebarText: colors.slate,
backdrop: "rgba(255, 255, 255, 0.3)",
shadow: "rgba(0, 0, 0, 0.6)",
diff --git a/shared/utils/collections.ts b/shared/utils/collections.ts
new file mode 100644
index 0000000000..38ea4379ad
--- /dev/null
+++ b/shared/utils/collections.ts
@@ -0,0 +1,27 @@
+import { NavigationNode } from "~/types";
+import naturalSort from "./naturalSort";
+
+type Sort = {
+ field: string;
+ direction: "asc" | "desc";
+};
+
+export const sortNavigationNodes = (
+ documents: NavigationNode[],
+ sort: Sort
+): NavigationNode[] => {
+ // "index" field is manually sorted and is represented by the documentStructure
+ // already saved in the database, no further sort is needed
+ if (sort.field === "index") {
+ return documents;
+ }
+
+ const orderedDocs = naturalSort(documents, sort.field, {
+ direction: sort.direction,
+ });
+
+ return orderedDocs.map((document) => ({
+ ...document,
+ children: sortNavigationNodes(document.children, sort),
+ }));
+};
diff --git a/shared/utils/naturalSort.ts b/shared/utils/naturalSort.ts
index 80d299587f..d0db9ac54d 100644
--- a/shared/utils/naturalSort.ts
+++ b/shared/utils/naturalSort.ts
@@ -6,6 +6,7 @@ type NaturalSortOptions = {
caseSensitive?: boolean;
direction?: "asc" | "desc";
};
+
const sorter = naturalSort();
const regex = emojiRegex();
@@ -13,15 +14,14 @@ const stripEmojis = (value: string) => value.replace(regex, "");
const cleanValue = (value: string) => stripEmojis(deburr(value));
-function getSortByField>(
+function getSortByField(
item: T,
- keyOrCallback: string | (() => string)
+ keyOrCallback: string | ((item: T) => string)
) {
const field =
typeof keyOrCallback === "string"
? item[keyOrCallback]
- : // @ts-expect-error ts-migrate(2554) FIXME: Expected 0 arguments, but got 1.
- keyOrCallback(item);
+ : keyOrCallback(item);
return cleanValue(field);
}
@@ -31,10 +31,14 @@ function naturalSortBy(
sortOptions?: NaturalSortOptions
): T[] {
if (!items) return [];
- // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'NaturalSortOptions' is not assig... Remove this comment to see the full error message
- const sort = sortOptions ? naturalSort(sortOptions) : sorter;
- return items.sort((a: any, b: any): -1 | 0 | 1 =>
- // @ts-expect-error ts-migrate(2322) FIXME: Type 'number' is not assignable to type '0 | 1 | -... Remove this comment to see the full error message
+ const sort = sortOptions
+ ? naturalSort({
+ caseSensitive: sortOptions.caseSensitive,
+ direction: sortOptions.direction === "desc" ? "desc" : undefined,
+ })
+ : sorter;
+
+ return items.sort((a: T, b: T) =>
sort(getSortByField(a, key), getSortByField(b, key))
);
}
diff --git a/tsconfig.json b/tsconfig.json
index 21a3add8f2..2e49d90f7f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,6 +4,7 @@
"alwaysStrict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"baseUrl": ".",