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 ( -
- {level} {message} -
+{content.trim()}
: null}{content.trim()}
+ 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')
+ })
+ })
+})