diff --git a/app/actions/definitions/revisions.tsx b/app/actions/definitions/revisions.tsx new file mode 100644 index 000000000..ed41ffea7 --- /dev/null +++ b/app/actions/definitions/revisions.tsx @@ -0,0 +1,85 @@ +import copy from "copy-to-clipboard"; +import { LinkIcon, RestoreIcon } from "outline-icons"; +import * as React from "react"; +import { matchPath } from "react-router-dom"; +import stores from "~/stores"; +import { createAction } from "~/actions"; +import { RevisionSection } from "~/actions/sections"; +import history from "~/utils/history"; +import { documentHistoryUrl, matchDocumentHistory } from "~/utils/routeHelpers"; + +export const restoreRevision = createAction({ + name: ({ t }) => t("Restore revision"), + icon: , + section: RevisionSection, + visible: ({ activeDocumentId, stores }) => + !!activeDocumentId && stores.policies.abilities(activeDocumentId).update, + perform: async ({ t, event, location, activeDocumentId }) => { + event?.preventDefault(); + if (!activeDocumentId) { + return; + } + + const match = matchPath<{ revisionId: string }>(location.pathname, { + path: matchDocumentHistory, + }); + const revisionId = match?.params.revisionId; + + const { team } = stores.auth; + const document = stores.documents.get(activeDocumentId); + if (!document) { + return; + } + + if (team?.collaborativeEditing) { + history.push(document.url, { + restore: true, + revisionId, + }); + } else { + await document.restore({ + revisionId, + }); + stores.toasts.showToast(t("Document restored"), { + type: "success", + }); + history.push(document.url); + } + }, +}); + +export const copyLinkToRevision = createAction({ + name: ({ t }) => t("Copy link"), + icon: , + section: RevisionSection, + perform: async ({ activeDocumentId, stores, t }) => { + if (!activeDocumentId) { + return; + } + + const match = matchPath<{ revisionId: string }>(location.pathname, { + path: matchDocumentHistory, + }); + const revisionId = match?.params.revisionId; + const document = stores.documents.get(activeDocumentId); + if (!document) { + return; + } + + const url = `${window.location.origin}${documentHistoryUrl( + document, + revisionId + )}`; + + copy(url, { + format: "text/plain", + onCopy: () => { + stores.toasts.showToast(t("Link copied"), { + type: "info", + }); + }, + }); + }, +}); + +export const rootRevisionActions = []; diff --git a/app/actions/root.ts b/app/actions/root.ts index db4ee3746..ec04d162a 100644 --- a/app/actions/root.ts +++ b/app/actions/root.ts @@ -2,6 +2,7 @@ import { rootCollectionActions } from "./definitions/collections"; import { rootDeveloperActions } from "./definitions/developer"; import { rootDocumentActions } from "./definitions/documents"; import { rootNavigationActions } from "./definitions/navigation"; +import { rootRevisionActions } from "./definitions/revisions"; import { rootSettingsActions } from "./definitions/settings"; import { rootTeamActions } from "./definitions/teams"; import { rootUserActions } from "./definitions/users"; @@ -11,6 +12,7 @@ export default [ ...rootDocumentActions, ...rootUserActions, ...rootNavigationActions, + ...rootRevisionActions, ...rootSettingsActions, ...rootDeveloperActions, ...rootTeamActions, diff --git a/app/actions/sections.ts b/app/actions/sections.ts index 00a69c4d5..fb1dee52a 100644 --- a/app/actions/sections.ts +++ b/app/actions/sections.ts @@ -6,6 +6,8 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug"); export const DocumentSection = ({ t }: ActionContext) => t("Document"); +export const RevisionSection = ({ t }: ActionContext) => t("Revision"); + export const SettingsSection = ({ t }: ActionContext) => t("Settings"); export const NavigationSection = ({ t }: ActionContext) => t("Navigation"); diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index a591804bf..de1be87a7 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -1,23 +1,23 @@ -import { observable } from "mobx"; +import { AnimatePresence } from "framer-motion"; import { observer } from "mobx-react"; import * as React from "react"; -import { withTranslation, WithTranslation } from "react-i18next"; -import { Switch, Route } from "react-router-dom"; -import RootStore from "~/stores/RootStore"; +import { Switch, Route, useLocation, matchPath } from "react-router-dom"; import ErrorSuspended from "~/scenes/ErrorSuspended"; import Layout from "~/components/Layout"; import RegisterKeyDown from "~/components/RegisterKeyDown"; import Sidebar from "~/components/Sidebar"; import SettingsSidebar from "~/components/Sidebar/Settings"; +import usePolicy from "~/hooks/usePolicy"; +import useStores from "~/hooks/useStores"; import history from "~/utils/history"; import { searchPath, matchDocumentSlug as slug, newDocumentPath, settingsPath, + matchDocumentHistory, } from "~/utils/routeHelpers"; import Fade from "./Fade"; -import withStores from "./withStores"; const DocumentHistory = React.lazy( () => @@ -34,16 +34,13 @@ const CommandBar = React.lazy( ) ); -type Props = WithTranslation & RootStore; +const AuthenticatedLayout: React.FC = ({ children }) => { + const { ui, auth } = useStores(); + const location = useLocation(); + const can = usePolicy(ui.activeCollectionId); + const { user, team } = auth; -@observer -class AuthenticatedLayout extends React.Component { - scrollable: HTMLDivElement | null | undefined; - - @observable - keyboardShortcutsOpen = false; - - goToSearch = (ev: KeyboardEvent) => { + const goToSearch = (ev: KeyboardEvent) => { if (!ev.metaKey && !ev.ctrlKey) { ev.preventDefault(); ev.stopPropagation(); @@ -51,60 +48,64 @@ class AuthenticatedLayout extends React.Component { } }; - goToNewDocument = (event: KeyboardEvent) => { + const goToNewDocument = (event: KeyboardEvent) => { if (event.metaKey || event.altKey) { return; } - - const { activeCollectionId } = this.props.ui; - if (!activeCollectionId) { - return; - } - const can = this.props.policies.abilities(activeCollectionId); - if (!can.update) { + const { activeCollectionId } = ui; + if (!activeCollectionId || !can.update) { return; } history.push(newDocumentPath(activeCollectionId)); }; - render() { - const { auth } = this.props; - const { user, team } = auth; - const showSidebar = auth.authenticated && user && team; - if (auth.isSuspended) { - return ; - } + if (auth.isSuspended) { + return ; + } - const sidebar = showSidebar ? ( - - - - - - - ) : undefined; + const showSidebar = auth.authenticated && user && team; - const rightRail = ( - - + const sidebar = showSidebar ? ( + + + + + + + ) : undefined; + + const sidebarRight = ( + + + - - ); + + + ); - return ( - - - - - {this.props.children} - - - ); - } -} + return ( + + + + + {children} + + + ); +}; -export default withTranslation()(withStores(AuthenticatedLayout)); +export default observer(AuthenticatedLayout); diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 556d31b63..02515f101 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -3,14 +3,19 @@ import { ExpandedIcon } from "outline-icons"; import { darken, lighten } from "polished"; import * as React from "react"; import styled from "styled-components"; +import ActionButton, { + Props as ActionButtonProps, +} from "~/components/ActionButton"; -const RealButton = styled.button<{ +type RealProps = { fullwidth?: boolean; borderOnHover?: boolean; $neutral?: boolean; danger?: boolean; iconColor?: string; -}>` +}; + +const RealButton = styled(ActionButton)` display: ${(props) => (props.fullwidth ? "block" : "inline-block")}; width: ${(props) => (props.fullwidth ? "100%" : "auto")}; margin: 0; @@ -146,7 +151,7 @@ export const Inner = styled.span<{ ${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"}; `; -export type Props = { +export type Props = ActionButtonProps & { icon?: React.ReactNode; iconColor?: string; children?: React.ReactNode; @@ -168,12 +173,19 @@ const Button = ( props: Props & React.ComponentPropsWithoutRef, ref: React.Ref ) => { - const { type, icon, children, value, disclosure, neutral, ...rest } = props; + const { type, children, value, disclosure, neutral, action, ...rest } = props; const hasText = children !== undefined || value !== undefined; + const icon = action?.icon ?? rest.icon; const hasIcon = icon !== undefined; return ( - + {hasIcon && icon} {hasText && } diff --git a/app/components/DocumentHistory.tsx b/app/components/DocumentHistory.tsx index 2323d2b77..163774be4 100644 --- a/app/components/DocumentHistory.tsx +++ b/app/components/DocumentHistory.tsx @@ -1,9 +1,10 @@ +import { m } from "framer-motion"; 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 styled, { useTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import Event from "~/models/Event"; import Button from "~/components/Button"; @@ -21,6 +22,7 @@ function DocumentHistory() { const { t } = useTranslation(); const match = useRouteMatch<{ documentSlug: string }>(); const history = useHistory(); + const theme = useTheme(); const document = documents.getByUrl(match.params.documentSlug); const eventsInDocument = document @@ -44,7 +46,8 @@ function DocumentHistory() { eventsInDocument.unshift( new Event( { - name: "documents.latest_version", + id: "live", + name: "documents.live_editing", documentId: document.id, createdAt: document.updatedAt, actor: document.updatedBy, @@ -58,7 +61,22 @@ function DocumentHistory() { }, [eventsInDocument, events, document]); return ( - + {document ? (
@@ -95,7 +113,7 @@ const Position = styled(Flex)` width: ${(props) => props.theme.sidebarWidth}px; `; -const Sidebar = styled(Flex)` +const Sidebar = styled(m.div)` display: none; position: relative; flex-shrink: 0; @@ -125,7 +143,7 @@ const Title = styled(Flex)` const Header = styled(Flex)` align-items: center; position: relative; - padding: 12px; + padding: 16px 12px; color: ${(props) => props.theme.text}; flex-shrink: 0; `; diff --git a/app/components/DocumentMeta.tsx b/app/components/DocumentMeta.tsx index c3fcba22f..9216d2b6c 100644 --- a/app/components/DocumentMeta.tsx +++ b/app/components/DocumentMeta.tsx @@ -12,24 +12,6 @@ import Time from "~/components/Time"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; -const Container = styled(Flex)<{ rtl?: boolean }>` - justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")}; - color: ${(props) => props.theme.textTertiary}; - font-size: 13px; - white-space: nowrap; - overflow: hidden; - min-width: 0; -`; - -const Viewed = styled.span` - text-overflow: ellipsis; - overflow: hidden; -`; - -const Modified = styled.span<{ highlight?: boolean }>` - font-weight: ${(props) => (props.highlight ? "600" : "400")}; -`; - type Props = { showCollection?: boolean; showPublished?: boolean; @@ -192,4 +174,22 @@ const DocumentMeta: React.FC = ({ ); }; +const Container = styled(Flex)<{ rtl?: boolean }>` + justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")}; + color: ${(props) => props.theme.textTertiary}; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + min-width: 0; +`; + +const Viewed = styled.span` + text-overflow: ellipsis; + overflow: hidden; +`; + +const Modified = styled.span<{ highlight?: boolean }>` + font-weight: ${(props) => (props.highlight ? "600" : "400")}; +`; + export default observer(DocumentMeta); diff --git a/app/components/DocumentMetaWithViews.tsx b/app/components/DocumentMetaWithViews.tsx index 79c820f64..8c23bd334 100644 --- a/app/components/DocumentMetaWithViews.tsx +++ b/app/components/DocumentMetaWithViews.tsx @@ -24,14 +24,6 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) { const totalViewers = documentViews.length; const onlyYou = totalViewers === 1 && documentViews[0].user.id; - React.useEffect(() => { - if (!document.isDeleted) { - views.fetchPage({ - documentId: document.id, - }); - } - }, [views, document.id, document.isDeleted]); - const popover = usePopoverState({ gutter: 8, placement: "bottom", diff --git a/app/components/EventListItem.tsx b/app/components/EventListItem.tsx index 0627439c7..8a540c597 100644 --- a/app/components/EventListItem.tsx +++ b/app/components/EventListItem.tsx @@ -1,11 +1,12 @@ +import { observer } from "mobx-react"; import { TrashIcon, ArchiveIcon, EditIcon, PublishIcon, MoveIcon, - CheckboxIcon, UnpublishIcon, + LightningIcon, } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; @@ -20,7 +21,7 @@ import CompositeItem, { } from "~/components/List/CompositeItem"; import Item, { Actions } from "~/components/List/Item"; import Time from "~/components/Time"; -import usePolicy from "~/hooks/usePolicy"; +import useStores from "~/hooks/useStores"; import RevisionMenu from "~/menus/RevisionMenu"; import { documentHistoryUrl } from "~/utils/routeHelpers"; @@ -32,8 +33,8 @@ type Props = { const EventListItem = ({ event, latest, document, ...rest }: Props) => { const { t } = useTranslation(); + const { revisions } = useStores(); const location = useLocation(); - const can = usePolicy(document); const opts = { userName: event.actor.name, }; @@ -43,25 +44,28 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => { const ref = React.useRef(null); // the time component tends to steal focus when clicked // ...so forward the focus back to the parent item - const handleTimeClick = React.useCallback(() => { + const handleTimeClick = () => { ref.current?.focus(); - }, [ref]); + }; + + const prefetchRevision = () => { + if (event.name === "revisions.create" && event.modelId) { + revisions.fetch(event.modelId); + } + }; 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; - } - } + icon = ; + meta = t("{{userName}} edited", opts); + to = documentHistoryUrl(document, event.modelId || ""); + break; + + case "documents.live_editing": + icon = ; + meta = t("Live editing"); + to = documentHistoryUrl(document); + break; case "documents.archive": icon = ; @@ -136,10 +140,11 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => { } actions={ - isRevision && isActive && event.modelId && can.update ? ( + isRevision && isActive && event.modelId ? ( ) : undefined } + onMouseEnter={prefetchRevision} ref={ref} {...rest} /> @@ -217,4 +222,4 @@ const CompositeListItem = styled(CompositeItem)` ${ItemStyle} `; -export default EventListItem; +export default observer(EventListItem); diff --git a/app/components/Layout.tsx b/app/components/Layout.tsx index ae85ceddb..f93c6fc01 100644 --- a/app/components/Layout.tsx +++ b/app/components/Layout.tsx @@ -15,10 +15,15 @@ import { isModKey } from "~/utils/keyboard"; type Props = { title?: string; sidebar?: React.ReactNode; - rightRail?: React.ReactNode; + sidebarRight?: React.ReactNode; }; -const Layout: React.FC = ({ title, children, sidebar, rightRail }) => { +const Layout: React.FC = ({ + title, + children, + sidebar, + sidebarRight, +}) => { const { ui } = useStores(); const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed; @@ -60,7 +65,7 @@ const Layout: React.FC = ({ title, children, sidebar, rightRail }) => { {children} - {rightRail} + {sidebarRight} ); diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx index 6a5973585..708e7d016 100644 --- a/app/components/List/Item.tsx +++ b/app/components/List/Item.tsx @@ -3,7 +3,7 @@ import styled, { useTheme } from "styled-components"; import Flex from "~/components/Flex"; import NavLink from "~/components/NavLink"; -export type Props = { +export type Props = Omit, "title"> & { image?: React.ReactNode; to?: string; exact?: boolean; diff --git a/app/components/NudeButton.tsx b/app/components/NudeButton.tsx index 8412c2bf5..9459c0b4f 100644 --- a/app/components/NudeButton.tsx +++ b/app/components/NudeButton.tsx @@ -10,7 +10,7 @@ type Props = ActionButtonProps & { type?: "button" | "submit" | "reset"; }; -const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({ +const NudeButton = styled(ActionButton).attrs((props: Props) => ({ type: "type" in props ? props.type : "button", }))` width: ${(props) => props.width || props.size || 24}px; @@ -26,4 +26,4 @@ const StyledNudeButton = styled(ActionButton).attrs((props: Props) => ({ color: inherit; `; -export default StyledNudeButton; +export default NudeButton; diff --git a/app/components/PaginatedEventList.tsx b/app/components/PaginatedEventList.tsx index de31df948..0ec42ca48 100644 --- a/app/components/PaginatedEventList.tsx +++ b/app/components/PaginatedEventList.tsx @@ -29,17 +29,15 @@ const PaginatedEventList = React.memo(function PaginatedEventList({ heading={heading} fetch={fetch} options={options} - renderItem={(item: Event, index, compositeProps) => { - return ( - - ); - }} + renderItem={(item: Event, index, compositeProps) => ( + + )} renderHeading={(name) => {name}} {...rest} /> diff --git a/app/menus/RevisionMenu.tsx b/app/menus/RevisionMenu.tsx index b499c41fb..e7f7903f4 100644 --- a/app/menus/RevisionMenu.tsx +++ b/app/menus/RevisionMenu.tsx @@ -1,19 +1,18 @@ import { observer } from "mobx-react"; -import { RestoreIcon, LinkIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useHistory } from "react-router-dom"; import { useMenuState } from "reakit/Menu"; import Document from "~/models/Document"; import ContextMenu from "~/components/ContextMenu"; -import MenuItem from "~/components/ContextMenu/MenuItem"; import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; -import Separator from "~/components/ContextMenu/Separator"; -import CopyToClipboard from "~/components/CopyToClipboard"; -import MenuIconWrapper from "~/components/MenuIconWrapper"; -import useCurrentTeam from "~/hooks/useCurrentTeam"; -import useToasts from "~/hooks/useToasts"; -import { documentHistoryUrl } from "~/utils/routeHelpers"; +import Template from "~/components/ContextMenu/Template"; +import { actionToMenuItem } from "~/actions"; +import { + copyLinkToRevision, + restoreRevision, +} from "~/actions/definitions/revisions"; +import useActionContext from "~/hooks/useActionContext"; +import separator from "./separator"; type Props = { document: Document; @@ -21,47 +20,14 @@ type Props = { className?: string; }; -function RevisionMenu({ document, revisionId, className }: Props) { - const { showToast } = useToasts(); - const team = useCurrentTeam(); +function RevisionMenu({ document, className }: Props) { const menu = useMenuState({ modal: true, }); const { t } = useTranslation(); - const history = useHistory(); - - const handleRestore = React.useCallback( - async (ev: React.SyntheticEvent) => { - ev.preventDefault(); - - if (team.collaborativeEditing) { - history.push(document.url, { - restore: true, - revisionId, - }); - } else { - await document.restore({ - revisionId, - }); - showToast(t("Document restored"), { - type: "success", - }); - history.push(document.url); - } - }, - [history, showToast, t, team.collaborativeEditing, document, revisionId] - ); - - const handleCopy = React.useCallback(() => { - showToast(t("Link copied"), { - type: "info", - }); - }, [showToast, t]); - - const url = `${window.location.origin}${documentHistoryUrl( - document, - revisionId - )}`; + const context = useActionContext({ + activeDocumentId: document.id, + }); return ( <> @@ -72,21 +38,13 @@ function RevisionMenu({ document, revisionId, className }: Props) { {...menu} /> - - - - - {t("Restore version")} - - - - - - - - {t("Copy link")} - - +