Revert "Move document history to revisions.list API (#8458)" (#8495)

This reverts commit 839ce889ad.
This commit is contained in:
Tom Moor
2025-02-18 20:25:52 -05:00
committed by GitHub
parent 7144536eb3
commit 2116041cd5
9 changed files with 84 additions and 269 deletions
+10 -45
View File
@@ -14,9 +14,8 @@ import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import User from "~/models/User";
import Event from "~/models/Event";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
@@ -26,47 +25,21 @@ import RevisionMenu from "~/menus/RevisionMenu";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
export type RevisionEvent = {
name: "revisions.create";
latest: boolean;
};
export type DocumentEvent = {
name:
| "documents.publish"
| "documents.unpublish"
| "documents.archive"
| "documents.unarchive"
| "documents.delete"
| "documents.restore"
| "documents.add_user"
| "documents.remove_user"
| "documents.move";
user?: User;
};
export type Event = { id: string; actor: User; createdAt: string } & (
| RevisionEvent
| DocumentEvent
);
type Props = {
document: Document;
event: Event;
event: Event<Document>;
latest?: boolean;
};
const EventListItem = ({ event, document, ...rest }: Props) => {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions } = useStores();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = React.useRef(false);
const opts = {
userName: event.actor.name,
};
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = React.useRef<HTMLAnchorElement>(null);
@@ -77,20 +50,15 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
};
const prefetchRevision = async () => {
if (
event.name === "revisions.create" &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
if (event.name === "revisions.create" && event.modelId) {
await revisions.fetch(event.modelId);
}
};
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = event.latest ? (
meta = latest ? (
<>
{t("Current version")} &middot; {event.actor.name}
</>
@@ -98,10 +66,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
pathname: documentHistoryPath(document, event.modelId || "latest"),
state: {
sidebarContext,
retainScrollPosition: true,
@@ -196,9 +161,9 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
</Subtitle>
}
actions={
isRevision && isActive && !event.latest ? (
isRevision && isActive && event.modelId && !latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.id} />
<RevisionMenu document={document} revisionId={event.modelId} />
</StyledEventBoundary>
) : undefined
}
+13 -5
View File
@@ -1,13 +1,16 @@
import * as React from "react";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import PaginatedList from "~/components/PaginatedList";
import EventListItem, { type Event } from "./EventListItem";
import EventListItem from "./EventListItem";
type Props = {
events: Event[];
events: Event<Document>[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -29,8 +32,13 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
+3 -8
View File
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
fetchCounter = 0;
@observable
renderCount = Pagination.defaultLimit;
renderCount = 15;
@observable
offset = 0;
@@ -108,16 +108,13 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
...this.props.options,
});
if (this.offset !== 0) {
this.renderCount += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
this.isFetchingInitial = false;
} catch (err) {
this.error = err;
@@ -251,9 +248,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
</div>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
);
+36 -130
View File
@@ -1,20 +1,12 @@
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 Document from "~/models/Document";
import EventModel from "~/models/Event";
import Revision from "~/models/Revision";
import Event from "~/models/Event";
import Empty from "~/components/Empty";
import {
DocumentEvent,
RevisionEvent,
type Event,
} from "~/components/EventListItem";
import PaginatedEventList from "~/components/PaginatedEventList";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
@@ -22,133 +14,21 @@ import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
const DocumentEvents = [
"documents.publish",
"documents.unpublish",
"documents.archive",
"documents.unarchive",
"documents.delete",
"documents.restore",
"documents.add_user",
"documents.remove_user",
"documents.move",
];
const EMPTY_ARRAY: Event<Document>[] = [];
function History() {
const { events, documents, revisions } = useStores();
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const document = documents.getByUrl(match.params.documentSlug);
const [, setForceRender] = React.useState(0);
const offset = React.useMemo(() => ({ revisions: 0, events: 0 }), []);
const eventsInDocument = document
? events.filter({ documentId: document.id })
: EMPTY_ARRAY;
const toEvent = React.useCallback(
(data: Revision | EventModel<Document>): Event => {
if (data instanceof Revision) {
return {
id: data.id,
name: "revisions.create",
actor: data.createdBy,
createdAt: data.createdAt,
latest: false,
} satisfies Event;
}
return {
id: data.id,
name: data.name as DocumentEvent["name"],
actor: data.actor,
user: data.user,
createdAt: data.createdAt,
} satisfies Event;
},
[]
);
const fetchHistory = React.useCallback(async () => {
if (!document) {
return [];
}
const [revisionsArr, eventsArr] = await Promise.all([
revisions.fetchPage({
documentId: document.id,
offset: offset.revisions,
limit: Pagination.defaultLimit,
}),
events.fetchPage({
events: DocumentEvents,
documentId: document.id,
offset: offset.events,
limit: Pagination.defaultLimit,
}),
]);
const pageEvents = orderBy(
[...revisionsArr, ...eventsArr].map(toEvent),
"createdAt",
"desc"
).slice(0, Pagination.defaultLimit);
const revisionsCount = pageEvents.filter(
(event) => event.name === "revisions.create"
).length;
offset.revisions += revisionsCount;
offset.events += pageEvents.length - revisionsCount;
// needed to re-render after mobx store and offset is updated
setForceRender((s) => ++s);
return pageEvents;
}, [document, revisions, events, toEvent, offset]);
const revisionEvents = React.useMemo(
() =>
document
? revisions
.filter({ documentId: document.id })
.slice(0, offset.revisions + 1) // take one extra to account for realtime edits
.map(toEvent)
: [],
[document, revisions, offset.revisions, toEvent]
);
const otherEvents = React.useMemo(
() =>
document
? events
.filter({ documentId: document.id })
.slice(0, offset.events)
.map(toEvent)
: [],
[document, events, offset.events, toEvent]
);
const latestRevision = revisionEvents[0];
if (latestRevision && document) {
if (latestRevision.createdAt !== document.updatedAt) {
revisionEvents.unshift({
id: RevisionHelper.latestId(document.id),
name: "revisions.create",
createdAt: document.updatedAt,
actor: document.updatedBy!,
latest: true,
});
} else {
(latestRevision as RevisionEvent).latest = true;
}
}
const mergedEvents = React.useMemo(
() => orderBy([...revisionEvents, ...otherEvents], "createdAt", "desc"),
[revisionEvents, otherEvents]
);
const onCloseHistory = React.useCallback(() => {
const onCloseHistory = () => {
if (document) {
history.push({
pathname: documentPath(document),
@@ -157,7 +37,30 @@ function History() {
} else {
history.goBack();
}
}, [history, document, sidebarContext]);
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event(
{
id: RevisionHelper.latestId(document.id),
name: "revisions.create",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
},
events
)
);
}
return eventsInDocument;
}, [eventsInDocument, events, document]);
useKeyDown("Escape", onCloseHistory);
@@ -166,8 +69,11 @@ function History() {
{document ? (
<PaginatedEventList
aria-label={t("History")}
fetch={fetchHistory}
events={mergedEvents}
fetch={events.fetchPage}
events={items}
options={{
documentId: document.id,
}}
document={document}
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
/>
+1 -1
View File
@@ -58,7 +58,7 @@ export default class RevisionsStore extends Store<Revision> {
@action
fetchPage = async (
options: { documentId: string } & (PaginationParams | undefined)
options: PaginationParams | undefined
): Promise<Revision[]> => {
this.isFetching = true;
-39
View File
@@ -228,45 +228,6 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(event.id);
});
it("should allow filtering by events param", async () => {
const user = await buildUser();
const admin = await buildAdmin({ teamId: user.teamId });
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
collectionId: collection.id,
teamId: user.teamId,
});
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
actorId: admin.id,
userId: user.id,
});
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
events: ["documents.publish"],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should return events with deleted actors", async () => {
const user = await buildUser();
const admin = await buildAdmin({ teamId: user.teamId });
+16 -23
View File
@@ -1,5 +1,4 @@
import Router from "koa-router";
import intersection from "lodash/intersection";
import { Op, WhereOptions } from "sequelize";
import { EventHelper } from "@shared/utils/EventHelper";
import auth from "@server/middlewares/authentication";
@@ -21,35 +20,20 @@ router.post(
async (ctx: APIContext<T.EventsListReq>) => {
const { user } = ctx.state.auth;
const {
name,
events,
auditLog,
sort,
direction,
actorId,
documentId,
collectionId,
sort,
direction,
name,
auditLog,
} = ctx.input.body;
let where: WhereOptions<Event> = {
name: EventHelper.ACTIVITY_EVENTS,
teamId: user.teamId,
};
if (auditLog) {
authorize(user, "audit", user.team);
where.name = events
? intersection(EventHelper.AUDIT_EVENTS, events)
: EventHelper.AUDIT_EVENTS;
} else {
where.name = events
? intersection(EventHelper.ACTIVITY_EVENTS, events)
: EventHelper.ACTIVITY_EVENTS;
}
if (name && (where.name as string[]).includes(name)) {
where.name = name;
}
if (actorId) {
where = { ...where, actorId };
}
@@ -58,6 +42,15 @@ router.post(
where = { ...where, documentId };
}
if (auditLog) {
authorize(user, "audit", user.team);
where.name = EventHelper.AUDIT_EVENTS;
}
if (name && (where.name as string[]).includes(name)) {
where.name = name;
}
if (collectionId) {
where = { ...where, collectionId };
@@ -84,7 +77,7 @@ router.post(
};
}
const loadedEvents = await Event.findAll({
const events = await Event.findAll({
where,
order: [[sort, direction]],
include: [
@@ -101,7 +94,7 @@ router.post(
ctx.body = {
pagination: ctx.state.pagination,
data: await Promise.all(
loadedEvents.map((event) => presentEvent(event, auditLog))
events.map((event) => presentEvent(event, auditLog))
),
};
}
+1 -14
View File
@@ -1,19 +1,8 @@
import { z } from "zod";
import { EventHelper } from "@shared/utils/EventHelper";
import { BaseSchema } from "@server/routes/api/schema";
export const EventsListSchema = BaseSchema.extend({
body: z.object({
/** Events to retrieve */
events: z
.array(
z.union([
z.enum(EventHelper.ACTIVITY_EVENTS),
z.enum(EventHelper.AUDIT_EVENTS),
])
)
.optional(),
/** Id of the user who performed the action */
actorId: z.string().uuid().optional(),
@@ -26,9 +15,7 @@ export const EventsListSchema = BaseSchema.extend({
/** Whether to include audit events */
auditLog: z.boolean().default(false),
/** @deprecated, use 'events' parameter instead
* Name of the event to retrieve
*/
/** Name of the event to retrieve */
name: z.string().optional(),
/** The attribute to sort the events by */
+4 -4
View File
@@ -1,5 +1,5 @@
export class EventHelper {
public static ACTIVITY_EVENTS = [
public static readonly ACTIVITY_EVENTS = [
"collections.create",
"collections.delete",
"collections.move",
@@ -20,9 +20,9 @@ export class EventHelper {
"users.create",
"users.demote",
"userMemberships.update",
] as const;
];
public static AUDIT_EVENTS = [
public static readonly AUDIT_EVENTS = [
"api_keys.create",
"api_keys.delete",
"authenticationProviders.update",
@@ -73,5 +73,5 @@ export class EventHelper {
"fileOperations.delete",
"webhookSubscriptions.create",
"webhookSubscriptions.delete",
] as const;
];
}