file-tree-actionable.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. import React, { createContext, useReducer, useContext } from 'react'
  2. import PropTypes from 'prop-types'
  3. import { mapSeries } from '../../../infrastructure/promise'
  4. import {
  5. syncRename,
  6. syncDelete,
  7. syncMove,
  8. syncCreateEntity
  9. } from '../util/sync-mutation'
  10. import { findInTreeOrThrow } from '../util/find-in-tree'
  11. import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
  12. import { isCleanFilename } from '../util/safe-path'
  13. import { FileTreeMainContext } from './file-tree-main'
  14. import { useFileTreeMutable } from './file-tree-mutable'
  15. import { useFileTreeSelectable } from './file-tree-selectable'
  16. import { InvalidFilenameError, DuplicateFilenameError } from '../errors'
  17. const FileTreeActionableContext = createContext()
  18. const ACTION_TYPES = {
  19. START_RENAME: 'START_RENAME',
  20. START_DELETE: 'START_DELETE',
  21. DELETING: 'DELETING',
  22. START_CREATE_FOLDER: 'START_CREATE_FOLDER',
  23. CREATING_FOLDER: 'CREATING_FOLDER',
  24. CANCEL: 'CANCEL',
  25. CLEAR: 'CLEAR',
  26. ERROR: 'ERROR'
  27. }
  28. const defaultState = {
  29. isDeleting: false,
  30. isRenaming: false,
  31. isCreatingFolder: false,
  32. inFlight: false,
  33. actionedEntities: null,
  34. error: null
  35. }
  36. function fileTreeActionableReadOnlyReducer(state) {
  37. return state
  38. }
  39. function fileTreeActionableReducer(state, action) {
  40. switch (action.type) {
  41. case ACTION_TYPES.START_RENAME:
  42. return { ...defaultState, isRenaming: true }
  43. case ACTION_TYPES.START_DELETE:
  44. return {
  45. ...defaultState,
  46. isDeleting: true,
  47. actionedEntities: action.actionedEntities
  48. }
  49. case ACTION_TYPES.START_CREATE_FOLDER:
  50. return { ...defaultState, isCreatingFolder: true }
  51. case ACTION_TYPES.CREATING_FOLDER:
  52. return { ...defaultState, isCreatingFolder: true, inFlight: true }
  53. case ACTION_TYPES.DELETING:
  54. // keep `actionedEntities` so the entities list remains displayed in the
  55. // delete modal
  56. return {
  57. ...defaultState,
  58. isDeleting: true,
  59. inFlight: true,
  60. actionedEntities: state.actionedEntities
  61. }
  62. case ACTION_TYPES.CLEAR:
  63. return { ...defaultState }
  64. case ACTION_TYPES.CANCEL:
  65. if (state.inFlight) return state
  66. return { ...defaultState }
  67. case ACTION_TYPES.ERROR:
  68. return { ...state, inFlight: false, error: action.error }
  69. default:
  70. throw new Error(`Unknown user action type: ${action.type}`)
  71. }
  72. }
  73. export function FileTreeActionableProvider({ hasWritePermissions, children }) {
  74. const [state, dispatch] = useReducer(
  75. hasWritePermissions
  76. ? fileTreeActionableReducer
  77. : fileTreeActionableReadOnlyReducer,
  78. defaultState
  79. )
  80. return (
  81. <FileTreeActionableContext.Provider value={{ ...state, dispatch }}>
  82. {children}
  83. </FileTreeActionableContext.Provider>
  84. )
  85. }
  86. FileTreeActionableProvider.propTypes = {
  87. hasWritePermissions: PropTypes.bool.isRequired,
  88. children: PropTypes.oneOfType([
  89. PropTypes.arrayOf(PropTypes.node),
  90. PropTypes.node
  91. ]).isRequired
  92. }
  93. export function useFileTreeActionable() {
  94. const {
  95. isDeleting,
  96. isRenaming,
  97. isCreatingFolder,
  98. inFlight,
  99. error,
  100. actionedEntities,
  101. dispatch
  102. } = useContext(FileTreeActionableContext)
  103. const { projectId } = useContext(FileTreeMainContext)
  104. const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
  105. const { selectedEntityIds } = useFileTreeSelectable()
  106. function startRenaming() {
  107. dispatch({ type: ACTION_TYPES.START_RENAME })
  108. }
  109. // update the entity with the new name immediately in the tree, but revert to
  110. // the old name if the sync fails
  111. function finishRenaming(newName) {
  112. const selectedEntityId = Array.from(selectedEntityIds)[0]
  113. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  114. const oldName = found.entity.name
  115. if (newName === oldName) {
  116. return dispatch({ type: ACTION_TYPES.CLEAR })
  117. }
  118. let error
  119. // check valid name
  120. if (!isCleanFilename(newName)) {
  121. error = new InvalidFilenameError()
  122. return dispatch({ type: ACTION_TYPES.ERROR, error })
  123. }
  124. // check for duplicates
  125. if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
  126. error = new DuplicateFilenameError()
  127. return dispatch({ type: ACTION_TYPES.ERROR, error })
  128. }
  129. dispatch({ type: ACTION_TYPES.CLEAR })
  130. dispatchRename(selectedEntityId, newName)
  131. return syncRename(projectId, found.type, found.entity._id, newName).catch(
  132. error => {
  133. dispatchRename(selectedEntityId, oldName)
  134. // The state from this error action isn't used anywhere right now
  135. // but we need to handle the error for linting
  136. dispatch({ type: ACTION_TYPES.ERROR, error })
  137. }
  138. )
  139. }
  140. // init deletion flow (this will open the delete modal).
  141. // A copy of the selected entities is set as `actionedEntities` so it is kept
  142. // unchanged as the entities are deleted and the selection is updated
  143. function startDeleting() {
  144. const actionedEntities = Array.from(selectedEntityIds).map(
  145. entityId => findInTreeOrThrow(fileTreeData, entityId).entity
  146. )
  147. dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
  148. }
  149. // deletes entities in serie. Tree will be updated via the socket event
  150. function finishDeleting() {
  151. dispatch({ type: ACTION_TYPES.DELETING })
  152. return mapSeries(Array.from(selectedEntityIds), id => {
  153. const found = findInTreeOrThrow(fileTreeData, id)
  154. return syncDelete(projectId, found.type, found.entity._id).catch(
  155. error => {
  156. // throw unless 404
  157. if (error.message !== '404') {
  158. throw error
  159. }
  160. }
  161. )
  162. })
  163. .then(() => {
  164. dispatch({ type: ACTION_TYPES.CLEAR })
  165. })
  166. .catch(error => {
  167. // set an error and allow user to retry
  168. dispatch({ type: ACTION_TYPES.ERROR, error })
  169. })
  170. }
  171. // moves entities. Tree is updated immediately and data are sync'd after.
  172. function finishMoving(toFolderId, draggedEntityIds) {
  173. draggedEntityIds.forEach(selectedEntityId => {
  174. dispatchMove(selectedEntityId, toFolderId)
  175. })
  176. return mapSeries(Array.from(draggedEntityIds), id => {
  177. const found = findInTreeOrThrow(fileTreeData, id)
  178. return syncMove(projectId, found.type, found.entity._id, toFolderId)
  179. })
  180. }
  181. function startCreatingFolder() {
  182. dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
  183. }
  184. function finishCreatingEntity(entity) {
  185. const parentFolderId = getSelectedParentFolderId(
  186. fileTreeData,
  187. selectedEntityIds
  188. )
  189. // check for duplicates and throw
  190. if (isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
  191. return syncCreateEntity(projectId, parentFolderId, entity)
  192. } else {
  193. return Promise.reject(new DuplicateFilenameError())
  194. }
  195. }
  196. function finishCreatingFolder(name) {
  197. dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
  198. return finishCreatingEntity({ endpoint: 'folder', name })
  199. .then(() => {
  200. dispatch({ type: ACTION_TYPES.CLEAR })
  201. })
  202. .catch(error => {
  203. dispatch({ type: ACTION_TYPES.ERROR, error })
  204. })
  205. }
  206. // bypass React file tree entirely; requesting the Angular new doc or file
  207. // modal instead
  208. function startCreatingDocOrFile() {
  209. const parentFolderId = getSelectedParentFolderId(
  210. fileTreeData,
  211. selectedEntityIds
  212. )
  213. window.dispatchEvent(
  214. new CustomEvent('FileTreeReactBridge.openNewDocModal', {
  215. detail: {
  216. mode: 'doc',
  217. parentFolderId
  218. }
  219. })
  220. )
  221. }
  222. function startUploadingDocOrFile() {
  223. const selectedEntityId = Array.from(selectedEntityIds)[0]
  224. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  225. const parentFolderId =
  226. found.type === 'folder' ? found.entity._id : found.parentFolderId
  227. window.dispatchEvent(
  228. new CustomEvent('FileTreeReactBridge.openNewDocModal', {
  229. detail: {
  230. mode: 'upload',
  231. parentFolderId
  232. }
  233. })
  234. )
  235. }
  236. function finishCreatingDocOrFile(entity) {
  237. return finishCreatingEntity(entity)
  238. .then(() => {
  239. // dispatch FileTreeReactBridge event to update the Angular modal
  240. window.dispatchEvent(
  241. new CustomEvent('FileTreeReactBridge.openNewFileModal', {
  242. detail: {
  243. done: true
  244. }
  245. })
  246. )
  247. })
  248. .catch(error => {
  249. // dispatch FileTreeReactBridge event to update the Angular modal with
  250. // an error
  251. window.dispatchEvent(
  252. new CustomEvent('FileTreeReactBridge.openNewFileModal', {
  253. detail: {
  254. error: true,
  255. data: error.message
  256. }
  257. })
  258. )
  259. })
  260. }
  261. function finishCreatingDoc(entity) {
  262. entity.endpoint = 'doc'
  263. return finishCreatingDocOrFile(entity)
  264. }
  265. function finishCreatingLinkedFile(entity) {
  266. entity.endpoint = 'linked_file'
  267. return finishCreatingDocOrFile(entity)
  268. }
  269. function cancel() {
  270. dispatch({ type: ACTION_TYPES.CANCEL })
  271. }
  272. return {
  273. canDelete: selectedEntityIds.size > 0,
  274. canRename: selectedEntityIds.size === 1,
  275. canCreate: selectedEntityIds.size < 2,
  276. isDeleting,
  277. isRenaming,
  278. isCreatingFolder,
  279. inFlight,
  280. actionedEntities,
  281. error,
  282. startRenaming,
  283. finishRenaming,
  284. startDeleting,
  285. finishDeleting,
  286. finishMoving,
  287. startCreatingFolder,
  288. finishCreatingFolder,
  289. startCreatingDocOrFile,
  290. startUploadingDocOrFile,
  291. finishCreatingDoc,
  292. finishCreatingLinkedFile,
  293. cancel
  294. }
  295. }
  296. function getSelectedParentFolderId(fileTreeData, selectedEntityIds) {
  297. // we expect only one entity to be selected in that case, so we pick the first
  298. const selectedEntityId = Array.from(selectedEntityIds)[0]
  299. if (!selectedEntityId) {
  300. // in some cases no entities are selected. Return the root folder id then.
  301. return fileTreeData._id
  302. }
  303. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  304. return found.type === 'folder' ? found.entity._id : found.parentFolderId
  305. }