From 3dfcb95802ee78e8c060a737abdf1d2cfc4dc06d Mon Sep 17 00:00:00 2001 From: Chrystal Maria Griffiths Date: Wed, 28 Apr 2021 13:41:20 +0200 Subject: [PATCH] Merge pull request #3960 from overleaf/cmg-binary-file [BinaryFile] Reopening of Binary file React migration GitOrigin-RevId: 050e66e3321bd6579d44932b669fc0a31df06d18 --- .../src/Features/Project/ProjectController.js | 2 + services/web/app/views/project/editor.pug | 6 +- .../project/editor/binary-file-react.pug | 9 + services/web/config/settings.defaults.coffee | 2 + .../web/frontend/extracted-translations.json | 10 +- .../components/binary-file-header.js | 268 ++++++++++++++++++ .../components/binary-file-image.js | 21 ++ .../components/binary-file-text.js | 67 +++++ .../binary-file/components/binary-file.js | 114 ++++++++ .../controllers/binary-file-controller.js | 21 ++ .../frontend/js/features/utils/format-date.js | 20 ++ services/web/frontend/js/ide.js | 1 + .../web/frontend/js/ide/binary-files/index.js | 1 + .../frontend/stories/binary-file.stories.js | 173 +++++++++++ .../frontend/stories/linked-file.stories.js | 26 ++ .../web/frontend/stylesheets/app/editor.less | 4 + .../stylesheets/app/editor/binary-file.less | 5 - services/web/locales/en.json | 9 + .../components/binary-file-header.test.js | 168 +++++++++++ .../components/binary-file-image.test.js | 27 ++ .../components/binary-file-text.test.js | 42 +++ .../components/binary-file.test.js | 71 +++++ 22 files changed, 1060 insertions(+), 7 deletions(-) create mode 100644 services/web/app/views/project/editor/binary-file-react.pug create mode 100644 services/web/frontend/js/features/binary-file/components/binary-file-header.js create mode 100644 services/web/frontend/js/features/binary-file/components/binary-file-image.js create mode 100644 services/web/frontend/js/features/binary-file/components/binary-file-text.js create mode 100644 services/web/frontend/js/features/binary-file/components/binary-file.js create mode 100644 services/web/frontend/js/features/binary-file/controllers/binary-file-controller.js create mode 100644 services/web/frontend/js/features/utils/format-date.js create mode 100644 services/web/frontend/js/ide/binary-files/index.js create mode 100644 services/web/frontend/stories/binary-file.stories.js create mode 100644 services/web/frontend/stories/linked-file.stories.js create mode 100644 services/web/test/frontend/features/binary-file/components/binary-file-header.test.js create mode 100644 services/web/test/frontend/features/binary-file/components/binary-file-image.test.js create mode 100644 services/web/test/frontend/features/binary-file/components/binary-file-text.test.js create mode 100644 services/web/test/frontend/features/binary-file/components/binary-file.test.js 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 ( + {fileName} + ) +} + +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__ affiliation.", "your_affiliation_is_confirmed": "Your <0>__institutionName__ 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__ at __formattedDate__ __relativeDate__", + "imported_from_another_project_at_date": "Imported from <0>Another project/__sourceEntityPathHTML__, at __formattedDate__ __relativeDate__", + "imported_from_the_output_of_another_project_at_date": "Imported from the output of <0>Another project: __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 }) + }) + }) +})