mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-14 23:24:46 -04:00
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:
parent
14dfb5f315
commit
79469c5ddc
14 changed files with 151 additions and 19 deletions
49
cypress/integration/maxLength.spec.ts
Normal file
49
cypress/integration/maxLength.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
|
@ -34,6 +34,7 @@ beforeEach(() => {
|
|||
oauth2: 'Olaf2',
|
||||
saml: 'aufSAMLn.de'
|
||||
},
|
||||
maxDocumentLength: 200,
|
||||
specialLinks: {
|
||||
privacy: 'https://example.com/privacy',
|
||||
termsOfUse: 'https://example.com/termsOfUse',
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
import 'cypress-commands'
|
||||
import './checkLinks'
|
||||
import './config'
|
||||
import './login'
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
"oauth2": "Olaf2",
|
||||
"saml": "aufSAMLn.de"
|
||||
},
|
||||
"maxDocumentLength": 100000,
|
||||
"useImageProxy": false,
|
||||
"plantumlServer": "http://www.plantuml.com/plantuml",
|
||||
"specialLinks": {
|
||||
|
|
|
@ -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",
|
||||
|
|
1
src/api/config/types.d.ts
vendored
1
src/api/config/types.d.ts
vendored
|
@ -9,6 +9,7 @@ export interface Config {
|
|||
specialLinks: SpecialLinks,
|
||||
version: BackendVersion,
|
||||
plantumlServer: string | null,
|
||||
maxDocumentLength: number,
|
||||
}
|
||||
|
||||
export interface BrandingConfig {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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)}
|
||||
|
|
|
@ -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')}> – {t('editor.statusBar.length', { length: charactersInDocument })}</span>
|
||||
–
|
||||
<span
|
||||
title={getLengthTooltip}
|
||||
className={remainingCharacters <= 0 ? 'text-danger' : remainingCharacters <= 100 ? 'text-warning' : ''}
|
||||
>
|
||||
{t('editor.statusBar.length', { length: charactersInDocument })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -31,6 +31,7 @@ export const initialState: Config = {
|
|||
oauth2: '',
|
||||
saml: ''
|
||||
},
|
||||
maxDocumentLength: 0,
|
||||
useImageProxy: false,
|
||||
plantumlServer: null,
|
||||
specialLinks: {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue