feat: Collaborative revision restore (#2721)
This commit is contained in:
parent
5dd5df6268
commit
b2a1e6b309
|
@ -137,7 +137,8 @@ const ListItem = styled(Item)`
|
||||||
|
|
||||||
&:nth-child(2)::before {
|
&:nth-child(2)::before {
|
||||||
height: 50%;
|
height: 50%;
|
||||||
top: 50%;
|
top: auto;
|
||||||
|
bottom: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child::before {
|
&:last-child::before {
|
||||||
|
|
|
@ -32,11 +32,19 @@ function RevisionMenu({ document, revisionId, className }: Props) {
|
||||||
const handleRestore = React.useCallback(
|
const handleRestore = React.useCallback(
|
||||||
async (ev: SyntheticEvent<>) => {
|
async (ev: SyntheticEvent<>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
await document.restore({ revisionId });
|
|
||||||
showToast(t("Document restored"), { type: "success" });
|
if (team.collaborativeEditing) {
|
||||||
history.push(document.url);
|
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(() => {
|
const handleCopy = React.useCallback(() => {
|
||||||
|
@ -57,11 +65,7 @@ function RevisionMenu({ document, revisionId, className }: Props) {
|
||||||
{...menu}
|
{...menu}
|
||||||
/>
|
/>
|
||||||
<ContextMenu {...menu} aria-label={t("Revision options")}>
|
<ContextMenu {...menu} aria-label={t("Revision options")}>
|
||||||
<MenuItem
|
<MenuItem {...menu} onClick={handleRestore}>
|
||||||
{...menu}
|
|
||||||
onClick={handleRestore}
|
|
||||||
disabled={team.collaborativeEditing}
|
|
||||||
>
|
|
||||||
<MenuIconWrapper>
|
<MenuIconWrapper>
|
||||||
<RestoreIcon />
|
<RestoreIcon />
|
||||||
</MenuIconWrapper>
|
</MenuIconWrapper>
|
||||||
|
|
|
@ -24,6 +24,7 @@ import { type LocationWithState, type NavigationNode } from "types";
|
||||||
import { NotFoundError, OfflineError } from "utils/errors";
|
import { NotFoundError, OfflineError } from "utils/errors";
|
||||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||||
import { isInternalUrl } from "utils/urls";
|
import { isInternalUrl } from "utils/urls";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
match: Match,
|
match: Match,
|
||||||
auth: AuthStore,
|
auth: AuthStore,
|
||||||
|
@ -45,6 +46,7 @@ class DataLoader extends React.Component<Props> {
|
||||||
sharedTree: ?NavigationNode;
|
sharedTree: ?NavigationNode;
|
||||||
@observable document: ?Document;
|
@observable document: ?Document;
|
||||||
@observable revision: ?Revision;
|
@observable revision: ?Revision;
|
||||||
|
@observable shapshot: ?Blob;
|
||||||
@observable error: ?Error;
|
@observable error: ?Error;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
@ -223,7 +225,8 @@ class DataLoader extends React.Component<Props> {
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { location, policies, auth, ui } = this.props;
|
const { location, policies, auth, match, ui } = this.props;
|
||||||
|
const { revisionId } = match.params;
|
||||||
|
|
||||||
if (this.error) {
|
if (this.error) {
|
||||||
return this.error instanceof OfflineError ? (
|
return this.error instanceof OfflineError ? (
|
||||||
|
@ -237,7 +240,7 @@ class DataLoader extends React.Component<Props> {
|
||||||
const document = this.document;
|
const document = this.document;
|
||||||
const revision = this.revision;
|
const revision = this.revision;
|
||||||
|
|
||||||
if (!document || !team) {
|
if (!document || !team || (revisionId && !revision)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Loading location={location} />
|
<Loading location={location} />
|
||||||
|
|
|
@ -37,6 +37,7 @@ import MarkAsViewed from "./MarkAsViewed";
|
||||||
import PublicReferences from "./PublicReferences";
|
import PublicReferences from "./PublicReferences";
|
||||||
import References from "./References";
|
import References from "./References";
|
||||||
import { type LocationWithState, type NavigationNode, type Theme } from "types";
|
import { type LocationWithState, type NavigationNode, type Theme } from "types";
|
||||||
|
import { client } from "utils/ApiClient";
|
||||||
import { isCustomDomain } from "utils/domains";
|
import { isCustomDomain } from "utils/domains";
|
||||||
import { emojiToUrl } from "utils/emoji";
|
import { emojiToUrl } from "utils/emoji";
|
||||||
import { isModKey } from "utils/keyboard";
|
import { isModKey } from "utils/keyboard";
|
||||||
|
@ -125,7 +126,7 @@ class DocumentScene extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectTemplate = (template: Document) => {
|
replaceDocument = (template: Document | Revision) => {
|
||||||
this.title = template.title;
|
this.title = template.title;
|
||||||
this.isDirty = true;
|
this.isDirty = true;
|
||||||
|
|
||||||
|
@ -141,13 +142,36 @@ class DocumentScene extends React.Component<Props> {
|
||||||
.replaceSelectionWith(parser.parse(template.text))
|
.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.title = template.title;
|
||||||
this.props.document.text = template.text;
|
this.props.document.text = template.text;
|
||||||
|
|
||||||
this.updateIsDirty();
|
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) => {
|
goToMove = (ev) => {
|
||||||
if (!this.props.readOnly) return;
|
if (!this.props.readOnly) return;
|
||||||
|
|
||||||
|
@ -457,7 +481,7 @@ class DocumentScene extends React.Component<Props> {
|
||||||
savingIsDisabled={document.isSaving || this.isEmpty}
|
savingIsDisabled={document.isSaving || this.isEmpty}
|
||||||
sharedTree={this.props.sharedTree}
|
sharedTree={this.props.sharedTree}
|
||||||
goBack={this.goBack}
|
goBack={this.goBack}
|
||||||
onSelectTemplate={this.onSelectTemplate}
|
onSelectTemplate={this.replaceDocument}
|
||||||
onSave={this.onSave}
|
onSave={this.onSave}
|
||||||
headings={headings}
|
headings={headings}
|
||||||
/>
|
/>
|
||||||
|
@ -530,6 +554,7 @@ class DocumentScene extends React.Component<Props> {
|
||||||
value={readOnly ? value : undefined}
|
value={readOnly ? value : undefined}
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
disableEmbeds={disableEmbeds}
|
disableEmbeds={disableEmbeds}
|
||||||
|
onSynced={this.onSynced}
|
||||||
onImageUploadStart={this.onImageUploadStart}
|
onImageUploadStart={this.onImageUploadStart}
|
||||||
onImageUploadStop={this.onImageUploadStop}
|
onImageUploadStop={this.onImageUploadStop}
|
||||||
onSearchLink={this.props.onSearchLink}
|
onSearchLink={this.props.onSearchLink}
|
||||||
|
|
|
@ -20,9 +20,10 @@ import { homePath } from "utils/routeHelpers";
|
||||||
type Props = {|
|
type Props = {|
|
||||||
...EditorProps,
|
...EditorProps,
|
||||||
id: string,
|
id: string,
|
||||||
|
onSynced?: () => void,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
function MultiplayerEditor({ ...props }: Props, ref: any) {
|
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||||
const documentId = props.id;
|
const documentId = props.id;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@ -154,6 +155,12 @@ function MultiplayerEditor({ ...props }: Props, ref: any) {
|
||||||
];
|
];
|
||||||
}, [remoteProvider, user, ydoc]);
|
}, [remoteProvider, user, ydoc]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isLocalSynced && isRemoteSynced) {
|
||||||
|
onSynced?.();
|
||||||
|
}
|
||||||
|
}, [onSynced, isLocalSynced, isRemoteSynced]);
|
||||||
|
|
||||||
// Disconnect the realtime connection while idle. `isIdle` also checks for
|
// Disconnect the realtime connection while idle. `isIdle` also checks for
|
||||||
// page visibility and will immediately disconnect when a tab is hidden.
|
// page visibility and will immediately disconnect when a tab is hidden.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
"build:webpack": "webpack --config webpack.config.prod.js",
|
"build:webpack": "webpack --config webpack.config.prod.js",
|
||||||
"build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server",
|
"build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server",
|
||||||
"start": "node ./build/server/index.js",
|
"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/",
|
"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",
|
"lint": "eslint app server shared",
|
||||||
"deploy": "git push heroku master",
|
"deploy": "git push heroku master",
|
||||||
|
|
Reference in New Issue