diff --git a/cypress/integration/maxLength.spec.ts b/cypress/integration/maxLength.spec.ts new file mode 100644 index 000000000..8a8bde35d --- /dev/null +++ b/cypress/integration/maxLength.spec.ts @@ -0,0 +1,49 @@ +const tenChars: string = '0123456789' + +describe('status-bar text-length info', () => { + beforeEach(() => { + cy.visit('/n/test') + cy.get('.CodeMirror textarea') + .type('{ctrl}a', { force: true }) + .type('{backspace}') + }) + + it('tooltip shows full remaining on empty text', () => { + cy.get('.status-bar div:nth-child(2) span:nth-child(2)') + .attribute('title') + .should('contain', ' 200 ') + }) + + it('color is warning on <= 100 chars remaining', () => { + cy.get('.CodeMirror textarea') + .type(`${tenChars.repeat(10)}`) + cy.get('.status-bar div:nth-child(2) span:nth-child(2)') + .should('have.class', 'text-warning') + }) + + it('color is danger on <= 0 chars remaining', () => { + cy.get('.CodeMirror textarea') + .type(`${tenChars.repeat(20)}`) + cy.get('.status-bar div:nth-child(2) span:nth-child(2)') + .should('have.class', 'text-danger') + }) +}) + +describe('show warning if content length > configured max length', () => { + beforeEach(() => { + cy.visit('/n/test') + cy.get('.CodeMirror textarea') + .type('{ctrl}a', { force: true }) + .type('{backspace}') + .type(`${tenChars.repeat(20)}`) + }) + + it('show warning alert in renderer and as modal', () => { + cy.get('.CodeMirror textarea') + .type('a') + cy.get('.modal-body.limit-warning') + .should('be.visible') + cy.get('.splitter .alert-danger') + .should('be.visible') + }) +}) diff --git a/cypress/support/config.ts b/cypress/support/config.ts index b92e7b357..10e5c2100 100644 --- a/cypress/support/config.ts +++ b/cypress/support/config.ts @@ -34,6 +34,7 @@ beforeEach(() => { oauth2: 'Olaf2', saml: 'aufSAMLn.de' }, + maxDocumentLength: 200, specialLinks: { privacy: 'https://example.com/privacy', termsOfUse: 'https://example.com/termsOfUse', diff --git a/cypress/support/index.ts b/cypress/support/index.ts index a5e67a059..a6f148d4b 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -13,6 +13,7 @@ // https://on.cypress.io/configuration // *********************************************************** +import 'cypress-commands' import './checkLinks' import './config' import './login' diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index b4871a1ab..092086254 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -2,9 +2,9 @@ "compilerOptions": { "strict": true, "baseUrl": "../node_modules", - "target": "es5", - "lib": ["es5", "dom"], - "types": ["cypress"] + "target": "es6", + "lib": ["es6", "dom"], + "types": ["cypress-commands", "cypress"] }, "include": [ "**/*.ts" diff --git a/package.json b/package.json index e94ab8fa4..ba163a20d 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "@types/redux-devtools": "3.0.47", "@types/redux-devtools-extension": "2.13.2", "cypress": "5.1.0", + "cypress-commands": "^1.1.0", "eslint-plugin-chai-friendly": "0.6.0", "eslint-plugin-cypress": "2.11.1", "http-server": "0.12.3", diff --git a/public/api/v2/config b/public/api/v2/config index 6a3dd78be..29e90faf8 100644 --- a/public/api/v2/config +++ b/public/api/v2/config @@ -27,6 +27,7 @@ "oauth2": "Olaf2", "saml": "aufSAMLn.de" }, + "maxDocumentLength": 100000, "useImageProxy": false, "plantumlServer": "http://www.plantuml.com/plantuml", "specialLinks": { diff --git a/public/locales/en.json b/public/locales/en.json index dc8ba77e6..f0f971f31 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -195,8 +195,8 @@ "description": "Sorry, only the owner can edit this note." }, "limitReached": { - "title": "Reach the limit", - "description": "Sorry, you've reached the maximum length this note can be.", + "title": "Reached the limit", + "description": "Sorry, this note reached its maximum length. Notes can be up to {{maxLength}} characters long.", "advice": "Please shorten the note." }, "incompatible": { @@ -277,7 +277,11 @@ }, "lines": "{{lines}} Lines", "length": "Length {{length}}", - "lengthTooltip": "You can write up to 100000 characters in this document." + "lengthTooltip": { + "maximumReached": "You've reached the maximum length of this note.", + "remaining": "You may still write {{remaining}} characters until you reach the limit for this note.", + "exceeded": "You've exceeded the maximum length of this note by {{exceeded}} characters. Consider shortening it." + } }, "export": { "rawHtml": "Raw HTML", diff --git a/src/api/config/types.d.ts b/src/api/config/types.d.ts index 4728a0e6d..f4aaec7db 100644 --- a/src/api/config/types.d.ts +++ b/src/api/config/types.d.ts @@ -9,6 +9,7 @@ export interface Config { specialLinks: SpecialLinks, version: BackendVersion, plantumlServer: string | null, + maxDocumentLength: number, } export interface BrandingConfig { diff --git a/src/components/editor/editor-modals/max-length-warning-modal.tsx b/src/components/editor/editor-modals/max-length-warning-modal.tsx new file mode 100644 index 000000000..e46b078d9 --- /dev/null +++ b/src/components/editor/editor-modals/max-length-warning-modal.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { Button, Modal } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { CommonModal } from '../../common/modals/common-modal' + +export interface MaxLengthWarningModalProps { + show: boolean + onHide: () => void + maxLength: number +} + +export const MaxLengthWarningModal: React.FC = ({ show, onHide, maxLength }) => { + useTranslation() + + return ( + + + + + + + + + + ) +} diff --git a/src/components/editor/editor-pane/editor-pane.tsx b/src/components/editor/editor-pane/editor-pane.tsx index 341e7258e..2babbd846 100644 --- a/src/components/editor/editor-pane/editor-pane.tsx +++ b/src/components/editor/editor-pane/editor-pane.tsx @@ -23,6 +23,9 @@ import 'codemirror/mode/gfm/gfm' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Controlled as ControlledCodeMirror } from 'react-codemirror2' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { ApplicationState } from '../../../redux' +import { MaxLengthWarningModal } from '../editor-modals/max-length-warning-modal' import { ScrollProps, ScrollState } from '../scroll/scroll-props' import { allHinters, findWordAtCursor } from './autocompletion' import './editor-pane.scss' @@ -52,6 +55,9 @@ const onChange = (editor: Editor) => { export const EditorPane: React.FC = ({ onContentChange, content, scrollState, onScroll, onMakeScrollSource }) => { const { t } = useTranslation() + const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength) + const [showMaxLengthWarning, setShowMaxLengthWarning] = useState(false) + const maxLengthWarningAlreadyShown = useRef(false) const [editor, setEditor] = useState() const [statusBarInfo, setStatusBarInfo] = useState(defaultState) const [editorPreferences, setEditorPreferences] = useState({ @@ -99,15 +105,24 @@ export const EditorPane: React.FC = ({ onContentC }, [editor, scrollState]) const onBeforeChange = useCallback((editor: Editor, data: EditorChange, value: string) => { + if (value.length > maxLength && !maxLengthWarningAlreadyShown.current) { + setShowMaxLengthWarning(true) + maxLengthWarningAlreadyShown.current = true + } + if (value.length <= maxLength) { + maxLengthWarningAlreadyShown.current = false + } onContentChange(value) - }, [onContentChange]) + }, [onContentChange, maxLength, maxLengthWarningAlreadyShown]) const onEditorDidMount = useCallback(mountedEditor => { - setStatusBarInfo(createStatusInfo(mountedEditor)) + setStatusBarInfo(createStatusInfo(mountedEditor, maxLength)) setEditor(mountedEditor) - }, []) + }, [maxLength]) + const onCursorActivity = useCallback((editorWithActivity) => { - setStatusBarInfo(createStatusInfo(editorWithActivity)) - }, []) + setStatusBarInfo(createStatusInfo(editorWithActivity, maxLength)) + }, [maxLength]) + const codeMirrorOptions: EditorConfiguration = useMemo(() => ({ ...editorPreferences, mode: 'gfm', @@ -140,6 +155,7 @@ export const EditorPane: React.FC = ({ onContentC return (
+ setShowMaxLengthWarning(false)} maxLength={maxLength}/> setEditorPreferences(config)} diff --git a/src/components/editor/editor-pane/status-bar/status-bar.tsx b/src/components/editor/editor-pane/status-bar/status-bar.tsx index a116cd93b..a3c62633a 100644 --- a/src/components/editor/editor-pane/status-bar/status-bar.tsx +++ b/src/components/editor/editor-pane/status-bar/status-bar.tsx @@ -1,5 +1,5 @@ import { Editor, Position } from 'codemirror' -import React from 'react' +import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ShowIf } from '../../../common/show-if/show-if' import './status-bar.scss' @@ -10,6 +10,7 @@ export interface StatusBarInfo { selectedLines: number linesInDocument: number charactersInDocument: number + remainingCharacters: number } export const defaultState: StatusBarInfo = { @@ -17,20 +18,32 @@ export const defaultState: StatusBarInfo = { selectedColumns: 0, selectedLines: 0, linesInDocument: 0, - charactersInDocument: 0 + charactersInDocument: 0, + remainingCharacters: 0 } -export const createStatusInfo = (editor: Editor): StatusBarInfo => ({ +export const createStatusInfo = (editor: Editor, maxDocumentLength: number): StatusBarInfo => ({ position: editor.getCursor(), charactersInDocument: editor.getValue().length, + remainingCharacters: maxDocumentLength - editor.getValue().length, linesInDocument: editor.lineCount(), selectedColumns: editor.getSelection().length, selectedLines: editor.getSelection().split('\n').length }) -export const StatusBar: React.FC = ({ position, selectedColumns, selectedLines, charactersInDocument, linesInDocument }) => { +export const StatusBar: React.FC = ({ position, selectedColumns, selectedLines, charactersInDocument, linesInDocument, remainingCharacters }) => { const { t } = useTranslation() + const getLengthTooltip = useMemo(() => { + if (remainingCharacters === 0) { + return t('editor.statusBar.lengthTooltip.maximumReached') + } + if (remainingCharacters < 0) { + return t('editor.statusBar.lengthTooltip.exceeded', { exceeded: -remainingCharacters }) + } + return t('editor.statusBar.lengthTooltip.remaining', { remaining: remainingCharacters }) + }, [remainingCharacters, t]) + return (
@@ -46,7 +59,13 @@ export const StatusBar: React.FC = ({ position, selectedColumns,
{t('editor.statusBar.lines', { lines: linesInDocument })} -  – {t('editor.statusBar.length', { length: charactersInDocument })} +  –  + + {t('editor.statusBar.length', { length: charactersInDocument })} +
) diff --git a/src/components/markdown-renderer/markdown-renderer.tsx b/src/components/markdown-renderer/markdown-renderer.tsx index 48f731e58..0ebba77c6 100644 --- a/src/components/markdown-renderer/markdown-renderer.tsx +++ b/src/components/markdown-renderer/markdown-renderer.tsx @@ -127,6 +127,7 @@ export const MarkdownRenderer: React.FC = ({ wide }) => { const [tocAst, setTocAst] = useState() + const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength) const lastTocAst = useRef() const [yamlError, setYamlError] = useState(false) const rawMetaRef = useRef() @@ -364,13 +365,14 @@ export const MarkdownRenderer: React.FC = ({ // This is used if the front-matter callback is never called, because the user deleted everything regarding metadata from the document rawMetaRef.current = undefined } - const html: string = markdownIt.render(content) - const contentLines = content.split('\n') + const trimmedContent = content.substr(0, maxLength) + const html: string = markdownIt.render(trimmedContent) + const contentLines = trimmedContent.split('\n') const { lines: newLines, lastUsedLineId: newLastUsedLineId } = calculateNewLineNumberMapping(contentLines, oldMarkdownLineKeys.current ?? [], lastUsedLineId.current) oldMarkdownLineKeys.current = newLines lastUsedLineId.current = newLastUsedLineId return ReactHtmlParser(html, { transform: buildTransformer(newLines, allReplacers) }) - }, [content, markdownIt, onMetaDataChange, onTaskCheckedChange]) + }, [content, markdownIt, onMetaDataChange, onTaskCheckedChange, maxLength]) return (
@@ -382,6 +384,11 @@ export const MarkdownRenderer: React.FC = ({ + maxLength}> + + + +
{markdownReactDom}
diff --git a/src/redux/config/reducers.ts b/src/redux/config/reducers.ts index c70238986..497ac3d05 100644 --- a/src/redux/config/reducers.ts +++ b/src/redux/config/reducers.ts @@ -31,6 +31,7 @@ export const initialState: Config = { oauth2: '', saml: '' }, + maxDocumentLength: 0, useImageProxy: false, plantumlServer: null, specialLinks: { diff --git a/yarn.lock b/yarn.lock index ca644b64f..4cca7cd91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4344,6 +4344,11 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= +cypress-commands@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cypress-commands/-/cypress-commands-1.1.0.tgz#9248190168783deb8ab27ae7c722e3e01d172c97" + integrity sha512-Q8Jr25pHJQFXwln6Hp8O+Hgs8Z506Y2wA9F1Te2cTajjc5L9gtt9WPOcw1Ogh+OgyqaMHF+uq31vdfImRTio5Q== + cypress@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/cypress/-/cypress-5.1.0.tgz#979e9ff3e0acd792eefd365bf104046479a9643b"