From f7044ef30d62f7cdf45fceaa69135369bba00a3d Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Fri, 8 Oct 2021 10:23:33 +0100 Subject: [PATCH] Refactor compile-related code from PDF preview context provider into a separate class (#5341) GitOrigin-RevId: 96b8bb527fa3d60a5fb84eee2b8f4fabc1726875 --- .../components/pdf-compile-button.js | 8 +- .../components/pdf-logs-button-content.js | 6 +- .../pdf-preview/components/pdf-logs-button.js | 11 +- .../pdf-preview/components/pdf-logs-viewer.js | 5 +- .../contexts/pdf-preview-context.js | 394 ++++++------------ .../js/features/pdf-preview/util/compiler.js | 179 ++++++++ .../frontend/stories/pdf-js-viewer.stories.js | 2 +- .../frontend/stories/pdf-preview.stories.js | 6 + .../components/pdf-preview.test.js | 178 ++++---- 9 files changed, 403 insertions(+), 386 deletions(-) create mode 100644 services/web/frontend/js/features/pdf-preview/util/compiler.js 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 ee4d5bd9a8..e463bcc8cb 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 @@ -3,7 +3,7 @@ import Icon from '../../../shared/components/icon' import ControlledDropdown from '../../../shared/components/controlled-dropdown' import { useTranslation } from 'react-i18next' import { usePdfPreviewContext } from '../contexts/pdf-preview-context' -import { memo, useCallback } from 'react' +import { memo } from 'react' import classnames from 'classnames' function PdfCompileButton() { @@ -12,10 +12,10 @@ function PdfCompileButton() { compiling, draft, hasChanges, - recompile, setAutoCompile, setDraft, setStopOnValidationError, + startCompile, stopCompile, stopOnValidationError, recompileFromScratch, @@ -25,10 +25,6 @@ function PdfCompileButton() { const compileButtonLabel = compiling ? t('compiling') + '…' : t('recompile') - const startCompile = useCallback(() => { - recompile() - }, [recompile]) - return ( @@ -59,7 +59,7 @@ export function PdfLogsButtonContent({ } PdfLogsButtonContent.propTypes = { - autoCompileLintingError: PropTypes.bool, + codeCheckFailed: PropTypes.bool, showLogs: PropTypes.bool, logEntries: PropTypes.object, } diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js index d0a03c2e00..56186251f6 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js @@ -6,8 +6,7 @@ import { sendMBOnce } from '../../../infrastructure/event-tracking' function PdfLogsButton() { const { - autoCompileLintingError, - stopOnValidationError, + codeCheckFailed, error, logEntries, showLogs, @@ -19,7 +18,7 @@ function PdfLogsButton() { return 'default' } - if (autoCompileLintingError && stopOnValidationError) { + if (codeCheckFailed) { return 'danger' } @@ -34,7 +33,7 @@ function PdfLogsButton() { } return 'default' - }, [autoCompileLintingError, logEntries, showLogs, stopOnValidationError]) + }, [codeCheckFailed, logEntries, showLogs]) const handleClick = useCallback(() => { setShowLogs(value => { @@ -57,9 +56,7 @@ function PdfLogsButton() { ) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js index 88e18eff18..05f56c846f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js @@ -14,8 +14,7 @@ import ErrorBoundaryFallback from './error-boundary-fallback' function PdfLogsViewer() { const { - autoCompileLintingError, - stopOnValidationError, + codeCheckFailed, error, logEntries, rawLog, @@ -27,7 +26,7 @@ function PdfLogsViewer() { return (
- {autoCompileLintingError && stopOnValidationError && ( + {codeCheckFailed && (
diff --git a/services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js b/services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js index 4d52dee992..afe1db8a96 100644 --- a/services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js +++ b/services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js @@ -9,32 +9,20 @@ import { import PropTypes from 'prop-types' import useScopeValue from '../../../shared/context/util/scope-value-hook' import { useProjectContext } from '../../../shared/context/project-context' -import getMeta from '../../../utils/meta' -import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json' import usePersistedState from '../../../shared/hooks/use-persisted-state' import { buildLogEntryAnnotations, handleOutputFiles, } from '../util/output-files' -import { debounce } from 'lodash' -import { useIdeContext } from '../../../shared/context/ide-context' import { send, sendMB, sendMBSampled, } from '../../../infrastructure/event-tracking' import { useEditorContext } from '../../../shared/context/editor-context' -import { isMainFile } from '../util/editor-files' import useAbortController from '../../../shared/hooks/use-abort-controller' - -const AUTO_COMPILE_MAX_WAIT = 5000 -// We add a 1 second debounce to sending user changes to server if they aren't -// collaborating with anyone. This needs to be higher than that, and allow for -// client to server latency, otherwise we compile before the op reaches the server -// and then again on ack. -const AUTO_COMPILE_DEBOUNCE = 2000 - -const searchParams = new URLSearchParams(window.location.search) +import DocumentCompiler from '../util/compiler' +import { useIdeContext } from '../../../shared/context/ide-context' export const PdfPreviewContext = createContext(undefined) @@ -45,7 +33,9 @@ PdfPreviewProvider.propTypes = { export default function PdfPreviewProvider({ children }) { const ide = useIdeContext() - const { _id: projectId, rootDoc_id: rootDocId } = useProjectContext() + const project = useProjectContext() + + const projectId = project._id const { hasPremiumCompile, isProjectOwner } = useEditorContext() @@ -65,10 +55,7 @@ export default function PdfPreviewProvider({ children }) { const [, setLogEntryAnnotations] = useScopeValue('pdf.logEntryAnnotations') // the id of the CLSI server which ran the compile - const [clsiServerId, setClsiServerId] = useScopeValue('ide.clsiServerId') - - // the compile group (standard or priority) - const [compileGroup, setCompileGroup] = useScopeValue('ide.compileGroup') + const [, setClsiServerId] = useScopeValue('ide.clsiServerId') // whether to display the editor and preview side-by-side or full-width ("flat") const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout') @@ -79,6 +66,9 @@ export default function PdfPreviewProvider({ children }) { // whether a compile is in progress const [compiling, setCompiling] = useState(false) + // data received in response to a compile request + const [data, setData] = useState() + // whether the project has been compiled yet const [compiledOnce, setCompiledOnce] = useState(false) @@ -117,10 +107,7 @@ export default function PdfPreviewProvider({ children }) { true ) - // the id of the currently open document in the editor - const [currentDocId] = useScopeValue('editor.open_doc_id') - - // the Document currently open in the editor? + // the Document currently open in the editor const [currentDoc] = useScopeValue('editor.sharejs_doc') // whether the PDF view is hidden @@ -137,6 +124,35 @@ export default function PdfPreviewProvider({ children }) { const { signal } = useAbortController() + // the document compiler + const [compiler] = useState(() => { + return new DocumentCompiler({ + project, + setChangedAt, + setCompiling, + setData, + setError, + signal, + }) + }) + + // clean up the compiler on unmount + useEffect(() => { + return () => { + compiler.destroy() + } + }, [compiler]) + + // keep currentDoc in sync with the compiler + useEffect(() => { + compiler.currentDoc = currentDoc + }, [compiler, currentDoc]) + + // keep draft setting in sync with the compiler + useEffect(() => { + compiler.draft = draft + }, [compiler, draft]) + // pass the "uncompiled" value up into the scope for use outside this context provider useEffect(() => { setUncompiled(changedAt > 0) @@ -151,46 +167,26 @@ export default function PdfPreviewProvider({ children }) { [_setAutoCompile] ) - // parse the text of the current doc in the editor - // if it contains "\documentclass" then use this as the root doc - const getRootDocOverrideId = useCallback(() => { - if (currentDocId === rootDocId) { - return null // no need to override when in the root doc itself + // always compile the PDF once after opening the project, after the doc has loaded + useEffect(() => { + if (!compiledOnce && currentDoc) { + setCompiledOnce(true) + compiler.compile({ isAutoCompileOnLoad: true }) } - - if (currentDoc) { - const doc = currentDoc.getSnapshot() - - if (doc) { - return isMainFile(doc) - } - } - - return null - }, [currentDoc, currentDocId, rootDocId]) - - // TODO: remove this? - const sendCompileMetrics = useCallback(() => { - if (compiledOnce && !error && !window.user.alphaProgram) { - const metadata = { - errors: logEntries.errors.length, - warnings: logEntries.warnings.length, - typesetting: logEntries.typesetting.length, - newPdfPreview: true, - } - sendMBSampled('compile-result', metadata, 0.01) - } - }, [compiledOnce, error, logEntries]) + }, [compiledOnce, currentDoc, compiler]) // handle the data returned from a compile request - const handleCompileData = useCallback( - (data, options) => { + // note: this should _only_ run when `data` changes, + // the other dependencies must all be static + useEffect(() => { + if (data) { if (data.clsiServerId) { - setClsiServerId(data.clsiServerId) + setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController + compiler.clsiServerId = data.clsiServerId } if (data.compileGroup) { - setCompileGroup(data.compileGroup) + compiler.compileGroup = data.compileGroup } if (data.outputFiles) { @@ -203,13 +199,27 @@ export default function PdfPreviewProvider({ children }) { setPdfDownloadUrl(result.pdfDownloadUrl) setPdfUrl(result.pdfUrl) setRawLog(result.log) + + // sample compile stats for real users + if (!window.user.alphaProgram && data.status === 'success') { + sendMBSampled( + 'compile-result', + { + errors: result.logEntries.errors.length, + warnings: result.logEntries.warnings.length, + typesetting: result.logEntries.typesetting.length, + newPdfPreview: true, // TODO: is this useful? + }, + 0.01 + ) + } }) } switch (data.status) { case 'success': setError(undefined) - setShowLogs(false) // TODO: always? + setShowLogs(false) break case 'clsi-maintenance': @@ -238,7 +248,7 @@ export default function PdfPreviewProvider({ children }) { break case 'autocompile-backoff': - if (!options.isAutoCompileOnLoad) { + if (!data.options.isAutoCompileOnLoad) { setError('autocompile-disabled') setAutoCompile(false) sendMB('autocompile-rate-limited', { hasPremiumCompile }) @@ -258,97 +268,21 @@ export default function PdfPreviewProvider({ children }) { setError('error') break } - }, - [ - hasPremiumCompile, - ide.fileTreeManager, - isProjectOwner, - projectId, - setAutoCompile, - setClsiServerId, - setCompileGroup, - setLogEntries, - setLogEntryAnnotations, - setPdfDownloadUrl, - setPdfUrl, - ] - ) - - const buildCompileParams = useCallback( - options => { - const params = new URLSearchParams() - - if (clsiServerId) { - params.set('clsiserverid', clsiServerId) - } - - if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) { - params.set('auto_compile', 'true') - } - - if (getMeta('ol-enablePdfCaching')) { - params.set('enable_pdf_caching', 'true') - } - - if (searchParams.get('file_line_errors') === 'true') { - params.file_line_errors = 'true' - } - - return params - }, - [clsiServerId] - ) - - // run a compile - const recompile = useCallback( - (options = {}) => { - if (compiling) { - return - } - - sendMBSampled('editor-recompile-sampled', options) - - setChangedAt(0) // NOTE: this sets uncompiled to false - setCompiling(true) - setValidationIssues(undefined) - - window.dispatchEvent(new CustomEvent('flush-changes')) // TODO: wait for this? - - postJSON(`/project/${projectId}/compile?${buildCompileParams(options)}`, { - body: { - rootDoc_id: getRootDocOverrideId(), - draft, - check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused - // use incremental compile for all users but revert to a full compile - // if there was previously a server error - incrementalCompilesEnabled: !error, - }, - signal, - }) - .then(data => { - handleCompileData(data, options) - }) - .catch(error => { - // console.error(error) - setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error') - }) - .finally(() => { - setCompiling(false) - sendCompileMetrics() - }) - }, - [ - compiling, - projectId, - buildCompileParams, - getRootDocOverrideId, - draft, - error, - handleCompileData, - sendCompileMetrics, - signal, - ] - ) + } + }, [ + compiler, + data, + ide, + hasPremiumCompile, + isProjectOwner, + projectId, + setAutoCompile, + setClsiServerId, + setLogEntries, + setLogEntryAnnotations, + setPdfDownloadUrl, + setPdfUrl, + ]) // switch to logs if there's an error useEffect(() => { @@ -360,7 +294,7 @@ export default function PdfPreviewProvider({ children }) { // recompile on key press useEffect(() => { const listener = event => { - recompile(event.detail) + compiler.compile(event.detail) } window.addEventListener('pdf:recompile', listener) @@ -368,73 +302,40 @@ export default function PdfPreviewProvider({ children }) { return () => { window.removeEventListener('pdf:recompile', listener) } - }, [recompile]) - - // always compile the PDF once, when joining the project - useEffect(() => { - const listener = () => { - if (!compiledOnce) { - setCompiledOnce(true) - recompile({ isAutoCompileOnLoad: true }) - } - } - - window.addEventListener('project:joined', listener) - - return () => { - window.removeEventListener('project:joined', listener) - } - }, [compiledOnce, recompile]) + }, [compiler]) // whether there has been an autocompile linting error, if syntax validation is switched on const autoCompileLintingError = Boolean( autoCompile && syntaxValidation && hasLintingError ) - // the project has visible changes + const codeCheckFailed = stopOnValidationError && autoCompileLintingError + + // show that the project has pending changes const hasChanges = Boolean( - autoCompile && - uncompiled && - compiledOnce && - !(stopOnValidationError && autoCompileLintingError) + autoCompile && uncompiled && compiledOnce && !codeCheckFailed ) // the project is available for auto-compiling const canAutoCompile = Boolean( - autoCompile && - !compiling && - !pdfHidden && - !(stopOnValidationError && autoCompileLintingError) + autoCompile && !compiling && !pdfHidden && !codeCheckFailed ) - // a debounced wrapper around the recompile function, used for auto-compile - const [debouncedAutoCompile] = useState(() => { - return debounce( - () => { - recompile({ isAutoCompileOnChange: true }) - }, - AUTO_COMPILE_DEBOUNCE, - { - maxWait: AUTO_COMPILE_MAX_WAIT, - } - ) - }) - - // call the debounced recompile function if the project is available for auto-compiling and it has changed + // call the debounced autocompile function if the project is available for auto-compiling and it has changed useEffect(() => { if (canAutoCompile && changedAt > 0) { - debouncedAutoCompile() + compiler.debouncedAutoCompile() } else { - debouncedAutoCompile.cancel() + compiler.debouncedAutoCompile.cancel() } - }, [canAutoCompile, debouncedAutoCompile, recompile, changedAt]) + }, [compiler, canAutoCompile, changedAt]) // cancel debounced recompile on unmount useEffect(() => { return () => { - debouncedAutoCompile.cancel() + compiler.debouncedAutoCompile.cancel() } - }, [debouncedAutoCompile]) + }, [compiler]) // record doc changes when notified by the editor useEffect(() => { @@ -451,52 +352,31 @@ export default function PdfPreviewProvider({ children }) { } }, []) - // send a request to stop the current compile + // start a compile manually + const startCompile = useCallback(() => { + compiler.compile() + }, [compiler]) + + // stop a compile manually const stopCompile = useCallback(() => { - // TODO: stoppingCompile state? - - const params = new URLSearchParams() - - if (clsiServerId) { - params.set('clsiserverid', clsiServerId) - } - - return postJSON(`/project/${projectId}/compile/stop?${params}`, { signal }) - .catch(error => { - setError(error) - }) - .finally(() => { - setCompiling(false) - }) - }, [projectId, clsiServerId, signal]) + compiler.stopCompile() + }, [compiler]) + // clear the compile cache const clearCache = useCallback(() => { setClearingCache(true) - const params = new URLSearchParams() - - if (clsiServerId) { - params.set('clsiserverid', clsiServerId) - } - - return deleteJSON(`/project/${projectId}/output?${params}`, { signal }) - .catch(error => { - console.error(error) - setError('clear-cache') - }) - .finally(() => { - setClearingCache(false) - }) - }, [clsiServerId, projectId, setError, signal]) + return compiler.clearCache().finally(() => { + setClearingCache(false) + }) + }, [compiler]) // clear the cache then run a compile, triggered by a menu item const recompileFromScratch = useCallback(() => { - setClearingCache(true) clearCache().then(() => { - setClearingCache(false) - recompile() + compiler.compile() }) - }, [clearCache, recompile]) + }, [clearCache, compiler]) // switch to either side-by-side or flat (full-width) layout const switchLayout = useCallback(() => { @@ -512,12 +392,9 @@ export default function PdfPreviewProvider({ children }) { const value = useMemo(() => { return { autoCompile, - autoCompileLintingError, + codeCheckFailed, clearCache, clearingCache, - clsiServerId, - compileGroup, - compiledOnce, compiling, draft, error, @@ -529,23 +406,14 @@ export default function PdfPreviewProvider({ children }) { pdfLayout, pdfUrl, rawLog, - recompile, recompileFromScratch, setAutoCompile, - setClsiServerId, - setCompileGroup, - setCompiledOnce, setDraft, - setError, - setHasLintingError, // for story - setLogEntries, - setPdfDownloadUrl, - setPdfLayout, - setPdfUrl, + setHasLintingError, // only for stories setShowLogs, setStopOnValidationError, - setUiView, showLogs, + startCompile, stopCompile, stopOnValidationError, switchLayout, @@ -554,12 +422,9 @@ export default function PdfPreviewProvider({ children }) { } }, [ autoCompile, - autoCompileLintingError, + codeCheckFailed, clearCache, clearingCache, - clsiServerId, - compileGroup, - compiledOnce, compiling, draft, error, @@ -571,22 +436,13 @@ export default function PdfPreviewProvider({ children }) { pdfLayout, pdfUrl, rawLog, - recompile, recompileFromScratch, setAutoCompile, - setClsiServerId, - setCompileGroup, - setCompiledOnce, setDraft, - setError, - setHasLintingError, - setLogEntries, - setPdfDownloadUrl, - setPdfLayout, - setPdfUrl, + setHasLintingError, // only for stories setStopOnValidationError, - setUiView, showLogs, + startCompile, stopCompile, stopOnValidationError, switchLayout, @@ -604,12 +460,9 @@ export default function PdfPreviewProvider({ children }) { PdfPreviewContext.Provider.propTypes = { value: PropTypes.shape({ autoCompile: PropTypes.bool.isRequired, - autoCompileLintingError: PropTypes.bool.isRequired, clearCache: PropTypes.func.isRequired, clearingCache: PropTypes.bool.isRequired, - clsiServerId: PropTypes.string, - compileGroup: PropTypes.string, - compiledOnce: PropTypes.bool.isRequired, + codeCheckFailed: PropTypes.bool.isRequired, compiling: PropTypes.bool.isRequired, draft: PropTypes.bool.isRequired, error: PropTypes.string, @@ -621,23 +474,14 @@ PdfPreviewContext.Provider.propTypes = { pdfLayout: PropTypes.string, pdfUrl: PropTypes.string, rawLog: PropTypes.string, - recompile: PropTypes.func.isRequired, recompileFromScratch: PropTypes.func.isRequired, setAutoCompile: PropTypes.func.isRequired, - setClsiServerId: PropTypes.func.isRequired, - setCompileGroup: PropTypes.func.isRequired, - setCompiledOnce: PropTypes.func.isRequired, setDraft: PropTypes.func.isRequired, - setError: PropTypes.func.isRequired, setHasLintingError: PropTypes.func.isRequired, // only for storybook - setLogEntries: PropTypes.func.isRequired, - setPdfDownloadUrl: PropTypes.func.isRequired, - setPdfLayout: PropTypes.func.isRequired, - setPdfUrl: PropTypes.func.isRequired, setShowLogs: PropTypes.func.isRequired, setStopOnValidationError: PropTypes.func.isRequired, - setUiView: PropTypes.func.isRequired, showLogs: PropTypes.bool.isRequired, + startCompile: PropTypes.func.isRequired, stopCompile: PropTypes.func.isRequired, stopOnValidationError: PropTypes.bool.isRequired, switchLayout: PropTypes.func.isRequired, diff --git a/services/web/frontend/js/features/pdf-preview/util/compiler.js b/services/web/frontend/js/features/pdf-preview/util/compiler.js new file mode 100644 index 0000000000..3f57cf4e6a --- /dev/null +++ b/services/web/frontend/js/features/pdf-preview/util/compiler.js @@ -0,0 +1,179 @@ +import { isMainFile } from './editor-files' +import getMeta from '../../../utils/meta' +import { sendMBSampled } from '../../../infrastructure/event-tracking' +import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json' +import { debounce } from 'lodash' + +const AUTO_COMPILE_MAX_WAIT = 5000 +// We add a 1 second debounce to sending user changes to server if they aren't +// collaborating with anyone. This needs to be higher than that, and allow for +// client to server latency, otherwise we compile before the op reaches the server +// and then again on ack. +const AUTO_COMPILE_DEBOUNCE = 2000 + +const searchParams = new URLSearchParams(window.location.search) + +export default class DocumentCompiler { + constructor({ + project, + setChangedAt, + setCompiling, + setData, + setError, + signal, + }) { + this.project = project + this.setChangedAt = setChangedAt + this.setCompiling = setCompiling + this.setData = setData + this.setError = setError + this.signal = signal + + this.clsiServerId = null + this.currentDoc = null + this.error = undefined + this.timer = 0 + + this.debouncedAutoCompile = debounce( + () => { + this.compile({ isAutoCompileOnChange: true }) + }, + AUTO_COMPILE_DEBOUNCE, + { + maxWait: AUTO_COMPILE_MAX_WAIT, + } + ) + } + + destroy() { + this.signal.abort() + this.debouncedAutoCompile.cancel() + } + + // The main "compile" function. + // Call this directly to run a compile now, otherwise call debouncedAutoCompile. + async compile(options = {}) { + // set "compiling" to true (in the React component's state), and return if it was already true + let wasCompiling + + this.setCompiling(oldValue => { + wasCompiling = oldValue + return true + }) + + if (wasCompiling) { + return + } + + try { + // log a sample of the compile requests + sendMBSampled('editor-recompile-sampled', options) + + // reset values + this.setChangedAt(0) + this.validationIssues = undefined + + window.dispatchEvent(new CustomEvent('flush-changes')) // TODO: wait for this? + + const params = this.buildQueryParams() + this.addCompileParams(params, options) + + const data = await postJSON( + `/project/${this.project._id}/compile?${params}`, + { + body: { + rootDoc_id: this.getRootDocOverrideId(), + draft: this.draft, + check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused + // use incremental compile for all users but revert to a full compile + // if there was previously a server error + incrementalCompilesEnabled: !this.error, + }, + signal: this.signal, + } + ) + data.options = options + this.setData(data) + } catch (error) { + console.error(error) + this.setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error') + } finally { + this.setCompiling(false) + } + } + + // parse the text of the current doc in the editor + // if it contains "\documentclass" then use this as the root doc + getRootDocOverrideId() { + // only override when not in the root doc itself + if (this.currentDoc.doc_id !== this.project.rootDoc_id) { + const snapshot = this.currentDoc.getSnapshot() + + if (snapshot && isMainFile(snapshot)) { + return this.currentDoc.doc_id + } + } + + return null + } + + // build the query parameters added to all requests + buildQueryParams() { + const params = new URLSearchParams() + + // the id of the CLSI server that processed the previous compile request + if (this.clsiServerId) { + params.set('clsiserverid', this.clsiServerId) + } + + return params + } + + // add extra query parameters to the compile request + addCompileParams(params, options) { + // tell the server whether this is an automatic or manual compile request + if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) { + params.set('auto_compile', 'true') + } + + // use the feature flag to enable PDF caching in a ServiceWorker + if (getMeta('ol-enablePdfCaching')) { + params.set('enable_pdf_caching', 'true') + } + + // use the feature flag to enable "file line errors" + if (searchParams.get('file_line_errors') === 'true') { + params.file_line_errors = 'true' + } + } + + // send a request to stop the current compile + stopCompile() { + // NOTE: no stoppingCompile state, as this should happen fairly quickly + // and doesn't matter if it runs twice. + + const params = this.buildQueryParams() + + return postJSON(`/project/${this.project._id}/compile/stop?${params}`, { + signal: this.signal, + }) + .catch(error => { + console.error(error) + this.setError('error') + }) + .finally(() => { + this.setCompiling(false) + }) + } + + clearCache() { + const params = this.buildQueryParams() + + return deleteJSON(`/project/${this.project._id}/output?${params}`, { + signal: this.signal, + }).catch(error => { + console.error(error) + this.setError('clear-cache') + }) + } +} diff --git a/services/web/frontend/stories/pdf-js-viewer.stories.js b/services/web/frontend/stories/pdf-js-viewer.stories.js index 1e89fc6ea3..ca140b8ee3 100644 --- a/services/web/frontend/stories/pdf-js-viewer.stories.js +++ b/services/web/frontend/stories/pdf-js-viewer.stories.js @@ -109,6 +109,6 @@ export const Interactive = () => {
, - { scope: { project } } + { project } ) } diff --git a/services/web/frontend/stories/pdf-preview.stories.js b/services/web/frontend/stories/pdf-preview.stories.js index 3824acfd4c..43c7ef3564 100644 --- a/services/web/frontend/stories/pdf-preview.stories.js +++ b/services/web/frontend/stories/pdf-preview.stories.js @@ -47,6 +47,12 @@ const scope = { }, hasLintingError: false, $applyAsync: () => {}, + editor: { + sharejs_doc: { + doc_id: 'test-doc', + getSnapshot: () => 'some doc content', + }, + }, } const dispatchProjectJoined = () => { 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 1142db5043..ee735e3e1b 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 @@ -9,7 +9,7 @@ const outputFiles = [ { path: 'output.pdf', build: '123', - // url: 'about:blank', // TODO: PDF URL to render + url: '/build/output.pdf', // TODO: PDF URL to render type: 'pdf', }, { @@ -119,6 +119,18 @@ const storeAndFireEvent = (key, value) => { fireEvent(window, new StorageEvent('storage', { key })) } +const scope = { + settings: { + syntaxValidation: false, + }, + editor: { + sharejs_doc: { + doc_id: 'test-doc', + getSnapshot: () => 'some doc content', + }, + }, +} + describe('', function () { var clock @@ -138,50 +150,31 @@ describe('', function () { localStorage.clear() }) - it('renders the PDF preview', function () { - renderWithEditorContext() - - screen.getByRole('button', { name: 'Recompile' }) - }) - - it('runs a compile only on the first project:joined event', async function () { + it('renders the PDF preview', async function () { mockCompile() mockBuildFile() - renderWithEditorContext() + renderWithEditorContext(, { scope }) - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) - - // fire another project:joined event => no compile - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Recompile' }) - - expect(fetchMock.calls()).to.have.length(3) - - expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param - expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path }) it('runs a compile when the Recompile button is pressed', async function () { mockCompile() mockBuildFile() - renderWithEditorContext() + renderWithEditorContext(, { scope }) - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) // press the Recompile button => compile const button = screen.getByRole('button', { name: 'Recompile' }) button.click() - screen.getByRole('button', { name: 'Compiling…' }) + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) expect(fetchMock.calls()).to.have.length(6) @@ -191,12 +184,10 @@ describe('', function () { mockCompile() mockBuildFile() - renderWithEditorContext() + renderWithEditorContext(, { scope }) - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) // switch on auto compile @@ -206,7 +197,7 @@ describe('', function () { fireEvent(window, new CustomEvent('doc:changed')) clock.tick(2000) // AUTO_COMPILE_DEBOUNCE - await screen.findByText('Compiling…') + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) expect(fetchMock.calls()).to.have.length(6) @@ -216,17 +207,14 @@ describe('', function () { mockCompile() mockBuildFile() - renderWithEditorContext() + renderWithEditorContext(, { scope }) - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) // make sure auto compile is switched off storeAndFireEvent('autocompile_enabled:project123', false) - screen.getByRole('button', { name: 'Recompile' }) // fire a doc:changed event => no compile fireEvent(window, new CustomEvent('doc:changed')) @@ -242,21 +230,19 @@ describe('', function () { renderWithEditorContext(, { scope: { + ...scope, 'settings.syntaxValidation': true, // enable linting in the editor hasLintingError: true, // mock a linting error }, }) - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) // switch on auto compile and syntax checking storeAndFireEvent('autocompile_enabled:project123', true) storeAndFireEvent('stop_on_validation_error:project123', true) - screen.getByRole('button', { name: 'Recompile' }) // fire a doc:changed event => no compile fireEvent(window, new CustomEvent('doc:changed')) @@ -270,13 +256,12 @@ describe('', function () { it('displays an error message if there was a compile error', async function () { mockCompileError('compile-in-progress') - renderWithEditorContext() + renderWithEditorContext(, { scope }) - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) + screen.getByText( 'Please wait for your other compile to finish before trying again.' ) @@ -285,27 +270,6 @@ describe('', function () { expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path }) - it('disables the view logs button if there is a compile error', async function () { - mockCompileError() - mockBuildFile() - - renderWithEditorContext() - - const logsButton = screen.getByRole('button', { name: 'View logs' }) - expect(logsButton.hasAttribute('disabled')).to.be.false - - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) - expect(logsButton.hasAttribute('disabled')).to.be.false - await screen.findByRole('button', { name: 'Recompile' }) - expect(logsButton.hasAttribute('disabled')).to.be.true - - expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param - expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path - }) - it('displays error messages if there were validation problems', async function () { const validationProblems = { sizeCheck: { @@ -327,13 +291,12 @@ describe('', function () { mockValidationProblems(validationProblems) - renderWithEditorContext() + renderWithEditorContext(, { scope }) - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) + screen.getByText('Project too large') screen.getByText('Unknown main document') screen.getByText('Conflicting Paths Found') @@ -347,24 +310,18 @@ describe('', function () { mockBuildFile() mockClearCache() - renderWithEditorContext() + renderWithEditorContext(, { scope }) - const logsButton = screen.getByRole('button', { name: 'View logs' }) - logsButton.click() - - let clearCacheButton = screen.getByRole('button', { - name: 'Clear cached files', - }) - expect(clearCacheButton.hasAttribute('disabled')).to.be.false - - // fire a project:joined event => compile - screen.getByRole('button', { name: 'Recompile' }) - fireEvent(window, new CustomEvent('project:joined')) - screen.getByRole('button', { name: 'Compiling…' }) - expect(clearCacheButton.hasAttribute('disabled')).to.be.true + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) await screen.findByRole('button', { name: 'Recompile' }) + + const logsButton = screen.getByRole('button', { + name: 'This project has an error', + }) logsButton.click() - clearCacheButton = await screen.findByRole('button', { + + const clearCacheButton = await screen.findByRole('button', { name: 'Clear cached files', }) expect(clearCacheButton.hasAttribute('disabled')).to.be.false @@ -379,4 +336,43 @@ describe('', function () { expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path }) + + it('handle "recompile from scratch"', async function () { + mockCompile() + mockBuildFile() + mockClearCache() + + renderWithEditorContext(, { scope }) + + // wait for "compile on load" to finish + await screen.findByRole('button', { name: 'Compiling…' }) + await screen.findByRole('button', { name: 'Recompile' }) + + // show the logs UI + const logsButton = screen.getByRole('button', { + name: 'This project has an error', + }) + logsButton.click() + + const clearCacheButton = await screen.findByRole('button', { + name: 'Clear cached files', + }) + expect(clearCacheButton.hasAttribute('disabled')).to.be.false + + const recompileFromScratch = screen.getByRole('menuitem', { + name: 'Recompile from scratch', + hidden: true, + }) + recompileFromScratch.click() + + expect(clearCacheButton.hasAttribute('disabled')).to.be.true + + // wait for compile to finish + await screen.findByRole('button', { name: 'Compiling…' }) + await screen.findByRole('button', { name: 'Recompile' }) + + expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param + expect(fetchMock.called('express:/project/:projectId/output')).to.be.true + expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path + }) })