feat: Show drafts in sidebar when viewing (#2820)
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
• {nestedDocumentsCount}{" "}
|
• {nestedDocumentsCount}{" "}
|
||||||
{t("nested document", {
|
{t("nested document", {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
))}
|
/>
|
||||||
</>
|
))}
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> = {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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,
|
sort: collection.sort.field,
|
||||||
direction: "ASC",
|
direction: "ASC",
|
||||||
}}
|
}}
|
||||||
showNestedDocuments
|
showParentDocuments
|
||||||
showPin
|
showPin
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"});
|
||||||
`};
|
`};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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;
|
placeholder: string;
|
||||||
sidebarBackground: string;
|
sidebarBackground: string;
|
||||||
sidebarItemBackground: string;
|
sidebarItemBackground: string;
|
||||||
|
sidebarDraftBorder: string;
|
||||||
sidebarText: string;
|
sidebarText: string;
|
||||||
backdrop: string;
|
backdrop: string;
|
||||||
shadow: string;
|
shadow: string;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
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;
|
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))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": ".",
|
||||||
|
|||||||
Reference in New Issue
Block a user