Merge pull request #3526 from overleaf/cmg-binary-file

[BinaryFile] Binary file React migration

GitOrigin-RevId: e229ad8ec3781607b5ca28387927b84d4af95060
This commit is contained in:
Eric Mc Sween 2021-04-23 07:51:31 -04:00 committed by Copybot
parent f33c00f2fd
commit 1186c3e9a4
21 changed files with 1053 additions and 9 deletions

View file

@ -865,7 +865,9 @@ const ProjectController = {
showReactFileTree: !wantsOldFileTreeUI,
showReactShareModal: !wantsOldShareModalUI,
showReactAddFilesModal: !wantsOldAddFilesModalUI,
showReactGithubSync: !wantsOldGithubSyncUI && user.alphaProgram
showReactGithubSync: !wantsOldGithubSyncUI && user.alphaProgram,
showNewBinaryFileUI:
req.query && req.query.new_binary_file === 'true'
})
timer.done()
}

View file

@ -108,6 +108,10 @@ block content
.ui-layout-center
include ./editor/editor
if showNewBinaryFileUI
include ./editor/binary-file-react
else
include ./editor/binary-file
include ./editor/history

View file

@ -0,0 +1,9 @@
div(
ng-controller="ReactBinaryFileController"
ng-show="ui.view == 'file'"
ng-if="openFile"
)
binary-file(
file='file'
store-references-keys='storeReferencesKeys'
)

View file

@ -405,6 +405,9 @@ module.exports = settings =
{code: "xh", name: "Xhosa"}
]
overleafModuleImports:
tprLinkedFileInfo: []
tprLinkedFileRefreshError: []
# Password Settings
# -----------
# These restrict the passwords users can use when registering

View file

@ -0,0 +1,268 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import PropTypes from 'prop-types'
import Icon from '../../../shared/components/icon'
import { formatTime, relativeDate } from '../../utils/format-date'
import { Trans, useTranslation } from 'react-i18next'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { postJSON } from '../../../infrastructure/fetch-json'
const tprLinkedFileInfo = importOverleafModules('tprLinkedFileInfo')
const tprLinkedFileRefreshError = importOverleafModules(
'tprLinkedFileRefreshError'
)
const MAX_URL_LENGTH = 60
const FRONT_OF_URL_LENGTH = 35
const FILLER = '...'
const TAIL_OF_URL_LENGTH = MAX_URL_LENGTH - FRONT_OF_URL_LENGTH - FILLER.length
function shortenedUrl(url) {
if (!url) {
return
}
if (url.length > MAX_URL_LENGTH) {
const front = url.slice(0, FRONT_OF_URL_LENGTH)
const tail = url.slice(url.length - TAIL_OF_URL_LENGTH)
return front + FILLER + tail
}
return url
}
export default function BinaryFileHeader({ file, storeReferencesKeys }) {
const isMounted = useRef(true)
const [refreshing, setRefreshing] = useState(false)
const [refreshError, setRefreshError] = useState(null)
const { t } = useTranslation()
useEffect(() => {
// set to false on unmount to avoid unmounted component warning when refreshing
return () => (isMounted.current = false)
}, [])
let fileInfo
if (file.linkedFileData) {
if (file.linkedFileData.provider === 'url') {
fileInfo = (
<div>
<UrlProvider file={file} />
</div>
)
} else if (file.linkedFileData.provider === 'project_file') {
fileInfo = (
<div>
<ProjectFilePathProvider file={file} />
</div>
)
} else if (file.linkedFileData.provider === 'project_output_file') {
fileInfo = (
<div>
<ProjectOutputFileProvider file={file} />
</div>
)
}
}
const refreshFile = useCallback(() => {
setRefreshing(true)
// Replacement of the file handled by the file tree
window.expectingLinkedFileRefreshedSocketFor = file.name
postJSON(`/project/${window.project_id}/linked_file/${file.id}/refresh`, {
disableAutoLoginRedirect: true
})
.then(() => {
if (isMounted.current) {
setRefreshing(false)
}
})
.catch(err => {
if (isMounted.current) {
setRefreshing(false)
setRefreshError(err.message)
}
})
.finally(() => {
if (
file.linkedFileData.provider === 'mendeley' ||
file.linkedFileData.provider === 'zotero' ||
file.name.match(/^.*\.bib$/)
) {
reindexReferences()
}
})
function reindexReferences() {
const opts = {
body: { shouldBroadcast: true }
}
postJSON(`/project/${window.project_id}/references/indexAll`, opts)
.then(response => {
// Later updated by the socket but also updated here for immediate use
storeReferencesKeys(response.keys)
})
.catch(error => {
console.log(error)
})
}
}, [file, isMounted, storeReferencesKeys])
return (
<div className="binary-file-header">
{file.linkedFileData && fileInfo}
{file.linkedFileData &&
tprLinkedFileInfo.map(({ import: { LinkedFileInfo }, path }) => (
<LinkedFileInfo key={path} file={file} />
))}
{file.linkedFileData && (
<button
className="btn btn-success"
onClick={refreshFile}
disabled={refreshing}
>
<Icon type="refresh" spin={refreshing} modifier="fw" />
<span>{refreshing ? t('refreshing') + '...' : t('refresh')}</span>
</button>
)}
&nbsp;
<a
href={`/project/${window.project_id}/file/${file.id}`}
className="btn btn-info"
>
<Icon type="download" modifier="fw" />
<span>{' ' + t('download')}</span>
</a>
{refreshError && (
<div className="row">
<br />
<div className="alert alert-danger col-md-6 col-md-offset-3">
Error: {refreshError}
{tprLinkedFileRefreshError.map(
({ import: { LinkedFileRefreshError }, path }) => (
<LinkedFileRefreshError key={path} file={file} />
)
)}
</div>
</div>
)}
</div>
)
}
BinaryFileHeader.propTypes = {
file: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
linkedFileData: PropTypes.object
}).isRequired,
storeReferencesKeys: PropTypes.func.isRequired
}
function UrlProvider({ file }) {
return (
<p>
<Icon
type="external-link-square"
modifier="rotate-180"
classes={{ icon: 'linked-file-icon' }}
/>
&nbsp;
<Trans
i18nKey="imported_from_external_provider_at_date"
components={
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
[<a href={file.linkedFileData.url} />]
}
values={{
shortenedUrl: shortenedUrl(file.linkedFileData.url),
formattedDate: formatTime(file.created),
relativeDate: relativeDate(file.created)
}}
/>
</p>
)
}
UrlProvider.propTypes = {
file: PropTypes.shape({
linkedFileData: PropTypes.object,
created: PropTypes.string
}).isRequired
}
function ProjectFilePathProvider({ file }) {
/* eslint-disable jsx-a11y/anchor-has-content, react/jsx-key */
return (
<p>
<Icon
type="external-link-square"
modifier="rotate-180"
classes={{ icon: 'linked-file-icon' }}
/>
&nbsp;
<Trans
i18nKey="imported_from_another_project_at_date"
components={
file.linkedFileData.v1_source_doc_id
? [<span />]
: [
<a
href={`/project/${file.linkedFileData.source_project_id}`}
target="_blank"
/>
]
}
values={{
sourceEntityPath: file.linkedFileData.source_entity_path.slice(1),
formattedDate: formatTime(file.created),
relativeDate: relativeDate(file.created)
}}
/>
</p>
/* esline-enable jsx-a11y/anchor-has-content, react/jsx-key */
)
}
ProjectFilePathProvider.propTypes = {
file: PropTypes.shape({
linkedFileData: PropTypes.object,
created: PropTypes.string
}).isRequired
}
function ProjectOutputFileProvider({ file }) {
return (
<p>
<Icon
type="external-link-square"
modifier="rotate-180"
classes={{ icon: 'linked-file-icon' }}
/>
&nbsp;
<Trans
i18nKey="imported_from_the_output_of_another_project_at_date"
components={
file.linkedFileData.v1_source_doc_id
? [<span />]
: [
<a
href={`/project/${file.linkedFileData.source_project_id}`}
target="_blank"
/>
]
}
values={{
sourceOutputFilePath: file.linkedFileData.source_output_file_path,
formattedDate: formatTime(file.created),
relativeDate: relativeDate(file.created)
}}
/>
</p>
)
}
ProjectOutputFileProvider.propTypes = {
file: PropTypes.shape({
linkedFileData: PropTypes.object,
created: PropTypes.string
}).isRequired
}

View file

@ -0,0 +1,21 @@
import React from 'react'
import PropTypes from 'prop-types'
export default function BinaryFileImage({ fileName, fileId, onLoad, onError }) {
return (
<img
src={`/project/${window.project_id}/file/${fileId}`}
onLoad={onLoad}
onError={onError}
onAbort={onError}
alt={fileName}
/>
)
}
BinaryFileImage.propTypes = {
fileName: PropTypes.string.isRequired,
fileId: PropTypes.string.isRequired,
onLoad: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired
}

View file

@ -0,0 +1,67 @@
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
const MAX_FILE_SIZE = 2 * 1024 * 1024
export default function BinaryFileText({ file, onLoad, onError }) {
const [textPreview, setTextPreview] = useState('')
const [shouldShowDots, setShouldShowDots] = useState(false)
useEffect(() => {
let path = `/project/${window.project_id}/file/${file.id}`
fetch(path, { method: 'HEAD' })
.then(response => {
if (!response.ok) throw new Error('HTTP Error Code: ' + response.status)
return response.headers.get('Content-Length')
})
.then(fileSize => {
let truncated = false
let maxSize = null
if (fileSize > MAX_FILE_SIZE) {
truncated = true
maxSize = MAX_FILE_SIZE
}
if (maxSize != null) {
path += `?range=0-${maxSize}`
}
fetch(path)
.then(response => {
response.text().then(text => {
if (truncated) {
text = text.replace(/\n.*$/, '')
}
setTextPreview(text)
onLoad()
setShouldShowDots(truncated)
})
})
.catch(err => {
onError()
console.error(err)
})
})
.catch(err => {
onError()
})
}, [file.id, onError, onLoad])
return (
<div>
{textPreview && (
<div className="text-preview">
<div className="scroll-container">
<p>{textPreview}</p>
{shouldShowDots && <p>...</p>}
</div>
</div>
)}
</div>
)
}
BinaryFileText.propTypes = {
file: PropTypes.shape({ id: PropTypes.string }).isRequired,
onLoad: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired
}

View file

@ -0,0 +1,114 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import BinaryFileHeader from './binary-file-header'
import BinaryFileImage from './binary-file-image'
import BinaryFileText from './binary-file-text'
import Icon from '../../../shared/components/icon'
import { useTranslation } from 'react-i18next'
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif']
const textExtensions = [
'tex',
'latex',
'sty',
'cls',
'bst',
'bib',
'bibtex',
'txt',
'tikz',
'mtx',
'rtex',
'md',
'asy',
'latexmkrc',
'lbx',
'bbx',
'cbx',
'm',
'lco',
'dtx',
'ins',
'ist',
'def',
'clo',
'ldf',
'rmd',
'lua',
'gv'
]
export default function BinaryFile({ file, storeReferencesKeys }) {
const extension = file.name.split('.').pop().toLowerCase()
const [contentLoading, setContentLoading] = useState(true)
const [hasError, setHasError] = useState(false)
const { t } = useTranslation()
const isUnpreviewableFile =
!imageExtensions.includes(extension) && !textExtensions.includes(extension)
function handleLoading() {
if (contentLoading) {
setContentLoading(false)
}
}
function handleError() {
if (!hasError) {
setContentLoading(false)
setHasError(true)
}
}
const content = (
<>
<BinaryFileHeader file={file} storeReferencesKeys={storeReferencesKeys} />
{imageExtensions.includes(extension) && (
<BinaryFileImage
fileName={file.name}
fileId={file.id}
onLoad={handleLoading}
onError={handleError}
/>
)}
{textExtensions.includes(extension) && (
<BinaryFileText
file={file}
onLoad={handleLoading}
onError={handleError}
/>
)}
</>
)
return (
<div className="binary-file full-size">
{!hasError && content}
{!isUnpreviewableFile && contentLoading && <BinaryFileLoadingIndicator />}
{(isUnpreviewableFile || hasError) && (
<p className="no-preview">{t('no_preview_available')}</p>
)}
</div>
)
}
function BinaryFileLoadingIndicator() {
const { t } = useTranslation()
return (
<div className="loading-panel loading-panel-binary-files">
<span>
<Icon type="refresh" modifier="spin" />
&nbsp;&nbsp;{t('loading')}
</span>
</div>
)
}
BinaryFile.propTypes = {
file: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string
}).isRequired,
storeReferencesKeys: PropTypes.func.isRequired
}

View file

@ -0,0 +1,21 @@
import App from '../../../base'
import { react2angular } from 'react2angular'
import BinaryFile from '../components/binary-file'
import _ from 'lodash'
export default App.controller(
'ReactBinaryFileController',
function ($scope, $rootScope) {
$scope.file = $scope.openFile
$scope.storeReferencesKeys = newKeys => {
const oldKeys = $rootScope._references.keys
return ($rootScope._references.keys = _.union(oldKeys, newKeys))
}
}
)
App.component(
'binaryFile',
react2angular(BinaryFile, ['storeReferencesKeys', 'file'])
)

View file

@ -0,0 +1,20 @@
import moment from 'moment'
moment.updateLocale('en', {
calendar: {
lastDay: '[Yesterday]',
sameDay: '[Today]',
nextDay: '[Tomorrow]',
lastWeek: 'ddd, Do MMM YY',
nextWeek: 'ddd, Do MMM YY',
sameElse: 'ddd, Do MMM YY'
}
})
export function formatTime(date) {
return moment(date).format('h:mm a')
}
export function relativeDate(date) {
return moment(date).calendar()
}

View file

@ -35,6 +35,7 @@ import SafariScrollPatcher from './ide/SafariScrollPatcher'
import './ide/cobranding/CobrandingDataService'
import './ide/settings/index'
import './ide/share/index'
import './ide/binary-files/index'
import './ide/chat/index'
import './ide/clone/index'
import './ide/hotkeys/index'

View file

@ -0,0 +1 @@
import '../../features/binary-file/controllers/binary-file-controller'

View file

@ -0,0 +1,173 @@
import React from 'react'
import BinaryFile from '../js/features/binary-file/components/binary-file'
import fetchMock from 'fetch-mock'
window.project_id = 'proj123'
fetchMock.restore()
fetchMock.head('express:/project/:project_id/file/:file_id', {
status: 201,
headers: { 'Content-Length': 10000 }
})
fetchMock.get('express:/project/:project_id/file/:file_id', 'Text file content')
fetchMock.post('express:/project/:project_id/linked_file/:file_id/refresh', {
status: 204
})
fetchMock.post('express:/project/:project_id/references/indexAll', {
status: 204
})
window.project_id = '1234'
export const FileFromUrl = args => {
return <BinaryFile {...args} />
}
FileFromUrl.args = {
file: {
linkedFileData: {
url: 'https://overleaf.com',
provider: 'url'
}
}
}
export const FileFromProjectWithLinkableProjectId = args => {
return <BinaryFile {...args} />
}
FileFromProjectWithLinkableProjectId.args = {
file: {
linkedFileData: {
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file'
}
}
}
export const FileFromProjectWithoutLinkableProjectId = args => {
return <BinaryFile {...args} />
}
FileFromProjectWithoutLinkableProjectId.args = {
file: {
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file'
}
}
}
export const FileFromProjectOutputWithLinkableProject = args => {
return <BinaryFile {...args} />
}
FileFromProjectOutputWithLinkableProject.args = {
file: {
linkedFileData: {
source_project_id: 'source_project_id',
source_output_file_path: '/source-entity-path.ext',
provider: 'project_output_file'
}
}
}
export const FileFromProjectOutputWithoutLinkableProjectId = args => {
return <BinaryFile {...args} />
}
FileFromProjectOutputWithoutLinkableProjectId.args = {
file: {
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_output_file_path: '/source-entity-path.ext',
provider: 'project_output_file'
}
}
}
export const ImageFile = args => {
return <BinaryFile {...args} />
}
ImageFile.args = {
file: {
id: '60097ca20454610027c442a8',
name: 'file.jpg',
linkedFileData: {
source_project_id: 'source_project_id',
source_entity_path: '/source-entity-path',
provider: 'project_file'
}
}
}
export const ThirdPartyReferenceFile = args => {
return <BinaryFile {...args} />
}
ThirdPartyReferenceFile.args = {
file: {
name: 'example.tex',
linkedFileData: {
provider: 'zotero'
}
}
}
export const ThirdPartyReferenceFileWithError = args => {
return <BinaryFile {...args} />
}
ThirdPartyReferenceFileWithError.args = {
file: {
id: '500500500500500500500500',
name: 'example.tex',
linkedFileData: {
provider: 'zotero'
}
}
}
export const TextFile = args => {
return <BinaryFile {...args} />
}
TextFile.args = {
file: {
linkedFileData: {
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file'
},
name: 'file.txt'
}
}
export const UploadedFile = args => {
return <BinaryFile {...args} />
}
UploadedFile.args = {
file: {
linkedFileData: null,
name: 'file.jpg'
}
}
export default {
title: 'BinaryFile',
component: BinaryFile,
args: {
file: {
id: 'file-id',
name: 'file.tex',
created: new Date()
},
storeReferencesKeys: () => {}
},
decorators: [
BinaryFile => (
<>
<style>{'html, body { height: 100%; }'}</style>
<BinaryFile />
</>
)
]
}

View file

@ -0,0 +1,26 @@
import React from 'react'
import { LinkedFileInfo } from '../../modules/tpr-webmodule/frontend/js/components/linked-file-info'
export const MendeleyLinkedFile = args => {
return <LinkedFileInfo {...args} />
}
MendeleyLinkedFile.args = {
file: {
linkedFileData: {
provider: 'mendeley'
}
}
}
export default {
title: 'LinkedFileInfo',
component: LinkedFileInfo,
args: {
file: {
id: 'file-id',
name: 'file.tex',
created: new Date()
}
}
}

View file

@ -190,6 +190,10 @@
background-color: #fafafa;
}
.loading-panel-binary-files {
background-color: @gray-lightest;
}
.error-panel {
.full-size;
padding: @line-height-computed;

View file

@ -13,11 +13,6 @@
.box-shadow(0 2px 3px @gray;);
background-color: white;
}
.img-preview {
background: url('/img/spinner.gif') no-repeat;
min-width: 200px;
min-height: 200px;
}
p.no-preview {
margin-top: @line-height-computed / 2;
font-size: 24px;

View file

@ -1373,5 +1373,12 @@
"resend_confirmation_email": "Resend confirmation email",
"your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
"thank_you": "Thank you!",
"add_email": "Add Email"
"add_email": "Add Email",
"imported_from_mendeley_at_date": "Imported from Mendeley at __formattedDate__ __relativeDate__",
"imported_from_zotero_at_date": "Imported from Zotero at __formattedDate__ __relativeDate__",
"imported_from_external_provider_at_date": "Imported from <0>__shortenedUrlHTML__</0> at __formattedDate__ __relativeDate__",
"imported_from_another_project_at_date": "Imported from <0>Another project</0>/__sourceEntityPathHTML__, at __formattedDate__ __relativeDate__",
"imported_from_the_output_of_another_project_at_date": "Imported from the output of <0>Another project</0>: __sourceOutputFilePathHTML__, at __formattedDate__ __relativeDate__",
"refreshing": "Refreshing",
"if_error_persists_try_relinking_provider": "If this error persists, try re-linking your __provider__ account here"
}

View file

@ -0,0 +1,168 @@
import React from 'react'
import {
render,
screen,
fireEvent,
waitForElementToBeRemoved
} from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import sinon from 'sinon'
import BinaryFileHeader from '../../../../../frontend/js/features/binary-file/components/binary-file-header.js'
describe('<BinaryFileHeader/>', function () {
const urlFile = {
name: 'example.tex',
linkedFileData: {
url: 'https://overleaf.com',
provider: 'url'
},
created: new Date(2021, 1, 17, 3, 24).toISOString()
}
const projectFile = {
name: 'example.tex',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file'
},
created: new Date(2021, 1, 17, 3, 24).toISOString()
}
const projectOutputFile = {
name: 'example.pdf',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_output_file_path: '/source-entity-path.ext',
provider: 'project_output_file'
},
created: new Date(2021, 1, 17, 3, 24).toISOString()
}
const thirdPartyReferenceFile = {
name: 'example.tex',
linkedFileData: {
provider: 'zotero'
},
created: new Date(2021, 1, 17, 3, 24).toISOString()
}
let storeReferencesKeys
beforeEach(function () {
fetchMock.reset()
storeReferencesKeys = sinon.stub()
})
describe('header text', function () {
it('Renders the correct text for a file with the url provider', function () {
render(<BinaryFileHeader file={urlFile} storeReferencesKeys={() => {}} />)
screen.getByText('Imported from', { exact: false })
screen.getByText('at 3:24 am Wed, 17th Feb 21', {
exact: false
})
})
it('Renders the correct text for a file with the project_file provider', function () {
render(
<BinaryFileHeader file={projectFile} storeReferencesKeys={() => {}} />
)
screen.getByText('Imported from', { exact: false })
screen.getByText('Another project', { exact: false })
screen.getByText('/source-entity-path.ext, at 3:24 am Wed, 17th Feb 21', {
exact: false
})
})
it('Renders the correct text for a file with the project_output_file provider', function () {
render(
<BinaryFileHeader
file={projectOutputFile}
storeReferencesKeys={() => {}}
/>
)
screen.getByText('Imported from the output of', { exact: false })
screen.getByText('Another project', { exact: false })
screen.getByText('/source-entity-path.ext, at 3:24 am Wed, 17th Feb 21', {
exact: false
})
})
})
describe('The refresh button', async function () {
let reindexResponse
beforeEach(function () {
window.project_id = '123abc'
reindexResponse = {
projectId: '123abc',
keys: ['reference1', 'reference2', 'reference3', 'reference4']
}
})
afterEach(function () {
delete window.project_id
})
it('Changes text when the file is refreshing', async function () {
fetchMock.post(
'express:/project/:project_id/linked_file/:file_id/refresh',
{
new_file_id: '5ff7418157b4e144321df5c4'
}
)
render(
<BinaryFileHeader file={projectFile} storeReferencesKeys={() => {}} />
)
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await waitForElementToBeRemoved(() =>
screen.getByText('Refreshing', { exact: false })
)
await screen.findByText('Refresh')
})
it('Reindexes references after refreshing a file from a third-party provider', async function () {
fetchMock.post(
'express:/project/:project_id/linked_file/:file_id/refresh',
{
new_file_id: '5ff7418157b4e144321df5c4'
}
)
fetchMock.post(
'express:/project/:project_id/references/indexAll',
reindexResponse
)
render(
<BinaryFileHeader
file={thirdPartyReferenceFile}
storeReferencesKeys={storeReferencesKeys}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await waitForElementToBeRemoved(() =>
screen.getByText('Refreshing', { exact: false })
)
expect(fetchMock.done()).to.be.true
expect(storeReferencesKeys).to.be.calledWith(reindexResponse.keys)
})
})
describe('The download button', function () {
it('exists', function () {
render(<BinaryFileHeader file={urlFile} storeReferencesKeys={() => {}} />)
screen.getByText('Download', { exact: false })
})
})
})

View file

@ -0,0 +1,27 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import BinaryFileImage from '../../../../../frontend/js/features/binary-file/components/binary-file-image.js'
describe('<BinaryFileImage />', function () {
const file = {
id: '60097ca20454610027c442a8',
name: 'file.jpg',
linkedFileData: {
source_entity_path: '/source-entity-path',
provider: 'project_file'
}
}
it('renders an image', function () {
render(
<BinaryFileImage
fileName={file.name}
fileId={file.id}
onError={() => {}}
onLoad={() => {}}
/>
)
screen.getByRole('img')
})
})

View file

@ -0,0 +1,42 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import BinaryFileText from '../../../../../frontend/js/features/binary-file/components/binary-file-text.js'
describe('<BinaryFileText/>', function () {
const file = {
name: 'example.tex',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file'
},
created: new Date(2021, 1, 17, 3, 24).toISOString()
}
beforeEach(function () {
fetchMock.reset()
window.project_id = '123abc'
})
afterEach(function () {
delete window.project_id
})
it('renders a text view', async function () {
fetchMock.head('express:/project/:project_id/file/:file_id', {
status: 201,
headers: { 'Content-Length': 10000 }
})
fetchMock.get(
'express:/project/:project_id/file/:file_id',
'Text file content'
)
render(<BinaryFileText file={file} onError={() => {}} onLoad={() => {}} />)
await screen.findByText('Text file content', { exact: false })
})
})

View file

@ -0,0 +1,71 @@
import React from 'react'
import {
render,
screen,
waitForElementToBeRemoved,
fireEvent
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import BinaryFile from '../../../../../frontend/js/features/binary-file/components/binary-file.js'
describe('<BinaryFile/>', function () {
const textFile = {
name: 'example.tex',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file'
},
created: new Date(2021, 1, 17, 3, 24).toISOString()
}
const imageFile = {
id: '60097ca20454610027c442a8',
name: 'file.jpg',
linkedFileData: {
source_entity_path: '/source-entity-path',
provider: 'project_file'
}
}
beforeEach(function () {
fetchMock.reset()
})
describe('for a text file', function () {
it('it shows a loading indicator while the file is loading', async function () {
render(<BinaryFile file={textFile} storeReferencesKeys={() => {}} />)
await waitForElementToBeRemoved(() =>
screen.getByText('Loading', { exact: false })
)
})
it('it shows messaging if the text view could not be loaded', async function () {
render(<BinaryFile file={textFile} storeReferencesKeys={() => {}} />)
await screen.findByText('Sorry, no preview is available', {
exact: false
})
})
})
describe('for an image file', function () {
it('it shows a loading indicator while the file is loading', async function () {
render(<BinaryFile file={imageFile} storeReferencesKeys={() => {}} />)
screen.getByText('Loading', { exact: false })
})
it('it shows messaging if the image could not be loaded', function () {
render(<BinaryFile file={imageFile} storeReferencesKeys={() => {}} />)
// Fake the image request failing as the request is handled by the browser
fireEvent.error(screen.getByRole('img'))
screen.findByText('Sorry, no preview is available', { exact: false })
})
})
})