|
|
@@ -0,0 +1,301 @@
|
|
|
+import React, { createContext, useReducer, useContext } from 'react'
|
|
|
+import PropTypes from 'prop-types'
|
|
|
+
|
|
|
+import { mapSeries } from '../../../infrastructure/promise'
|
|
|
+
|
|
|
+import {
|
|
|
+ syncRename,
|
|
|
+ syncDelete,
|
|
|
+ syncMove,
|
|
|
+ syncCreateEntity
|
|
|
+} from '../util/sync-mutation'
|
|
|
+import { findInTreeOrThrow } from '../util/find-in-tree'
|
|
|
+
|
|
|
+import { FileTreeMainContext } from './file-tree-main'
|
|
|
+import { useFileTreeMutable } from './file-tree-mutable'
|
|
|
+import { useFileTreeSelectable } from './file-tree-selectable'
|
|
|
+
|
|
|
+const FileTreeActionableContext = createContext()
|
|
|
+
|
|
|
+const ACTION_TYPES = {
|
|
|
+ START_RENAME: 'START_RENAME',
|
|
|
+ START_DELETE: 'START_DELETE',
|
|
|
+ DELETING: 'DELETING',
|
|
|
+ START_CREATE_FOLDER: 'START_CREATE_FOLDER',
|
|
|
+ CREATING_FOLDER: 'CREATING_FOLDER',
|
|
|
+ CANCEL: 'CANCEL',
|
|
|
+ CLEAR: 'CLEAR',
|
|
|
+ ERROR: 'ERROR'
|
|
|
+}
|
|
|
+
|
|
|
+const defaultState = {
|
|
|
+ isDeleting: false,
|
|
|
+ isRenaming: false,
|
|
|
+ isCreatingFolder: false,
|
|
|
+ inFlight: false,
|
|
|
+ actionedEntities: null,
|
|
|
+ error: null
|
|
|
+}
|
|
|
+
|
|
|
+function fileTreeActionableReadOnlyReducer(state) {
|
|
|
+ return state
|
|
|
+}
|
|
|
+
|
|
|
+function fileTreeActionableReducer(state, 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_FOLDER:
|
|
|
+ return { ...defaultState, isCreatingFolder: 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.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.type}`)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export function FileTreeActionableProvider({ hasWritePermissions, children }) {
|
|
|
+ const [state, dispatch] = useReducer(
|
|
|
+ hasWritePermissions
|
|
|
+ ? fileTreeActionableReducer
|
|
|
+ : fileTreeActionableReadOnlyReducer,
|
|
|
+ defaultState
|
|
|
+ )
|
|
|
+
|
|
|
+ return (
|
|
|
+ <FileTreeActionableContext.Provider value={{ ...state, dispatch }}>
|
|
|
+ {children}
|
|
|
+ </FileTreeActionableContext.Provider>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+FileTreeActionableProvider.propTypes = {
|
|
|
+ hasWritePermissions: PropTypes.bool.isRequired,
|
|
|
+ children: PropTypes.oneOfType([
|
|
|
+ PropTypes.arrayOf(PropTypes.node),
|
|
|
+ PropTypes.node
|
|
|
+ ]).isRequired
|
|
|
+}
|
|
|
+
|
|
|
+export function useFileTreeActionable() {
|
|
|
+ const {
|
|
|
+ isDeleting,
|
|
|
+ isRenaming,
|
|
|
+ isCreatingFolder,
|
|
|
+ inFlight,
|
|
|
+ error,
|
|
|
+ actionedEntities,
|
|
|
+ dispatch
|
|
|
+ } = useContext(FileTreeActionableContext)
|
|
|
+ const { projectId } = useContext(FileTreeMainContext)
|
|
|
+ const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
|
|
|
+ const { selectedEntityIds } = useFileTreeSelectable()
|
|
|
+
|
|
|
+ function startRenaming() {
|
|
|
+ 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
|
|
|
+ function finishRenaming(newName) {
|
|
|
+ dispatch({ type: ACTION_TYPES.CLEAR })
|
|
|
+ const selectedEntityId = Array.from(selectedEntityIds)[0]
|
|
|
+ const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
|
|
+ const oldName = found.entity.name
|
|
|
+ dispatchRename(selectedEntityId, newName)
|
|
|
+ return syncRename(projectId, found.type, found.entity._id, newName).catch(
|
|
|
+ error => {
|
|
|
+ dispatch({ type: ACTION_TYPES.ERROR, error })
|
|
|
+ dispatchRename(selectedEntityId, oldName)
|
|
|
+ }
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // 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 actionedEntities = Array.from(selectedEntityIds).map(
|
|
|
+ entityId => findInTreeOrThrow(fileTreeData, entityId).entity
|
|
|
+ )
|
|
|
+ dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
|
|
|
+ }
|
|
|
+
|
|
|
+ // deletes entities in serie. Tree will be updated via the socket event
|
|
|
+ function finishDeleting() {
|
|
|
+ dispatch({ type: ACTION_TYPES.DELETING })
|
|
|
+
|
|
|
+ return mapSeries(Array.from(selectedEntityIds), id => {
|
|
|
+ const found = findInTreeOrThrow(fileTreeData, id)
|
|
|
+ return syncDelete(projectId, found.type, found.entity._id).catch(
|
|
|
+ error => {
|
|
|
+ // throw unless 404
|
|
|
+ if (error.message !== '404') {
|
|
|
+ throw error
|
|
|
+ }
|
|
|
+ }
|
|
|
+ )
|
|
|
+ })
|
|
|
+ .then(() => {
|
|
|
+ dispatch({ type: ACTION_TYPES.CLEAR })
|
|
|
+ })
|
|
|
+ .catch(error => {
|
|
|
+ // set an error and allow user to retry
|
|
|
+ dispatch({ type: ACTION_TYPES.ERROR, error })
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ // moves entities. Tree is updated immediately and data are sync'd after.
|
|
|
+ function finishMoving(toFolderId, draggedEntityIds) {
|
|
|
+ draggedEntityIds.forEach(selectedEntityId => {
|
|
|
+ dispatchMove(selectedEntityId, toFolderId)
|
|
|
+ })
|
|
|
+
|
|
|
+ return mapSeries(Array.from(draggedEntityIds), id => {
|
|
|
+ const found = findInTreeOrThrow(fileTreeData, id)
|
|
|
+ return syncMove(projectId, found.type, found.entity._id, toFolderId)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ function startCreatingFolder() {
|
|
|
+ dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
|
|
|
+ }
|
|
|
+
|
|
|
+ function finishCreatingEntity(entity) {
|
|
|
+ const selectedEntityId = Array.from(selectedEntityIds)[0]
|
|
|
+ const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
|
|
+ const parentFolderId =
|
|
|
+ found.type === 'folder' ? found.entity._id : found.parentFolderId
|
|
|
+
|
|
|
+ return syncCreateEntity(projectId, parentFolderId, entity)
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 })
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ // bypass React file tree entirely; requesting the Angular new doc or file
|
|
|
+ // modal instead
|
|
|
+ function startCreatingDocOrFile() {
|
|
|
+ const selectedEntityId = Array.from(selectedEntityIds)[0]
|
|
|
+ const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
|
|
+ const parentFolderId =
|
|
|
+ found.type === 'folder' ? found.entity._id : found.parentFolderId
|
|
|
+ window.dispatchEvent(
|
|
|
+ new CustomEvent('FileTreeReactBridge.openNewDocModal', {
|
|
|
+ detail: {
|
|
|
+ mode: 'doc',
|
|
|
+ parentFolderId
|
|
|
+ }
|
|
|
+ })
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ function startUploadingDocOrFile() {
|
|
|
+ const selectedEntityId = Array.from(selectedEntityIds)[0]
|
|
|
+ const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
|
|
+ const parentFolderId =
|
|
|
+ found.type === 'folder' ? found.entity._id : found.parentFolderId
|
|
|
+ window.dispatchEvent(
|
|
|
+ new CustomEvent('FileTreeReactBridge.openNewDocModal', {
|
|
|
+ detail: {
|
|
|
+ mode: 'upload',
|
|
|
+ parentFolderId
|
|
|
+ }
|
|
|
+ })
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ })
|
|
|
+ )
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ function finishCreatingDoc(entity) {
|
|
|
+ entity.endpoint = 'doc'
|
|
|
+ return finishCreatingDocOrFile(entity)
|
|
|
+ }
|
|
|
+
|
|
|
+ function finishCreatingLinkedFile(entity) {
|
|
|
+ entity.endpoint = 'linked_file'
|
|
|
+ return finishCreatingDocOrFile(entity)
|
|
|
+ }
|
|
|
+
|
|
|
+ function cancel() {
|
|
|
+ dispatch({ type: ACTION_TYPES.CANCEL })
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ canDelete: selectedEntityIds.size > 0,
|
|
|
+ canRename: selectedEntityIds.size === 1,
|
|
|
+ canCreate: selectedEntityIds.size === 1,
|
|
|
+ isDeleting,
|
|
|
+ isRenaming,
|
|
|
+ isCreatingFolder,
|
|
|
+ inFlight,
|
|
|
+ actionedEntities,
|
|
|
+ error,
|
|
|
+ startRenaming,
|
|
|
+ finishRenaming,
|
|
|
+ startDeleting,
|
|
|
+ finishDeleting,
|
|
|
+ finishMoving,
|
|
|
+ startCreatingFolder,
|
|
|
+ finishCreatingFolder,
|
|
|
+ startCreatingDocOrFile,
|
|
|
+ startUploadingDocOrFile,
|
|
|
+ finishCreatingDoc,
|
|
|
+ finishCreatingLinkedFile,
|
|
|
+ cancel
|
|
|
+ }
|
|
|
+}
|