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": ".",