diff --git a/app/components/Button.js b/app/components/Button.js index 6ba2b8b5..fc3b4f64 100644 --- a/app/components/Button.js +++ b/app/components/Button.js @@ -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; diff --git a/app/components/DocumentHistory.js b/app/components/DocumentHistory.js new file mode 100644 index 00000000..264e9d23 --- /dev/null +++ b/app/components/DocumentHistory.js @@ -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 ( + + {document ? ( + +
+ {t("History")} +
+ + {t("Oh weird, there's nothing here")}} + /> + +
+ ) : null} +
+ ); +} + +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); diff --git a/app/components/DocumentHistory/DocumentHistory.js b/app/components/DocumentHistory/DocumentHistory.js deleted file mode 100644 index 68c8576f..00000000 --- a/app/components/DocumentHistory/DocumentHistory.js +++ /dev/null @@ -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 { - @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 we’re 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 ; - - return ( - - -
- History -
- {showLoading ? ( - - - - ) : ( - - {this.revisions.map((revision, index) => ( - - ))} - - )} - {this.allowLoadMore && ( - - )} -
-
- ); - } -} - -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); diff --git a/app/components/DocumentHistory/components/Revision.js b/app/components/DocumentHistory/components/Revision.js deleted file mode 100644 index f1469f89..00000000 --- a/app/components/DocumentHistory/components/Revision.js +++ /dev/null @@ -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 { - render() { - const { revision, document, showMenu, selected, theme } = this.props; - - return ( - - - {" "} - {revision.createdBy.name} - - - - - {showMenu && ( - - )} - - ); - } -} - -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); diff --git a/app/components/DocumentHistory/index.js b/app/components/DocumentHistory/index.js deleted file mode 100644 index 7d566709..00000000 --- a/app/components/DocumentHistory/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import DocumentHistory from "./DocumentHistory"; -export default DocumentHistory; diff --git a/app/components/EventListItem.js b/app/components/EventListItem.js new file mode 100644 index 00000000..47f2d296 --- /dev/null +++ b/app/components/EventListItem.js @@ -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 = ; + meta = t("Latest version"); + to = documentHistoryUrl(document); + break; + } else { + icon = ; + meta = t("{{userName}} edited", opts); + to = documentHistoryUrl(document, event.modelId || ""); + break; + } + } + case "documents.archive": + icon = ; + meta = t("{{userName}} archived", opts); + break; + case "documents.unarchive": + meta = t("{{userName}} restored", opts); + break; + case "documents.delete": + icon = ; + meta = t("{{userName}} deleted", opts); + break; + case "documents.restore": + meta = t("{{userName}} moved from trash", opts); + break; + case "documents.publish": + icon = ; + meta = t("{{userName}} published", opts); + break; + case "documents.move": + icon = ; + meta = t("{{userName}} moved", opts); + break; + default: + console.warn("Unhandled event: ", event.name); + } + + if (!meta) { + return null; + } + + return ( + + } + image={} + subtitle={ + + {icon} + {meta} + + } + actions={ + isRevision ? ( + + ) : 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; diff --git a/app/components/List/Item.js b/app/components/List/Item.js index 29b300f8..0dfc5f0b 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -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 ( - + const content = (selected) => ( + <> {image && {image}} - + {title} - {subtitle && {subtitle}} + {subtitle && ( + + {subtitle} + + )} - {actions && {actions}} + {actions && {actions}} + + ); + + return ( + + {to ? content : content(false)} ); }; -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(ListItem); diff --git a/app/components/LocaleTime.js b/app/components/LocaleTime.js index 107a7c21..b01094f5 100644 --- a/app/components/LocaleTime.js +++ b/app/components/LocaleTime.js @@ -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 ( - - + + ); } diff --git a/app/components/NavLink.js b/app/components/NavLink.js new file mode 100644 index 00000000..12dbc8f9 --- /dev/null +++ b/app/components/NavLink.js @@ -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 ( + + {({ match }) => ( + + {children ? children(match) : null} + + )} + + ); +} diff --git a/app/components/NudeButton.js b/app/components/NudeButton.js index 5e0027b2..193f56a8 100644 --- a/app/components/NudeButton.js +++ b/app/components/NudeButton.js @@ -12,6 +12,7 @@ const Button = styled.button` padding: 0; cursor: pointer; user-select: none; + color: inherit; `; export default React.forwardRef( diff --git a/app/components/PaginatedEventList.js b/app/components/PaginatedEventList.js new file mode 100644 index 00000000..5414c49c --- /dev/null +++ b/app/components/PaginatedEventList.js @@ -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, + options?: Object, + heading?: React.Node, + empty?: React.Node, +|}; + +const PaginatedEventList = React.memo(function PaginatedEventList({ + empty, + heading, + events, + fetch, + options, + document, + ...rest +}: Props) { + return ( + ( + + )} + renderHeading={(name) => {name}} + /> + ); +}); + +const Heading = styled("h3")` + font-size: 14px; + padding: 0 12px; +`; + +export default PaginatedEventList; diff --git a/app/components/PaginatedList.js b/app/components/PaginatedList.js index 7deeb9f5..ca1d7061 100644 --- a/app/components/PaginatedList.js +++ b/app/components/PaginatedList.js @@ -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, @@ -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 | string) => React.Node, + t: TFunction, }; @observer @@ -101,8 +105,9 @@ class PaginatedList extends React.Component { }; 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 { 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 ( + + {renderHeading(currentHeading)} + {children} + + ); + } + + return children; + })} {this.allowLoadMore && ( @@ -136,4 +171,6 @@ class PaginatedList extends React.Component { } } -export default PaginatedList; +export const Component = PaginatedList; + +export default withTranslation()(PaginatedList); diff --git a/app/components/PaginatedList.test.js b/app/components/PaginatedList.test.js index 506c9c54..438f65ac 100644 --- a/app/components/PaginatedList.test.js +++ b/app/components/PaginatedList.test.js @@ -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; diff --git a/app/components/Tab.js b/app/components/Tab.js index feb1c0df..4ad6a4cd 100644 --- a/app/components/Tab.js +++ b/app/components/Tab.js @@ -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 }) => ( - - {({ match }) => ( - - {children(match)} - - )} - -); - 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) { ); } - -export default withTheme(Tab); diff --git a/app/components/Time.js b/app/components/Time.js index 8569de78..78dd550a 100644 --- a/app/components/Time.js +++ b/app/components/Time.js @@ -11,6 +11,7 @@ type Props = { children?: React.Node, tooltipDelay?: number, addSuffix?: boolean, + format?: string, shorten?: boolean, }; diff --git a/app/menus/RevisionMenu.js b/app/menus/RevisionMenu.js index 000f8099..1da8e43f 100644 --- a/app/menus/RevisionMenu.js +++ b/app/menus/RevisionMenu.js @@ -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 ( <> diff --git a/app/models/Event.js b/app/models/Event.js index d9e69b0d..f3d5e997 100644 --- a/app/models/Event.js +++ b/app/models/Event.js @@ -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; diff --git a/app/stores/EventsStore.js b/app/stores/EventsStore.js new file mode 100644 index 00000000..4f0dcbec --- /dev/null +++ b/app/stores/EventsStore.js @@ -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 { + 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); + } +} diff --git a/app/stores/RootStore.js b/app/stores/RootStore.js index 48718686..13ebf28c 100644 --- a/app/stores/RootStore.js +++ b/app/stores/RootStore.js @@ -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(); diff --git a/app/test/setup.js b/app/test/setup.js index bfed90c9..23ce7ef7 100644 --- a/app/test/setup.js +++ b/app/test/setup.js @@ -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() }); diff --git a/app/utils/dates.js b/app/utils/dates.js new file mode 100644 index 00000000..a902f475 --- /dev/null +++ b/app/utils/dates.js @@ -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 ; + } + + 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 ; +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 9a349482..2012361a 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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 }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet." + "{{ userName }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet.", + "Today": "Today", + "Yesterday": "Yesterday", + "Last week": "Last week", + "This month": "This month", + "Last month": "Last month", + "This year": "This year" }