@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
380 lines (378 loc) • 12.8 kB
JavaScript
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import Core from '@uppy/core';
import XHRUpload from '@uppy/xhr-upload';
import ProgressBar from '@uppy/progress-bar';
import Form from '@uppy/form';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import '@uppy/core/src/style.scss';
import '@uppy/progress-bar/src/style.scss';
import '@uppy/file-input/src/style.scss';
import { makeStyles } from 'tss-react/mui';
import { getGlobalHeaders } from '../../utils/ajax';
import { validateActionPolicy } from '../../services/sites';
import ConfirmDialog from '../ConfirmDialog/ConfirmDialog';
import { useDispatch } from 'react-redux';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import useSiteUIConfig from '../../hooks/useSiteUIConfig';
import { ensureSingleSlash } from '../../utils/string';
import { toQueryString } from '../../utils/object';
import Alert from '@mui/material/Alert';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { getResponseError } from '../UploadDialog/util';
import ReplayRoundedIcon from '@mui/icons-material/ReplayRounded';
import Box from '@mui/material/Box';
const messages = defineMessages({
chooseFile: {
id: 'fileUpload.chooseFile',
defaultMessage: 'Choose File'
},
validatingFile: {
id: 'fileUpload.validatingFile',
defaultMessage: 'Validating File'
},
uploadingFile: {
id: 'fileUpload.uploadingFile',
defaultMessage: 'Uploading File'
},
uploadedFile: {
id: 'fileUpload.uploadedFile',
defaultMessage: 'Uploaded File'
},
selectFileMessage: {
id: 'fileUpload.selectFileMessage',
defaultMessage: 'Please select a file to upload'
},
policyError: {
defaultMessage: 'File "{fileName}" doesn\'t comply with project policies: {detail}'
}
});
const singleFileUploadStyles = makeStyles()(() => ({
fileNameTrimmed: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
input: {
display: 'none !important'
},
inputContainer: {
marginBottom: '10px'
}
}));
export function SingleFileUpload(props) {
const {
url = '/studio/api/1/services/api/1/content/write-content.json',
formTarget = '#asset_upload_form',
onUploadStart,
onComplete,
onError,
customFileName,
fileTypes,
path,
site
} = props;
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const [description, setDescription] = useState(formatMessage(messages.selectFileMessage));
const [file, setFile] = useState(null);
const fileRef = useRef(null);
const [suggestedName, setSuggestedName] = useState(null);
const suggestedNameRef = useRef(null);
const [fileNameErrorClass, setFileNameErrorClass] = useState();
const [disableInput, setDisableInput] = useState(false);
const { upload } = useSiteUIConfig();
const [confirm, setConfirm] = useState(null);
const [error, setError] = useState(null);
fileRef.current = file;
suggestedNameRef.current = suggestedName;
const { classes, cx } = singleFileUploadStyles();
const uppy = useMemo(
() =>
new Core({
autoProceed: false,
...(fileTypes ? { restrictions: { allowedFileTypes: fileTypes } } : {}),
...(customFileName
? {
onBeforeFileAdded: (currentFile) => {
return {
...currentFile,
name: customFileName,
meta: {
...currentFile.meta,
name: customFileName
}
};
}
}
: {}),
onBeforeUpload: (files) => {
if (suggestedNameRef.current) {
const updatedFiles = {
...files,
[fileRef.current.id]: {
...files[fileRef.current.id],
name: suggestedNameRef.current,
meta: {
...files[fileRef.current.id].meta,
name: suggestedNameRef.current
}
}
};
setSuggestedName(null);
return updatedFiles;
} else {
return files;
}
}
}),
[fileTypes, customFileName]
);
const retryUpload = () => {
setError(null);
setConfirm(null);
uppy.retryUpload(file.id);
};
useEffect(() => {
const instance = uppy
.use(Form, {
target: formTarget,
getMetaFromForm: true,
addResultToForm: true,
submitOnSuccess: false,
triggerUploadOnSubmit: false
})
.use(ProgressBar, {
target: '.uppy-progress-bar',
hideAfterFinish: false
})
.use(XHRUpload, {
endpoint: `${url}${toQueryString({ path, site })}`,
formData: true,
fieldName: 'file',
timeout: upload.timeout,
headers: getGlobalHeaders(),
getResponseError: (responseText) => getResponseError(responseText, formatMessage),
getResponseData: (responseText, response) => response
});
return () => {
// https://uppy.io/docs/uppy/#uppy-close
instance.cancelAll();
instance.close();
};
}, [uppy, formTarget, url, upload.timeout, path, site, formatMessage]);
useEffect(() => {
const onUploadSuccess = (file) => {
setDescription(`${formatMessage(messages.uploadedFile)}:`);
};
const onCompleteUpload = (result) => {
// Uppy triggers 'complete' event even if the upload fails. When the upload fails, we call 'onError' instead of
// 'onComplete'.
if (result.successful.length > 0) {
onComplete?.(result);
setDisableInput(false);
}
};
uppy.on('upload-success', onUploadSuccess);
uppy.on('complete', onCompleteUpload);
return () => {
uppy.off('upload-success', onUploadSuccess);
uppy.off('complete', onCompleteUpload);
};
}, [onComplete, dispatch, formatMessage, path, uppy]);
useEffect(() => {
const onUploadError = (file, error, response) => {
setFileNameErrorClass('text-danger');
setError(error);
onError?.({ file, error, response });
setDisableInput(false);
};
uppy.on('upload-error', onUploadError);
return () => {
uppy.off('upload-error', onUploadError);
};
}, [onError, uppy]);
useEffect(() => {
const onFileAdded = (file) => {
setError(null);
setDescription(`${formatMessage(messages.validatingFile)}:`);
setFile(file);
setFileNameErrorClass('');
validateActionPolicy(site, {
type: 'CREATE',
target: ensureSingleSlash(`${path}/${file.name}`),
contentMetadata: {
fileSize: file.size
}
}).subscribe(({ allowed, modifiedValue, message }) => {
if (allowed) {
setDisableInput(true);
if (modifiedValue) {
// Modified value is expected to be a path.
const modifiedName = modifiedValue.match(/[^/]+$/)?.[0] ?? modifiedValue;
setConfirm({ body: message });
setSuggestedName(modifiedName);
} else {
uppy.upload();
setDescription(`${formatMessage(messages.uploadingFile)}:`);
onUploadStart?.();
}
} else {
setConfirm({
error: true,
body: formatMessage(messages.policyError, { fileName: file.name, detail: message })
});
}
});
};
uppy.on('file-added', onFileAdded);
return () => {
uppy.off('file-added', onFileAdded);
};
}, [onUploadStart, formatMessage, path, site, uppy]);
const onConfirm = () => {
uppy.upload().then(() => {});
setSuggestedName(null);
setDescription(`${formatMessage(messages.uploadingFile)}:`);
onUploadStart?.();
setConfirm(null);
};
const onConfirmCancel = () => {
document.querySelector('.uppy-FileInput-btn')?.removeAttribute('disabled');
uppy.removeFile(file.id);
setFile(null);
setConfirm(null);
setDescription(formatMessage(messages.selectFileMessage));
setDisableInput(false);
};
const onChange = ({ nativeEvent: event }) => {
const files = Array.from(event.target.files);
files.forEach((file) => {
try {
uppy.addFile({
source: 'file input',
name: file.name,
type: file.type,
data: file
});
} catch (err) {
console.error(err);
}
});
};
// Clear input current value on click, so if you need to select the same file (in case of an error) it will re-trigger
// the change/file selection.
const onInputClick = (event) => {
const element = event.target;
element.value = '';
};
return React.createElement(
React.Fragment,
null,
React.createElement(
'form',
{ id: 'asset_upload_form' },
React.createElement('input', { type: 'hidden', name: 'path', value: path }),
React.createElement('input', { type: 'hidden', name: 'site', value: site })
),
React.createElement(Box, { className: 'uppy-progress-bar', sx: { display: error ? 'none' : null } }),
React.createElement(
'div',
{ className: 'uploaded-files' },
error
? React.createElement(
Alert,
{
icon: false,
severity: 'error',
action: React.createElement(
Tooltip,
{ title: React.createElement(FormattedMessage, { defaultMessage: 'Retry' }) },
React.createElement(
IconButton,
{ onClick: () => retryUpload(), size: 'small' },
React.createElement(ReplayRoundedIcon, null)
)
),
sx: { mb: 2 }
},
React.createElement(Typography, { variant: 'subtitle1', component: 'h2' }, error.message)
)
: React.createElement(Typography, { variant: 'subtitle1', component: 'h2', sx: { mb: 2 } }, description),
React.createElement(
Typography,
{ variant: 'subtitle1', component: 'h2', sx: { mb: 2 } },
file &&
React.createElement(
'em',
{
className: cx('single-file-upload--filename', fileNameErrorClass, classes.fileNameTrimmed),
title: file.name
},
file.name
)
),
React.createElement(
'div',
{ className: classes.inputContainer },
React.createElement('input', {
accept: fileTypes?.join(','),
className: classes.input,
id: 'contained-button-file',
type: 'file',
onChange: onChange,
onClick: onInputClick,
disabled: disableInput
}),
React.createElement(
'label',
{ htmlFor: 'contained-button-file' },
React.createElement(
Button,
{ variant: 'outlined', component: 'span', disabled: disableInput },
formatMessage(messages.chooseFile)
)
)
)
),
React.createElement(ConfirmDialog, {
open: Boolean(confirm),
body: confirm?.body,
onOk: confirm?.error ? onConfirmCancel : onConfirm,
onCancel: confirm?.error ? null : onConfirmCancel,
disableEnforceFocus: true
})
);
}
export default SingleFileUpload;