file-tree-actionable.js 11 KB

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