Fix file input field accepting a filename only once (#1547)

This commit is contained in:
Erik Michelson 2021-10-24 22:46:39 +02:00 committed by GitHub
parent 9118c8310b
commit 3591c90f9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 130 additions and 37 deletions

View file

@ -0,0 +1 @@
{"version":2,"entries":[{"identifier":"cypress2","title":"cy-Test2","tags":[],"lastVisited":"2019-04-30T09:36:45.249+02:00","pinStatus":false}]}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1 @@
{"version":2,"entries":[{"identifier":"cypress","title":"cy-Test","tags":[],"lastVisited":"2019-04-30T09:36:45.249+02:00","pinStatus":false}]}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

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

View file

@ -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[]>
}

View file

@ -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}`}

View file

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

View file

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

View file

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

View file

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