feat: Document history design (#12112)

* refactor

* refactor

* design
This commit is contained in:
Tom Moor
2026-04-19 09:37:09 -04:00
committed by GitHub
parent 46b040a9f4
commit 666b3879b3
19 changed files with 234 additions and 151 deletions
@@ -120,7 +120,7 @@ const DefaultCollectionInputSelect = observer(
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
labelHidden
short
/>
);
@@ -39,7 +39,7 @@ export default function InputMemberPermissionSelect(
value={value || EmptySelectValue}
onChange={onChange}
label={t("Permissions")}
hideLabel
labelHidden
nude
{...rest}
/>
+5 -5
View File
@@ -60,7 +60,7 @@ type Props = Omit<React.HTMLAttributes<HTMLButtonElement>, "onChange"> & {
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
labelHidden?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
@@ -76,7 +76,7 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
value,
onChange,
label,
hideLabel,
labelHidden,
short,
help,
...triggerProps
@@ -149,7 +149,7 @@ export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} help={help} />
<Label text={label} hidden={labelHidden ?? false} help={help} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
@@ -188,7 +188,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
value,
onChange,
label,
hideLabel,
labelHidden,
disabled,
short,
placeholder,
@@ -252,7 +252,7 @@ const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Label text={label} hidden={labelHidden ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
+1 -1
View File
@@ -11,7 +11,7 @@ type Props = {
shrink?: boolean;
} & Pick<
React.ComponentProps<typeof InputSelect>,
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
"value" | "onChange" | "disabled" | "labelHidden" | "nude" | "help"
>;
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
@@ -123,7 +123,7 @@ function Notifications(
<HStack>
<StyledInputSelect
label={t("Filter")}
hideLabel
labelHidden
options={filterOptions}
value={filter}
onChange={(value) => setFilter(value as NotificationFilter)}
@@ -125,7 +125,7 @@ export const AccessControlList = observer(
}}
disabled={!can.update}
value={collection?.permission}
hideLabel
labelHidden
nude
shrink
/>
@@ -75,7 +75,7 @@ const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
value={value}
onChange={handleChange}
label={t("Sort comments")}
hideLabel
labelHidden
borderOnHover
/>
);
@@ -12,11 +12,11 @@ import {
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Text from "@shared/components/Text";
import type Document from "~/models/Document";
import type Event from "~/models/Event";
import Time from "~/components/Time";
import Logger from "~/utils/Logger";
import Text from "./Text";
type Props = {
document: Document;
@@ -0,0 +1,129 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { dateLocale } from "@shared/utils/date";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import type Document from "~/models/Document";
import type Event from "~/models/Event";
import Revision from "~/models/Revision";
import { InputSelect, type Option } from "~/components/InputSelect";
import Switch from "~/components/Switch";
import useUserLocale from "~/hooks/useUserLocale";
import { revisionCollaboratorText } from "./utils";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Fade from "~/components/Fade";
export const COMPARE_TO_PREVIOUS = "previous";
interface Props {
showChanges: boolean;
onShowChangesToggle: (checked: boolean) => void;
items: (Revision | Event<Document>)[];
document?: Document;
selectedRevisionId?: string;
compareTo: string;
onCompareToChange: (value: string) => void;
}
export function HighlightChangesControl({
showChanges,
onShowChangesToggle,
items,
document,
selectedRevisionId,
compareTo,
onCompareToChange,
}: Props) {
const { t } = useTranslation();
const userLocale = useUserLocale();
const compareOptions = React.useMemo((): Option[] => {
const revisionItems = items.filter(
(item): item is Revision => item instanceof Revision
);
const locale = dateLocale(userLocale);
const resolvedSelectedId =
selectedRevisionId === "latest" && document
? RevisionHelper.latestId(document.id)
: selectedRevisionId;
const options: Option[] = [
{
type: "item",
label: t("Previous version"),
value: COMPARE_TO_PREVIOUS,
},
];
const latestId = document
? RevisionHelper.latestId(document.id)
: undefined;
for (const rev of revisionItems) {
if (rev.id === resolvedSelectedId) {
continue;
}
const dateLabel = formatDate(new Date(rev.createdAt), "MMM do, h:mm a", {
locale,
});
const collaboratorText = revisionCollaboratorText(rev, t);
options.push({
type: "item",
label: rev.name ?? dateLabel,
value: rev.id === latestId ? "latest" : rev.id,
description: collaboratorText,
});
}
return options;
}, [items, selectedRevisionId, document, userLocale, t]);
return (
<Content>
<ResizingHeightContainer>
<Text size="small" as="div" style={{ padding: 4 }}>
<Switch
label={t("Highlight changes")}
checked={showChanges}
onChange={onShowChangesToggle}
/>
</Text>
{showChanges && (
<Fade as="div">
<StyledInputSelect
options={compareOptions}
value={compareTo}
onChange={onCompareToChange}
label={t("Compare to")}
labelHidden
nude
short
/>
</Fade>
)}
</ResizingHeightContainer>
</Content>
);
}
const StyledInputSelect = styled(InputSelect)`
margin: -4px -9px -1px;
width: calc(100% + 18px);
border-top-left-radius: 0;
border-top-right-radius: 0;
position: relative;
inset-block-end: -1px;
`;
const Content = styled.div`
margin: 0 16px 8px;
border: 1px solid ${(props) => props.theme.inputBorder};
border-radius: 6px;
padding: 8px 8px 0;
flex-shrink: 0;
`;
@@ -1,34 +1,29 @@
import { format as formatDate } from "date-fns";
import isEqual from "fast-deep-equal";
import { dateLocale } from "@shared/utils/date";
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 { InputSelect, type Option } from "~/components/InputSelect";
import PaginatedEventList from "~/components/PaginatedEventList";
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 useUserLocale from "~/hooks/useUserLocale";
import { documentPath, matchDocumentHistory } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
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";
import Flex from "@shared/components/Flex";
const COMPARE_TO_PREVIOUS = "previous";
const DocumentEvents = [
"documents.publish",
"documents.unpublish",
@@ -55,7 +50,6 @@ function History() {
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
const [eventsOffset, setEventsOffset] = React.useState(0);
const isMobile = useMobile();
const userLocale = useUserLocale();
const [compareTo, setCompareTo] = React.useState(
() => query.get("compareTo") ?? COMPARE_TO_PREVIOUS
);
@@ -221,49 +215,6 @@ function History() {
return merged;
}, [revisions, document, revisionEvents, nonRevisionEvents]);
const compareOptions = React.useMemo((): Option[] => {
const revisionItems = items.filter(
(item): item is Revision => item instanceof Revision
);
const locale = dateLocale(userLocale);
const resolvedSelectedId =
selectedRevisionId === "latest" && document
? RevisionHelper.latestId(document.id)
: selectedRevisionId;
const options: Option[] = [
{ type: "item", label: t("Previous revision"), value: COMPARE_TO_PREVIOUS },
];
const latestId = document ? RevisionHelper.latestId(document.id) : undefined;
for (const rev of revisionItems) {
if (rev.id === resolvedSelectedId) {
continue;
}
const dateLabel = formatDate(
new Date(rev.createdAt),
"MMM do, h:mm a",
{ locale }
);
const collaboratorName =
rev.collaborators?.[0]?.name ?? rev.createdBy?.name;
options.push({
type: "item",
label: dateLabel,
value: rev.id === latestId ? "latest" : rev.id,
description: collaboratorName
? t("{{userName}} edited", { userName: collaboratorName })
: undefined,
});
}
return options;
}, [items, selectedRevisionId, document, userLocale, t]);
const onCloseHistory = React.useCallback(() => {
if (isMobile) {
// Allow closing the history drawer on mobile to view revision content
@@ -283,26 +234,15 @@ function History() {
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>
{showChanges && (
<CompareToWrapper>
<InputSelect
options={compareOptions}
value={compareTo}
onChange={handleCompareToChange}
label={t("Compare to")}
short
/>
</CompareToWrapper>
)}
</Content>
<HighlightChangesControl
showChanges={showChanges}
onShowChangesToggle={handleShowChangesToggle}
items={items}
document={document}
selectedRevisionId={selectedRevisionId}
compareTo={compareTo}
onCompareToChange={handleCompareToChange}
/>
<Scrollable hiddenScrollbars topShadow>
{document ? (
<PaginatedEventList
@@ -333,16 +273,4 @@ function History() {
);
}
const Content = styled.div`
margin: 0 16px 8px;
border: 1px solid ${(props) => props.theme.inputBorder};
border-radius: 8px;
padding: 8px 8px 0;
flex-shrink: 0;
`;
const CompareToWrapper = styled.div`
padding: 4px 0 8px;
`;
export default observer(History);
@@ -4,9 +4,9 @@ import { EditIcon, TrashIcon } from "outline-icons";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled from "styled-components";
import styled, { css } from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import { ellipsis, hover } from "@shared/styles";
import { ellipsis, hover, s } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
@@ -27,8 +27,9 @@ import { useMenuAction } from "~/hooks/useMenuAction";
import RevisionMenu from "~/menus/RevisionMenu";
import { documentHistoryPath } from "~/utils/routeHelpers";
import { EventItem, lineStyle } from "./EventListItem";
import Facepile from "./Facepile";
import Text from "./Text";
import Facepile from "~/components/Facepile";
import Text from "~/components/Text";
import { revisionCollaboratorText } from "./utils";
type Props = {
document: Document;
@@ -70,16 +71,7 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
} else {
icon = <EditIcon size={16} />;
let collaboratorText: string | undefined;
if (item.collaborators && item.collaborators.length === 2) {
collaboratorText = `${item.collaborators[0].name} and ${item.collaborators[1].name}`;
} else if (item.collaborators && item.collaborators.length > 2) {
collaboratorText = t("{{count}} people", {
count: item.collaborators.length,
});
} else {
collaboratorText = item.createdBy?.name;
}
const collaboratorText = revisionCollaboratorText(item, t);
meta = isLatestRevision ? (
<>
@@ -101,11 +93,6 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
};
}
const isActive =
typeof to === "string"
? location.pathname === to
: location.pathname === to?.pathname;
if (document.isDeleted) {
to = undefined;
}
@@ -157,11 +144,9 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
}
subtitle={<Meta>{meta}</Meta>}
actions={
isActive ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={item.id} />
</StyledEventBoundary>
) : undefined
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={item.id} />
</StyledEventBoundary>
}
ref={ref}
$menuOpen={menuOpen}
@@ -192,15 +177,33 @@ const RevisionItem = styled(Item)<{ $menuOpen?: boolean }>`
padding: 8px;
border-radius: 8px;
${lineStyle}
${Actions} {
opacity: ${(props) => (props.$menuOpen ? 1 : 0.5)};
opacity: 0;
}
&: ${hover} {
&:${hover},
&:active,
&:focus,
&:focus-within,
&:has([data-state="open"]) {
background: ${s("listItemHoverBackground")};
${Actions} {
opacity: 1;
}
}
${(props) =>
props.$menuOpen &&
css`
background: ${s("listItemHoverBackground")};
${Actions} {
opacity: 1;
}
`}
${lineStyle}
`;
export default observer(RevisionListItem);
@@ -0,0 +1,23 @@
import type { TFunction } from "i18next";
import type Revision from "~/models/Revision";
/**
* Returns a human-readable summary of who collaborated on a revision. Uses the
* collaborator list when available, falling back to the creator's name.
*
* @param revision the revision to summarize.
* @param t translation function.
* @returns the collaborator text, or undefined if unavailable.
*/
export function revisionCollaboratorText(
revision: Revision,
t: TFunction
): string | undefined {
if (revision.collaborators && revision.collaborators.length === 2) {
return `${revision.collaborators[0].name} and ${revision.collaborators[1].name}`;
}
if (revision.collaborators && revision.collaborators.length > 2) {
return t("{{count}} people", { count: revision.collaborators.length });
}
return revision.createdBy?.name;
}
@@ -22,7 +22,7 @@ const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments/Comments")
);
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
() => import("~/scenes/Document/components/History/History")
);
interface DocumentSidebarContentProps {
+1 -1
View File
@@ -187,7 +187,7 @@ const Application = observer(function Application({ oauthClient }: Props) {
name="clientType"
render={({ field }) => (
<InputClientType
hideLabel
labelHidden
value={field.value}
onChange={field.onChange}
ref={field.ref}
+1 -1
View File
@@ -317,7 +317,7 @@ function Details() {
value={tocPosition}
onChange={handleTocPositionChange}
label={t("Table of contents position")}
hideLabel
labelHidden
/>
</SettingRow>
+3 -3
View File
@@ -188,7 +188,7 @@ function Preferences() {
value={user.language}
onChange={handleLanguageChange}
label={t("Language")}
hideLabel
labelHidden
/>
</SettingRow>
<SettingRow
@@ -201,7 +201,7 @@ function Preferences() {
value={ui.theme}
onChange={handleThemeChange}
label={t("Appearance")}
hideLabel
labelHidden
/>
</SettingRow>
<SettingRow
@@ -293,7 +293,7 @@ function Preferences() {
value={user.getPreference(UserPreference.NotificationBadge)}
onChange={handleNotificationBadgeChange}
label={t("Notification badge")}
hideLabel
labelHidden
/>
</SettingRow>
+2 -2
View File
@@ -258,7 +258,7 @@ function Security() {
options={userRoleOptions}
onChange={handleDefaultRoleChange}
label={t("Default role")}
hideLabel
labelHidden
short
/>
</SettingRow>
@@ -331,7 +331,7 @@ function Security() {
options={emailDisplayOptions}
onChange={handleEmailDisplayChange}
label={t("Email address visibility")}
hideLabel
labelHidden
short
/>
</SettingRow>
+16 -16
View File
@@ -324,15 +324,6 @@
"our engineers have been notified": "our engineers have been notified",
"Clear cache + reload": "Clear cache + reload",
"Show detail": "Show detail",
"{{userName}} archived": "{{userName}} archived",
"{{userName}} restored": "{{userName}} restored",
"{{userName}} deleted": "{{userName}} deleted",
"{{userName}} added {{addedUserName}}": "{{userName}} added {{addedUserName}}",
"{{userName}} removed {{removedUserName}}": "{{userName}} removed {{removedUserName}}",
"{{userName}} moved from trash": "{{userName}} moved from trash",
"{{userName}} published": "{{userName}} published",
"{{userName}} unpublished": "{{userName}} unpublished",
"{{userName}} moved": "{{userName}} moved",
"A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.",
"A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.",
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.",
@@ -424,12 +415,6 @@
"{{ hours }}h {{ minutes }}m read": "{{ hours }}h {{ minutes }}m read",
"{{ hours }}h read": "{{ hours }}h read",
"{{ minutes }}m read": "{{ minutes }}m read",
"Revision deleted": "Revision deleted",
"{{count}} people": "{{count}} person",
"{{count}} people_plural": "{{count}} people",
"Current version": "Current version",
"{{userName}} edited": "{{userName}} edited",
"Revision options": "Revision options",
"Manage": "Manage",
"All members": "All members",
"Everyone in the workspace": "Everyone in the workspace",
@@ -725,6 +710,7 @@
"Revoking": "Revoking",
"Are you sure you want to revoke access?": "Are you sure you want to revoke access?",
"Delete app": "Delete app",
"Revision options": "Revision options",
"Share options": "Share options",
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
"Contents": "Contents",
@@ -832,10 +818,24 @@
"Archived": "Archived",
"Save draft": "Save draft",
"Restore version": "Restore version",
"Previous revision": "Previous revision",
"{{userName}} archived": "{{userName}} archived",
"{{userName}} restored": "{{userName}} restored",
"{{userName}} deleted": "{{userName}} deleted",
"{{userName}} added {{addedUserName}}": "{{userName}} added {{addedUserName}}",
"{{userName}} removed {{removedUserName}}": "{{userName}} removed {{removedUserName}}",
"{{userName}} moved from trash": "{{userName}} moved from trash",
"{{userName}} published": "{{userName}} published",
"{{userName}} unpublished": "{{userName}} unpublished",
"{{userName}} moved": "{{userName}} moved",
"Previous version": "Previous version",
"Highlight changes": "Highlight changes",
"Compare to": "Compare to",
"No history yet": "No history yet",
"Revision deleted": "Revision deleted",
"Current version": "Current version",
"{{userName}} edited": "{{userName}} edited",
"{{count}} people": "{{count}} person",
"{{count}} people_plural": "{{count}} people",
"Source": "Source",
"Created": "Created",
"Imported from {{ source }}": "Imported from {{ source }}",