file-tree-actionable.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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. return (
  117. <FileTreeActionableContext.Provider value={{ ...state, dispatch }}>
  118. {children}
  119. </FileTreeActionableContext.Provider>
  120. )
  121. }
  122. FileTreeActionableProvider.propTypes = {
  123. hasWritePermissions: PropTypes.bool.isRequired,
  124. children: PropTypes.oneOfType([
  125. PropTypes.arrayOf(PropTypes.node),
  126. PropTypes.node
  127. ]).isRequired
  128. }
  129. export function useFileTreeActionable() {
  130. const {
  131. isDeleting,
  132. isRenaming,
  133. isMoving,
  134. isCreatingFile,
  135. isCreatingFolder,
  136. inFlight,
  137. error,
  138. actionedEntities,
  139. newFileCreateMode,
  140. dispatch
  141. } = useContext(FileTreeActionableContext)
  142. const { projectId } = useFileTreeMainContext()
  143. const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable()
  144. const { selectedEntityIds } = useFileTreeSelectable()
  145. const startRenaming = useCallback(() => {
  146. dispatch({ type: ACTION_TYPES.START_RENAME })
  147. }, [dispatch])
  148. // update the entity with the new name immediately in the tree, but revert to
  149. // the old name if the sync fails
  150. const finishRenaming = useCallback(
  151. newName => {
  152. const selectedEntityId = Array.from(selectedEntityIds)[0]
  153. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  154. const oldName = found.entity.name
  155. if (newName === oldName) {
  156. return dispatch({ type: ACTION_TYPES.CLEAR })
  157. }
  158. const error = validateRename(fileTreeData, found, newName)
  159. if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
  160. dispatch({ type: ACTION_TYPES.CLEAR })
  161. dispatchRename(selectedEntityId, newName)
  162. return syncRename(projectId, found.type, found.entity._id, newName).catch(
  163. error => {
  164. dispatchRename(selectedEntityId, oldName)
  165. // The state from this error action isn't used anywhere right now
  166. // but we need to handle the error for linting
  167. dispatch({ type: ACTION_TYPES.ERROR, error })
  168. }
  169. )
  170. },
  171. [dispatch, dispatchRename, fileTreeData, projectId, selectedEntityIds]
  172. )
  173. const isDuplicate = useCallback(
  174. (parentFolderId, name) => {
  175. return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
  176. },
  177. [fileTreeData]
  178. )
  179. // init deletion flow (this will open the delete modal).
  180. // A copy of the selected entities is set as `actionedEntities` so it is kept
  181. // unchanged as the entities are deleted and the selection is updated
  182. const startDeleting = useCallback(() => {
  183. const actionedEntities = Array.from(selectedEntityIds).map(
  184. entityId => findInTreeOrThrow(fileTreeData, entityId).entity
  185. )
  186. dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
  187. }, [dispatch, fileTreeData, selectedEntityIds])
  188. // deletes entities in serie. Tree will be updated via the socket event
  189. const finishDeleting = useCallback(() => {
  190. dispatch({ type: ACTION_TYPES.DELETING })
  191. return mapSeries(Array.from(selectedEntityIds), id => {
  192. const found = findInTreeOrThrow(fileTreeData, id)
  193. return syncDelete(projectId, found.type, found.entity._id).catch(
  194. error => {
  195. // throw unless 404
  196. if (error.info.statusCode !== 404) {
  197. throw error
  198. }
  199. }
  200. )
  201. })
  202. .then(() => {
  203. dispatch({ type: ACTION_TYPES.CLEAR })
  204. })
  205. .catch(error => {
  206. // set an error and allow user to retry
  207. dispatch({ type: ACTION_TYPES.ERROR, error })
  208. })
  209. }, [dispatch, fileTreeData, projectId, selectedEntityIds])
  210. // moves entities. Tree is updated immediately and data are sync'd after.
  211. const finishMoving = useCallback(
  212. (toFolderId, draggedEntityIds) => {
  213. dispatch({ type: ACTION_TYPES.MOVING })
  214. // find entities and filter out no-ops
  215. const founds = Array.from(draggedEntityIds)
  216. .map(draggedEntityId =>
  217. findInTreeOrThrow(fileTreeData, draggedEntityId)
  218. )
  219. .filter(found => found.parentFolderId !== toFolderId)
  220. // make sure all entities can be moved, return early otherwise
  221. const isMoveToRoot = toFolderId === fileTreeData._id
  222. const validationError = founds
  223. .map(found =>
  224. validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
  225. )
  226. .find(error => error)
  227. if (validationError) {
  228. return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
  229. }
  230. // dispatch moves immediately
  231. founds.forEach(found => dispatchMove(found.entity._id, toFolderId))
  232. // sync dispatched moves after
  233. return mapSeries(founds, found =>
  234. syncMove(projectId, found.type, found.entity._id, toFolderId)
  235. )
  236. .then(() => {
  237. dispatch({ type: ACTION_TYPES.CLEAR })
  238. })
  239. .catch(error => {
  240. dispatch({ type: ACTION_TYPES.ERROR, error })
  241. })
  242. },
  243. [dispatch, dispatchMove, fileTreeData, projectId]
  244. )
  245. const startCreatingFolder = useCallback(() => {
  246. dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
  247. }, [dispatch])
  248. const finishCreatingEntity = useCallback(
  249. entity => {
  250. const parentFolderId = getSelectedParentFolderId(
  251. fileTreeData,
  252. selectedEntityIds
  253. )
  254. const error = validateCreate(fileTreeData, parentFolderId, entity)
  255. if (error) {
  256. return Promise.reject(error)
  257. }
  258. return syncCreateEntity(projectId, parentFolderId, entity)
  259. },
  260. [fileTreeData, projectId, selectedEntityIds]
  261. )
  262. const finishCreatingFolder = useCallback(
  263. name => {
  264. dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
  265. return finishCreatingEntity({ endpoint: 'folder', name })
  266. .then(() => {
  267. dispatch({ type: ACTION_TYPES.CLEAR })
  268. })
  269. .catch(error => {
  270. dispatch({ type: ACTION_TYPES.ERROR, error })
  271. })
  272. },
  273. [dispatch, finishCreatingEntity]
  274. )
  275. const startCreatingFile = useCallback(
  276. newFileCreateMode => {
  277. dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
  278. },
  279. [dispatch]
  280. )
  281. const startCreatingDocOrFile = useCallback(() => {
  282. if (window.showReactAddFilesModal) {
  283. startCreatingFile('doc')
  284. } else {
  285. const parentFolderId = getSelectedParentFolderId(
  286. fileTreeData,
  287. selectedEntityIds
  288. )
  289. window.dispatchEvent(
  290. new CustomEvent('FileTreeReactBridge.openNewDocModal', {
  291. detail: {
  292. mode: 'doc',
  293. parentFolderId
  294. }
  295. })
  296. )
  297. }
  298. }, [fileTreeData, selectedEntityIds, startCreatingFile])
  299. const startUploadingDocOrFile = useCallback(() => {
  300. if (window.showReactAddFilesModal) {
  301. startCreatingFile('upload')
  302. } else {
  303. const parentFolderId = getSelectedParentFolderId(
  304. fileTreeData,
  305. selectedEntityIds
  306. )
  307. window.dispatchEvent(
  308. new CustomEvent('FileTreeReactBridge.openNewDocModal', {
  309. detail: {
  310. mode: 'upload',
  311. parentFolderId
  312. }
  313. })
  314. )
  315. }
  316. }, [fileTreeData, selectedEntityIds, startCreatingFile])
  317. const finishCreatingDocOrFile = useCallback(
  318. entity => {
  319. dispatch({ type: ACTION_TYPES.CREATING_FILE })
  320. return finishCreatingEntity(entity)
  321. .then(() => {
  322. if (window.showReactAddFilesModal) {
  323. dispatch({ type: ACTION_TYPES.CLEAR })
  324. } else {
  325. // dispatch FileTreeReactBridge event to update the Angular modal
  326. window.dispatchEvent(
  327. new CustomEvent('FileTreeReactBridge.openNewFileModal', {
  328. detail: {
  329. done: true
  330. }
  331. })
  332. )
  333. }
  334. })
  335. .catch(error => {
  336. if (window.showReactAddFilesModal) {
  337. dispatch({ type: ACTION_TYPES.ERROR, error })
  338. } else {
  339. // dispatch FileTreeReactBridge event to update the Angular modal with
  340. // an error
  341. window.dispatchEvent(
  342. new CustomEvent('FileTreeReactBridge.openNewFileModal', {
  343. detail: {
  344. error: true,
  345. data: error.message
  346. }
  347. })
  348. )
  349. }
  350. })
  351. },
  352. [dispatch, finishCreatingEntity]
  353. )
  354. const finishCreatingDoc = useCallback(
  355. entity => {
  356. entity.endpoint = 'doc'
  357. return finishCreatingDocOrFile(entity)
  358. },
  359. [finishCreatingDocOrFile]
  360. )
  361. const finishCreatingLinkedFile = useCallback(
  362. entity => {
  363. entity.endpoint = 'linked_file'
  364. return finishCreatingDocOrFile(entity)
  365. },
  366. [finishCreatingDocOrFile]
  367. )
  368. const cancel = useCallback(() => {
  369. dispatch({ type: ACTION_TYPES.CANCEL })
  370. }, [dispatch])
  371. const parentFolderId = useMemo(
  372. () => getSelectedParentFolderId(fileTreeData, selectedEntityIds),
  373. [fileTreeData, selectedEntityIds]
  374. )
  375. return {
  376. canDelete: selectedEntityIds.size > 0,
  377. canRename: selectedEntityIds.size === 1,
  378. canCreate: selectedEntityIds.size < 2,
  379. isDeleting,
  380. isMoving,
  381. isRenaming,
  382. isCreatingFile,
  383. isCreatingFolder,
  384. inFlight,
  385. actionedEntities,
  386. error,
  387. parentFolderId,
  388. isDuplicate,
  389. newFileCreateMode,
  390. startRenaming,
  391. finishRenaming,
  392. startDeleting,
  393. finishDeleting,
  394. finishMoving,
  395. startCreatingFile,
  396. startCreatingFolder,
  397. finishCreatingFolder,
  398. startCreatingDocOrFile,
  399. startUploadingDocOrFile,
  400. finishCreatingDoc,
  401. finishCreatingLinkedFile,
  402. cancel
  403. }
  404. }
  405. function getSelectedParentFolderId(fileTreeData, selectedEntityIds) {
  406. // we expect only one entity to be selected in that case, so we pick the first
  407. const selectedEntityId = Array.from(selectedEntityIds)[0]
  408. if (!selectedEntityId) {
  409. // in some cases no entities are selected. Return the root folder id then.
  410. return fileTreeData._id
  411. }
  412. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  413. return found.type === 'folder' ? found.entity._id : found.parentFolderId
  414. }
  415. function validateCreate(fileTreeData, parentFolderId, entity) {
  416. if (!isCleanFilename(entity.name)) {
  417. return new InvalidFilenameError()
  418. }
  419. if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
  420. return new DuplicateFilenameError()
  421. }
  422. // check that the name of a file is allowed, if creating in the root folder
  423. const isMoveToRoot = parentFolderId === fileTreeData._id
  424. const isFolder = entity.endpoint === 'folder'
  425. if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
  426. return new BlockedFilenameError()
  427. }
  428. }
  429. function validateRename(fileTreeData, found, newName) {
  430. if (!isCleanFilename(newName)) {
  431. return new InvalidFilenameError()
  432. }
  433. if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
  434. return new DuplicateFilenameError()
  435. }
  436. const isTopLevel = found.path.length === 1
  437. const isFolder = found.type === 'folder'
  438. if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
  439. return new BlockedFilenameError()
  440. }
  441. }
  442. function validateMove(fileTreeData, toFolderId, found, isMoveToRoot) {
  443. if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
  444. const error = new DuplicateFilenameMoveError()
  445. error.entityName = found.entity.name
  446. return error
  447. }
  448. const isFolder = found.type === 'folder'
  449. if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
  450. return new BlockedFilenameError()
  451. }
  452. }