feat: Show drafts in sidebar when viewing (#2820)
This commit is contained in:
@@ -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: <EditIcon color="currentColor" />,
|
||||
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(() => {
|
||||
|
||||
@@ -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 && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
@@ -122,7 +122,7 @@ function DocumentListItem(
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showNestedDocuments={showNestedDocuments}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
|
||||
@@ -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({
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showNestedDocuments && nestedDocumentsCount > 0 && (
|
||||
{showParentDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
• {nestedDocumentsCount}{" "}
|
||||
{t("nested document", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
showNestedDocuments?: boolean;
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showPin?: boolean;
|
||||
|
||||
@@ -101,13 +101,6 @@ function MainSidebar() {
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -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<any>;
|
||||
prefetchDocument: (id: string) => Promise<Document | void>;
|
||||
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({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded &&
|
||||
collection.documents.map((node, index) => (
|
||||
collectionDocuments.map((node, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -239,6 +264,7 @@ function CollectionLink({
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
|
||||
@@ -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<any>;
|
||||
prefetchDocument: (documentId: string) => Promise<Document | void>;
|
||||
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 (
|
||||
<>
|
||||
<Relative onDragLeave={resetHoverExpanding}>
|
||||
@@ -244,7 +275,7 @@ function DocumentLink(
|
||||
/>
|
||||
)}
|
||||
<EditableTitle
|
||||
title={node.title || t("Untitled")}
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
@@ -259,6 +290,7 @@ function DocumentLink(
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
scrollIntoViewIfNeeded={!document?.isStarred}
|
||||
isDraft={isDraft}
|
||||
ref={ref}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
@@ -279,23 +311,22 @@ function DocumentLink(
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Relative>
|
||||
{expanded && !isDragging && (
|
||||
<>
|
||||
{node.children.map((childNode, index) => (
|
||||
<ObservedDocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{expanded &&
|
||||
!isDragging &&
|
||||
nodeChildren.map((childNode, index) => (
|
||||
<ObservedDocumentLink
|
||||
key={childNode.id}
|
||||
collection={collection}
|
||||
node={childNode}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
index={index}
|
||||
parentId={node.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<NavLinkProps, "to"> & {
|
||||
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(
|
||||
<>
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
@@ -127,7 +130,7 @@ const Actions = styled(EventBoundary)<{ showActions?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<HTMLButtonElement>) => {
|
||||
@@ -32,25 +33,25 @@ function Star({ size, document, ...rest }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
<NudeButton
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
aria-label={document.isStarred ? t("Unstar") : t("Star")}
|
||||
{...rest}
|
||||
>
|
||||
{document.isStarred ? (
|
||||
<AnimatedStar size={size} color="currentColor" />
|
||||
<AnimatedStar size={size} color={theme.textSecondary} />
|
||||
) : (
|
||||
<AnimatedStar size={size} color="currentColor" as={UnstarredIcon} />
|
||||
<AnimatedStar
|
||||
size={size}
|
||||
color={theme.textTertiary}
|
||||
as={UnstarredIcon}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</NudeButton>
|
||||
);
|
||||
}
|
||||
|
||||
const Button = styled(NudeButton)`
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export const AnimatedStar = styled(StarredIcon)`
|
||||
flex-shrink: 0;
|
||||
transition: all 100ms ease-in-out;
|
||||
|
||||
@@ -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: <ShapesIcon />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, any>, 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<string, any>}
|
||||
*/
|
||||
toJS = (): Record<string, any> => {
|
||||
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<BaseModel> = {};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, any>, 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<string, any>) => {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
app/models/decorators/Field.ts
Normal file
19
app/models/decorators/Field.ts
Normal file
@@ -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 = <T>(target: any, propertyKey: keyof T) => {
|
||||
const className = target.constructor.name;
|
||||
fields.set(className, [...(fields.get(className) || []), propertyKey]);
|
||||
};
|
||||
|
||||
export default Field;
|
||||
@@ -377,7 +377,7 @@ function CollectionScene() {
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showNestedDocuments
|
||||
showParentDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
|
||||
@@ -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<Props> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Props> {
|
||||
@observable
|
||||
editor = React.createRef();
|
||||
editor = React.createRef<typeof Editor>();
|
||||
|
||||
@observable
|
||||
isUploading = false;
|
||||
@@ -86,7 +87,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
isPublishing = false;
|
||||
|
||||
@observable
|
||||
isDirty = false;
|
||||
isEditorDirty = false;
|
||||
|
||||
@observable
|
||||
isEmpty = true;
|
||||
@@ -114,12 +115,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
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<Props> {
|
||||
}
|
||||
|
||||
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<Props> {
|
||||
.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<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
// @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<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
// @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<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
// @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<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
// @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<Props> {
|
||||
});
|
||||
};
|
||||
|
||||
// @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<Props> {
|
||||
|
||||
// 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<Props> {
|
||||
});
|
||||
}
|
||||
|
||||
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<Props> {
|
||||
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<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
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<Props> {
|
||||
!revision &&
|
||||
!isShare;
|
||||
|
||||
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{this.props.location.pathname !== canonicalUrl && (
|
||||
<Redirect to={canonicalUrl} />
|
||||
)}
|
||||
<RegisterKeyDown trigger="m" handler={this.goToMove} />
|
||||
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
|
||||
<RegisterKeyDown trigger="Escape" handler={this.goBack} />
|
||||
@@ -468,7 +454,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
<>
|
||||
<Prompt
|
||||
when={
|
||||
this.isDirty &&
|
||||
this.isEditorDirty &&
|
||||
!this.isUploading &&
|
||||
!team?.collaborativeEditing
|
||||
}
|
||||
@@ -477,7 +463,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
/>
|
||||
<Prompt
|
||||
when={this.isUploading && !this.isDirty}
|
||||
when={this.isUploading && !this.isEditorDirty}
|
||||
message={t(
|
||||
`Images are still uploading.\nAre you sure you want to discard them?`
|
||||
)}
|
||||
@@ -565,7 +551,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
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)<MaxWidthProps>`
|
||||
${(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"});
|
||||
`};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
<Fade>
|
||||
<Tabs>
|
||||
{showNestedDocuments && (
|
||||
{showParentDocuments && (
|
||||
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
||||
<Trans>Nested documents</Trans>
|
||||
</Tab>
|
||||
|
||||
@@ -124,6 +124,7 @@ class Drafts extends React.Component<Props> {
|
||||
fetch={this.props.documents.fetchDrafts}
|
||||
documents={this.props.documents.drafts(options)}
|
||||
options={options}
|
||||
showParentDocuments
|
||||
showCollection
|
||||
/>
|
||||
</Scene>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default class BaseStore<T extends BaseModel> {
|
||||
const existingModel = this.data.get(item.id);
|
||||
|
||||
if (existingModel) {
|
||||
set(existingModel, item);
|
||||
existingModel.updateFromJson(item);
|
||||
return existingModel;
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
@computed
|
||||
get active(): Document | null | undefined {
|
||||
get active(): Document | undefined {
|
||||
return this.rootStore.ui.activeDocumentId
|
||||
? this.data.get(this.rootStore.ui.activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,6 +134,7 @@ export type NavigationNode = {
|
||||
title: string;
|
||||
url: string;
|
||||
children: NavigationNode[];
|
||||
isDraft?: boolean;
|
||||
};
|
||||
|
||||
// Pagination response in an API call
|
||||
|
||||
1
app/typings/styled-components.d.ts
vendored
1
app/typings/styled-components.d.ts
vendored
@@ -148,6 +148,7 @@ declare module "styled-components" {
|
||||
placeholder: string;
|
||||
sidebarBackground: string;
|
||||
sidebarItemBackground: string;
|
||||
sidebarDraftBorder: string;
|
||||
sidebarText: string;
|
||||
backdrop: string;
|
||||
shadow: string;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)",
|
||||
|
||||
27
shared/utils/collections.ts
Normal file
27
shared/utils/collections.ts
Normal file
@@ -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),
|
||||
}));
|
||||
};
|
||||
@@ -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<T extends Record<string, any>>(
|
||||
function getSortByField<T>(
|
||||
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<T>(
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"alwaysStrict": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"baseUrl": ".",
|
||||
|
||||
Reference in New Issue
Block a user