mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #3526 from overleaf/cmg-binary-file
[BinaryFile] Binary file React migration GitOrigin-RevId: e229ad8ec3781607b5ca28387927b84d4af95060
This commit is contained in:
parent
f33c00f2fd
commit
1186c3e9a4
21 changed files with 1053 additions and 9 deletions
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -108,7 +108,11 @@ block content
|
|||
|
||||
.ui-layout-center
|
||||
include ./editor/editor
|
||||
include ./editor/binary-file
|
||||
|
||||
if showNewBinaryFileUI
|
||||
include ./editor/binary-file-react
|
||||
else
|
||||
include ./editor/binary-file
|
||||
include ./editor/history
|
||||
|
||||
if !isRestrictedTokenMember
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
div(
|
||||
ng-controller="ReactBinaryFileController"
|
||||
ng-show="ui.view == 'file'"
|
||||
ng-if="openFile"
|
||||
)
|
||||
binary-file(
|
||||
file='file'
|
||||
store-references-keys='storeReferencesKeys'
|
||||
)
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
||||
<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' }}
|
||||
/>
|
||||
|
||||
<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' }}
|
||||
/>
|
||||
|
||||
<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' }}
|
||||
/>
|
||||
|
||||
<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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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" />
|
||||
{t('loading')}…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
BinaryFile.propTypes = {
|
||||
file: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string
|
||||
}).isRequired,
|
||||
storeReferencesKeys: PropTypes.func.isRequired
|
||||
}
|
|
@ -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'])
|
||||
)
|
20
services/web/frontend/js/features/utils/format-date.js
Normal file
20
services/web/frontend/js/features/utils/format-date.js
Normal 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()
|
||||
}
|
|
@ -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'
|
||||
|
|
1
services/web/frontend/js/ide/binary-files/index.js
Normal file
1
services/web/frontend/js/ide/binary-files/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
import '../../features/binary-file/controllers/binary-file-controller'
|
173
services/web/frontend/stories/binary-file.stories.js
Normal file
173
services/web/frontend/stories/binary-file.stories.js
Normal 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 />
|
||||
</>
|
||||
)
|
||||
]
|
||||
}
|
26
services/web/frontend/stories/linked-file.stories.js
Normal file
26
services/web/frontend/stories/linked-file.stories.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -190,6 +190,10 @@
|
|||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.loading-panel-binary-files {
|
||||
background-color: @gray-lightest;
|
||||
}
|
||||
|
||||
.error-panel {
|
||||
.full-size;
|
||||
padding: @line-height-computed;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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 })
|
||||
})
|
||||
})
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue