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 {
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(() => {

View File

@@ -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>

View File

@@ -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>
&nbsp; {nestedDocumentsCount}{" "}
{t("nested document", {

View File

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

View File

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

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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}
/>
))}
</>
);
}

View File

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

View File

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

View File

@@ -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 />,
},
{

View File

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

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 {
@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> = {};
}

View File

@@ -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,

View File

@@ -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();

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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";

View File

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

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,
direction: "ASC",
}}
showNestedDocuments
showParentDocuments
showPin
/>
</Route>

View File

@@ -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);
}
}
}
};

View File

@@ -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"});
`};

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

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

View File

@@ -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;
}
};

View File

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

View File

@@ -148,6 +148,7 @@ declare module "styled-components" {
placeholder: string;
sidebarBackground: string;
sidebarItemBackground: string;
sidebarDraftBorder: string;
sidebarText: string;
backdrop: 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 {
// 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(

View File

@@ -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;
}

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
// 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);

View File

@@ -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)",

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

View File

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