From 3c01402bbd4c905439d3b1f4a7e9c206994ae8ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Alby?= Date: Thu, 31 Mar 2022 13:22:36 +0200 Subject: [PATCH] Merge pull request #7034 from overleaf/ta-pdf-detach-full PDF Detach v2 GitOrigin-RevId: 3deb76474185f9176cde23ab32ef51b90df6e8e9 --- .../src/Features/Project/ProjectController.js | 7 +- services/web/app/views/project/editor.pug | 6 +- .../web/app/views/project/editor/meta.pug | 1 + .../web/app/views/project/editor_detached.pug | 16 + .../web/frontend/extracted-translations.json | 1 + .../components/layout-dropdown-button.js | 6 +- .../components/detach-compile-button.js | 19 +- .../components/pdf-clear-cache-button.js | 4 +- .../components/pdf-compile-button.js | 6 +- .../components/pdf-download-files-button.js | 2 +- .../pdf-hybrid-code-check-button.js | 15 +- .../components/pdf-hybrid-download-button.js | 2 +- .../components/pdf-hybrid-logs-button.js | 15 +- .../pdf-preview/components/pdf-js-viewer.js | 2 +- .../pdf-preview/components/pdf-log-entry.js | 9 + .../components/pdf-logs-entries.js | 3 +- .../pdf-preview/components/pdf-logs-viewer.js | 2 +- .../components/pdf-preview-detached-root.js | 18 + .../components/pdf-preview-hybrid-toolbar.js | 51 +- .../components/pdf-preview-pane.js | 2 +- .../components/pdf-synctex-controls.js | 76 ++- .../pdf-preview/components/pdf-viewer.js | 2 +- .../pdf-preview/hooks/use-compile-triggers.js | 30 +- services/web/frontend/js/ide-detached.js | 5 + .../human-readable-logs/HumanReadableLogs.js | 18 - .../HumanReadableLogsHints.js | 449 ++++++++++++++++ .../HumanReadableLogsRules.js | 480 ++---------------- .../shared/context/detach-compile-context.js | 386 ++++++++++++++ .../js/shared/context/detach-context.js | 21 +- .../js/shared/context/editor-context.js | 2 +- .../frontend/js/shared/context/ide-context.js | 9 +- .../js/shared/context/layout-context.js | 15 +- ...le-context.js => local-compile-context.js} | 44 +- .../js/shared/context/mock/mock-ide.js | 65 +++ .../js/shared/context/root-context.js | 15 +- .../js/shared/context/user-context.js | 4 +- .../js/shared/hooks/use-callback-handlers.js | 38 +- .../js/shared/hooks/use-detach-layout.js | 26 +- .../shared/hooks/use-detach-state-watcher.js | 22 + .../js/shared/hooks/use-detach-state.js | 20 +- .../js/shared/hooks/use-persisted-state.js | 4 +- .../frontend/stories/pdf-preview.stories.js | 2 +- services/web/locales/en.json | 1 + .../chat/components/chat-pane.test.js | 15 +- .../chat/context/chat-context.test.js | 39 +- .../components/layout-dropdown-button.test.js | 6 - .../components/file-tree-root.test.js | 3 + .../file-tree/flows/context-menu.test.js | 6 + .../file-tree/flows/create-folder.test.js | 3 + .../file-tree/flows/delete-entity.test.js | 6 + .../file-tree/flows/rename-entity.test.js | 3 + .../components/detach-compile-button.test.js | 21 +- .../components/pdf-logs-entries.test.js | 12 +- .../pdf-preview-detached-root.test.js | 70 +++ .../pdf-preview-hybrid-toolbar.test.js | 58 ++- .../components/pdf-preview.test.js | 13 +- .../components/pdf-synctex-controls.test.js | 263 +++++----- .../pdf-preview/utils/mock-compile.js | 11 +- .../components/share-project-modal.test.js | 13 +- .../test/frontend/helpers/editor-providers.js | 7 +- services/web/test/frontend/helpers/sysend.js | 5 + .../shared/hooks/use-detach-layout.test.js | 51 +- services/web/webpack.config.js | 1 + 63 files changed, 1636 insertions(+), 891 deletions(-) create mode 100644 services/web/app/views/project/editor_detached.pug create mode 100644 services/web/frontend/js/features/pdf-preview/components/pdf-preview-detached-root.js create mode 100644 services/web/frontend/js/ide-detached.js create mode 100644 services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.js create mode 100644 services/web/frontend/js/shared/context/detach-compile-context.js rename services/web/frontend/js/shared/context/{compile-context.js => local-compile-context.js} (93%) create mode 100644 services/web/frontend/js/shared/context/mock/mock-ide.js create mode 100644 services/web/frontend/js/shared/hooks/use-detach-state-watcher.js create mode 100644 services/web/test/frontend/features/pdf-preview/components/pdf-preview-detached-root.test.js diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 3333f2ccce..ce0f91282d 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -948,11 +948,16 @@ const ProjectController = { !Features.hasFeature('saas') || (user.features && user.features.symbolPalette) - res.render('project/editor', { + const template = + detachRole === 'detached' + ? 'project/editor_detached' + : 'project/editor' + res.render(template, { title: project.name, priority_title: true, bodyClasses: ['editor'], project_id: project._id, + projectName: project.name, user: { id: userId, email: user.email, diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 5055de3ecb..fb3f16c394 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -65,11 +65,7 @@ block content span.sr-only #{translate("close")} .system-message-content(ng-bind-html="htmlContent") - if detachRole === 'detached' - div.full-size - pdf-preview() - else - include ./editor/main + include ./editor/main script(type="text/ng-template", id="genericMessageModalTemplate") .modal-header diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug index b6026d4b3e..4c0d8a7fbe 100644 --- a/services/web/app/views/project/editor/meta.pug +++ b/services/web/app/views/project/editor/meta.pug @@ -1,5 +1,6 @@ meta(name="ol-useV2History" data-type="boolean" content=useV2History) meta(name="ol-project_id" content=project_id) +meta(name="ol-projectName" content=projectName) meta(name="ol-userSettings" data-type="json" content=userSettings) meta(name="ol-user" data-type="json" content=user) meta(name="ol-learnedWords" data-type="json" content=learnedWords) diff --git a/services/web/app/views/project/editor_detached.pug b/services/web/app/views/project/editor_detached.pug new file mode 100644 index 0000000000..104eca8e79 --- /dev/null +++ b/services/web/app/views/project/editor_detached.pug @@ -0,0 +1,16 @@ +extends ../layout + +block entrypointVar + - entrypoint = 'ide-detached' + +block vars + - var suppressNavbar = true + - var suppressFooter = true + - var suppressSkipToContent = true + - metadata.robotsNoindexNofollow = true + +block content + #pdf-preview-detached-root() + +block append meta + include ./editor/meta diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index a7ef41cba1..78871e37b9 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -342,6 +342,7 @@ "sync_project_to_github_explanation": "", "sync_to_dropbox": "", "sync_to_github": "", + "tab_connecting": "", "tab_no_longer_connected": "", "tags": "", "template_approved_by_publisher": "", diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js index d82a5a5b2f..af4aa1fec0 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js +++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js @@ -7,7 +7,6 @@ import IconChecked from '../../../shared/components/icon-checked' import ControlledDropdown from '../../../shared/components/controlled-dropdown' import IconEditorOnly from './icon-editor-only' import IconPdfOnly from './icon-pdf-only' -import { useCompileContext } from '../../../shared/context/compile-context' import { useLayoutContext } from '../../../shared/context/layout-context' import * as eventTracking from '../../../infrastructure/event-tracking' @@ -59,13 +58,10 @@ function LayoutDropdownButton() { pdfLayout, } = useLayoutContext(layoutContextPropTypes) - const { stopCompile } = useCompileContext() - const handleDetach = useCallback(() => { detach() - stopCompile() eventTracking.sendMB('project-layout-detach') - }, [detach, stopCompile]) + }, [detach]) const handleReattach = useCallback(() => { if (detachRole !== 'detacher') { diff --git a/services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js b/services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js index 0f0ece8e1d..e0e52913cf 100644 --- a/services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js @@ -1,26 +1,13 @@ -import { memo, useCallback } from 'react' +import { memo } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import { useLayoutContext } from '../../../shared/context/layout-context' -import { useCompileContext } from '../../../shared/context/compile-context' -import useDetachAction from '../../../shared/hooks/use-detach-action' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import PdfCompileButtonInner from './pdf-compile-button-inner' export function DetachCompileButton() { const { compiling, hasChanges, startCompile } = useCompileContext() - const startOrTriggerCompile = useDetachAction( - 'start-compile', - startCompile, - 'detacher', - 'detached' - ) - - const handleStartCompile = useCallback( - () => startOrTriggerCompile(), - [startOrTriggerCompile] - ) - return (
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js index d28028e394..368ff74416 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js @@ -2,7 +2,7 @@ import Icon from '../../../shared/components/icon' import { Button } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { memo } from 'react' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' function PdfClearCacheButton() { const { compiling, clearCache, clearingCache } = useCompileContext() @@ -14,7 +14,7 @@ function PdfClearCacheButton() { bsSize="small" bsStyle="danger" className="logs-pane-actions-clear-cache" - onClick={clearCache} + onClick={() => clearCache()} disabled={clearingCache || compiling} > {clearingCache ? : } diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js index 42a53f3982..583e68f07d 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js @@ -4,7 +4,7 @@ import ControlledDropdown from '../../../shared/components/controlled-dropdown' import { useTranslation } from 'react-i18next' import { memo } from 'react' import classnames from 'classnames' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import PdfCompileButtonInner from './pdf-compile-button-inner' function PdfCompileButton() { @@ -84,7 +84,7 @@ function PdfCompileButton() { stopCompile()} disabled={!compiling} aria-disabled={!compiling} > @@ -92,7 +92,7 @@ function PdfCompileButton() { recompileFromScratch()} disabled={compiling} aria-disabled={compiling} > diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js index 86640c73ff..34a90d418a 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js @@ -3,7 +3,7 @@ import PdfFileList from './pdf-file-list' import ControlledDropdown from '../../../shared/components/controlled-dropdown' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' function PdfDownloadFilesButton() { const { compiling, fileList } = useCompileContext() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js index 92b02c2875..5e2cd805a0 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js @@ -1,24 +1,17 @@ import { memo, useCallback } from 'react' -import { sendMBOnce } from '../../../infrastructure/event-tracking' import { Button } from 'react-bootstrap' import Icon from '../../../shared/components/icon' import { useTranslation } from 'react-i18next' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' function PdfHybridCodeCheckButton() { - const { codeCheckFailed, error, setShowLogs } = useCompileContext() + const { codeCheckFailed, error, toggleLogs } = useCompileContext() const { t } = useTranslation() const handleClick = useCallback(() => { - setShowLogs(value => { - if (!value) { - sendMBOnce('ide-open-logs-once') - } - - return !value - }) - }, [setShowLogs]) + toggleLogs() + }, [toggleLogs]) if (!codeCheckFailed) { return null diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js index bda9c4d432..3516e80a07 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap' import Icon from '../../../shared/components/icon' import { memo } from 'react' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' function PdfHybridDownloadButton() { const { pdfDownloadUrl } = useCompileContext() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js index c16359d81a..09194ffb8b 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js @@ -1,24 +1,17 @@ import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { Button, Label, OverlayTrigger, Tooltip } from 'react-bootstrap' -import { sendMBOnce } from '../../../infrastructure/event-tracking' import Icon from '../../../shared/components/icon' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' function PdfHybridLogsButton() { - const { error, logEntries, setShowLogs, showLogs } = useCompileContext() + const { error, logEntries, toggleLogs, showLogs } = useCompileContext() const { t } = useTranslation() const handleClick = useCallback(() => { - setShowLogs(value => { - if (!value) { - sendMBOnce('ide-open-logs-once') - } - - return !value - }) - }, [setShowLogs]) + toggleLogs() + }, [toggleLogs]) const errorCount = Number(logEntries?.errors?.length) const warningCount = Number(logEntries?.warnings?.length) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js index 655ffb46d7..e54fa96570 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js @@ -8,7 +8,7 @@ import { buildHighlightElement } from '../util/highlights' import PDFJSWrapper from '../util/pdf-js-wrapper' import withErrorBoundary from '../../../infrastructure/error-boundary' import ErrorBoundaryFallback from './error-boundary-fallback' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import getMeta from '../../../utils/meta' function PdfJsViewer({ url }) { diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-log-entry.js b/services/web/frontend/js/features/pdf-preview/components/pdf-log-entry.js index b70c891a5d..3f1ef45306 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-log-entry.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-log-entry.js @@ -3,8 +3,10 @@ import classNames from 'classnames' import { memo, useCallback } from 'react' import PreviewLogEntryHeader from '../../preview/components/preview-log-entry-header' import PdfLogEntryContent from './pdf-log-entry-content' +import HumanReadableLogsHints from '../../../ide/human-readable-logs/HumanReadableLogsHints' function PdfLogEntry({ + ruleId, headerTitle, headerIcon, rawContent, @@ -20,6 +22,12 @@ function PdfLogEntry({ onSourceLocationClick, onClose, }) { + if (ruleId && HumanReadableLogsHints[ruleId]) { + const hint = HumanReadableLogsHints[ruleId] + formattedContent = hint.formattedContent + extraInfoURL = hint.extraInfoURL + } + const handleLogEntryLinkClick = useCallback( event => { event.preventDefault() @@ -56,6 +64,7 @@ function PdfLogEntry({ } PdfLogEntry.propTypes = { + ruleId: PropTypes.string, sourceLocation: PreviewLogEntryHeader.propTypes.sourceLocation, headerTitle: PropTypes.string, headerIcon: PropTypes.element, diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js index 04a92e3532..c52f3f805b 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js @@ -48,11 +48,10 @@ function PdfLogsEntries({ entries, hasErrors }) { {logEntries.map(logEntry => ( + + + ) +} + +export default PdfPreviewDetachedRoot // for testing + +const element = document.getElementById('pdf-preview-detached-root') +if (element) { + ReactDOM.render(, element) +} diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js index 2a7fd7b849..7e0a17b30f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js @@ -1,4 +1,4 @@ -import { memo } from 'react' +import { memo, useState, useEffect, useRef } from 'react' import { ButtonToolbar } from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { useLayoutContext } from '../../../shared/context/layout-context' @@ -9,19 +9,46 @@ import PdfHybridDownloadButton from './pdf-hybrid-download-button' import PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button' import PdfOrphanRefreshButton from './pdf-orphan-refresh-button' import { DetachedSynctexControl } from './detach-synctex-control' +import Icon from '../../../shared/components/icon' + +const ORPHAN_UI_TIMEOUT_MS = 5000 function PdfPreviewHybridToolbar() { const { detachRole, detachIsLinked } = useLayoutContext() + const uiTimeoutRef = useRef() + const [orphanPdfTabAfterDelay, setOrphanPdfTabAfterDelay] = useState(false) + const orphanPdfTab = !detachIsLinked && detachRole === 'detached' + useEffect(() => { + if (uiTimeoutRef.current) { + clearTimeout(uiTimeoutRef.current) + } + + if (orphanPdfTab) { + uiTimeoutRef.current = setTimeout(() => { + setOrphanPdfTabAfterDelay(true) + }, ORPHAN_UI_TIMEOUT_MS) + } else { + setOrphanPdfTabAfterDelay(false) + } + }, [orphanPdfTab]) + + let ToolbarInner = null + if (orphanPdfTabAfterDelay) { + // when the detached tab has been orphan for a while + ToolbarInner = + } else if (orphanPdfTab) { + ToolbarInner = + } else { + // tab is not detached or not orphan + ToolbarInner = + } + return ( - {orphanPdfTab ? ( - - ) : ( - - )} + {ToolbarInner} ) } @@ -55,4 +82,16 @@ function PdfPreviewHybridToolbarOrphanInner() { ) } +function PdfPreviewHybridToolbarConnectingInner() { + const { t } = useTranslation() + return ( + <> +
+ + {t('tab_connecting')}… +
+ + ) +} + export default memo(PdfPreviewHybridToolbar) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js index 2ae4f99ee9..61d692ba0a 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js @@ -4,7 +4,7 @@ import PdfLogsViewer from './pdf-logs-viewer' import PdfViewer from './pdf-viewer' import LoadingSpinner from '../../../shared/components/loading-spinner' import PdfHybridPreviewToolbar from './pdf-preview-hybrid-toolbar' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' function PdfPreviewPane() { const { pdfUrl } = useCompileContext() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js index d0f0a1aa5f..47ea7879de 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js @@ -1,10 +1,10 @@ import classNames from 'classnames' -import { memo, useCallback, useEffect, useState, useMemo } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import PropTypes from 'prop-types' import { useIdeContext } from '../../../shared/context/ide-context' import { useProjectContext } from '../../../shared/context/project-context' import { getJSON } from '../../../infrastructure/fetch-json' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import { useLayoutContext } from '../../../shared/context/layout-context' import useScopeValue from '../../../shared/hooks/use-scope-value' import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap' @@ -134,32 +134,19 @@ function PdfSynctexControls() { const { signal } = useAbortController() - // for detacher editor tab, which cannot access pdfUrl in a scope value in - // detached state - const [pdfExists, setPdfExists] = useDetachState( - 'pdf-exists', - !!pdfUrl, - 'detached', - 'detacher' - ) - - useEffect(() => { - setPdfExists(!!pdfUrl) - }, [pdfUrl, setPdfExists]) - useEffect(() => { const listener = event => setCursorPosition(event.detail) window.addEventListener('cursor:editor:update', listener) return () => window.removeEventListener('cursor:editor:update', listener) }, [ide]) - const [syncToPdfInFlight, setSyncToPdfInFlight] = useDetachState( - 'sync-to-pdf-inflight', + const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false) + const [syncToCodeInFlight, setSyncToCodeInFlight] = useDetachState( + 'sync-to-code-inflight', false, - 'detached', - 'detacher' + 'detacher', + 'detached' ) - const [syncToCodeInFlight, setSyncToCodeInFlight] = useState(false) const [, setSynctexError] = useScopeValue('sync_tex_error') @@ -179,7 +166,7 @@ function PdfSynctexControls() { return path }, [ide]) - const _goToCodeLine = useCallback( + const goToCodeLine = useCallback( (file, line) => { if (file) { const doc = ide.fileTreeManager.findEntityByPath(file) @@ -200,14 +187,7 @@ function PdfSynctexControls() { [ide, isMounted, setSynctexError] ) - const goToCodeLine = useDetachAction( - 'go-to-code-line', - _goToCodeLine, - 'detached', - 'detacher' - ) - - const _goToPdfLocation = useCallback( + const goToPdfLocation = useCallback( params => { setSyncToPdfInFlight(true) @@ -240,13 +220,6 @@ function PdfSynctexControls() { ] ) - const goToPdfLocation = useDetachAction( - 'go-to-pdf-location', - _goToPdfLocation, - 'detacher', - 'detached' - ) - const syncToPdf = useCallback( cursorPosition => { const params = new URLSearchParams({ @@ -260,7 +233,7 @@ function PdfSynctexControls() { [getCurrentFilePath, goToPdfLocation] ) - const syncToCode = useCallback( + const _syncToCode = useCallback( (position, visualOffset = 0) => { setSyncToCodeInFlight(true) // FIXME: this actually works better if it's halfway across the @@ -317,6 +290,13 @@ function PdfSynctexControls() { ] ) + const syncToCode = useDetachAction( + 'sync-to-code', + _syncToCode, + 'detached', + 'detacher' + ) + useEffect(() => { const listener = event => syncToCode(event.detail) window.addEventListener('synctex:sync-to-position', listener) @@ -325,22 +305,32 @@ function PdfSynctexControls() { } }, [syncToCode]) - const hasSingleSelectedDoc = useMemo(() => { + const [hasSingleSelectedDoc, setHasSingleSelectedDoc] = useDetachState( + 'has-single-selected-doc', + false, + 'detacher', + 'detached' + ) + + useEffect(() => { if (selectedEntities.length !== 1) { - return false + setHasSingleSelectedDoc(false) + return } if (selectedEntities[0].type !== 'doc') { - return false + setHasSingleSelectedDoc(false) + return } - return true - }, [selectedEntities]) + + setHasSingleSelectedDoc(true) + }, [selectedEntities, setHasSingleSelectedDoc]) if (!position) { return null } - if (!pdfExists || pdfViewer === 'native') { + if (!pdfUrl || pdfViewer === 'native') { return null } diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js b/services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js index 362aa86bbc..e5de0d3ead 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js @@ -1,5 +1,5 @@ import { lazy, memo } from 'react' -import { useCompileContext } from '../../../shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' const PdfJsViewer = lazy(() => import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer') diff --git a/services/web/frontend/js/features/pdf-preview/hooks/use-compile-triggers.js b/services/web/frontend/js/features/pdf-preview/hooks/use-compile-triggers.js index a4765e5e2b..34e4b8f0a5 100644 --- a/services/web/frontend/js/features/pdf-preview/hooks/use-compile-triggers.js +++ b/services/web/frontend/js/features/pdf-preview/hooks/use-compile-triggers.js @@ -1,18 +1,13 @@ -import { useCallback, useEffect } from 'react' +import { useCallback } from 'react' import getMeta from '../../../utils/meta' -import { useCompileContext } from '../../../shared/context/compile-context' -import { useDetachContext } from '../../../shared/context/detach-context' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' import useEventListener from '../../../shared/hooks/use-event-listener' import useDetachAction from '../../../shared/hooks/use-detach-action' -import usePreviousValue from '../../../shared/hooks/use-previous-value' const showPdfDetach = getMeta('ol-showPdfDetach') -const debugPdfDetach = getMeta('ol-debugPdfDetach') export default function useCompileTriggers() { - const { startCompile, setChangedAt, cleanupCompileResult, setError } = - useCompileContext() - const { role: detachRole } = useDetachContext() + const { startCompile, setChangedAt } = useCompileContext() // recompile on key press const startOrTriggerCompile = useDetachAction( @@ -43,23 +38,4 @@ export default function useCompileTriggers() { }, [setOrTriggerChangedAt, setChangedAt]) useEventListener('doc:changed', setChangedAtHandler) useEventListener('doc:saved', setChangedAtHandler) - - // clear preview and recompile when the detach role is reset - const previousDetachRole = usePreviousValue(detachRole) - useEffect(() => { - if (previousDetachRole && !detachRole) { - if (debugPdfDetach) { - console.log('Recompile on reattach', { previousDetachRole, detachRole }) - } - cleanupCompileResult() - setError() - startCompile() - } - }, [ - cleanupCompileResult, - setError, - startCompile, - previousDetachRole, - detachRole, - ]) } diff --git a/services/web/frontend/js/ide-detached.js b/services/web/frontend/js/ide-detached.js new file mode 100644 index 0000000000..a335498888 --- /dev/null +++ b/services/web/frontend/js/ide-detached.js @@ -0,0 +1,5 @@ +import './utils/meta' +import './utils/webpack-public-path' +import './infrastructure/error-reporter' +import './i18n' +import './features/pdf-preview/components/pdf-preview-detached-root' diff --git a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js index 4715db68de..d97fa3f188 100644 --- a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js +++ b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogs.js @@ -28,11 +28,6 @@ export default { let type if (ruleDetails.ruleId != null) { entry.ruleId = ruleDetails.ruleId - } else if (ruleDetails.regexToMatch != null) { - entry.ruleId = `hint_${ruleDetails.regexToMatch - .toString() - .replace(/\s/g, '_') - .slice(1, -1)}` } if (ruleDetails.newMessage != null) { entry.message = entry.message.replace( @@ -54,19 +49,6 @@ export default { seenErrorTypes[type] = true } } - - if (ruleDetails.humanReadableHint != null) { - entry.humanReadableHint = ruleDetails.humanReadableHint - } - - if (ruleDetails.humanReadableHintComponent != null) { - entry.humanReadableHintComponent = - ruleDetails.humanReadableHintComponent - } - - if (ruleDetails.extraInfoURL != null) { - entry.extraInfoURL = ruleDetails.extraInfoURL - } } } diff --git a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.js b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.js new file mode 100644 index 0000000000..e3c60e524f --- /dev/null +++ b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsHints.js @@ -0,0 +1,449 @@ +import PropTypes from 'prop-types' + +function WikiLink({ url, children }) { + if (window.wikiEnabled) { + return ( + + {children} + + ) + } else { + return <>{children} + } +} + +WikiLink.propTypes = { + url: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +} + +const hints = { + hint_misplaced_alignment_tab_character: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/Misplaced_alignment_tab_character_%26', + formattedContent: ( + <> + You have placed an alignment tab character '&' in the wrong place. If + you want to align something, you must write it inside an align + environment such as \begin + {'{align}'} … \end + {'{align}'}, \begin + {'{tabular}'} … \end + {'{tabular}'}, etc. If you want to write an ampersand '&' in text, you + must write \& instead. + + ), + }, + hint_extra_alignment_tab_has_been_changed: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr', + formattedContent: ( + <> + You have written too many alignment tabs in a table, causing one of them + to be turned into a line break. Make sure you have specified the correct + number of columns in your{' '} + table. + + ), + }, + hint_display_math_should_end_with: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/Display_math_should_end_with_$$', + formattedContent: ( + <> + You have forgotten a $ sign at the end of 'display math' mode. When + writing in display math mode, you must always math write inside $$ … $$. + Check that the number of $s match around each math expression. + + ), + }, + hint_missing_inserted: { + extraInfoURL: 'https://www.overleaf.com/learn/Errors/Missing_$_inserted', + formattedContent: ( + <> +

+ You need to enclose all mathematical expressions and symbols with + special markers. These special markers create a ‘math mode’. +

+

+ Use $...$ for inline math mode, and \[...\] + or one of the mathematical environments (e.g. equation) for display + math mode. +

+

+ This applies to symbols such as subscripts ( _ ), + integrals ( \int ), Greek letters ( \alpha,{' '} + \beta, \delta ) and modifiers{' '} + {'(\\vec{x}'}, {'\\tilde{x}'}). +

+ + ), + }, + hint_reference_undefined: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/There_were_undefined_references', + formattedContent: ( + <> + You have referenced something which has not yet been labelled. If you + have labelled it already, make sure that what is written inside \ref + {'{...}'} is the same as what is written inside \label + {'{...}'}. + + ), + }, + hint_there_were_undefined_references: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/There_were_undefined_references', + formattedContent: ( + <> + You have referenced something which has not yet been labelled. If you + have labelled it already, make sure that what is written inside \ref + {'{...}'} is the same as what is written inside \label + {'{...}'}. + + ), + }, + hint_citation_on_page_undefined_on_input_line: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX', + formattedContent: ( + <> + You have cited something which is not included in your bibliography. + Make sure that the citation (\cite + {'{...}'}) has a corresponding key in your bibliography, and that both + are spelled the same way. + + ), + }, + hint_label_multiply_defined_labels: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/There_were_multiply-defined_labels', + formattedContent: ( + <> + You have used the same label more than once. Check that each \label + {'{...}'} labels only one item. + + ), + }, + hint_float_specifier_changed: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27', + formattedContent: ( + <> + The float specifier 'h' is too strict of a demand for LaTeX to place + your float in a nice way here. Try relaxing it by using 'ht', or even + 'htbp' if necessary. If you want to try keep the float here anyway, + check out the{' '} + + float package + + . + + ), + }, + hint_no_positions_in_optional_float_specifier: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/No_positions_in_optional_float_specifier', + formattedContent: ( + <> + You have forgotten to include a float specifier, which tells LaTeX where + to position your figure. To fix this, either insert a float specifier + inside the square brackets (e.g. \begin + {'{figure}'} + [h]), or remove the square brackets (e.g. \begin + {'{figure}'} + ). Find out more about float specifiers{' '} + + here + + . + + ), + }, + hint_undefined_control_sequence: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/Undefined_control_sequence', + formattedContent: ( + <> + The compiler is having trouble understanding a command you have used. + Check that the command is spelled correctly. If the command is part of a + package, make sure you have included the package in your preamble using + \usepackage + {'{...}'}. + + ), + }, + hint_file_not_found: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX', + formattedContent: ( + <> + The compiler cannot find the file you want to include. Make sure that + you have{' '} + + uploaded the file + {' '} + and{' '} + + specified the file location correctly + + . + + ), + }, + hint_unknown_graphics_extension: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX', + formattedContent: ( + <> + The compiler does not recognise the file type of one of your images. + Make sure you are using a{' '} + + supported image format + {' '} + for your choice of compiler, and check that there are no periods (.) in + the name of your image. + + ), + }, + hint_unknown_float_option_h: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27', + formattedContent: ( + <> + The compiler isn't recognizing the float option 'H'. Include \usepackage + {'{float}'} in your preamble to fix this. + + ), + }, + hint_unknown_float_option_q: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27', + formattedContent: ( + <> + You have used a float specifier which the compiler does not understand. + You can learn more about the different float options available for + placing figures{' '} + + here + {' '} + . + + ), + }, + hint_math_allowed_only_in_math_mode: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode', + formattedContent: ( + <> + You have used a font command which is only available in math mode. To + use this command, you must be in maths mode (E.g. $ … $ or \begin + {'{math}'} … \end + {'{math}'} + ). If you want to use it outside of math mode, use the text version + instead: \textrm, \textit, etc. + + ), + }, + hint_mismatched_environment: { + formattedContent: ( + <> + You have used \begin + {'{...}'} without a corresponding \end + {'{...}'}. + + ), + }, + hint_mismatched_brackets: { + formattedContent: ( + <>You have used an open bracket without a corresponding close bracket. + ), + }, + hint_can_be_used_only_in_preamble: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Can_be_used_only_in_preamble', + formattedContent: ( + <> + You have used a command in the main body of your document which should + be used in the preamble. Make sure that \documentclass[…] + {'{…}'} and all \usepackage + {'{…}'} commands are written before \begin + {'{document}'}. + + ), + }, + hint_missing_right_inserted: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/Missing_%5Cright_insertede', + formattedContent: ( + <> + You have started an expression with a \left command, but have not + included a corresponding \right command. Make sure that your \left and + \right commands balance everywhere, or else try using \Biggl and \Biggr + commands instead as shown{' '} + + here + + . + + ), + }, + hint_double_superscript: { + extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_superscript', + formattedContent: ( + <> + You have written a double superscript incorrectly as a^b^c, or else you + have written a prime with a superscript. Remember to include {'{and}'}{' '} + when using multiple superscripts. Try a^ + {'{b ^ c}'} instead. + + ), + }, + hint_double_subscript: { + extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_subscript', + formattedContent: ( + <> + You have written a double subscript incorrectly as a_b_c. Remember to + include {'{and}'} when using multiple subscripts. Try a_ + {'{b_c}'} instead. + + ), + }, + hint_no_author_given: { + extraInfoURL: 'https://www.overleaf.com/learn/Errors/No_%5Cauthor_given', + formattedContent: ( + <> + You have used the \maketitle command, but have not specified any + \author. To fix this, include an author in your preamble using the + \author + {'{…}'} command. + + ), + }, + hint_environment_undefined: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Environment%20XXX%20undefined', + formattedContent: ( + <> + You have created an environment (using \begin + {'{…}'} and \end + {'{…}'} commands) which is not recognized. Make sure you have included + the required package for that environment in your preamble, and that the + environment is spelled correctly. + + ), + }, + hint_somethings_wrong_perhaps_a_missing_item: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Something%27s_wrong--perhaps_a_missing_%5Citem', + formattedContent: ( + <> + There are no entries found in a list you have created. Make sure you + label list entries using the \item command, and that you have not used a + list inside a table. + + ), + }, + hint_misplaced_noalign: { + extraInfoURL: 'https://www.overleaf.com/learn/Errors/Misplaced_%5Cnoalign', + formattedContent: ( + <> + You have used a \hline command in the wrong place, probably outside a + table. If the \hline command is written inside a table, try including \\ + before it. + + ), + }, + hint_no_line_here_to_end: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_There%27s_no_line_here_to_end', + formattedContent: ( + <> + You have used a \\ or \newline command where LaTeX was not expecting + one. Make sure that you only use line breaks after blocks of text, and + be careful using linebreaks inside lists and other environments.\ + + ), + }, + hint_verb_ended_by_end_of_line: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cverb_ended_by_end_of_line', + formattedContent: ( + <> + You have used a \verb command incorrectly. Try replacling the \verb + command with \begin + {'{verbatim}'} + …\end + {'{verbatim}'} + .\ + + ), + }, + hint_illegal_unit_of_measure_pt_inserted: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors%2FIllegal%20unit%20of%20measure%20(pt%20inserted)', + formattedContent: ( + <> + You have written a length, but have not specified the appropriate units + (pt, mm, cm etc.). If you have not written a length, check that you have + not witten a linebreak \\ followed by square brackets […] anywhere. + + ), + }, + hint_extra_right: { + extraInfoURL: 'https://www.overleaf.com/learn/Errors/Extra_%5Cright', + formattedContent: ( + <> + You have written a \right command without a corresponding \left command. + Check that all \left and \right commands balance everywhere. + + ), + }, + hint_missing_begin_document_: { + extraInfoURL: + 'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Missing%20%5Cbegin%20document', + formattedContent: ( + <> + No \begin + {'{document}'} command was found. Make sure you have included \begin + {'{document}'} in your preamble, and that your main document is set + correctly. + + ), + }, + hint_mismatched_environment2: { + formattedContent: ( + <> + You have used \begin + {'{}'} without a corresponding \end + {'{}'}. + + ), + }, + hint_mismatched_environment3: { + formattedContent: ( + <> + You have used \begin + {'{}'} without a corresponding \end + {'{}'}. + + ), + }, + hint_mismatched_environment4: { + formattedContent: ( + <> + You have used \begin + {'{}'} without a corresponding \end + {'{}'}. + + ), + }, +} + +if (!window.wikiEnabled) { + Object.keys(hints).forEach(ruleId => { + hints[ruleId].extraInfoURL = null + }) +} + +export default hints diff --git a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.js b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.js index aaad597b2d..3c87f98d42 100644 --- a/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.js +++ b/services/web/frontend/js/ide/human-readable-logs/HumanReadableLogsRules.js @@ -1,515 +1,133 @@ /* eslint-disable no-useless-escape */ -import PropTypes from 'prop-types' - -function WikiLink({ url, children }) { - if (window.wikiEnabled) { - return ( - - {children} - - ) - } else { - return <>{children} - } -} - -WikiLink.propTypes = { - url: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, -} const rules = [ { + ruleId: 'hint_misplaced_alignment_tab_character', regexToMatch: /Misplaced alignment tab character \&/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/Misplaced_alignment_tab_character_%26', - humanReadableHintComponent: ( - <> - You have placed an alignment tab character '&' in the wrong place. If - you want to align something, you must write it inside an align - environment such as \begin - {'{align}'} … \end - {'{align}'}, \begin - {'{tabular}'} … \end - {'{tabular}'}, etc. If you want to write an ampersand '&' in text, you - must write \& instead. - - ), - humanReadableHint: - 'You have placed an alignment tab character '&' in the wrong place. If you want to align something, you must write it inside an align environment such as \\begin{align} … \\end{align}, \\begin{tabular} … \\end{tabular}, etc. If you want to write an ampersand '&' in text, you must write \\& instead.', }, { + ruleId: 'hint_extra_alignment_tab_has_been_changed', regexToMatch: /Extra alignment tab has been changed to \\cr/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr', - humanReadableHintComponent: ( - <> - You have written too many alignment tabs in a table, causing one of them - to be turned into a line break. Make sure you have specified the correct - number of columns in your{' '} - table. - - ), - humanReadableHint: - 'You have written too many alignment tabs in a table, causing one of them to be turned into a line break. Make sure you have specified the correct number of columns in your table.', }, { + ruleId: 'hint_display_math_should_end_with', regexToMatch: /Display math should end with \$\$/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/Display_math_should_end_with_$$', - humanReadableHintComponent: ( - <> - You have forgotten a $ sign at the end of 'display math' mode. When - writing in display math mode, you must always math write inside $$ … $$. - Check that the number of $s match around each math expression. - - ), - humanReadableHint: - 'You have forgotten a $ sign at the end of 'display math' mode. When writing in display math mode, you must always math write inside $$ … $$. Check that the number of $s match around each math expression.', }, { + ruleId: 'hint_missing_inserted', regexToMatch: /Missing [{$] inserted./, - extraInfoURL: 'https://www.overleaf.com/learn/Errors/Missing_$_inserted', - humanReadableHintComponent: ( - <> -

- You need to enclose all mathematical expressions and symbols with - special markers. These special markers create a ‘math mode’. -

-

- Use $...$ for inline math mode, and \[...\] - or one of the mathematical environments (e.g. equation) for display - math mode. -

-

- This applies to symbols such as subscripts ( _ ), - integrals ( \int ), Greek letters ( \alpha,{' '} - \beta, \delta ) and modifiers{' '} - {'(\\vec{x}'}, {'\\tilde{x}'}). -

- - ), - humanReadableHint: - 'You need to enclose all mathematical expressions and symbols with special markers. These special markers create a ‘math mode’. Use $...$ for inline math mode, and \\[...\\] or one of the mathematical environments (e.g. equation) for display math mode. This applies to symbols such as subscripts ( _ ), integrals ( \\int ), Greek letters ( \\alpha, \\beta, \\delta ) and modifiers (\\vec{x}, \\tilde{x} ).', }, { + ruleId: 'hint_reference_undefined', regexToMatch: /Reference.+undefined/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/There_were_undefined_references', - humanReadableHintComponent: ( - <> - You have referenced something which has not yet been labelled. If you - have labelled it already, make sure that what is written inside \ref - {'{...}'} is the same as what is written inside \label - {'{...}'}. - - ), - humanReadableHint: - 'You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.', }, { + ruleId: 'hint_there_were_undefined_references', regexToMatch: /There were undefined references/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/There_were_undefined_references', - humanReadableHintComponent: ( - <> - You have referenced something which has not yet been labelled. If you - have labelled it already, make sure that what is written inside \ref - {'{...}'} is the same as what is written inside \label - {'{...}'}. - - ), - humanReadableHint: - 'You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.', }, { + ruleId: 'hint_citation_on_page_undefined_on_input_line', regexToMatch: /Citation .+ on page .+ undefined on input line .+/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX', - humanReadableHintComponent: ( - <> - You have cited something which is not included in your bibliography. - Make sure that the citation (\cite - {'{...}'}) has a corresponding key in your bibliography, and that both - are spelled the same way. - - ), - humanReadableHint: - 'You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.', }, { + ruleId: 'hint_label_multiply_defined_labels', regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/There_were_multiply-defined_labels', - humanReadableHintComponent: ( - <> - You have used the same label more than once. Check that each \label - {'{...}'} labels only one item. - - ), - humanReadableHint: - 'You have used the same label more than once. Check that each \\label{...} labels only one item.', }, { + ruleId: 'hint_float_specifier_changed', regexToMatch: /`!?h' float specifier changed to `!?ht'/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27', - humanReadableHintComponent: ( - <> - The float specifier 'h' is too strict of a demand for LaTeX to place - your float in a nice way here. Try relaxing it by using 'ht', or even - 'htbp' if necessary. If you want to try keep the float here anyway, - check out the{' '} - - float package - - . - - ), - humanReadableHint: - 'The float specifier 'h' is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using 'ht', or even 'htbp' if necessary. If you want to try keep the float here anyway, check out the float package.', }, { + ruleId: 'hint_no_positions_in_optional_float_specifier', regexToMatch: /No positions in optional float specifier/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/No_positions_in_optional_float_specifier', - humanReadableHintComponent: ( - <> - You have forgotten to include a float specifier, which tells LaTeX where - to position your figure. To fix this, either insert a float specifier - inside the square brackets (e.g. \begin - {'{figure}'} - [h]), or remove the square brackets (e.g. \begin - {'{figure}'} - ). Find out more about float specifiers{' '} - - here - - . - - ), - humanReadableHint: - 'You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \\begin{figure}[h]), or remove the square brackets (e.g. \\begin{figure}). Find out more about float specifiers here.', }, { + ruleId: 'hint_undefined_control_sequence', regexToMatch: /Undefined control sequence/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/Undefined_control_sequence', - humanReadableHintComponent: ( - <> - The compiler is having trouble understanding a command you have used. - Check that the command is spelled correctly. If the command is part of a - package, make sure you have included the package in your preamble using - \usepackage - {'{...}'}. - - ), - humanReadableHint: - 'The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.', }, { + ruleId: 'hint_file_not_found', regexToMatch: /File .+ not found/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX', - humanReadableHintComponent: ( - <> - The compiler cannot find the file you want to include. Make sure that - you have{' '} - - uploaded the file - {' '} - and{' '} - - specified the file location correctly - - . - - ), - humanReadableHint: - 'The compiler cannot find the file you want to include. Make sure that you have uploaded the file and specified the file location correctly.', }, { + ruleId: 'hint_unknown_graphics_extension', regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX', - humanReadableHintComponent: ( - <> - The compiler does not recognise the file type of one of your images. - Make sure you are using a{' '} - - supported image format - {' '} - for your choice of compiler, and check that there are no periods (.) in - the name of your image. - - ), - humanReadableHint: - 'The compiler does not recognise the file type of one of your images. Make sure you are using a supported image format for your choice of compiler, and check that there are no periods (.) in the name of your image.', }, { + ruleId: 'hint_unknown_float_option_h', regexToMatch: /LaTeX Error: Unknown float option `H'/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27', - humanReadableHintComponent: ( - <> - The compiler isn't recognizing the float option 'H'. Include \usepackage - {'{float}'} in your preamble to fix this. - - ), - humanReadableHint: - 'The compiler isn't recognizing the float option 'H'. Include \\usepackage{float} in your preamble to fix this.', }, { + ruleId: 'hint_unknown_float_option_q', regexToMatch: /LaTeX Error: Unknown float option `q'/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27', - humanReadableHintComponent: ( - <> - You have used a float specifier which the compiler does not understand. - You can learn more about the different float options available for - placing figures{' '} - - here - {' '} - . - - ), - humanReadableHint: - 'You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures here .', }, { + ruleId: 'hint_math_allowed_only_in_math_mode', regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode', - humanReadableHintComponent: ( - <> - You have used a font command which is only available in math mode. To - use this command, you must be in maths mode (E.g. $ … $ or \begin - {'{math}'} … \end - {'{math}'} - ). If you want to use it outside of math mode, use the text version - instead: \textrm, \textit, etc. - - ), - humanReadableHint: - 'You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ … $ or \\begin{math} … \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.', }, { ruleId: 'hint_mismatched_environment', types: ['environment'], regexToMatch: /Error: `([^']{2,})' expected, found `([^']{2,})'.*/, newMessage: 'Error: environment does not match \\begin{$1} ... \\end{$2}', - humanReadableHintComponent: ( - <> - You have used \begin - {'{...}'} without a corresponding \end - {'{...}'}. - - ), - humanReadableHint: - 'You have used \\begin{...} without a corresponding \\end{...}.', }, { ruleId: 'hint_mismatched_brackets', types: ['environment'], regexToMatch: /Error: `([^a-zA-Z0-9])' expected, found `([^a-zA-Z0-9])'.*/, newMessage: "Error: brackets do not match, found '$2' instead of '$1'", - humanReadableHintComponent: ( - <>You have used an open bracket without a corresponding close bracket. - ), - humanReadableHint: - 'You have used an open bracket without a corresponding close bracket.', }, { + ruleId: 'hint_can_be_used_only_in_preamble', regexToMatch: /LaTeX Error: Can be used only in preamble/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Can_be_used_only_in_preamble', - humanReadableHintComponent: ( - <> - You have used a command in the main body of your document which should - be used in the preamble. Make sure that \documentclass[…] - {'{…}'} and all \usepackage - {'{…}'} commands are written before \begin - {'{document}'}. - - ), - humanReadableHint: - 'You have used a command in the main body of your document which should be used in the preamble. Make sure that \\documentclass[…]{…} and all \\usepackage{…} commands are written before \\begin{document}.', }, { + ruleId: 'hint_missing_right_inserted', regexToMatch: /Missing \\right inserted/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/Missing_%5Cright_insertede', - humanReadableHintComponent: ( - <> - You have started an expression with a \left command, but have not - included a corresponding \right command. Make sure that your \left and - \right commands balance everywhere, or else try using \Biggl and \Biggr - commands instead as shown{' '} - - here - - . - - ), - humanReadableHint: - 'You have started an expression with a \\left command, but have not included a corresponding \\right command. Make sure that your \\left and \\right commands balance everywhere, or else try using \\Biggl and \\Biggr commands instead as shown here.', }, { + ruleId: 'hint_double_superscript', regexToMatch: /Double superscript/, - extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_superscript', - humanReadableHintComponent: ( - <> - You have written a double superscript incorrectly as a^b^c, or else you - have written a prime with a superscript. Remember to include {'{and}'}{' '} - when using multiple superscripts. Try a^ - {'{b ^ c}'} instead. - - ), - humanReadableHint: - 'You have written a double superscript incorrectly as a^b^c, or else you have written a prime with a superscript. Remember to include {and} when using multiple superscripts. Try a^{b ^ c} instead.', }, { + ruleId: 'hint_double_subscript', regexToMatch: /Double subscript/, - extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_subscript', - humanReadableHintComponent: ( - <> - You have written a double subscript incorrectly as a_b_c. Remember to - include {'{and}'} when using multiple subscripts. Try a_ - {'{b_c}'} instead. - - ), - humanReadableHint: - 'You have written a double subscript incorrectly as a_b_c. Remember to include {and} when using multiple subscripts. Try a_{b_c} instead.', }, { + ruleId: 'hint_no_author_given', regexToMatch: /No \\author given/, - extraInfoURL: 'https://www.overleaf.com/learn/Errors/No_%5Cauthor_given', - humanReadableHintComponent: ( - <> - You have used the \maketitle command, but have not specified any - \author. To fix this, include an author in your preamble using the - \author - {'{…}'} command. - - ), - humanReadableHint: - 'You have used the \\maketitle command, but have not specified any \\author. To fix this, include an author in your preamble using the \\author{…} command.', }, { + ruleId: 'hint_environment_undefined', regexToMatch: /LaTeX Error: Environment .+ undefined/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Environment%20XXX%20undefined', - humanReadableHintComponent: ( - <> - You have created an environment (using \begin - {'{…}'} and \end - {'{…}'} commands) which is not recognized. Make sure you have included - the required package for that environment in your preamble, and that the - environment is spelled correctly. - - ), - humanReadableHint: - 'You have created an environment (using \\begin{…} and \\end{…} commands) which is not recognized. Make sure you have included the required package for that environment in your preamble, and that the environment is spelled correctly.', }, { + ruleId: 'hint_somethings_wrong_perhaps_a_missing_item', regexToMatch: /LaTeX Error: Something's wrong--perhaps a missing \\item/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Something%27s_wrong--perhaps_a_missing_%5Citem', - humanReadableHintComponent: ( - <> - There are no entries found in a list you have created. Make sure you - label list entries using the \item command, and that you have not used a - list inside a table. - - ), - humanReadableHint: - 'There are no entries found in a list you have created. Make sure you label list entries using the \\item command, and that you have not used a list inside a table.', }, { + ruleId: 'hint_misplaced_noalign', regexToMatch: /Misplaced \\noalign/, - extraInfoURL: 'https://www.overleaf.com/learn/Errors/Misplaced_%5Cnoalign', - humanReadableHintComponent: ( - <> - You have used a \hline command in the wrong place, probably outside a - table. If the \hline command is written inside a table, try including \\ - before it. - - ), - humanReadableHint: - 'You have used a \\hline command in the wrong place, probably outside a table. If the \\hline command is written inside a table, try including \\\\ before it.', }, { + ruleId: 'hint_no_line_here_to_end', regexToMatch: /LaTeX Error: There's no line here to end/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_There%27s_no_line_here_to_end', - humanReadableHintComponent: ( - <> - You have used a \\ or \newline command where LaTeX was not expecting - one. Make sure that you only use line breaks after blocks of text, and - be careful using linebreaks inside lists and other environments.\ - - ), - humanReadableHint: - 'You have used a \\\\ or \\newline command where LaTeX was not expecting one. Make sure that you only use line breaks after blocks of text, and be careful using linebreaks inside lists and other environments.\\', }, { + ruleId: 'hint_verb_ended_by_end_of_line', regexToMatch: /LaTeX Error: \\verb ended by end of line/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cverb_ended_by_end_of_line', - humanReadableHintComponent: ( - <> - You have used a \verb command incorrectly. Try replacling the \verb - command with \begin - {'{verbatim}'} - …\end - {'{verbatim}'} - .\ - - ), - humanReadableHint: - 'You have used a \\verb command incorrectly. Try replacling the \\verb command with \\begin{verbatim}…\\end{verbatim}.\\', }, { + ruleId: 'hint_illegal_unit_of_measure_pt_inserted', regexToMatch: /Illegal unit of measure (pt inserted)/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors%2FIllegal%20unit%20of%20measure%20(pt%20inserted)', - humanReadableHintComponent: ( - <> - You have written a length, but have not specified the appropriate units - (pt, mm, cm etc.). If you have not written a length, check that you have - not witten a linebreak \\ followed by square brackets […] anywhere. - - ), - humanReadableHint: - 'You have written a length, but have not specified the appropriate units (pt, mm, cm etc.). If you have not written a length, check that you have not witten a linebreak \\\\ followed by square brackets […] anywhere.', }, { + ruleId: 'hint_extra_right', regexToMatch: /Extra \\right/, - extraInfoURL: 'https://www.overleaf.com/learn/Errors/Extra_%5Cright', - humanReadableHintComponent: ( - <> - You have written a \right command without a corresponding \left command. - Check that all \left and \right commands balance everywhere. - - ), - humanReadableHint: - 'You have written a \\right command without a corresponding \\left command. Check that all \\left and \\right commands balance everywhere.', }, { + ruleId: 'hint_missing_begin_document_', regexToMatch: /Missing \\begin{document}/, - extraInfoURL: - 'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Missing%20%5Cbegin%20document', - humanReadableHintComponent: ( - <> - No \begin - {'{document}'} command was found. Make sure you have included \begin - {'{document}'} in your preamble, and that your main document is set - correctly. - - ), - humanReadableHint: - 'No \\begin{document} command was found. Make sure you have included \\begin{document} in your preamble, and that your main document is set correctly.', }, { ruleId: 'hint_mismatched_environment2', @@ -518,15 +136,6 @@ const rules = [ regexToMatch: /Error: `\\end\{([^\}]+)\}' expected but found `\\end\{([^\}]+)\}'.*/, newMessage: 'Error: environments do not match: \\begin{$1} ... \\end{$2}', - humanReadableHintComponent: ( - <> - You have used \begin - {'{}'} without a corresponding \end - {'{}'}. - - ), - humanReadableHint: - 'You have used \\begin{} without a corresponding \\end{}.', }, { ruleId: 'hint_mismatched_environment3', @@ -535,15 +144,6 @@ const rules = [ regexToMatch: /Warning: No matching \\end found for `\\begin\{([^\}]+)\}'.*/, newMessage: 'Warning: No matching \\end found for \\begin{$1}', - humanReadableHintComponent: ( - <> - You have used \begin - {'{}'} without a corresponding \end - {'{}'}. - - ), - humanReadableHint: - 'You have used \\begin{} without a corresponding \\end{}.', }, { ruleId: 'hint_mismatched_environment4', @@ -552,29 +152,7 @@ const rules = [ regexToMatch: /Error: Found `\\end\{([^\}]+)\}' without corresponding \\begin.*/, newMessage: 'Error: found \\end{$1} without a corresponding \\begin{$1}', - humanReadableHintComponent: ( - <> - You have used \begin - {'{}'} without a corresponding \end - {'{}'}. - - ), - humanReadableHint: - 'You have used \\begin{} without a corresponding \\end{}.', }, ] -if (!window.wikiEnabled) { - rules.forEach(rule => { - rule.extraInfoURL = null - rule.humanReadableHint = stripHTMLFromString(rule.humanReadableHint) - }) -} - -function stripHTMLFromString(htmlStr) { - const tmp = document.createElement('DIV') - tmp.innerHTML = htmlStr - return tmp.textContent || tmp.innerText || '' -} - export default rules diff --git a/services/web/frontend/js/shared/context/detach-compile-context.js b/services/web/frontend/js/shared/context/detach-compile-context.js new file mode 100644 index 0000000000..8a025b0dc1 --- /dev/null +++ b/services/web/frontend/js/shared/context/detach-compile-context.js @@ -0,0 +1,386 @@ +import { createContext, useContext, useMemo } from 'react' +import PropTypes from 'prop-types' +import { + useLocalCompileContext, + CompileContextPropTypes, +} from './local-compile-context' +import useDetachStateWatcher from '../hooks/use-detach-state-watcher' +import useDetachAction from '../hooks/use-detach-action' + +export const DetachCompileContext = createContext() + +DetachCompileContext.Provider.propTypes = CompileContextPropTypes + +export function DetachCompileProvider({ children }) { + const localCompileContext = useLocalCompileContext() + if (!localCompileContext) { + throw new Error( + 'DetachCompileProvider is only available inside LocalCompileProvider' + ) + } + + const { + autoCompile: _autoCompile, + clearingCache: _clearingCache, + clsiServerId: _clsiServerId, + codeCheckFailed: _codeCheckFailed, + compiling: _compiling, + draft: _draft, + error: _error, + fileList: _fileList, + hasChanges: _hasChanges, + highlights: _highlights, + logEntries: _logEntries, + logEntryAnnotations: _logEntryAnnotations, + pdfDownloadUrl: _pdfDownloadUrl, + pdfUrl: _pdfUrl, + pdfViewer: _pdfViewer, + position: _position, + rawLog: _rawLog, + setAutoCompile: _setAutoCompile, + setDraft: _setDraft, + setError: _setError, + setHasLintingError: _setHasLintingError, + setHighlights: _setHighlights, + setPosition: _setPosition, + setShowLogs: _setShowLogs, + toggleLogs: _toggleLogs, + setStopOnValidationError: _setStopOnValidationError, + showLogs: _showLogs, + stopOnValidationError: _stopOnValidationError, + uncompiled: _uncompiled, + validationIssues: _validationIssues, + firstRenderDone: _firstRenderDone, + cleanupCompileResult: _cleanupCompileResult, + recompileFromScratch: _recompileFromScratch, + setCompiling: _setCompiling, + startCompile: _startCompile, + stopCompile: _stopCompile, + setChangedAt: _setChangedAt, + clearCache: _clearCache, + } = localCompileContext + + const [autoCompile] = useDetachStateWatcher( + 'autoCompile', + _autoCompile, + 'detacher', + 'detached' + ) + const [clearingCache] = useDetachStateWatcher( + 'clearingCache', + _clearingCache, + 'detacher', + 'detached' + ) + const [clsiServerId] = useDetachStateWatcher( + 'clsiServerId', + _clsiServerId, + 'detacher', + 'detached' + ) + const [codeCheckFailed] = useDetachStateWatcher( + 'codeCheckFailed', + _codeCheckFailed, + 'detacher', + 'detached' + ) + const [compiling] = useDetachStateWatcher( + 'compiling', + _compiling, + 'detacher', + 'detached' + ) + const [draft] = useDetachStateWatcher('draft', _draft, 'detacher', 'detached') + const [error] = useDetachStateWatcher('error', _error, 'detacher', 'detached') + const [fileList] = useDetachStateWatcher( + 'fileList', + _fileList, + 'detacher', + 'detached' + ) + const [hasChanges] = useDetachStateWatcher( + 'hasChanges', + _hasChanges, + 'detacher', + 'detached' + ) + const [highlights] = useDetachStateWatcher( + 'highlights', + _highlights, + 'detacher', + 'detached' + ) + const [logEntries] = useDetachStateWatcher( + 'logEntries', + _logEntries, + 'detacher', + 'detached' + ) + const [logEntryAnnotations] = useDetachStateWatcher( + 'logEntryAnnotations', + _logEntryAnnotations, + 'detacher', + 'detached' + ) + const [pdfDownloadUrl] = useDetachStateWatcher( + 'pdfDownloadUrl', + _pdfDownloadUrl, + 'detacher', + 'detached' + ) + const [pdfUrl] = useDetachStateWatcher( + 'pdfUrl', + _pdfUrl, + 'detacher', + 'detached' + ) + const [pdfViewer] = useDetachStateWatcher( + 'pdfViewer', + _pdfViewer, + 'detacher', + 'detached' + ) + const [position] = useDetachStateWatcher( + 'position', + _position, + 'detacher', + 'detached' + ) + const [rawLog] = useDetachStateWatcher( + 'rawLog', + _rawLog, + 'detacher', + 'detached' + ) + const [showLogs] = useDetachStateWatcher( + 'showLogs', + _showLogs, + 'detacher', + 'detached' + ) + const [stopOnValidationError] = useDetachStateWatcher( + 'stopOnValidationError', + _stopOnValidationError, + 'detacher', + 'detached' + ) + const [uncompiled] = useDetachStateWatcher( + 'uncompiled', + _uncompiled, + 'detacher', + 'detached' + ) + const [validationIssues] = useDetachStateWatcher( + 'validationIssues', + _validationIssues, + 'detacher', + 'detached' + ) + + const setAutoCompile = useDetachAction( + 'setAutoCompile', + _setAutoCompile, + 'detached', + 'detacher' + ) + const setDraft = useDetachAction( + 'setDraft', + _setDraft, + 'detached', + 'detacher' + ) + const setError = useDetachAction( + 'setError', + _setError, + 'detacher', + 'detached' + ) + const setPosition = useDetachAction( + 'setPosition', + _setPosition, + 'detached', + 'detacher' + ) + const firstRenderDone = useDetachAction( + 'firstRenderDone', + _firstRenderDone, + 'detacher', + 'detached' + ) + const setHasLintingError = useDetachAction( + 'setHasLintingError', + _setHasLintingError, + 'detacher', + 'detached' + ) + const setHighlights = useDetachAction( + 'setHighlights', + _setHighlights, + 'detacher', + 'detached' + ) + const setShowLogs = useDetachAction( + 'setShowLogs', + _setShowLogs, + 'detached', + 'detacher' + ) + const toggleLogs = useDetachAction( + 'toggleLogs', + _toggleLogs, + 'detached', + 'detacher' + ) + const setStopOnValidationError = useDetachAction( + 'setStopOnValidationError', + _setStopOnValidationError, + 'detached', + 'detacher' + ) + const cleanupCompileResult = useDetachAction( + 'cleanupCompileResult', + _cleanupCompileResult, + 'detached', + 'detacher' + ) + const recompileFromScratch = useDetachAction( + 'recompileFromScratch', + _recompileFromScratch, + 'detached', + 'detacher' + ) + const setCompiling = useDetachAction( + 'setCompiling', + _setCompiling, + 'detacher', + 'detached' + ) + const startCompile = useDetachAction( + 'startCompile', + _startCompile, + 'detached', + 'detacher' + ) + const stopCompile = useDetachAction( + 'stopCompile', + _stopCompile, + 'detached', + 'detacher' + ) + const setChangedAt = useDetachAction( + 'setChangedAt', + _setChangedAt, + 'detached', + 'detacher' + ) + const clearCache = useDetachAction( + 'clearCache', + _clearCache, + 'detached', + 'detacher' + ) + + const value = useMemo( + () => ({ + autoCompile, + clearCache, + clearingCache, + clsiServerId, + codeCheckFailed, + compiling, + draft, + error, + fileList, + hasChanges, + highlights, + logEntryAnnotations, + logEntries, + pdfDownloadUrl, + pdfUrl, + pdfViewer, + position, + rawLog, + recompileFromScratch, + setAutoCompile, + setCompiling, + setDraft, + setError, + setHasLintingError, + setHighlights, + setPosition, + setShowLogs, + toggleLogs, + setStopOnValidationError, + showLogs, + startCompile, + stopCompile, + stopOnValidationError, + uncompiled, + validationIssues, + firstRenderDone, + setChangedAt, + cleanupCompileResult, + }), + [ + autoCompile, + clearCache, + clearingCache, + clsiServerId, + codeCheckFailed, + compiling, + draft, + error, + fileList, + hasChanges, + highlights, + logEntryAnnotations, + logEntries, + pdfDownloadUrl, + pdfUrl, + pdfViewer, + position, + rawLog, + recompileFromScratch, + setAutoCompile, + setCompiling, + setDraft, + setError, + setHasLintingError, + setHighlights, + setPosition, + setShowLogs, + toggleLogs, + setStopOnValidationError, + showLogs, + startCompile, + stopCompile, + stopOnValidationError, + uncompiled, + validationIssues, + firstRenderDone, + setChangedAt, + cleanupCompileResult, + ] + ) + + return ( + + {children} + + ) +} + +DetachCompileProvider.propTypes = { + children: PropTypes.any, +} + +export function useDetachCompileContext(propTypes) { + const data = useContext(DetachCompileContext) + PropTypes.checkPropTypes( + propTypes, + data, + 'data', + 'DetachCompileContext.Provider' + ) + return data +} diff --git a/services/web/frontend/js/shared/context/detach-context.js b/services/web/frontend/js/shared/context/detach-context.js index 41a0acf2cf..c6008d88db 100644 --- a/services/web/frontend/js/shared/context/detach-context.js +++ b/services/web/frontend/js/shared/context/detach-context.js @@ -29,6 +29,7 @@ const debugPdfDetach = getMeta('ol-debugPdfDetach') const SYSEND_CHANNEL = `detach-${getMeta('ol-project_id')}` export function DetachProvider({ children }) { + const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState() const [role, setRole] = useState(() => getMeta('ol-detachRole') || null) const { addHandler: addEventHandler, @@ -94,15 +95,33 @@ export function DetachProvider({ children }) { return () => window.removeEventListener('beforeunload', onBeforeUnload) }, [broadcastEvent]) + useEffect(() => { + const updateLastDetachedConnectedAt = message => { + if (message.role === 'detached' && message.event === 'connected') { + setLastDetachedConnectedAt(new Date()) + } + } + addEventHandler(updateLastDetachedConnectedAt) + return () => deleteEventHandler(updateLastDetachedConnectedAt) + }, [addEventHandler, deleteEventHandler]) + const value = useMemo( () => ({ role, setRole, broadcastEvent, + lastDetachedConnectedAt, addEventHandler, deleteEventHandler, }), - [role, setRole, broadcastEvent, addEventHandler, deleteEventHandler] + [ + role, + setRole, + broadcastEvent, + lastDetachedConnectedAt, + addEventHandler, + deleteEventHandler, + ] ) return ( diff --git a/services/web/frontend/js/shared/context/editor-context.js b/services/web/frontend/js/shared/context/editor-context.js index 1508efa169..04533082d6 100644 --- a/services/web/frontend/js/shared/context/editor-context.js +++ b/services/web/frontend/js/shared/context/editor-context.js @@ -155,7 +155,7 @@ export function EditorProvider({ children, settings }) { EditorProvider.propTypes = { children: PropTypes.any, - settings: PropTypes.any.isRequired, + settings: PropTypes.object, } export function useEditorContext(propTypes) { diff --git a/services/web/frontend/js/shared/context/ide-context.js b/services/web/frontend/js/shared/context/ide-context.js index 52546da157..39175888ff 100644 --- a/services/web/frontend/js/shared/context/ide-context.js +++ b/services/web/frontend/js/shared/context/ide-context.js @@ -1,5 +1,6 @@ -import { createContext, useContext } from 'react' +import { createContext, useContext, useState } from 'react' import PropTypes from 'prop-types' +import { getMockIde } from './mock/mock-ide' const IdeContext = createContext() @@ -20,11 +21,13 @@ export function useIdeContext() { } export function IdeProvider({ ide, children }) { - return {children} + const [value] = useState(() => ide || getMockIde()) + + return {children} } IdeProvider.propTypes = { children: PropTypes.any.isRequired, ide: PropTypes.shape({ $scope: PropTypes.object.isRequired, - }).isRequired, + }), } diff --git a/services/web/frontend/js/shared/context/layout-context.js b/services/web/frontend/js/shared/context/layout-context.js index e03ef54755..5b62f7e557 100644 --- a/services/web/frontend/js/shared/context/layout-context.js +++ b/services/web/frontend/js/shared/context/layout-context.js @@ -108,11 +108,13 @@ export function LayoutProvider({ children }) { isLinking: detachIsLinking, isLinked: detachIsLinked, role: detachRole, + isRedundant: detachIsRedundant, } = useDetachLayout() useEffect(() => { if (debugPdfDetach) { console.log('Layout Effect', { + detachIsRedundant, detachRole, detachIsLinking, detachIsLinked, @@ -121,12 +123,23 @@ export function LayoutProvider({ children }) { if (detachRole !== 'detacher') return // not in a PDF detacher layout + if (detachIsRedundant) { + changeLayout('sideBySide') + return + } + if (detachIsLinking || detachIsLinked) { // the tab is linked to a detached tab (or about to be linked); show // editor only changeLayout('flat', 'editor') } - }, [detachRole, detachIsLinking, detachIsLinked, changeLayout]) + }, [ + detachIsRedundant, + detachRole, + detachIsLinking, + detachIsLinked, + changeLayout, + ]) const value = useMemo( () => ({ diff --git a/services/web/frontend/js/shared/context/compile-context.js b/services/web/frontend/js/shared/context/local-compile-context.js similarity index 93% rename from services/web/frontend/js/shared/context/compile-context.js rename to services/web/frontend/js/shared/context/local-compile-context.js index 0b19de17d7..66f5a10b6a 100644 --- a/services/web/frontend/js/shared/context/compile-context.js +++ b/services/web/frontend/js/shared/context/local-compile-context.js @@ -13,7 +13,11 @@ import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only' import usePersistedState from '../hooks/use-persisted-state' import useAbortController from '../hooks/use-abort-controller' import DocumentCompiler from '../../features/pdf-preview/util/compiler' -import { send, sendMBSampled } from '../../infrastructure/event-tracking' +import { + send, + sendMBOnce, + sendMBSampled, +} from '../../infrastructure/event-tracking' import { buildLogEntryAnnotations, handleLogFiles, @@ -24,9 +28,9 @@ import { useProjectContext } from './project-context' import { useEditorContext } from './editor-context' import { buildFileList } from '../../features/pdf-preview/util/file-list' -export const CompileContext = createContext() +export const LocalCompileContext = createContext() -CompileContext.Provider.propTypes = { +export const CompileContextPropTypes = { value: PropTypes.shape({ autoCompile: PropTypes.bool.isRequired, clearingCache: PropTypes.bool.isRequired, @@ -52,6 +56,7 @@ CompileContext.Provider.propTypes = { setHighlights: PropTypes.func.isRequired, setPosition: PropTypes.func.isRequired, setShowLogs: PropTypes.func.isRequired, + toggleLogs: PropTypes.func.isRequired, setStopOnValidationError: PropTypes.func.isRequired, showLogs: PropTypes.bool.isRequired, stopOnValidationError: PropTypes.bool.isRequired, @@ -62,7 +67,9 @@ CompileContext.Provider.propTypes = { }), } -export function CompileProvider({ children }) { +LocalCompileContext.Provider.propTypes = CompileContextPropTypes + +export function LocalCompileProvider({ children }) { const ide = useIdeContext() const { hasPremiumCompile, isProjectOwner } = useEditorContext() @@ -111,6 +118,15 @@ export function CompileProvider({ children }) { // whether the logs should be visible const [showLogs, setShowLogs] = useState(false) + const toggleLogs = useCallback(() => { + setShowLogs(prev => { + if (!prev) { + sendMBOnce('ide-open-logs-once') + } + return !prev + }) + }, [setShowLogs]) + // an error that occurred const [error, setError] = useState() @@ -445,6 +461,7 @@ export function CompileProvider({ children }) { setHighlights, setPosition, setShowLogs, + toggleLogs, setStopOnValidationError, showLogs, startCompile, @@ -492,20 +509,29 @@ export function CompileProvider({ children }) { firstRenderDone, setChangedAt, cleanupCompileResult, + setShowLogs, + toggleLogs, ] ) return ( - {children} + + {children} + ) } -CompileProvider.propTypes = { +LocalCompileProvider.propTypes = { children: PropTypes.any, } -export function useCompileContext(propTypes) { - const data = useContext(CompileContext) - PropTypes.checkPropTypes(propTypes, data, 'data', 'CompileContext.Provider') +export function useLocalCompileContext(propTypes) { + const data = useContext(LocalCompileContext) + PropTypes.checkPropTypes( + propTypes, + data, + 'data', + 'LocalCompileContext.Provider' + ) return data } diff --git a/services/web/frontend/js/shared/context/mock/mock-ide.js b/services/web/frontend/js/shared/context/mock/mock-ide.js new file mode 100644 index 0000000000..b4a1bc2582 --- /dev/null +++ b/services/web/frontend/js/shared/context/mock/mock-ide.js @@ -0,0 +1,65 @@ +import getMeta from '../../../utils/meta' + +// When rendered without Angular, ide isn't defined. In that case we use +// a mock object that only has the required properties to pass proptypes +// checks and the values needed for the app. In the longer term, the mock +// object will replace ide completely. +export const getMockIde = () => { + return { + _id: getMeta('ol-project_id'), + $scope: { + $on: () => {}, + $watch: () => {}, + $applyAsync: () => {}, + user: {}, + project: { + _id: getMeta('ol-project_id'), + name: getMeta('ol-projectName'), + rootDocId: '', + members: [], + invites: [], + features: { + collaborators: 0, + compileGroup: 'standard', + trackChangesVisible: false, + references: false, + mendeley: false, + zotero: false, + }, + publicAccessLevel: '', + tokens: { + readOnly: '', + readAndWrite: '', + }, + owner: { + _id: '', + email: '', + }, + }, + state: { loading: false }, + permissionsLevel: 'readOnly', + editor: { + sharejs_doc: null, + showSymbolPalette: false, + toggleSymbolPalette: () => {}, + }, + ui: { + view: 'pdf', + chatOpen: false, + reviewPanelOpen: false, + leftMenuShown: false, + pdfLayout: 'flat', + }, + pdf: { + uncompiled: true, + logEntryAnnotations: {}, + }, + settings: { syntaxValidation: false, pdfViewer: 'pdfjs' }, + hasLintingError: false, + }, + editorManager: { + openDoc: () => {}, + getCurrentDocId: () => {}, + }, + } +} diff --git a/services/web/frontend/js/shared/context/root-context.js b/services/web/frontend/js/shared/context/root-context.js index 71d285ea41..eb425fa5ea 100644 --- a/services/web/frontend/js/shared/context/root-context.js +++ b/services/web/frontend/js/shared/context/root-context.js @@ -4,7 +4,8 @@ import createSharedContext from 'react2angular-shared-context' import { UserProvider } from './user-context' import { IdeProvider } from './ide-context' import { EditorProvider } from './editor-context' -import { CompileProvider } from './compile-context' +import { LocalCompileProvider } from './local-compile-context' +import { DetachCompileProvider } from './detach-compile-context' import { LayoutProvider } from './layout-context' import { DetachProvider } from './detach-context' import { ChatProvider } from '../../features/chat/context/chat-context' @@ -22,9 +23,11 @@ export function ContextRoot({ children, ide, settings }) { - - {children} - + + + {children} + + @@ -38,8 +41,8 @@ export function ContextRoot({ children, ide, settings }) { ContextRoot.propTypes = { children: PropTypes.any, - ide: PropTypes.any.isRequired, - settings: PropTypes.any.isRequired, + ide: PropTypes.object, + settings: PropTypes.object, } export const rootContext = createSharedContext(ContextRoot) diff --git a/services/web/frontend/js/shared/context/user-context.js b/services/web/frontend/js/shared/context/user-context.js index 392d6970fc..0a9104b8e5 100644 --- a/services/web/frontend/js/shared/context/user-context.js +++ b/services/web/frontend/js/shared/context/user-context.js @@ -1,6 +1,6 @@ import { createContext, useContext } from 'react' import PropTypes from 'prop-types' -import useScopeValue from '../hooks/use-scope-value' +import getMeta from '../../utils/meta' export const UserContext = createContext() @@ -18,7 +18,7 @@ UserContext.Provider.propTypes = { } export function UserProvider({ children }) { - const [user] = useScopeValue('user', true) + const user = getMeta('ol-user') return {children} } diff --git a/services/web/frontend/js/shared/hooks/use-callback-handlers.js b/services/web/frontend/js/shared/hooks/use-callback-handlers.js index 954ffbdb84..64a7fca6a5 100644 --- a/services/web/frontend/js/shared/hooks/use-callback-handlers.js +++ b/services/web/frontend/js/shared/hooks/use-callback-handlers.js @@ -1,33 +1,21 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef } from 'react' export default function useCallbackHandlers() { - const [handlers, setHandlers] = useState(new Set()) + const handlersRef = useRef(new Set()) - const addHandler = useCallback( - handler => { - setHandlers(prev => new Set(prev.add(handler))) - }, - [setHandlers] - ) + const addHandler = useCallback(handler => { + handlersRef.current.add(handler) + }, []) - const deleteHandler = useCallback( - handler => { - setHandlers(prev => { - prev.delete(handler) - return new Set(prev) - }) - }, - [setHandlers] - ) + const deleteHandler = useCallback(handler => { + handlersRef.current.delete(handler) + }, []) - const callHandlers = useCallback( - (...args) => { - for (const handler of handlers) { - handler(...args) - } - }, - [handlers] - ) + const callHandlers = useCallback((...args) => { + for (const handler of handlersRef.current) { + handler(...args) + } + }, []) return { addHandler, deleteHandler, callHandlers } } diff --git a/services/web/frontend/js/shared/hooks/use-detach-layout.js b/services/web/frontend/js/shared/hooks/use-detach-layout.js index 9425f19e78..d744cf340f 100644 --- a/services/web/frontend/js/shared/hooks/use-detach-layout.js +++ b/services/web/frontend/js/shared/hooks/use-detach-layout.js @@ -20,6 +20,10 @@ export default function useDetachLayout() { // isLinked: when the tab is linked to another tab (of different role) const [isLinked, setIsLinked] = useState(false) + // isRedundant: when a second detacher tab is opened, the first becomes + // redundant + const [isRedundant, setIsRedundant] = useState(false) + const uiTimeoutRef = useRef() useEffect(() => { @@ -76,12 +80,24 @@ export default function useDetachLayout() { }, [setRole, setIsLinked, broadcastEvent]) const detach = useCallback(() => { + setIsRedundant(false) setRole('detacher') setIsLinking(true) window.open(buildUrlWithDetachRole('detached').toString(), '_blank') }, [setRole, setIsLinking]) + const handleEventForDetacherFromDetacher = useCallback(() => { + if (debugPdfDetach) { + console.log( + 'Duplicate detacher detected, turning into a regular editor again' + ) + } + setIsRedundant(true) + setIsLinked(false) + setRole(null) + }, [setRole, setIsLinked]) + const handleEventForDetacherFromDetached = useCallback( message => { switch (message.event) { @@ -122,7 +138,7 @@ export default function useDetachLayout() { [setIsLinked, broadcastEvent] ) - const handleEventFromSelf = useCallback( + const handleEventForDetachedFromDetached = useCallback( message => { switch (message.event) { case 'closed': @@ -137,7 +153,7 @@ export default function useDetachLayout() { message => { if (role === 'detacher') { if (message.role === 'detacher') { - handleEventFromSelf(message) + handleEventForDetacherFromDetacher(message) } else if (message.role === 'detached') { handleEventForDetacherFromDetached(message) } @@ -145,15 +161,16 @@ export default function useDetachLayout() { if (message.role === 'detacher') { handleEventForDetachedFromDetacher(message) } else if (message.role === 'detached') { - handleEventFromSelf(message) + handleEventForDetachedFromDetached(message) } } }, [ role, + handleEventForDetacherFromDetacher, handleEventForDetacherFromDetached, handleEventForDetachedFromDetacher, - handleEventFromSelf, + handleEventForDetachedFromDetached, ] ) @@ -168,5 +185,6 @@ export default function useDetachLayout() { isLinked, isLinking, role, + isRedundant, } } diff --git a/services/web/frontend/js/shared/hooks/use-detach-state-watcher.js b/services/web/frontend/js/shared/hooks/use-detach-state-watcher.js new file mode 100644 index 0000000000..9154535677 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-detach-state-watcher.js @@ -0,0 +1,22 @@ +import { useEffect } from 'react' +import useDetachState from './use-detach-state' + +export default function useDetachStateWatcher( + key, + stateValue, + senderRole, + targetRole +) { + const [value, setValue] = useDetachState( + key, + stateValue, + senderRole, + targetRole + ) + + useEffect(() => { + setValue(stateValue) + }, [setValue, stateValue]) + + return [value, setValue] +} diff --git a/services/web/frontend/js/shared/hooks/use-detach-state.js b/services/web/frontend/js/shared/hooks/use-detach-state.js index e8f9aeec54..6b9cbf7f00 100644 --- a/services/web/frontend/js/shared/hooks/use-detach-state.js +++ b/services/web/frontend/js/shared/hooks/use-detach-state.js @@ -12,16 +12,30 @@ export default function useDetachState( ) { const [value, setValue] = useState(defaultValue) - const { role, broadcastEvent, addEventHandler, deleteEventHandler } = - useDetachContext() + const { + role, + broadcastEvent, + lastDetachedConnectedAt, + addEventHandler, + deleteEventHandler, + } = useDetachContext() const eventName = `state-${key}` + // lastDetachedConnectedAt is added as a dependency in order to re-broadcast + // all states when a new detached tab connects useEffect(() => { if (role === senderRole) { broadcastEvent(eventName, { value }) } - }, [role, senderRole, eventName, value, broadcastEvent]) + }, [ + role, + senderRole, + eventName, + value, + broadcastEvent, + lastDetachedConnectedAt, + ]) const handleStateEvent = useCallback( message => { diff --git a/services/web/frontend/js/shared/hooks/use-persisted-state.js b/services/web/frontend/js/shared/hooks/use-persisted-state.js index c66247067e..e01abb97b8 100644 --- a/services/web/frontend/js/shared/hooks/use-persisted-state.js +++ b/services/web/frontend/js/shared/hooks/use-persisted-state.js @@ -37,7 +37,7 @@ function usePersistedState(key, defaultValue, listen = false) { if (event.key === key) { // note: this value is read via getItem rather than from event.newValue // because getItem handles deserializing the JSON that's stored in localStorage. - setValue(localStorage.getItem(key)) + setValue(localStorage.getItem(key) ?? defaultValue) } } @@ -47,7 +47,7 @@ function usePersistedState(key, defaultValue, listen = false) { window.removeEventListener('storage', listener) } } - }, [key, listen]) + }, [key, listen, defaultValue]) return [value, updateFunction] } diff --git a/services/web/frontend/stories/pdf-preview.stories.js b/services/web/frontend/stories/pdf-preview.stories.js index 06ef750e95..5fb15a5fac 100644 --- a/services/web/frontend/stories/pdf-preview.stories.js +++ b/services/web/frontend/stories/pdf-preview.stories.js @@ -10,7 +10,7 @@ import { buildFileList } from '../js/features/pdf-preview/util/file-list' import PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer' import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error' import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar' -import { useCompileContext } from '../js/shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../js/shared/context/detach-compile-context' import { dispatchDocChanged, mockBuildFile, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 9013d9128e..c5119a75ce 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1580,6 +1580,7 @@ "project_layout_sharing_submission": "Project Layout, Sharing, and Submission", "pdf_in_separate_tab": "PDF in separate tab", "tab_no_longer_connected": "This tab is no longer connected with the editor", + "tab_connecting": "Connecting with the editor", "redirect_to_editor": "Redirect to editor", "layout_processing": "Layout processing", "show_in_code": "Show in code", diff --git a/services/web/test/frontend/features/chat/components/chat-pane.test.js b/services/web/test/frontend/features/chat/components/chat-pane.test.js index 48a0b6de63..d7bd8ce038 100644 --- a/services/web/test/frontend/features/chat/components/chat-pane.test.js +++ b/services/web/test/frontend/features/chat/components/chat-pane.test.js @@ -15,24 +15,27 @@ import { stubMathJax, tearDownMathJaxStubs } from './stubs' import sinon from 'sinon' describe('', function () { + const user = { + id: 'fake_user', + first_name: 'fake_user_first_name', + email: 'fake@example.com', + } + beforeEach(function () { this.clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], }) + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', user) }) afterEach(function () { this.clock.runAll() this.clock.restore() fetchMock.reset() + window.metaAttributesCache = new Map() }) - const user = { - id: 'fake_user', - first_name: 'fake_user_first_name', - email: 'fake@example.com', - } - const testMessages = [ { id: 'msg_1', diff --git a/services/web/test/frontend/features/chat/context/chat-context.test.js b/services/web/test/frontend/features/chat/context/chat-context.test.js index 75c4c9d653..ed2e5e7999 100644 --- a/services/web/test/frontend/features/chat/context/chat-context.test.js +++ b/services/web/test/frontend/features/chat/context/chat-context.test.js @@ -25,10 +25,15 @@ describe('ChatContext', function () { cleanUpContext() stubMathJax() + + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', user) }) afterEach(function () { tearDownMathJaxStubs() + + window.metaAttributesCache = new Map() }) describe('socket connection', function () { @@ -42,7 +47,7 @@ describe('ChatContext', function () { it('subscribes when mounted', function () { const socket = new EventEmitter() - renderChatContextHook({ user, socket }) + renderChatContextHook({ socket }) // Assert that there is 1 listener expect(socket.rawListeners('new-chat-message').length).to.equal(1) @@ -50,7 +55,7 @@ describe('ChatContext', function () { it('unsubscribes when unmounted', function () { const socket = new EventEmitter() - const { unmount } = renderChatContextHook({ user, socket }) + const { unmount } = renderChatContextHook({ socket }) unmount() @@ -62,7 +67,6 @@ describe('ChatContext', function () { // Mock socket: we only need to emit events, not mock actual connections const socket = new EventEmitter() const { result, waitForNextUpdate } = renderChatContextHook({ - user, socket, }) @@ -93,7 +97,6 @@ describe('ChatContext', function () { it("doesn't add received messages from the current user if a message was just sent", async function () { const socket = new EventEmitter() const { result, waitForNextUpdate } = renderChatContextHook({ - user, socket, }) @@ -123,7 +126,6 @@ describe('ChatContext', function () { it('adds the new message from the current user if another message was received after sending', async function () { const socket = new EventEmitter() const { result, waitForNextUpdate } = renderChatContextHook({ - user, socket, }) @@ -187,7 +189,7 @@ describe('ChatContext', function () { }) it('adds messages to the list', async function () { - const { result, waitForNextUpdate } = renderChatContextHook({ user }) + const { result, waitForNextUpdate } = renderChatContextHook({}) result.current.loadInitialMessages() await waitForNextUpdate() @@ -196,7 +198,7 @@ describe('ChatContext', function () { }) it("won't load messages a second time", async function () { - const { result, waitForNextUpdate } = renderChatContextHook({ user }) + const { result, waitForNextUpdate } = renderChatContextHook({}) result.current.loadInitialMessages() await waitForNextUpdate() @@ -211,7 +213,7 @@ describe('ChatContext', function () { it('provides an error on failure', async function () { fetchMock.reset() fetchMock.get('express:/project/:projectId/messages', 500) - const { result, waitForNextUpdate } = renderChatContextHook({ user }) + const { result, waitForNextUpdate } = renderChatContextHook({}) result.current.loadInitialMessages() await waitForNextUpdate() @@ -233,7 +235,7 @@ describe('ChatContext', function () { }, ]) - const { result, waitForNextUpdate } = renderChatContextHook({ user }) + const { result, waitForNextUpdate } = renderChatContextHook({}) result.current.loadMoreMessages() await waitForNextUpdate() @@ -267,7 +269,7 @@ describe('ChatContext', function () { { overwriteRoutes: false } ) - const { result, waitForNextUpdate } = renderChatContextHook({ user }) + const { result, waitForNextUpdate } = renderChatContextHook({}) result.current.loadMoreMessages() await waitForNextUpdate() @@ -297,7 +299,7 @@ describe('ChatContext', function () { createMessages(49, user) ) - const { result, waitForNextUpdate } = renderChatContextHook({ user }) + const { result, waitForNextUpdate } = renderChatContextHook({}) result.current.loadMoreMessages() await waitForNextUpdate() @@ -322,7 +324,6 @@ describe('ChatContext', function () { const socket = new EventEmitter() const { result, waitForNextUpdate } = renderChatContextHook({ - user, socket, }) @@ -367,7 +368,7 @@ describe('ChatContext', function () { it('provides an error on failures', async function () { fetchMock.reset() fetchMock.get('express:/project/:projectId/messages', 500) - const { result, waitForNextUpdate } = renderChatContextHook({ user }) + const { result, waitForNextUpdate } = renderChatContextHook({}) result.current.loadMoreMessages() await waitForNextUpdate() @@ -387,7 +388,7 @@ describe('ChatContext', function () { }) it('optimistically adds the message to the list', function () { - const { result } = renderChatContextHook({ user }) + const { result } = renderChatContextHook({}) result.current.sendMessage('sent message') @@ -397,7 +398,7 @@ describe('ChatContext', function () { }) it('POSTs the message to the backend', function () { - const { result } = renderChatContextHook({ user }) + const { result } = renderChatContextHook({}) result.current.sendMessage('sent message') @@ -409,7 +410,7 @@ describe('ChatContext', function () { }) it("doesn't send if the content is empty", function () { - const { result } = renderChatContextHook({ user }) + const { result } = renderChatContextHook({}) result.current.sendMessage('') @@ -426,7 +427,7 @@ describe('ChatContext', function () { fetchMock .get('express:/project/:projectId/messages', []) .postOnce('express:/project/:projectId/messages', 500) - const { result, waitForNextUpdate } = renderChatContextHook({ user }) + const { result, waitForNextUpdate } = renderChatContextHook({}) result.current.sendMessage('sent message') await waitForNextUpdate() @@ -444,7 +445,7 @@ describe('ChatContext', function () { it('increments unreadMessageCount when a new message is received', function () { const socket = new EventEmitter() - const { result } = renderChatContextHook({ user, socket }) + const { result } = renderChatContextHook({ socket }) // Receive a new message from the socket socket.emit('new-chat-message', { @@ -459,7 +460,7 @@ describe('ChatContext', function () { it('resets unreadMessageCount when markMessagesAsRead is called', function () { const socket = new EventEmitter() - const { result } = renderChatContextHook({ user, socket }) + const { result } = renderChatContextHook({ socket }) // Receive a new message from the socket, incrementing unreadMessageCount // by 1 diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js index 06f07573b9..951f1e5cf9 100644 --- a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js +++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js @@ -18,7 +18,6 @@ describe('', function () { beforeEach(function () { openStub = sinon.stub(window, 'open') window.metaAttributesCache = new Map() - fetchMock.post('express:/project/:projectId/compile/stop', () => 204) }) afterEach(function () { @@ -101,11 +100,6 @@ describe('', function () { screen.getByText('Layout processing') }) - it('should stop compile when detaching', function () { - expect(fetchMock.called('express:/project/:projectId/compile/stop')).to.be - .true - }) - it('should record event', function () { sinon.assert.calledWith(eventTrackingSpy.sendMB, 'project-layout-detach') }) diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js b/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js index d8247d1123..11c498191a 100644 --- a/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js +++ b/services/web/test/frontend/features/file-tree/components/file-tree-root.test.js @@ -15,6 +15,8 @@ describe('', function () { beforeEach(function () { global.requestAnimationFrame = sinon.stub() + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', { id: 'user1' }) }) afterEach(function () { @@ -24,6 +26,7 @@ describe('', function () { onInit.reset() cleanUpContext() global.localStorage.clear() + window.metaAttributesCache = new Map() }) it('renders', function () { diff --git a/services/web/test/frontend/features/file-tree/flows/context-menu.test.js b/services/web/test/frontend/features/file-tree/flows/context-menu.test.js index 005b782d50..4710b5847c 100644 --- a/services/web/test/frontend/features/file-tree/flows/context-menu.test.js +++ b/services/web/test/frontend/features/file-tree/flows/context-menu.test.js @@ -12,10 +12,16 @@ describe('FileTree Context Menu Flow', function () { const onSelect = sinon.stub() const onInit = sinon.stub() + beforeEach(function () { + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', { id: 'user1' }) + }) + afterEach(function () { onSelect.reset() onInit.reset() cleanUpContext() + window.metaAttributesCache = new Map() }) it('opens on contextMenu event', async function () { diff --git a/services/web/test/frontend/features/file-tree/flows/create-folder.test.js b/services/web/test/frontend/features/file-tree/flows/create-folder.test.js index 27a2e417ca..e46a86b3ca 100644 --- a/services/web/test/frontend/features/file-tree/flows/create-folder.test.js +++ b/services/web/test/frontend/features/file-tree/flows/create-folder.test.js @@ -16,6 +16,8 @@ describe('FileTree Create Folder Flow', function () { beforeEach(function () { global.requestAnimationFrame = sinon.stub() + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', { id: 'user1' }) }) afterEach(function () { @@ -24,6 +26,7 @@ describe('FileTree Create Folder Flow', function () { onSelect.reset() onInit.reset() cleanUpContext() + window.metaAttributesCache = new Map() }) it('add to root when no files are selected', async function () { diff --git a/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js b/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js index 561b6a6fe0..75b13ff82a 100644 --- a/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js +++ b/services/web/test/frontend/features/file-tree/flows/delete-entity.test.js @@ -14,11 +14,17 @@ describe('FileTree Delete Entity Flow', function () { const onSelect = sinon.stub() const onInit = sinon.stub() + beforeEach(function () { + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', { id: 'user1' }) + }) + afterEach(function () { fetchMock.restore() onSelect.reset() onInit.reset() cleanUpContext() + window.metaAttributesCache = new Map() }) describe('single entity', function () { diff --git a/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js b/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js index 6d3cc9225a..9c2fd64c0d 100644 --- a/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js +++ b/services/web/test/frontend/features/file-tree/flows/rename-entity.test.js @@ -16,6 +16,8 @@ describe('FileTree Rename Entity Flow', function () { beforeEach(function () { global.requestAnimationFrame = sinon.stub() + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', { id: 'user1' }) }) afterEach(function () { @@ -24,6 +26,7 @@ describe('FileTree Rename Entity Flow', function () { onSelect.reset() onInit.reset() cleanUpContext() + window.metaAttributesCache = new Map() }) beforeEach(function () { diff --git a/services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js b/services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js index 6e099a6e19..f2c5ac4b50 100644 --- a/services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js +++ b/services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js @@ -1,6 +1,6 @@ import DetachCompileButton from '../../../../../frontend/js/features/pdf-preview/components/detach-compile-button' import { renderWithEditorContext } from '../../../helpers/render-with-context' -import { screen, fireEvent } from '@testing-library/react' +import { screen } from '@testing-library/react' import sysendTestHelper from '../../../helpers/sysend' import { expect } from 'chai' @@ -48,23 +48,4 @@ describe('', function () { }) ).to.not.exist }) - - it('send compile clicks via detached action', async function () { - window.metaAttributesCache.set('ol-detachRole', 'detacher') - renderWithEditorContext() - sysendTestHelper.receiveMessage({ - role: 'detached', - event: 'connected', - }) - - const compileButton = await screen.getByRole('button', { - name: 'Recompile', - }) - fireEvent.click(compileButton) - expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ - role: 'detacher', - event: 'action-start-compile', - data: { args: [] }, - }) - }) }) diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-logs-entries.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-logs-entries.test.js index 3995c994e5..51796f6275 100644 --- a/services/web/test/frontend/features/pdf-preview/components/pdf-logs-entries.test.js +++ b/services/web/test/frontend/features/pdf-preview/components/pdf-logs-entries.test.js @@ -17,9 +17,7 @@ describe('', function () { message: 'LaTeX Error', content: 'See the LaTeX manual', raw: '', - ruleId: 'latex_error', - humanReadableHint: '', - humanReadableHintComponent: <>, + ruleId: 'hint_misplaced_alignment_tab_character', key: '', }, ] @@ -36,6 +34,14 @@ describe('', function () { fileTreeManager.findEntityByPath.resetHistory() }) + it('displays human readable hint', async function () { + renderWithEditorContext(, { + fileTreeManager, + editorManager, + }) + screen.getByText(/You have placed an alignment tab character/) + }) + it('opens doc on click', async function () { renderWithEditorContext(, { fileTreeManager, diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-preview-detached-root.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-preview-detached-root.test.js new file mode 100644 index 0000000000..6af6d9ca32 --- /dev/null +++ b/services/web/test/frontend/features/pdf-preview/components/pdf-preview-detached-root.test.js @@ -0,0 +1,70 @@ +import { expect } from 'chai' +import { render, screen, fireEvent } from '@testing-library/react' +import sysendTestHelper from '../../../helpers/sysend' +import PdfPreviewDetachedRoot from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root' + +describe('', function () { + beforeEach(function () { + const user = { id: 'user1' } + window.user = user + + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', user) + window.metaAttributesCache.set('ol-project_id', 'project1') + window.metaAttributesCache.set('ol-detachRole', 'detached') + window.metaAttributesCache.set('ol-projectName', 'Project Name') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('syncs compiling state', async function () { + render() + + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'connected', + }) + + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'state-compiling', + data: { value: true }, + }) + await screen.findByRole('button', { name: 'Compiling…' }) + expect(screen.queryByRole('button', { name: 'Recompile' })).to.not.exist + + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'state-compiling', + data: { value: false }, + }) + await screen.findByRole('button', { name: 'Recompile' }) + expect(screen.queryByRole('button', { name: 'Compiling…' })).to.not.exist + }) + + it('sends a clear cache request when the button is pressed', async function () { + render() + + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'state-showLogs', + data: { value: true }, + }) + + const clearCacheButton = await screen.findByRole('button', { + name: 'Clear cached files', + }) + expect(clearCacheButton.hasAttribute('disabled')).to.be.false + + fireEvent.click(clearCacheButton) + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detached', + event: 'action-clearCache', + data: { + args: [], + }, + }) + }) +}) diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js index 28eab021ad..6167bd1a10 100644 --- a/services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js +++ b/services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js @@ -1,10 +1,21 @@ +import sinon from 'sinon' import PdfPreviewHybridToolbar from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar' import { renderWithEditorContext } from '../../../helpers/render-with-context' import { screen } from '@testing-library/react' +import sysendTestHelper from '../../../helpers/sysend' describe('', function () { + let clock + + beforeEach(function () { + clock = sinon.useFakeTimers() + }) + afterEach(function () { window.metaAttributesCache = new Map() + sysendTestHelper.resetHistory() + clock.runAll() + clock.restore() }) it('shows normal mode', async function () { @@ -15,12 +26,49 @@ describe('', function () { }) }) - it('shows orphan mode', async function () { - window.metaAttributesCache.set('ol-detachRole', 'detached') - renderWithEditorContext() + describe('orphan mode', async function () { + it('shows connecting message on load', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + renderWithEditorContext() - await screen.getByRole('button', { - name: 'Redirect to editor', + await screen.getByText(/Connecting with the editor/) + }) + + it('shows compile UI when connected', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + renderWithEditorContext() + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'connected', + }) + await screen.getByRole('button', { + name: 'Recompile', + }) + }) + + it('shows connecting message when disconnected', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + renderWithEditorContext() + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'connected', + }) + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'closed', + }) + + await screen.getByText(/Connecting with the editor/) + }) + + it('shows redirect button after timeout', async function () { + window.metaAttributesCache.set('ol-detachRole', 'detached') + renderWithEditorContext() + clock.tick(6000) + + await screen.getByRole('button', { + name: 'Redirect to editor', + }) }) }) }) diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js index 6e743bf0f2..501c6d4b6d 100644 --- a/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js +++ b/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js @@ -350,7 +350,10 @@ describe('', function () { // click the button clearCacheButton.click() - expect(clearCacheButton.hasAttribute('disabled')).to.be.true + await waitFor(() => { + expect(clearCacheButton.hasAttribute('disabled')).to.be.true + }) + await waitFor(() => { expect(clearCacheButton.hasAttribute('disabled')).to.be.false }) @@ -382,7 +385,7 @@ describe('', function () { expect(clearCacheButton.hasAttribute('disabled')).to.be.false mockValidPdf() - mockClearCache() + const finishClearCache = mockDelayed(mockClearCache) const recompileFromScratch = screen.getByRole('menuitem', { name: 'Recompile from scratch', @@ -390,7 +393,11 @@ describe('', function () { }) recompileFromScratch.click() - expect(clearCacheButton.hasAttribute('disabled')).to.be.true + await waitFor(() => { + expect(clearCacheButton.hasAttribute('disabled')).to.be.true + }) + + finishClearCache() // wait for compile to finish await screen.findByRole('button', { name: 'Compiling…' }) diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js index 5cb5e6baec..aa91463237 100644 --- a/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js +++ b/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js @@ -7,7 +7,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import fs from 'fs' import path from 'path' import { expect } from 'chai' -import { useCompileContext } from '../../../../../frontend/js/shared/context/compile-context' +import { useDetachCompileContext as useCompileContext } from '../../../../../frontend/js/shared/context/detach-compile-context' import { useFileTreeData } from '../../../../../frontend/js/shared/context/file-tree-data-context' import { useEffect } from 'react' @@ -122,7 +122,6 @@ const WithSelectedEntities = ({ mockSelectedEntities = [] }) => { return null } - describe('', function () { beforeEach(function () { window.metaAttributesCache = new Map() @@ -185,7 +184,6 @@ describe('', function () { .true }) }) - it('disables button when multiple entities are selected', async function () { renderWithEditorContext( <> @@ -236,9 +234,14 @@ describe('', function () { }) it('does not have go to PDF location button nor arrow icon', async function () { - const { container } = renderWithEditorContext(, { - scope, - }) + const { container } = renderWithEditorContext( + <> + + + + , + { scope } + ) expect( await screen.queryByRole('button', { @@ -249,7 +252,50 @@ describe('', function () { expect(container.querySelector('.synctex-control-icon')).to.not.exist }) - it('send go to PDF location action', async function () { + it('send set highlights action', async function () { + renderWithEditorContext( + <> + + + + , + { scope } + ) + sysendTestHelper.resetHistory() + + const syncToPdfButton = await screen.findByRole('button', { + name: 'Go to code location in PDF', + }) + + // mock editor cursor position update + fireEvent( + window, + new CustomEvent('cursor:editor:update', { + detail: { row: 100, column: 10 }, + }) + ) + + expect(syncToPdfButton.disabled).to.be.false + + fireEvent.click(syncToPdfButton) + + expect(syncToPdfButton.disabled).to.be.true + + await waitFor(() => { + expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be + .true + }) + + // synctex is called locally and the result are broadcast for the detached + // tab + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detacher', + event: 'action-setHighlights', + data: { args: [mockHighlights] }, + }) + }) + + it('reacts to sync to code action', async function () { renderWithEditorContext( <> @@ -259,74 +305,23 @@ describe('', function () { { scope } ) - sysendTestHelper.resetHistory() - - const syncToPdfButton = await screen.findByRole('button', { - name: 'Go to code location in PDF', + await waitFor(() => { + expect(fetchMock.called('express:/project/:projectId/compile')).to.be + .true }) - // mock editor cursor position update - fireEvent( - window, - new CustomEvent('cursor:editor:update', { - detail: { row: 100, column: 10 }, - }) - ) - - fireEvent.click(syncToPdfButton) - - // the button is only disabled when the state is updated via sysend - expect(syncToPdfButton.disabled).to.be.false - - expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ - role: 'detacher', - event: 'action-go-to-pdf-location', - data: { args: ['file=&line=101&column=10'] }, - }) - }) - - it('update inflight state', async function () { - const { container } = renderWithEditorContext( - <> - - - - , - { scope } - ) - sysendTestHelper.resetHistory() - - const syncToPdfButton = await screen.findByRole('button', { - name: 'Go to code location in PDF', - }) - - // mock editor cursor position update - fireEvent( - window, - new CustomEvent('cursor:editor:update', { - detail: { row: 100, column: 10 }, - }) - ) - sysendTestHelper.receiveMessage({ role: 'detached', - event: 'state-sync-to-pdf-inflight', - data: { value: true }, + event: 'action-sync-to-code', + data: { + args: [mockPosition], + }, }) - expect(syncToPdfButton.disabled).to.be.true - expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal( - 1 - ) - sysendTestHelper.receiveMessage({ - role: 'detached', - event: 'state-sync-to-pdf-inflight', - data: { value: false }, + await waitFor(() => { + expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be + .true }) - expect(syncToPdfButton.disabled).to.be.false - expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal( - 0 - ) }) }) @@ -336,9 +331,13 @@ describe('', function () { }) it('does not have go to code location button nor arrow icon', async function () { - const { container } = renderWithEditorContext(, { - scope, - }) + const { container } = renderWithEditorContext( + <> + + + , + { scope } + ) expect( await screen.queryByRole('button', { @@ -349,102 +348,90 @@ describe('', function () { expect(container.querySelector('.synctex-control-icon')).to.not.exist }) - it('send go to code line action and update inflight state', async function () { + it('send go to code line action', async function () { const { container } = renderWithEditorContext( <> - , { scope } ) - sysendTestHelper.resetHistory() const syncToCodeButton = await screen.findByRole('button', { name: /Go to PDF location in code/, }) + expect(syncToCodeButton.disabled).to.be.true + + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'state-has-single-selected-doc', + data: { value: true }, + }) + expect(syncToCodeButton.disabled).to.be.false sysendTestHelper.resetHistory() + fireEvent.click(syncToCodeButton) + + // the button is only disabled when the state is updated via sysend + expect(syncToCodeButton.disabled).to.be.false + expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal( + 0 + ) + + expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ + role: 'detached', + event: 'action-sync-to-code', + data: { + args: [mockPosition, 72], + }, + }) + }) + + it('update inflight state', async function () { + const { container } = renderWithEditorContext( + <> + + + , + { scope } + ) + sysendTestHelper.receiveMessage({ + role: 'detached', + event: 'state-has-single-selected-doc', + data: { value: true }, + }) + + const syncToCodeButton = await screen.findByRole('button', { + name: /Go to PDF location in code/, + }) + expect(syncToCodeButton.disabled).to.be.false expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal( 0 ) - fireEvent.click(syncToCodeButton) + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'state-sync-to-code-inflight', + data: { value: true }, + }) expect(syncToCodeButton.disabled).to.be.true expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal( 1 ) - await waitFor(() => { - expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be - .true - }) - - expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ - role: 'detached', - event: 'action-go-to-code-line', - data: { args: ['main.tex', 100] }, - }) - }) - - it('sends PDF exists state', async function () { - renderWithEditorContext( - <> - - - - , - { scope } - ) - sysendTestHelper.resetHistory() - - await waitFor(() => { - expect(fetchMock.called('express:/project/:projectId/compile')).to.be - .true - }) - expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ - role: 'detached', - event: 'state-pdf-exists', - data: { value: true }, - }) - }) - - it('reacts to go to PDF location action', async function () { - renderWithEditorContext( - <> - - - - , - { scope } - ) - sysendTestHelper.resetHistory() - - await waitFor(() => { - expect(fetchMock.called('express:/project/:projectId/compile')).to.be - .true - }) - sysendTestHelper.spy.broadcast.resetHistory() - sysendTestHelper.receiveMessage({ role: 'detacher', - event: 'action-go-to-pdf-location', - data: { args: ['file=&line=101&column=10'] }, - }) - - await waitFor(() => { - expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be - .true - }) - - expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ - role: 'detached', - event: 'state-sync-to-pdf-inflight', + event: 'state-sync-to-code-inflight', data: { value: false }, }) + + expect(syncToCodeButton.disabled).to.be.false + expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal( + 0 + ) }) }) }) diff --git a/services/web/test/frontend/features/pdf-preview/utils/mock-compile.js b/services/web/test/frontend/features/pdf-preview/utils/mock-compile.js index 05caf4f8bc..55d78820d4 100644 --- a/services/web/test/frontend/features/pdf-preview/utils/mock-compile.js +++ b/services/web/test/frontend/features/pdf-preview/utils/mock-compile.js @@ -82,8 +82,15 @@ export const mockValidationProblems = validationProblems => }, }) -export const mockClearCache = () => - fetchMock.delete('express:/project/:projectId/output', 204) +export const mockClearCache = (delayPromise = Promise.resolve()) => + fetchMock.delete( + 'express:/project/:projectId/output', + delayPromise.then(() => ({ + body: { + status: 204, + }, + })) + ) export const mockValidPdf = () => { nock('https://clsi.test-overleaf.com') diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js index 5f9ac64cd4..fde6a57430 100644 --- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js +++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js @@ -83,11 +83,14 @@ describe('', function () { beforeEach(function () { fetchMock.get('/user/contacts', { contacts }) + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true }) }) afterEach(function () { fetchMock.restore() cleanUpContext() + window.metaAttributesCache = new Map() }) it('renders the modal', async function () { @@ -179,7 +182,7 @@ describe('', function () { ] // render as admin: actions should be present - const { rerender } = render( + render( ', function () { await screen.findByRole('button', { name: 'Resend' }) // render as non-admin (non-owner), link sharing on: actions should be missing and message should be present - rerender( + render( ', function () { expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null // render as non-admin (non-owner), link sharing off: actions should be missing and message should be present - rerender( + render( ', function () { ) renderWithEditorContext(, { - user: { - id: '123abd', - allowedFreeTrial: true, - }, scope: { project: { ...project, diff --git a/services/web/test/frontend/helpers/editor-providers.js b/services/web/test/frontend/helpers/editor-providers.js index 4335a82aa7..40878b87ea 100644 --- a/services/web/test/frontend/helpers/editor-providers.js +++ b/services/web/test/frontend/helpers/editor-providers.js @@ -10,7 +10,8 @@ import { FileTreeDataProvider } from '../../../frontend/js/shared/context/file-t import { EditorProvider } from '../../../frontend/js/shared/context/editor-context' import { DetachProvider } from '../../../frontend/js/shared/context/detach-context' import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context' -import { CompileProvider } from '../../../frontend/js/shared/context/compile-context' +import { LocalCompileProvider } from '../../../frontend/js/shared/context/local-compile-context' +import { DetachCompileProvider } from '../../../frontend/js/shared/context/detach-compile-context' // these constants can be imported in tests instead of // using magic strings @@ -110,7 +111,9 @@ export function EditorProviders({ - {children} + + {children} + diff --git a/services/web/test/frontend/helpers/sysend.js b/services/web/test/frontend/helpers/sysend.js index e0fe0fed06..214e168b20 100644 --- a/services/web/test/frontend/helpers/sysend.js +++ b/services/web/test/frontend/helpers/sysend.js @@ -25,6 +25,10 @@ function getLastBroacastMessage() { return getLastDetachCall('broadcast').args[1] } +function getAllBroacastMessages() { + return getDetachCalls('broadcast') +} + // this fakes receiving a message by calling the handler add to `on`. A bit // funky, but works for now function receiveMessage(message) { @@ -37,5 +41,6 @@ export default { getDetachCalls, getLastDetachCall, getLastBroacastMessage, + getAllBroacastMessages, receiveMessage, } diff --git a/services/web/test/frontend/shared/hooks/use-detach-layout.test.js b/services/web/test/frontend/shared/hooks/use-detach-layout.test.js index 9cdb1f015d..41d8c8abb1 100644 --- a/services/web/test/frontend/shared/hooks/use-detach-layout.test.js +++ b/services/web/test/frontend/shared/hooks/use-detach-layout.test.js @@ -46,6 +46,7 @@ describe('useDetachLayout', function () { }) it('detacher role', async function () { + sysendTestHelper.spy.broadcast.resetHistory() window.metaAttributesCache.set('ol-detachRole', 'detacher') // 1. create hook in detacher mode @@ -55,6 +56,8 @@ describe('useDetachLayout', function () { expect(result.current.isLinked).to.be.false expect(result.current.isLinking).to.be.false expect(result.current.role).to.equal('detacher') + const broadcastMessagesCount = + sysendTestHelper.getAllBroacastMessages().length // 2. simulate connected detached tab sysendTestHelper.spy.broadcast.resetHistory() @@ -70,6 +73,12 @@ describe('useDetachLayout', function () { expect(result.current.isLinking).to.be.false expect(result.current.role).to.equal('detacher') + // check that all message were re-broadcast for the new tab + await nextTick() // necessary to ensure all event handler have run + const reBroadcastMessagesCount = + sysendTestHelper.getAllBroacastMessages().length + expect(reBroadcastMessagesCount).to.equal(broadcastMessagesCount) + // 3. simulate closed detached tab sysendTestHelper.spy.broadcast.resetHistory() sysendTestHelper.receiveMessage({ @@ -90,21 +99,7 @@ describe('useDetachLayout', function () { expect(result.current.isLinking).to.be.false expect(result.current.role).to.equal('detacher') - // 5. simulate closed detacher tab - sysendTestHelper.spy.broadcast.resetHistory() - sysendTestHelper.receiveMessage({ - role: 'detacher', - event: 'closed', - }) - expect(result.current.isLinked).to.be.true - expect(result.current.isLinking).to.be.false - expect(result.current.role).to.equal('detacher') - expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({ - role: 'detacher', - event: 'up', - }) - - // 6. reattach + // 5. reattach sysendTestHelper.spy.broadcast.resetHistory() act(() => { result.current.reattach() @@ -118,6 +113,26 @@ describe('useDetachLayout', function () { }) }) + it('reset detacher role when other detacher tab connects', function () { + window.metaAttributesCache.set('ol-detachRole', 'detacher') + + // 1. create hook in detacher mode + const { result } = renderHookWithEditorContext(() => useDetachLayout()) + expect(result.current.reattach).to.be.a('function') + expect(result.current.detach).to.be.a('function') + expect(result.current.isLinked).to.be.false + expect(result.current.isLinking).to.be.false + expect(result.current.role).to.equal('detacher') + + // 2. simulate other detacher tab + sysendTestHelper.receiveMessage({ + role: 'detacher', + event: 'up', + }) + expect(result.current.isRedundant).to.be.true + expect(result.current.role).to.equal(null) + }) + it('detached role', async function () { window.metaAttributesCache.set('ol-detachRole', 'detached') @@ -185,3 +200,9 @@ describe('useDetachLayout', function () { sinon.assert.called(closeStub) }) }) + +const nextTick = () => { + return new Promise(resolve => { + setTimeout(resolve) + }) +} diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index 3acb09d1bf..ae6a9ee2de 100644 --- a/services/web/webpack.config.js +++ b/services/web/webpack.config.js @@ -12,6 +12,7 @@ const entryPoints = { serviceWorker: './frontend/js/serviceWorker.js', main: './frontend/js/main.js', ide: './frontend/js/ide.js', + 'ide-detached': './frontend/js/ide-detached.js', marketing: './frontend/js/marketing.js', style: './frontend/stylesheets/style.less', 'ieee-style': './frontend/stylesheets/ieee-style.less',