From b2a1e6b30970fc354a81973376fe76c361c09538 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 7 Nov 2021 08:58:44 -0800 Subject: [PATCH] feat: Collaborative revision restore (#2721) --- app/components/EventListItem.js | 3 +- app/menus/RevisionMenu.js | 22 +++++++------ app/scenes/Document/components/DataLoader.js | 7 +++-- app/scenes/Document/components/Document.js | 31 +++++++++++++++++-- .../Document/components/MultiplayerEditor.js | 9 +++++- package.json | 2 +- 6 files changed, 57 insertions(+), 17 deletions(-) diff --git a/app/components/EventListItem.js b/app/components/EventListItem.js index 47f2d2966..8da20ba94 100644 --- a/app/components/EventListItem.js +++ b/app/components/EventListItem.js @@ -137,7 +137,8 @@ const ListItem = styled(Item)` &:nth-child(2)::before { height: 50%; - top: 50%; + top: auto; + bottom: -4px; } &:last-child::before { diff --git a/app/menus/RevisionMenu.js b/app/menus/RevisionMenu.js index ea4c47f44..f48b76380 100644 --- a/app/menus/RevisionMenu.js +++ b/app/menus/RevisionMenu.js @@ -32,11 +32,19 @@ function RevisionMenu({ document, revisionId, className }: Props) { const handleRestore = React.useCallback( async (ev: SyntheticEvent<>) => { ev.preventDefault(); - await document.restore({ revisionId }); - showToast(t("Document restored"), { type: "success" }); - history.push(document.url); + + 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, document, revisionId] + [history, showToast, t, team.collaborativeEditing, document, revisionId] ); const handleCopy = React.useCallback(() => { @@ -57,11 +65,7 @@ function RevisionMenu({ document, revisionId, className }: Props) { {...menu} /> - + diff --git a/app/scenes/Document/components/DataLoader.js b/app/scenes/Document/components/DataLoader.js index 522c3661c..d84c06d8a 100644 --- a/app/scenes/Document/components/DataLoader.js +++ b/app/scenes/Document/components/DataLoader.js @@ -24,6 +24,7 @@ import { type LocationWithState, type NavigationNode } from "types"; import { NotFoundError, OfflineError } from "utils/errors"; import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers"; import { isInternalUrl } from "utils/urls"; + type Props = {| match: Match, auth: AuthStore, @@ -45,6 +46,7 @@ class DataLoader extends React.Component { sharedTree: ?NavigationNode; @observable document: ?Document; @observable revision: ?Revision; + @observable shapshot: ?Blob; @observable error: ?Error; componentDidMount() { @@ -223,7 +225,8 @@ class DataLoader extends React.Component { }; render() { - const { location, policies, auth, ui } = this.props; + const { location, policies, auth, match, ui } = this.props; + const { revisionId } = match.params; if (this.error) { return this.error instanceof OfflineError ? ( @@ -237,7 +240,7 @@ class DataLoader extends React.Component { const document = this.document; const revision = this.revision; - if (!document || !team) { + if (!document || !team || (revisionId && !revision)) { return ( <> diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index 5f0f348ad..28177eb03 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -37,6 +37,7 @@ import MarkAsViewed from "./MarkAsViewed"; import PublicReferences from "./PublicReferences"; import References from "./References"; import { type LocationWithState, type NavigationNode, type Theme } from "types"; +import { client } from "utils/ApiClient"; import { isCustomDomain } from "utils/domains"; import { emojiToUrl } from "utils/emoji"; import { isModKey } from "utils/keyboard"; @@ -125,7 +126,7 @@ class DocumentScene extends React.Component { } } - onSelectTemplate = (template: Document) => { + replaceDocument = (template: Document | Revision) => { this.title = template.title; this.isDirty = true; @@ -141,13 +142,36 @@ class DocumentScene extends React.Component { .replaceSelectionWith(parser.parse(template.text)) ); - this.props.document.templateId = template.id; + if (template instanceof Document) { + this.props.document.templateId = template.id; + } this.props.document.title = template.title; this.props.document.text = template.text; this.updateIsDirty(); }; + onSynced = async () => { + const { toasts, history, location, t } = this.props; + const restore = location.state?.restore; + const revisionId = location.state?.revisionId; + + const editorRef = this.editor.current; + if (!editorRef || !restore) { + return; + } + + const response = await client.post("/revisions.info", { + id: revisionId, + }); + + if (response) { + this.replaceDocument(response.data); + toasts.showToast(t("Document restored")); + history.replace(this.props.document.url); + } + }; + goToMove = (ev) => { if (!this.props.readOnly) return; @@ -457,7 +481,7 @@ class DocumentScene extends React.Component { savingIsDisabled={document.isSaving || this.isEmpty} sharedTree={this.props.sharedTree} goBack={this.goBack} - onSelectTemplate={this.onSelectTemplate} + onSelectTemplate={this.replaceDocument} onSave={this.onSave} headings={headings} /> @@ -530,6 +554,7 @@ class DocumentScene extends React.Component { value={readOnly ? value : undefined} defaultValue={value} disableEmbeds={disableEmbeds} + onSynced={this.onSynced} onImageUploadStart={this.onImageUploadStart} onImageUploadStop={this.onImageUploadStop} onSearchLink={this.props.onSearchLink} diff --git a/app/scenes/Document/components/MultiplayerEditor.js b/app/scenes/Document/components/MultiplayerEditor.js index 1581264cd..6ddead023 100644 --- a/app/scenes/Document/components/MultiplayerEditor.js +++ b/app/scenes/Document/components/MultiplayerEditor.js @@ -20,9 +20,10 @@ import { homePath } from "utils/routeHelpers"; type Props = {| ...EditorProps, id: string, + onSynced?: () => void, |}; -function MultiplayerEditor({ ...props }: Props, ref: any) { +function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { const documentId = props.id; const history = useHistory(); const { t } = useTranslation(); @@ -154,6 +155,12 @@ function MultiplayerEditor({ ...props }: Props, ref: any) { ]; }, [remoteProvider, user, ydoc]); + React.useEffect(() => { + if (isLocalSynced && isRemoteSynced) { + onSynced?.(); + } + }, [onSynced, isLocalSynced, isRemoteSynced]); + // Disconnect the realtime connection while idle. `isIdle` also checks for // page visibility and will immediately disconnect when a tab is hidden. React.useEffect(() => { diff --git a/package.json b/package.json index 395578078..668bb4526 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:webpack": "webpack --config webpack.config.prod.js", "build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server", "start": "node ./build/server/index.js", - "dev": "yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=collaboration,websockets,admin,web,worker\"", + "dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=collaboration,websockets,admin,web,worker\"", "dev:watch": "nodemon --exec \"yarn build:server && yarn build:i18n && yarn dev\" -e js --ignore build/ --ignore app/ --ignore flow-typed/", "lint": "eslint app server shared", "deploy": "git push heroku master",