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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  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. beforeEach(function () {
  20. window.csrfToken = 'token'
  21. })
  22. afterEach(function () {
  23. delete window.csrfToken
  24. fetchMock.restore()
  25. cleanup()
  26. })
  27. it('handles invalid file names', async function () {
  28. render(
  29. <FileTreeContext {...contextProps}>
  30. <OpenWithMode mode="doc" />
  31. </FileTreeContext>
  32. )
  33. const submitButton = screen.getByRole('button', { name: 'Create' })
  34. const input = screen.getByLabelText('File Name')
  35. expect(input.value).to.equal('name.tex')
  36. expect(submitButton.disabled).to.be.false
  37. expect(screen.queryAllByRole('alert')).to.be.empty
  38. fireEvent.change(input, { target: { value: '' } })
  39. expect(submitButton.disabled).to.be.true
  40. screen.getByRole(
  41. (role, element) =>
  42. role === 'alert' && element.textContent.match(/File name is empty/)
  43. )
  44. await fireEvent.change(input, { target: { value: 'test.tex' } })
  45. expect(submitButton.disabled).to.be.false
  46. expect(screen.queryAllByRole('alert')).to.be.empty
  47. await fireEvent.change(input, { target: { value: 'oops/i/did/it/again' } })
  48. expect(submitButton.disabled).to.be.true
  49. screen.getByRole(
  50. (role, element) =>
  51. role === 'alert' &&
  52. element.textContent.match(/contains invalid characters/)
  53. )
  54. })
  55. it('displays an error when the file limit is reached', async function () {
  56. const rootFolder = [
  57. {
  58. docs: Array.from({ length: 10 }, (_, index) => ({
  59. _id: `entity-${index}`,
  60. })),
  61. fileRefs: [],
  62. folders: [],
  63. },
  64. ]
  65. render(
  66. <FileTreeContext
  67. {...contextProps}
  68. rootFolder={rootFolder}
  69. initialSelectedEntityId="entity-1"
  70. >
  71. <OpenWithMode mode="doc" />
  72. </FileTreeContext>
  73. )
  74. screen.getByRole(
  75. (role, element) =>
  76. role === 'alert' &&
  77. element.textContent.match(/This project has reached the \d+ file limit/)
  78. )
  79. })
  80. it('displays a warning when the file limit is nearly reached', async function () {
  81. const rootFolder = [
  82. {
  83. docs: Array.from({ length: 9 }, (_, index) => ({
  84. _id: `entity-${index}`,
  85. })),
  86. fileRefs: [],
  87. folders: [],
  88. },
  89. ]
  90. render(
  91. <FileTreeContext
  92. {...contextProps}
  93. rootFolder={rootFolder}
  94. initialSelectedEntityId="entity-1"
  95. >
  96. <OpenWithMode mode="doc" />
  97. </FileTreeContext>
  98. )
  99. screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
  100. })
  101. it('counts files in nested folders', async function () {
  102. const rootFolder = [
  103. {
  104. docs: [{ _id: 'entity-1' }],
  105. fileRefs: [],
  106. folders: [
  107. {
  108. docs: [{ _id: 'entity-1-2' }],
  109. fileRefs: [],
  110. folders: [
  111. {
  112. docs: [
  113. { _id: 'entity-3' },
  114. { _id: 'entity-4' },
  115. { _id: 'entity-5' },
  116. { _id: 'entity-6' },
  117. { _id: 'entity-7' },
  118. { _id: 'entity-8' },
  119. { _id: 'entity-9' },
  120. ],
  121. fileRefs: [],
  122. folders: [],
  123. },
  124. ],
  125. },
  126. ],
  127. },
  128. ]
  129. render(
  130. <FileTreeContext
  131. {...contextProps}
  132. rootFolder={rootFolder}
  133. initialSelectedEntityId="entity-1"
  134. >
  135. <OpenWithMode mode="doc" />
  136. </FileTreeContext>
  137. )
  138. screen.getByText(/This project is approaching the file limit \(\d+\/\d+\)/)
  139. })
  140. it('creates a new file when the form is submitted', async function () {
  141. fetchMock.post('express:/project/:projectId/doc', () => 204)
  142. render(
  143. <FileTreeContext {...contextProps}>
  144. <OpenWithMode mode="doc" />
  145. </FileTreeContext>
  146. )
  147. const input = screen.getByLabelText('File Name')
  148. await fireEvent.change(input, { target: { value: 'test.tex' } })
  149. const submitButton = screen.getByRole('button', { name: 'Create' })
  150. await fireEvent.click(submitButton)
  151. expect(
  152. fetchMock.called('express:/project/:projectId/doc', {
  153. body: { name: 'test.tex' },
  154. })
  155. ).to.be.true
  156. })
  157. it('imports a new file from a project', async function () {
  158. fetchMock
  159. .get('path:/user/projects', {
  160. projects: [
  161. {
  162. _id: 'test-project',
  163. name: 'This Project',
  164. },
  165. {
  166. _id: 'project-1',
  167. name: 'Project One',
  168. },
  169. {
  170. _id: 'project-2',
  171. name: 'Project Two',
  172. },
  173. ],
  174. })
  175. .get('express:/project/:projectId/entities', {
  176. entities: [
  177. {
  178. path: '/foo.tex',
  179. },
  180. {
  181. path: '/bar.tex',
  182. },
  183. ],
  184. })
  185. .post('express:/project/:projectId/compile', {
  186. status: 'success',
  187. outputFiles: [
  188. {
  189. build: 'test',
  190. path: 'baz.jpg',
  191. },
  192. {
  193. build: 'test',
  194. path: 'ball.jpg',
  195. },
  196. ],
  197. })
  198. .post('express:/project/:projectId/linked_file', () => 204)
  199. render(
  200. <FileTreeContext {...contextProps}>
  201. <OpenWithMode mode="project" />
  202. </FileTreeContext>
  203. )
  204. // initial state, no project selected
  205. const projectInput = screen.getByLabelText('Select a Project')
  206. expect(projectInput.disabled).to.be.true
  207. await waitFor(() => {
  208. expect(projectInput.disabled).to.be.false
  209. })
  210. // the submit button should be disabled
  211. const submitButton = screen.getByRole('button', { name: 'Create' })
  212. expect(submitButton.disabled).to.be.true
  213. // the source file selector should be disabled
  214. const fileInput = screen.getByLabelText('Select a File')
  215. expect(fileInput.disabled).to.be.true
  216. // TODO: check for options length, excluding current project
  217. // select a project
  218. await fireEvent.change(projectInput, { target: { value: 'project-2' } }) // TODO: getByRole('option')?
  219. // wait for the source file selector to be enabled
  220. await waitFor(() => {
  221. expect(fileInput.disabled).to.be.false
  222. })
  223. expect(screen.queryByLabelText('Select a File')).not.to.be.null
  224. expect(screen.queryByLabelText('Select an Output File')).to.be.null
  225. expect(submitButton.disabled).to.be.true
  226. // TODO: check for fileInput options length, excluding current project
  227. // click on the button to toggle between source and output files
  228. const sourceTypeButton = screen.getByRole('button', {
  229. // NOTE: When changing the label, update the other tests with this label as well.
  230. name: 'select from output files',
  231. })
  232. await fireEvent.click(sourceTypeButton)
  233. // wait for the output file selector to be enabled
  234. const entityInput = screen.getByLabelText('Select an Output File')
  235. await waitFor(() => {
  236. expect(entityInput.disabled).to.be.false
  237. })
  238. expect(screen.queryByLabelText('Select a File')).to.be.null
  239. expect(screen.queryByLabelText('Select an Output File')).not.to.be.null
  240. expect(submitButton.disabled).to.be.true
  241. // TODO: check for entityInput options length, excluding current project
  242. await fireEvent.change(entityInput, { target: { value: 'ball.jpg' } }) // TODO: getByRole('option')?
  243. await waitFor(() => {
  244. expect(submitButton.disabled).to.be.false
  245. })
  246. await fireEvent.click(submitButton)
  247. expect(
  248. fetchMock.called('express:/project/:projectId/linked_file', {
  249. body: {
  250. name: 'ball.jpg',
  251. provider: 'project_output_file',
  252. data: {
  253. source_project_id: 'project-2',
  254. source_output_file_path: 'ball.jpg',
  255. build_id: 'test',
  256. },
  257. },
  258. })
  259. ).to.be.true
  260. })
  261. describe('when the output files feature is not available', function () {
  262. const flagBefore = window.ExposedSettings.hasLinkedProjectOutputFileFeature
  263. before(function () {
  264. window.ExposedSettings.hasLinkedProjectOutputFileFeature = false
  265. })
  266. after(function () {
  267. window.ExposedSettings.hasLinkedProjectOutputFileFeature = flagBefore
  268. })
  269. it('should not show the import from output file mode', async function () {
  270. fetchMock.get('path:/user/projects', {
  271. projects: [
  272. {
  273. _id: 'test-project',
  274. name: 'This Project',
  275. },
  276. {
  277. _id: 'project-1',
  278. name: 'Project One',
  279. },
  280. {
  281. _id: 'project-2',
  282. name: 'Project Two',
  283. },
  284. ],
  285. })
  286. render(
  287. <FileTreeContext {...contextProps}>
  288. <OpenWithMode mode="project" />
  289. </FileTreeContext>
  290. )
  291. // should not show the toggle
  292. expect(
  293. screen.queryByRole('button', {
  294. name: 'select from output files',
  295. })
  296. ).to.be.null
  297. })
  298. })
  299. it('import from a URL when the form is submitted', async function () {
  300. fetchMock.post('express:/project/:projectId/linked_file', () => 204)
  301. render(
  302. <FileTreeContext {...contextProps}>
  303. <OpenWithMode mode="url" />
  304. </FileTreeContext>
  305. )
  306. const urlInput = screen.getByLabelText('URL to fetch the file from')
  307. const nameInput = screen.getByLabelText('File Name In This Project')
  308. await fireEvent.change(urlInput, {
  309. target: { value: 'https://example.com/example.tex' },
  310. })
  311. // check that the name has updated automatically
  312. expect(nameInput.value).to.equal('example.tex')
  313. await fireEvent.change(nameInput, {
  314. target: { value: 'test.tex' },
  315. })
  316. // check that the name can still be edited manually
  317. expect(nameInput.value).to.equal('test.tex')
  318. const submitButton = screen.getByRole('button', { name: 'Create' })
  319. await fireEvent.click(submitButton)
  320. expect(
  321. fetchMock.called('express:/project/:projectId/linked_file', {
  322. body: {
  323. name: 'test.tex',
  324. provider: 'url',
  325. data: { url: 'https://example.com/example.tex' },
  326. },
  327. })
  328. ).to.be.true
  329. })
  330. it('uploads a dropped file', async function () {
  331. const xhr = sinon.useFakeXMLHttpRequest()
  332. const requests = []
  333. xhr.onCreate = request => {
  334. requests.push(request)
  335. }
  336. render(
  337. <FileTreeContext {...contextProps}>
  338. <OpenWithMode mode="upload" />
  339. </FileTreeContext>
  340. )
  341. // the submit button should not be present
  342. expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
  343. const dropzone = screen.getByLabelText('File Uploader')
  344. expect(dropzone).not.to.be.null
  345. fireEvent.drop(dropzone, {
  346. dataTransfer: {
  347. files: [new File(['test'], 'test.tex', { type: 'text/plain' })],
  348. },
  349. })
  350. await waitFor(() => expect(requests).to.have.length(1))
  351. const [request] = requests
  352. expect(request.url).to.equal('/project/test-project/upload')
  353. expect(request.method).to.equal('POST')
  354. xhr.restore()
  355. })
  356. it('uploads a pasted file', async function () {
  357. const xhr = sinon.useFakeXMLHttpRequest()
  358. const requests = []
  359. xhr.onCreate = request => {
  360. requests.push(request)
  361. }
  362. render(
  363. <FileTreeContext {...contextProps}>
  364. <OpenWithMode mode="upload" />
  365. </FileTreeContext>
  366. )
  367. // the submit button should not be present
  368. expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
  369. const dropzone = screen.getByLabelText('File Uploader')
  370. expect(dropzone).not.to.be.null
  371. fireEvent.paste(dropzone, {
  372. clipboardData: {
  373. files: [new File(['test'], 'test.tex', { type: 'text/plain' })],
  374. },
  375. })
  376. await waitFor(() => expect(requests).to.have.length(1))
  377. const [request] = requests
  378. expect(request.url).to.equal('/project/test-project/upload')
  379. expect(request.method).to.equal('POST')
  380. xhr.restore()
  381. })
  382. it('displays upload errors', async function () {
  383. const xhr = sinon.useFakeXMLHttpRequest()
  384. const requests = []
  385. xhr.onCreate = request => {
  386. requests.push(request)
  387. }
  388. render(
  389. <FileTreeContext {...contextProps}>
  390. <OpenWithMode mode="upload" />
  391. </FileTreeContext>
  392. )
  393. // the submit button should not be present
  394. expect(screen.queryByRole('button', { name: 'Create' })).to.be.null
  395. const dropzone = screen.getByLabelText('File Uploader')
  396. expect(dropzone).not.to.be.null
  397. fireEvent.paste(dropzone, {
  398. clipboardData: {
  399. files: [new File(['test'], 'tes!t.tex', { type: 'text/plain' })],
  400. },
  401. })
  402. await waitFor(() => expect(requests).to.have.length(1))
  403. const [request] = requests
  404. expect(request.url).to.equal('/project/test-project/upload')
  405. expect(request.method).to.equal('POST')
  406. request.respond(
  407. 422,
  408. { 'Content-Type': 'application/json' },
  409. '{ "success": false, "error": "invalid_filename" }'
  410. )
  411. await screen.findByText(
  412. `Upload failed: check that the file name doesn’t contain special characters, trailing/leading whitespace or more than 150 characters`
  413. )
  414. xhr.restore()
  415. })
  416. })
  417. function OpenWithMode({ mode }) {
  418. const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
  419. const { fileCount } = useFileTreeMutable()
  420. // eslint-disable-next-line react-hooks/exhaustive-deps
  421. useEffect(() => startCreatingFile(mode), [])
  422. if (!fileCount || !newFileCreateMode) {
  423. return null
  424. }
  425. return <FileTreeModalCreateFile />
  426. }
  427. OpenWithMode.propTypes = {
  428. mode: PropTypes.string.isRequired,
  429. }