feat: Include more events in document history sidebar (#2334)

closes #2230
This commit is contained in:
Tom Moor 2021-08-05 18:03:55 -04:00 committed by GitHub
parent 57a2524fbd
commit 9db72217af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 600 additions and 369 deletions

View File

@ -66,7 +66,11 @@ const RealButton = styled.button`
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;

View File

@ -0,0 +1,124 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "models/Event";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PaginatedEventList from "components/PaginatedEventList";
import Scrollable from "components/Scrollable";
import useStores from "hooks/useStores";
import { documentUrl } from "utils/routeHelpers";
const EMPTY_ARRAY = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
history.push(documentUrl(document));
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event({
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
})
);
}
return eventsInDocument;
}, [eventsInDocument, document]);
return (
<Sidebar>
{document ? (
<Position column>
<Header>
<Title>{t("History")}</Title>
<Button
icon={<CloseIcon />}
onClick={onCloseHistory}
borderOnHover
neutral
/>
</Header>
<Scrollable topShadow>
<PaginatedEventList
fetch={events.fetchPage}
events={items}
options={{ documentId: document.id }}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
) : null}
</Sidebar>
);
}
const Position = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
background: ${(props) => props.theme.background};
width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default observer(DocumentHistory);

View File

@ -1,199 +0,0 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import PlaceholderList from "components/List/Placeholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match,
documents: DocumentsStore,
revisions: RevisionsStore,
history: RouterHistory,
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
this.selectFirstRevision();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
documentId: this.props.match.params.documentSlug,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
selectFirstRevision = () => {
if (this.revisions.length) {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return;
this.props.history.replace(
documentHistoryUrl(document, this.revisions[0].id)
);
}
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
get revisions() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return [];
return this.props.revisions.getDocumentRevisions(document.id);
}
onCloseHistory = () => {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
this.redirectTo = documentUrl(document);
};
render() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
const showLoading = (!this.isLoaded && this.isFetching) || !document;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Sidebar>
<Wrapper column>
<Header>
<Title>History</Title>
<Button
icon={<CloseIcon />}
onClick={this.onCloseHistory}
borderOnHover
neutral
/>
</Header>
{showLoading ? (
<Loading>
<PlaceholderList count={5} />
</Loading>
) : (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.revisions.map((revision, index) => (
<Revision
key={revision.id}
revision={revision}
document={document}
showMenu={index !== 0}
selected={this.props.match.params.revisionId === revision.id}
/>
))}
</ArrowKeyNavigation>
)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</Wrapper>
</Sidebar>
);
}
}
const Loading = styled.div`
margin: 0 16px;
`;
const Wrapper = styled(Flex)`
position: fixed;
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth}px;
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth}px;
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);

View File

@ -1,87 +0,0 @@
// @flow
import { format } from "date-fns";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Revision from "models/Revision";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme,
showMenu: boolean,
selected: boolean,
document: Document,
revision: Revision,
};
class RevisionListItem extends React.Component<Props> {
render() {
const { revision, document, showMenu, selected, theme } = this.props;
return (
<StyledNavLink
to={documentHistoryUrl(document, revision.id)}
activeStyle={{ background: theme.primary, color: theme.white }}
>
<Author>
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
</Time>
</Meta>
{showMenu && (
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>
);
}
}
const StyledAvatar = styled(Avatar)`
border-color: transparent;
margin-right: 4px;
`;
const StyledRevisionMenu = styled(RevisionMenu)`
position: absolute;
right: 16px;
top: 20px;
`;
const StyledNavLink = styled(NavLink)`
color: ${(props) => props.theme.text};
display: block;
padding: 8px 16px;
font-size: 15px;
position: relative;
`;
const Author = styled(Flex)`
font-weight: 500;
padding: 0;
margin: 0;
`;
const Meta = styled.p`
font-size: 14px;
opacity: 0.75;
margin: 0 0 2px;
padding: 0;
`;
export default withTheme(RevisionListItem);

View File

@ -1,3 +0,0 @@
// @flow
import DocumentHistory from "./DocumentHistory";
export default DocumentHistory;

View File

@ -0,0 +1,163 @@
// @flow
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import Avatar from "components/Avatar";
import Item, { Actions } from "components/List/Item";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
event: Event,
latest?: boolean,
|};
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const opts = { userName: event.actor.name };
const isRevision = event.name === "revisions.create";
let meta, icon, to;
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon color="currentColor" size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
break;
default:
console.warn("Unhandled event: ", event.name);
}
if (!meta) {
return null;
}
return (
<ListItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={250}
format="MMMM do, h:mm a"
relative={false}
addSuffix
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
/>
);
};
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${(props) => props.theme.textSecondary};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: 50%;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.25;
transition: opacity 100ms ease-in-out;
}
&:hover {
${Actions} {
opacity: 1;
}
}
`;
export default EventListItem;

View File

@ -1,41 +1,62 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import Flex from "components/Flex";
import NavLink from "components/NavLink";
type Props = {
type Props = {|
image?: React.Node,
to?: string,
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
};
|};
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
ref
) => {
const theme = useTheme();
const compact = !subtitle;
return (
<Wrapper compact={compact} $border={border}>
const content = (selected) => (
<>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Content
align={compact ? "center" : undefined}
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
</Subtitle>
)}
</Content>
{actions && <Actions>{actions}</Actions>}
{actions && <Actions $selected={selected}>{actions}</Actions>}
</>
);
return (
<Wrapper
ref={ref}
$border={border}
activeStyle={{ background: theme.primary }}
{...rest}
as={to ? NavLink : undefined}
to={to}
>
{to ? content : content(false)}
</Wrapper>
);
};
const Wrapper = styled.li`
const Wrapper = styled.div`
display: flex;
user-select: none;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid
@ -57,28 +78,36 @@ const Image = styled(Flex)`
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2;
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
const Content = styled(Flex)`
const Content = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) => props.theme.textTertiary};
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`
export const Actions = styled(Flex)`
align-self: center;
justify-content: center;
margin-right: 4px;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default ListItem;
export default React.forwardRef<Props, HTMLDivElement>(ListItem);

View File

@ -1,5 +1,5 @@
// @flow
import { format, formatDistanceToNow } from "date-fns";
import { format as formatDate, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
@ -57,6 +57,9 @@ type Props = {
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
relative?: boolean,
format?: string,
tooltip?: boolean,
};
function LocaleTime({
@ -64,7 +67,10 @@ function LocaleTime({
children,
dateTime,
shorten,
format,
relative,
tooltipDelay,
tooltip,
}: Props) {
const userLocale = useUserLocale();
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars
@ -82,25 +88,34 @@ function LocaleTime({
};
}, []);
let content = formatDistanceToNow(Date.parse(dateTime), {
const locale = userLocale ? locales[userLocale] : undefined;
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
addSuffix,
locale: userLocale ? locales[userLocale] : undefined,
locale,
});
if (shorten) {
content = content
relativeContent = relativeContent
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
const tooltipContent = formatDate(
Date.parse(dateTime),
format || "MMMM do, yyyy h:mm a",
{ locale }
);
const content = children || relative ? relativeContent : tooltipContent;
if (!tooltip) {
return content;
}
return (
<Tooltip
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
<Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
<time dateTime={dateTime}>{content}</time>
</Tooltip>
);
}

26
app/components/NavLink.js Normal file
View File

@ -0,0 +1,26 @@
// @flow
import * as React from "react";
import { NavLink, Route, type Match } from "react-router-dom";
type Props = {
children?: (match: Match) => React.Node,
exact?: boolean,
to: string,
};
export default function NavLinkWithChildrenFunc({
to,
exact = false,
children,
...rest
}: Props) {
return (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink {...rest} to={to} exact={exact}>
{children ? children(match) : null}
</NavLink>
)}
</Route>
);
}

View File

@ -12,6 +12,7 @@ const Button = styled.button`
padding: 0;
cursor: pointer;
user-select: none;
color: inherit;
`;
export default React.forwardRef<any, typeof Button>(

View File

@ -0,0 +1,53 @@
// @flow
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 from "./EventListItem";
type Props = {|
events: Event[],
document: Document,
fetch: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
|};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
events,
fetch,
options,
document,
...rest
}: Props) {
return (
<PaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
/>
);
});
const Heading = styled("h3")`
font-size: 14px;
padding: 0 12px;
`;
export default PaginatedEventList;

View File

@ -4,10 +4,12 @@ import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { dateToHeading } from "utils/dates";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
@ -15,7 +17,9 @@ type Props = {
heading?: React.Node,
empty?: React.Node,
items: any[],
renderItem: (any) => React.Node,
renderItem: (any, index: number) => React.Node,
renderHeading?: (name: React.Element<any> | string) => React.Node,
t: TFunction,
};
@observer
@ -101,8 +105,9 @@ class PaginatedList extends React.Component<Props> {
};
render() {
const { items, heading, empty } = this.props;
const { items, heading, empty, renderHeading } = this.props;
let previousHeading = "";
const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
@ -119,7 +124,37 @@ class PaginatedList extends React.Component<Props> {
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map(this.props.renderItem)}
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(currentDate, this.props.t);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
@ -136,4 +171,6 @@ class PaginatedList extends React.Component<Props> {
}
}
export default PaginatedList;
export const Component = PaginatedList;
export default withTranslation()<PaginatedList>(PaginatedList);

View File

@ -4,7 +4,7 @@ import { shallow } from "enzyme";
import * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList";
import { Component as PaginatedList } from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;

View File

@ -1,25 +1,13 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import { NavLink, Route } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
import styled, { useTheme } from "styled-components";
import NavLinkWithChildrenFunc from "components/NavLink";
type Props = {
theme: Theme,
children: React.Node,
};
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} exact={exact} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative;
display: inline-flex;
@ -53,7 +41,8 @@ const transition = {
damping: 30,
};
function Tab({ theme, children, ...rest }: Props) {
export default function Tab({ children, ...rest }: Props) {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
};
@ -75,5 +64,3 @@ function Tab({ theme, children, ...rest }: Props) {
</TabLink>
);
}
export default withTheme(Tab);

View File

@ -11,6 +11,7 @@ type Props = {
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
format?: string,
shorten?: boolean,
};

View File

@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import Document from "models/Document";
import Revision from "models/Revision";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
@ -16,12 +15,11 @@ import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
revision: Revision,
iconColor?: string,
revisionId: string,
className?: string,
|};
function RevisionMenu({ document, revision, className, iconColor }: Props) {
function RevisionMenu({ document, revisionId, className }: Props) {
const { showToast } = useToasts();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
@ -30,11 +28,11 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await document.restore({ revisionId: revision.id });
await document.restore({ revisionId });
showToast(t("Document restored"), { type: "success" });
history.push(document.url);
},
[history, showToast, t, document, revision]
[history, showToast, t, document, revisionId]
);
const handleCopy = React.useCallback(() => {
@ -43,14 +41,14 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
const url = `${window.location.origin}${documentHistoryUrl(
document,
revision.id
revisionId
)}`;
return (
<>
<OverflowMenuButton
className={className}
iconColor={iconColor}
iconColor="currentColor"
aria-label={t("Show menu")}
{...menu}
/>

View File

@ -20,20 +20,6 @@ class Event extends BaseModel {
published: boolean,
templateId: string,
};
get model() {
return this.name.split(".")[0];
}
get verb() {
return this.name.split(".")[1];
}
get verbPastTense() {
const v = this.verb;
if (v.endsWith("e")) return `${v}d`;
return `${v}ed`;
}
}
export default Event;

23
app/stores/EventsStore.js Normal file
View File

@ -0,0 +1,23 @@
// @flow
import { sortBy, filter } from "lodash";
import { computed } from "mobx";
import Event from "models/Event";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
export default class EventsStore extends BaseStore<Event> {
actions = ["list"];
constructor(rootStore: RootStore) {
super(rootStore, Event);
}
@computed
get orderedData(): Event[] {
return sortBy(Array.from(this.data.values()), "createdAt").reverse();
}
inDocument(documentId: string): Event[] {
return filter(this.orderedData, (event) => event.documentId === documentId);
}
}

View File

@ -5,6 +5,7 @@ import CollectionGroupMembershipsStore from "./CollectionGroupMembershipsStore";
import CollectionsStore from "./CollectionsStore";
import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore";
import EventsStore from "./EventsStore";
import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupsStore from "./GroupsStore";
import IntegrationsStore from "./IntegrationsStore";
@ -24,6 +25,7 @@ export default class RootStore {
collections: CollectionsStore;
collectionGroupMemberships: CollectionGroupMembershipsStore;
documents: DocumentsStore;
events: EventsStore;
groups: GroupsStore;
groupMemberships: GroupMembershipsStore;
integrations: IntegrationsStore;
@ -46,6 +48,7 @@ export default class RootStore {
this.collections = new CollectionsStore(this);
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
this.documents = new DocumentsStore(this);
this.events = new EventsStore(this);
this.groups = new GroupsStore(this);
this.groupMemberships = new GroupMembershipsStore(this);
this.integrations = new IntegrationsStore(this);
@ -66,6 +69,7 @@ export default class RootStore {
this.collections.clear();
this.collectionGroupMemberships.clear();
this.documents.clear();
this.events.clear();
this.groups.clear();
this.groupMemberships.clear();
this.integrations.clear();

View File

@ -3,6 +3,9 @@
import localStorage from '../../__mocks__/localStorage';
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { initI18n } from "shared/i18n";
initI18n();
Enzyme.configure({ adapter: new Adapter() });

51
app/utils/dates.js Normal file
View File

@ -0,0 +1,51 @@
// @flow
import {
isToday,
isYesterday,
differenceInCalendarWeeks,
differenceInCalendarMonths,
} from "date-fns";
import * as React from "react";
import { type TFunction } from "react-i18next";
import LocaleTime from "components/LocaleTime";
export function dateToHeading(dateTime: string, t: TFunction) {
const date = Date.parse(dateTime);
const now = new Date();
if (isToday(date)) {
return t("Today");
}
if (isYesterday(date)) {
return t("Yesterday");
}
// If the current calendar week but not today or yesterday then return the day
// of the week as a string. We use the LocaleTime component here to gain
// async bundle loading of languages
const weekDiff = differenceInCalendarWeeks(now, date);
if (weekDiff === 0) {
return <LocaleTime dateTime={dateTime} tooltip={false} format="iiii" />;
}
if (weekDiff === 1) {
return t("Last week");
}
const monthDiff = differenceInCalendarMonths(now, date);
if (monthDiff === 0) {
return t("This month");
}
if (monthDiff === 1) {
return t("Last month");
}
if (monthDiff <= 12) {
return t("This year");
}
// If older than the current calendar year then just print the year e.g 2020
return <LocaleTime dateTime={dateTime} tooltip={false} format="y" />;
}

View File

@ -14,6 +14,8 @@
"Drafts": "Drafts",
"Templates": "Templates",
"Deleted Collection": "Deleted Collection",
"History": "History",
"Oh weird, there's nothing here": "Oh weird, there's nothing here",
"New": "New",
"Only visible to you": "Only visible to you",
"Draft": "Draft",
@ -110,6 +112,14 @@
"our engineers have been notified": "our engineers have been notified",
"Report a Bug": "Report a Bug",
"Show Detail": "Show Detail",
"Latest version": "Latest version",
"{{userName}} edited": "{{userName}} edited",
"{{userName}} archived": "{{userName}} archived",
"{{userName}} restored": "{{userName}} restored",
"{{userName}} deleted": "{{userName}} deleted",
"{{userName}} moved from trash": "{{userName}} moved from trash",
"{{userName}} published": "{{userName}} published",
"{{userName}} moved": "{{userName}} moved",
"Icon": "Icon",
"Show menu": "Show menu",
"Choose icon": "Choose icon",
@ -200,7 +210,6 @@
"Unpublish": "Unpublish",
"Permanently delete": "Permanently delete",
"Move": "Move",
"History": "History",
"Download": "Download",
"Print": "Print",
"Move {{ documentName }}": "Move {{ documentName }}",
@ -552,5 +561,11 @@
"Joined": "Joined",
"{{ time }} ago.": "{{ time }} ago.",
"Edit Profile": "Edit Profile",
"{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet."
"{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet.",
"Today": "Today",
"Yesterday": "Yesterday",
"Last week": "Last week",
"This month": "This month",
"Last month": "Last month",
"This year": "This year"
}