Log pane improvements (#3418)

* Ordering of log entries in the new errors UI

* Don't show the expand-collapse widget when not needed; smaller font size in the raw log output

* Expose log actions in the log pane.

* Use "This project" instead of "Your project" in the new errors UI

* Better handling of long log messages; left-ellipsize the file/line number button

* Make log location more button-like; add tooltip when needed.

* Add a PDF expand button to the toolbar.

* Add a stop compilation button to the new compile UI

* Use aria-label for button accessible text; improve handling of long filenames in the log location button

* Set max-height correctly for the logs pane dropdown

* Avoid changing raw logs sizing when expanded and collapsed

* Add comment on the solution for right-to-left text and ellipsis

* Improve logs pane actions

GitOrigin-RevId: 4098d77a9ee6d333644906876b9ff27035b79319
This commit is contained in:
Paulo Jorge Reis 2020-12-02 10:03:03 +00:00 committed by Copybot
parent 475b51d21e
commit 4e74fb2694
28 changed files with 888 additions and 418 deletions

View file

@ -16,6 +16,7 @@ div.full-size.pdf(ng-controller="PdfController")
}`
on-clear-cache="clearCache"
on-recompile="recompile"
on-recompile-from-scratch="recompileFromScratch"
on-run-syntax-check-now="runSyntaxCheckNow"
on-set-auto-compile="setAutoCompile"
on-set-draft-mode="setDraftMode"
@ -23,6 +24,10 @@ div.full-size.pdf(ng-controller="PdfController")
on-toggle-logs="toggleLogs"
output-files="pdf.outputFiles"
pdf-download-url="pdf.downloadUrl"
split-layout="ui.pdfLayout === 'sideBySide'"
on-set-split-layout="setPdfSplitLayout"
on-set-full-layout="setPdfFullLayout"
on-stop-compilation="stop"
show-logs="shouldShowLogs"
on-log-entry-location-click="openInEditor"
)
@ -234,7 +239,7 @@ div.full-size.pdf(ng-controller="PdfController")
href
dropdown-toggle
)
| !{translate("other_logs_and_files")}
| #{translate("other_logs_and_files")}
span.caret
ul.dropdown-menu.dropdown-menu-right
li(ng-repeat="file in pdf.outputFiles")

View file

@ -2,6 +2,7 @@
"auto_compile",
"autocompile_disabled_reason",
"autocompile_disabled",
"clear_cached_files",
"clsi_maintenance",
"clsi_unavailable",
"collapse",
@ -21,6 +22,7 @@
"find_out_more_about_the_file_outline",
"first_error_popup_label",
"following_paths_conflict",
"full_screen",
"go_to_error_location",
"hide_outline",
"ignore_validation_errors",
@ -39,6 +41,7 @@
"normal",
"off",
"on",
"other_logs_and_files",
"other_output_files",
"pdf_compile_in_progress_error",
"pdf_compile_rate_limit_hit",
@ -59,6 +62,8 @@
"show_outline",
"something_went_wrong_rendering_pdf",
"somthing_went_wrong_compiling",
"split_screen",
"stop_compile",
"stop_on_validation_error",
"terminated",
"timedout",

View file

@ -1,36 +1,18 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Dropdown, MenuItem, OverlayTrigger, Tooltip } from 'react-bootstrap'
import { Dropdown, OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import PreviewDownloadFileList from './preview-download-file-list'
import Icon from '../../../shared/components/icon'
export const topFileTypes = ['bbl', 'gls', 'ind']
function PreviewDownloadButton({
isCompiling,
outputFiles,
pdfDownloadUrl,
showText
}) {
let topFiles = []
let otherFiles = []
const { t } = useTranslation()
if (outputFiles) {
topFiles = outputFiles.filter(file => {
if (topFileTypes.includes(file.type)) {
return file
}
})
otherFiles = outputFiles.filter(file => {
if (!topFileTypes.includes(file.type)) {
if (file.type === 'pdf' && file.main === true) return
return file
}
})
}
let textStyle = {}
if (!showText) {
textStyle = {
@ -77,37 +59,12 @@ function PreviewDownloadButton({
bsStyle="info"
/>
<Dropdown.Menu id="download-dropdown-list">
<MenuItem header>{t('other_output_files')}</MenuItem>
<FileList list={topFiles} listType="main" />
{otherFiles.length > 0 && topFiles.length > 0 ? (
<>
<MenuItem divider />
</>
) : (
<></>
)}
{otherFiles.length > 0 ? (
<>
<FileList list={otherFiles} listType="other" />
</>
) : (
<></>
)}
<PreviewDownloadFileList fileList={outputFiles} />
</Dropdown.Menu>
</Dropdown>
)
}
function FileList({ listType, list }) {
return list.map((file, index) => {
return (
<MenuItem download href={file.url} key={`${listType}-${index}`}>
<b>{file.fileName}</b>
</MenuItem>
)
})
}
PreviewDownloadButton.propTypes = {
isCompiling: PropTypes.bool.isRequired,
outputFiles: PropTypes.array,
@ -115,9 +72,4 @@ PreviewDownloadButton.propTypes = {
showText: PropTypes.bool.isRequired
}
FileList.propTypes = {
list: PropTypes.array.isRequired,
listType: PropTypes.string.isRequired
}
export default PreviewDownloadButton

View file

@ -0,0 +1,70 @@
import React from 'react'
import PropTypes from 'prop-types'
import { MenuItem } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
export const topFileTypes = ['bbl', 'gls', 'ind']
function PreviewDownloadFileList({ fileList = [] }) {
const { t } = useTranslation()
let topFiles = []
let otherFiles = []
if (fileList) {
topFiles = fileList.filter(file => {
if (topFileTypes.includes(file.type)) {
return file
}
})
otherFiles = fileList.filter(file => {
if (!topFileTypes.includes(file.type)) {
if (file.type === 'pdf' && file.main === true) return
return file
}
})
}
return (
<>
<MenuItem header>{t('other_output_files')}</MenuItem>
<SubFileList subFileList={topFiles} listType="main" />
{otherFiles.length > 0 && topFiles.length > 0 ? (
<>
<MenuItem divider />
</>
) : (
<></>
)}
{otherFiles.length > 0 ? (
<>
<SubFileList subFileList={otherFiles} listType="other" />
</>
) : (
<></>
)}
</>
)
}
function SubFileList({ subFileList, listType }) {
return subFileList.map((file, index) => {
return (
<MenuItem download href={file.url} key={`${listType}${index}`}>
<b>{file.fileName}</b>
</MenuItem>
)
})
}
SubFileList.propTypes = {
subFileList: PropTypes.array.isRequired,
listType: PropTypes.string.isRequired
}
PreviewDownloadFileList.propTypes = {
fileList: PropTypes.array
}
export default PreviewDownloadFileList

View file

@ -31,6 +31,7 @@ function PreviewFirstErrorPopUp({
level={logEntry.level}
showLineAndNoLink={false}
showCloseButton
customClass="log-entry-first-error-popup"
onClose={onClose}
/>
<div className="first-error-popup-actions">

View file

@ -1,8 +1,10 @@
import React from 'react'
import React, { useState, useRef } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import useExpandCollapse from '../../../shared/hooks/use-expand-collapse'
import useResizeObserver from '../../../shared/hooks/use-resize-observer'
import Icon from '../../../shared/components/icon'
function PreviewLogsPaneEntry({
@ -15,15 +17,19 @@ function PreviewLogsPaneEntry({
showSourceLocationLink = true,
showCloseButton = false,
entryAriaLabel = null,
customClass,
onSourceLocationClick,
onClose
}) {
function handleLogEntryLinkClick() {
const logEntryClasses = classNames('log-entry', customClass)
function handleLogEntryLinkClick(e) {
e.preventDefault()
onSourceLocationClick(sourceLocation)
}
return (
<div className="log-entry" aria-label={entryAriaLabel}>
<div className={logEntryClasses} aria-label={entryAriaLabel}>
<PreviewLogEntryHeader
level={level}
sourceLocation={sourceLocation}
@ -54,6 +60,15 @@ function PreviewLogEntryHeader({
onClose
}) {
const { t } = useTranslation()
const logLocationSpanRef = useRef()
const [showLocationTooltip, setShowLocationTooltip] = useState(false)
useResizeObserver(
logLocationSpanRef,
showLocationTooltip,
setTooltipForLogLocationLinkIfNeeded
)
const file = sourceLocation ? sourceLocation.file : null
const line = sourceLocation ? sourceLocation.line : null
const logEntryHeaderClasses = classNames('log-entry-header', {
@ -62,26 +77,69 @@ function PreviewLogEntryHeader({
'log-entry-header-typesetting': level === 'typesetting',
'log-entry-header-raw': level === 'raw'
})
const logEntryLocationBtnClasses = classNames('log-entry-header-link', {
'log-entry-header-link-error': level === 'error',
'log-entry-header-link-warning': level === 'warning',
'log-entry-header-link-typesetting': level === 'typesetting',
'log-entry-header-link-raw': level === 'raw'
})
const headerLogLocationTitle = t('navigate_log_source', {
location: file + (line ? `, ${line}` : '')
})
function setTooltipForLogLocationLinkIfNeeded(observedElement) {
const spanEl = observedElement.target
const shouldShowTooltip = spanEl.scrollWidth > spanEl.clientWidth
setShowLocationTooltip(shouldShowTooltip)
}
const locationLinkText =
showSourceLocationLink && file ? `${file}${line ? `, ${line}` : ''}` : null
// Because we want an ellipsis on the left-hand side (e.g. "...longfilename.tex"), the
// `log-entry-header-link-location` class has text laid out from right-to-left using the CSS
// rule `direction: rtl;`.
// This works most of the times, except when the first character of the filename is considered
// a punctuation mark, like `/` (e.g. `/foo/bar/baz.sty`). In this case, because of
// right-to-left writing rules, the punctuation mark is moved to the right-side of the string,
// resulting in `...bar/baz.sty/` instead of `...bar/baz.sty`.
// To avoid this edge-case, we wrap the `logLocationLinkText` in two directional formatting
// characters:
// * \u202A LEFT-TO-RIGHT EMBEDDING Treat the following text as embedded left-to-right.
// * \u202C POP DIRECTIONAL FORMATTING End the scope of the last LRE, RLE, RLO, or LRO.
// This essentially tells the browser that, althought the text is laid out from right-to-left,
// the wrapped portion of text should follow left-to-right writing rules.
const locationLink = locationLinkText ? (
<button
className={logEntryLocationBtnClasses}
type="button"
aria-label={headerLogLocationTitle}
onClick={onSourceLocationClick}
>
<Icon type="chain" />
&nbsp;
<span ref={logLocationSpanRef} className="log-entry-header-link-location">
{`\u202A${locationLinkText}\u202C`}
</span>
</button>
) : null
const locationTooltip = showLocationTooltip ? (
<Tooltip id={locationLinkText} className="log-location-tooltip">
{locationLinkText}
</Tooltip>
) : null
return (
<header className={logEntryHeaderClasses}>
<h3 className="log-entry-header-title">{headerTitle}</h3>
{showSourceLocationLink && file ? (
<button
className="btn-inline-link log-entry-header-link"
type="button"
title={headerLogLocationTitle}
onClick={onSourceLocationClick}
>
<Icon type="chain" />
&nbsp;
<span>{file}</span>
{line ? <span>, {line}</span> : null}
</button>
) : null}
{showLocationTooltip ? (
<OverlayTrigger placement="left" overlay={locationTooltip}>
{locationLink}
</OverlayTrigger>
) : (
locationLink
)}
{showCloseButton ? (
<button
className="btn-inline-link log-entry-header-link"
@ -101,45 +159,50 @@ function PreviewLogEntryContent({
formattedContent,
extraInfoURL
}) {
const { isExpanded, expandableProps, toggleProps } = useExpandCollapse({
collapsedSize: 150,
classes: {
container: 'log-entry-content-raw-expandable-container'
}
})
const logContentClasses = classNames('log-entry-content-raw', {
'log-entry-content-raw-collapsed': !isExpanded
const {
isExpanded,
needsExpandCollapse,
expandableProps,
toggleProps
} = useExpandCollapse({
collapsedSize: 150
})
const buttonContainerClasses = classNames(
'log-entry-content-button-container',
{
'log-entry-content-button-container-collapsed': !isExpanded
}
)
const { t } = useTranslation()
return (
<div className="log-entry-content">
{rawContent ? (
<div {...expandableProps}>
<pre className={logContentClasses}>{rawContent.trim()}</pre>
<div className={buttonContainerClasses}>
<button
type="button"
className="btn btn-xs btn-default log-entry-btn-expand-collapse"
{...toggleProps}
>
{isExpanded ? (
<>
<Icon type="angle-up" /> {t('collapse')}
</>
) : (
<>
<Icon type="angle-down" /> {t('expand')}
</>
)}
</button>
<div className="log-entry-content-raw-container">
<div {...expandableProps}>
<pre className="log-entry-content-raw">{rawContent.trim()}</pre>
</div>
{needsExpandCollapse ? (
<div className={buttonContainerClasses}>
<button
type="button"
className="btn btn-xs btn-default log-entry-btn-expand-collapse"
{...toggleProps}
>
{isExpanded ? (
<>
<Icon type="angle-up" /> {t('collapse')}
</>
) : (
<>
<Icon type="angle-down" /> {t('expand')}
</>
)}
</button>
</div>
) : null}
</div>
) : null}
{formattedContent ? (
@ -186,6 +249,7 @@ PreviewLogsPaneEntry.propTypes = {
formattedContent: PropTypes.node,
extraInfoURL: PropTypes.string,
level: PropTypes.oneOf(['error', 'warning', 'typesetting', 'raw']).isRequired,
customClass: PropTypes.string,
showSourceLocationLink: PropTypes.bool,
showCloseButton: PropTypes.bool,
entryAriaLabel: PropTypes.string,

View file

@ -1,18 +1,31 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { Dropdown } from 'react-bootstrap'
import PreviewLogsPaneEntry from './preview-logs-pane-entry'
import PreviewValidationIssue from './preview-validation-issue'
import PreviewDownloadFileList from './preview-download-file-list'
import PreviewError from './preview-error'
import Icon from '../../../shared/components/icon'
function PreviewLogsPane({
logEntries = [],
logEntries = { all: [], errors: [], warnings: [], typesetting: [] },
rawLog = '',
validationIssues = {},
errors = {},
onLogEntryLocationClick
outputFiles = [],
isClearingCache,
isCompiling = false,
onLogEntryLocationClick,
onClearCache
}) {
const { t } = useTranslation()
const {
all: allCompilerIssues = [],
errors: compilerErrors = [],
warnings: compilerWarnings = [],
typesetting: compilerTypesettingIssues = []
} = logEntries
const errorsUI = Object.keys(errors).map((name, index) => (
<PreviewError key={index} name={name} />
@ -28,7 +41,11 @@ function PreviewLogsPane({
)
)
const logEntriesUI = logEntries.map((logEntry, idx) => (
const logEntriesUI = [
...compilerErrors,
...compilerWarnings,
...compilerTypesettingIssues
].map((logEntry, idx) => (
<PreviewLogsPaneEntry
key={idx}
headerTitle={logEntry.message}
@ -48,6 +65,39 @@ function PreviewLogsPane({
/>
))
const actionsUI = (
<div className="logs-pane-actions">
<button
className="btn btn-sm btn-danger logs-pane-actions-clear-cache"
onClick={onClearCache}
disabled={isClearingCache || isCompiling}
>
{isClearingCache ? (
<Icon type="refresh" spin />
) : (
<Icon type="trash-o" />
)}
&nbsp;
<span>{t('clear_cached_files')}</span>
</button>
<Dropdown
id="dropdown-files-logs-pane"
dropup
pullRight
disabled={isCompiling}
>
<Dropdown.Toggle
className="btn btn-sm btn-info dropdown-toggle"
title={t('other_logs_and_files')}
bsStyle="info"
/>
<Dropdown.Menu id="dropdown-files-logs-pane-list">
<PreviewDownloadFileList fileList={outputFiles} />
</Dropdown.Menu>
</Dropdown>
</div>
)
const rawLogUI = (
<PreviewLogsPaneEntry
headerTitle={t('raw_logs')}
@ -59,18 +109,30 @@ function PreviewLogsPane({
return (
<div className="logs-pane">
{errors ? errorsUI : null}
{validationIssues ? validationIssuesUI : null}
{logEntries ? logEntriesUI : null}
{rawLog && rawLog !== '' ? rawLogUI : null}
<div className="logs-pane-content">
{errors ? errorsUI : null}
{validationIssues ? validationIssuesUI : null}
{allCompilerIssues.length > 0 ? logEntriesUI : null}
{rawLog && rawLog !== '' ? rawLogUI : null}
{actionsUI}
</div>
</div>
)
}
PreviewLogsPane.propTypes = {
logEntries: PropTypes.array,
logEntries: PropTypes.shape({
all: PropTypes.array,
errors: PropTypes.array,
warning: PropTypes.array,
typesetting: PropTypes.array
}),
rawLog: PropTypes.string,
outputFiles: PropTypes.array,
isClearingCache: PropTypes.bool,
isCompiling: PropTypes.bool,
onLogEntryLocationClick: PropTypes.func.isRequired,
onClearCache: PropTypes.func.isRequired,
validationIssues: PropTypes.object,
errors: PropTypes.object
}

View file

@ -9,15 +9,20 @@ function PreviewPane({
compilerState,
onClearCache,
onRecompile,
onRecompileFromScratch,
onRunSyntaxCheckNow,
onSetAutoCompile,
onSetDraftMode,
onSetSyntaxCheck,
onToggleLogs,
onSetFullLayout,
onSetSplitLayout,
onStopCompilation,
outputFiles,
pdfDownloadUrl,
onLogEntryLocationClick,
showLogs
showLogs,
splitLayout
}) {
const { t } = useTranslation()
@ -81,15 +86,19 @@ function PreviewPane({
compilerState={compilerState}
logsState={{ nErrors, nWarnings, nLogEntries }}
showLogs={showLogs}
onClearCache={onClearCache}
onRecompile={onRecompile}
onRecompileFromScratch={onRecompileFromScratch}
onRunSyntaxCheckNow={onRunSyntaxCheckNow}
onSetAutoCompile={onSetAutoCompile}
onSetDraftMode={onSetDraftMode}
onSetSyntaxCheck={onSetSyntaxCheck}
onToggleLogs={onToggleLogs}
onSetSplitLayout={onSetSplitLayout}
onSetFullLayout={onSetFullLayout}
onStopCompilation={onStopCompilation}
outputFiles={outputFiles}
pdfDownloadUrl={pdfDownloadUrl}
splitLayout={splitLayout}
/>
<span aria-live="polite" className="sr-only">
{hasCLSIErrors ? t('compile_error_description') : ''}
@ -117,11 +126,15 @@ function PreviewPane({
) : null}
{showLogs ? (
<PreviewLogsPane
logEntries={compilerState.logEntries.all}
logEntries={compilerState.logEntries}
rawLog={compilerState.rawLog}
validationIssues={compilerState.validationIssues}
errors={compilerState.errors}
outputFiles={outputFiles}
onLogEntryLocationClick={onLogEntryLocationClick}
isClearingCache={compilerState.isClearingCache}
isCompiling={compilerState.isCompiling}
onClearCache={onClearCache}
/>
) : null}
</>
@ -134,6 +147,7 @@ PreviewPane.propTypes = {
isCompiling: PropTypes.bool.isRequired,
isDraftModeOn: PropTypes.bool.isRequired,
isSyntaxCheckOn: PropTypes.bool.isRequired,
isClearingCache: PropTypes.bool.isRequired,
lastCompileTimestamp: PropTypes.number,
logEntries: PropTypes.object,
validationIssues: PropTypes.object,
@ -144,14 +158,19 @@ PreviewPane.propTypes = {
onClearCache: PropTypes.func.isRequired,
onLogEntryLocationClick: PropTypes.func.isRequired,
onRecompile: PropTypes.func.isRequired,
onRecompileFromScratch: PropTypes.func.isRequired,
onRunSyntaxCheckNow: PropTypes.func.isRequired,
onSetAutoCompile: PropTypes.func.isRequired,
onSetDraftMode: PropTypes.func.isRequired,
onSetSyntaxCheck: PropTypes.func.isRequired,
onSetSplitLayout: PropTypes.func.isRequired,
onSetFullLayout: PropTypes.func.isRequired,
onStopCompilation: PropTypes.func.isRequired,
onToggleLogs: PropTypes.func.isRequired,
outputFiles: PropTypes.array,
pdfDownloadUrl: PropTypes.string,
showLogs: PropTypes.bool.isRequired
showLogs: PropTypes.bool.isRequired,
splitLayout: PropTypes.bool.isRequired
}
export default PreviewPane

View file

@ -7,14 +7,14 @@ import Icon from '../../../shared/components/icon'
function PreviewRecompileButton({
compilerState: {
isAutoCompileOn,
isClearingCache,
isCompiling,
isDraftModeOn,
isSyntaxCheckOn
},
onClearCache,
onRecompile,
onRecompileFromScratch,
onRunSyntaxCheckNow,
onStopCompilation,
onSetAutoCompile,
onSetDraftMode,
onSetSyntaxCheck,
@ -22,16 +22,6 @@ function PreviewRecompileButton({
}) {
const { t } = useTranslation()
function handleRecompileFromScratch() {
onClearCache()
.then(() => {
onRecompile()
})
.catch(error => {
console.error(error)
})
}
function handleSelectAutoCompileOn() {
onSetAutoCompile(true)
}
@ -69,9 +59,9 @@ function PreviewRecompileButton({
}
if (!showText) {
compilingProps = _hideText(isCompiling || isClearingCache)
recompileProps = _hideText(!isCompiling || !isClearingCache)
} else if (isCompiling || isClearingCache) {
compilingProps = _hideText(isCompiling)
recompileProps = _hideText(!isCompiling)
} else if (isCompiling) {
recompileProps = _hideText()
} else {
compilingProps = _hideText()
@ -131,11 +121,20 @@ function PreviewRecompileButton({
<Icon type="" modifier="fw" />
{t('run_syntax_check_now')}
</MenuItem>
<MenuItem className={!isCompiling ? 'hidden' : ''} divider />
<MenuItem
onSelect={onStopCompilation}
className={!isCompiling ? 'hidden' : ''}
disabled={!isCompiling}
aria-disabled={!isCompiling}
>
{t('stop_compile')}
</MenuItem>
<MenuItem divider />
<MenuItem
onSelect={handleRecompileFromScratch}
disabled={isCompiling || isClearingCache}
aria-disabled={!!(isCompiling || isClearingCache)}
onSelect={onRecompileFromScratch}
disabled={isCompiling}
aria-disabled={!!isCompiling}
>
{t('recompile_from_scratch')}
</MenuItem>
@ -150,7 +149,7 @@ function PreviewRecompileButton({
placement="bottom"
overlay={
<Tooltip id="tooltip-download-pdf">
{isCompiling || isClearingCache ? t('compiling') : t('recompile')}
{isCompiling ? t('compiling') : t('recompile')}
</Tooltip>
}
>
@ -162,18 +161,18 @@ function PreviewRecompileButton({
PreviewRecompileButton.propTypes = {
compilerState: PropTypes.shape({
isAutoCompileOn: PropTypes.bool.isRequired,
isClearingCache: PropTypes.bool.isRequired,
isCompiling: PropTypes.bool.isRequired,
isDraftModeOn: PropTypes.bool.isRequired,
isSyntaxCheckOn: PropTypes.bool.isRequired,
logEntries: PropTypes.object.isRequired
}),
onClearCache: PropTypes.func.isRequired,
onRecompile: PropTypes.func.isRequired,
onRecompileFromScratch: PropTypes.func.isRequired,
onRunSyntaxCheckNow: PropTypes.func.isRequired,
onSetAutoCompile: PropTypes.func.isRequired,
onSetDraftMode: PropTypes.func.isRequired,
onSetSyntaxCheck: PropTypes.func.isRequired,
onStopCompilation: PropTypes.func.isRequired,
showText: PropTypes.bool.isRequired
}

View file

@ -1,9 +1,12 @@
import React, { useRef, useState } from 'react'
import PropTypes from 'prop-types'
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import PreviewDownloadButton from './preview-download-button'
import PreviewRecompileButton from './preview-recompile-button'
import PreviewLogsToggleButton from './preview-logs-toggle-button'
import useResizeObserver from '../../../shared/hooks/use-resize-observer'
import Icon from '../../../shared/components/icon'
function _getElementWidth(element) {
if (!element) return 0
@ -13,16 +16,20 @@ function _getElementWidth(element) {
function PreviewToolbar({
compilerState,
logsState,
onClearCache,
onRecompileFromScratch,
onRecompile,
onRunSyntaxCheckNow,
onSetAutoCompile,
onSetDraftMode,
onSetSyntaxCheck,
onToggleLogs,
onSetSplitLayout,
onSetFullLayout,
onStopCompilation,
outputFiles,
pdfDownloadUrl,
showLogs
showLogs,
splitLayout
}) {
const showTextRef = useRef(true)
const showToggleTextRef = useRef(true)
@ -33,6 +40,7 @@ function PreviewToolbar({
const [showToggleText, setShowToggleText] = useState(
showToggleTextRef.current
)
const { t } = useTranslation()
function checkCanShowText(observedElement) {
// toolbar items can be in 3 states:
@ -182,6 +190,20 @@ function PreviewToolbar({
return itemWidth
}
const pdfExpandLabel = splitLayout ? t('full_screen') : t('split_screen')
const pdfExpandIconType = splitLayout ? 'expand' : 'compress'
const pdfExpandTooltip = (
<Tooltip id="expand-pdf-btn">{pdfExpandLabel}</Tooltip>
)
function handlePdfExpandBtnClick() {
if (splitLayout) {
onSetFullLayout()
} else {
onSetSplitLayout()
}
}
useResizeObserver(toolbarRef, logsState, checkCanShowText)
return (
@ -195,11 +217,12 @@ function PreviewToolbar({
<PreviewRecompileButton
compilerState={compilerState}
onRecompile={onRecompile}
onRecompileFromScratch={onRecompileFromScratch}
onRunSyntaxCheckNow={onRunSyntaxCheckNow}
onSetAutoCompile={onSetAutoCompile}
onSetDraftMode={onSetDraftMode}
onSetSyntaxCheck={onSetSyntaxCheck}
onClearCache={onClearCache}
onStopCompilation={onStopCompilation}
showText={showText}
/>
<PreviewDownloadButton
@ -217,6 +240,15 @@ function PreviewToolbar({
onToggle={onToggleLogs}
showText={showToggleText}
/>
<OverlayTrigger placement="left" overlay={pdfExpandTooltip}>
<button
onClick={handlePdfExpandBtnClick}
className="toolbar-pdf-expand-btn toolbar-item"
aria-label={pdfExpandLabel}
>
<Icon type={pdfExpandIconType} />
</button>
</OverlayTrigger>
</div>
</div>
)
@ -237,13 +269,17 @@ PreviewToolbar.propTypes = {
nLogEntries: PropTypes.number.isRequired
}),
showLogs: PropTypes.bool.isRequired,
onClearCache: PropTypes.func.isRequired,
splitLayout: PropTypes.bool.isRequired,
onRecompile: PropTypes.func.isRequired,
onRecompileFromScratch: PropTypes.func.isRequired,
onRunSyntaxCheckNow: PropTypes.func.isRequired,
onSetAutoCompile: PropTypes.func.isRequired,
onSetDraftMode: PropTypes.func.isRequired,
onSetSyntaxCheck: PropTypes.func.isRequired,
onToggleLogs: PropTypes.func.isRequired,
onSetSplitLayout: PropTypes.func.isRequired,
onSetFullLayout: PropTypes.func.isRequired,
onStopCompilation: PropTypes.func.isRequired,
pdfDownloadUrl: PropTypes.string,
outputFiles: PropTypes.array
}

View file

@ -33,6 +33,14 @@ App.controller('PdfController', function(
// view logic to check whether the files dropdown should "drop up" or "drop down"
$scope.shouldDropUp = false
// Exposed methods for React layout handling
$scope.setPdfSplitLayout = function() {
$scope.$applyAsync(() => $scope.switchToSideBySideLayout('editor'))
}
$scope.setPdfFullLayout = function() {
$scope.$applyAsync(() => $scope.switchToFlatLayout('pdf'))
}
const logsContainerEl = document.querySelector('.pdf-logs')
const filesDropdownEl =
logsContainerEl && logsContainerEl.querySelector('.files-dropdown')
@ -587,17 +595,20 @@ App.controller('PdfController', function(
const logEntries = {
all: [],
errors: [],
warnings: []
warnings: [],
typesetting: []
}
function accumulateResults(newEntries) {
for (let key of ['all', 'errors', 'warnings']) {
if (newEntries.type != null) {
for (let entry of newEntries[key]) {
entry.type = newEntries.type
for (let key of ['all', 'errors', 'warnings', 'typesetting']) {
if (newEntries[key]) {
if (newEntries.type != null) {
for (let entry of newEntries[key]) {
entry.type = newEntries.type
}
}
logEntries[key] = logEntries[key].concat(newEntries[key])
}
logEntries[key] = logEntries[key].concat(newEntries[key])
}
}
@ -608,7 +619,7 @@ App.controller('PdfController', function(
ignoreDuplicates: true
})
const all = [].concat(errors, warnings, typesetting)
accumulateResults({ all, errors, warnings })
accumulateResults({ all, errors, warnings, typesetting })
}
function processChkTex(log) {
@ -861,6 +872,19 @@ App.controller('PdfController', function(
return deferred.promise
}
$scope.recompileFromScratch = function() {
$scope.pdf.compiling = true
return $scope
.clearCache()
.then(() => {
$scope.pdf.compiling = false
$scope.recompile()
})
.catch(error => {
console.error(error)
})
}
$scope.toggleLogs = function() {
$scope.$applyAsync(() => {
$scope.shouldShowLogs = !$scope.shouldShowLogs

View file

@ -9,19 +9,30 @@ function useExpandCollapse({
} = {}) {
const ref = useRef()
const [isExpanded, setIsExpanded] = useState(initiallyExpanded)
const [size, setSize] = useState()
const [sizing, setSizing] = useState({
size: null,
needsExpandCollapse: null
})
useLayoutEffect(
() => {
const expandCollapseEl = ref.current
if (isExpanded) {
if (expandCollapseEl) {
const expandedSize =
dimension === 'height'
? expandCollapseEl.scrollHeight
: expandCollapseEl.scrollWidth
setSize(expandedSize)
} else {
setSize(collapsedSize)
const needsExpandCollapse = expandedSize > collapsedSize
if (isExpanded) {
setSizing({ size: expandedSize, needsExpandCollapse })
} else {
setSizing({
size: needsExpandCollapse ? collapsedSize : expandedSize,
needsExpandCollapse
})
}
}
},
[isExpanded]
@ -39,10 +50,11 @@ function useExpandCollapse({
return {
isExpanded,
needsExpandCollapse: sizing.needsExpandCollapse,
expandableProps: {
ref,
style: {
[dimension === 'height' ? 'height' : 'width']: `${size}px`
[dimension === 'height' ? 'height' : 'width']: `${sizing.size}px`
},
className: expandableClasses
},

View file

@ -17,7 +17,6 @@ function useResizeObserver(observedElement, observedData, callback) {
() => {
if ('ResizeObserver' in window) {
const observedCurrent = observedElement && observedElement.current
if (observedCurrent) {
observe(observedElement.current)
}
@ -27,7 +26,9 @@ function useResizeObserver(observedElement, observedData, callback) {
}
return () => {
unobserve(observedCurrent)
if (observedCurrent) {
unobserve(observedCurrent)
}
}
}
},

View file

@ -1,42 +1,81 @@
.logs-pane {
.full-size;
top: @pdf-top-offset;
padding: @padding-sm;
overflow-y: auto;
background-color: @logs-pane-bg;
}
.logs-pane-content {
display: flex;
flex-direction: column;
padding: @padding-sm;
min-height: 100%;
}
.logs-pane-actions {
display: flex;
justify-content: flex-end;
padding: @padding-sm 0;
flex-grow: 1;
align-items: flex-end;
}
.logs-pane-actions-clear-cache {
.no-outline-ring-on-click;
margin-right: @margin-sm;
}
.log-entry {
margin-bottom: @margin-sm;
border-radius: @border-radius-base;
overflow: hidden;
}
.log-entry-first-error-popup {
border-radius: 0;
overflow: auto;
}
.log-entry-header {
padding: 3px @padding-sm;
display: flex;
align-items: flex-start;
color: #fff;
border-radius: @border-radius-base @border-radius-base 0 0;
&:last-child {
border-radius: @border-radius-base;
}
color: #fff;
}
.log-entry-header-error {
background-color: @ol-red;
}
.log-entry-header-link-error {
.btn-alert-variant(@ol-red);
}
.log-entry-header-warning {
background-color: @orange;
}
.log-entry-header-link-warning {
.btn-alert-variant(@orange);
}
.log-entry-header-typesetting {
background-color: @ol-blue;
}
.log-entry-header-link-typesetting {
.btn-alert-variant(@ol-blue);
}
.log-entry-header-raw {
background-color: @ol-blue-gray-4;
}
.log-entry-header-link-raw {
.btn-alert-variant(@ol-blue-gray-4);
}
.log-entry-header-title,
.log-entry-header-link {
font-family: @font-family-sans-serif;
@ -49,11 +88,14 @@
}
.log-entry-header-link {
display: flex;
align-items: center;
border-radius: 9999px;
border-width: 0;
flex-grow: 0;
text-align: right;
white-space: nowrap;
padding-left: @padding-sm;
margin-left: @margin-sm;
max-width: 33%;
&:hover,
&:focus {
outline: 0;
@ -64,59 +106,44 @@
}
}
.log-entry-header-link-location {
white-space: nowrap;
direction: rtl;
text-overflow: ellipsis;
overflow: hidden;
padding: 0 @padding-xs;
}
.log-entry-content {
background-color: #fff;
padding: @padding-sm;
&:last-child {
border-radius: 0 0 @border-radius-base @border-radius-base;
}
}
.log-entry-content-raw-expandable-container {
position: relative;
.log-entry-content-raw-container {
background-color: @ol-blue-gray-1;
border-radius: @border-radius-base;
}
.log-entry-content-raw-button-container-collapsed {
padding-bottom: 0;
overflow: hidden;
}
.log-entry-content-raw {
font-size: @font-size-small;
font-size: @font-size-extra-small;
color: @ol-blue-gray-4;
padding: @padding-sm @padding-sm @padding-xl @padding-sm;
padding: @padding-sm;
margin: 0;
}
.log-entry-content-raw-collapsed {
padding-bottom: 0;
}
.log-entry-content-button-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
position: relative;
height: 40px;
margin-top: 0;
transition: margin 0.15s ease-in-out, opacity 0.15s ease-in-out;
padding-bottom: @padding-sm;
text-align: center;
background-image: linear-gradient(0, @ol-blue-gray-1, transparent);
border-radius: 0 0 @border-radius-base @border-radius-base;
&::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-image: linear-gradient(0, @ol-blue-gray-1, transparent);
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
}
.log-entry-content-button-container-collapsed::before {
opacity: 1;
.log-entry-content-button-container-collapsed {
margin-top: -40px;
}
.log-entry-btn-expand-collapse {
@ -139,7 +166,6 @@
z-index: 1;
top: @toolbar-small-height + 2px;
right: @padding-xs;
border-radius: @border-radius-base;
width: 90%;
max-width: 450px;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
@ -149,7 +175,7 @@
content: '';
.triangle(top, @padding-sm, @padding-xs, @ol-red);
top: -@padding-xs;
right: @padding-md;
right: @padding-xl;
}
}
@ -157,9 +183,20 @@
display: flex;
justify-content: space-between;
padding: 0 @padding-sm @padding-sm @padding-sm;
margin-top: -@margin-sm;
border-radius: 0 0 @border-radius-base @border-radius-base;
}
.first-error-btn {
.no-outline-ring-on-click;
}
.log-location-tooltip {
word-break: break-all;
&.tooltip.in {
opacity: 1;
}
& > .tooltip-inner {
max-width: 450px;
text-align: left;
}
}

View file

@ -465,12 +465,43 @@
}
}
#download-dropdown-list {
max-height: calc(
~'100vh - ' @toolbar-small-height ~' - ' @toolbar-height ~' - ' @margin-md
);
@editor-and-logs-pane-toolbars-height: @toolbar-small-height + @toolbar-height;
@btn-small-height: (@padding-small-vertical * 2)+ (@font-size-small *
@line-height-small);
#download-dropdown-list,
#dropdown-files-logs-pane-list {
overflow-y: auto;
.dropdown-header {
white-space: nowrap;
}
}
#download-dropdown-list {
max-height: calc(
~'100vh - ' @editor-and-logs-pane-toolbars-height ~' - ' @margin-md
);
}
#dropdown-files-logs-pane-list {
max-height: calc(
~'100vh - ' @editor-and-logs-pane-toolbars-height ~' - ' @btn-small-height ~' - '
@margin-md
);
}
.toolbar-pdf-expand-btn {
.btn-inline-link;
margin-left: @margin-xs;
color: @toolbar-icon-btn-color;
border-radius: @border-radius-small;
&:hover {
color: @toolbar-icon-btn-hover-color;
}
&:active {
background-color: @link-color;
color: #fff;
}
&:focus {
outline: 0;
color: #fff;
}
}

View file

@ -58,19 +58,43 @@
.alert-success {
.alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text);
}
.btn-alert-success {
.btn-alert-variant(@alert-success-bg);
}
.alert-info {
.alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text);
}
.btn-alert-info {
.btn-alert-variant(@alert-info-bg);
}
.alert-warning {
.alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text);
}
.btn-alert-warning {
.btn-alert-variant(@alert-warning-bg);
}
.alert-danger {
.alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text);
}
.btn-alert-danger {
.btn-alert-variant(@alert-danger-bg);
}
.alert-alt {
.alert-variant(@alert-alt-bg; @alert-alt-border; @alert-alt-text);
}
.btn-alert-alt {
.btn-alert-variant(@alert-alt-bg);
}
.alert {
a,
.btn-inline-link {

View file

@ -522,7 +522,7 @@
display: inline-block;
font-weight: bold;
text-decoration: none;
.button-variant(#fff, shade(@background, 20%), transparent);
.btn-alert-variant(@background);
.button-size(
@padding-xs-vertical; @padding-small-horizontal; @font-size-small;
@line-height-small; @btn-border-radius-small
@ -538,6 +538,11 @@
}
}
.btn-alert-variant(@alert-bg) {
.button-variant(#fff, shade(@alert-bg, 15%), transparent);
border-radius: @btn-border-radius-base;
}
// Tables
// -------------------------
.table-row-variant(@state; @background) {

View file

@ -85,7 +85,8 @@
@font-size-base: 16px;
@font-size-large: ceil((@font-size-base * 1.25)); // ~18px
@font-size-small: ceil((@font-size-base * 0.85)); // ~12px
@font-size-small: ceil((@font-size-base * 0.85)); // ~14px
@font-size-extra-small: ceil((@font-size-base * 0.7)); // ~12px
@font-size-h1: floor((@font-size-base * 2)); // ~36px
@font-size-h2: floor((@font-size-base * 1.6)); // ~30px

View file

@ -1,11 +1,11 @@
{
"compile_error_description": "Your project did not compile because of an error",
"validation_issue_description": "Your project did not compile because of a validation issue",
"compile_error_entry_description": "An error which prevented your project from compiling",
"validation_issue_entry_description": "A validation issue which prevented your project from compiling",
"compile_error_description": "This project did not compile because of an error",
"validation_issue_description": "This project did not compile because of a validation issue",
"compile_error_entry_description": "An error which prevented this project from compiling",
"validation_issue_entry_description": "A validation issue which prevented this project from compiling",
"raw_logs_description": "Raw logs from the LaTeX compiler",
"raw_logs": "Raw logs",
"first_error_popup_label": "Your project has errors. This is the first one.",
"first_error_popup_label": "This project has errors. This is the first one.",
"dismiss_error_popup": "Dismiss first error alert",
"go_to_error_location": "Go to error location",
"view_all_errors": "View all errors",
@ -20,7 +20,7 @@
"n_errors_plural": "__count__ errors",
"toggle_compile_options_menu": "Toggle compile options menu",
"view_pdf": "View PDF",
"your_project_has_errors": "Your project has errors",
"your_project_has_errors": "This project has errors",
"view_warnings": "View warnings",
"view_logs": "View logs",
"recompile_from_scratch": "Recompile from scratch",
@ -913,7 +913,7 @@
"no_errors_good_job": "No errors, good job!",
"compile_error": "Compile Error",
"generic_failed_compile_message": "Sorry, your LaTeX code couldn't compile for some reason. Please check the errors below for details, or view the raw log",
"other_logs_and_files": "Other logs &amp; files",
"other_logs_and_files": "Other logs and files",
"view_raw_logs": "View Raw Logs",
"hide_raw_logs": "Hide Raw Logs",
"clear_cache": "Clear cache",

View file

@ -2,23 +2,12 @@ import React from 'react'
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import PreviewDownloadButton, {
topFileTypes
} from '../../../../../frontend/js/features/preview/components/preview-download-button'
import PreviewDownloadButton from '../../../../../frontend/js/features/preview/components/preview-download-button'
describe('<PreviewDownloadButton />', function() {
const projectId = 'projectId123'
const pdfDownloadUrl = `/download/project/${projectId}/build/17523aaafdf-1ad9063af140f004/output/output.pdf?compileGroup=priority&popupDownload=true`
function makeFile(fileName, main) {
return {
fileName,
url: `/project/${projectId}/output/${fileName}`,
type: fileName.split('.').pop(),
main: main || false
}
}
function renderPreviewDownloadButton(
isCompiling,
outputFiles,
@ -92,72 +81,7 @@ describe('<PreviewDownloadButton />', function() {
expect(buttons[0]).to.exist
expect(buttons[0].getAttribute('disabled')).to.not.exist
})
it('should list all output files and group them', function() {
const isCompiling = false
const outputFiles = [
makeFile('output.ind'),
makeFile('output.log'),
makeFile('output.pdf', true),
makeFile('alt.pdf'),
makeFile('output.stderr'),
makeFile('output.stdout'),
makeFile('output.aux'),
makeFile('output.bbl'),
makeFile('output.blg')
]
renderPreviewDownloadButton(isCompiling, outputFiles, pdfDownloadUrl)
const menuItems = screen.getAllByRole('menuitem')
expect(menuItems.length).to.equal(outputFiles.length - 1) // main PDF is listed separately
const fileTypes = outputFiles.map(file => {
return file.type
})
menuItems.forEach((item, index) => {
// check displayed text
const fileType = item.textContent.split('.').pop()
expect(fileTypes).to.include(fileType)
})
// check grouped correctly
expect(topFileTypes).to.exist
expect(topFileTypes.length).to.be.above(0)
const outputTopFileTypes = outputFiles
.filter(file => {
if (topFileTypes.includes(file.type)) return file.type
})
.map(file => file.type)
const topMenuItems = menuItems.slice(0, outputTopFileTypes.length)
topMenuItems.forEach(item => {
const fileType = item.textContent
.split('.')
.pop()
.replace(' file', '')
expect(topFileTypes.includes(fileType)).to.be.true
})
})
it('should list all files when there are duplicate types', function() {
const isCompiling = false
const pdfFile = makeFile('output.pdf', true)
const bblFile = makeFile('output.bbl')
const outputFiles = [Object.assign({}, { ...bblFile }), bblFile, pdfFile]
renderPreviewDownloadButton(isCompiling, outputFiles, pdfDownloadUrl)
const bblMenuItems = screen.getAllByText((content, element) => {
return content !== '' && element.textContent === 'output.bbl'
})
expect(bblMenuItems.length).to.equal(2)
})
it('should list the non-main PDF in the dropdown', function() {
const isCompiling = false
const pdfFile = makeFile('output.pdf', true)
const pdfAltFile = makeFile('alt.pdf')
const outputFiles = [pdfFile, pdfAltFile]
renderPreviewDownloadButton(isCompiling, outputFiles, pdfDownloadUrl)
screen.getAllByRole('menuitem', { name: 'alt.pdf' })
})
it('should show the button text when prop showText=true', function() {
const isCompiling = false
const showText = true
@ -172,39 +96,4 @@ describe('<PreviewDownloadButton />', function() {
'position: absolute; right: -100vw;'
)
})
describe('list divider and header', function() {
it('should display when there are top files and other files', function() {
const outputFiles = [
makeFile('output.bbl'),
makeFile('output.ind'),
makeFile('output.gls'),
makeFile('output.log')
]
renderPreviewDownloadButton(false, outputFiles, pdfDownloadUrl, true)
screen.getByText('Download other output files')
screen.getByRole('separator')
})
it('should not display when there are top files and no other files', function() {
const outputFiles = [
makeFile('output.bbl'),
makeFile('output.ind'),
makeFile('output.gls')
]
renderPreviewDownloadButton(false, outputFiles, pdfDownloadUrl, true)
expect(screen.queryByText('Other output files')).to.not.exist
expect(screen.queryByRole('separator')).to.not.exist
})
it('should not display when there are other files and no top files', function() {
const outputFiles = [makeFile('output.log')]
renderPreviewDownloadButton(false, outputFiles, pdfDownloadUrl, true)
expect(screen.queryByText('Other output files')).to.not.exist
expect(screen.queryByRole('separator')).to.not.exist
})
})
})

View file

@ -0,0 +1,124 @@
import React from 'react'
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import PreviewDownloadFileList, {
topFileTypes
} from '../../../../../frontend/js/features/preview/components/preview-download-file-list'
describe('<PreviewDownloadFileList />', function() {
const projectId = 'projectId123'
function makeFile(fileName, main) {
return {
fileName,
url: `/project/${projectId}/output/${fileName}`,
type: fileName.split('.').pop(),
main: main || false
}
}
it('should list all output files and group them', function() {
const outputFiles = [
makeFile('output.ind'),
makeFile('output.log'),
makeFile('output.pdf', true),
makeFile('alt.pdf'),
makeFile('output.stderr'),
makeFile('output.stdout'),
makeFile('output.aux'),
makeFile('output.bbl'),
makeFile('output.blg')
]
render(<PreviewDownloadFileList fileList={outputFiles} />)
const menuItems = screen.getAllByRole('menuitem')
expect(menuItems.length).to.equal(outputFiles.length - 1) // main PDF is listed separately
const fileTypes = outputFiles.map(file => {
return file.type
})
menuItems.forEach((item, index) => {
// check displayed text
const fileType = item.textContent.split('.').pop()
expect(fileTypes).to.include(fileType)
})
// check grouped correctly
expect(topFileTypes).to.exist
expect(topFileTypes.length).to.be.above(0)
const outputTopFileTypes = outputFiles
.filter(file => {
if (topFileTypes.includes(file.type)) return file.type
})
.map(file => file.type)
const topMenuItems = menuItems.slice(0, outputTopFileTypes.length)
topMenuItems.forEach(item => {
const fileType = item.textContent
.split('.')
.pop()
.replace(' file', '')
expect(topFileTypes.includes(fileType)).to.be.true
})
})
it('should list all files when there are duplicate types', function() {
const pdfFile = makeFile('output.pdf', true)
const bblFile = makeFile('output.bbl')
const outputFiles = [Object.assign({}, { ...bblFile }), bblFile, pdfFile]
render(<PreviewDownloadFileList fileList={outputFiles} />)
const bblMenuItems = screen.getAllByText((content, element) => {
return content !== '' && element.textContent === 'output.bbl'
})
expect(bblMenuItems.length).to.equal(2)
})
it('should list the non-main PDF in the dropdown', function() {
const pdfFile = makeFile('output.pdf', true)
const pdfAltFile = makeFile('alt.pdf')
const outputFiles = [pdfFile, pdfAltFile]
render(<PreviewDownloadFileList fileList={outputFiles} />)
screen.getAllByRole('menuitem', { name: 'alt.pdf' })
})
describe('list divider and header', function() {
it('should display when there are top files and other files', function() {
const outputFiles = [
makeFile('output.bbl'),
makeFile('output.ind'),
makeFile('output.gls'),
makeFile('output.log')
]
render(<PreviewDownloadFileList fileList={outputFiles} />)
screen.getByText('Download other output files')
screen.getByRole('separator')
})
it('should not display when there are top files and no other files', function() {
const outputFiles = [
makeFile('output.bbl'),
makeFile('output.ind'),
makeFile('output.gls')
]
render(<PreviewDownloadFileList fileList={outputFiles} />)
expect(screen.queryByText('Other output files')).to.not.exist
expect(screen.queryByRole('separator')).to.not.exist
})
it('should not display when there are other files and no top files', function() {
const outputFiles = [makeFile('output.log')]
render(<PreviewDownloadFileList fileList={outputFiles} />)
expect(screen.queryByText('Other output files')).to.not.exist
expect(screen.queryByRole('separator')).to.not.exist
})
})
})

View file

@ -76,6 +76,42 @@ describe('<PreviewLogsPaneEntry />', function() {
describe('logs pane entry raw contents', function() {
const rawContent = 'foo bar latex error stuff baz'
// JSDom doesn't compute layout/sizing, so we need to simulate sizing for the elements
// Here we are simulating that the content is bigger than the `collapsedSize`, so
// the expand-collapse widget is used
const originalScrollHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight'
)
const originalScrollWidth = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth'
)
beforeEach(function() {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 500
})
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
value: 500
})
})
afterEach(function() {
Object.defineProperty(
HTMLElement.prototype,
'scrollHeight',
originalScrollHeight
)
Object.defineProperty(
HTMLElement.prototype,
'scrollWidth',
originalScrollWidth
)
})
it('renders collapsed contents by default', function() {
render(<PreviewLogsPaneEntry rawContent={rawContent} level={level} />)
screen.getByText(rawContent)

View file

@ -49,65 +49,71 @@ entering extended mode
const errors = [sampleError1, sampleError2]
const warnings = [sampleWarning]
const typesetting = [sampleTypesettingIssue]
const logEntries = [...errors, ...warnings, ...typesetting]
const logEntries = {
all: [...errors, ...warnings, ...typesetting],
errors,
warnings,
typesetting
}
const onLogEntryLocationClick = sinon.stub()
const noOp = () =>
describe('with logs', function() {
beforeEach(function() {
render(
<PreviewLogsPane
logEntries={logEntries}
rawLog={sampleRawLog}
onLogEntryLocationClick={onLogEntryLocationClick}
onClearCache={noOp}
/>
)
})
it('renders all log entries with appropriate labels', function() {
const errorEntries = screen.getAllByLabelText(
`Log entry with level: error`
)
const warningEntries = screen.getAllByLabelText(
`Log entry with level: warning`
)
const typesettingEntries = screen.getAllByLabelText(
`Log entry with level: typesetting`
)
expect(errorEntries).to.have.lengthOf(errors.length)
expect(warningEntries).to.have.lengthOf(warnings.length)
expect(typesettingEntries).to.have.lengthOf(typesetting.length)
})
describe('with logs', function() {
beforeEach(function() {
render(
<PreviewLogsPane
logEntries={logEntries}
rawLog={sampleRawLog}
onLogEntryLocationClick={onLogEntryLocationClick}
/>
)
})
it('renders all log entries with appropriate labels', function() {
const errorEntries = screen.getAllByLabelText(
`Log entry with level: error`
)
const warningEntries = screen.getAllByLabelText(
`Log entry with level: warning`
)
const typesettingEntries = screen.getAllByLabelText(
`Log entry with level: typesetting`
)
expect(errorEntries).to.have.lengthOf(errors.length)
expect(warningEntries).to.have.lengthOf(warnings.length)
expect(typesettingEntries).to.have.lengthOf(typesetting.length)
})
it('renders the raw log', function() {
screen.getByLabelText('Raw logs from the LaTeX compiler')
})
it('renders the raw log', function() {
screen.getByLabelText('Raw logs from the LaTeX compiler')
})
it('renders a link to location button for every error and warning log entry', function() {
logEntries.forEach((entry, index) => {
const linkToSourceButton = screen.getByRole('button', {
name: `Navigate to log position in source code: ${entry.file}, ${
entry.line
}`
})
fireEvent.click(linkToSourceButton)
expect(onLogEntryLocationClick).to.have.callCount(index + 1)
const call = onLogEntryLocationClick.getCall(index)
expect(
call.calledWith({
file: entry.file,
line: entry.line,
column: entry.column
it('renders a link to location button for every error and warning log entry', function() {
logEntries.all.forEach((entry, index) => {
const linkToSourceButton = screen.getByRole('button', {
name: `Navigate to log position in source code: ${entry.file}, ${
entry.line
}`
})
).to.be.true
fireEvent.click(linkToSourceButton)
expect(onLogEntryLocationClick).to.have.callCount(index + 1)
const call = onLogEntryLocationClick.getCall(index)
expect(
call.calledWith({
file: entry.file,
line: entry.line,
column: entry.column
})
).to.be.true
})
})
it(' does not render a link to location button for the raw log entry', function() {
const rawLogEntry = screen.getByLabelText(
'Raw logs from the LaTeX compiler'
)
expect(rawLogEntry.querySelector('.log-entry-header-link')).to.not.exist
})
})
it(' does not render a link to location button for the raw log entry', function() {
const rawLogEntry = screen.getByLabelText(
'Raw logs from the LaTeX compiler'
)
expect(rawLogEntry.querySelector('.log-entry-header-link')).to.not.exist
})
})
describe('with validation issues', function() {
const sampleValidationIssues = {
@ -125,10 +131,11 @@ entering extended mode
<PreviewLogsPane
validationIssues={sampleValidationIssues}
onLogEntryLocationClick={onLogEntryLocationClick}
onClearCache={noOp}
/>
)
const validationEntries = screen.getAllByLabelText(
'A validation issue which prevented your project from compiling'
'A validation issue which prevented this project from compiling'
)
expect(validationEntries).to.have.lengthOf(
Object.keys(sampleValidationIssues).length
@ -140,10 +147,11 @@ entering extended mode
<PreviewLogsPane
validationIssues={{ unknownIssue: true }}
onLogEntryLocationClick={onLogEntryLocationClick}
onClearCache={noOp}
/>
)
const validationEntries = screen.queryAllByLabelText(
'A validation issue prevented your project from compiling'
'A validation issue prevented this project from compiling'
)
expect(validationEntries).to.have.lengthOf(0)
})
@ -161,10 +169,11 @@ entering extended mode
<PreviewLogsPane
errors={sampleErrors}
onLogEntryLocationClick={onLogEntryLocationClick}
onClearCache={noOp}
/>
)
const errorEntries = screen.getAllByLabelText(
'An error which prevented your project from compiling'
'An error which prevented this project from compiling'
)
expect(errorEntries).to.have.lengthOf(Object.keys(sampleErrors).length)
})
@ -174,10 +183,11 @@ entering extended mode
<PreviewLogsPane
errors={{ unknownIssue: true }}
onLogEntryLocationClick={onLogEntryLocationClick}
onClearCache={noOp}
/>
)
const errorEntries = screen.queryAllByLabelText(
'There was an error compiling your project'
'There was an error compiling this project'
)
expect(errorEntries).to.have.lengthOf(0)
})

View file

@ -60,7 +60,7 @@ describe('<PreviewLogsToggleButton />', function() {
nLogEntries: 0
}
renderPreviewLogsToggleButton(logsState, onToggleLogs, showLogs)
screen.getByText(`Your project has errors (${logsState.nErrors})`)
screen.getByText(`This project has errors (${logsState.nErrors})`)
})
it('should render an error status message when there are both errors and warnings', function() {
@ -70,7 +70,7 @@ describe('<PreviewLogsToggleButton />', function() {
nLogEntries: 0
}
renderPreviewLogsToggleButton(logsState, onToggleLogs, showLogs)
screen.getByText(`Your project has errors (${logsState.nErrors})`)
screen.getByText(`This project has errors (${logsState.nErrors})`)
})
it('should render a warning status message when there are warnings but no errors', function() {
@ -90,7 +90,7 @@ describe('<PreviewLogsToggleButton />', function() {
nLogEntries: 0
}
renderPreviewLogsToggleButton(logsState, onToggleLogs, showLogs)
screen.getByText('Your project has errors (9+)')
screen.getByText('This project has errors (9+)')
})
it('should show the button text when prop showText=true', function() {
const logsState = {

View file

@ -33,7 +33,7 @@ describe('<PreviewPane />', function() {
})
render(<PreviewPane {...propsAfterCompileWithErrors} />)
screen.getByRole('alertdialog', {
name: 'Your project has errors. This is the first one.'
name: 'This project has errors. This is the first one.'
})
screen.getByText(sampleError1.message)
})
@ -46,7 +46,7 @@ describe('<PreviewPane />', function() {
render(<PreviewPane {...propsAfterCompileWithWarningsOnly} />)
expect(
screen.queryByRole('alertdialog', {
name: 'Your project has errors. This is the first one.'
name: 'This project has errors. This is the first one.'
})
).to.not.exist
})
@ -59,7 +59,7 @@ describe('<PreviewPane />', function() {
render(<PreviewPane {...propsWhileCompiling} />)
expect(
screen.queryByRole('alertdialog', {
name: 'Your project has errors. This is the first one.'
name: 'This project has errors. This is the first one.'
})
).to.not.exist
})
@ -77,7 +77,7 @@ describe('<PreviewPane />', function() {
render(<PreviewPane {...propsWithErrorsViewingLogs} />)
expect(
screen.queryByRole('alertdialog', {
name: 'Your project has errors. This is the first one.'
name: 'This project has errors. This is the first one.'
})
).to.not.exist
})
@ -108,7 +108,7 @@ describe('<PreviewPane />', function() {
rerender(<PreviewPane {...propsWithErrorsAfterViewingLogs} />)
expect(
screen.queryByRole('alertdialog', {
name: 'Your project has errors. This is the first one.'
name: 'This project has errors. This is the first one.'
})
).to.not.exist
})
@ -139,7 +139,7 @@ describe('<PreviewPane />', function() {
)
rerender(<PreviewPane {...propsWithErrorsAfterSecondCompile} />)
screen.getByRole('alertdialog', {
name: 'Your project has errors. This is the first one.'
name: 'This project has errors. This is the first one.'
})
screen.getByText(sampleError2.message)
})
@ -157,7 +157,7 @@ describe('<PreviewPane />', function() {
fireEvent.click(dismissPopUpButton)
expect(
screen.queryByRole('alertdialog', {
name: 'Your project has errors. This is the first one.'
name: 'This project has errors. This is the first one.'
})
).to.not.exist
})
@ -191,7 +191,7 @@ describe('<PreviewPane />', function() {
rerender(<PreviewPane {...propsWithErrorsForSecondCompile} />)
expect(
screen.queryByRole('alertdialog', {
name: 'Your project has errors. This is the first one.'
name: 'This project has errors. This is the first one.'
})
).to.not.exist
})
@ -228,7 +228,7 @@ describe('<PreviewPane />', function() {
)
render(<PreviewPane {...propsWithCLSIError} />)
screen.getByText('Your project did not compile because of an error')
screen.getByText('This project did not compile because of an error')
})
it('renders an accessible description for failed compiles with validation issues', function() {
@ -248,7 +248,7 @@ describe('<PreviewPane />', function() {
render(<PreviewPane {...propsWithValidationIssue} />)
screen.getByText(
'Your project did not compile because of a validation issue'
'This project did not compile because of a validation issue'
)
})
})
@ -262,6 +262,17 @@ describe('<PreviewPane />', function() {
validationIssues = {},
errors = {}
) {
const logEntriesWithDefaults = {
errors: [],
warnings: [],
typesetting: [],
...logEntries
}
logEntriesWithDefaults.all = [
...logEntriesWithDefaults.errors,
...logEntriesWithDefaults.warnings,
...logEntriesWithDefaults.typesetting
]
return {
compilerState: {
isAutoCompileOn: false,
@ -270,7 +281,7 @@ describe('<PreviewPane />', function() {
isDraftModeOn: false,
isSyntaxCheckOn: false,
lastCompileTimestamp,
logEntries,
logEntries: logEntriesWithDefaults,
compileFailed,
validationIssues,
errors
@ -278,12 +289,17 @@ describe('<PreviewPane />', function() {
onClearCache: () => {},
onLogEntryLocationClick: () => {},
onRecompile: () => {},
onRecompileFromScratch: () => {},
onRunSyntaxCheckNow: () => {},
onSetAutoCompile: () => {},
onSetDraftMode: () => {},
onSetSyntaxCheck: () => {},
onToggleLogs: () => {},
showLogs: isShowingLogs
onSetSplitLayout: () => {},
onSetFullLayout: () => {},
onStopCompilation: () => {},
showLogs: isShowingLogs,
splitLayout: true
}
}
})

View file

@ -5,18 +5,19 @@ import PreviewRecompileButton from '../../../../../frontend/js/features/preview/
const { expect } = require('chai')
describe('<PreviewRecompileButton />', function() {
let onRecompile, onClearCache
let onRecompile, onRecompileFromScratch, onStopCompilation
beforeEach(function() {
onRecompile = sinon.stub().resolves()
onClearCache = sinon.stub().resolves()
onRecompileFromScratch = sinon.stub().resolves()
onStopCompilation = sinon.stub().resolves()
})
it('renders all items', function() {
renderPreviewRecompileButton()
const menuItems = screen.getAllByRole('menuitem')
expect(menuItems.length).to.equal(8)
expect(menuItems.length).to.equal(9)
expect(menuItems.map(item => item.textContent)).to.deep.equal([
'On',
'Off',
@ -25,6 +26,7 @@ describe('<PreviewRecompileButton />', function() {
'Check syntax before compile',
"Don't check syntax",
'Run syntax check now',
'Stop compilation',
'Recompile from scratch'
])
@ -39,39 +41,17 @@ describe('<PreviewRecompileButton />', function() {
describe('Recompile from scratch', function() {
describe('click', function() {
it('should call onClearCache and onRecompile', async function() {
it('should call onRecompileFromScratch', async function() {
renderPreviewRecompileButton()
const button = screen.getByRole('menuitem', {
name: 'Recompile from scratch'
})
await fireEvent.click(button)
expect(onClearCache).to.have.been.calledOnce
expect(onRecompile).to.have.been.calledOnce
expect(onRecompileFromScratch).to.have.been.calledOnce
})
})
describe('processing', function() {
it('shows processing view and disable menuItem when clearing cache', function() {
renderPreviewRecompileButton({ isClearingCache: true })
screen.getByRole('button', { name: 'Compiling …' })
expect(
screen
.getByRole('menuitem', {
name: 'Recompile from scratch'
})
.getAttribute('aria-disabled')
).to.equal('true')
expect(
screen
.getByRole('menuitem', {
name: 'Recompile from scratch'
})
.closest('li')
.getAttribute('class')
).to.equal('disabled')
})
it('shows processing view and disable menuItem when recompiling', function() {
renderPreviewRecompileButton({ isCompiling: true })
@ -128,7 +108,8 @@ describe('<PreviewRecompileButton />', function() {
onSetAutoCompile={() => {}}
onSetDraftMode={() => {}}
onSetSyntaxCheck={() => {}}
onClearCache={onClearCache}
onRecompileFromScratch={onRecompileFromScratch}
onStopCompilation={onStopCompilation}
showText={showText}
/>
)

View file

@ -1,19 +1,27 @@
import React from 'react'
import sinon from 'sinon'
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import { screen, render, fireEvent } from '@testing-library/react'
import PreviewToolbar from '../../../../../frontend/js/features/preview/components/preview-toolbar'
describe('<PreviewToolbar />', function() {
const onClearCache = sinon.stub()
const onRecompile = sinon.stub()
const onRecompileFromScratch = sinon.stub()
const onRunSyntaxCheckNow = sinon.stub()
const onSetAutoCompile = sinon.stub()
const onSetDraftMode = sinon.stub()
const onSetSyntaxCheck = sinon.stub()
const onToggleLogs = sinon.stub()
const onSetSplitLayout = sinon.stub()
const onSetFullLayout = sinon.stub()
const onStopCompilation = sinon.stub()
function renderPreviewToolbar(compilerState = {}, logState = {}, showLogs) {
function renderPreviewToolbar(
compilerState = {},
logState = {},
showLogs = false,
splitLayout = true
) {
render(
<PreviewToolbar
compilerState={{
@ -26,8 +34,8 @@ describe('<PreviewToolbar />', function() {
...compilerState
}}
logsState={{ nErrors: 0, nWarnings: 0, nLogEntries: 0, ...logState }}
onClearCache={onClearCache}
onRecompile={onRecompile}
onRecompileFromScratch={onRecompileFromScratch}
onRunSyntaxCheckNow={onRunSyntaxCheckNow}
onSetAutoCompile={onSetAutoCompile}
onSetDraftMode={onSetDraftMode}
@ -35,7 +43,11 @@ describe('<PreviewToolbar />', function() {
onToggleLogs={onToggleLogs}
outputFiles={[]}
pdfDownloadUrl="/download-pdf-url"
showLogs={showLogs || false}
showLogs={showLogs}
splitLayout={splitLayout}
onSetSplitLayout={onSetSplitLayout}
onSetFullLayout={onSetFullLayout}
onStopCompilation={onStopCompilation}
/>
)
}
@ -63,4 +75,22 @@ describe('<PreviewToolbar />', function() {
}
}
})
it('renders a full-screen button with a tooltip when when in split-screen mode', function() {
renderPreviewToolbar()
const btn = screen.getByLabelText('Full screen')
fireEvent.click(btn)
expect(onSetFullLayout).to.have.been.calledOnce
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Full screen' })
})
it('renders a split-screen button with a tooltip when when in full-screen mode', function() {
renderPreviewToolbar({}, {}, false, false)
const btn = screen.getByLabelText('Split screen')
fireEvent.click(btn)
expect(onSetSplitLayout).to.have.been.calledOnce
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Split screen' })
})
})

View file

@ -14,6 +14,15 @@ const sampleContent = (
</div>
)
const originalScrollHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight'
)
const originalScrollWidth = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth'
)
function ExpandCollapseTestUI({ expandCollapseArgs }) {
const { expandableProps } = useExpandCollapse(expandCollapseArgs)
return (
@ -27,6 +36,33 @@ ExpandCollapseTestUI.propTypes = {
}
describe('useExpandCollapse', function() {
// JSDom doesn't compute layout/sizing, so we need to simulate sizing for the elements
// Here we are simulating that the content is bigger than the `collapsedSize`, so
// the expand-collapse widget is used
beforeEach(function() {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 500
})
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
value: 500
})
})
afterEach(function() {
Object.defineProperty(
HTMLElement.prototype,
'scrollHeight',
originalScrollHeight
)
Object.defineProperty(
HTMLElement.prototype,
'scrollWidth',
originalScrollWidth
)
})
describe('custom CSS classes', function() {
it('supports a custom CSS class', function() {
const testArgs = {