mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #19743 from overleaf/mj-restore-project
[web+project-history] Add project version reverting GitOrigin-RevId: 0f77cec730393187d531c0c6561faaa652bebf29
This commit is contained in:
parent
54f0c24633
commit
654d96ace6
25 changed files with 619 additions and 13 deletions
|
@ -6,6 +6,7 @@ const assert = require('check-types').assert
|
|||
// dependency
|
||||
let RestoreOrigin = null
|
||||
let RestoreFileOrigin = null
|
||||
let RestoreProjectOrigin = null
|
||||
|
||||
/**
|
||||
* An Origin records where a {@link Change} came from. The Origin class handles
|
||||
|
@ -34,6 +35,8 @@ class Origin {
|
|||
if (raw.kind === RestoreOrigin.KIND) return RestoreOrigin.fromRaw(raw)
|
||||
if (raw.kind === RestoreFileOrigin.KIND)
|
||||
return RestoreFileOrigin.fromRaw(raw)
|
||||
if (raw.kind === RestoreProjectOrigin.KIND)
|
||||
return RestoreProjectOrigin.fromRaw(raw)
|
||||
return new Origin(raw.kind)
|
||||
}
|
||||
|
||||
|
@ -58,3 +61,4 @@ module.exports = Origin
|
|||
|
||||
RestoreOrigin = require('./restore_origin')
|
||||
RestoreFileOrigin = require('./restore_file_origin')
|
||||
RestoreProjectOrigin = require('./restore_project_origin')
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
'use strict'
|
||||
|
||||
const assert = require('check-types').assert
|
||||
|
||||
const Origin = require('.')
|
||||
|
||||
class RestoreProjectOrigin extends Origin {
|
||||
/**
|
||||
* @param {number} version that was restored
|
||||
* @param {Date} timestamp from the restored version
|
||||
*/
|
||||
constructor(version, timestamp) {
|
||||
assert.integer(version, 'RestoreProjectOrigin: bad version')
|
||||
assert.date(timestamp, 'RestoreProjectOrigin: bad timestamp')
|
||||
|
||||
super(RestoreProjectOrigin.KIND)
|
||||
this.version = version
|
||||
this.timestamp = timestamp
|
||||
}
|
||||
|
||||
static fromRaw(raw) {
|
||||
return new RestoreProjectOrigin(raw.version, new Date(raw.timestamp))
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toRaw() {
|
||||
return {
|
||||
kind: RestoreProjectOrigin.KIND,
|
||||
version: this.version,
|
||||
timestamp: this.timestamp.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
getVersion() {
|
||||
return this.version
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Date}
|
||||
*/
|
||||
getTimestamp() {
|
||||
return this.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
RestoreProjectOrigin.KIND = 'project-restore'
|
||||
|
||||
module.exports = RestoreProjectOrigin
|
|
@ -230,6 +230,16 @@ export function getProjectSnapshot(req, res, next) {
|
|||
)
|
||||
}
|
||||
|
||||
export function getPathsAtVersion(req, res, next) {
|
||||
const { project_id: projectId, version } = req.params
|
||||
SnapshotManager.getPathsAtVersion(projectId, version, (error, result) => {
|
||||
if (error != null) {
|
||||
return next(error)
|
||||
}
|
||||
res.json(result)
|
||||
})
|
||||
}
|
||||
|
||||
export function healthCheck(req, res) {
|
||||
HealthChecker.check(err => {
|
||||
if (err != null) {
|
||||
|
|
|
@ -156,6 +156,11 @@ export function initialize(app) {
|
|||
HttpController.getProjectSnapshot
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/project/:project_id/paths/version/:version',
|
||||
HttpController.getPathsAtVersion
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/project/:project_id/force',
|
||||
validate({
|
||||
|
|
|
@ -237,6 +237,13 @@ async function getProjectSnapshot(projectId, version) {
|
|||
}
|
||||
}
|
||||
|
||||
async function getPathsAtVersion(projectId, version) {
|
||||
const snapshot = await _getSnapshotAtVersion(projectId, version)
|
||||
return {
|
||||
paths: snapshot.getFilePathnames(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} projectId
|
||||
|
@ -295,12 +302,14 @@ const getFileSnapshotStreamCb = callbackify(getFileSnapshotStream)
|
|||
const getProjectSnapshotCb = callbackify(getProjectSnapshot)
|
||||
const getLatestSnapshotCb = callbackify(getLatestSnapshot)
|
||||
const getRangesSnapshotCb = callbackify(getRangesSnapshot)
|
||||
const getPathsAtVersionCb = callbackify(getPathsAtVersion)
|
||||
|
||||
export {
|
||||
getFileSnapshotStreamCb as getFileSnapshotStream,
|
||||
getProjectSnapshotCb as getProjectSnapshot,
|
||||
getLatestSnapshotCb as getLatestSnapshot,
|
||||
getRangesSnapshotCb as getRangesSnapshot,
|
||||
getPathsAtVersionCb as getPathsAtVersion,
|
||||
}
|
||||
|
||||
export const promises = {
|
||||
|
@ -308,4 +317,5 @@ export const promises = {
|
|||
getProjectSnapshot,
|
||||
getLatestSnapshot,
|
||||
getRangesSnapshot,
|
||||
getPathsAtVersion,
|
||||
}
|
||||
|
|
|
@ -261,6 +261,12 @@ function _shouldMergeUpdate(update, summarizedUpdate, labels) {
|
|||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
update.meta.origin.kind === 'project-restore' &&
|
||||
update.meta.origin.timestamp !== summarizedUpdate.meta.origin.timestamp
|
||||
) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -998,4 +998,50 @@ Four five six\
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPathsAtVersion', function () {
|
||||
beforeEach(function () {
|
||||
this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
|
||||
this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
|
||||
chunk: (this.chunk = {
|
||||
history: {
|
||||
snapshot: {
|
||||
files: {
|
||||
'main.tex': {
|
||||
hash: (this.fileHash =
|
||||
'5d2781d78fa5a97b7bafa849fe933dfc9dc93eba'),
|
||||
rangesHash: (this.rangesHash =
|
||||
'73061952d41ce54825e2fc1c36b4cf736d5fb62f'),
|
||||
stringLength: 41,
|
||||
},
|
||||
'other.tex': {
|
||||
hash: (this.fileHash =
|
||||
'f572d396fae9206628714fb2ce00f72e94f2258f'),
|
||||
stringLength: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
changes: [],
|
||||
},
|
||||
startVersion: 4,
|
||||
authors: [
|
||||
{
|
||||
id: 31,
|
||||
email: 'author@example.com',
|
||||
name: 'Author',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an array of paths', async function () {
|
||||
const result = await this.SnapshotManager.promises.getPathsAtVersion(
|
||||
this.projectId,
|
||||
4
|
||||
)
|
||||
expect(result.paths).to.have.length(2)
|
||||
expect(result.paths).to.include.members(['main.tex', 'other.tex'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -118,6 +118,7 @@ module.exports = HistoryController = {
|
|||
projectId,
|
||||
version,
|
||||
pathname,
|
||||
{},
|
||||
function (err, entity) {
|
||||
if (err) {
|
||||
return next(err)
|
||||
|
@ -130,6 +131,18 @@ module.exports = HistoryController = {
|
|||
)
|
||||
},
|
||||
|
||||
revertProject(req, res, next) {
|
||||
const { project_id: projectId } = req.params
|
||||
const { version } = req.body
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
RestoreManager.revertProject(userId, projectId, version, function (err) {
|
||||
if (err) {
|
||||
return next(err)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
})
|
||||
},
|
||||
|
||||
getLabels(req, res, next) {
|
||||
const projectId = req.params.Project_id
|
||||
HistoryController._makeRequest(
|
||||
|
|
|
@ -16,6 +16,7 @@ const EditorRealTimeController = require('../Editor/EditorRealTimeController')
|
|||
const ChatManager = require('../Chat/ChatManager')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const ProjectGetter = require('../Project/ProjectGetter')
|
||||
const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
|
||||
|
||||
const RestoreManager = {
|
||||
async restoreFileFromV2(userId, projectId, version, pathname) {
|
||||
|
@ -49,7 +50,7 @@ const RestoreManager = {
|
|||
)
|
||||
},
|
||||
|
||||
async revertFile(userId, projectId, version, pathname) {
|
||||
async revertFile(userId, projectId, version, pathname, options = {}) {
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
overleaf: true,
|
||||
})
|
||||
|
@ -85,7 +86,7 @@ const RestoreManager = {
|
|||
)
|
||||
const updateAtVersion = updates.find(update => update.toV === version)
|
||||
|
||||
const origin = {
|
||||
const origin = options.origin || {
|
||||
kind: 'file-restore',
|
||||
path: pathname,
|
||||
version,
|
||||
|
@ -239,6 +240,61 @@ const RestoreManager = {
|
|||
}
|
||||
},
|
||||
|
||||
async revertProject(userId, projectId, version) {
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
overleaf: true,
|
||||
})
|
||||
if (!project?.overleaf?.history?.rangesSupportEnabled) {
|
||||
throw new OError('project does not have ranges support', { projectId })
|
||||
}
|
||||
|
||||
// Get project paths at version
|
||||
const pathsAtPastVersion = await RestoreManager._getProjectPathsAtVersion(
|
||||
projectId,
|
||||
version
|
||||
)
|
||||
|
||||
const updates = await RestoreManager._getUpdatesFromHistory(
|
||||
projectId,
|
||||
version
|
||||
)
|
||||
const updateAtVersion = updates.find(update => update.toV === version)
|
||||
|
||||
const origin = {
|
||||
kind: 'project-restore',
|
||||
version,
|
||||
timestamp: new Date(updateAtVersion.meta.end_ts).toISOString(),
|
||||
}
|
||||
|
||||
for (const pathname of pathsAtPastVersion) {
|
||||
await RestoreManager.revertFile(userId, projectId, version, pathname, {
|
||||
origin,
|
||||
})
|
||||
}
|
||||
|
||||
const entitiesAtLiveVersion =
|
||||
await ProjectEntityHandler.promises.getAllEntities(projectId)
|
||||
|
||||
const trimLeadingSlash = path => path.replace(/^\//, '')
|
||||
|
||||
const pathsAtLiveVersion = entitiesAtLiveVersion.docs
|
||||
.map(doc => doc.path)
|
||||
.concat(entitiesAtLiveVersion.files.map(file => file.path))
|
||||
.map(trimLeadingSlash)
|
||||
|
||||
// Delete files that were not present at the reverted version
|
||||
for (const path of pathsAtLiveVersion) {
|
||||
if (!pathsAtPastVersion.includes(path)) {
|
||||
await EditorController.promises.deleteEntityWithPath(
|
||||
projectId,
|
||||
path,
|
||||
origin,
|
||||
userId
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async _writeFileVersionToDisk(projectId, version, pathname) {
|
||||
const url = `${
|
||||
Settings.apis.project_history.url
|
||||
|
@ -258,6 +314,12 @@ const RestoreManager = {
|
|||
const res = await fetchJson(url)
|
||||
return res.updates
|
||||
},
|
||||
|
||||
async _getProjectPathsAtVersion(projectId, version) {
|
||||
const url = `${Settings.apis.project_history.url}/project/${projectId}/paths/version/${version}`
|
||||
const res = await fetchJson(url)
|
||||
return res.paths
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = { ...callbackifyAll(RestoreManager), promises: RestoreManager }
|
||||
|
|
|
@ -335,6 +335,7 @@ const _ProjectController = {
|
|||
'pdfjs-40',
|
||||
'personal-access-token',
|
||||
'revert-file',
|
||||
'revert-project',
|
||||
'review-panel-redesign',
|
||||
'track-pdf-download',
|
||||
!anonymous && 'writefull-oauth-promotion',
|
||||
|
|
|
@ -784,6 +784,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
|||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
HistoryController.revertFile
|
||||
)
|
||||
webRouter.post(
|
||||
'/project/:project_id/revert-project',
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
HistoryController.revertProject
|
||||
)
|
||||
webRouter.get(
|
||||
'/project/:project_id/version/:version/zip',
|
||||
RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision),
|
||||
|
|
|
@ -87,6 +87,7 @@
|
|||
"already_subscribed_try_refreshing_the_page": "",
|
||||
"also": "",
|
||||
"an_email_has_already_been_sent_to": "",
|
||||
"an_error_occured_while_restoring_project": "",
|
||||
"an_error_occurred_when_verifying_the_coupon_code": "",
|
||||
"anonymous": "",
|
||||
"anyone_with_link_can_edit": "",
|
||||
|
@ -430,6 +431,7 @@
|
|||
"file_action_edited": "",
|
||||
"file_action_renamed": "",
|
||||
"file_action_restored": "",
|
||||
"file_action_restored_project": "",
|
||||
"file_already_exists": "",
|
||||
"file_already_exists_in_this_location": "",
|
||||
"file_name": "",
|
||||
|
@ -1144,6 +1146,8 @@
|
|||
"restore_file_error_message": "",
|
||||
"restore_file_error_title": "",
|
||||
"restore_file_version": "",
|
||||
"restore_project_to_this_version": "",
|
||||
"restore_this_version": "",
|
||||
"restoring": "",
|
||||
"resync_completed": "",
|
||||
"resync_message": "",
|
||||
|
@ -1420,6 +1424,7 @@
|
|||
"then_x_price_per_month": "",
|
||||
"then_x_price_per_year": "",
|
||||
"there_are_lots_of_options_to_edit_and_customize_your_figures": "",
|
||||
"there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "",
|
||||
"they_lose_access_to_account": "",
|
||||
"this_action_cannot_be_reversed": "",
|
||||
"this_action_cannot_be_undone": "",
|
||||
|
@ -1712,6 +1717,7 @@
|
|||
"your_affiliation_is_confirmed": "",
|
||||
"your_browser_does_not_support_this_feature": "",
|
||||
"your_compile_timed_out": "",
|
||||
"your_current_project_will_revert_to_the_version_from_time": "",
|
||||
"your_git_access_info": "",
|
||||
"your_git_access_info_bullet_1": "",
|
||||
"your_git_access_info_bullet_2": "",
|
||||
|
|
|
@ -3,17 +3,20 @@ import Download from './menu-item/download'
|
|||
import { Version } from '../../../services/types/update'
|
||||
import { useCallback } from 'react'
|
||||
import { ActiveDropdown } from '../../../hooks/use-dropdown-active-item'
|
||||
import RestoreProject from './menu-item/restore-project'
|
||||
|
||||
type VersionDropdownContentProps = {
|
||||
projectId: string
|
||||
version: Version
|
||||
closeDropdownForItem: ActiveDropdown['closeDropdownForItem']
|
||||
endTimestamp: number
|
||||
}
|
||||
|
||||
function HistoryDropdownContent({
|
||||
projectId,
|
||||
version,
|
||||
closeDropdownForItem,
|
||||
endTimestamp,
|
||||
}: VersionDropdownContentProps) {
|
||||
const closeDropdown = useCallback(() => {
|
||||
closeDropdownForItem(version, 'moreOptions')
|
||||
|
@ -31,6 +34,12 @@ function HistoryDropdownContent({
|
|||
version={version}
|
||||
closeDropdown={closeDropdown}
|
||||
/>
|
||||
<RestoreProject
|
||||
projectId={projectId}
|
||||
version={version}
|
||||
closeDropdown={closeDropdown}
|
||||
endTimestamp={endTimestamp}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
import Icon from '@/shared/components/icon'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { MenuItem } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RestoreProjectModal } from '../../../diff-view/modals/restore-project-modal'
|
||||
import { useSplitTestContext } from '@/shared/context/split-test-context'
|
||||
import { useRestoreProject } from '@/features/history/context/hooks/use-restore-project'
|
||||
import withErrorBoundary from '@/infrastructure/error-boundary'
|
||||
import { RestoreProjectErrorModal } from '../../../diff-view/modals/restore-project-error-modal'
|
||||
|
||||
type RestoreProjectProps = {
|
||||
projectId: string
|
||||
version: number
|
||||
closeDropdown: () => void
|
||||
endTimestamp: number
|
||||
}
|
||||
|
||||
const RestoreProject = ({
|
||||
projectId,
|
||||
version,
|
||||
closeDropdown,
|
||||
endTimestamp,
|
||||
}: RestoreProjectProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
const { restoreProject, isRestoring } = useRestoreProject()
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
closeDropdown()
|
||||
setShowModal(true)
|
||||
}, [closeDropdown])
|
||||
|
||||
const onRestore = useCallback(() => {
|
||||
restoreProject(projectId, version)
|
||||
}, [restoreProject, version, projectId])
|
||||
|
||||
if (
|
||||
splitTestVariants['revert-file'] !== 'enabled' ||
|
||||
splitTestVariants['revert-project'] !== 'enabled'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem onClick={handleClick}>
|
||||
<Icon type="undo" fw /> {t('restore_project_to_this_version')}
|
||||
</MenuItem>
|
||||
<RestoreProjectModal
|
||||
setShow={setShowModal}
|
||||
show={showModal}
|
||||
endTimestamp={endTimestamp}
|
||||
isRestoring={isRestoring}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(RestoreProject, RestoreProjectErrorModal)
|
|
@ -21,6 +21,7 @@ import CompareVersionDropdown from './dropdown/compare-version-dropdown'
|
|||
import { CompareVersionDropdownContentAllHistory } from './dropdown/compare-version-dropdown-content'
|
||||
import FileRestoreChange from './file-restore-change'
|
||||
import HistoryResyncChange from './history-resync-change'
|
||||
import ProjectRestoreChange from './project-restore-change'
|
||||
|
||||
type HistoryVersionProps = {
|
||||
update: LoadedUpdate
|
||||
|
@ -113,6 +114,7 @@ function HistoryVersion({
|
|||
{dropdownActive ? (
|
||||
<HistoryDropdownContent
|
||||
version={update.toV}
|
||||
endTimestamp={update.meta.end_ts}
|
||||
projectId={projectId}
|
||||
closeDropdownForItem={closeDropdownForItem}
|
||||
/>
|
||||
|
@ -166,16 +168,7 @@ function HistoryVersion({
|
|||
label={label}
|
||||
/>
|
||||
))}
|
||||
{update.meta.origin?.kind === 'file-restore' ? (
|
||||
<FileRestoreChange origin={update.meta.origin} />
|
||||
) : update.meta.origin?.kind === 'history-resync' ? (
|
||||
<HistoryResyncChange />
|
||||
) : (
|
||||
<Changes
|
||||
pathnames={update.pathnames}
|
||||
projectOps={update.project_ops}
|
||||
/>
|
||||
)}
|
||||
<ChangeEntry update={update} />
|
||||
{update.meta.origin?.kind !== 'history-resync' ? (
|
||||
<>
|
||||
<MetadataUsersList
|
||||
|
@ -193,4 +186,19 @@ function HistoryVersion({
|
|||
)
|
||||
}
|
||||
|
||||
function ChangeEntry({ update }: { update: LoadedUpdate }) {
|
||||
switch (update.meta.origin?.kind) {
|
||||
case 'file-restore':
|
||||
return <FileRestoreChange origin={update.meta.origin} />
|
||||
case 'history-resync':
|
||||
return <HistoryResyncChange />
|
||||
case 'project-restore':
|
||||
return <ProjectRestoreChange origin={update.meta.origin} />
|
||||
default:
|
||||
return (
|
||||
<Changes pathnames={update.pathnames} projectOps={update.project_ops} />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default memo(HistoryVersion)
|
||||
|
|
|
@ -91,6 +91,7 @@ function LabelListItem({
|
|||
version={version}
|
||||
projectId={projectId}
|
||||
closeDropdownForItem={closeDropdownForItem}
|
||||
endTimestamp={toVTimestamp * 1000}
|
||||
/>
|
||||
) : null}
|
||||
</HistoryDropdown>
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { formatTime } from '@/features/utils/format-date'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoadedUpdate } from '../../services/types/update'
|
||||
|
||||
function ProjectRestoreChange({
|
||||
origin,
|
||||
}: Pick<LoadedUpdate['meta'], 'origin'>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!origin || origin.kind !== 'project-restore') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="history-version-restore-project">
|
||||
{t('file_action_restored_project', {
|
||||
date: formatTime(origin.timestamp, 'Do MMMM, h:mm a'),
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectRestoreChange
|
|
@ -0,0 +1,34 @@
|
|||
import { Button, Modal } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function RestoreProjectErrorModal({
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
resetErrorBoundary: VoidFunction
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal show onHide={resetErrorBoundary}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
{t('an_error_occured_while_restoring_project')}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{t(
|
||||
'there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us'
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
bsStyle={null}
|
||||
className="btn-secondary"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import AccessibleModal from '@/shared/components/accessible-modal'
|
||||
import { formatDate } from '@/utils/dates'
|
||||
import { useCallback } from 'react'
|
||||
import { Button, Modal } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type RestoreProjectModalProps = {
|
||||
setShow: React.Dispatch<React.SetStateAction<boolean>>
|
||||
show: boolean
|
||||
isRestoring: boolean
|
||||
endTimestamp: number
|
||||
onRestore: () => void
|
||||
}
|
||||
|
||||
export const RestoreProjectModal = ({
|
||||
setShow,
|
||||
show,
|
||||
endTimestamp,
|
||||
isRestoring,
|
||||
onRestore,
|
||||
}: RestoreProjectModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setShow(false)
|
||||
}, [setShow])
|
||||
|
||||
return (
|
||||
<AccessibleModal onHide={() => setShow(false)} show={show}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>{t('restore_this_version')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
{t('your_current_project_will_revert_to_the_version_from_time', {
|
||||
timestamp: formatDate(endTimestamp),
|
||||
})}
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
className="btn btn-secondary"
|
||||
bsStyle={null}
|
||||
onClick={onCancel}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
bsStyle={null}
|
||||
onClick={onRestore}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
{isRestoring ? t('restoring') : t('restore')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { useErrorHandler } from 'react-error-boundary'
|
||||
import { restoreProjectToVersion } from '../../services/api'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
|
||||
type RestorationState = 'initial' | 'restoring' | 'restored' | 'error'
|
||||
|
||||
export const useRestoreProject = () => {
|
||||
const handleError = useErrorHandler()
|
||||
const { setView } = useLayoutContext()
|
||||
|
||||
const [restorationState, setRestorationState] =
|
||||
useState<RestorationState>('initial')
|
||||
|
||||
const restoreProject = useCallback(
|
||||
(projectId: string, version: number) => {
|
||||
setRestorationState('restoring')
|
||||
restoreProjectToVersion(projectId, version)
|
||||
.then(() => {
|
||||
setRestorationState('restored')
|
||||
setView('editor')
|
||||
})
|
||||
.catch(err => {
|
||||
setRestorationState('error')
|
||||
handleError(err)
|
||||
})
|
||||
},
|
||||
[handleError, setView]
|
||||
)
|
||||
|
||||
return {
|
||||
restorationState,
|
||||
restoreProject,
|
||||
isRestoring: restorationState === 'restoring',
|
||||
}
|
||||
}
|
|
@ -107,3 +107,9 @@ export function restoreFileToVersion(
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreProjectToVersion(projectId: string, version: number) {
|
||||
return postJSON(`/project/${projectId}/revert-project`, {
|
||||
body: { version },
|
||||
})
|
||||
}
|
||||
|
|
|
@ -29,4 +29,9 @@ export interface Meta {
|
|||
timestamp: number
|
||||
version: number
|
||||
}
|
||||
| {
|
||||
kind: 'project-restore'
|
||||
timestamp: number
|
||||
version: number
|
||||
}
|
||||
}
|
||||
|
|
|
@ -286,7 +286,10 @@ export const EditorManagerProvider: FC = ({ children }) => {
|
|||
) {
|
||||
return
|
||||
}
|
||||
if (update.meta.origin?.kind === 'file-restore') {
|
||||
if (
|
||||
update.meta.origin?.kind === 'file-restore' ||
|
||||
update.meta.origin?.kind === 'project-restore'
|
||||
) {
|
||||
return
|
||||
}
|
||||
showGenericMessageModal(
|
||||
|
|
|
@ -124,6 +124,7 @@
|
|||
"also_available_as_on_premises": "Also available as On-Premises",
|
||||
"alternatively_create_new_institution_account": "Alternatively, you can create a <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
|
||||
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__</0>. Please wait and try again later.",
|
||||
"an_error_occured_while_restoring_project": "An error occured while restoring the project",
|
||||
"an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
|
||||
"and": "and",
|
||||
"annual": "Annual",
|
||||
|
@ -638,6 +639,7 @@
|
|||
"file_action_edited": "Edited",
|
||||
"file_action_renamed": "Renamed",
|
||||
"file_action_restored": "Restored __fileName__ from: __date__",
|
||||
"file_action_restored_project": "Restored project from __date__",
|
||||
"file_already_exists": "A file or folder with this name already exists",
|
||||
"file_already_exists_in_this_location": "An item named <0>__fileName__</0> already exists in this location. If you wish to move this file, rename or remove the conflicting file and try again.",
|
||||
"file_name": "File Name",
|
||||
|
@ -1673,6 +1675,8 @@
|
|||
"restore_file_error_message": "There was a problem restoring the file version. Please try again in a few moments. If the problem continues please contact us.",
|
||||
"restore_file_error_title": "Restore File Error",
|
||||
"restore_file_version": "Restore this version",
|
||||
"restore_project_to_this_version": "Restore project to this version",
|
||||
"restore_this_version": "Restore this version",
|
||||
"restoring": "Restoring",
|
||||
"restricted": "Restricted",
|
||||
"restricted_no_permission": "Restricted, sorry you don’t have permission to load this page.",
|
||||
|
@ -2034,6 +2038,7 @@
|
|||
"then_x_price_per_month": "Then __price__ per month",
|
||||
"then_x_price_per_year": "Then __price__ per year",
|
||||
"there_are_lots_of_options_to_edit_and_customize_your_figures": "There are lots of options to edit and customize your figures, such as wrapping text around the figure, rotating the image, or including multiple images in a single figure. You’ll need to edit the LaTeX code to do this. <0>Find out how</0>",
|
||||
"there_was_a_problem_restoring_the_project_please_try_again_in_a_few_moments_or_contact_us": "There was a problem restoring the project. Please try again in a few moments. Contact us of the problem persists.",
|
||||
"there_was_an_error_opening_your_content": "There was an error creating your project",
|
||||
"thesis": "Thesis",
|
||||
"they_lose_access_to_account": "They lose all access to this Overleaf account immediately",
|
||||
|
@ -2393,6 +2398,7 @@
|
|||
"your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
|
||||
"your_browser_does_not_support_this_feature": "Sorry, your browser doesn’t support this feature. Please update your browser to its latest version.",
|
||||
"your_compile_timed_out": "Your compile timed out",
|
||||
"your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
|
||||
"your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
|
||||
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",
|
||||
"your_git_access_info_bullet_2": "If you reach the maximum limit, you’ll need to delete a token before you can generate a new one.",
|
||||
|
|
|
@ -35,6 +35,9 @@ describe('RestoreManager', function () {
|
|||
'../Editor/EditorRealTimeController': (this.EditorRealTimeController =
|
||||
{}),
|
||||
'../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
|
||||
'../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {
|
||||
promises: {},
|
||||
}),
|
||||
},
|
||||
})
|
||||
this.user_id = 'mock-user-id'
|
||||
|
@ -488,4 +491,136 @@ describe('RestoreManager', function () {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('revertProject', function () {
|
||||
beforeEach(function () {
|
||||
this.ProjectGetter.promises.getProject = sinon.stub()
|
||||
this.ProjectGetter.promises.getProject
|
||||
.withArgs(this.project_id)
|
||||
.resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
|
||||
this.RestoreManager.promises.revertFile = sinon.stub().resolves()
|
||||
this.RestoreManager.promises._getProjectPathsAtVersion = sinon
|
||||
.stub()
|
||||
.resolves([])
|
||||
this.ProjectEntityHandler.promises.getAllEntities = sinon
|
||||
.stub()
|
||||
.resolves({ docs: [], files: [] })
|
||||
this.EditorController.promises.deleteEntityWithPath = sinon
|
||||
.stub()
|
||||
.resolves()
|
||||
this.RestoreManager.promises._getUpdatesFromHistory = sinon
|
||||
.stub()
|
||||
.resolves([
|
||||
{ toV: this.version, meta: { end_ts: (this.end_ts = Date.now()) } },
|
||||
])
|
||||
})
|
||||
|
||||
describe('reverting a project without ranges support', function () {
|
||||
beforeEach(function () {
|
||||
this.ProjectGetter.promises.getProject = sinon.stub().resolves({
|
||||
overleaf: { history: { rangesSupportEnabled: false } },
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error', async function () {
|
||||
await expect(
|
||||
this.RestoreManager.promises.revertProject(
|
||||
this.user_id,
|
||||
this.project_id,
|
||||
this.version
|
||||
)
|
||||
).to.eventually.be.rejectedWith('project does not have ranges support')
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a project with overlap in current files and old files', function () {
|
||||
beforeEach(async function () {
|
||||
this.ProjectEntityHandler.promises.getAllEntities = sinon
|
||||
.stub()
|
||||
.resolves({
|
||||
docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
|
||||
files: [{ path: '/figures/image.png' }],
|
||||
})
|
||||
this.RestoreManager.promises._getProjectPathsAtVersion = sinon
|
||||
.stub()
|
||||
.resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
|
||||
|
||||
await this.RestoreManager.promises.revertProject(
|
||||
this.user_id,
|
||||
this.project_id,
|
||||
this.version
|
||||
)
|
||||
this.origin = {
|
||||
kind: 'project-restore',
|
||||
version: this.version,
|
||||
timestamp: new Date(this.end_ts).toISOString(),
|
||||
}
|
||||
})
|
||||
|
||||
it('should delete the old files', function () {
|
||||
expect(
|
||||
this.EditorController.promises.deleteEntityWithPath
|
||||
).to.have.been.calledWith(
|
||||
this.project_id,
|
||||
'new-file.tex',
|
||||
this.origin,
|
||||
this.user_id
|
||||
)
|
||||
})
|
||||
|
||||
it('should not delete the current files', function () {
|
||||
expect(
|
||||
this.EditorController.promises.deleteEntityWithPath
|
||||
).to.not.have.been.calledWith(
|
||||
this.project_id,
|
||||
'main.tex',
|
||||
this.origin,
|
||||
this.user_id
|
||||
)
|
||||
|
||||
expect(
|
||||
this.EditorController.promises.deleteEntityWithPath
|
||||
).to.not.have.been.calledWith(
|
||||
this.project_id,
|
||||
'figures/image.png',
|
||||
this.origin,
|
||||
this.user_id
|
||||
)
|
||||
})
|
||||
|
||||
it('should revert the old files', function () {
|
||||
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
|
||||
this.user_id,
|
||||
this.project_id,
|
||||
this.version,
|
||||
'main.tex'
|
||||
)
|
||||
|
||||
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
|
||||
this.user_id,
|
||||
this.project_id,
|
||||
this.version,
|
||||
'figures/image.png'
|
||||
)
|
||||
|
||||
expect(this.RestoreManager.promises.revertFile).to.have.been.calledWith(
|
||||
this.user_id,
|
||||
this.project_id,
|
||||
this.version,
|
||||
'since-deleted.tex'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not revert the current files', function () {
|
||||
expect(
|
||||
this.RestoreManager.promises.revertFile
|
||||
).to.not.have.been.calledWith(
|
||||
this.user_id,
|
||||
this.project_id,
|
||||
this.version,
|
||||
'new-file.tex'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue