diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index 2fd89bd39b..c807659643 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -866,6 +866,8 @@ const ProjectController = {
showReactShareModal: !wantsOldShareModalUI,
showReactAddFilesModal: !wantsOldAddFilesModalUI,
showReactGithubSync: !wantsOldGithubSyncUI && user.alphaProgram,
+ showNewBinaryFileUI:
+ req.query && req.query.new_binary_file === 'true',
})
timer.done()
}
diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug
index 371d48c08f..e7777db3d9 100644
--- a/services/web/app/views/project/editor.pug
+++ b/services/web/app/views/project/editor.pug
@@ -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
diff --git a/services/web/app/views/project/editor/binary-file-react.pug b/services/web/app/views/project/editor/binary-file-react.pug
new file mode 100644
index 0000000000..d1ed6f9b3d
--- /dev/null
+++ b/services/web/app/views/project/editor/binary-file-react.pug
@@ -0,0 +1,9 @@
+div(
+ ng-controller="ReactBinaryFileController"
+ ng-show="ui.view == 'file'"
+ ng-if="openFile"
+)
+ binary-file(
+ file='file'
+ store-references-keys='storeReferencesKeys'
+ )
diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee
index d0b8bf1bdb..8556dc3bc6 100644
--- a/services/web/config/settings.defaults.coffee
+++ b/services/web/config/settings.defaults.coffee
@@ -743,6 +743,8 @@ module.exports = settings =
createFileModes: []
gitBridge: []
publishModal: []
+ tprLinkedFileInfo: []
+ tprLinkedFileRefreshError: []
}
csp: {
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index f26d5f6eb5..751c8e0d22 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -11,7 +11,6 @@
"autocomplete": "",
"autocomplete_references": "",
"back_to_your_projects": "",
- "beta_badge_tooltip": "",
"blocked_filename": "",
"can_edit": "",
"cancel": "",
@@ -59,6 +58,7 @@
"dismiss": "",
"dismiss_error_popup": "",
"done": "",
+ "download": "",
"download_pdf": "",
"drag_here": "",
"duplicate_file": "",
@@ -103,7 +103,13 @@
"hide_outline": "",
"history": "",
"hotkeys": "",
+ "if_error_persists_try_relinking_provider": "",
"ignore_validation_errors": "",
+ "imported_from_another_project_at_date": "",
+ "imported_from_external_provider_at_date": "",
+ "imported_from_mendeley_at_date": "",
+ "imported_from_the_output_of_another_project_at_date": "",
+ "imported_from_zotero_at_date": "",
"importing_and_merging_changes_in_github": "",
"invalid_email": "",
"invalid_file_name": "",
@@ -148,6 +154,7 @@
"new_name": "",
"no_messages": "",
"no_new_commits_in_github": "",
+ "no_preview_available": "",
"normal": "",
"off": "",
"ok": "",
@@ -189,6 +196,7 @@
"reference_error_relink_hint": "",
"refresh": "",
"refresh_page_after_starting_free_trial": "",
+ "refreshing": "",
"remote_service_error": "",
"remove_collaborator": "",
"rename": "",
diff --git a/services/web/frontend/js/features/binary-file/components/binary-file-header.js b/services/web/frontend/js/features/binary-file/components/binary-file-header.js
new file mode 100644
index 0000000000..eb082314c3
--- /dev/null
+++ b/services/web/frontend/js/features/binary-file/components/binary-file-header.js
@@ -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 = (
+
+
+
+ )
+ } else if (file.linkedFileData.provider === 'project_file') {
+ fileInfo = (
+
+ )
+ } else if (file.linkedFileData.provider === 'project_output_file') {
+ fileInfo = (
+
+ )
+ }
+ }
+
+ 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 (
+
+ {file.linkedFileData && fileInfo}
+ {file.linkedFileData &&
+ tprLinkedFileInfo.map(({ import: { LinkedFileInfo }, path }) => (
+
+ ))}
+ {file.linkedFileData && (
+
+ )}
+
+
+
+ {' ' + t('download')}
+
+ {refreshError && (
+
+
+
+ Error: {refreshError}
+ {tprLinkedFileRefreshError.map(
+ ({ import: { LinkedFileRefreshError }, path }) => (
+
+ )
+ )}
+
+
+ )}
+
+ )
+}
+
+BinaryFileHeader.propTypes = {
+ file: PropTypes.shape({
+ id: PropTypes.string,
+ name: PropTypes.string,
+ linkedFileData: PropTypes.object,
+ }).isRequired,
+ storeReferencesKeys: PropTypes.func.isRequired,
+}
+
+function UrlProvider({ file }) {
+ return (
+
+
+
+ ]
+ }
+ values={{
+ shortenedUrl: shortenedUrl(file.linkedFileData.url),
+ formattedDate: formatTime(file.created),
+ relativeDate: relativeDate(file.created),
+ }}
+ />
+
+ )
+}
+
+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 (
+
+
+
+ ]
+ : [
+ ,
+ ]
+ }
+ values={{
+ sourceEntityPath: file.linkedFileData.source_entity_path.slice(1),
+ formattedDate: formatTime(file.created),
+ relativeDate: relativeDate(file.created),
+ }}
+ />
+
+ /* 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 (
+
+
+
+ ]
+ : [
+ ,
+ ]
+ }
+ values={{
+ sourceOutputFilePath: file.linkedFileData.source_output_file_path,
+ formattedDate: formatTime(file.created),
+ relativeDate: relativeDate(file.created),
+ }}
+ />
+
+ )
+}
+
+ProjectOutputFileProvider.propTypes = {
+ file: PropTypes.shape({
+ linkedFileData: PropTypes.object,
+ created: PropTypes.string,
+ }).isRequired,
+}
diff --git a/services/web/frontend/js/features/binary-file/components/binary-file-image.js b/services/web/frontend/js/features/binary-file/components/binary-file-image.js
new file mode 100644
index 0000000000..d5499f3cb1
--- /dev/null
+++ b/services/web/frontend/js/features/binary-file/components/binary-file-image.js
@@ -0,0 +1,21 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+export default function BinaryFileImage({ fileName, fileId, onLoad, onError }) {
+ return (
+
+ )
+}
+
+BinaryFileImage.propTypes = {
+ fileName: PropTypes.string.isRequired,
+ fileId: PropTypes.string.isRequired,
+ onLoad: PropTypes.func.isRequired,
+ onError: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/binary-file/components/binary-file-text.js b/services/web/frontend/js/features/binary-file/components/binary-file-text.js
new file mode 100644
index 0000000000..696b45b220
--- /dev/null
+++ b/services/web/frontend/js/features/binary-file/components/binary-file-text.js
@@ -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 (
+
+ {textPreview && (
+
+
+
{textPreview}
+ {shouldShowDots &&
...
}
+
+
+ )}
+
+ )
+}
+
+BinaryFileText.propTypes = {
+ file: PropTypes.shape({ id: PropTypes.string }).isRequired,
+ onLoad: PropTypes.func.isRequired,
+ onError: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/binary-file/components/binary-file.js b/services/web/frontend/js/features/binary-file/components/binary-file.js
new file mode 100644
index 0000000000..3a67288cb6
--- /dev/null
+++ b/services/web/frontend/js/features/binary-file/components/binary-file.js
@@ -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 = (
+ <>
+
+ {imageExtensions.includes(extension) && (
+
+ )}
+ {textExtensions.includes(extension) && (
+
+ )}
+ >
+ )
+
+ return (
+
+ {!hasError && content}
+ {!isUnpreviewableFile && contentLoading &&
}
+ {(isUnpreviewableFile || hasError) && (
+
{t('no_preview_available')}
+ )}
+
+ )
+}
+
+function BinaryFileLoadingIndicator() {
+ const { t } = useTranslation()
+ return (
+
+
+
+ {t('loading')}…
+
+
+ )
+}
+
+BinaryFile.propTypes = {
+ file: PropTypes.shape({
+ id: PropTypes.string,
+ name: PropTypes.string,
+ }).isRequired,
+ storeReferencesKeys: PropTypes.func.isRequired,
+}
diff --git a/services/web/frontend/js/features/binary-file/controllers/binary-file-controller.js b/services/web/frontend/js/features/binary-file/controllers/binary-file-controller.js
new file mode 100644
index 0000000000..990bfab9d3
--- /dev/null
+++ b/services/web/frontend/js/features/binary-file/controllers/binary-file-controller.js
@@ -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'])
+)
diff --git a/services/web/frontend/js/features/utils/format-date.js b/services/web/frontend/js/features/utils/format-date.js
new file mode 100644
index 0000000000..4f001c451f
--- /dev/null
+++ b/services/web/frontend/js/features/utils/format-date.js
@@ -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()
+}
diff --git a/services/web/frontend/js/ide.js b/services/web/frontend/js/ide.js
index ea092846e9..a800875438 100644
--- a/services/web/frontend/js/ide.js
+++ b/services/web/frontend/js/ide.js
@@ -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'
diff --git a/services/web/frontend/js/ide/binary-files/index.js b/services/web/frontend/js/ide/binary-files/index.js
new file mode 100644
index 0000000000..ff01c5e7db
--- /dev/null
+++ b/services/web/frontend/js/ide/binary-files/index.js
@@ -0,0 +1 @@
+import '../../features/binary-file/controllers/binary-file-controller'
diff --git a/services/web/frontend/stories/binary-file.stories.js b/services/web/frontend/stories/binary-file.stories.js
new file mode 100644
index 0000000000..8ca76d02eb
--- /dev/null
+++ b/services/web/frontend/stories/binary-file.stories.js
@@ -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
+}
+FileFromUrl.args = {
+ file: {
+ linkedFileData: {
+ url: 'https://overleaf.com',
+ provider: 'url',
+ },
+ },
+}
+
+export const FileFromProjectWithLinkableProjectId = args => {
+ return
+}
+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
+}
+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
+}
+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
+}
+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
+}
+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
+}
+
+ThirdPartyReferenceFile.args = {
+ file: {
+ name: 'example.tex',
+ linkedFileData: {
+ provider: 'zotero',
+ },
+ },
+}
+
+export const ThirdPartyReferenceFileWithError = args => {
+ return
+}
+
+ThirdPartyReferenceFileWithError.args = {
+ file: {
+ id: '500500500500500500500500',
+ name: 'example.tex',
+ linkedFileData: {
+ provider: 'zotero',
+ },
+ },
+}
+
+export const TextFile = args => {
+ return
+}
+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
+}
+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 => (
+ <>
+
+
+ >
+ ),
+ ],
+}
diff --git a/services/web/frontend/stories/linked-file.stories.js b/services/web/frontend/stories/linked-file.stories.js
new file mode 100644
index 0000000000..010a10962c
--- /dev/null
+++ b/services/web/frontend/stories/linked-file.stories.js
@@ -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
+}
+
+MendeleyLinkedFile.args = {
+ file: {
+ linkedFileData: {
+ provider: 'mendeley',
+ },
+ },
+}
+
+export default {
+ title: 'LinkedFileInfo',
+ component: LinkedFileInfo,
+ args: {
+ file: {
+ id: 'file-id',
+ name: 'file.tex',
+ created: new Date(),
+ },
+ },
+}
diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less
index 01d07dc2fa..6168da159a 100644
--- a/services/web/frontend/stylesheets/app/editor.less
+++ b/services/web/frontend/stylesheets/app/editor.less
@@ -190,6 +190,10 @@
background-color: #fafafa;
}
+.loading-panel-binary-files {
+ background-color: @gray-lightest;
+}
+
.error-panel {
.full-size;
padding: @line-height-computed;
diff --git a/services/web/frontend/stylesheets/app/editor/binary-file.less b/services/web/frontend/stylesheets/app/editor/binary-file.less
index 4c868be724..4f57591021 100644
--- a/services/web/frontend/stylesheets/app/editor/binary-file.less
+++ b/services/web/frontend/stylesheets/app/editor/binary-file.less
@@ -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;
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index b8a805bb0b..aa4e1123f9 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1379,6 +1379,15 @@
"confirm_affiliation": "Confirm Affiliation",
"please_check_your_inbox_to_confirm": "Please check your email inbox to confirm your <0>__institutionName__0> affiliation.",
"your_affiliation_is_confirmed": "Your <0>__institutionName__0> affiliation is confirmed.",
+ "thank_you": "Thank you!",
+ "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 project0>/__sourceEntityPathHTML__, at __formattedDate__ __relativeDate__",
+ "imported_from_the_output_of_another_project_at_date": "Imported from the output of <0>Another project0>: __sourceOutputFilePathHTML__, at __formattedDate__ __relativeDate__",
+ "refreshing": "Refreshing",
+ "if_error_persists_try_relinking_provider": "If this error persists, try re-linking your __provider__ account here",
"thank_you_exclamation": "Thank you!",
"add_email": "Add Email"
}
diff --git a/services/web/test/frontend/features/binary-file/components/binary-file-header.test.js b/services/web/test/frontend/features/binary-file/components/binary-file-header.test.js
new file mode 100644
index 0000000000..ba43a90980
--- /dev/null
+++ b/services/web/test/frontend/features/binary-file/components/binary-file-header.test.js
@@ -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('', 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( {}} />)
+ 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(
+ {}} />
+ )
+ 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(
+ {}}
+ />
+ )
+ 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(
+ {}} />
+ )
+
+ 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(
+
+ )
+
+ 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( {}} />)
+
+ screen.getByText('Download', { exact: false })
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/binary-file/components/binary-file-image.test.js b/services/web/test/frontend/features/binary-file/components/binary-file-image.test.js
new file mode 100644
index 0000000000..ca1756a6ed
--- /dev/null
+++ b/services/web/test/frontend/features/binary-file/components/binary-file-image.test.js
@@ -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('', function () {
+ const file = {
+ id: '60097ca20454610027c442a8',
+ name: 'file.jpg',
+ linkedFileData: {
+ source_entity_path: '/source-entity-path',
+ provider: 'project_file',
+ },
+ }
+
+ it('renders an image', function () {
+ render(
+ {}}
+ onLoad={() => {}}
+ />
+ )
+ screen.getByRole('img')
+ })
+})
diff --git a/services/web/test/frontend/features/binary-file/components/binary-file-text.test.js b/services/web/test/frontend/features/binary-file/components/binary-file-text.test.js
new file mode 100644
index 0000000000..f74e7e03ca
--- /dev/null
+++ b/services/web/test/frontend/features/binary-file/components/binary-file-text.test.js
@@ -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('', 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( {}} onLoad={() => {}} />)
+
+ await screen.findByText('Text file content', { exact: false })
+ })
+})
diff --git a/services/web/test/frontend/features/binary-file/components/binary-file.test.js b/services/web/test/frontend/features/binary-file/components/binary-file.test.js
new file mode 100644
index 0000000000..47ac1c9bba
--- /dev/null
+++ b/services/web/test/frontend/features/binary-file/components/binary-file.test.js
@@ -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('', 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( {}} />)
+
+ await waitForElementToBeRemoved(() =>
+ screen.getByText('Loading', { exact: false })
+ )
+ })
+
+ it('it shows messaging if the text view could not be loaded', async function () {
+ render( {}} />)
+
+ 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( {}} />)
+
+ screen.getByText('Loading', { exact: false })
+ })
+
+ it('it shows messaging if the image could not be loaded', function () {
+ render( {}} />)
+
+ // 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 })
+ })
+ })
+})