overleaf/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx

636 lines
18 KiB
TypeScript
Raw Normal View History

import {
createContext,
useCallback,
useMemo,
useReducer,
useContext,
useEffect,
useState,
FC,
} from 'react'
import { mapSeries } from '../../../infrastructure/promise'
import {
syncRename,
syncDelete,
syncMove,
syncCreateEntity,
} from '../util/sync-mutation'
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
import { useProjectContext } from '../../../shared/context/project-context'
import { useEditorContext } from '../../../shared/context/editor-context'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import { useFileTreeSelectable } from './file-tree-selectable'
import {
InvalidFilenameError,
BlockedFilenameError,
DuplicateFilenameError,
DuplicateFilenameMoveError,
} from '../errors'
import { Folder } from '../../../../../types/folder'
type DroppedFile = File & {
relativePath?: string
}
type DroppedFiles = {
files: DroppedFile[]
targetFolderId: string
}
const FileTreeActionableContext = createContext<
| {
isDeleting: boolean
isRenaming: boolean
isCreatingFile: boolean
isCreatingFolder: boolean
isMoving: boolean
inFlight: boolean
actionedEntities: any | null
newFileCreateMode: any | null
error: any | null
canDelete: boolean
canRename: boolean
canCreate: boolean
parentFolderId: string
selectedFileName: string | null
isDuplicate: (parentFolderId: string, name: string) => boolean
startRenaming: any
finishRenaming: any
startDeleting: any
finishDeleting: any
finishMoving: any
startCreatingFile: any
startCreatingFolder: any
finishCreatingFolder: any
startCreatingDocOrFile: any
startUploadingDocOrFile: any
finishCreatingDoc: any
finishCreatingLinkedFile: any
cancel: () => void
droppedFiles: { files: File[]; targetFolderId: string } | null
setDroppedFiles: (value: DroppedFiles | null) => void
downloadPath?: string
}
| undefined
>(undefined)
/* eslint-disable no-unused-vars */
enum ACTION_TYPES {
START_RENAME = 'START_RENAME',
START_DELETE = 'START_DELETE',
DELETING = 'DELETING',
START_CREATE_FILE = 'START_CREATE_FILE',
START_CREATE_FOLDER = 'START_CREATE_FOLDER',
CREATING_FILE = 'CREATING_FILE',
CREATING_FOLDER = 'CREATING_FOLDER',
MOVING = 'MOVING',
CANCEL = 'CANCEL',
CLEAR = 'CLEAR',
ERROR = 'ERROR',
}
/* eslint-enable no-unused-vars */
type State = {
isDeleting: boolean
isRenaming: boolean
isCreatingFile: boolean
isCreatingFolder: boolean
isMoving: boolean
inFlight: boolean
actionedEntities: any | null
newFileCreateMode: any | null
error: unknown | null
}
const defaultState: State = {
isDeleting: false,
isRenaming: false,
isCreatingFile: false,
isCreatingFolder: false,
isMoving: false,
inFlight: false,
actionedEntities: null,
newFileCreateMode: null,
error: null,
}
function fileTreeActionableReadOnlyReducer(state: State) {
return state
}
type Action =
| {
type: ACTION_TYPES.START_RENAME
}
| {
type: ACTION_TYPES.START_DELETE
actionedEntities: any | null
}
| {
type: ACTION_TYPES.START_CREATE_FILE
newFileCreateMode: any | null
}
| {
type: ACTION_TYPES.START_CREATE_FOLDER
}
| {
type: ACTION_TYPES.CREATING_FILE
}
| {
type: ACTION_TYPES.CREATING_FOLDER
}
| {
type: ACTION_TYPES.DELETING
}
| {
type: ACTION_TYPES.MOVING
}
| {
type: ACTION_TYPES.CLEAR
}
| {
type: ACTION_TYPES.CANCEL
}
| {
type: ACTION_TYPES.ERROR
error: unknown
}
function fileTreeActionableReducer(state: State, action: Action) {
switch (action.type) {
case ACTION_TYPES.START_RENAME:
return { ...defaultState, isRenaming: true }
case ACTION_TYPES.START_DELETE:
return {
...defaultState,
isDeleting: true,
actionedEntities: action.actionedEntities,
}
case ACTION_TYPES.START_CREATE_FILE:
return {
...defaultState,
isCreatingFile: true,
newFileCreateMode: action.newFileCreateMode,
}
case ACTION_TYPES.START_CREATE_FOLDER:
return { ...defaultState, isCreatingFolder: true }
case ACTION_TYPES.CREATING_FILE:
return {
...defaultState,
isCreatingFile: true,
newFileCreateMode: state.newFileCreateMode,
inFlight: true,
}
case ACTION_TYPES.CREATING_FOLDER:
return { ...defaultState, isCreatingFolder: true, inFlight: true }
case ACTION_TYPES.DELETING:
// keep `actionedEntities` so the entities list remains displayed in the
// delete modal
return {
...defaultState,
isDeleting: true,
inFlight: true,
actionedEntities: state.actionedEntities,
}
case ACTION_TYPES.MOVING:
return {
...defaultState,
isMoving: true,
inFlight: true,
}
case ACTION_TYPES.CLEAR:
return { ...defaultState }
case ACTION_TYPES.CANCEL:
if (state.inFlight) return state
return { ...defaultState }
case ACTION_TYPES.ERROR:
return { ...state, inFlight: false, error: action.error }
default:
throw new Error(`Unknown user action type: ${(action as Action).type}`)
}
}
export const FileTreeActionableProvider: FC<{
reindexReferences: () => void
}> = ({ reindexReferences, children }) => {
const { _id: projectId } = useProjectContext()
const { permissionsLevel } = useEditorContext()
const [state, dispatch] = useReducer(
permissionsLevel === 'readOnly'
? fileTreeActionableReadOnlyReducer
: fileTreeActionableReducer,
defaultState
)
const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeData()
const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
const [droppedFiles, setDroppedFiles] = useState<DroppedFiles | null>(null)
const startRenaming = useCallback(() => {
dispatch({ type: ACTION_TYPES.START_RENAME })
}, [])
// update the entity with the new name immediately in the tree, but revert to
// the old name if the sync fails
const finishRenaming = useCallback(
(newName: string) => {
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 })
}
)
},
[dispatchRename, fileTreeData, projectId, selectedEntityIds]
)
const isDuplicate = useCallback(
(parentFolderId: string, name: string) => {
return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
},
[fileTreeData]
)
// 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
const startDeleting = useCallback(() => {
const actionedEntities = Array.from(selectedEntityIds).map(
entityId => findInTreeOrThrow(fileTreeData, entityId).entity
)
dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
}, [fileTreeData, selectedEntityIds])
// deletes entities in series. Tree will be updated via the socket event
const finishDeleting = useCallback(() => {
dispatch({ type: ACTION_TYPES.DELETING })
let shouldReindexReferences = false
return (
mapSeries(Array.from(selectedEntityIds), id => {
const found = findInTreeOrThrow(fileTreeData, id)
shouldReindexReferences =
shouldReindexReferences || /\.bib$/.test(found.entity.name)
return syncDelete(projectId, found.type, found.entity._id).catch(
error => {
// throw unless 404
if (error.info.statusCode !== 404) {
throw error
}
}
)
})
// @ts-ignore (TODO: improve mapSeries types)
.then(() => {
if (shouldReindexReferences) {
reindexReferences()
}
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch((error: Error) => {
// set an error and allow user to retry
dispatch({ type: ACTION_TYPES.ERROR, error })
})
)
}, [fileTreeData, projectId, selectedEntityIds, reindexReferences])
// moves entities. Tree is updated immediately and data are sync'd after.
const finishMoving = useCallback(
(toFolderId, draggedEntityIds) => {
dispatch({ type: ACTION_TYPES.MOVING })
// find entities and filter out no-ops and nested files
const founds = Array.from(draggedEntityIds)
.map(draggedEntityId =>
findInTreeOrThrow(fileTreeData, draggedEntityId)
)
.filter(
found =>
found.parentFolderId !== toFolderId &&
!draggedEntityIds.has(found.parentFolderId)
)
// 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 })
}
// keep track of old parent folder ids so we can revert entities if sync fails
const oldParentFolderIds: Record<string, string> = {}
let isMoveFailed = false
// dispatch moves immediately
founds.forEach(found => {
oldParentFolderIds[found.entity._id] = found.parentFolderId
dispatchMove(found.entity._id, toFolderId)
})
// sync dispatched moves after
return (
mapSeries(founds, async found => {
try {
await syncMove(projectId, found.type, found.entity._id, toFolderId)
} catch (error) {
isMoveFailed = true
dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
dispatch({ type: ACTION_TYPES.ERROR, error })
}
})
// @ts-ignore (TODO: improve mapSeries types)
.then(() => {
if (!isMoveFailed) {
dispatch({ type: ACTION_TYPES.CLEAR })
}
})
)
},
[dispatchMove, fileTreeData, projectId]
)
const startCreatingFolder = useCallback(() => {
dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
}, [])
const parentFolderId = useMemo(() => {
return getSelectedParentFolderId(
fileTreeData,
selectedEntityIds,
isRootFolderSelected
)
}, [fileTreeData, selectedEntityIds, isRootFolderSelected])
// return the name of the selected file or doc if there is only one selected
const selectedFileName = useMemo(() => {
if (selectedEntityIds.size === 1) {
const [selectedEntityId] = selectedEntityIds
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
return selectedEntity?.entity?.name
}
return null
}, [fileTreeData, selectedEntityIds])
const finishCreatingEntity = useCallback(
entity => {
const error = validateCreate(fileTreeData, parentFolderId, entity)
if (error) {
return Promise.reject(error)
}
return syncCreateEntity(projectId, parentFolderId, entity)
},
[fileTreeData, parentFolderId, projectId]
)
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 })
})
},
[finishCreatingEntity]
)
const startCreatingFile = useCallback(newFileCreateMode => {
dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
}, [])
const startCreatingDocOrFile = useCallback(() => {
startCreatingFile('doc')
}, [startCreatingFile])
const startUploadingDocOrFile = useCallback(() => {
startCreatingFile('upload')
}, [startCreatingFile])
const finishCreatingDocOrFile = useCallback(
entity => {
dispatch({ type: ACTION_TYPES.CREATING_FILE })
return finishCreatingEntity(entity)
.then(() => {
dispatch({ type: ACTION_TYPES.CLEAR })
})
.catch(error => {
dispatch({ type: ACTION_TYPES.ERROR, error })
})
},
[finishCreatingEntity]
)
const finishCreatingDoc = useCallback(
entity => {
entity.endpoint = 'doc'
return finishCreatingDocOrFile(entity)
},
[finishCreatingDocOrFile]
)
const finishCreatingLinkedFile = useCallback(
entity => {
entity.endpoint = 'linked_file'
return finishCreatingDocOrFile(entity)
},
[finishCreatingDocOrFile]
)
const cancel = useCallback(() => {
dispatch({ type: ACTION_TYPES.CANCEL })
}, [])
// listen for `file-tree.start-creating` events
useEffect(() => {
function handleEvent(event: Event) {
dispatch({
type: ACTION_TYPES.START_CREATE_FILE,
newFileCreateMode: (event as CustomEvent<{ mode: string }>).detail.mode,
})
}
window.addEventListener('file-tree.start-creating', handleEvent)
return () => {
window.removeEventListener('file-tree.start-creating', handleEvent)
}
}, [])
// build the path for downloading a single file or doc
const downloadPath = useMemo(() => {
if (selectedEntityIds.size === 1) {
const [selectedEntityId] = selectedEntityIds
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
if (selectedEntity?.type === 'fileRef') {
return `/project/${projectId}/file/${selectedEntityId}`
}
if (selectedEntity?.type === 'doc') {
return `/project/${projectId}/doc/${selectedEntityId}/download`
}
}
}, [fileTreeData, projectId, selectedEntityIds])
// TODO: wrap in useMemo
const value = {
canDelete: selectedEntityIds.size > 0 && !isRootFolderSelected,
canRename: selectedEntityIds.size === 1 && !isRootFolderSelected,
canCreate: selectedEntityIds.size < 2,
...state,
parentFolderId,
selectedFileName,
isDuplicate,
startRenaming,
finishRenaming,
startDeleting,
finishDeleting,
finishMoving,
startCreatingFile,
startCreatingFolder,
finishCreatingFolder,
startCreatingDocOrFile,
startUploadingDocOrFile,
finishCreatingDoc,
finishCreatingLinkedFile,
cancel,
droppedFiles,
setDroppedFiles,
downloadPath,
}
return (
<FileTreeActionableContext.Provider value={value}>
{children}
</FileTreeActionableContext.Provider>
)
}
export function useFileTreeActionable() {
const context = useContext(FileTreeActionableContext)
if (!context) {
throw new Error(
'useFileTreeActionable is only available inside FileTreeActionableProvider'
)
}
return context
}
function getSelectedParentFolderId(
fileTreeData: Folder,
selectedEntityIds: Set<string>,
isRootFolderSelected: boolean
) {
if (isRootFolderSelected) {
return fileTreeData._id
}
// we expect only one entity to be selected in that case, so we pick the first
const selectedEntityId = Array.from(selectedEntityIds)[0]
if (!selectedEntityId) {
// in some cases no entities are selected. Return the root folder id then.
return fileTreeData._id
}
const found = findInTree(fileTreeData, selectedEntityId)
if (!found) {
// if the entity isn't in the tree, return the root folder id.
return fileTreeData._id
}
return found.type === 'folder' ? found.entity._id : found.parentFolderId
}
function validateCreate(
fileTreeData: Folder,
parentFolderId: string,
entity: { name: string; endpoint: string }
) {
if (!isCleanFilename(entity.name)) {
return new InvalidFilenameError()
}
if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
return new DuplicateFilenameError()
}
// check that the name of a file is allowed, if creating in the root folder
const isMoveToRoot = parentFolderId === fileTreeData._id
const isFolder = entity.endpoint === 'folder'
if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
return new BlockedFilenameError()
}
}
function validateRename(
fileTreeData: Folder,
found: { parentFolderId: string; path: string; type: string },
newName: string
) {
if (!isCleanFilename(newName)) {
return new InvalidFilenameError()
}
if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
return new DuplicateFilenameError()
}
const isTopLevel = found.path.length === 1
const isFolder = found.type === 'folder'
if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
return new BlockedFilenameError()
}
}
function validateMove(
fileTreeData: Folder,
toFolderId: string,
found: { entity: { name: string }; type: string },
isMoveToRoot: boolean
) {
if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
const error = new DuplicateFilenameMoveError()
;(error as DuplicateFilenameMoveError & { entityName: string }).entityName =
found.entity.name
return error
}
const isFolder = found.type === 'folder'
if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
return new BlockedFilenameError()
}
}