Show warning if note is longer than configured maximum length (#534)

* Add maximum document length config option

* Show remaining characters in tooltip of status-bar length-info

* Remove unnecessary checkDocumentLength function

* Add max-length warning

* Update translation wording

* Set dialog to medium size

* Add coloring to status-bar length info

* Improve wording in warning modal

* Add cypress e2e tests

I included the cypress-commands package and set the language level to ES6 to allow easier testing e.g. of element attributes.

* Changed way how the modal-advice was styled and positioned

* Show warning modal only on first length exceeding

* Improved length tooltip by adding messages when exceeding or reaching limit
This commit is contained in:
Erik Michelson 2020-09-05 16:36:46 +02:00 committed by GitHub
parent 14dfb5f315
commit 79469c5ddc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 151 additions and 19 deletions

View file

@ -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')
})
})

View file

@ -34,6 +34,7 @@ beforeEach(() => {
oauth2: 'Olaf2',
saml: 'aufSAMLn.de'
},
maxDocumentLength: 200,
specialLinks: {
privacy: 'https://example.com/privacy',
termsOfUse: 'https://example.com/termsOfUse',

View file

@ -13,6 +13,7 @@
// https://on.cypress.io/configuration
// ***********************************************************
import 'cypress-commands'
import './checkLinks'
import './config'
import './login'

View file

@ -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"

View file

@ -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",

View file

@ -27,6 +27,7 @@
"oauth2": "Olaf2",
"saml": "aufSAMLn.de"
},
"maxDocumentLength": 100000,
"useImageProxy": false,
"plantumlServer": "http://www.plantuml.com/plantuml",
"specialLinks": {

View file

@ -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",

View file

@ -9,6 +9,7 @@ export interface Config {
specialLinks: SpecialLinks,
version: BackendVersion,
plantumlServer: string | null,
maxDocumentLength: number,
}
export interface BrandingConfig {

View file

@ -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<MaxLengthWarningModalProps> = ({ show, onHide, maxLength }) => {
useTranslation()
return (
<CommonModal show={show} onHide={onHide} titleI18nKey={'editor.error.limitReached.title'} closeButton={true}>
<Modal.Body className={'limit-warning'}>
<Trans i18nKey={'editor.error.limitReached.description'} values={{ maxLength }} />
<strong className='mt-2 d-block'><Trans i18nKey={'editor.error.limitReached.advice'}/></strong>
</Modal.Body>
<Modal.Footer>
<Button onClick={onHide}><Trans i18nKey={'common.close'}/></Button>
</Modal.Footer>
</CommonModal>
)
}

View file

@ -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<EditorPaneProps & ScrollProps> = ({ 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<Editor>()
const [statusBarInfo, setStatusBarInfo] = useState<StatusBarInfo>(defaultState)
const [editorPreferences, setEditorPreferences] = useState<EditorConfiguration>({
@ -99,15 +105,24 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ 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<EditorConfiguration>(() => ({
...editorPreferences,
mode: 'gfm',
@ -140,6 +155,7 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentC
return (
<div className={'d-flex flex-column h-100'} onMouseEnter={onMakeScrollSource}>
<MaxLengthWarningModal show={showMaxLengthWarning} onHide={() => setShowMaxLengthWarning(false)} maxLength={maxLength}/>
<ToolBar
editor={editor}
onPreferencesChange={config => setEditorPreferences(config)}

View file

@ -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<StatusBarInfo> = ({ position, selectedColumns, selectedLines, charactersInDocument, linesInDocument }) => {
export const StatusBar: React.FC<StatusBarInfo> = ({ 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 (
<div className="d-flex flex-row status-bar px-2">
<div>
@ -46,7 +59,13 @@ export const StatusBar: React.FC<StatusBarInfo> = ({ position, selectedColumns,
</div>
<div className="ml-auto">
<span>{t('editor.statusBar.lines', { lines: linesInDocument })}</span>
<span title={t('editor.statusBar.lengthTooltip')}>&nbsp;&nbsp;{t('editor.statusBar.length', { length: charactersInDocument })}</span>
&nbsp;&nbsp;
<span
title={getLengthTooltip}
className={remainingCharacters <= 0 ? 'text-danger' : remainingCharacters <= 100 ? 'text-warning' : ''}
>
{t('editor.statusBar.length', { length: charactersInDocument })}
</span>
</div>
</div>
)

View file

@ -127,6 +127,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
wide
}) => {
const [tocAst, setTocAst] = useState<TocAst>()
const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength)
const lastTocAst = useRef<TocAst>()
const [yamlError, setYamlError] = useState(false)
const rawMetaRef = useRef<RawYAMLMetadata>()
@ -364,13 +365,14 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
// 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 (
<div className={'bg-light flex-fill'}>
@ -382,6 +384,11 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
</Trans>
</Alert>
</ShowIf>
<ShowIf condition={content.length > maxLength}>
<Alert variant='danger' dir={'auto'}>
<Trans i18nKey={'editor.error.limitReached.description'} values={{ maxLength }}/>
</Alert>
</ShowIf>
<div ref={documentElement} className={'markdown-body w-100 d-flex flex-column align-items-center'}>
{markdownReactDom}
</div>

View file

@ -31,6 +31,7 @@ export const initialState: Config = {
oauth2: '',
saml: ''
},
maxDocumentLength: 0,
useImageProxy: false,
plantumlServer: null,
specialLinks: {

View file

@ -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"