Files
outline/app/scenes/Document/components/DataLoader.tsx
2024-07-02 03:55:16 -07:00

238 lines
6.6 KiB
TypeScript

import { observer } from "mobx-react";
import * as React from "react";
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
import { NavigationNode, TeamPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error402 from "~/scenes/Error402";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import {
NotFoundError,
OfflineError,
PaymentRequiredError,
} from "~/utils/errors";
import history from "~/utils/history";
import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers";
import Loading from "./Loading";
type Params = {
/** The document urlId + slugified title */
documentSlug: string;
/** A specific revision id to load. */
revisionId?: string;
/** The share ID to use to load data. */
shareId?: string;
};
type LocationState = {
/** The document title, if preloaded */
title?: string;
restore?: boolean;
revisionId?: string;
};
type Children = (options: {
document: Document;
revision: Revision | undefined;
abilities: Record<string, boolean>;
readOnly: boolean;
onCreateLink: (title: string, nested?: boolean) => Promise<string>;
sharedTree: NavigationNode | undefined;
}) => React.ReactNode;
type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
children: Children;
};
function DataLoader({ match, children }: Props) {
const { ui, views, shares, comments, documents, revisions, subscriptions } =
useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const [error, setError] = React.useState<Error | null>(null);
const { revisionId, shareId, documentSlug } = match.params;
// Allows loading by /doc/slug-<urlId> or /doc/<id>
const document =
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
const revision = revisionId
? revisions.get(
revisionId === "latest"
? RevisionHelper.latestId(document?.id)
: revisionId
)
: undefined;
const sharedTree = document
? documents.getSharedTree(document.id)
: undefined;
const isEditRoute =
match.path === matchDocumentEdit || match.path.startsWith(settingsPath());
const isEditing = isEditRoute || !user?.separateEditMode;
const can = usePolicy(document);
const location = useLocation<LocationState>();
React.useEffect(() => {
async function fetchDocument() {
try {
await documents.fetchWithSharedTree(documentSlug, {
shareId,
});
} catch (err) {
setError(err);
}
}
void fetchDocument();
}, [ui, documents, shareId, documentSlug]);
React.useEffect(() => {
async function fetchRevision() {
if (revisionId && revisionId !== "latest") {
try {
await revisions.fetch(revisionId);
} catch (err) {
setError(err);
}
}
}
void fetchRevision();
}, [revisions, revisionId]);
React.useEffect(() => {
async function fetchRevision() {
if (document && revisionId === "latest") {
try {
await revisions.fetchLatest(document.id);
} catch (err) {
setError(err);
}
}
}
void fetchRevision();
}, [document, revisionId, revisions]);
React.useEffect(() => {
async function fetchSubscription() {
if (document?.id && !document?.isDeleted && !revisionId) {
try {
await subscriptions.fetchPage({
documentId: document.id,
event: "documents.update",
});
} catch (err) {
Logger.error("Failed to fetch subscriptions", err);
}
}
}
void fetchSubscription();
}, [document?.id, document?.isDeleted, subscriptions, revisionId]);
React.useEffect(() => {
async function fetchViews() {
if (document?.id && !document?.isDeleted && !revisionId) {
try {
await views.fetchPage({
documentId: document.id,
});
} catch (err) {
Logger.error("Failed to fetch views", err);
}
}
}
void fetchViews();
}, [document?.id, document?.isDeleted, revisionId, views]);
const onCreateLink = React.useCallback(
async (title: string, nested?: boolean) => {
if (!document) {
throw new Error("Document not loaded yet");
}
const newDocument = await documents.create({
collectionId: nested ? undefined : document.collectionId,
parentDocumentId: nested ? document.id : document.parentDocumentId,
title,
data: ProsemirrorHelper.getEmptyDocument(),
});
return newDocument.url;
},
[document, documents]
);
React.useEffect(() => {
if (document) {
// sets the current document as active in the sidebar
ui.setActiveDocument(document);
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && isEditRoute) {
history.push(document.url);
return;
}
// Prevents unauthorized request to load share information for the document
// when viewing a public share link
if (can.read && !document.isDeleted) {
if (team.getPreference(TeamPreference.Commenting)) {
void comments.fetchAll({
documentId: document.id,
limit: 100,
});
}
shares.fetch(document.id).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
}
});
}
}
}, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]);
if (error) {
return error instanceof OfflineError ? (
<ErrorOffline />
) : error instanceof PaymentRequiredError ? (
<Error402 />
) : (
<Error404 />
);
}
if (!document || (revisionId && !revision)) {
return (
<>
<Loading location={location} />
</>
);
}
return (
<React.Fragment>
{children({
document,
revision,
abilities: can,
readOnly:
!isEditing || !can.update || document.isArchived || !!revisionId,
onCreateLink,
sharedTree,
})}
</React.Fragment>
);
}
export default observer(DataLoader);