Skip to content

Commit

Permalink
[#179] enables loading local backup via drag and drop
Browse files Browse the repository at this point in the history
  • Loading branch information
GentlemanHal committed Nov 7, 2020
1 parent a9d29c8 commit 3416796
Show file tree
Hide file tree
Showing 15 changed files with 325 additions and 120 deletions.
2 changes: 1 addition & 1 deletion resources/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6.3.0
6.4.0
37 changes: 32 additions & 5 deletions src/client/backup/local/Export.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import React, {ReactElement, useCallback, useState} from 'react'
import styles from './export.scss'
import {SecondaryButton} from '../../common/forms/Button'
import {iPaste} from '../../common/fonts/Icons'
import {PrimaryButton, SecondaryButton} from '../../common/forms/Button'
import {iFloppyDisk, iPaste} from '../../common/fonts/Icons'
import {useClipboard} from './ClipboardHook'
import {useSelector} from 'react-redux'
import {toExportableConfigurationJson} from '../../configuration/Configuration'
import {TextArea} from './TextArea'
import {TimedMessage} from './TimedMessage'
import {MessagesType} from '../../common/Messages'
import format from 'date-fns/format'

function saveFile(configuration: string) {
const timestamp = format(new Date(), 'yyyyMMddHHmmssSSS')
const file = new Blob([configuration], {type: 'application/json'})
const a = document.createElement('a')

a.href = URL.createObjectURL(file)
a.download = `nevergreen-configuration-backup-${timestamp}.json`
a.click()

URL.revokeObjectURL(a.href)
}

export function Export(): ReactElement {
const configuration = useSelector(toExportableConfigurationJson)
Expand All @@ -24,7 +37,16 @@ export function Export(): ReactElement {
setMessage('Unable to copy, please manually copy')
}, [])

const supported = useClipboard('#copy-to-clipboard', copySuccess, copyError)
const autoCopySupported = useClipboard('#copy-to-clipboard', copySuccess, copyError)

const saveBackup = () => {
try {
saveFile(configuration)
} catch (e) {
setMessageType(MessagesType.ERROR)
setMessage('Unable to save, please copy and manually save')
}
}

return (
<>
Expand All @@ -33,9 +55,14 @@ export function Export(): ReactElement {
readOnly
id='export-data'
data-locator='export-data'/>
{supported && (
<TimedMessage type={messageType} clear={setMessage} messages={message}/>
<PrimaryButton className={styles.saveFile}
icon={iFloppyDisk}
onClick={saveBackup}>
Save backup...
</PrimaryButton>
{autoCopySupported && (
<>
<TimedMessage type={messageType} clear={setMessage} messages={message}/>
<SecondaryButton className={styles.copy}
id='copy-to-clipboard'
data-clipboard-target='#export-data'
Expand Down
98 changes: 88 additions & 10 deletions src/client/backup/local/Import.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,65 @@ import {isRight} from 'fp-ts/lib/Either'
import {TextArea} from './TextArea'
import {TimedMessage} from './TimedMessage'
import {MessagesType} from '../../common/Messages'
import * as logger from '../../common/Logger'
import {useStopDefaultDragAndDropBehaviour} from './StopDefaultDragAndDropBehaviourHook'

const PLACEHOLDER = 'paste exported configuration here and press import'
interface LoadedFile {
readonly content: string;
readonly filename: string;
}

const placeholder = 'Open, drag and drop or paste exported configuration here and press Import now'
const supportedFileTypes = ['application/json', 'text/plain']

function unsupportedMessage(file: File) {
const supported = supportedFileTypes
.map((s) => `"${s}"`)
.join(' or ')
return `Please open a backup file with type ${supported} ("${file.name}" has type "${file.type}")`
}

async function loadFile(file: File): Promise<LoadedFile> {
return new Promise((resolve, reject) => {
if (supportedFileTypes.some((supported) => supported === file.type)) {
try {
const fileReader = new FileReader()
fileReader.onloadend = (result) => {
resolve({
content: result.target?.result as string,
filename: file.name
})
}
fileReader.readAsText(file)
} catch (e) {
logger.error('Unable to load file because of an error', e)
reject('An error occurred while trying to open, please try again')
}
} else {
reject(unsupportedMessage(file))
}
})
}

export function Import(): ReactElement {
const dispatch = useDispatch()
const [success, setSuccess] = useState('')
const [errors, setErrors] = useState<ReadonlyArray<string>>([])
const [success, setSuccessState] = useState('')
const [errors, setErrorsState] = useState<ReadonlyArray<string>>([])
const [data, setData] = useState('')

const doImport = () => {
setErrors([])
setSuccess('')
useStopDefaultDragAndDropBehaviour()

const setSuccess = (message: string) => {
setSuccessState(message)
setErrorsState([])
}

const setErrors = (messages: ReadonlyArray<string>) => {
setSuccessState('')
setErrorsState(messages)
}

const doImport = () => {
if (isBlank(data)) {
setErrors(['Please enter the configuration to import'])
} else {
Expand All @@ -37,11 +83,36 @@ export function Import(): ReactElement {
}
}

const openFile = async (files: FileList | null) => {
if (!files) {
return
}

if (files.length === 1) {
try {
const {content, filename} = await loadFile(files[0])
setData(content)
setSuccess(`Opened backup file "${filename}"`)
} catch (e) {
setErrors([e])
}
} else {
setErrors([`Please open a single backup file (attempted to open ${files.length} files)`])
}
}

return (
<>
<div onDragOver={(evt) => {
evt.preventDefault()
evt.dataTransfer.dropEffect = 'copy'
}}
onDrop={(evt) => {
evt.preventDefault()
void openFile(evt.dataTransfer.files)
}}>
<TextArea label='Configuration to import'
error={errors}
placeholder={PLACEHOLDER}
placeholder={placeholder}
value={data}
onChange={({target}) => {
setData(target.value)
Expand All @@ -53,8 +124,15 @@ export function Import(): ReactElement {
onClick={doImport}
data-locator='import'
icon={iFloppyDisk}>
Import
Import now
</PrimaryButton>
</>
<input className={styles.openFileInput}
id='open-file'
type='file'
accept='.json,.txt,application/json,text/plain'
multiple={false}
onChange={(evt) => openFile(evt.target.files)}/>
<label className={styles.openFile} htmlFor='open-file'>Open backup...</label>
</div>
)
}
18 changes: 18 additions & 0 deletions src/client/backup/local/StopDefaultDragAndDropBehaviourHook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {useEffect} from 'react'

/** This stops the browser allowing files to be dropped anywhere in the window to open them */
export function useStopDefaultDragAndDropBehaviour(): void {
useEffect(() => {
const listener = (evt: DragEvent) => {
evt.preventDefault()
}

window.addEventListener('dragover', listener)
window.addEventListener('drop', listener)

return () => {
window.removeEventListener('dragover', listener)
window.removeEventListener('drop', listener)
}
}, [])
}
6 changes: 6 additions & 0 deletions src/client/backup/local/export.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
@import '../../common/layout';
@import './local-backup';

.saveFile,
.copy {
@extend %blocking;

@include respond-to(tablet, desktop) {
width: $button-size;
}
}
37 changes: 37 additions & 0 deletions src/client/backup/local/import.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
@import '../../common/layout';
@import '../../common/variables';
@import '../../common/accessibility';
@import '../../common/forms/button';
@import '../../common/fonts/icon-font.scss';
@import './local-backup';

.import {
@extend %blocking;

@include respond-to(tablet, desktop) {
width: $button-size;
}
}

.openFile {
@include interactive();

@extend .secondary;
@extend .icon-folder-open;

box-shadow: $shadow;
cursor: pointer;
transition: box-shadow 0.1s ease-in;

&::before {
margin-right: 0.5em;
}

@include respond-to(tablet, desktop) {
margin: $margin-top 0 0 0;
width: $button-size;
}
}

.openFileInput {
@extend %visually-hidden;

&:focus + label {
@include focus();
}
}
2 changes: 2 additions & 0 deletions src/client/backup/local/local-backup.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@import '../../common/responsive';

$button-size: 220px;

.container {
column-gap: 1em;
display: grid;
Expand Down
5 changes: 1 addition & 4 deletions src/client/common/fonts/Icons.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
export const iFloppyDisk = 'floppy-disk'
export const iCog = 'cog'
export const iEye = 'eye'
export const iNotification = 'notification'
export const iQuestion = 'question'
export const iCross = 'cross'
export const iCheckmark = 'checkmark'
export const iRefresh = 'loop2'
export const iCircleUp = 'circle-up'
export const iCircleDown = 'circle-down'
export const iCheckboxChecked = 'checkbox-checked'
export const iCheckboxUnchecked = 'checkbox-unchecked'
Expand All @@ -29,3 +25,4 @@ export const iDisplay = 'display'
export const iList = 'list'
export const iEyeBlocked = 'eye-blocked'
export const iCircle = 'circle'
export const iFolderOpen = 'folder-open'
6 changes: 6 additions & 0 deletions src/client/common/fonts/icon-font.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
text-transform: none;
}

.icon-folder-open::before {
@extend .icon;

content: '\e930';
}

.icon-floppy-disk::before {
@extend .icon;

Expand Down
Loading

0 comments on commit 3416796

Please sign in to comment.