Merge pull request #3717 from overleaf/ae-file-tree-context-minimal

Wrap file tree context functions in useCallback

GitOrigin-RevId: 86fbaf4f168bbe31f474bdea38d2cd09b69c950f
This commit is contained in:
Alexandre Bourdin 2021-03-10 10:11:43 +01:00 committed by Copybot
parent 8001e55b88
commit 623d77cef6
4 changed files with 270 additions and 196 deletions

View file

@ -25,17 +25,17 @@ function FileTreeContext({
projectId={projectId}
hasWritePermissions={hasWritePermissions}
>
<FileTreeActionableProvider hasWritePermissions={hasWritePermissions}>
<FileTreeMutableProvider rootFolder={rootFolder}>
<FileTreeSelectableProvider
hasWritePermissions={hasWritePermissions}
rootDocId={rootDocId}
onSelect={onSelect}
>
<FileTreeMutableProvider rootFolder={rootFolder}>
<FileTreeSelectableProvider
hasWritePermissions={hasWritePermissions}
rootDocId={rootDocId}
onSelect={onSelect}
>
<FileTreeActionableProvider hasWritePermissions={hasWritePermissions}>
<FileTreeDraggableProvider>{children}</FileTreeDraggableProvider>
</FileTreeSelectableProvider>
</FileTreeMutableProvider>
</FileTreeActionableProvider>
</FileTreeActionableProvider>
</FileTreeSelectableProvider>
</FileTreeMutableProvider>
</FileTreeMainProvider>
)
}

View file

@ -1,4 +1,9 @@
import React, { createContext, useReducer, useContext } from 'react'
import React, {
createContext,
useCallback,
useReducer,
useContext
} from 'react'
import PropTypes from 'prop-types'
import { mapSeries } from '../../../infrastructure/promise'
@ -131,47 +136,50 @@ export function useFileTreeActionable() {
const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
const { selectedEntityIds } = useFileTreeSelectable()
function startRenaming() {
const startRenaming = useCallback(() => {
dispatch({ type: ACTION_TYPES.START_RENAME })
}
}, [dispatch])
// update the entity with the new name immediately in the tree, but revert to
// the old name if the sync fails
function finishRenaming(newName) {
const selectedEntityId = Array.from(selectedEntityIds)[0]
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
const oldName = found.entity.name
if (newName === oldName) {
return dispatch({ type: ACTION_TYPES.CLEAR })
}
const error = validateRename(fileTreeData, found, newName)
if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
dispatch({ type: ACTION_TYPES.CLEAR })
dispatchRename(selectedEntityId, newName)
return syncRename(projectId, found.type, found.entity._id, newName).catch(
error => {
dispatchRename(selectedEntityId, oldName)
// The state from this error action isn't used anywhere right now
// but we need to handle the error for linting
dispatch({ type: ACTION_TYPES.ERROR, error })
const finishRenaming = useCallback(
newName => {
const selectedEntityId = Array.from(selectedEntityIds)[0]
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
const oldName = found.entity.name
if (newName === oldName) {
return dispatch({ type: ACTION_TYPES.CLEAR })
}
)
}
const error = validateRename(fileTreeData, found, newName)
if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
dispatch({ type: ACTION_TYPES.CLEAR })
dispatchRename(selectedEntityId, newName)
return syncRename(projectId, found.type, found.entity._id, newName).catch(
error => {
dispatchRename(selectedEntityId, oldName)
// The state from this error action isn't used anywhere right now
// but we need to handle the error for linting
dispatch({ type: ACTION_TYPES.ERROR, error })
}
)
},
[dispatch, dispatchRename, fileTreeData, projectId, selectedEntityIds]
)
// init deletion flow (this will open the delete modal).
// A copy of the selected entities is set as `actionedEntities` so it is kept
// unchanged as the entities are deleted and the selection is updated
function startDeleting() {
const startDeleting = useCallback(() => {
const actionedEntities = Array.from(selectedEntityIds).map(
entityId => findInTreeOrThrow(fileTreeData, entityId).entity
)
dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
}
}, [dispatch, fileTreeData, selectedEntityIds])
// deletes entities in serie. Tree will be updated via the socket event
function finishDeleting() {
const finishDeleting = useCallback(() => {
dispatch({ type: ACTION_TYPES.DELETING })
return mapSeries(Array.from(selectedEntityIds), id => {
@ -192,73 +200,86 @@ export function useFileTreeActionable() {
// set an error and allow user to retry
dispatch({ type: ACTION_TYPES.ERROR, error })
})
}
}, [dispatch, fileTreeData, projectId, selectedEntityIds])
// moves entities. Tree is updated immediately and data are sync'd after.
function finishMoving(toFolderId, draggedEntityIds) {
dispatch({ type: ACTION_TYPES.MOVING })
const finishMoving = useCallback(
(toFolderId, draggedEntityIds) => {
dispatch({ type: ACTION_TYPES.MOVING })
// find entities and filter out no-ops
const founds = Array.from(draggedEntityIds)
.map(draggedEntityId => findInTreeOrThrow(fileTreeData, draggedEntityId))
.filter(found => found.parentFolderId !== toFolderId)
// find entities and filter out no-ops
const founds = Array.from(draggedEntityIds)
.map(draggedEntityId =>
findInTreeOrThrow(fileTreeData, draggedEntityId)
)
.filter(found => found.parentFolderId !== toFolderId)
// make sure all entities can be moved, return early otherwise
const isMoveToRoot = toFolderId === fileTreeData._id
const validationError = founds
.map(found => validateMove(fileTreeData, toFolderId, found, isMoveToRoot))
.find(error => error)
if (validationError) {
return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
}
// make sure all entities can be moved, return early otherwise
const isMoveToRoot = toFolderId === fileTreeData._id
const validationError = founds
.map(found =>
validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
)
.find(error => error)
if (validationError) {
return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
}
// dispatch moves immediately
founds.forEach(found => dispatchMove(found.entity._id, toFolderId))
// dispatch moves immediately
founds.forEach(found => dispatchMove(found.entity._id, toFolderId))
// sync dispatched moves after
return mapSeries(founds, found =>
syncMove(projectId, found.type, found.entity._id, toFolderId)
)
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
})
}
// sync dispatched moves after
return mapSeries(founds, found =>
syncMove(projectId, found.type, found.entity._id, toFolderId)
)
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
})
},
[dispatch, dispatchMove, fileTreeData, projectId]
)
function startCreatingFolder() {
const startCreatingFolder = useCallback(() => {
dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
}
}, [dispatch])
function finishCreatingEntity(entity) {
const parentFolderId = getSelectedParentFolderId(
fileTreeData,
selectedEntityIds
)
const finishCreatingEntity = useCallback(
entity => {
const parentFolderId = getSelectedParentFolderId(
fileTreeData,
selectedEntityIds
)
// check for duplicates and throw
if (isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
return syncCreateEntity(projectId, parentFolderId, entity)
} else {
return Promise.reject(new DuplicateFilenameError())
}
}
// check for duplicates and throw
if (isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
return syncCreateEntity(projectId, parentFolderId, entity)
} else {
return Promise.reject(new DuplicateFilenameError())
}
},
[fileTreeData, projectId, selectedEntityIds]
)
function finishCreatingFolder(name) {
dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
return finishCreatingEntity({ endpoint: 'folder', name })
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
})
}
const finishCreatingFolder = useCallback(
name => {
dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
return finishCreatingEntity({ endpoint: 'folder', name })
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
})
},
[dispatch, finishCreatingEntity]
)
// bypass React file tree entirely; requesting the Angular new doc or file
// modal instead
function startCreatingDocOrFile() {
const startCreatingDocOrFile = useCallback(() => {
const parentFolderId = getSelectedParentFolderId(
fileTreeData,
selectedEntityIds
@ -271,9 +292,9 @@ export function useFileTreeActionable() {
}
})
)
}
}, [fileTreeData, selectedEntityIds])
function startUploadingDocOrFile() {
const startUploadingDocOrFile = useCallback(() => {
const parentFolderId = getSelectedParentFolderId(
fileTreeData,
selectedEntityIds
@ -287,47 +308,55 @@ export function useFileTreeActionable() {
}
})
)
}
}, [fileTreeData, selectedEntityIds])
function finishCreatingDocOrFile(entity) {
return finishCreatingEntity(entity)
.then(() => {
// dispatch FileTreeReactBridge event to update the Angular modal
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
done: true
}
})
)
})
.catch(error => {
// dispatch FileTreeReactBridge event to update the Angular modal with
// an error
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
error: true,
data: error.message
}
})
)
})
}
const finishCreatingDocOrFile = useCallback(
entity =>
finishCreatingEntity(entity)
.then(() => {
// dispatch FileTreeReactBridge event to update the Angular modal
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
done: true
}
})
)
})
.catch(error => {
// dispatch FileTreeReactBridge event to update the Angular modal with
// an error
window.dispatchEvent(
new CustomEvent('FileTreeReactBridge.openNewFileModal', {
detail: {
error: true,
data: error.message
}
})
)
}),
[finishCreatingEntity]
)
function finishCreatingDoc(entity) {
entity.endpoint = 'doc'
return finishCreatingDocOrFile(entity)
}
const finishCreatingDoc = useCallback(
entity => {
entity.endpoint = 'doc'
return finishCreatingDocOrFile(entity)
},
[finishCreatingDocOrFile]
)
function finishCreatingLinkedFile(entity) {
entity.endpoint = 'linked_file'
return finishCreatingDocOrFile(entity)
}
const finishCreatingLinkedFile = useCallback(
entity => {
entity.endpoint = 'linked_file'
return finishCreatingDocOrFile(entity)
},
[finishCreatingDocOrFile]
)
function cancel() {
const cancel = useCallback(() => {
dispatch({ type: ACTION_TYPES.CANCEL })
}
}, [dispatch])
return {
canDelete: selectedEntityIds.size > 0,

View file

@ -1,4 +1,9 @@
import React, { createContext, useReducer, useContext } from 'react'
import React, {
createContext,
useCallback,
useReducer,
useContext
} from 'react'
import PropTypes from 'prop-types'
import {
@ -73,48 +78,66 @@ FileTreeMutableProvider.propTypes = {
export function useFileTreeMutable() {
const { fileTreeData, dispatch } = useContext(FileTreeMutableContext)
function dispatchCreateFolder(parentFolderId, entity) {
entity.type = 'folder'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
}
const dispatchCreateFolder = useCallback(
(parentFolderId, entity) => {
entity.type = 'folder'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
},
[dispatch]
)
function dispatchCreateDoc(parentFolderId, entity) {
entity.type = 'doc'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
}
const dispatchCreateDoc = useCallback(
(parentFolderId, entity) => {
entity.type = 'doc'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
},
[dispatch]
)
function dispatchCreateFile(parentFolderId, entity) {
entity.type = 'fileRef'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
}
const dispatchCreateFile = useCallback(
(parentFolderId, entity) => {
entity.type = 'fileRef'
dispatch({
type: ACTION_TYPES.CREATE_ENTITY,
parentFolderId,
entity
})
},
[dispatch]
)
function dispatchRename(id, newName) {
dispatch({
type: ACTION_TYPES.RENAME,
newName,
id
})
}
const dispatchRename = useCallback(
(id, newName) => {
dispatch({
type: ACTION_TYPES.RENAME,
newName,
id
})
},
[dispatch]
)
function dispatchDelete(id) {
dispatch({ type: ACTION_TYPES.DELETE, id })
}
const dispatchDelete = useCallback(
id => {
dispatch({ type: ACTION_TYPES.DELETE, id })
},
[dispatch]
)
function dispatchMove(entityId, toFolderId) {
dispatch({ type: ACTION_TYPES.MOVE, entityId, toFolderId })
}
const dispatchMove = useCallback(
(entityId, toFolderId) => {
dispatch({ type: ACTION_TYPES.MOVE, entityId, toFolderId })
},
[dispatch]
)
return {
fileTreeData,

View file

@ -1,8 +1,10 @@
import React, {
createContext,
useCallback,
useContext,
useReducer,
useEffect,
useMemo,
useState
} from 'react'
import PropTypes from 'prop-types'
@ -163,40 +165,54 @@ export function useSelectableEntity(id) {
const isSelected = selectedEntityIds.has(id)
function selectOrMultiSelectEntity(ev) {
const isMultiSelect = ev.ctrlKey || ev.metaKey
const actionType = isMultiSelect
? ACTION_TYPES.MULTI_SELECT
: ACTION_TYPES.SELECT
const selectOrMultiSelectEntity = useCallback(
ev => {
const isMultiSelect = ev.ctrlKey || ev.metaKey
const actionType = isMultiSelect
? ACTION_TYPES.MULTI_SELECT
: ACTION_TYPES.SELECT
dispatch({ type: actionType, id })
}
dispatch({ type: actionType, id })
},
[dispatch, id]
)
function handleClick(ev) {
selectOrMultiSelectEntity(ev)
}
function handleKeyPress(ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
const handleClick = useCallback(
ev => {
selectOrMultiSelectEntity(ev)
}
}
},
[selectOrMultiSelectEntity]
)
function handleContextMenu(ev) {
// make sure the right-clicked entity gets selected
if (!selectedEntityIds.has(id)) selectOrMultiSelectEntity(ev)
}
const handleKeyPress = useCallback(
ev => {
if (ev.key === 'Enter' || ev.key === ' ') {
selectOrMultiSelectEntity(ev)
}
},
[selectOrMultiSelectEntity]
)
return {
isSelected,
props: {
const handleContextMenu = useCallback(
ev => {
// make sure the right-clicked entity gets selected
if (!selectedEntityIds.has(id)) selectOrMultiSelectEntity(ev)
},
[id, selectOrMultiSelectEntity, selectedEntityIds]
)
const props = useMemo(
() => ({
className: classNames({ selected: isSelected }),
'aria-selected': isSelected,
onClick: handleClick,
onContextMenu: handleContextMenu,
onKeyPress: handleKeyPress
}
}
}),
[handleClick, handleContextMenu, handleKeyPress, isSelected]
)
return { isSelected, props }
}
export function useFileTreeSelectable() {
@ -204,13 +220,19 @@ export function useFileTreeSelectable() {
FileTreeSelectableContext
)
function select(id) {
dispatch({ type: ACTION_TYPES.SELECT, id })
}
const select = useCallback(
id => {
dispatch({ type: ACTION_TYPES.SELECT, id })
},
[dispatch]
)
function unselect(id) {
dispatch({ type: ACTION_TYPES.UNSELECT, id })
}
const unselect = useCallback(
id => {
dispatch({ type: ACTION_TYPES.UNSELECT, id })
},
[dispatch]
)
return {
selectedEntityIds,