diff --git a/CHANGELOG.md b/CHANGELOG.md index 792ee2c74..499ef8b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Change history for ui-data-import ## 1.0.1 (IN-PROGRESS) +* Add file extensions validation and `InvalidFilesModal` component for file upload (UIDATIMP-46) +* Hide popover when user clicks on the link button (UIDATIMP-71) +* Write documentation for `FileUploader` component and some code refactor (UIDATIMP-65) ## [1.0.0](https://github.com/folio-org/ui-data-import/tree/v1.0.0) (2018-11-10) diff --git a/src/components/DataFetcher/DataFetcher.js b/src/components/DataFetcher/DataFetcher.js index 108040bd5..231eb2e84 100644 --- a/src/components/DataFetcher/DataFetcher.js +++ b/src/components/DataFetcher/DataFetcher.js @@ -4,17 +4,41 @@ import { get } from 'lodash'; import jobPropTypes from '../Jobs/components/Job/jobPropTypes'; import jobLogPropTypes from '../JobLogs/jobLogPropTypes'; +import { createUrl } from '../../utils'; import { PROCESSING_IN_PROGRESS, PROCESSING_FINISHED, PARSING_IN_PROGRESS, - COMMITTED, } from '../Jobs/jobStatuses'; -import { DataFetcherContextProvider } from './DataFetcherContext'; +import { DataFetcherContextProvider } from '.'; const DEFAULT_UPDATE_INTERVAL = 5000; +const jobsUrl = createUrl('metadata-provider/jobExecutions', { + query: `(status=("${PROCESSING_IN_PROGRESS}" OR "${PROCESSING_FINISHED}" OR "${PARSING_IN_PROGRESS}"))`, +}); + +const logsUrl = createUrl('metadata-provider/logs', { + landingPage: true, + limit: 25, +}); + class DataFetcher extends Component { + static manifest = Object.freeze({ + jobs: { + type: 'okapi', + path: jobsUrl, + accumulate: true, + throwErrors: false, + }, + logs: { + type: 'okapi', + path: logsUrl + '&query=(status=COMMITTED)', // TODO: remove query once backend issue is fixed + accumulate: true, + throwErrors: false, + }, + }); + static propTypes = { children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), @@ -51,27 +75,13 @@ class DataFetcher extends Component { updateInterval: DEFAULT_UPDATE_INTERVAL, }; - static manifest = Object.freeze({ - jobs: { - type: 'okapi', - path: `metadata-provider/jobExecutions?query=(status=(${PROCESSING_IN_PROGRESS} OR ${PROCESSING_FINISHED} OR ${PARSING_IN_PROGRESS}))`, - accumulate: true, - throwErrors: false, - }, - logs: { - type: 'okapi', - path: `metadata-provider/logs?query=(status=${COMMITTED})&landingPage=true&limit=25`, - accumulate: true, - throwErrors: false, - }, - }); - state = { - contextData: {}, + contextData: { + hasLoaded: false, + }, }; componentDidMount() { - this.setInitialState(); this.getResourcesData(); this.updateResourcesData(); } @@ -88,22 +98,6 @@ class DataFetcher extends Component { this.intervalId = setInterval(this.getResourcesData, updateInterval); } - setInitialState() { - const { mutator } = this.props; - const initialContextData = {}; - - Object.keys(mutator) - .forEach(resourceName => { - initialContextData[resourceName] = { - hasLoaded: false, - }; - }); - - this.setState({ - contextData: initialContextData, - }); - } - getResourcesData = async () => { const { mutator } = this.props; @@ -126,7 +120,10 @@ class DataFetcher extends Component { } }; - async getResourceData({ GET, reset }) { + async getResourceData({ + GET, + reset, + }) { // accumulate: true in manifest saves the results of all requests // because of that it is required to clear old data by invoking reset method before each request reset(); @@ -138,21 +135,14 @@ class DataFetcher extends Component { */ mapResourcesToState(isEmpty) { const { resources } = this.props; - const contextData = {}; + const contextData = { hasLoaded: true }; Object.entries(resources) .forEach(([resourceName, resourceValue]) => { - const itemsObject = isEmpty ? {} : get(resourceValue, ['records', 0], {}); - - contextData[resourceName] = { - hasLoaded: true, - itemsObject, - }; + contextData[resourceName] = isEmpty ? {} : get(resourceValue, ['records', 0], {}); }); - this.setState({ - contextData, - }); + this.setState({ contextData }); } render() { diff --git a/src/components/DataFetcher/index.js b/src/components/DataFetcher/index.js index 16ac5364f..13b9ca11e 100644 --- a/src/components/DataFetcher/index.js +++ b/src/components/DataFetcher/index.js @@ -1 +1,2 @@ export { default } from './DataFetcher'; +export * from './DataFetcherContext'; diff --git a/src/components/ImportJobs/ImportJobs.js b/src/components/ImportJobs/ImportJobs.js index b7406d3c6..827ee7453 100644 --- a/src/components/ImportJobs/ImportJobs.js +++ b/src/components/ImportJobs/ImportJobs.js @@ -2,8 +2,10 @@ import React, { Component } from 'react'; import { withRouter, Redirect } from 'react-router'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import { get } from 'lodash'; import FileUploader from './components/FileUploader'; +import InvalidFilesModal from './components/InvalidFilesModal'; import css from './components/FileUploader/FileUploader.css'; @@ -16,35 +18,62 @@ class ImportJobs extends Component { state = { isDropZoneActive: false, + isModalOpen: false, redirect: false, }; onDragEnter = () => { - this.setState({ - isDropZoneActive: true, - }); + this.setState({ isDropZoneActive: true }); }; onDragLeave = () => { - this.setState({ - isDropZoneActive: false, - }); + this.setState({ isDropZoneActive: false }); }; + /** + * @param {Array} acceptedFiles + * @param {Array} rejectedFiles + */ onDrop = (acceptedFiles, rejectedFiles) => { - this.setState({ - isDropZoneActive: false, - acceptedFiles, - rejectedFiles, - redirect: true, - }); + const isValidFileExtensions = this.validateFileExtensions(acceptedFiles); + + if (isValidFileExtensions) { + this.setState({ + isDropZoneActive: false, + redirect: true, + acceptedFiles, + rejectedFiles, + }); + + return; + } + + this.setState({ isDropZoneActive: false }); + this.showModal(); }; - getMessageById = (idEnding, moduleName = 'ui-data-import') => { + /** + * @param {Array} files + */ + validateFileExtensions(files = []) { + const filesType = get(files, [0, 'type'], ''); + + return files.every(({ type }) => type === filesType); + } + + showModal() { + this.setState({ isModalOpen: true }); + } + + hideModal = () => { + this.setState({ isModalOpen: false }); + }; + + getMessageById(idEnding, moduleName = 'ui-data-import') { const id = `${moduleName}.${idEnding}`; return ; - }; + } render() { const { @@ -52,6 +81,7 @@ class ImportJobs extends Component { rejectedFiles, redirect, isDropZoneActive, + isModalOpen, } = this.state; const { match } = this.props; const titleMessageIdEnding = isDropZoneActive ? 'activeUploadTitle' : 'uploadTitle'; @@ -82,7 +112,15 @@ class ImportJobs extends Component { onDragEnter={this.onDragEnter} onDragLeave={this.onDragLeave} onDrop={this.onDrop} - /> + > + {openFileUploadDialogWindow => ( + + )} + ); } } diff --git a/src/components/ImportJobs/components/FileUploader/FileUploader.js b/src/components/ImportJobs/components/FileUploader/FileUploader.js index 499343044..21d0ed3bf 100644 --- a/src/components/ImportJobs/components/FileUploader/FileUploader.js +++ b/src/components/ImportJobs/components/FileUploader/FileUploader.js @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import ReactDropzone from 'react-dropzone'; import classNames from 'classnames/bind'; +import { isFunction } from 'lodash'; import { Button } from '@folio/stripes/components'; @@ -10,18 +11,7 @@ import css from './FileUploader.css'; const cx = classNames.bind(css); -const getTitleClassName = dropZoneState => { - return cx({ - uploadTitle: true, - activeUploadTitle: dropZoneState, - }); -}; - -const getUsedStyle = (styleFromProps, classNameFromProps) => { - return classNameFromProps ? null : styleFromProps; -}; - -const FileUploader = (props) => { +const FileUploader = props => { const { title, uploadBtnText, @@ -40,15 +30,16 @@ const FileUploader = (props) => { onDragEnter, onDragLeave, } = props; - - const titleClassName = getTitleClassName(isDropZoneActive); - const usedStyle = getUsedStyle(style, className); + const titleClassName = cx({ + uploadTitle: true, + activeUploadTitle: isDropZoneActive, + }); return ( { onDragLeave={onDragLeave} > {({ open }) => ( - - {title} + + + {title} + - + )} ); @@ -102,6 +95,7 @@ FileUploader.propTypes = { children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node, + PropTypes.func, ]), }; diff --git a/src/components/ImportJobs/components/FileUploader/readme.md b/src/components/ImportJobs/components/FileUploader/readme.md new file mode 100644 index 000000000..8c4065a04 --- /dev/null +++ b/src/components/ImportJobs/components/FileUploader/readme.md @@ -0,0 +1,117 @@ +# FileUploader + +Component is built on top of [ReactDropzone](https://react-dropzone.netlify.com) component. Provides drag-and-drop zone for file uploading. Note that uploading files to a server is out of scope of this component and should be implemented additionally on the client and server. + +## Basic Usage + +```javascript + +``` + +## `onDrop` callback example + +`onDrop` callback takes an array of accepted files as the first argument and an array of rejected files as the second argument. Files are being accepted or rejected based on `accept` property. Find more info about `accept` property [here](https://react-dropzone.netlify.com/#src). + +Example of `onDrop` method that validates files based on their extensions with some custom logic. + +```javascript +/** + * @param {Array} acceptedFiles + * @param {Array} rejectedFiles + */ +onDrop = (acceptedFiles, rejectedFiles) => { + const isValidFileExtensions = this.validateFileExtensions(acceptedFiles); + + if (isValidFileExtensions) { + this.setState({ + isDropZoneActive: false, + redirect: true, + acceptedFiles, + rejectedFiles, + }); + + return; + } + + this.setState({ isDropZoneActive: false }); + this.showModal(); +}; +``` + +## Using with `children` property + +`FileUploader` component accepts `children` property that can be node, array of nodes or function. + +### Example of using `children` property with a component + +```javascript + + + +``` + +Here `` will be rendered below the title and upload button. + +### Example of using `children` property with a function + +```javascript + + {openFileUploadDialogWindow => ( + + )} + +``` + +When using a function as `children` this function accepts `openFileUploadDialogWindow` as an argument and `FileUploader` renders the returned value. +`openFileUploadDialogWindow` is a function provided by the `ReactDropzone` component that opens a modal file upload window for manually selecting files from the OS file system. + +## FileUploader props + +`FileUploader` component is built on top of the `ReactDropzone` component. For this reason, he has both his own props and some of the ReactDropzone props. + +### Own props + +| Prop | Type | Default | Required | Description | +|------------------|------------------------|---------|----------|----------------------------------------------| +| title | node | | Yes | Title of the component | +| uploadBtnText | node | | Yes | Upload files button text | +| isDropZoneActive | bool | | Yes | Value specifying whether dropzone is active | +| children | node \| node[] \| func | | No | | + +### Props passed to `ReactDropzone` + +| Prop | Type | Default | Required | Description | +|----------------------|--------------------|----------|----------|-----------------------------------------------------------------------------------------------------| +| onDrop | func | | Yes | onDrop callback that takes acceptedFiles and rejectedFiles as arguments | +| onDragEnter | func | | No | onDragEnter callback | +| onDragLeave | func | | No | onDragLeave callback | +| className | string | | No | `FileUploader` puts default styles in case the property is not passed | +| style | object | | No | CSS styles to apply | +| acceptClassName | string | | No | className to apply when drop will be accepted | +| activeClassName | string | | No | className to apply when drag is active | +| rejectClassName | string | | No | className to apply when drop will be rejected | +| disabledClassName | string | | No | className to apply when dropzone is disabled | +| maxSize | number | Infinity | No | Maximum file size (in bytes) | +| getDataTransferItems | func | | No | Find info about getDataTransferItems [here](https://react-dropzone.netlify.com/#extending-dropzone) | +| accept | string \| string[] | | No | Allow specific types of files | + +Find more info about `ReactDropzone` props by following this [link](https://react-dropzone.netlify.com/#proptypes) diff --git a/src/components/ImportJobs/components/InvalidFilesModal/InvalidFilesModal.js b/src/components/ImportJobs/components/InvalidFilesModal/InvalidFilesModal.js new file mode 100644 index 000000000..5efda0d73 --- /dev/null +++ b/src/components/ImportJobs/components/InvalidFilesModal/InvalidFilesModal.js @@ -0,0 +1,61 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { noop } from 'lodash'; + +import { + Modal, + ModalFooter, +} from '@folio/stripes/components'; + +const InvalidFilesModal = props => { + const { + isModalOpen, + onConfirmModal, + openFileUploadDialogWindow, + } = props; + const Footer = ( + , + onClick: openFileUploadDialogWindow, + }} + secondaryButton={{ + label: , + onClick: onConfirmModal, + }} + /> + ); + + return ( + } + footer={Footer} + > + + + + ), + }} + /> + + ); +}; + +InvalidFilesModal.propTypes = { + isModalOpen: PropTypes.bool.isRequired, + onConfirmModal: PropTypes.func, + openFileUploadDialogWindow: PropTypes.func, +}; + +InvalidFilesModal.defaultProps = { + onConfirmModal: noop, + openFileUploadDialogWindow: noop, +}; + +export default InvalidFilesModal; diff --git a/src/components/ImportJobs/components/InvalidFilesModal/index.js b/src/components/ImportJobs/components/InvalidFilesModal/index.js new file mode 100644 index 000000000..26944275f --- /dev/null +++ b/src/components/ImportJobs/components/InvalidFilesModal/index.js @@ -0,0 +1 @@ +export { default } from './InvalidFilesModal'; diff --git a/src/components/JobLogs/withJobLogsSort.js b/src/components/JobLogs/withJobLogsSort.js index 4cdef6a2c..1693bf356 100644 --- a/src/components/JobLogs/withJobLogsSort.js +++ b/src/components/JobLogs/withJobLogsSort.js @@ -10,25 +10,19 @@ import { sortStrings, } from '../../utils/sort'; import { compose } from '../../utils'; -import jobLogPropTypes from './jobLogPropTypes'; -import { DataFetcherContext } from '../DataFetcher/DataFetcherContext'; +import { DataFetcherContext } from '../DataFetcher'; const withJobLogsSort = WrappedComponent => { return class extends Component { static propTypes = { - formatter: PropTypes.object, - resource: PropTypes.shape({ - records: PropTypes.arrayOf(PropTypes.shape({ - logDtos: PropTypes.arrayOf(jobLogPropTypes).isRequired, - })), - isPending: PropTypes.bool.isRequired, - }), history: PropTypes.shape({ push: PropTypes.func.isRequired, }).isRequired, location: PropTypes.shape({ search: PropTypes.string.isRequired, + pathname: PropTypes.string.isRequired, }).isRequired, + formatter: PropTypes.object, }; static defaultProps = { @@ -91,7 +85,7 @@ const withJobLogsSort = WrappedComponent => { direction, } = this.state; - const logs = get(this.context, ['logs', 'itemsObject', 'logDtos'], []); + const logs = get(this.context, ['logs', 'logDtos'], []); return logs.sort((a, b) => { const cellFormatter = this.props.formatter[sort]; @@ -138,7 +132,7 @@ const withJobLogsSort = WrappedComponent => { }; render() { - const hasLoaded = get(this.context, ['jobs', 'hasLoaded'], false); + const { hasLoaded } = this.context; const contentData = this.prepareLogsData(); return ( diff --git a/src/components/Jobs/components/Job/Job.js b/src/components/Jobs/components/Job/Job.js index 1c7189b7c..4f0bdb4d4 100644 --- a/src/components/Jobs/components/Job/Job.js +++ b/src/components/Jobs/components/Job/Job.js @@ -1,4 +1,7 @@ -import React, { Component, Fragment } from 'react'; +import React, { + Component, + Fragment, +} from 'react'; import PropTypes from 'prop-types'; import { injectIntl, diff --git a/src/components/Jobs/components/Job/jobPropTypes.js b/src/components/Jobs/components/Job/jobPropTypes.js index d1e0c4fe8..a2e7aba8c 100644 --- a/src/components/Jobs/components/Job/jobPropTypes.js +++ b/src/components/Jobs/components/Job/jobPropTypes.js @@ -13,9 +13,9 @@ const jobPropTypes = PropTypes.shape({ current: PropTypes.number.isRequired, total: PropTypes.number.isRequired, }).isRequired, - startedDate: PropTypes.string.isRequired, - completedDate: PropTypes.string.isRequired, status: PropTypes.string.isRequired, + startedDate: PropTypes.string.isRequired, + completedDate: PropTypes.string, }); export default jobPropTypes; diff --git a/src/components/Jobs/components/JobsList/JobsList.js b/src/components/Jobs/components/JobsList/JobsList.js index cbe5d0fa1..6f80f0000 100644 --- a/src/components/Jobs/components/JobsList/JobsList.js +++ b/src/components/Jobs/components/JobsList/JobsList.js @@ -10,11 +10,12 @@ import jobPropTypes from '../Job/jobPropTypes'; import css from './JobsList.css'; -const JobsList = ({ - jobs, - hasLoaded, - noJobsMessage, -}) => { +const JobsList = props => { + const { + jobs, + hasLoaded, + noJobsMessage, + } = props; const itemFormatter = job => ( jobStatuses.includes(status)); return sortPreviewJobs(jobs); } render() { - const hasLoaded = get(this.context, ['jobs', 'hasLoaded'], false); + const { hasLoaded } = this.context; const jobs = this.prepareJobsData(); return ( diff --git a/src/components/Jobs/components/RunningJobs/RunningJobs.js b/src/components/Jobs/components/RunningJobs/RunningJobs.js index 956c1b9b4..22338dd45 100644 --- a/src/components/Jobs/components/RunningJobs/RunningJobs.js +++ b/src/components/Jobs/components/RunningJobs/RunningJobs.js @@ -5,21 +5,21 @@ import { get } from 'lodash'; import JobsList from '../JobsList'; import sortRunningJobs from './sortRunningJobs'; import { PARSING_IN_PROGRESS } from '../../jobStatuses'; -import { DataFetcherContext } from '../../../DataFetcher/DataFetcherContext'; +import { DataFetcherContext } from '../../../DataFetcher'; class RunningJobs extends PureComponent { static contextType = DataFetcherContext; prepareJobsData() { const jobStatuses = [PARSING_IN_PROGRESS]; // TODO: could be changed on backend - const jobs = get(this.context, ['jobs', 'itemsObject', 'jobExecutionDtos'], []) + const jobs = get(this.context, ['jobs', 'jobExecutionDtos'], []) .filter(({ status }) => jobStatuses.includes(status)); return sortRunningJobs(jobs); } render() { - const hasLoaded = get(this.context, ['jobs', 'hasLoaded'], false); + const { hasLoaded } = this.context; const jobs = this.prepareJobsData(); return ( diff --git a/src/components/Jobs/jobStatuses.js b/src/components/Jobs/jobStatuses.js index 946e3dfd4..ab3e57bb3 100644 --- a/src/components/Jobs/jobStatuses.js +++ b/src/components/Jobs/jobStatuses.js @@ -1,9 +1,3 @@ -// export const READY_FOR_PREVIEW = 'READY_FOR_PREVIEW'; -// export const PREPARING_FOR_PREVIEW = 'PREPARING_FOR_PREVIEW'; -// export const RUNNING = 'RUNNING'; - -// temporary status names export const PROCESSING_FINISHED = 'PROCESSING_FINISHED'; export const PROCESSING_IN_PROGRESS = 'PROCESSING_IN_PROGRESS'; export const PARSING_IN_PROGRESS = 'PARSING_IN_PROGRESS'; -export const COMMITTED = 'COMMITTED'; diff --git a/src/components/Report/Report.js b/src/components/Report/Report.js index e98d58776..bd5527fc5 100644 --- a/src/components/Report/Report.js +++ b/src/components/Report/Report.js @@ -1,5 +1,11 @@ import React from 'react'; -import { Accordion, AccordionSet, Row, Col } from '@folio/stripes/components'; + +import { + Accordion, + AccordionSet, + Row, + Col, +} from '@folio/stripes/components'; import RecordItem from './components/RecordItem'; import RecordPreview from './components/RecordPreview'; diff --git a/src/components/Report/components/RecordPreview/RecordPreview.js b/src/components/Report/components/RecordPreview/RecordPreview.js index e51101d14..25ac5501f 100644 --- a/src/components/Report/components/RecordPreview/RecordPreview.js +++ b/src/components/Report/components/RecordPreview/RecordPreview.js @@ -1,6 +1,9 @@ import React from 'react'; -import { Row, Col } from '@folio/stripes/components'; +import { + Row, + Col, +} from '@folio/stripes/components'; import css from './RecordPreview.css'; diff --git a/src/components/UploadingJobsDisplay/UploadingJobsDisplay.js b/src/components/UploadingJobsDisplay/UploadingJobsDisplay.js index d03d1561e..ee1c917a5 100644 --- a/src/components/UploadingJobsDisplay/UploadingJobsDisplay.js +++ b/src/components/UploadingJobsDisplay/UploadingJobsDisplay.js @@ -6,6 +6,11 @@ import { stripesShape, } from '@folio/stripes/core'; +import { + UPLOADED, + UPLOADING, + FAILED, +} from './components/FileItem/fileItemStatuses'; import FileItem from './components/FileItem'; import { createFileDefinition, // eslint-disable-line @@ -41,24 +46,28 @@ class UploadingJobsDisplay extends Component { async uploadJobs() { const { files } = this.state; - const { fileDefinitions } = await createFileDefinition( - files, - this.fileDefinitionUrl, - this.createJobFilesDefinitionHeaders() - ); + try { + const { fileDefinitions } = await createFileDefinition( + files, + this.fileDefinitionUrl, + this.createJobFilesDefinitionHeaders(), + ); - const preparedFiles = prepareFilesToUpload(files, fileDefinitions); + const preparedFiles = prepareFilesToUpload(files, fileDefinitions); - this.setState({ files: preparedFiles }, () => { - uploadFiles( - this.state.files, - this.fileUploaderUrl, - this.createUploadJobFilesHeaders(), - this.onFileUploadProgress, - this.onFileUploadSuccess, - this.onFileUploadFail, - ); - }); + this.setState({ files: preparedFiles }, () => { + uploadFiles( + this.state.files, + this.fileUploaderUrl, + this.createUploadJobFilesHeaders(), + this.onFileUploadProgress, + this.onFileUploadSuccess, + this.onFileUploadFail, + ); + }); + } catch (e) { + this.onAllFilesUploadFail(); + } } createJobFilesDefinitionHeaders() { @@ -92,6 +101,7 @@ class UploadingJobsDisplay extends Component { const keyNameValue = currentFile.name + currentFile.lastModified; currentFile.keyName = keyNameValue; + currentFile.uploadStatus = UPLOADING; currentFile.currentUploaded = 0; result[keyNameValue] = currentFile; @@ -117,13 +127,30 @@ class UploadingJobsDisplay extends Component { }; onFileUploadSuccess = ({ file }) => { - this.updateFileState(file, { uploadStatus: 'success' }); + this.updateFileState(file, { uploadStatus: UPLOADED }); }; onFileUploadFail = ({ file }) => { - this.updateFileState(file, { uploadStatus: 'failed' }); + this.updateFileState(file, { uploadStatus: FAILED }); }; + onAllFilesUploadFail() { + this.setState(state => { + const files = Object.keys(state.files).reduce((res, key) => { + const file = state.files[key]; + + file.uploadStatus = FAILED; + + return { + ...res, + [key]: file, + }; + }, {}); + + return { files }; + }); + } + renderFiles() { const { files } = this.state; diff --git a/src/components/UploadingJobsDisplay/components/FileItem/FileItem.css b/src/components/UploadingJobsDisplay/components/FileItem/FileItem.css index 349e5459e..d64e9a861 100644 --- a/src/components/UploadingJobsDisplay/components/FileItem/FileItem.css +++ b/src/components/UploadingJobsDisplay/components/FileItem/FileItem.css @@ -1,12 +1,34 @@ @import "@folio/stripes-components/lib/variables.css"; .fileItem { + position: relative; margin-bottom: .7rem; - padding: .5rem; + padding: .5rem 35px .5rem .5rem; border: 2px solid var(--color-border); border-radius: var(--radius); } +.fileItemFailed { + border-color: var(--danger); + background-color: color(var(--error) alpha(-90%)); + color: var(--danger); +} + +.fileItemHeader { + display: inline-block; + overflow-wrap: break-word; +} + +.fileItemHeaderName { + margin-right: 2rem; +} + +.icon { + position: absolute; + top: 0.5rem; + right: 10px; +} + .progress { display: flex; flex-direction: column; diff --git a/src/components/UploadingJobsDisplay/components/FileItem/FileItem.js b/src/components/UploadingJobsDisplay/components/FileItem/FileItem.js index e254de627..d78b3269c 100644 --- a/src/components/UploadingJobsDisplay/components/FileItem/FileItem.js +++ b/src/components/UploadingJobsDisplay/components/FileItem/FileItem.js @@ -1,20 +1,94 @@ -import React, { PureComponent } from 'react'; +import React, { + PureComponent, + Fragment, +} from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { + Icon, + IconButton, +} from '@folio/stripes/components'; + +import { + UPLOADED, + UPLOADING, + FAILED, +} from './fileItemStatuses'; import Progress from '../../../Progress'; import css from './FileItem.css'; +const getFileItemMeta = (props) => { + const { + uploadStatus, + name, + } = props; + + const defaultFileMeta = { + showProgress: false, + renderHeading: () => ( + + {name} + + ), + }; + + const fileTypesMeta = { + [UPLOADING]: { + showProgress: true, + renderHeading: () => ( + + {name} + + ), + }, + [UPLOADED]: { + renderHeading: () => ( + + {name} + + ), + }, + [FAILED]: { + fileWrapperClassName: css.fileItemFailed, + renderHeading: () => ( + + {name} + + + + + + } + size="small" + className={css.icon} + /> + + ), + }, + }; + + return { + ...defaultFileMeta, + ...fileTypesMeta[uploadStatus], + }; +}; + class FileItem extends PureComponent { static propTypes = { name: PropTypes.string.isRequired, size: PropTypes.number.isRequired, uploadedValue: PropTypes.number, + uploadStatus: PropTypes.string, }; static defaultProps = { uploadedValue: 0, + uploadStatus: UPLOADING, }; progressPayload = { @@ -23,23 +97,34 @@ class FileItem extends PureComponent { render() { const { - name, uploadedValue, size, + uploadStatus, + name, } = this.props; + const meta = getFileItemMeta({ + uploadStatus, + name, + }); + return ( -
- {name} - +
+
+ {meta.renderHeading()} +
+ + {meta.showProgress && ( + + )}
); } diff --git a/src/components/UploadingJobsDisplay/components/FileItem/fileItemStatuses.js b/src/components/UploadingJobsDisplay/components/FileItem/fileItemStatuses.js new file mode 100644 index 000000000..ff7b5c372 --- /dev/null +++ b/src/components/UploadingJobsDisplay/components/FileItem/fileItemStatuses.js @@ -0,0 +1,3 @@ +export const UPLOADING = '[FileItem] Uploading'; +export const UPLOADED = '[FileItem] Uploaded'; +export const FAILED = '[FileItem] Failed upload'; diff --git a/src/routes/Results.js b/src/routes/Results.js index bc08cd61f..e1aa849b1 100644 --- a/src/routes/Results.js +++ b/src/routes/Results.js @@ -1,5 +1,14 @@ import React from 'react'; -import { Button, Icon, Pane, Paneset, PaneMenu, IconButton } from '@folio/stripes/components'; + +import { + Button, + Icon, + Pane, + Paneset, + PaneMenu, + IconButton, +} from '@folio/stripes/components'; + import SearchPanel from '../components/SearchPanel'; import ResultPanel from '../components/ResultPanel'; import Report from '../components/Report/Report'; @@ -71,7 +80,10 @@ export default class Results extends React.Component { } render() { - const { filterPaneIsVisible, recordDetailsPaneIsVisible } = this.state; + const { + filterPaneIsVisible, + recordDetailsPaneIsVisible, + } = this.state; return ( diff --git a/src/settings/DataImportSettings/DataImportSettings.js b/src/settings/DataImportSettings/DataImportSettings.js index 28e62f9e8..8f23bfcf5 100644 --- a/src/settings/DataImportSettings/DataImportSettings.js +++ b/src/settings/DataImportSettings/DataImportSettings.js @@ -62,6 +62,7 @@ class DataImportSettings extends Component { } content={} diff --git a/src/utils/createUrl.js b/src/utils/createUrl.js new file mode 100644 index 000000000..3aab233e5 --- /dev/null +++ b/src/utils/createUrl.js @@ -0,0 +1,21 @@ +const generateQueryParams = (params = {}) => Object.entries(params) + .map(param => param.map(encodeURIComponent).join('=')) + .join('&'); + +/** + * Creates url with query parameters + * + * @param {string} url + * @param {object} [params] + */ +const createUrl = (url, params = {}) => { + if (typeof url !== 'string') { + throw new Error('First parameter must be of type string'); + } + + const paramsString = generateQueryParams(params); + + return `${url.endsWith('?') ? url.slice(0, -1) : url}?${paramsString}`; +}; + +export default createUrl; diff --git a/src/utils/index.js b/src/utils/index.js index 63bfc5c1f..689935aa6 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,3 +1,4 @@ export { default as calculatePercentage } from './calculatePercentage'; export { default as sortCollection } from './sortCollection'; export { default as compose } from './compose'; +export { default as createUrl } from './createUrl'; diff --git a/translations/ui-data-import/en.json b/translations/ui-data-import/en.json index 790bf9599..1f01acf23 100644 --- a/translations/ui-data-import/en.json +++ b/translations/ui-data-import/en.json @@ -20,13 +20,15 @@ "today": "today", "loading": "Loading", "undo": "Undo", + "delete": "Delete", "uploadTitle": "Drag and drop to start a new data import job", "activeUploadTitle": "Drop to start import", "uploadBtnText": "or choose files", "uploadingPaneTitle": "Files", "uploadingMessage": "Uploading", - + "uploadFileError": "Error: file upload", + "jobFileName": "File name", "jobProfileName": "Job profile", "jobExecutionHrId": "Import ID", @@ -34,16 +36,19 @@ "jobRunBy": "Run by", "settings.index.paneTitle": "Data Import", - "settings.profiles": "Profiles", "settings.learnMore": "Learn more", "settings.profilesInfo": "The four types of profiles allow you to create, and centrally maintain, jobs, matches, actions and field mappings across multiple jobs. When you update a profile, all the jobs that contain it, are automatically updated.", - "settings.jobProfiles": "Job profiles", "settings.matchProfiles": "Match profiles", "settings.actionProfiles": "Action profiles", "settings.fieldMappingProfiles": "Field mapping profiles", - "settings.other": "Other", - "settings.fileExtensions": "File extensions" + "settings.fileExtensions": "File extensions", + + "modal.header": "Inconsistent file extensions", + "modal.message": "You cannot upload files with {highlightedText}. Please upload files with the same extension.", + "modal.messageHighlightedText": "different extensions", + "modal.cancel": "Cancel", + "modal.actionButtonText": "Choose other files to upload" }