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

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