diff --git a/services/web/app/views/project/editor/pdf.pug b/services/web/app/views/project/editor/pdf.pug index 9212da8f7c..9bb196a5cb 100644 --- a/services/web/app/views/project/editor/pdf.pug +++ b/services/web/app/views/project/editor/pdf.pug @@ -19,6 +19,7 @@ div.full-size.pdf(ng-controller="PdfController") output-files="pdf.outputFiles" pdf-download-url="pdf.downloadUrl" show-logs="shouldShowLogs" + on-log-entry-link-click="openInEditor" ) else .toolbar.toolbar-pdf(ng-class="{ 'changes-to-autocompile': changesToAutoCompile && !autoCompileLintingError }") @@ -172,7 +173,7 @@ div.full-size.pdf(ng-controller="PdfController") ) #{translate("failed_compile_check_ignore")} | . - div(ng-repeat="entry in pdf.logEntries.all", ng-controller="PdfLogEntryController") + div(ng-repeat="entry in pdf.logEntries.all") .alert( ng-class="{\ 'alert-danger': entry.level == 'error',\ diff --git a/services/web/frontend/extracted-translation-keys.json b/services/web/frontend/extracted-translation-keys.json index 8381c67561..77ec3f01f3 100644 --- a/services/web/frontend/extracted-translation-keys.json +++ b/services/web/frontend/extracted-translation-keys.json @@ -1,40 +1,43 @@ [ - "download_pdf", - "download_file", - "n_warnings", - "n_warnings_plural", - "n_errors", - "n_errors_plural", - "file_outline", - "the_file_outline_is_a_new_feature_click_the_icon_to_learn_more", - "we_cant_find_any_sections_or_subsections_in_this_file", - "find_out_more_about_the_file_outline", - "expand", - "collapse", - "show_outline", - "hide_outline", - "compiling", - "recompile", "auto_compile", - "on", - "off", - "other_output_files", + "collapse", "compile_mode", - "normal", + "compiling", + "download_file", + "download_pdf", + "expand", "fast", - "stop_on_validation_error", + "file_outline", + "find_out_more_about_the_file_outline", + "hide_outline", "ignore_validation_errors", - "run_syntax_check_now", "loading", + "log_entry_description", + "log_hint_extra_info", + "n_errors_plural", + "n_errors", + "n_warnings_plural", + "n_warnings", + "navigate_log_source", "no_messages", + "normal", + "off", + "on", + "other_output_files", + "recompile_from_scratch", + "recompile", + "run_syntax_check_now", + "run_syntax_check_now", "send_first_message", + "show_outline", + "stop_on_validation_error", + "the_file_outline_is_a_new_feature_click_the_icon_to_learn_more", + "toggle_compile_options_menu", "toggle_output_files_list", - "your_message", - "your_project_has_errors", - "view_warnings", "view_logs", "view_pdf", - "recompile_from_scratch", - "run_syntax_check_now", - "toggle_compile_options_menu" + "view_warnings", + "we_cant_find_any_sections_or_subsections_in_this_file", + "your_message", + "your_project_has_errors" ] diff --git a/services/web/frontend/js/features/preview/components/preview-log-entry.js b/services/web/frontend/js/features/preview/components/preview-log-entry.js index e0c57ddc24..25d1b07b83 100644 --- a/services/web/frontend/js/features/preview/components/preview-log-entry.js +++ b/services/web/frontend/js/features/preview/components/preview-log-entry.js @@ -1,41 +1,178 @@ import React from 'react' import PropTypes from 'prop-types' import classNames from 'classnames' +import { useTranslation } from 'react-i18next' +import useExpandCollapse from '../../../shared/hooks/use-expand-collapse' import Icon from '../../../shared/components/icon' -function PreviewLogEntry({ file, line, message, content, raw, level }) { - const logEntryClasses = classNames('alert', { - 'alert-danger': level === 'error', - 'alert-warning': level === 'warning', - 'alert-info': level === 'typesetting' +function PreviewLogEntry({ + file, + line, + message, + content, + column, + humanReadableHintComponent, + extraInfoURL, + level, + onLogEntryLinkClick +}) { + const { t } = useTranslation() + function handleLogEntryLinkClick() { + onLogEntryLinkClick({ file, line, column }) + } + const logEntryDescription = t('log_entry_description', { + level: level }) return ( -
- - - {file ? {file} : null} - {line ? , {line} : null} - - {message ? ( -

- {level} {message} -

+
+ + {content ? ( + ) : null} - {content ?

{content.trim()}

: null}
) } +function PreviewLogEntryHeader({ + level, + file, + line, + message, + onLogEntryLinkClick +}) { + const { t } = useTranslation() + const logEntryHeaderClasses = classNames('log-entry-header', { + 'log-entry-header-error': level === 'error', + 'log-entry-header-warning': level === 'warning', + 'log-entry-header-typesetting': level === 'typesetting' + }) + const headerLinkBtnTitle = t('navigate_log_source', { + location: file + (line ? `, ${line}` : '') + }) + return ( +
+

{message}

+ {file ? ( + + ) : null} +
+ ) +} + +function PreviewLogEntryContent({ + content, + humanReadableHintComponent, + 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 buttonContainerClasses = classNames( + 'log-entry-content-button-container', + { + 'log-entry-content-button-container-collapsed': !isExpanded + } + ) + const { t } = useTranslation() + + return ( +
+
+
{content.trim()}
+
+ +
+
+ {humanReadableHintComponent ? ( +
+ {humanReadableHintComponent} +
+ ) : null} + {extraInfoURL ? ( +
+ + {t('log_hint_extra_info')} + +
+ ) : null} +
+ ) +} + +PreviewLogEntryHeader.propTypes = { + level: PropTypes.string.isRequired, + file: PropTypes.string, + line: PropTypes.any, + message: PropTypes.string, + onLogEntryLinkClick: PropTypes.func.isRequired +} + +PreviewLogEntryContent.propTypes = { + content: PropTypes.string.isRequired, + humanReadableHintComponent: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.element + ]), + extraInfoURL: PropTypes.string +} + PreviewLogEntry.propTypes = { file: PropTypes.string, // `line should be either a number or null (i.e. not required), but currently sometimes we get // an empty string (from BibTeX errors), which is why we're using `any` here. We should revert // to PropTypes.number (not required) once we fix that. line: PropTypes.any, + column: PropTypes.any, message: PropTypes.string, content: PropTypes.string, - raw: PropTypes.string, - level: PropTypes.string.isRequired + humanReadableHintComponent: PropTypes.node, + extraInfoURL: PropTypes.string, + level: PropTypes.oneOf(['error', 'warning', 'typesetting']).isRequired, + onLogEntryLinkClick: PropTypes.func.isRequired } export default PreviewLogEntry diff --git a/services/web/frontend/js/features/preview/components/preview-logs-pane.js b/services/web/frontend/js/features/preview/components/preview-logs-pane.js index 536433ed64..82748a37a6 100644 --- a/services/web/frontend/js/features/preview/components/preview-logs-pane.js +++ b/services/web/frontend/js/features/preview/components/preview-logs-pane.js @@ -2,12 +2,16 @@ import React from 'react' import PropTypes from 'prop-types' import PreviewLogEntry from './preview-log-entry' -function PreviewLogsPane({ logEntries }) { +function PreviewLogsPane({ logEntries, onLogEntryLinkClick }) { return ( -
+
{logEntries && logEntries.length > 0 ? ( logEntries.map((logEntry, idx) => ( - + )) ) : (
No logs
@@ -17,7 +21,8 @@ function PreviewLogsPane({ logEntries }) { } PreviewLogsPane.propTypes = { - logEntries: PropTypes.array + logEntries: PropTypes.array, + onLogEntryLinkClick: PropTypes.func.isRequired } export default PreviewLogsPane diff --git a/services/web/frontend/js/features/preview/components/preview-pane.js b/services/web/frontend/js/features/preview/components/preview-pane.js index 5ec3511cc5..8677dcdd2a 100644 --- a/services/web/frontend/js/features/preview/components/preview-pane.js +++ b/services/web/frontend/js/features/preview/components/preview-pane.js @@ -15,10 +15,10 @@ function PreviewPane({ onToggleLogs, outputFiles, pdfDownloadUrl, + onLogEntryLinkClick, showLogs }) { const { t } = useTranslation() - const nErrors = compilerState.logEntries && compilerState.logEntries.errors ? compilerState.logEntries.errors.length @@ -59,7 +59,10 @@ function PreviewPane({ : ''} {showLogs ? ( - + ) : null} ) @@ -74,6 +77,7 @@ PreviewPane.propTypes = { logEntries: PropTypes.object.isRequired }), onClearCache: PropTypes.func.isRequired, + onLogEntryLinkClick: PropTypes.func.isRequired, onRecompile: PropTypes.func.isRequired, onRunSyntaxCheckNow: PropTypes.func.isRequired, onSetAutoCompile: PropTypes.func.isRequired, diff --git a/services/web/frontend/js/ide/pdf/controllers/PdfController.js b/services/web/frontend/js/ide/pdf/controllers/PdfController.js index 7e8d8f1dff..ea799e5b1b 100644 --- a/services/web/frontend/js/ide/pdf/controllers/PdfController.js +++ b/services/web/frontend/js/ide/pdf/controllers/PdfController.js @@ -878,6 +878,25 @@ App.controller('PdfController', function( $scope.recompile({ check: true }) }) } + + $scope.openInEditor = function(entry) { + let column, line + eventTracking.sendMBOnce('logs-jump-to-location-once') + const entity = ide.fileTreeManager.findEntityByPath(entry.file) + if (entity == null || entity.type !== 'doc') { + return + } + if (entry.line != null) { + line = entry.line + } + if (entry.column != null) { + column = entry.column + } + ide.editorManager.openDoc(entity, { + gotoLine: line, + gotoColumn: column + }) + } }) App.factory('synctex', function(ide, $http, $q) { @@ -1048,27 +1067,6 @@ App.controller('PdfSynctexController', function($scope, synctex, ide) { } }) -App.controller('PdfLogEntryController', function($scope, ide, eventTracking) { - $scope.openInEditor = function(entry) { - let column, line - eventTracking.sendMBOnce('logs-jump-to-location-once') - const entity = ide.fileTreeManager.findEntityByPath(entry.file) - if (entity == null || entity.type !== 'doc') { - return - } - if (entry.line != null) { - line = entry.line - } - if (entry.column != null) { - column = entry.column - } - ide.editorManager.openDoc(entity, { - gotoLine: line, - gotoColumn: column - }) - } -}) - App.controller('ClearCacheModalController', function($scope, $modalInstance) { $scope.state = { error: false, inflight: false } diff --git a/services/web/frontend/js/shared/hooks/use-expand-collapse.js b/services/web/frontend/js/shared/hooks/use-expand-collapse.js new file mode 100644 index 0000000000..65035b9f86 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-expand-collapse.js @@ -0,0 +1,55 @@ +import { useRef, useState, useLayoutEffect } from 'react' +import classNames from 'classnames' + +function useExpandCollapse({ + initiallyExpanded = false, + collapsedSize = '0', + dimension = 'height', + classes = {} +} = {}) { + const ref = useRef() + const [isExpanded, setIsExpanded] = useState(initiallyExpanded) + const [size, setSize] = useState() + + useLayoutEffect( + () => { + const expandCollapseEl = ref.current + if (isExpanded) { + const expandedSize = + dimension === 'height' + ? expandCollapseEl.scrollHeight + : expandCollapseEl.scrollWidth + setSize(expandedSize) + } else { + setSize(collapsedSize) + } + }, + [isExpanded] + ) + + const expandableClasses = classNames( + 'expand-collapse-container', + classes.container, + !isExpanded ? classes.containerCollapsed : null + ) + + function handleToggle() { + setIsExpanded(!isExpanded) + } + + return { + isExpanded, + expandableProps: { + ref, + style: { + [dimension === 'height' ? 'height' : 'width']: `${size}px` + }, + className: expandableClasses + }, + toggleProps: { + onClick: handleToggle + } + } +} + +export default useExpandCollapse diff --git a/services/web/frontend/stories/preview-log-entry.stories.js b/services/web/frontend/stories/preview-log-entry.stories.js new file mode 100644 index 0000000000..cd6c2fb4c8 --- /dev/null +++ b/services/web/frontend/stories/preview-log-entry.stories.js @@ -0,0 +1,117 @@ +import React from 'react' +import PreviewLogEntry from '../js/features/preview/components/preview-log-entry.js' + +export const ErrorWithCompilerOutput = args => +ErrorWithCompilerOutput.args = { + level: 'error' +} + +export const ErrorWithCompilerOutputAndHumanReadableHint = args => ( + +) +ErrorWithCompilerOutputAndHumanReadableHint.args = { + level: 'error', + humanReadableHintComponent: , + extraInfoURL: + 'https://www.overleaf.com/learn/latex/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr' +} + +export const ErrorWithoutCompilerOutput = args => +ErrorWithoutCompilerOutput.args = { + level: 'error', + content: null +} + +export const WarningWithCompilerOutput = args => +WarningWithCompilerOutput.args = { + level: 'warning' +} + +export const WarningWithCompilerOutputAndHumanReadableHint = args => ( + +) +WarningWithCompilerOutputAndHumanReadableHint.args = { + level: 'warning', + humanReadableHintComponent: , + extraInfoURL: + 'https://www.overleaf.com/learn/latex/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr' +} + +export const WarningWithoutCompilerOutput = args => ( + +) +WarningWithoutCompilerOutput.args = { + level: 'warning', + content: null +} + +export const InfoWithCompilerOutput = args => +InfoWithCompilerOutput.args = { + level: 'typesetting' +} + +export const InfoWithCompilerOutputAndHumanReadableHint = args => ( + +) +InfoWithCompilerOutputAndHumanReadableHint.args = { + level: 'typesetting', + humanReadableHintComponent: , + extraInfoURL: + 'https://www.overleaf.com/learn/latex/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr' +} + +export const InfoWithoutCompilerOutput = args => +InfoWithoutCompilerOutput.args = { + level: 'typesetting', + content: null +} + +function SampleHumanReadableHintComponent() { + return ( + <> + Human-readable hint to help LaTeX newbies. Supports mark-up for + e.g.{' '} + + inline links + + . + + ) +} + +export default { + title: 'PreviewLogEntry', + component: PreviewLogEntry, + args: { + file: 'foo/bar.tex', + line: 10, + column: 20, + message: 'Lorem ipsum', + content: ` +The LaTeX compiler output + * With a lot of details + +Wrapped in an HTML
 element with 
+      preformatted text which is to be presented exactly
+            as written in the HTML file
+
+                                              (whitespace included™)
+
+The text is typically rendered using a non-proportional ("monospace") font.
+
+LaTeX Font Info:    External font \`cmex10' loaded for size
+(Font)              <7> on input line 18.
+LaTeX Font Info:    External font \`cmex10' loaded for size
+(Font)              <5> on input line 18.
+! Undefined control sequence.
+ \\Zlpha
+
+ main.tex, line 23
+     
+`
+  }
+}
diff --git a/services/web/frontend/stories/use-expand-collapse.stories.js b/services/web/frontend/stories/use-expand-collapse.stories.js
new file mode 100644
index 0000000000..e5c11d92c1
--- /dev/null
+++ b/services/web/frontend/stories/use-expand-collapse.stories.js
@@ -0,0 +1,136 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import useExpandCollapse from '../js/shared/hooks/use-expand-collapse'
+
+function TestUI({ children, expandableProps, toggleProps }) {
+  return (
+    <>
+      
{children}
+ + + ) +} + +function VerticalTestUI(props) { + return {verticalContents} +} + +function HorizontalTestUI(props) { + return {horizontalContents} +} + +VerticalTestUI.propTypes = { + expandableProps: PropTypes.object, + toggleProps: PropTypes.object +} + +HorizontalTestUI.propTypes = VerticalTestUI.propTypes + +TestUI.propTypes = { + ...VerticalTestUI.propTypes, + children: PropTypes.node +} + +export const Vertical = args => { + const { expandableProps, toggleProps } = useExpandCollapse(args) + return ( + + ) +} + +export const VerticalInitiallyExpanded = args => { + const { expandableProps, toggleProps } = useExpandCollapse(args) + return ( + + ) +} +VerticalInitiallyExpanded.args = { + initiallyExpanded: true +} + +export const VerticalWithMinSize = args => { + const { expandableProps, toggleProps } = useExpandCollapse(args) + return ( + + ) +} +VerticalWithMinSize.args = { + collapsedSize: 200 +} + +export const Horizontal = args => { + const { expandableProps, toggleProps } = useExpandCollapse(args) + return ( + + ) +} +Horizontal.args = { + dimension: 'width' +} + +export const HorizontalInitiallyExpanded = args => { + const { expandableProps, toggleProps } = useExpandCollapse(args) + return ( + + ) +} +HorizontalInitiallyExpanded.args = { + initiallyExpanded: true +} + +export const HorizontalWithMinSize = args => { + const { expandableProps, toggleProps } = useExpandCollapse(args) + return ( + + ) +} +HorizontalWithMinSize.args = { + dimension: 'width', + collapsedSize: 200 +} + +const defaultContentStyles = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '2em', + backgroundImage: 'linear-gradient(to bottom, red, blue)', + width: '500px', + height: '500px', + color: '#FFF' +} + +const verticalContents =
Vertical
+ +const horizontalContents = ( +
+ Horizontal +
+) + +export default { + title: 'useExpandCollapse' +} diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less index b811ec5827..2ee68c51ac 100644 --- a/services/web/frontend/stylesheets/_style_includes.less +++ b/services/web/frontend/stylesheets/_style_includes.less @@ -55,6 +55,7 @@ @import 'components/nvd3.less'; @import 'components/nvd3_override.less'; @import 'components/infinite-scroll.less'; +@import 'components/expand-collapse.less'; // Components w/ JavaScript @import 'components/modals.less'; diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less index 9e59f566be..d5d855e4e6 100644 --- a/services/web/frontend/stylesheets/app/editor.less +++ b/services/web/frontend/stylesheets/app/editor.less @@ -14,6 +14,7 @@ @import './editor/rich-text.less'; @import './editor/publish-modal.less'; @import './editor/outline.less'; +@import './editor/logs.less'; @ui-layout-toggler-def-height: 50px; @ui-resizer-size: 7px; diff --git a/services/web/frontend/stylesheets/app/editor/logs.less b/services/web/frontend/stylesheets/app/editor/logs.less new file mode 100644 index 0000000000..f9ec0f74d3 --- /dev/null +++ b/services/web/frontend/stylesheets/app/editor/logs.less @@ -0,0 +1,131 @@ +.logs-pane { + .full-size; + top: @pdf-top-offset; + padding: @padding-sm; + overflow-y: auto; + background-color: @logs-pane-bg; +} + +.log-entry { + margin-bottom: @margin-sm; +} + +.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; + } +} + +.log-entry-header-error { + background-color: @ol-red; +} + +.log-entry-header-warning { + background-color: @orange; +} + +.log-entry-header-typesetting { + background-color: @ol-blue; +} + +.log-entry-header-title, +.log-entry-header-link { + font-family: @font-family-sans-serif; + font-size: @font-size-base; + line-height: @line-height-base; + font-weight: 700; + flex-grow: 1; + margin: 0; + color: #fff; +} + +.log-entry-header-link { + border-width: 0; + flex-grow: 0; + text-align: right; + white-space: nowrap; + padding-left: @padding-sm; + &:hover, + &:focus { + outline: 0; + color: #fff; + } + &:focus { + text-decoration: none; + } +} + +.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; + background-color: @ol-blue-gray-1; + border-radius: @border-radius-base; +} + +.log-entry-content-raw-button-container-collapsed { + padding-bottom: 0; +} + +.log-entry-content-raw { + font-size: @font-size-small; + color: @ol-blue-gray-4; + padding: @padding-sm @padding-sm @padding-xl @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; + height: 40px; + padding-bottom: @padding-sm; + text-align: center; + 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-btn-expand-collapse { + position: relative; + z-index: 1; + &:focus, + &:focus:active { + outline: 0; + } +} + +.log-entry-human-readable-hint, +.log-entry-human-readable-hint-link { + font-size: @font-size-small; + margin-top: @margin-sm; +} diff --git a/services/web/frontend/stylesheets/components/expand-collapse.less b/services/web/frontend/stylesheets/components/expand-collapse.less new file mode 100644 index 0000000000..1a5b6e3eb5 --- /dev/null +++ b/services/web/frontend/stylesheets/components/expand-collapse.less @@ -0,0 +1,4 @@ +.expand-collapse-container { + overflow: hidden; + transition: height 0.15s ease-in-out, width 0.15s ease-in-out; +} diff --git a/services/web/frontend/stylesheets/core/ol-light-variables.less b/services/web/frontend/stylesheets/core/ol-light-variables.less index 1b1214e7b7..95c08f0c4c 100644 --- a/services/web/frontend/stylesheets/core/ol-light-variables.less +++ b/services/web/frontend/stylesheets/core/ol-light-variables.less @@ -89,6 +89,7 @@ @editor-header-logo-background : url(/img/ol-brand/overleaf-o.svg) center / contain no-repeat; @project-name-color : @ol-blue-gray-3; @pdf-bg : @ol-blue-gray-0; +@logs-pane-bg: @ol-blue-gray-1; // Navbar @navbar-default-bg : #FFF; diff --git a/services/web/frontend/stylesheets/core/variables.less b/services/web/frontend/stylesheets/core/variables.less index c8f2083ca2..46f3569d3f 100644 --- a/services/web/frontend/stylesheets/core/variables.less +++ b/services/web/frontend/stylesheets/core/variables.less @@ -1060,11 +1060,12 @@ @chat-new-message-textarea-color: @ol-blue-gray-6; @chat-new-message-border-color: @editor-border-color; -// PDF +// PDF and logs @pdf-top-offset: @toolbar-small-height; @pdf-bg: @ol-blue-gray-1; @pdfjs-bg: transparent; @pdf-page-shadow-color: rgba(0, 0, 0, 0.5); +@logs-pane-bg: @ol-blue-gray-5; @log-line-no-color: #fff; @log-hints-color: @ol-blue-gray-4; diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 2969f42e1d..83224b9ad0 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1,4 +1,6 @@ { + "log_entry_description": "Log entry with level: \"__level__\"", + "navigate_log_source": "Navigate to log position in source code: __location__", "other_output_files": "Other output files", "toggle_output_files_list": "Toggle output files list", "n_warnings": "__count__ warning", diff --git a/services/web/test/frontend/features/preview/components/preview-log-entry.test.js b/services/web/test/frontend/features/preview/components/preview-log-entry.test.js new file mode 100644 index 0000000000..28362f44f0 --- /dev/null +++ b/services/web/test/frontend/features/preview/components/preview-log-entry.test.js @@ -0,0 +1,174 @@ +import React from 'react' +import { expect } from 'chai' +import sinon from 'sinon' +import { screen, render, fireEvent } from '@testing-library/react' + +import PreviewLogEntry from '../../../../../frontend/js/features/preview/components/preview-log-entry.js' + +describe('', function() { + const level = 'error' + const noOp = () => {} + + describe('log entry description', function() { + for (const level of ['error', 'warning', 'typesetting']) { + it(`describes the log entry with ${level} information`, function() { + render() + screen.getByLabelText(`Log entry with level: "${level}"`) + }) + } + }) + + describe('log location link', function() { + const file = 'foo.tex' + const line = 42 + const column = 21 + const onLogEntryLinkClick = sinon.stub() + + afterEach(function() { + onLogEntryLinkClick.reset() + }) + + it('renders both file and line', function() { + render( + + ) + screen.getByRole('button', { + name: `Navigate to log position in source code: ${file}, ${line}` + }) + }) + + it('renders only file when line information is not available', function() { + render( + + ) + screen.getByRole('button', { + name: `Navigate to log position in source code: ${file}` + }) + }) + + it('does not render when file information is not available', function() { + render() + expect( + screen.queryByRole('button', { + name: `Navigate to log position in source code: ` + }) + ).to.not.exist + }) + + it('calls the callback with file, line and column on click', function() { + render( + + ) + const linkToSourceButton = screen.getByRole('button', { + name: `Navigate to log position in source code: ${file}, ${line}` + }) + + fireEvent.click(linkToSourceButton) + expect(onLogEntryLinkClick).to.be.calledOnce + expect(onLogEntryLinkClick).to.be.calledWith({ + file, + line: line, + column: column + }) + }) + }) + + describe('log entry contents', function() { + const logContent = 'foo bar latex error stuff baz' + + it('renders collapsed contents by default', function() { + render( + + ) + screen.getByText(logContent) + screen.getByRole('button', { + name: 'Expand' + }) + }) + + it('supports expanding contents', function() { + render( + + ) + screen.getByText(logContent) + const expandCollapseBtn = screen.getByRole('button', { + name: 'Expand' + }) + fireEvent.click(expandCollapseBtn) + screen.getByRole('button', { + name: 'Collapse' + }) + }) + + it('should not render at all when there are no log contents', function() { + const { container } = render( + + ) + expect(container.querySelector('.log-entry-content')).to.not.exist + }) + }) + + describe('human-readable hints', function() { + const logContent = 'foo bar latex error stuff baz' + const logHintText = 'foo bar baz' + const logHint = <>{logHintText} + const infoURL = 'www.overleaf.com/learn/latex' + + it('renders the hint', function() { + render( + + ) + screen.getByText(logHintText) + }) + + it('renders the link to learn more', function() { + render( + + ) + screen.getByRole('link', { name: 'Learn more' }) + }) + + it('does not render the link when it is not available', function() { + render( + + ) + expect(screen.queryByRole('link', { name: 'Learn more' })).to.not.exist + }) + }) +}) diff --git a/services/web/test/frontend/shared/hooks/use-expand-collapse.test.js b/services/web/test/frontend/shared/hooks/use-expand-collapse.test.js new file mode 100644 index 0000000000..55df269ad4 --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-expand-collapse.test.js @@ -0,0 +1,122 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { expect } from 'chai' +import { render } from '@testing-library/react' + +import useExpandCollapse from '../../../../frontend/js/shared/hooks/use-expand-collapse' + +const sampleContent = ( +
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+

Maecenas maximus ultrices sollicitudin.

+

Praesent mollis arcu eget molestie viverra.

+

Pellentesque eget molestie nisl, non hendrerit lectus.

+
+) + +function ExpandCollapseTestUI({ expandCollapseArgs }) { + const { expandableProps } = useExpandCollapse(expandCollapseArgs) + return ( + <> +
{sampleContent}
+ + ) +} +ExpandCollapseTestUI.propTypes = { + expandCollapseArgs: PropTypes.object +} + +describe('useExpandCollapse', function() { + describe('custom CSS classes', function() { + it('supports a custom CSS class', function() { + const testArgs = { + classes: { + container: 'my-custom-class' + } + } + const { container } = render( + + ) + const elWithCustomCSSClass = container.querySelector('div') + expect(elWithCustomCSSClass).to.exist + }) + it('supports an extra custom CSS class for the collapsed state', function() { + const testArgs = { + classes: { + containerCollapsed: 'my-custom-collapsed-class' + } + } + + const { container } = render( + + ) + const elWithCustomCollapsedCSSClass = container.querySelector( + '.my-custom-collapsed-class' + ) + expect(elWithCustomCollapsedCSSClass).to.exist + }) + it('ignores the collapsed CSS class when expanded', function() { + const testArgs = { + initiallyExpanded: true, + classes: { + containerCollapsed: 'my-custom-collapsed-class' + } + } + const { container } = render( + + ) + const elWithCustomCollapsedCSSClass = container.querySelector( + '.my-custom-collapsed-class' + ) + expect(elWithCustomCollapsedCSSClass).to.not.exist + }) + }) + describe('height and width support via dimension argument', function() { + it('defaults to height', function() { + const { container } = render() + const expandCollapseEl = container.firstChild + expect(expandCollapseEl.style.height).to.not.be.empty + expect(expandCollapseEl.style.width).to.be.empty + }) + it('supports width', function() { + const testArgs = { + dimension: 'width' + } + const { container } = render( + + ) + const expandCollapseEl = container.firstChild + expect(expandCollapseEl.style.height).to.be.empty + expect(expandCollapseEl.style.width).to.not.be.empty + }) + }) + describe('collapsed size support via collapsedSize argument', function() { + it('defaults to 0px', function() { + const { container } = render() + const expandCollapseEl = container.firstChild + expect(expandCollapseEl.style.height).to.equal('0px') + }) + it('supports a custom collapsed size', function() { + const testArgs = { + collapsedSize: 200 + } + const { container } = render( + + ) + const expandCollapseEl = container.firstChild + expect(expandCollapseEl.style.height).to.equal('200px') + }) + it('supports a custom collapsed size for width', function() { + const testArgs = { + collapsedSize: 200, + dimension: 'width' + } + const { container } = render( + + ) + const expandCollapseEl = container.firstChild + expect(expandCollapseEl.style.height).to.be.empty + expect(expandCollapseEl.style.width).to.equal('200px') + }) + }) +})