mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-17 16:44:49 -04:00
Fix file input field accepting a filename only once (#1547)
This commit is contained in:
parent
9118c8310b
commit
3591c90f9f
11 changed files with 130 additions and 37 deletions
1
cypress/fixtures/history-2.json
Normal file
1
cypress/fixtures/history-2.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{"version":2,"entries":[{"identifier":"cypress2","title":"cy-Test2","tags":[],"lastVisited":"2019-04-30T09:36:45.249+02:00","pinStatus":false}]}
|
3
cypress/fixtures/history-2.json.license
Normal file
3
cypress/fixtures/history-2.json.license
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
|
||||||
|
SPDX-License-Identifier: CC0-1.0
|
1
cypress/fixtures/history.json
Normal file
1
cypress/fixtures/history.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{"version":2,"entries":[{"identifier":"cypress","title":"cy-Test","tags":[],"lastVisited":"2019-04-30T09:36:45.249+02:00","pinStatus":false}]}
|
3
cypress/fixtures/history.json.license
Normal file
3
cypress/fixtures/history.json.license
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
|
||||||
|
SPDX-License-Identifier: CC0-1.0
|
|
@ -5,7 +5,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
describe('History', () => {
|
describe('History', () => {
|
||||||
|
|
||||||
describe('History Mode', () => {
|
describe('History Mode', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.visit('/history')
|
cy.visit('/history')
|
||||||
|
@ -125,4 +124,55 @@ describe('History', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Import', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.clearLocalStorage('history')
|
||||||
|
cy.intercept('GET', '/mock-backend/api/private/me/history', {
|
||||||
|
body: []
|
||||||
|
})
|
||||||
|
cy.visit('/history')
|
||||||
|
cy.logout()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works with valid file', () => {
|
||||||
|
cy.get('[data-cypress-id="import-history-file-button"]').click()
|
||||||
|
cy.get('[data-cypress-id="import-history-file-input"]').attachFile({
|
||||||
|
filePath: 'history.json',
|
||||||
|
mimeType: 'application/json'
|
||||||
|
})
|
||||||
|
cy.get('[data-cypress-id="history-entry-title"]')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.contains('cy-Test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails on invalid file', () => {
|
||||||
|
cy.get('[data-cypress-id="import-history-file-button"]').click()
|
||||||
|
cy.get('[data-cypress-id="import-history-file-input"]').attachFile({
|
||||||
|
filePath: 'history.json.license',
|
||||||
|
mimeType: 'text/plain'
|
||||||
|
})
|
||||||
|
cy.get('[data-cypress-id="notification-toast"]').should('be.visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when selecting two files with the same name', () => {
|
||||||
|
cy.get('[data-cypress-id="import-history-file-button"]').click()
|
||||||
|
cy.get('[data-cypress-id="import-history-file-input"]').attachFile({
|
||||||
|
filePath: 'history.json',
|
||||||
|
mimeType: 'application/json'
|
||||||
|
})
|
||||||
|
cy.get('[data-cypress-id="history-entry-title"]')
|
||||||
|
.should('have.length', 1)
|
||||||
|
.contains('cy-Test')
|
||||||
|
cy.get('[data-cypress-id="import-history-file-button"]').click()
|
||||||
|
cy.get('[data-cypress-id="import-history-file-input"]').attachFile({
|
||||||
|
filePath: 'history-2.json',
|
||||||
|
fileName: 'history.json',
|
||||||
|
mimeType: 'application/json'
|
||||||
|
})
|
||||||
|
cy.get('[data-cypress-id="history-entry-title"]')
|
||||||
|
.should('have.length', 2)
|
||||||
|
.contains('cy-Test2')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,7 +8,9 @@ import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
||||||
import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
|
import type { HistoryEntryDto, HistoryEntryPutDto, HistoryEntryUpdateDto } from './types'
|
||||||
|
|
||||||
export const getHistory = async (): Promise<HistoryEntryDto[]> => {
|
export const getHistory = async (): Promise<HistoryEntryDto[]> => {
|
||||||
const response = await fetch(getApiUrl() + 'me/history')
|
const response = await fetch(getApiUrl() + 'me/history', {
|
||||||
|
...defaultFetchConfig
|
||||||
|
})
|
||||||
expectResponseCode(response)
|
expectResponseCode(response)
|
||||||
return (await response.json()) as Promise<HistoryEntryDto[]>
|
return (await response.json()) as Promise<HistoryEntryDto[]>
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import './entry-menu.scss'
|
||||||
import { RemoveNoteEntryItem } from './remove-note-entry-item'
|
import { RemoveNoteEntryItem } from './remove-note-entry-item'
|
||||||
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
|
import { cypressId } from '../../../utils/cypress-attribute'
|
||||||
|
|
||||||
export interface EntryMenuProps {
|
export interface EntryMenuProps {
|
||||||
id: string
|
id: string
|
||||||
|
@ -31,7 +32,7 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, origin, isDark,
|
||||||
const userExists = useApplicationState((state) => !!state.user)
|
const userExists = useApplicationState((state) => !!state.user)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown className={`d-inline-flex ${className || ''}`}>
|
<Dropdown className={`d-inline-flex ${className || ''}`} {...cypressId('history-entry-menu')}>
|
||||||
<Dropdown.Toggle
|
<Dropdown.Toggle
|
||||||
variant={isDark ? 'secondary' : 'light'}
|
variant={isDark ? 'secondary' : 'light'}
|
||||||
id={`dropdown-card-${id}`}
|
id={`dropdown-card-${id}`}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { DropdownItemWithDeletionModal } from './dropdown-item-with-deletion-modal'
|
import { DropdownItemWithDeletionModal } from './dropdown-item-with-deletion-modal'
|
||||||
|
import { cypressId } from '../../../utils/cypress-attribute'
|
||||||
|
|
||||||
export interface RemoveNoteEntryItemProps {
|
export interface RemoveNoteEntryItemProps {
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
|
@ -23,6 +24,7 @@ export const RemoveNoteEntryItem: React.FC<RemoveNoteEntryItemProps> = ({ noteTi
|
||||||
modalQuestionI18nKey={'landing.history.modal.removeNote.question'}
|
modalQuestionI18nKey={'landing.history.modal.removeNote.question'}
|
||||||
modalWarningI18nKey={'landing.history.modal.removeNote.warning'}
|
modalWarningI18nKey={'landing.history.modal.removeNote.warning'}
|
||||||
noteTitle={noteTitle}
|
noteTitle={noteTitle}
|
||||||
|
{...cypressId('history-entry-menu-remove-button')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { DeletionModal } from '../../common/modals/deletion-modal'
|
import { DeletionModal } from '../../common/modals/deletion-modal'
|
||||||
import { deleteAllHistoryEntries, refreshHistoryState } from '../../../redux/history/methods'
|
import { deleteAllHistoryEntries, refreshHistoryState } from '../../../redux/history/methods'
|
||||||
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||||
|
import { cypressId } from '../../../utils/cypress-attribute'
|
||||||
|
|
||||||
export const ClearHistoryButton: React.FC = () => {
|
export const ClearHistoryButton: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
@ -29,7 +30,11 @@ export const ClearHistoryButton: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Button variant={'light'} title={t('landing.history.toolbar.clear')} onClick={handleShow}>
|
<Button
|
||||||
|
variant={'light'}
|
||||||
|
title={t('landing.history.toolbar.clear')}
|
||||||
|
onClick={handleShow}
|
||||||
|
{...cypressId('history-clear-button')}>
|
||||||
<ForkAwesomeIcon icon={'trash'} />
|
<ForkAwesomeIcon icon={'trash'} />
|
||||||
</Button>
|
</Button>
|
||||||
<DeletionModal
|
<DeletionModal
|
||||||
|
|
|
@ -6,9 +6,8 @@
|
||||||
|
|
||||||
import React, { useCallback, useRef, useState } from 'react'
|
import React, { useCallback, useRef, useState } from 'react'
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
|
||||||
import { ErrorModal } from '../../common/modals/error-modal'
|
|
||||||
import type { HistoryEntry, HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
|
import type { HistoryEntry, HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
|
||||||
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
||||||
import {
|
import {
|
||||||
|
@ -17,27 +16,16 @@ import {
|
||||||
mergeHistoryEntries,
|
mergeHistoryEntries,
|
||||||
refreshHistoryState
|
refreshHistoryState
|
||||||
} from '../../../redux/history/methods'
|
} from '../../../redux/history/methods'
|
||||||
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
|
import { cypressId } from '../../../utils/cypress-attribute'
|
||||||
|
|
||||||
export const ImportHistoryButton: React.FC = () => {
|
export const ImportHistoryButton: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const userExists = useApplicationState((state) => !!state.user)
|
const userExists = useApplicationState((state) => !!state.user)
|
||||||
const historyState = useApplicationState((state) => state.history)
|
const historyState = useApplicationState((state) => state.history)
|
||||||
const uploadInput = useRef<HTMLInputElement>(null)
|
const uploadInput = useRef<HTMLInputElement>(null)
|
||||||
const [show, setShow] = useState(false)
|
|
||||||
const [fileName, setFilename] = useState('')
|
const [fileName, setFilename] = useState('')
|
||||||
const [i18nKey, setI18nKey] = useState('')
|
|
||||||
|
|
||||||
const handleShow = useCallback((key: string) => {
|
|
||||||
setI18nKey(key)
|
|
||||||
setShow(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setI18nKey('')
|
|
||||||
setShow(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onImportHistory = useCallback(
|
const onImportHistory = useCallback(
|
||||||
(entries: HistoryEntry[]): void => {
|
(entries: HistoryEntry[]): void => {
|
||||||
|
@ -50,17 +38,29 @@ export const ImportHistoryButton: React.FC = () => {
|
||||||
[historyState, userExists]
|
[historyState, userExists]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const resetInputField = useCallback(() => {
|
||||||
|
if (!uploadInput.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploadInput.current.value = ''
|
||||||
|
}, [uploadInput])
|
||||||
|
|
||||||
|
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { validity, files } = event.target
|
const { validity, files } = event.target
|
||||||
if (files && files[0] && validity.valid) {
|
if (files && files[0] && validity.valid) {
|
||||||
const file = files[0]
|
const file = files[0]
|
||||||
setFilename(file.name)
|
setFilename(file.name)
|
||||||
if (file.type !== 'application/json' && file.type !== '') {
|
if (file.type !== 'application/json' && file.type !== '') {
|
||||||
handleShow('landing.history.modal.importHistoryError.textWithFile')
|
await dispatchUiNotification('common.errorOccurred', 'landing.history.modal.importHistoryError.textWithFile', {
|
||||||
|
contentI18nOptions: {
|
||||||
|
fileName
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resetInputField()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fileReader = new FileReader()
|
const fileReader = new FileReader()
|
||||||
fileReader.onload = (event) => {
|
fileReader.onload = async (event) => {
|
||||||
if (event.target && event.target.result) {
|
if (event.target && event.target.result) {
|
||||||
try {
|
try {
|
||||||
const result = event.target.result as string
|
const result = event.target.result as string
|
||||||
|
@ -71,42 +71,63 @@ export const ImportHistoryButton: React.FC = () => {
|
||||||
onImportHistory(data.entries)
|
onImportHistory(data.entries)
|
||||||
} else {
|
} else {
|
||||||
// probably a newer version we can't support
|
// probably a newer version we can't support
|
||||||
handleShow('landing.history.modal.importHistoryError.tooNewVersion')
|
await dispatchUiNotification(
|
||||||
|
'common.errorOccurred',
|
||||||
|
'landing.history.modal.importHistoryError.tooNewVersion',
|
||||||
|
{
|
||||||
|
contentI18nOptions: {
|
||||||
|
fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const oldEntries = JSON.parse(result) as V1HistoryEntry[]
|
const oldEntries = JSON.parse(result) as V1HistoryEntry[]
|
||||||
onImportHistory(convertV1History(oldEntries))
|
onImportHistory(convertV1History(oldEntries))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resetInputField()
|
||||||
} catch {
|
} catch {
|
||||||
handleShow('landing.history.modal.importHistoryError.textWithFile')
|
await dispatchUiNotification(
|
||||||
|
'common.errorOccurred',
|
||||||
|
'landing.history.modal.importHistoryError.textWithFile',
|
||||||
|
{
|
||||||
|
contentI18nOptions: {
|
||||||
|
fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fileReader.readAsText(file)
|
fileReader.readAsText(file)
|
||||||
} else {
|
} else {
|
||||||
handleShow('landing.history.modal.importHistoryError.textWithOutFile')
|
await dispatchUiNotification(
|
||||||
|
'common.errorOccurred',
|
||||||
|
'landing.history.modal.importHistoryError.textWithOutFile',
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
resetInputField()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input type='file' className='d-none' accept='.json' onChange={handleUpload} ref={uploadInput} />
|
<input
|
||||||
|
type='file'
|
||||||
|
className='d-none'
|
||||||
|
accept='.json'
|
||||||
|
onChange={handleUpload}
|
||||||
|
ref={uploadInput}
|
||||||
|
{...cypressId('import-history-file-input')}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant={'light'}
|
variant={'light'}
|
||||||
title={t('landing.history.toolbar.import')}
|
title={t('landing.history.toolbar.import')}
|
||||||
onClick={() => uploadInput.current?.click()}>
|
onClick={() => uploadInput.current?.click()}
|
||||||
|
{...cypressId('import-history-file-button')}>
|
||||||
<ForkAwesomeIcon icon='upload' />
|
<ForkAwesomeIcon icon='upload' />
|
||||||
</Button>
|
</Button>
|
||||||
<ErrorModal
|
|
||||||
show={show}
|
|
||||||
onHide={handleClose}
|
|
||||||
titleI18nKey='landing.history.modal.importHistoryError.title'
|
|
||||||
icon='exclamation-circle'>
|
|
||||||
<h5>
|
|
||||||
<Trans i18nKey={i18nKey} values={fileName !== '' ? { fileName: fileName } : {}} />
|
|
||||||
</h5>
|
|
||||||
</ErrorModal>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import type { IconName } from '../common/fork-awesome/types'
|
||||||
import { dismissUiNotification } from '../../redux/ui-notifications/methods'
|
import { dismissUiNotification } from '../../redux/ui-notifications/methods'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { Logger } from '../../utils/logger'
|
import { Logger } from '../../utils/logger'
|
||||||
|
import { cypressId } from '../../utils/cypress-attribute'
|
||||||
|
|
||||||
const STEPS_PER_SECOND = 10
|
const STEPS_PER_SECOND = 10
|
||||||
const log = new Logger('UiNotificationToast')
|
const log = new Logger('UiNotificationToast')
|
||||||
|
@ -108,7 +109,10 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
|
||||||
}, [contentI18nKey, contentI18nOptions, t])
|
}, [contentI18nKey, contentI18nOptions, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toast show={!dismissed && eta !== undefined} onClose={dismissThisNotification}>
|
<Toast
|
||||||
|
show={!dismissed && eta !== undefined}
|
||||||
|
onClose={dismissThisNotification}
|
||||||
|
{...cypressId('notification-toast')}>
|
||||||
<Toast.Header>
|
<Toast.Header>
|
||||||
<strong className='mr-auto'>
|
<strong className='mr-auto'>
|
||||||
<ShowIf condition={!!icon}>
|
<ShowIf condition={!!icon}>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue