Files
outline/app/scenes/Document/components/History.tsx
T
2025-12-28 08:56:32 -05:00

235 lines
6.8 KiB
TypeScript

import isEqual from "fast-deep-equal";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { Pagination } from "@shared/constants";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Revision from "~/models/Revision";
import Empty from "~/components/Empty";
import PaginatedEventList from "~/components/PaginatedEventList";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
import useMobile from "~/hooks/useMobile";
import Switch from "~/components/Switch";
import Text from "@shared/components/Text";
import usePersistedState from "~/hooks/usePersistedState";
import Scrollable from "~/components/Scrollable";
const DocumentEvents = [
"documents.publish",
"documents.unpublish",
"documents.archive",
"documents.unarchive",
"documents.delete",
"documents.restore",
"documents.add_user",
"documents.remove_user",
"documents.move",
];
function History() {
const { events, documents, revisions } = useStores();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const document = documents.get(match.params.documentSlug);
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
const [eventsOffset, setEventsOffset] = React.useState(0);
const isMobile = useMobile();
const [defaultShowChanges, setDefaultShowChanges] =
usePersistedState<boolean>("history-show-changes", true);
const searchParams = new URLSearchParams(history.location.search);
const [showChanges, setShowChanges] = React.useState(
searchParams.get("changes") === "true" || defaultShowChanges
);
const updateLocation = React.useCallback(
(changes: Record<string, string | null>) => {
const params = new URLSearchParams(history.location.search);
Object.entries(changes).forEach(([key, value]) => {
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
});
const search = params.toString();
history.replace({
pathname: history.location.pathname,
search: search ? `?${search}` : "",
state: history.location.state,
});
},
[history]
);
// Handler for toggling the "Show Changes" switch, updating state and URL parameter
const handleShowChangesToggle = React.useCallback(
(checked: boolean) => {
setShowChanges(checked);
setDefaultShowChanges(checked);
updateLocation({ changes: checked ? "true" : null });
},
[history]
);
// Ensure that the URL parameter is in sync with the persisted state on mount
React.useEffect(() => {
if (defaultShowChanges) {
updateLocation({ changes: "true" });
}
}, [defaultShowChanges]);
const fetchHistory = React.useCallback(async () => {
if (!document) {
return [];
}
const limit = Pagination.defaultLimit;
const [revisionsPage, eventsPage] = await Promise.all([
revisions.fetchPage({
documentId: document.id,
offset: revisionsOffset,
limit,
}),
events.fetchPage({
events: DocumentEvents,
documentId: document.id,
offset: eventsOffset,
limit,
}),
]);
const pageEvents = orderBy(
[...revisionsPage, ...eventsPage],
"createdAt",
"desc"
).slice(0, limit);
setRevisionsOffset(revisionsOffset + revisionsPage.length);
setEventsOffset(eventsOffset + pageEvents.length - revisionsPage.length);
return pageEvents;
}, [document, revisions, events, revisionsOffset, eventsOffset]);
const revisionEvents = React.useMemo(() => {
if (!document) {
return [];
}
const latestRevisionId = RevisionHelper.latestId(document.id);
return revisions
.getByDocumentId(document.id)
.filter((revision: Revision) => revision.id !== latestRevisionId)
.slice(0, revisionsOffset);
}, [document, revisions.orderedData, revisionsOffset]);
const nonRevisionEvents = React.useMemo(
() =>
document
? events.getByDocumentId(document.id).slice(0, eventsOffset)
: [],
[document, events.orderedData, eventsOffset]
);
const items = React.useMemo(() => {
const merged = orderBy(
[...revisionEvents, ...nonRevisionEvents],
"createdAt",
"desc"
);
const latestRevisionEvent = revisionEvents[0];
if (latestRevisionEvent && document) {
const latestRevision = revisions.get(latestRevisionEvent.id);
const isDocUpdated =
latestRevision?.title !== document.title ||
!isEqual(latestRevision.data, document.data);
if (isDocUpdated) {
const createdById = document.updatedBy?.id ?? "";
merged.unshift(
new Revision(
{
id: RevisionHelper.latestId(document.id),
createdAt: document.updatedAt,
createdById,
collaboratorIds: [createdById],
},
revisions
)
);
}
}
return merged;
}, [revisions, document, revisionEvents, nonRevisionEvents]);
const onCloseHistory = React.useCallback(() => {
if (isMobile) {
// Allow closing the history drawer on mobile to view revision content
return;
}
if (document) {
history.push({
pathname: documentPath(document),
state: { sidebarContext },
});
} else {
history.goBack();
}
}, [history, document, sidebarContext]);
useKeyDown("Escape", onCloseHistory);
return (
<Sidebar title={t("History")} onClose={onCloseHistory} scrollable={false}>
<Content>
<Text type="secondary" size="small" as="span">
<Switch
label={t("Highlight changes")}
checked={showChanges}
onChange={handleShowChangesToggle}
/>
</Text>
</Content>
<Scrollable hiddenScrollbars topShadow>
{document ? (
<PaginatedEventList
aria-label={t("History")}
fetch={fetchHistory}
items={items}
document={document}
empty={
<Content>
<Empty>{t("No history yet")}</Empty>
</Content>
}
/>
) : null}
</Scrollable>
</Sidebar>
);
}
const Content = styled.div`
margin: 0 16px 8px;
border: 1px solid ${(props) => props.theme.inputBorder};
border-radius: 8px;
padding: 8px 8px 0;
`;
export default observer(History);