mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-29 00:14:30 -05: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 Mode', () => {
|
||||
beforeEach(() => {
|
||||
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'
|
||||
|
||||
export const getHistory = async (): Promise<HistoryEntryDto[]> => {
|
||||
const response = await fetch(getApiUrl() + 'me/history')
|
||||
const response = await fetch(getApiUrl() + 'me/history', {
|
||||
...defaultFetchConfig
|
||||
})
|
||||
expectResponseCode(response)
|
||||
return (await response.json()) as Promise<HistoryEntryDto[]>
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import './entry-menu.scss'
|
|||
import { RemoveNoteEntryItem } from './remove-note-entry-item'
|
||||
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
|
||||
export interface EntryMenuProps {
|
||||
id: string
|
||||
|
@ -31,7 +32,7 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({ id, title, origin, isDark,
|
|||
const userExists = useApplicationState((state) => !!state.user)
|
||||
|
||||
return (
|
||||
<Dropdown className={`d-inline-flex ${className || ''}`}>
|
||||
<Dropdown className={`d-inline-flex ${className || ''}`} {...cypressId('history-entry-menu')}>
|
||||
<Dropdown.Toggle
|
||||
variant={isDark ? 'secondary' : 'light'}
|
||||
id={`dropdown-card-${id}`}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import React from 'react'
|
||||
import { DropdownItemWithDeletionModal } from './dropdown-item-with-deletion-modal'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
|
||||
export interface RemoveNoteEntryItemProps {
|
||||
onConfirm: () => void
|
||||
|
@ -23,6 +24,7 @@ export const RemoveNoteEntryItem: React.FC<RemoveNoteEntryItemProps> = ({ noteTi
|
|||
modalQuestionI18nKey={'landing.history.modal.removeNote.question'}
|
||||
modalWarningI18nKey={'landing.history.modal.removeNote.warning'}
|
||||
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 { deleteAllHistoryEntries, refreshHistoryState } from '../../../redux/history/methods'
|
||||
import { showErrorNotification } from '../../../redux/ui-notifications/methods'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
|
||||
export const ClearHistoryButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
@ -29,7 +30,11 @@ export const ClearHistoryButton: React.FC = () => {
|
|||
|
||||
return (
|
||||
<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'} />
|
||||
</Button>
|
||||
<DeletionModal
|
||||
|
|
|
@ -6,9 +6,8 @@
|
|||
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
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 { ErrorModal } from '../../common/modals/error-modal'
|
||||
import type { HistoryEntry, HistoryExportJson, V1HistoryEntry } from '../../../redux/history/types'
|
||||
import { HistoryEntryOrigin } from '../../../redux/history/types'
|
||||
import {
|
||||
|
@ -17,27 +16,16 @@ import {
|
|||
mergeHistoryEntries,
|
||||
refreshHistoryState
|
||||
} 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 { cypressId } from '../../../utils/cypress-attribute'
|
||||
|
||||
export const ImportHistoryButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const userExists = useApplicationState((state) => !!state.user)
|
||||
const historyState = useApplicationState((state) => state.history)
|
||||
const uploadInput = useRef<HTMLInputElement>(null)
|
||||
const [show, setShow] = useState(false)
|
||||
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(
|
||||
(entries: HistoryEntry[]): void => {
|
||||
|
@ -50,17 +38,29 @@ export const ImportHistoryButton: React.FC = () => {
|
|||
[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
|
||||
if (files && files[0] && validity.valid) {
|
||||
const file = files[0]
|
||||
setFilename(file.name)
|
||||
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
|
||||
}
|
||||
const fileReader = new FileReader()
|
||||
fileReader.onload = (event) => {
|
||||
fileReader.onload = async (event) => {
|
||||
if (event.target && event.target.result) {
|
||||
try {
|
||||
const result = event.target.result as string
|
||||
|
@ -71,42 +71,63 @@ export const ImportHistoryButton: React.FC = () => {
|
|||
onImportHistory(data.entries)
|
||||
} else {
|
||||
// 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 {
|
||||
const oldEntries = JSON.parse(result) as V1HistoryEntry[]
|
||||
onImportHistory(convertV1History(oldEntries))
|
||||
}
|
||||
}
|
||||
resetInputField()
|
||||
} catch {
|
||||
handleShow('landing.history.modal.importHistoryError.textWithFile')
|
||||
await dispatchUiNotification(
|
||||
'common.errorOccurred',
|
||||
'landing.history.modal.importHistoryError.textWithFile',
|
||||
{
|
||||
contentI18nOptions: {
|
||||
fileName
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fileReader.readAsText(file)
|
||||
} else {
|
||||
handleShow('landing.history.modal.importHistoryError.textWithOutFile')
|
||||
await dispatchUiNotification(
|
||||
'common.errorOccurred',
|
||||
'landing.history.modal.importHistoryError.textWithOutFile',
|
||||
{}
|
||||
)
|
||||
resetInputField()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
variant={'light'}
|
||||
title={t('landing.history.toolbar.import')}
|
||||
onClick={() => uploadInput.current?.click()}>
|
||||
onClick={() => uploadInput.current?.click()}
|
||||
{...cypressId('import-history-file-button')}>
|
||||
<ForkAwesomeIcon icon='upload' />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { IconName } from '../common/fork-awesome/types'
|
|||
import { dismissUiNotification } from '../../redux/ui-notifications/methods'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Logger } from '../../utils/logger'
|
||||
import { cypressId } from '../../utils/cypress-attribute'
|
||||
|
||||
const STEPS_PER_SECOND = 10
|
||||
const log = new Logger('UiNotificationToast')
|
||||
|
@ -108,7 +109,10 @@ export const UiNotificationToast: React.FC<UiNotificationProps> = ({
|
|||
}, [contentI18nKey, contentI18nOptions, t])
|
||||
|
||||
return (
|
||||
<Toast show={!dismissed && eta !== undefined} onClose={dismissThisNotification}>
|
||||
<Toast
|
||||
show={!dismissed && eta !== undefined}
|
||||
onClose={dismissThisNotification}
|
||||
{...cypressId('notification-toast')}>
|
||||
<Toast.Header>
|
||||
<strong className='mr-auto'>
|
||||
<ShowIf condition={!!icon}>
|
||||
|
|
Loading…
Reference in a new issue