file-tree-modal-create-file.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import { expect } from 'chai'
  2. import * as sinon from 'sinon'
  3. import { useEffect } from 'react'
  4. import {
  5. screen,
  6. render,
  7. fireEvent,
  8. cleanup,
  9. waitFor,
  10. } from '@testing-library/react'
  11. import fetchMock from 'fetch-mock'
  12. import PropTypes from 'prop-types'
  13. import { contextProps } from './context-props'
  14. import FileTreeModalCreateFile from '../../../../../../frontend/js/features/file-tree/components/modals/file-tree-modal-create-file'
  15. import FileTreeContext from '../../../../../../frontend/js/features/file-tree/components/file-tree-context'
  16. import { useFileTreeActionable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-actionable'
  17. import { useFileTreeMutable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-mutable'
  18. describe('<FileTreeModalCreateFile/>', function () {
  19. afterEach(function () {
  20. fetchMock.restore()
  21. cleanup()
  22. })
  23. it('handles invalid file names', async function () {
  24. render(
  25. <FileTreeContext {...contextProps}>
  26. <OpenWithMode mode="doc" />
  27. </FileTreeContext>
  28. )
  29. const submitButton = screen.getByRole('button', { name: 'Create' })
  30. const input = screen.getByLabelText('File Name')
  31. expect(input.value).to.equal('name.tex')
  32. expect(submitButton.disabled).to.be.false
  33. expect(screen.queryAllByRole('alert')).to.be.empty
  34. fireEvent.change(input, { target: { value: '' } })
  35. expect(submitButton.disabled).to.be.true
  36. screen.getByRole(
  37. (role, element) =>
  38. role === 'alert' && element.textContent.match(/File name is empty/)
  39. )
  40. await fireEvent.change(input, { target: { value: 'test.tex' } })
  41. expect(submitButton.disabled).to.be.false
  42. expect(screen.queryAllByRole('alert')).to.be.empty
  43. await fireEvent.change(input, { target: { value: 'oops/i/did/it/again' } })
  44. expect(submitButton.disabled).to.be.true
  45. screen.getByRole(
  46. (role, element) =>
  47. role === 'alert' &&
  48. element.textContent.match(/contains invalid characters/)
  49. )
  50. })
  51. it('displays an error when the file limit is reached', async function () {
  52. const rootFolder = [
  53. {
  54. docs: Array.from({ length: 10 }, (_, index) => ({
  55. _id: `entity-${index}`,
  56. })),
  57. fileRefs: [],
  58. folders: [],
  59. },
  60. ]
  61. render(
  62. <FileTreeContext
  63. {...contextProps}
  64. rootFolder={rootFolder}
  65. initialSelectedEntityId="entity-1"
  66. >
  67. <OpenWithMode mode="doc" />
  68. </FileTreeContext>
  69. )
  70. screen.getByRole(
  71. (role, element) =>
  72. role === 'alert' &&
  73. element.textContent.match(/This project has reached the \d+ file limit/)
  74. )
  75. })
  76. it('displays a warning when the file limit is nearly reached', async function () {
  77. const rootFolder = [
  78. {
  79. docs: Array.from({ length: 9 }, (_, index) => ({
  80. _id: `entity-${index}`,
  81. })),
  82. fileRefs: [],
  83. folders: [],
  84. },
  85. ]
  86. render(
  87. <FileTreeContext
  88. {...contextProps}
  89. rootFolder={rootFolder}
  90. initialSelectedEntityId="entity-1"
  91. >
  92. <OpenWithMode mode="doc" />
  93. </FileTreeContext>
  94. )
  95. screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
  96. })
  97. it('counts files in nested folders', async function () {
  98. const rootFolder = [
  99. {
  100. docs: [{ _id: 'entity-1' }],
  101. fileRefs: [],
  102. folders: [
  103. {
  104. docs: [{ _id: 'entity-1-2' }],
  105. fileRefs: [],
  106. folders: [
  107. {
  108. docs: [
  109. { _id: 'entity-3' },
  110. { _id: 'entity-4' },
  111. { _id: 'entity-5' },
  112. { _id: 'entity-6' },
  113. { _id: 'entity-7' },
  114. { _id: 'entity-8' },
  115. { _id: 'entity-9' },
  116. ],
  117. fileRefs: [],
  118. folders: [],
  119. },
  120. ],
  121. },
  122. ],
  123. },
  124. ]
  125. render(
  126. <FileTreeContext
  127. {...contextProps}
  128. rootFolder={rootFolder}
  129. initialSelectedEntityId="entity-1"
  130. >
  131. <OpenWithMode mode="doc" />
  132. </FileTreeContext>
  133. )
  134. screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
  135. })
  136. it('creates a new file when the form is submitted', async function () {
  137. fetchMock.post('express:/project/:projectId/doc', () => 204)
  138. render(
  139. <FileTreeContext {...contextProps}>
  140. <OpenWithMode mode="doc" />
  141. </FileTreeContext>
  142. )
  143. const input = screen.getByLabelText('File Name')
  144. await fireEvent.change(input, { target: { value: 'test.tex' } })
  145. const submitButton = screen.getByRole('button', { name: 'Create' })
  146. await fireEvent.click(submitButton)
  147. expect(
  148. fetchMock.called('express:/project/:projectId/doc', {
  149. body: { name: 'test.tex' },
  150. })
  151. ).to.be.true
  152. })
  153. it('imports a new file from a project', async function () {
  154. fetchMock
  155. .get('path:/user/projects', {
  156. projects: [
  157. {
  158. _id: 'test-project',
  159. name: 'This Project',
  160. },
  161. {
  162. _id: 'project-1',
  163. name: 'Project One',
  164. },
  165. {
  166. _id: 'project-2',
  167. name: 'Project Two',
  168. },
  169. ],
  170. })
  171. .get('express:/project/:projectId/entities', {
  172. entities: [
  173. {
  174. path: '/foo.tex',
  175. },
  176. {
  177. path: '/bar.tex',
  178. },
  179. ],
  180. })
  181. .post('express:/project/:projectId/compile', {
  182. status: 'success',
  183. outputFiles: [
  184. {
  185. build: 'test',
  186. path: 'baz.jpg',
  187. },
  188. {
  189. build: 'test',
  190. path: 'ball.jpg',
  191. },
  192. ],
  193. })
  194. .post('express:/project/:projectId/linked_file', () => 204)
  195. render(
  196. <FileTreeContext {...contextProps}>
  197. <OpenWithMode mode="project" />
  198. </FileTreeContext>
  199. )
  200. // initial state, no project selected
  201. const projectInput = screen.getByLabelText('Select a Project')
  202. expect(projectInput.disabled).to.be.true
  203. await waitFor(() => {
  204. expect(projectInput.disabled).to.be.false
  205. })
  206. // the submit button should be disabled
  207. const submitButton = screen.getByRole('button', { name: 'Create' })
  208. expect(submitButton.disabled).to.be.true
  209. // the source file selector should be disabled
  210. const fileInput = screen.getByLabelText('Select a File')
  211. expect(fileInput.disabled).to.be.true
  212. // TODO: check for options length, excluding current project
  213. // select a project
  214. await fireEvent.change(projectInput, { target: { value: 'project-2' } }) // TODO: getByRole('option')?
  215. // wait for the source file selector to be enabled
  216. await waitFor(() => {
  217. expect(fileInput.disabled).to.be.false
  218. })
  219. expect(screen.queryByLabelText('Select a File')).not.to.be.null
  220. expect(screen.queryByLabelText('Select an Output File')).to.be.null
  221. expect(submitButton.disabled).to.be.true
  222. // TODO: check for fileInput options length, excluding current project
  223. // click on the button to toggle between source and output files
  224. const sourceTypeButton = screen.getByRole('button', {
  225. name: 'select from output files',
  226. })
  227. await fireEvent.click(sourceTypeButton)
  228. // wait for the output file selector to be enabled
  229. const entityInput = screen.getByLabelText('Select an Output File')
  230. await waitFor(() => {
  231. expect(entityInput.disabled).to.be.false
  232. })
  233. expect(screen.queryByLabelText('Select a File')).to.be.null
  234. expect(screen.queryByLabelText('Select an Output File')).not.to.be.null
  235. expect(submitButton.disabled).to.be.true
  236. // TODO: check for entityInput options length, excluding current project
  237. await fireEvent.change(entityInput, { target: { value: 'ball.jpg' } }) // TODO: getByRole('option')?
  238. await waitFor(() => {
  239. expect(submitButton.disabled).to.be.false
  240. })
  241. await fireEvent.click(submitButton)
  242. expect(
  243. fetchMock.called('express:/project/:projectId/linked_file', {
  244. body: {
  245. name: 'ball.jpg',
  246. provider: 'project_output_file',
  247. data: {
  248. source_project_id: 'project-2',
  249. source_output_file_path: 'ball.jpg',
  250. build_id: 'test',
  251. },
  252. },
  253. })
  254. ).to.be.true
  255. })
  256. it('import from a URL when the form is submitted', async function () {
  257. fetchMock.post('express:/project/:projectId/linked_file', () => 204)
  258. render(
  259. <FileTreeContext {...contextProps}>
  260. <OpenWithMode mode="url" />
  261. </FileTreeContext>
  262. )
  263. const urlInput = screen.getByLabelText('URL to fetch the file from')
  264. const nameInput = screen.getByLabelText('File Name In This Project')
  265. await fireEvent.change(urlInput, {
  266. target: { value: 'https://example.com/example.tex' },
  267. })
  268. // check that the name has updated automatically
  269. expect(nameInput.value).to.equal('example.tex')
  270. await fireEvent.change(nameInput, {
  271. target: { value: 'test.tex' },
  272. })
  273. // check that the name can still be edited manually
  274. expect(nameInput.value).to.equal('test.tex')
  275. const submitButton = screen.getByRole('button', { name: 'Create' })
  276. await fireEvent.click(submitButton)
  277. expect(
  278. fetchMock.called('express:/project/:projectId/linked_file', {
  279. body: {
  280. name: 'test.tex',
  281. provider: 'url',
  282. data: { url: 'https://example.com/example.tex' },
  283. },
  284. })
  285. ).to.be.true
  286. })
  287. it('uploads a dropped file', async function () {
  288. const xhr = sinon.useFakeXMLHttpRequest()
  289. const requests = []
  290. xhr.onCreate = request => {
  291. requests.push(request)
  292. }
  293. render(
  294. <FileTreeContext {...contextProps}>
  295. <OpenWithMode mode="upload" />
  296. </FileTreeContext>
  297. )
  298. // the submit button should not be present
  299. expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
  300. const dropzone = screen.getByLabelText('File Uploader')
  301. expect(dropzone).not.to.be.null
  302. fireEvent.drop(dropzone, {
  303. dataTransfer: {
  304. files: [new File(['test'], 'test.tex', { type: 'text/plain' })],
  305. },
  306. })
  307. await waitFor(() => expect(requests).to.have.length(1))
  308. const [request] = requests
  309. expect(request.url).to.equal('/project/test-project/upload')
  310. expect(request.method).to.equal('POST')
  311. xhr.restore()
  312. })
  313. it('uploads a pasted file', async function () {
  314. const xhr = sinon.useFakeXMLHttpRequest()
  315. const requests = []
  316. xhr.onCreate = request => {
  317. requests.push(request)
  318. }
  319. render(
  320. <FileTreeContext {...contextProps}>
  321. <OpenWithMode mode="upload" />
  322. </FileTreeContext>
  323. )
  324. // the submit button should not be present
  325. expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
  326. const dropzone = screen.getByLabelText('File Uploader')
  327. expect(dropzone).not.to.be.null
  328. fireEvent.paste(dropzone, {
  329. clipboardData: {
  330. files: [new File(['test'], 'test.tex', { type: 'text/plain' })],
  331. },
  332. })
  333. await waitFor(() => expect(requests).to.have.length(1))
  334. const [request] = requests
  335. expect(request.url).to.equal('/project/test-project/upload')
  336. expect(request.method).to.equal('POST')
  337. xhr.restore()
  338. })
  339. })
  340. function OpenWithMode({ mode }) {
  341. const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
  342. const { fileCount } = useFileTreeMutable()
  343. // eslint-disable-next-line react-hooks/exhaustive-deps
  344. useEffect(() => startCreatingFile(mode), [])
  345. if (!fileCount || !newFileCreateMode) {
  346. return null
  347. }
  348. return <FileTreeModalCreateFile />
  349. }
  350. OpenWithMode.propTypes = {
  351. mode: PropTypes.string.isRequired,
  352. }