|
|
@@ -6,8 +6,8 @@ import {
|
|
|
useContext,
|
|
|
useEffect,
|
|
|
useState,
|
|
|
+ FC,
|
|
|
} from 'react'
|
|
|
-import PropTypes from 'prop-types'
|
|
|
|
|
|
import { mapSeries } from '../../../infrastructure/promise'
|
|
|
|
|
|
@@ -32,24 +32,73 @@ import {
|
|
|
DuplicateFilenameError,
|
|
|
DuplicateFilenameMoveError,
|
|
|
} from '../errors'
|
|
|
-
|
|
|
-const FileTreeActionableContext = createContext()
|
|
|
-
|
|
|
-const 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',
|
|
|
+import { Folder } from '../../../../../types/folder'
|
|
|
+
|
|
|
+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
|
|
|
+ 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: any; targetFolderId: string } | null
|
|
|
+ setDroppedFiles: (files: any) => 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 = {
|
|
|
+const defaultState: State = {
|
|
|
isDeleting: false,
|
|
|
isRenaming: false,
|
|
|
isCreatingFile: false,
|
|
|
@@ -61,11 +110,49 @@ const defaultState = {
|
|
|
error: null,
|
|
|
}
|
|
|
|
|
|
-function fileTreeActionableReadOnlyReducer(state) {
|
|
|
+function fileTreeActionableReadOnlyReducer(state: State) {
|
|
|
return state
|
|
|
}
|
|
|
|
|
|
-function fileTreeActionableReducer(state, action) {
|
|
|
+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 }
|
|
|
@@ -115,13 +202,15 @@ function fileTreeActionableReducer(state, action) {
|
|
|
case ACTION_TYPES.ERROR:
|
|
|
return { ...state, inFlight: false, error: action.error }
|
|
|
default:
|
|
|
- throw new Error(`Unknown user action type: ${action.type}`)
|
|
|
+ throw new Error(`Unknown user action type: ${(action as Action).type}`)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
- const { _id: projectId } = useProjectContext(projectContextPropTypes)
|
|
|
- const { permissionsLevel } = useEditorContext(editorContextPropTypes)
|
|
|
+export const FileTreeActionableProvider: FC<{
|
|
|
+ reindexReferences: () => void
|
|
|
+}> = ({ reindexReferences, children }) => {
|
|
|
+ const { _id: projectId } = useProjectContext()
|
|
|
+ const { permissionsLevel } = useEditorContext()
|
|
|
|
|
|
const [state, dispatch] = useReducer(
|
|
|
permissionsLevel === 'readOnly'
|
|
|
@@ -142,7 +231,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
// 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 => {
|
|
|
+ (newName: string) => {
|
|
|
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
|
|
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
|
|
const oldName = found.entity.name
|
|
|
@@ -168,7 +257,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
)
|
|
|
|
|
|
const isDuplicate = useCallback(
|
|
|
- (parentFolderId, name) => {
|
|
|
+ (parentFolderId: string, name: string) => {
|
|
|
return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
|
|
|
},
|
|
|
[fileTreeData]
|
|
|
@@ -189,29 +278,32 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
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
|
|
|
+ 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
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
- )
|
|
|
- })
|
|
|
- .then(() => {
|
|
|
- if (shouldReindexReferences) {
|
|
|
- reindexReferences()
|
|
|
- }
|
|
|
- dispatch({ type: ACTION_TYPES.CLEAR })
|
|
|
- })
|
|
|
- .catch(error => {
|
|
|
- // set an error and allow user to retry
|
|
|
- dispatch({ type: ACTION_TYPES.ERROR, 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.
|
|
|
@@ -242,7 +334,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
}
|
|
|
|
|
|
// keep track of old parent folder ids so we can revert entities if sync fails
|
|
|
- const oldParentFolderIds = {}
|
|
|
+ const oldParentFolderIds: Record<string, string> = {}
|
|
|
let isMoveFailed = false
|
|
|
|
|
|
// dispatch moves immediately
|
|
|
@@ -252,19 +344,23 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
})
|
|
|
|
|
|
// 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 })
|
|
|
- }
|
|
|
- }).then(() => {
|
|
|
- if (!isMoveFailed) {
|
|
|
- dispatch({ type: ACTION_TYPES.CLEAR })
|
|
|
- }
|
|
|
- })
|
|
|
+ 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]
|
|
|
)
|
|
|
@@ -356,10 +452,10 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
|
|
|
// listen for `file-tree.start-creating` events
|
|
|
useEffect(() => {
|
|
|
- function handleEvent(event) {
|
|
|
+ function handleEvent(event: Event) {
|
|
|
dispatch({
|
|
|
type: ACTION_TYPES.START_CREATE_FILE,
|
|
|
- newFileCreateMode: event.detail.mode,
|
|
|
+ newFileCreateMode: (event as CustomEvent<{ mode: string }>).detail.mode,
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -381,6 +477,7 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
}
|
|
|
}, [fileTreeData, projectId, selectedEntityIds])
|
|
|
|
|
|
+ // TODO: wrap in useMemo
|
|
|
const value = {
|
|
|
canDelete: selectedEntityIds.size > 0 && !isRootFolderSelected,
|
|
|
canRename: selectedEntityIds.size === 1 && !isRootFolderSelected,
|
|
|
@@ -413,22 +510,6 @@ export function FileTreeActionableProvider({ reindexReferences, children }) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-FileTreeActionableProvider.propTypes = {
|
|
|
- reindexReferences: PropTypes.func.isRequired,
|
|
|
- children: PropTypes.oneOfType([
|
|
|
- PropTypes.arrayOf(PropTypes.node),
|
|
|
- PropTypes.node,
|
|
|
- ]).isRequired,
|
|
|
-}
|
|
|
-
|
|
|
-const projectContextPropTypes = {
|
|
|
- _id: PropTypes.string.isRequired,
|
|
|
-}
|
|
|
-
|
|
|
-const editorContextPropTypes = {
|
|
|
- permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
|
|
|
-}
|
|
|
-
|
|
|
export function useFileTreeActionable() {
|
|
|
const context = useContext(FileTreeActionableContext)
|
|
|
|
|
|
@@ -442,9 +523,9 @@ export function useFileTreeActionable() {
|
|
|
}
|
|
|
|
|
|
function getSelectedParentFolderId(
|
|
|
- fileTreeData,
|
|
|
- selectedEntityIds,
|
|
|
- isRootFolderSelected
|
|
|
+ fileTreeData: Folder,
|
|
|
+ selectedEntityIds: Set<string>,
|
|
|
+ isRootFolderSelected: boolean
|
|
|
) {
|
|
|
if (isRootFolderSelected) {
|
|
|
return fileTreeData._id
|
|
|
@@ -467,7 +548,11 @@ function getSelectedParentFolderId(
|
|
|
return found.type === 'folder' ? found.entity._id : found.parentFolderId
|
|
|
}
|
|
|
|
|
|
-function validateCreate(fileTreeData, parentFolderId, entity) {
|
|
|
+function validateCreate(
|
|
|
+ fileTreeData: Folder,
|
|
|
+ parentFolderId: string,
|
|
|
+ entity: { name: string; endpoint: string }
|
|
|
+) {
|
|
|
if (!isCleanFilename(entity.name)) {
|
|
|
return new InvalidFilenameError()
|
|
|
}
|
|
|
@@ -484,7 +569,11 @@ function validateCreate(fileTreeData, parentFolderId, entity) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function validateRename(fileTreeData, found, newName) {
|
|
|
+function validateRename(
|
|
|
+ fileTreeData: Folder,
|
|
|
+ found: { parentFolderId: string; path: string; type: string },
|
|
|
+ newName: string
|
|
|
+) {
|
|
|
if (!isCleanFilename(newName)) {
|
|
|
return new InvalidFilenameError()
|
|
|
}
|
|
|
@@ -500,10 +589,16 @@ function validateRename(fileTreeData, found, newName) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function validateMove(fileTreeData, toFolderId, found, isMoveToRoot) {
|
|
|
+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.entityName = found.entity.name
|
|
|
+ ;(error as DuplicateFilenameMoveError & { entityName: string }).entityName =
|
|
|
+ found.entity.name
|
|
|
return error
|
|
|
}
|
|
|
|