mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
0139b91b5d
* chore: Replace lodash with es-toolkit Migrate all direct lodash imports to es-toolkit/compat for a smaller, faster, lodash-compatible utility library. Transitive lodash usage from other packages remains unchanged. * fix: Restore isPlainObject semantics in CanCan policy The lodash migration aliased `isObject` to `lodash/isPlainObject` and the codemod incorrectly mapped the local name to es-toolkit's `isObject`, which also returns true for arrays and functions. This caused condition objects in policy definitions to be skipped, breaking authorization checks across the codebase. * fix: Restore unicode-aware length counting in validators es-toolkit/compat's size() returns string.length, while lodash's _.size() counts unicode code points. Switch to [...value].length to preserve the previous behavior so multi-byte characters like emoji count as one.
279 lines
8.5 KiB
TypeScript
279 lines
8.5 KiB
TypeScript
import isEqual from "fast-deep-equal";
|
|
import { orderBy } from "es-toolkit/compat";
|
|
import { observer } from "mobx-react";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useHistory, useRouteMatch } from "react-router-dom";
|
|
import { Pagination } from "@shared/constants";
|
|
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
|
import Revision from "~/models/Revision";
|
|
import Empty from "~/components/Empty";
|
|
import PaginatedEventList from "./PaginatedEventList";
|
|
import {
|
|
COMPARE_TO_PREVIOUS,
|
|
HighlightChangesControl,
|
|
} from "./HighlightChangesControl";
|
|
import useKeyDown from "~/hooks/useKeyDown";
|
|
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
|
import useQuery from "~/hooks/useQuery";
|
|
import useStores from "~/hooks/useStores";
|
|
import { documentPath, matchDocumentHistory } from "~/utils/routeHelpers";
|
|
import Sidebar from "../SidebarLayout";
|
|
import useMobile from "~/hooks/useMobile";
|
|
import usePersistedState from "~/hooks/usePersistedState";
|
|
import Scrollable from "~/components/Scrollable";
|
|
import Flex from "@shared/components/Flex";
|
|
|
|
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 historyMatch = useRouteMatch<{ revisionId?: string }>({
|
|
path: matchDocumentHistory,
|
|
});
|
|
const history = useHistory();
|
|
const query = useQuery();
|
|
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 [compareTo, setCompareTo] = React.useState(
|
|
() => query.get("compareTo") ?? COMPARE_TO_PREVIOUS
|
|
);
|
|
|
|
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);
|
|
if (checked) {
|
|
updateLocation({ changes: "true" });
|
|
} else {
|
|
setCompareTo(COMPARE_TO_PREVIOUS);
|
|
updateLocation({ changes: null, compareTo: null });
|
|
}
|
|
},
|
|
[updateLocation, setDefaultShowChanges]
|
|
);
|
|
|
|
const selectedRevisionId = historyMatch?.params.revisionId;
|
|
|
|
// Reset "Compare to" when the user clicks a different revision in the list,
|
|
// but not on initial mount (which would break deep links with ?compareTo=…)
|
|
const prevSelectedRef = React.useRef(selectedRevisionId);
|
|
React.useEffect(() => {
|
|
if (prevSelectedRef.current !== selectedRevisionId) {
|
|
prevSelectedRef.current = selectedRevisionId;
|
|
setCompareTo(COMPARE_TO_PREVIOUS);
|
|
updateLocation({ compareTo: null });
|
|
}
|
|
}, [selectedRevisionId, updateLocation]);
|
|
|
|
const handleCompareToChange = React.useCallback(
|
|
(value: string) => {
|
|
setCompareTo(value);
|
|
updateLocation({
|
|
compareTo: value === COMPARE_TO_PREVIOUS ? null : value,
|
|
});
|
|
},
|
|
[updateLocation]
|
|
);
|
|
|
|
// Ensure that the URL parameter is in sync with the persisted state on mount
|
|
React.useEffect(() => {
|
|
if (defaultShowChanges) {
|
|
updateLocation({ changes: "true" });
|
|
}
|
|
}, [defaultShowChanges, updateLocation]);
|
|
|
|
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);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [document, revisions.orderedData, revisionsOffset]);
|
|
|
|
const nonRevisionEvents = React.useMemo(
|
|
() =>
|
|
document
|
|
? events.getByDocumentId(document.id).slice(0, eventsOffset)
|
|
: [],
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[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, isMobile]);
|
|
|
|
useKeyDown("Escape", onCloseHistory);
|
|
|
|
return (
|
|
<Sidebar title={t("History")} onClose={onCloseHistory} scrollable={false}>
|
|
<HighlightChangesControl
|
|
showChanges={showChanges}
|
|
onShowChangesToggle={handleShowChangesToggle}
|
|
items={items}
|
|
document={document}
|
|
selectedRevisionId={selectedRevisionId}
|
|
compareTo={compareTo}
|
|
onCompareToChange={handleCompareToChange}
|
|
/>
|
|
<Scrollable hiddenScrollbars topShadow>
|
|
{document ? (
|
|
<PaginatedEventList
|
|
aria-label={t("History")}
|
|
fetch={fetchHistory}
|
|
items={items}
|
|
document={document}
|
|
empty={
|
|
<Flex
|
|
align="center"
|
|
justify="center"
|
|
style={{
|
|
// When there are no items, drawer renders with a minimum height
|
|
// and that height is retained when items are fetched and re-rendered.
|
|
// To circumvent this, we force some `minHeight` here.
|
|
minHeight: isMobile ? "70vh" : undefined,
|
|
height: "100%",
|
|
}}
|
|
auto
|
|
>
|
|
<Empty>{t("No history yet")}</Empty>
|
|
</Flex>
|
|
}
|
|
/>
|
|
) : null}
|
|
</Scrollable>
|
|
</Sidebar>
|
|
);
|
|
}
|
|
|
|
export default observer(History);
|