file-tree-actionable.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import React, {
  2. createContext,
  3. useCallback,
  4. useMemo,
  5. useReducer,
  6. useContext,
  7. } from 'react'
  8. import PropTypes from 'prop-types'
  9. import { mapSeries } from '../../../infrastructure/promise'
  10. import {
  11. syncRename,
  12. syncDelete,
  13. syncMove,
  14. syncCreateEntity,
  15. } from '../util/sync-mutation'
  16. import { findInTreeOrThrow } from '../util/find-in-tree'
  17. import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
  18. import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
  19. import { useFileTreeMainContext } from './file-tree-main'
  20. import { useFileTreeMutable } from './file-tree-mutable'
  21. import { useFileTreeSelectable } from './file-tree-selectable'
  22. import {
  23. InvalidFilenameError,
  24. BlockedFilenameError,
  25. DuplicateFilenameError,
  26. DuplicateFilenameMoveError,
  27. } from '../errors'
  28. const FileTreeActionableContext = createContext()
  29. const ACTION_TYPES = {
  30. START_RENAME: 'START_RENAME',
  31. START_DELETE: 'START_DELETE',
  32. DELETING: 'DELETING',
  33. START_CREATE_FILE: 'START_CREATE_FILE',
  34. START_CREATE_FOLDER: 'START_CREATE_FOLDER',
  35. CREATING_FILE: 'CREATING_FILE',
  36. CREATING_FOLDER: 'CREATING_FOLDER',
  37. MOVING: 'MOVING',
  38. CANCEL: 'CANCEL',
  39. CLEAR: 'CLEAR',
  40. ERROR: 'ERROR',
  41. }
  42. const defaultState = {
  43. isDeleting: false,
  44. isRenaming: false,
  45. isCreatingFile: false,
  46. isCreatingFolder: false,
  47. isMoving: false,
  48. inFlight: false,
  49. actionedEntities: null,
  50. newFileCreateMode: null,
  51. error: null,
  52. }
  53. function fileTreeActionableReadOnlyReducer(state) {
  54. return state
  55. }
  56. function fileTreeActionableReducer(state, action) {
  57. switch (action.type) {
  58. case ACTION_TYPES.START_RENAME:
  59. return { ...defaultState, isRenaming: true }
  60. case ACTION_TYPES.START_DELETE:
  61. return {
  62. ...defaultState,
  63. isDeleting: true,
  64. actionedEntities: action.actionedEntities,
  65. }
  66. case ACTION_TYPES.START_CREATE_FILE:
  67. return {
  68. ...defaultState,
  69. isCreatingFile: true,
  70. newFileCreateMode: action.newFileCreateMode,
  71. }
  72. case ACTION_TYPES.START_CREATE_FOLDER:
  73. return { ...defaultState, isCreatingFolder: true }
  74. case ACTION_TYPES.CREATING_FILE:
  75. return {
  76. ...defaultState,
  77. isCreatingFile: true,
  78. newFileCreateMode: state.newFileCreateMode,
  79. inFlight: true,
  80. }
  81. case ACTION_TYPES.CREATING_FOLDER:
  82. return { ...defaultState, isCreatingFolder: true, inFlight: true }
  83. case ACTION_TYPES.DELETING:
  84. // keep `actionedEntities` so the entities list remains displayed in the
  85. // delete modal
  86. return {
  87. ...defaultState,
  88. isDeleting: true,
  89. inFlight: true,
  90. actionedEntities: state.actionedEntities,
  91. }
  92. case ACTION_TYPES.MOVING:
  93. return {
  94. ...defaultState,
  95. isMoving: true,
  96. inFlight: true,
  97. }
  98. case ACTION_TYPES.CLEAR:
  99. return { ...defaultState }
  100. case ACTION_TYPES.CANCEL:
  101. if (state.inFlight) return state
  102. return { ...defaultState }
  103. case ACTION_TYPES.ERROR:
  104. return { ...state, inFlight: false, error: action.error }
  105. default:
  106. throw new Error(`Unknown user action type: ${action.type}`)
  107. }
  108. }
  109. export function FileTreeActionableProvider({ hasWritePermissions, children }) {
  110. const [state, dispatch] = useReducer(
  111. hasWritePermissions
  112. ? fileTreeActionableReducer
  113. : fileTreeActionableReadOnlyReducer,
  114. defaultState
  115. )
  116. const { projectId } = useFileTreeMainContext()
  117. const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
  118. const { selectedEntityIds } = useFileTreeSelectable()
  119. const startRenaming = useCallback(() => {
  120. dispatch({ type: ACTION_TYPES.START_RENAME })
  121. }, [])
  122. // update the entity with the new name immediately in the tree, but revert to
  123. // the old name if the sync fails
  124. const finishRenaming = useCallback(
  125. newName => {
  126. const selectedEntityId = Array.from(selectedEntityIds)[0]
  127. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  128. const oldName = found.entity.name
  129. if (newName === oldName) {
  130. return dispatch({ type: ACTION_TYPES.CLEAR })
  131. }
  132. const error = validateRename(fileTreeData, found, newName)
  133. if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
  134. dispatch({ type: ACTION_TYPES.CLEAR })
  135. dispatchRename(selectedEntityId, newName)
  136. return syncRename(projectId, found.type, found.entity._id, newName).catch(
  137. error => {
  138. dispatchRename(selectedEntityId, oldName)
  139. // The state from this error action isn't used anywhere right now
  140. // but we need to handle the error for linting
  141. dispatch({ type: ACTION_TYPES.ERROR, error })
  142. }
  143. )
  144. },
  145. [dispatchRename, fileTreeData, projectId, selectedEntityIds]
  146. )
  147. const isDuplicate = useCallback(
  148. (parentFolderId, name) => {
  149. return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
  150. },
  151. [fileTreeData]
  152. )
  153. // init deletion flow (this will open the delete modal).
  154. // A copy of the selected entities is set as `actionedEntities` so it is kept
  155. // unchanged as the entities are deleted and the selection is updated
  156. const startDeleting = useCallback(() => {
  157. const actionedEntities = Array.from(selectedEntityIds).map(
  158. entityId => findInTreeOrThrow(fileTreeData, entityId).entity
  159. )
  160. dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
  161. }, [fileTreeData, selectedEntityIds])
  162. // deletes entities in series. Tree will be updated via the socket event
  163. const finishDeleting = useCallback(() => {
  164. dispatch({ type: ACTION_TYPES.DELETING })
  165. return mapSeries(Array.from(selectedEntityIds), id => {
  166. const found = findInTreeOrThrow(fileTreeData, id)
  167. return syncDelete(projectId, found.type, found.entity._id).catch(
  168. error => {
  169. // throw unless 404
  170. if (error.info.statusCode !== 404) {
  171. throw error
  172. }
  173. }
  174. )
  175. })
  176. .then(() => {
  177. dispatch({ type: ACTION_TYPES.CLEAR })
  178. })
  179. .catch(error => {
  180. // set an error and allow user to retry
  181. dispatch({ type: ACTION_TYPES.ERROR, error })
  182. })
  183. }, [fileTreeData, projectId, selectedEntityIds])
  184. // moves entities. Tree is updated immediately and data are sync'd after.
  185. const finishMoving = useCallback(
  186. (toFolderId, draggedEntityIds) => {
  187. dispatch({ type: ACTION_TYPES.MOVING })
  188. // find entities and filter out no-ops
  189. const founds = Array.from(draggedEntityIds)
  190. .map(draggedEntityId =>
  191. findInTreeOrThrow(fileTreeData, draggedEntityId)
  192. )
  193. .filter(found => found.parentFolderId !== toFolderId)
  194. // make sure all entities can be moved, return early otherwise
  195. const isMoveToRoot = toFolderId === fileTreeData._id
  196. const validationError = founds
  197. .map(found =>
  198. validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
  199. )
  200. .find(error => error)
  201. if (validationError) {
  202. return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
  203. }
  204. // dispatch moves immediately
  205. founds.forEach(found => dispatchMove(found.entity._id, toFolderId))
  206. // sync dispatched moves after
  207. return mapSeries(founds, found =>
  208. syncMove(projectId, found.type, found.entity._id, toFolderId)
  209. )
  210. .then(() => {
  211. dispatch({ type: ACTION_TYPES.CLEAR })
  212. })
  213. .catch(error => {
  214. dispatch({ type: ACTION_TYPES.ERROR, error })
  215. })
  216. },
  217. [dispatchMove, fileTreeData, projectId]
  218. )
  219. const startCreatingFolder = useCallback(() => {
  220. dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
  221. }, [])
  222. const parentFolderId = useMemo(
  223. () => getSelectedParentFolderId(fileTreeData, selectedEntityIds),
  224. [fileTreeData, selectedEntityIds]
  225. )
  226. const finishCreatingEntity = useCallback(
  227. entity => {
  228. const error = validateCreate(fileTreeData, parentFolderId, entity)
  229. if (error) {
  230. return Promise.reject(error)
  231. }
  232. return syncCreateEntity(projectId, parentFolderId, entity)
  233. },
  234. [fileTreeData, parentFolderId, projectId]
  235. )
  236. const finishCreatingFolder = useCallback(
  237. name => {
  238. dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
  239. return finishCreatingEntity({ endpoint: 'folder', name })
  240. .then(() => {
  241. dispatch({ type: ACTION_TYPES.CLEAR })
  242. })
  243. .catch(error => {
  244. dispatch({ type: ACTION_TYPES.ERROR, error })
  245. })
  246. },
  247. [finishCreatingEntity]
  248. )
  249. const startCreatingFile = useCallback(newFileCreateMode => {
  250. dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
  251. }, [])
  252. const startCreatingDocOrFile = useCallback(() => {
  253. startCreatingFile('doc')
  254. }, [startCreatingFile])
  255. const startUploadingDocOrFile = useCallback(() => {
  256. startCreatingFile('upload')
  257. }, [startCreatingFile])
  258. const finishCreatingDocOrFile = useCallback(
  259. entity => {
  260. dispatch({ type: ACTION_TYPES.CREATING_FILE })
  261. return finishCreatingEntity(entity)
  262. .then(() => {
  263. dispatch({ type: ACTION_TYPES.CLEAR })
  264. })
  265. .catch(error => {
  266. dispatch({ type: ACTION_TYPES.ERROR, error })
  267. })
  268. },
  269. [finishCreatingEntity]
  270. )
  271. const finishCreatingDoc = useCallback(
  272. entity => {
  273. entity.endpoint = 'doc'
  274. return finishCreatingDocOrFile(entity)
  275. },
  276. [finishCreatingDocOrFile]
  277. )
  278. const finishCreatingLinkedFile = useCallback(
  279. entity => {
  280. entity.endpoint = 'linked_file'
  281. return finishCreatingDocOrFile(entity)
  282. },
  283. [finishCreatingDocOrFile]
  284. )
  285. const cancel = useCallback(() => {
  286. dispatch({ type: ACTION_TYPES.CANCEL })
  287. }, [])
  288. const value = {
  289. canDelete: selectedEntityIds.size > 0,
  290. canRename: selectedEntityIds.size === 1,
  291. canCreate: selectedEntityIds.size < 2,
  292. ...state,
  293. parentFolderId,
  294. isDuplicate,
  295. startRenaming,
  296. finishRenaming,
  297. startDeleting,
  298. finishDeleting,
  299. finishMoving,
  300. startCreatingFile,
  301. startCreatingFolder,
  302. finishCreatingFolder,
  303. startCreatingDocOrFile,
  304. startUploadingDocOrFile,
  305. finishCreatingDoc,
  306. finishCreatingLinkedFile,
  307. cancel,
  308. }
  309. return (
  310. <FileTreeActionableContext.Provider value={value}>
  311. {children}
  312. </FileTreeActionableContext.Provider>
  313. )
  314. }
  315. FileTreeActionableProvider.propTypes = {
  316. hasWritePermissions: PropTypes.bool.isRequired,
  317. children: PropTypes.oneOfType([
  318. PropTypes.arrayOf(PropTypes.node),
  319. PropTypes.node,
  320. ]).isRequired,
  321. }
  322. export function useFileTreeActionable() {
  323. const context = useContext(FileTreeActionableContext)
  324. if (!context) {
  325. throw new Error(
  326. 'useFileTreeActionable is only available inside FileTreeActionableProvider'
  327. )
  328. }
  329. return context
  330. }
  331. function getSelectedParentFolderId(fileTreeData, selectedEntityIds) {
  332. // we expect only one entity to be selected in that case, so we pick the first
  333. const selectedEntityId = Array.from(selectedEntityIds)[0]
  334. if (!selectedEntityId) {
  335. // in some cases no entities are selected. Return the root folder id then.
  336. return fileTreeData._id
  337. }
  338. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  339. return found.type === 'folder' ? found.entity._id : found.parentFolderId
  340. }
  341. function validateCreate(fileTreeData, parentFolderId, entity) {
  342. if (!isCleanFilename(entity.name)) {
  343. return new InvalidFilenameError()
  344. }
  345. if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
  346. return new DuplicateFilenameError()
  347. }
  348. // check that the name of a file is allowed, if creating in the root folder
  349. const isMoveToRoot = parentFolderId === fileTreeData._id
  350. const isFolder = entity.endpoint === 'folder'
  351. if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
  352. return new BlockedFilenameError()
  353. }
  354. }
  355. function validateRename(fileTreeData, found, newName) {
  356. if (!isCleanFilename(newName)) {
  357. return new InvalidFilenameError()
  358. }
  359. if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
  360. return new DuplicateFilenameError()
  361. }
  362. const isTopLevel = found.path.length === 1
  363. const isFolder = found.type === 'folder'
  364. if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
  365. return new BlockedFilenameError()
  366. }
  367. }
  368. function validateMove(fileTreeData, toFolderId, found, isMoveToRoot) {
  369. if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
  370. const error = new DuplicateFilenameMoveError()
  371. error.entityName = found.entity.name
  372. return error
  373. }
  374. const isFolder = found.type === 'folder'
  375. if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
  376. return new BlockedFilenameError()
  377. }
  378. }