feat: Show drafts in sidebar when viewing (#2820)

This commit is contained in:
Tom Moor
2021-12-11 09:34:36 -08:00
committed by GitHub
parent e5b4186faa
commit 7aa4709e69
39 changed files with 445 additions and 246 deletions

View File

@@ -1,11 +1,5 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
ArchiveIcon,
EditIcon,
GoToIcon,
ShapesIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; 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) { if (document.isTemplate) {
return { return {
type: "route", type: "route",
@@ -90,7 +75,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const path = React.useMemo( const path = React.useMemo(
() => collection?.pathToDocument?.(document.id).slice(0, -1) || [], () => collection?.pathToDocument?.(document.id).slice(0, -1) || [],
[collection, document.id] [collection, document]
); );
const items = React.useMemo(() => { const items = React.useMemo(() => {

View File

@@ -25,7 +25,7 @@ type Props = {
document: Document; document: Document;
highlight?: string | undefined; highlight?: string | undefined;
context?: string | undefined; context?: string | undefined;
showNestedDocuments?: boolean; showParentDocuments?: boolean;
showCollection?: boolean; showCollection?: boolean;
showPublished?: boolean; showPublished?: boolean;
showPin?: boolean; showPin?: boolean;
@@ -52,7 +52,7 @@ function DocumentListItem(
const { const {
document, document,
showNestedDocuments, showParentDocuments,
showCollection, showCollection,
showPublished, showPublished,
showPin, showPin,
@@ -89,7 +89,7 @@ function DocumentListItem(
highlight={highlight} highlight={highlight}
dir={document.dir} dir={document.dir}
/> />
{document.isNew && document.createdBy.id !== currentUser.id && ( {document.isBadgedNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge> <Badge yellow>{t("New")}</Badge>
)} )}
{canStar && ( {canStar && (
@@ -122,7 +122,7 @@ function DocumentListItem(
document={document} document={document}
showCollection={showCollection} showCollection={showCollection}
showPublished={showPublished} showPublished={showPublished}
showNestedDocuments={showNestedDocuments} showParentDocuments={showParentDocuments}
showLastViewed showLastViewed
/> />
</Content> </Content>

View File

@@ -34,7 +34,7 @@ type Props = {
showCollection?: boolean; showCollection?: boolean;
showPublished?: boolean; showPublished?: boolean;
showLastViewed?: boolean; showLastViewed?: boolean;
showNestedDocuments?: boolean; showParentDocuments?: boolean;
document: Document; document: Document;
children?: React.ReactNode; children?: React.ReactNode;
to?: string; to?: string;
@@ -44,7 +44,7 @@ function DocumentMeta({
showPublished, showPublished,
showCollection, showCollection,
showLastViewed, showLastViewed,
showNestedDocuments, showParentDocuments,
document, document,
children, children,
to, to,
@@ -152,7 +152,7 @@ function DocumentMeta({
</strong> </strong>
</span> </span>
)} )}
{showNestedDocuments && nestedDocumentsCount > 0 && ( {showParentDocuments && nestedDocumentsCount > 0 && (
<span> <span>
&nbsp; {nestedDocumentsCount}{" "} &nbsp; {nestedDocumentsCount}{" "}
{t("nested document", { {t("nested document", {

View File

@@ -47,7 +47,7 @@ export type Props = {
readOnlyWriteCheckboxes?: boolean; readOnlyWriteCheckboxes?: boolean;
onBlur?: () => void; onBlur?: () => void;
onFocus?: () => void; onFocus?: () => void;
onPublish?: (event: React.SyntheticEvent) => any; onPublish?: (event: React.MouseEvent) => any;
onSave?: (arg0: { onSave?: (arg0: {
done?: boolean; done?: boolean;
autosave?: boolean; autosave?: boolean;

View File

@@ -9,7 +9,7 @@ type Props = {
options?: Record<string, any>; options?: Record<string, any>;
heading?: React.ReactNode; heading?: React.ReactNode;
empty?: React.ReactNode; empty?: React.ReactNode;
showNestedDocuments?: boolean; showParentDocuments?: boolean;
showCollection?: boolean; showCollection?: boolean;
showPublished?: boolean; showPublished?: boolean;
showPin?: boolean; showPin?: boolean;

View File

@@ -101,13 +101,6 @@ function MainSidebar() {
<Bubble count={documents.totalDrafts} /> <Bubble count={documents.totalDrafts} />
</Drafts> </Drafts>
} }
active={
documents.active
? !documents.active.publishedAt &&
!documents.active.isDeleted &&
!documents.active.isTemplate
: undefined
}
/> />
)} )}
</Section> </Section>

View File

@@ -5,6 +5,7 @@ import { useDrop, useDrag } from "react-dnd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useHistory } from "react-router-dom"; import { useLocation, useHistory } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import { sortNavigationNodes } from "@shared/utils/collections";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import DocumentReparent from "~/scenes/DocumentReparent"; import DocumentReparent from "~/scenes/DocumentReparent";
@@ -25,7 +26,7 @@ type Props = {
collection: Collection; collection: Collection;
canUpdate: boolean; canUpdate: boolean;
activeDocument: Document | null | undefined; activeDocument: Document | null | undefined;
prefetchDocument: (id: string) => Promise<any>; prefetchDocument: (id: string) => Promise<Document | void>;
belowCollection: Collection | 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 = const isDraggingAnyCollection =
isDraggingAnotherCollection || isCollectionDragging; isDraggingAnotherCollection || isCollectionDragging;
@@ -229,9 +255,8 @@ function CollectionLink({
/> />
)} )}
</div> </div>
{expanded && {expanded &&
collection.documents.map((node, index) => ( collectionDocuments.map((node, index) => (
<DocumentLink <DocumentLink
key={node.id} key={node.id}
node={node} node={node}
@@ -239,6 +264,7 @@ function CollectionLink({
activeDocument={activeDocument} activeDocument={activeDocument}
prefetchDocument={prefetchDocument} prefetchDocument={prefetchDocument}
canUpdate={canUpdate} canUpdate={canUpdate}
isDraft={node.isDraft}
depth={2} depth={2}
index={index} index={index}
/> />

View File

@@ -4,6 +4,7 @@ import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "@shared/constants"; import { MAX_TITLE_LENGTH } from "@shared/constants";
import { sortNavigationNodes } from "@shared/utils/collections";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Document from "~/models/Document"; import Document from "~/models/Document";
import Fade from "~/components/Fade"; import Fade from "~/components/Fade";
@@ -22,7 +23,8 @@ type Props = {
canUpdate: boolean; canUpdate: boolean;
collection?: Collection; collection?: Collection;
activeDocument: Document | null | undefined; activeDocument: Document | null | undefined;
prefetchDocument: (documentId: string) => Promise<any>; prefetchDocument: (documentId: string) => Promise<Document | void>;
isDraft?: boolean;
depth: number; depth: number;
index: number; index: number;
parentId?: string; parentId?: string;
@@ -35,6 +37,7 @@ function DocumentLink(
collection, collection,
activeDocument, activeDocument,
prefetchDocument, prefetchDocument,
isDraft,
depth, depth,
index, index,
parentId, parentId,
@@ -135,9 +138,10 @@ function DocumentLink(
}), }),
canDrag: () => { canDrag: () => {
return ( return (
policies.abilities(node.id).move || !isDraft &&
policies.abilities(node.id).archive || (policies.abilities(node.id).move ||
policies.abilities(node.id).delete 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 ( return (
<> <>
<Relative onDragLeave={resetHoverExpanding}> <Relative onDragLeave={resetHoverExpanding}>
@@ -244,7 +275,7 @@ function DocumentLink(
/> />
)} )}
<EditableTitle <EditableTitle
title={node.title || t("Untitled")} title={title}
onSubmit={handleTitleChange} onSubmit={handleTitleChange}
canUpdate={canUpdate} canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH} maxLength={MAX_TITLE_LENGTH}
@@ -259,6 +290,7 @@ function DocumentLink(
exact={false} exact={false}
showActions={menuOpen} showActions={menuOpen}
scrollIntoViewIfNeeded={!document?.isStarred} scrollIntoViewIfNeeded={!document?.isStarred}
isDraft={isDraft}
ref={ref} ref={ref}
menu={ menu={
document && !isMoving ? ( document && !isMoving ? (
@@ -279,23 +311,22 @@ function DocumentLink(
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} /> <DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)} )}
</Relative> </Relative>
{expanded && !isDragging && ( {expanded &&
<> !isDragging &&
{node.children.map((childNode, index) => ( nodeChildren.map((childNode, index) => (
<ObservedDocumentLink <ObservedDocumentLink
key={childNode.id} key={childNode.id}
collection={collection} collection={collection}
node={childNode} node={childNode}
activeDocument={activeDocument} activeDocument={activeDocument}
prefetchDocument={prefetchDocument} prefetchDocument={prefetchDocument}
depth={depth + 1} isDraft={childNode.isDraft}
canUpdate={canUpdate} depth={depth + 1}
index={index} canUpdate={canUpdate}
parentId={node.id} index={index}
/> parentId={node.id}
))} />
</> ))}
)}
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
import { transparentize } from "polished"; import { transparentize } from "polished";
import * as React from "react"; 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 breakpoint from "styled-components-breakpoint";
import EventBoundary from "~/components/EventBoundary"; import EventBoundary from "~/components/EventBoundary";
import NudeButton from "~/components/NudeButton"; import NudeButton from "~/components/NudeButton";
@@ -25,6 +25,7 @@ type Props = Omit<NavLinkProps, "to"> & {
showActions?: boolean; showActions?: boolean;
active?: boolean; active?: boolean;
isActiveDrop?: boolean; isActiveDrop?: boolean;
isDraft?: boolean;
depth?: number; depth?: number;
scrollIntoViewIfNeeded?: boolean; scrollIntoViewIfNeeded?: boolean;
}; };
@@ -42,6 +43,7 @@ function SidebarLink(
label, label,
active, active,
isActiveDrop, isActiveDrop,
isDraft,
menu, menu,
showActions, showActions,
exact, exact,
@@ -74,6 +76,7 @@ function SidebarLink(
<> <>
<Link <Link
$isActiveDrop={isActiveDrop} $isActiveDrop={isActiveDrop}
$isDraft={isDraft}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle} activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style} style={active ? activeStyle : style}
onClick={onClick} 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; display: flex;
position: relative; position: relative;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -143,6 +146,13 @@ const Link = styled(NavLink)<{ $isActiveDrop?: boolean }>`
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
${(props) =>
props.$isDraft &&
css`
padding: 4px 14px;
border: 1px dashed ${props.theme.sidebarDraftBorder};
`}
svg { svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")} ${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
transition: fill 50ms; transition: fill 50ms;

View File

@@ -1,7 +1,7 @@
import { StarredIcon, UnstarredIcon } from "outline-icons"; import { StarredIcon, UnstarredIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled, { useTheme } from "styled-components";
import Document from "~/models/Document"; import Document from "~/models/Document";
import NudeButton from "./NudeButton"; import NudeButton from "./NudeButton";
@@ -12,6 +12,7 @@ type Props = {
function Star({ size, document, ...rest }: Props) { function Star({ size, document, ...rest }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme();
const handleClick = React.useCallback( const handleClick = React.useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => { (ev: React.MouseEvent<HTMLButtonElement>) => {
@@ -32,25 +33,25 @@ function Star({ size, document, ...rest }: Props) {
} }
return ( return (
<Button <NudeButton
onClick={handleClick} onClick={handleClick}
size={size} size={size}
aria-label={document.isStarred ? t("Unstar") : t("Star")} aria-label={document.isStarred ? t("Unstar") : t("Star")}
{...rest} {...rest}
> >
{document.isStarred ? ( {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)` export const AnimatedStar = styled(StarredIcon)`
flex-shrink: 0; flex-shrink: 0;
transition: all 100ms ease-in-out; transition: all 100ms ease-in-out;

View File

@@ -359,7 +359,8 @@ function DocumentMenu({
type: "button", type: "button",
title: `${t("Create template")}`, title: `${t("Create template")}`,
onClick: () => setShowTemplateModal(true), onClick: () => setShowTemplateModal(true),
visible: !!can.update && !document.isTemplate, visible:
!!can.update && !document.isTemplate && !document.isDraft,
icon: <ShapesIcon />, icon: <ShapesIcon />,
}, },
{ {

View File

@@ -1,8 +1,14 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class ApiKey extends BaseModel { class ApiKey extends BaseModel {
@Field
@observable
id: string; id: string;
@Field
@observable
name: string; name: string;
secret: string; secret: string;

View File

@@ -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 { export default class BaseModel {
@observable @observable
@@ -10,7 +12,7 @@ export default class BaseModel {
store: any; store: any;
constructor(fields: Record<string, any>, store: any) { constructor(fields: Record<string, any>, store: any) {
set(this, fields); this.updateFromJson(fields);
this.store = store; this.store = store;
} }
@@ -19,16 +21,28 @@ export default class BaseModel {
try { try {
// ensure that the id is passed if the document has one // 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()); const model = await this.store.save(params || this.toJS());
// if saving is successful set the new values on the model itself // if saving is successful set the new values on the model itself
set(this, { ...params, ...model }); set(this, { ...params, ...model });
this.persistedAttributes = this.toJS();
return model; return model;
} finally { } finally {
this.isSaving = false; this.isSaving = false;
} }
}; };
updateFromJson = (data: any) => {
set(this, data);
this.persistedAttributes = this.toJS();
};
fetch = (options?: any) => { fetch = (options?: any) => {
return this.store.fetch(this.id, options); 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> => { 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> = {};
} }

View File

@@ -1,9 +1,10 @@
import { pick, trim } from "lodash"; import { trim } from "lodash";
import { action, computed, observable } from "mobx"; import { action, computed, observable } from "mobx";
import BaseModel from "~/models/BaseModel"; import BaseModel from "~/models/BaseModel";
import Document from "~/models/Document"; import Document from "~/models/Document";
import { NavigationNode } from "~/types"; import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import Field from "./decorators/Field";
export default class Collection extends BaseModel { export default class Collection extends BaseModel {
@observable @observable
@@ -12,22 +13,45 @@ export default class Collection extends BaseModel {
@observable @observable
isLoadingUsers: boolean; isLoadingUsers: boolean;
@Field
@observable
id: string; id: string;
@Field
@observable
name: string; name: string;
@Field
@observable
description: string; description: string;
@Field
@observable
icon: string; icon: string;
@Field
@observable
color: string; color: string;
@Field
@observable
permission: "read" | "read_write" | void; permission: "read" | "read_write" | void;
@Field
@observable
sharing: boolean; sharing: boolean;
@Field
@observable
index: string; index: string;
@Field
@observable
sort: {
field: string;
direction: "asc" | "desc";
};
documents: NavigationNode[]; documents: NavigationNode[];
createdAt: string; createdAt: string;
@@ -36,11 +60,6 @@ export default class Collection extends BaseModel {
deletedAt: string | null | undefined; deletedAt: string | null | undefined;
sort: {
field: string;
direction: "asc" | "desc";
};
url: string; url: string;
urlId: string; urlId: string;
@@ -112,6 +131,7 @@ export default class Collection extends BaseModel {
pathToDocument(documentId: string) { pathToDocument(documentId: string) {
let path: NavigationNode[] | undefined; let path: NavigationNode[] | undefined;
const document = this.store.rootStore.documents.get(documentId);
const travelNodes = ( const travelNodes = (
nodes: NavigationNode[], nodes: NavigationNode[],
@@ -125,6 +145,14 @@ export default class Collection extends BaseModel {
return; return;
} }
if (
document?.parentDocumentId &&
node?.id === document?.parentDocumentId
) {
path = [...newPath, document.asNavigationNode];
return;
}
return travelNodes(node.children, newPath); return travelNodes(node.children, newPath);
}); });
}; };
@@ -136,20 +164,6 @@ export default class Collection extends BaseModel {
return path || []; return path || [];
} }
toJS = () => {
return pick(this, [
"id",
"name",
"color",
"description",
"sharing",
"icon",
"permission",
"sort",
"index",
]);
};
export = () => { export = () => {
return client.get("/collections.export", { return client.get("/collections.export", {
id: this.id, id: this.id,

View File

@@ -7,7 +7,9 @@ import unescape from "@shared/utils/unescape";
import DocumentsStore from "~/stores/DocumentsStore"; import DocumentsStore from "~/stores/DocumentsStore";
import BaseModel from "~/models/BaseModel"; import BaseModel from "~/models/BaseModel";
import User from "~/models/User"; import User from "~/models/User";
import { NavigationNode } from "~/types";
import View from "./View"; import View from "./View";
import Field from "./decorators/Field";
type SaveOptions = { type SaveOptions = {
publish?: boolean; publish?: boolean;
@@ -28,10 +30,36 @@ export default class Document extends BaseModel {
store: DocumentsStore; store: DocumentsStore;
collaboratorIds: string[]; @Field
@observable
collectionId: string; 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; createdAt: string;
createdBy: User; createdBy: User;
@@ -40,22 +68,8 @@ export default class Document extends BaseModel {
updatedBy: User; updatedBy: User;
id: string;
team: string;
pinned: boolean; pinned: boolean;
text: string;
title: string;
template: boolean;
templateId: string | undefined;
parentDocumentId: string | undefined;
publishedAt: string | undefined; publishedAt: string | undefined;
archivedAt: string; archivedAt: string;
@@ -76,7 +90,7 @@ export default class Document extends BaseModel {
constructor(fields: Record<string, any>, store: DocumentsStore) { constructor(fields: Record<string, any>, store: DocumentsStore) {
super(fields, store); super(fields, store);
if (this.isNewDocument && this.isFromTemplate) { if (this.isPersistedOnce && this.isFromTemplate) {
this.title = ""; this.title = "";
} }
} }
@@ -122,7 +136,7 @@ export default class Document extends BaseModel {
} }
@computed @computed
get isNew(): boolean { get isBadgedNew(): boolean {
return ( return (
!this.lastViewedAt && !this.lastViewedAt &&
differenceInDays(new Date(), new Date(this.createdAt)) < 14 differenceInDays(new Date(), new Date(this.createdAt)) < 14
@@ -169,7 +183,7 @@ export default class Document extends BaseModel {
} }
@computed @computed
get isNewDocument(): boolean { get isPersistedOnce(): boolean {
return this.createdAt === this.updatedAt; 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 = () => { archive = () => {
return this.store.archive(this); return this.store.archive(this);
}; };
@@ -376,6 +385,24 @@ export default class Document extends BaseModel {
return result; 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 () => { download = async () => {
// Ensure the document is upto date with latest server contents // Ensure the document is upto date with latest server contents
await this.fetch(); await this.fetch();

View File

@@ -1,19 +1,19 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class Group extends BaseModel { class Group extends BaseModel {
@Field
@observable
id: string; id: string;
@Field
@observable
name: string; name: string;
memberCount: number; memberCount: number;
updatedAt: string; updatedAt: string;
toJS = () => {
return {
name: this.name,
};
};
} }
export default Group; export default Group;

View File

@@ -1,8 +1,14 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class NotificationSetting extends BaseModel { class NotificationSetting extends BaseModel {
@Field
@observable
id: string; id: string;
@Field
@observable
event: string; event: string;
} }

View File

@@ -1,13 +1,23 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
import User from "./User"; import User from "./User";
import Field from "./decorators/Field";
class Share extends BaseModel { class Share extends BaseModel {
@Field
@observable
id: string; id: string;
url: string; @Field
@observable
published: boolean; published: boolean;
@Field
@observable
includeChildDocuments: boolean;
@Field
@observable
documentId: string; documentId: string;
documentTitle: string; documentTitle: string;
@@ -16,7 +26,7 @@ class Share extends BaseModel {
lastAccessedAt: string | null | undefined; lastAccessedAt: string | null | undefined;
includeChildDocuments: boolean; url: string;
createdBy: User; createdBy: User;

View File

@@ -1,29 +1,48 @@
import { computed } from "mobx"; import { computed, observable } from "mobx";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class Team extends BaseModel { class Team extends BaseModel {
@Field
@observable
id: string; id: string;
@Field
@observable
name: string; name: string;
@Field
@observable
avatarUrl: string; avatarUrl: string;
@Field
@observable
sharing: boolean; sharing: boolean;
@Field
@observable
collaborativeEditing: boolean; collaborativeEditing: boolean;
@Field
@observable
documentEmbeds: boolean; documentEmbeds: boolean;
@Field
@observable
guestSignin: boolean; guestSignin: boolean;
@Field
@observable
subdomain: string | null | undefined; subdomain: string | null | undefined;
@Field
@observable
defaultUserRole: string;
domain: string | null | undefined; domain: string | null | undefined;
url: string; url: string;
defaultUserRole: string;
@computed @computed
get signinMethods(): string { get signinMethods(): string {
return "SSO"; return "SSO";

View File

@@ -1,18 +1,31 @@
import { computed } from "mobx"; import { computed, observable } from "mobx";
import { Role } from "@shared/types"; import { Role } from "@shared/types";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class User extends BaseModel { class User extends BaseModel {
avatarUrl: string; @Field
@observable
id: string; id: string;
@Field
@observable
avatarUrl: string;
@Field
@observable
name: string; name: string;
email: string; @Field
@observable
color: string; color: string;
@Field
@observable
language: string;
email: string;
isAdmin: boolean; isAdmin: boolean;
isViewer: boolean; isViewer: boolean;
@@ -23,8 +36,6 @@ class User extends BaseModel {
createdAt: string; createdAt: string;
language: string;
@computed @computed
get isInvited(): boolean { get isInvited(): boolean {
return !this.lastActiveAt; return !this.lastActiveAt;

View 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;

View File

@@ -377,7 +377,7 @@ function CollectionScene() {
sort: collection.sort.field, sort: collection.sort.field,
direction: "ASC", direction: "ASC",
}} }}
showNestedDocuments showParentDocuments
showPin showPin
/> />
</Route> </Route>

View File

@@ -15,7 +15,7 @@ import withStores from "~/components/withStores";
import { NavigationNode } from "~/types"; import { NavigationNode } from "~/types";
import { NotFoundError, OfflineError } from "~/utils/errors"; import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history"; import history from "~/utils/history";
import { matchDocumentEdit, updateDocumentUrl } from "~/utils/routeHelpers"; import { matchDocumentEdit } from "~/utils/routeHelpers";
import { isInternalUrl } from "~/utils/urls"; import { isInternalUrl } from "~/utils/urls";
import HideSidebar from "./HideSidebar"; import HideSidebar from "./HideSidebar";
import Loading from "./Loading"; 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);
}
}
} }
}; };

View File

@@ -11,6 +11,7 @@ import {
RouteComponentProps, RouteComponentProps,
StaticContext, StaticContext,
withRouter, withRouter,
Redirect,
} from "react-router"; } from "react-router";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
@@ -41,6 +42,7 @@ import {
documentHistoryUrl, documentHistoryUrl,
editDocumentUrl, editDocumentUrl,
documentUrl, documentUrl,
updateDocumentUrl,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
import Container from "./Container"; import Container from "./Container";
import Contents from "./Contents"; import Contents from "./Contents";
@@ -52,7 +54,6 @@ import PublicReferences from "./PublicReferences";
import References from "./References"; import References from "./References";
const AUTOSAVE_DELAY = 3000; const AUTOSAVE_DELAY = 3000;
const IS_DIRTY_DELAY = 500;
type Props = WithTranslation & type Props = WithTranslation &
RootStore & RootStore &
@@ -74,7 +75,7 @@ type Props = WithTranslation &
@observer @observer
class DocumentScene extends React.Component<Props> { class DocumentScene extends React.Component<Props> {
@observable @observable
editor = React.createRef(); editor = React.createRef<typeof Editor>();
@observable @observable
isUploading = false; isUploading = false;
@@ -86,7 +87,7 @@ class DocumentScene extends React.Component<Props> {
isPublishing = false; isPublishing = false;
@observable @observable
isDirty = false; isEditorDirty = false;
@observable @observable
isEmpty = true; isEmpty = true;
@@ -114,12 +115,6 @@ class DocumentScene extends React.Component<Props> {
this.lastRevision = document.revision; this.lastRevision = document.revision;
} }
if (this.props.readOnly) {
if (document.title !== this.title) {
this.title = document.title;
}
}
if ( if (
!this.props.readOnly && !this.props.readOnly &&
!auth.team?.collaborativeEditing && !auth.team?.collaborativeEditing &&
@@ -146,8 +141,6 @@ class DocumentScene extends React.Component<Props> {
} }
replaceDocument = (template: Document | Revision) => { replaceDocument = (template: Document | Revision) => {
this.title = template.title;
this.isDirty = true;
const editorRef = this.editor.current; const editorRef = this.editor.current;
if (!editorRef) { if (!editorRef) {
@@ -162,6 +155,8 @@ class DocumentScene extends React.Component<Props> {
.replaceSelectionWith(parser.parse(template.text)) .replaceSelectionWith(parser.parse(template.text))
); );
this.isEditorDirty = true;
if (template instanceof Document) { if (template instanceof Document) {
this.props.document.templateId = template.id; 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: KeyboardEvent) => {
goToMove = (ev) => {
if (!this.props.readOnly) return; if (!this.props.readOnly) return;
ev.preventDefault(); ev.preventDefault();
const { document, abilities } = this.props; 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: KeyboardEvent) => {
goToEdit = (ev) => {
if (!this.props.readOnly) return; if (!this.props.readOnly) return;
ev.preventDefault(); ev.preventDefault();
const { document, abilities } = this.props; 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: KeyboardEvent) => {
goToHistory = (ev) => {
if (!this.props.readOnly) return; if (!this.props.readOnly) return;
if (ev.ctrlKey) return; if (ev.ctrlKey) return;
ev.preventDefault(); 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: React.MouseEvent | KeyboardEvent) => {
onPublish = (ev) => {
ev.preventDefault(); ev.preventDefault();
const { document } = this.props; const { document } = this.props;
if (document.publishedAt) return; 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: KeyboardEvent) => {
onToggleTableOfContents = (ev) => {
if (!this.props.readOnly) return; if (!this.props.readOnly) return;
ev.preventDefault(); ev.preventDefault();
const { ui } = this.props; const { ui } = this.props;
@@ -265,25 +255,18 @@ class DocumentScene extends React.Component<Props> {
// get the latest version of the editor text value // get the latest version of the editor text value
const text = this.getEditorText ? this.getEditorText() : document.text; const text = this.getEditorText ? this.getEditorText() : document.text;
const title = this.title;
// prevent save before anything has been written (single hash is empty doc) // 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() === "" && document.title.trim() === "") return;
if (text.trim() === "" && title.trim === "") return;
document.text = text;
document.tasks = getTasks(document.text);
// prevent autosave if nothing has changed // prevent autosave if nothing has changed
if ( if (options.autosave && !this.isEditorDirty && !document.isDirty()) {
options.autosave &&
document.text.trim() === text.trim() &&
document.title.trim() === title.trim()
) {
return; return;
} }
document.title = title;
document.text = text;
document.tasks = getTasks(document.text);
const isNew = !document.id;
this.isSaving = true; this.isSaving = true;
this.isPublishing = !!options.publish; this.isPublishing = !!options.publish;
@@ -305,13 +288,13 @@ class DocumentScene extends React.Component<Props> {
}); });
} }
this.isDirty = false; this.isEditorDirty = false;
this.lastRevision = savedDocument.revision; this.lastRevision = savedDocument.revision;
if (options.done) { if (options.done) {
this.props.history.push(savedDocument.url); this.props.history.push(savedDocument.url);
this.props.ui.setActiveDocument(savedDocument); this.props.ui.setActiveDocument(savedDocument);
} else if (isNew) { } else if (document.isNew) {
this.props.history.push(editDocumentUrl(savedDocument)); this.props.history.push(editDocumentUrl(savedDocument));
this.props.ui.setActiveDocument(savedDocument); this.props.ui.setActiveDocument(savedDocument);
} }
@@ -335,15 +318,13 @@ class DocumentScene extends React.Component<Props> {
updateIsDirty = () => { updateIsDirty = () => {
const { document } = this.props; const { document } = this.props;
const editorText = this.getEditorText().trim(); const editorText = this.getEditorText().trim();
const titleChanged = this.title !== document.title; this.isEditorDirty = editorText !== document.text.trim();
const bodyChanged = editorText !== document.text.trim();
// a single hash is a doc with just an empty title // a single hash is a doc with just an empty title
this.isEmpty = (!editorText || editorText === "#") && !this.title; this.isEmpty = (!editorText || editorText === "#") && !this.title;
this.isDirty = bodyChanged || titleChanged;
}; };
updateIsDirtyDebounced = debounce(this.updateIsDirty, IS_DIRTY_DELAY); updateIsDirtyDebounced = debounce(this.updateIsDirty, 500);
onImageUploadStart = () => { onImageUploadStart = () => {
this.isUploading = true; this.isUploading = true;
@@ -381,11 +362,11 @@ class DocumentScene extends React.Component<Props> {
} }
}; };
onChangeTitle = (value: string) => { onChangeTitle = action((value: string) => {
this.title = value; this.props.document.title = value;
this.updateIsDirtyDebounced(); this.updateIsDirty();
this.autosave(); this.autosave();
}; });
goBack = () => { goBack = () => {
this.props.history.push(this.props.document.url); this.props.history.push(this.props.document.url);
@@ -420,8 +401,13 @@ class DocumentScene extends React.Component<Props> {
!revision && !revision &&
!isShare; !isShare;
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
return ( return (
<ErrorBoundary> <ErrorBoundary>
{this.props.location.pathname !== canonicalUrl && (
<Redirect to={canonicalUrl} />
)}
<RegisterKeyDown trigger="m" handler={this.goToMove} /> <RegisterKeyDown trigger="m" handler={this.goToMove} />
<RegisterKeyDown trigger="e" handler={this.goToEdit} /> <RegisterKeyDown trigger="e" handler={this.goToEdit} />
<RegisterKeyDown trigger="Escape" handler={this.goBack} /> <RegisterKeyDown trigger="Escape" handler={this.goBack} />
@@ -468,7 +454,7 @@ class DocumentScene extends React.Component<Props> {
<> <>
<Prompt <Prompt
when={ when={
this.isDirty && this.isEditorDirty &&
!this.isUploading && !this.isUploading &&
!team?.collaborativeEditing !team?.collaborativeEditing
} }
@@ -477,7 +463,7 @@ class DocumentScene extends React.Component<Props> {
)} )}
/> />
<Prompt <Prompt
when={this.isUploading && !this.isDirty} when={this.isUploading && !this.isEditorDirty}
message={t( message={t(
`Images are still uploading.\nAre you sure you want to discard them?` `Images are still uploading.\nAre you sure you want to discard them?`
)} )}
@@ -565,7 +551,7 @@ class DocumentScene extends React.Component<Props> {
shareId={shareId} shareId={shareId}
isDraft={document.isDraft} isDraft={document.isDraft}
template={document.isTemplate} template={document.isTemplate}
title={revision ? revision.title : this.title} title={revision ? revision.title : document.title}
document={document} document={document}
value={readOnly ? value : undefined} value={readOnly ? value : undefined}
defaultValue={value} defaultValue={value}
@@ -639,11 +625,13 @@ const ReferencesWrapper = styled.div<{ isOnlyTitle?: boolean }>`
} }
`; `;
const MaxWidth = styled(Flex)<{ type MaxWidthProps = {
isEditing?: boolean; isEditing?: boolean;
archived?: boolean; archived?: boolean;
showContents?: boolean; showContents?: boolean;
}>` };
const MaxWidth = styled(Flex)<MaxWidthProps>`
${(props) => ${(props) =>
props.archived && `* { color: ${props.theme.textSecondary} !important; } `}; props.archived && `* { color: ${props.theme.textSecondary} !important; } `};
@@ -657,7 +645,7 @@ const MaxWidth = styled(Flex)<{
${breakpoint("tablet")` ${breakpoint("tablet")`
padding: 0 24px; padding: 0 24px;
margin: 4px auto 12px; margin: 4px auto 12px;
max-width: calc(48px + ${(props: any) => max-width: calc(48px + ${(props: MaxWidthProps) =>
props.showContents ? "64em" : "46em"}); props.showContents ? "64em" : "46em"});
`}; `};

View File

@@ -90,7 +90,7 @@ function DocumentHeader({
}); });
}, [onSave]); }, [onSave]);
const isNew = document.isNewDocument; const isNew = document.isPersistedOnce;
const isTemplate = document.isTemplate; const isTemplate = document.isTemplate;
const can = policies.abilities(document.id); const can = policies.abilities(document.id);
const canToggleEmbeds = team?.documentEmbeds; const canToggleEmbeds = team?.documentEmbeds;

View File

@@ -27,13 +27,13 @@ function References({ document }: Props) {
? collection.getDocumentChildren(document.id) ? collection.getDocumentChildren(document.id)
: []; : [];
const showBacklinks = !!backlinks.length; const showBacklinks = !!backlinks.length;
const showNestedDocuments = !!children.length; const showParentDocuments = !!children.length;
const isBacklinksTab = location.hash === "#backlinks" || !showNestedDocuments; const isBacklinksTab = location.hash === "#backlinks" || !showParentDocuments;
return showBacklinks || showNestedDocuments ? ( return showBacklinks || showParentDocuments ? (
<Fade> <Fade>
<Tabs> <Tabs>
{showNestedDocuments && ( {showParentDocuments && (
<Tab to="#children" isActive={() => !isBacklinksTab}> <Tab to="#children" isActive={() => !isBacklinksTab}>
<Trans>Nested documents</Trans> <Trans>Nested documents</Trans>
</Tab> </Tab>

View File

@@ -124,6 +124,7 @@ class Drafts extends React.Component<Props> {
fetch={this.props.documents.fetchDrafts} fetch={this.props.documents.fetchDrafts}
documents={this.props.documents.drafts(options)} documents={this.props.documents.drafts(options)}
options={options} options={options}
showParentDocuments
showCollection showCollection
/> />
</Scene> </Scene>

View File

@@ -86,7 +86,7 @@ export default class BaseStore<T extends BaseModel> {
const existingModel = this.data.get(item.id); const existingModel = this.data.get(item.id);
if (existingModel) { if (existingModel) {
set(existingModel, item); existingModel.updateFromJson(item);
return existingModel; return existingModel;
} }

View File

@@ -233,7 +233,7 @@ export default class DocumentsStore extends BaseStore<Document> {
}; };
@computed @computed
get active(): Document | null | undefined { get active(): Document | undefined {
return this.rootStore.ui.activeDocumentId return this.rootStore.ui.activeDocumentId
? this.data.get(this.rootStore.ui.activeDocumentId) ? this.data.get(this.rootStore.ui.activeDocumentId)
: undefined; : undefined;

View File

@@ -122,12 +122,7 @@ class UiStore {
setActiveDocument = (document: Document): void => { setActiveDocument = (document: Document): void => {
this.activeDocumentId = document.id; this.activeDocumentId = document.id;
if ( if (document.isActive) {
document.publishedAt &&
!document.isArchived &&
!document.isDeleted &&
!document.isTemplate
) {
this.activeCollectionId = document.collectionId; this.activeCollectionId = document.collectionId;
} }
}; };

View File

@@ -134,6 +134,7 @@ export type NavigationNode = {
title: string; title: string;
url: string; url: string;
children: NavigationNode[]; children: NavigationNode[];
isDraft?: boolean;
}; };
// Pagination response in an API call // Pagination response in an API call

View File

@@ -148,6 +148,7 @@ declare module "styled-components" {
placeholder: string; placeholder: string;
sidebarBackground: string; sidebarBackground: string;
sidebarItemBackground: string; sidebarItemBackground: string;
sidebarDraftBorder: string;
sidebarText: string; sidebarText: string;
backdrop: string; backdrop: string;
shadow: string; shadow: string;

View File

@@ -70,7 +70,10 @@ export function documentHistoryUrl(doc: Document, revisionId?: string): string {
*/ */
export function updateDocumentUrl(oldUrl: string, document: Document): string { export function updateDocumentUrl(oldUrl: string, document: Document): string {
// Update url to match the current one // 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( export function newDocumentPath(

View File

@@ -1,24 +1,6 @@
import naturalSort from "@shared/utils/naturalSort"; import { sortNavigationNodes } from "@shared/utils/collections";
import { Collection } from "@server/models"; 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 // @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) { export default function present(collection: Collection) {
const data = { const data = {
@@ -47,11 +29,7 @@ export default function present(collection: Collection) {
}; };
} }
// "index" field is manually sorted and is represented by the documentStructure data.documents = sortNavigationNodes(collection.documentStructure, data.sort);
// already saved in the database, no further sort is needed
if (data.sort.field !== "index") {
data.documents = sortDocuments(collection.documentStructure, data.sort);
}
return data; return data;
} }

View File

@@ -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 // index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column // collection.documentStructure rather than a database column
if (sort === "index") { if (sort === "index") {
documentIds = collection.documentStructure documentIds = (collection.documentStructure || [])
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type. // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'node' implicitly has an 'any' type.
.map((node) => node.id) .map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit); .slice(ctx.state.pagination.offset, ctx.state.pagination.limit);

View File

@@ -134,6 +134,7 @@ export const light: DefaultTheme = {
placeholder: "#a2b2c3", placeholder: "#a2b2c3",
sidebarBackground: colors.warmGrey, sidebarBackground: colors.warmGrey,
sidebarItemBackground: "#d7e0ea", sidebarItemBackground: "#d7e0ea",
sidebarDraftBorder: darken("0.25", colors.warmGrey),
sidebarText: "rgb(78, 92, 110)", sidebarText: "rgb(78, 92, 110)",
backdrop: "rgba(0, 0, 0, 0.2)", backdrop: "rgba(0, 0, 0, 0.2)",
shadow: "rgba(0, 0, 0, 0.2)", shadow: "rgba(0, 0, 0, 0.2)",
@@ -182,6 +183,7 @@ export const dark: DefaultTheme = {
placeholder: colors.slateDark, placeholder: colors.slateDark,
sidebarBackground: colors.veryDarkBlue, sidebarBackground: colors.veryDarkBlue,
sidebarItemBackground: lighten(0.015, colors.almostBlack), sidebarItemBackground: lighten(0.015, colors.almostBlack),
sidebarDraftBorder: darken("0.35", colors.slate),
sidebarText: colors.slate, sidebarText: colors.slate,
backdrop: "rgba(255, 255, 255, 0.3)", backdrop: "rgba(255, 255, 255, 0.3)",
shadow: "rgba(0, 0, 0, 0.6)", shadow: "rgba(0, 0, 0, 0.6)",

View 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),
}));
};

View File

@@ -6,6 +6,7 @@ type NaturalSortOptions = {
caseSensitive?: boolean; caseSensitive?: boolean;
direction?: "asc" | "desc"; direction?: "asc" | "desc";
}; };
const sorter = naturalSort(); const sorter = naturalSort();
const regex = emojiRegex(); const regex = emojiRegex();
@@ -13,15 +14,14 @@ const stripEmojis = (value: string) => value.replace(regex, "");
const cleanValue = (value: string) => stripEmojis(deburr(value)); const cleanValue = (value: string) => stripEmojis(deburr(value));
function getSortByField<T extends Record<string, any>>( function getSortByField<T>(
item: T, item: T,
keyOrCallback: string | (() => string) keyOrCallback: string | ((item: T) => string)
) { ) {
const field = const field =
typeof keyOrCallback === "string" typeof keyOrCallback === "string"
? item[keyOrCallback] ? item[keyOrCallback]
: // @ts-expect-error ts-migrate(2554) FIXME: Expected 0 arguments, but got 1. : keyOrCallback(item);
keyOrCallback(item);
return cleanValue(field); return cleanValue(field);
} }
@@ -31,10 +31,14 @@ function naturalSortBy<T>(
sortOptions?: NaturalSortOptions sortOptions?: NaturalSortOptions
): T[] { ): T[] {
if (!items) return []; 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
const sort = sortOptions ? naturalSort(sortOptions) : sorter; ? naturalSort({
return items.sort((a: any, b: any): -1 | 0 | 1 => caseSensitive: sortOptions.caseSensitive,
// @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 direction: sortOptions.direction === "desc" ? "desc" : undefined,
})
: sorter;
return items.sort((a: T, b: T) =>
sort(getSortByField(a, key), getSortByField(b, key)) sort(getSortByField(a, key), getSortByField(b, key))
); );
} }

View File

@@ -4,6 +4,7 @@
"alwaysStrict": true, "alwaysStrict": true,
"esModuleInterop": true, "esModuleInterop": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"isolatedModules": true, "isolatedModules": true,
"baseUrl": ".", "baseUrl": ".",