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 { &: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, ${ box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder props.theme.buttonNeutralBorder
} 0 0 0 1px inset; } 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 // @flow
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled, { useTheme } from "styled-components";
import Flex from "components/Flex"; import Flex from "components/Flex";
import NavLink from "components/NavLink";
type Props = { type Props = {|
image?: React.Node, image?: React.Node,
to?: string,
title: React.Node, title: React.Node,
subtitle?: React.Node, subtitle?: React.Node,
actions?: React.Node, actions?: React.Node,
border?: boolean, border?: boolean,
small?: boolean, small?: boolean,
}; |};
const ListItem = ({ const ListItem = (
image, { image, title, subtitle, actions, small, border, to, ...rest }: Props,
title, ref
subtitle, ) => {
actions, const theme = useTheme();
small,
border,
}: Props) => {
const compact = !subtitle; const compact = !subtitle;
return ( const content = (selected) => (
<Wrapper compact={compact} $border={border}> <>
{image && <Image>{image}</Image>} {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> <Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>} {subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
</Subtitle>
)}
</Content> </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> </Wrapper>
); );
}; };
const Wrapper = styled.li` const Wrapper = styled.div`
display: flex; display: flex;
user-select: none;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")}; padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)}; margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
border-bottom: 1px solid border-bottom: 1px solid
@ -57,28 +78,36 @@ const Image = styled(Flex)`
`; `;
const Heading = styled.p` const Heading = styled.p`
font-size: ${(props) => (props.$small ? 15 : 16)}px; font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
line-height: 1.2; line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0; margin: 0;
`; `;
const Content = styled(Flex)` const Content = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1; flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`; `;
const Subtitle = styled.p` const Subtitle = styled.p`
margin: 0; margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px; 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; margin-top: -2px;
`; `;
const Actions = styled.div` export const Actions = styled(Flex)`
align-self: center; 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 // @flow
import { format, formatDistanceToNow } from "date-fns"; import { format as formatDate, formatDistanceToNow } from "date-fns";
import { import {
enUS, enUS,
de, de,
@ -57,6 +57,9 @@ type Props = {
tooltipDelay?: number, tooltipDelay?: number,
addSuffix?: boolean, addSuffix?: boolean,
shorten?: boolean, shorten?: boolean,
relative?: boolean,
format?: string,
tooltip?: boolean,
}; };
function LocaleTime({ function LocaleTime({
@ -64,7 +67,10 @@ function LocaleTime({
children, children,
dateTime, dateTime,
shorten, shorten,
format,
relative,
tooltipDelay, tooltipDelay,
tooltip,
}: Props) { }: Props) {
const userLocale = useUserLocale(); const userLocale = useUserLocale();
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line no-unused-vars 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, addSuffix,
locale: userLocale ? locales[userLocale] : undefined, locale,
}); });
if (shorten) { if (shorten) {
content = content relativeContent = relativeContent
.replace("about", "") .replace("about", "")
.replace("less than a minute ago", "just now") .replace("less than a minute ago", "just now")
.replace("minute", "min"); .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 ( return (
<Tooltip <Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")} <time dateTime={dateTime}>{content}</time>
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
</Tooltip> </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; padding: 0;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
color: inherit;
`; `;
export default React.forwardRef<any, typeof Button>( 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 { observable, action } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Waypoint } from "react-waypoint"; import { Waypoint } from "react-waypoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore"; import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount"; import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder"; import PlaceholderList from "components/List/Placeholder";
import { dateToHeading } from "utils/dates";
type Props = { type Props = {
fetch?: (options: ?Object) => Promise<void>, fetch?: (options: ?Object) => Promise<void>,
@ -15,7 +17,9 @@ type Props = {
heading?: React.Node, heading?: React.Node,
empty?: React.Node, empty?: React.Node,
items: any[], items: any[],
renderItem: (any) => React.Node, renderItem: (any, index: number) => React.Node,
renderHeading?: (name: React.Element<any> | string) => React.Node,
t: TFunction,
}; };
@observer @observer
@ -101,8 +105,9 @@ class PaginatedList extends React.Component<Props> {
}; };
render() { render() {
const { items, heading, empty } = this.props; const { items, heading, empty, renderHeading } = this.props;
let previousHeading = "";
const showLoading = const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded; this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading; const showEmpty = !items.length && !showLoading;
@ -119,7 +124,37 @@ class PaginatedList extends React.Component<Props> {
mode={ArrowKeyNavigation.mode.VERTICAL} mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0} 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> </ArrowKeyNavigation>
{this.allowLoadMore && ( {this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} /> <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 * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore"; import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support"; import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList"; import { Component as PaginatedList } from "./PaginatedList";
describe("PaginatedList", () => { describe("PaginatedList", () => {
const render = () => null; const render = () => null;

View File

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

View File

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

View File

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

View File

@ -20,20 +20,6 @@ class Event extends BaseModel {
published: boolean, published: boolean,
templateId: string, 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; 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 CollectionsStore from "./CollectionsStore";
import DocumentPresenceStore from "./DocumentPresenceStore"; import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore"; import DocumentsStore from "./DocumentsStore";
import EventsStore from "./EventsStore";
import GroupMembershipsStore from "./GroupMembershipsStore"; import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupsStore from "./GroupsStore"; import GroupsStore from "./GroupsStore";
import IntegrationsStore from "./IntegrationsStore"; import IntegrationsStore from "./IntegrationsStore";
@ -24,6 +25,7 @@ export default class RootStore {
collections: CollectionsStore; collections: CollectionsStore;
collectionGroupMemberships: CollectionGroupMembershipsStore; collectionGroupMemberships: CollectionGroupMembershipsStore;
documents: DocumentsStore; documents: DocumentsStore;
events: EventsStore;
groups: GroupsStore; groups: GroupsStore;
groupMemberships: GroupMembershipsStore; groupMemberships: GroupMembershipsStore;
integrations: IntegrationsStore; integrations: IntegrationsStore;
@ -46,6 +48,7 @@ export default class RootStore {
this.collections = new CollectionsStore(this); this.collections = new CollectionsStore(this);
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this); this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
this.documents = new DocumentsStore(this); this.documents = new DocumentsStore(this);
this.events = new EventsStore(this);
this.groups = new GroupsStore(this); this.groups = new GroupsStore(this);
this.groupMemberships = new GroupMembershipsStore(this); this.groupMemberships = new GroupMembershipsStore(this);
this.integrations = new IntegrationsStore(this); this.integrations = new IntegrationsStore(this);
@ -66,6 +69,7 @@ export default class RootStore {
this.collections.clear(); this.collections.clear();
this.collectionGroupMemberships.clear(); this.collectionGroupMemberships.clear();
this.documents.clear(); this.documents.clear();
this.events.clear();
this.groups.clear(); this.groups.clear();
this.groupMemberships.clear(); this.groupMemberships.clear();
this.integrations.clear(); this.integrations.clear();

View File

@ -3,6 +3,9 @@
import localStorage from '../../__mocks__/localStorage'; import localStorage from '../../__mocks__/localStorage';
import Enzyme from "enzyme"; import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16"; import Adapter from "enzyme-adapter-react-16";
import { initI18n } from "shared/i18n";
initI18n();
Enzyme.configure({ adapter: new Adapter() }); 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", "Drafts": "Drafts",
"Templates": "Templates", "Templates": "Templates",
"Deleted Collection": "Deleted Collection", "Deleted Collection": "Deleted Collection",
"History": "History",
"Oh weird, there's nothing here": "Oh weird, there's nothing here",
"New": "New", "New": "New",
"Only visible to you": "Only visible to you", "Only visible to you": "Only visible to you",
"Draft": "Draft", "Draft": "Draft",
@ -110,6 +112,14 @@
"our engineers have been notified": "our engineers have been notified", "our engineers have been notified": "our engineers have been notified",
"Report a Bug": "Report a Bug", "Report a Bug": "Report a Bug",
"Show Detail": "Show Detail", "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", "Icon": "Icon",
"Show menu": "Show menu", "Show menu": "Show menu",
"Choose icon": "Choose icon", "Choose icon": "Choose icon",
@ -200,7 +210,6 @@
"Unpublish": "Unpublish", "Unpublish": "Unpublish",
"Permanently delete": "Permanently delete", "Permanently delete": "Permanently delete",
"Move": "Move", "Move": "Move",
"History": "History",
"Download": "Download", "Download": "Download",
"Print": "Print", "Print": "Print",
"Move {{ documentName }}": "Move {{ documentName }}", "Move {{ documentName }}": "Move {{ documentName }}",
@ -552,5 +561,11 @@
"Joined": "Joined", "Joined": "Joined",
"{{ time }} ago.": "{{ time }} ago.", "{{ time }} ago.": "{{ time }} ago.",
"Edit Profile": "Edit Profile", "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"
} }