@bhch/react-json-form
Version:
Create forms using JSON Schema
485 lines (410 loc) • 16.3 kB
JavaScript
import React from 'react';
import ReactModal from 'react-modal';
import Button from './buttons';
import Loader from './loaders';
import {EditorContext, capitalize, getCsrfCookie, getCoordsFromName} from '../util';
import {FormFileInput, Label} from './form.js';
import Icon from './icons';
export default class FileUploader extends React.Component {
static contextType = EditorContext;
constructor(props) {
super(props);
this.state = {
value: props.value,
//fileName: this.getFileName(),
loading: false,
open: false,
pane: 'upload'
};
this.hiddenInputRef = React.createRef();
this.newFiles = []; // track new uploaded files to send DELETE request
// on page exit if unsaved
this.exitListenersAdded = false;
}
openModal = (e) => {
this.setState({open: true});
}
closeModal = (e) => {
this.setState({open: false, pane: 'upload'});
}
togglePane = (name) => {
this.setState({pane: name});
}
handleFileSelect = (value) => {
// we create a fake event
let event = {
target: {
type: 'text',
value: value,
name: this.props.name
}
};
this.props.onChange(event);
this.closeModal();
}
handleFileUpload = (e) => {
this.newFiles.push(e.target.value);
this.addExitEventListeners();
this.props.onChange(e);
this.closeModal();
}
addExitEventListeners = () => {
/* Sets page exit (unload) event listeners.
*
* The purpose of these listeners is to send a DELETE
* request uf user leaves page WITHOUT SAVING FORM.
*
* The event listeners are only added if there a <form> element
* parent of this react-jsonform component because if there's
* no form to save, then the user will always have to leave
* without saving. Hence, no point in sending unsaved DELETE requests.
*/
if (this.exitListenersAdded)
return;
if (!this.hiddenInputRef.current)
return;
if (!this.hiddenInputRef.current.form)
return;
window.addEventListener('beforeunload', this.promptOnExit);
window.addEventListener('unload', this.sendDeleteRequestOnExit);
this.hiddenInputRef.current.form.addEventListener('submit', (e) => {
window.removeEventListener('beforeunload', this.promptOnExit);
window.removeEventListener('unload', this.sendDeleteRequestOnExit);
});
this.exitListenersAdded = true;
}
promptOnExit = (e) => {
if (!this.newFiles.length)
return;
e.preventDefault();
e.returnValue = '';
}
sendDeleteRequestOnExit = (e) => {
if (!this.newFiles.length)
return;
this.sendDeleteRequest([this.newFiles], 'unsaved_form_page_exit', true);
}
clearFile = () => {
if (window.confirm('Do you want to remove this file?')) {
let event = {
target: {
type: 'text',
value: '',
name: this.props.name
}
};
this.props.onChange(event);
}
}
sendDeleteRequest = (values, trigger, keepalive) => {
/* Sends DELETE request to file handler endpoint.
*
* Prams:
* values: (array) names of files to delete
* trigger: (string) the action which triggered the deletion
* keepalive: (bool) whether to use keepalive flag or not
*/
let endpoint = this.props.handler || this.context.fileHandler;
let querystring = new URLSearchParams({
...this.context.fileHandlerArgs,
coords: getCoordsFromName(this.props.name),
trigger: trigger
});
for (let i = 0; i < values.length; i++) {
querystring.append('value', values[i]);
}
let url = endpoint + '?' + querystring;
let options = {
method: 'DELETE',
headers: {
'X-CSRFToken': getCsrfCookie(),
},
};
if (keepalive)
options['keepalive'] = true;
return fetch(url, options);
}
render() {
if (!this.props.handler && !this.context.fileHandler) {
return <FormFileInput {...this.props} />;
}
return (
<div>
<Label label={this.props.label} required={this.props.required} />
<div className="rjf-file-field">
{this.props.value &&
<div className="rjf-current-file-name">
Current file: <span>{this.props.value}</span> {' '}
<Button className="remove-file" onClick={this.clearFile}>Clear</Button>
</div>
}
<Button onClick={this.openModal} className="upload-modal__open">
{this.props.value ? 'Change file' : 'Select file'}
</Button>
{this.props.error && this.props.error.map((error, i) => <span className="rjf-error-text" key={i}>{error}</span>)}
{this.props.help_text && <span className="rjf-help-text">{this.props.help_text}</span>}
</div>
<input type="hidden" ref={this.hiddenInputRef} />
<ReactModal
isOpen={this.state.open}
onRequestClose={this.closeModal}
contentLabel="Select file"
portalClassName="rjf-modal-portal"
overlayClassName="rjf-modal__overlay"
className="rjf-modal__dialog"
bodyOpenClassName="rjf-modal__main-body--open"
closeTimeoutMS={150}
ariaHideApp={false}
>
<div className="rjf-modal__content">
<div className="rjf-modal__header">
<TabButton
onClick={this.togglePane}
tabName="upload"
active={this.state.pane === "upload"}
>
Upload new
</TabButton>{' '}
<TabButton
onClick={this.togglePane}
tabName="library"
active={this.state.pane === "library"}
>
Choose from library
</TabButton>
<Button className="modal__close" onClick={this.closeModal} title="Close (Esc)">
<Icon name="x-lg" />
</Button>
</div>
<div className="rjf-modal__body">
{this.state.pane === 'upload' &&
<UploadPane
{...this.props}
onChange={this.handleFileUpload}
label=''
value=''
help_text=''
error=''
/>
}
{this.state.pane === 'library' &&
<LibraryPane
fileHandler={this.props.handler || this.context.fileHandler}
fileHandlerArgs={{
...this.context.fileHandlerArgs,
coords: getCoordsFromName(this.props.name),
}}
onFileSelect={this.handleFileSelect}
sendDeleteRequest={this.sendDeleteRequest}
/>
}
</div>
<div className="rjf-modal__footer">
<Button className="modal__footer-close" onClick={this.closeModal}>Cancel</Button>
</div>
</div>
</ReactModal>
</div>
);
}
}
function TabButton(props) {
let className = 'rjf-upload-modal__tab-button';
if (props.active)
className += ' rjf-upload-modal__tab-button--active';
return (
<button
onClick={() => props.onClick(props.tabName)}
className={className}
>
{props.children}
</button>
);
}
function UploadPane(props) {
return (
<div className="rjf-upload-modal__pane">
<h3>Upload new</h3>
<br/>
<FormFileInput {...props} />
</div>
);
}
class LibraryPane extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: true,
files: [],
page: 0, // current page
hasMore: true,
};
}
componentDidMount() {
//setTimeout(() => this.setState({loading: false}), 1000);
this.fetchList();
}
fetchList = () => {
let endpoint = this.props.fileHandler;
if (!endpoint) {
console.error(
"Error: fileHandler option need to be passed "
+ "while initializing editor for enabling file listing.");
this.setState({loading: false, hasMore: false});
return;
}
let url = endpoint + '?' + new URLSearchParams({
...this.props.fileHandlerArgs,
page: this.state.page + 1
});
fetch(url, {method: 'GET'})
.then((response) => response.json())
.then((result) => {
if (!Array.isArray(result.results))
result.results = [];
this.setState((state) => ({
loading: false,
files: [...state.files, ...result.results],
page: result.results.length > 0 ? state.page + 1 : state.page,
hasMore: result.results.length > 0,
})
);
})
.catch((error) => {
alert('Something went wrong while retrieving media files');
console.error('Error:', error);
this.setState({loading: false});
});
}
onLoadMore = (e) => {
this.setState({loading: true}, this.fetchList);
}
onFileDelete = () => {
this.setState({page: 0, files: []}, this.onLoadMore);
}
render() {
return (
<div className="rjf-upload-modal__pane">
<h3>Media library</h3>
<div className="rjf-upload-modal__media-container">
{this.state.files.map((i) => {
return (
<MediaTile
{...i}
onClick={this.props.onFileSelect}
sendDeleteRequest={this.props.sendDeleteRequest}
onFileDelete={this.onFileDelete}
/>
);
})}
</div>
{this.state.loading && <Loader className="rjf-upload-modal__media-loader" />}
{!this.state.loading && this.state.hasMore &&
<div>
<Button onClick={this.onLoadMore} className="upload-modal__media-load">
<Icon name="arrow-down" /> View more
</Button>
</div>
}
{!this.state.hasMore &&
<div className="rjf-upload-modal__media-end-message">
{this.state.files.length ? 'End of list' : 'No files found'}
</div>
}
</div>
);
}
}
const DEFAULT_THUBNAIL = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='%23999999' viewBox='0 0 16 16'%3E%3Cpath d='M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z'/%3E%3C/svg%3E";
function MediaTile(props) {
let metadata = props.metadata || {};
return (
<div className="rjf-upload-modal__media-tile">
<MediaTileMenu
value={props.value}
sendDeleteRequest={props.sendDeleteRequest}
onFileDelete={props.onFileDelete}
/>
<div className="rjf-upload-modal__media-tile-inner" tabIndex="0" onClick={() => props.onClick(props.value)}>
<img src={props.thumbnail ? props.thumbnail : DEFAULT_THUBNAIL} />
{props.metadata &&
<div className="rjf-upload-modal__media-tile-metadata">
{Object.getOwnPropertyNames(metadata).map((key) => {
return <span>{metadata[key]}</span>;
})}
</div>
}
</div>
</div>
);
}
class MediaTileMenu extends React.Component {
constructor(props) {
super(props);
this.state = {
open: false,
loading: false
};
}
toggleMenu = (e) => {
this.setState((state) => ({open: !state.open}));
}
handleDeleteClick = (e) => {
if (window.confirm('Do you want to delete this file?')) {
this.setState({loading: true});
this.props.sendDeleteRequest([this.props.value], 'delete_button')
.then((response) => {
let status = response.status;
let msg;
if (status === 200) {
// success
} else if (status === 400)
msg = 'Bad request';
else if (status === 401 || status === 403)
msg = "You don't have permission to delete this file";
else if (status === 404)
msg = 'This file does not exist on server';
else if (status === 405)
msg = 'This operation is not permitted';
else if (status > 405)
msg = 'Something went wrong while deleting file';
this.setState({loading: false, open: false});
if (msg)
alert(msg);
else
this.props.onFileDelete();
})
.catch((error) => {
alert('Something went wrong while deleting file');
console.error('Error:', error);
this.setState({loading: false});
});
}
}
render() {
return (
<div className={this.state.open ? 'rjf-dropdown open' : 'rjf-dropdown'}>
<Button
className="rjf-dropdown-toggler"
alterClassName={false}
title={this.state.open ? 'Close menu' : 'Open menu'}
onClick={this.toggleMenu}
>
<Icon name={this.state.open ? 'x-lg' : 'three-dots-vertical'} />
</Button>
{this.state.open &&
<div className="rjf-dropdown-items">
<Button
className="rjf-dropdown-item rjf-text-danger"
alterClassName={false}
onClick={this.handleDeleteClick}
>
{this.state.loading && <Loader />}
{this.state.loading ? ' Deleting...' : 'Delete'}
</Button>
</div>
}
</div>
);
}
}