src/components/ImportRecordsFlow/index.js
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import XLSX from 'xlsx';
import Modal from '../Modal';
import getDataFromWorkbook from './helpers/getDataFromWorkbook';
import getHeaderRowFromWorkbook from './helpers/getHeaderRowFromWorkbook';
// eslint-disable-next-line import/no-cycle
import getDataToImport from './helpers/getDataToImport';
import isStepThreeNextButtonDisabled from './helpers/isStepThreeNextButtonDisabled';
import Footer from './footer';
import getStepComponent from './helpers/getStepComponent';
import getValidatedData from './helpers/getValidatedData';
const stepNames = ['step-1', 'step-2', 'step-3', 'step-4'];
const modalTitleMap = {
'step-1': 'Whats do you want to do?',
'step-2': 'Select Data File',
'step-3': 'Assign Fields',
'step-4': 'Review and Start Import',
};
const ADD_RECORDS = Symbol('add-records');
const MERGE_RECORDS = Symbol('merge-records');
const ADD_RECORDS_TYPE = 'add-records';
/**
* @category Experiences
*/
function ImportRecordsFlow(props) {
const {
className,
style,
isOpen,
onRequestClose,
schema,
onComplete,
actionType,
borderRadius,
validateRecordCallback,
} = props;
const [currentStepIndex, setCurrentStepIndex] = useState(
actionType === ADD_RECORDS_TYPE ? 1 : 0,
);
const [actionOption, setActionOption] = useState(
actionType === ADD_RECORDS_TYPE ? ADD_RECORDS_TYPE : '',
);
const [matchField, setMatchField] = useState('default');
const [fileName, setFileName] = useState('');
const [fileType, setFileType] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [hasFileSelected, setHasFileSelected] = useState(false);
const [columns, setColumns] = useState([]);
const [data, setData] = useState([]);
const [fieldsMap, setFieldsMap] = useState({});
const [schemaFields, setSchemaFields] = useState([]);
const mappedRecords = useRef([]);
const [validRecords, setValidRecords] = useState([]);
const [invalidRecords, setInvalidRecords] = useState([]);
const currentStep = stepNames[currentStepIndex];
const StepComponent = getStepComponent({ currentStep });
const removeFile = () => {
setHasFileSelected(false);
setFileName('');
setFileType('');
setData([]);
setColumns([]);
setFieldsMap({});
};
useEffect(() => {
setSchemaFields(Object.keys(schema.attributes));
}, [schema.attributes]);
const prevIsOpen = useRef(isOpen);
useEffect(() => {
if (prevIsOpen.current && !isOpen) {
setCurrentStepIndex(actionType === ADD_RECORDS_TYPE ? 1 : 0);
removeFile();
setActionOption('');
setMatchField('default');
}
prevIsOpen.current = isOpen;
}, [isOpen, actionType]);
const getModalTitle = () => {
if (currentStepIndex === 1 && hasFileSelected) {
return 'Your File Preview';
}
return modalTitleMap[currentStep];
};
const goBackStep = () => {
if (currentStepIndex > 0) {
const prevStepIndex = currentStepIndex - 1;
setCurrentStepIndex(prevStepIndex);
}
};
const goNextStep = () => {
if (currentStepIndex === 2) {
mappedRecords.current = getDataToImport({
data,
fieldsMap,
schema,
actionOption,
matchField,
});
if (typeof validateRecordCallback === 'function') {
const {
validRecords: validValidatedRecord,
invalidRecords: invalidValidatedRecord,
} = getValidatedData({
validateRecordCallback,
dataToValidate: mappedRecords.current.data,
});
setValidRecords(validValidatedRecord);
setInvalidRecords(invalidValidatedRecord);
} else {
setValidRecords(mappedRecords.current.data);
}
}
if (currentStepIndex === 3) {
onComplete({
...mappedRecords.current,
data: validRecords,
});
}
if (currentStepIndex < stepNames.length - 1) {
const nextStepIndex = currentStepIndex + 1;
setCurrentStepIndex(nextStepIndex);
}
};
const isBackButtonDisabled = () => {
if (actionType === ADD_RECORDS_TYPE) {
return currentStepIndex === 1;
}
return currentStepIndex === 0;
};
const isNextButtonDisabled = () => {
if (currentStepIndex === 0) {
return !actionOption || (actionOption === 'merge-records' && matchField === 'default');
}
if (currentStepIndex === 1) {
return !hasFileSelected || isLoading;
}
if (currentStepIndex === 2) {
return isStepThreeNextButtonDisabled({
fieldsMap,
attributes: schema.attributes,
matchField,
});
}
return false;
};
const processFile = file => {
const { name, type } = file;
setFileName(name);
setFileType(type);
setIsLoading(true);
setHasFileSelected(true);
const reader = new FileReader();
reader.onload = event => {
const uInt8ArrayData = new Uint8Array(event.target.result);
const workbook = XLSX.read(uInt8ArrayData, { type: 'array', raw: true });
setColumns(getHeaderRowFromWorkbook(workbook));
setData(getDataFromWorkbook(workbook));
setIsLoading(false);
};
reader.readAsArrayBuffer(file);
};
const assignField = (databaseFieldToAssign, fileFieldsToAssign) => {
setFieldsMap({
...fieldsMap,
[databaseFieldToAssign]: fileFieldsToAssign.join(','),
});
};
return (
<Modal
className={className}
style={style}
title={getModalTitle()}
size="medium"
isOpen={isOpen}
onRequestClose={onRequestClose}
borderRadius={borderRadius}
footer={
<Footer
onBack={goBackStep}
onNext={goNextStep}
currentStep={currentStep}
isBackButtonDisabled={isBackButtonDisabled()}
isNextButtonDisabled={isNextButtonDisabled()}
actionType={actionType}
borderRadius={borderRadius}
/>
}
>
<StepComponent
schemaFields={schemaFields}
attributes={schema.attributes}
actionOption={actionOption}
onChangeAction={setActionOption}
matchField={matchField}
onChangeMatchField={setMatchField}
onProcessFile={processFile}
fileName={fileName}
fileType={fileType}
isLoading={isLoading}
hasFileSelected={hasFileSelected}
columns={columns}
data={data}
onRemoveFile={removeFile}
onAssignField={assignField}
fieldsMap={fieldsMap}
invalidRecords={invalidRecords}
validRecords={validRecords}
borderRadius={borderRadius}
/>
</Modal>
);
}
ImportRecordsFlow.propTypes = {
/** The schema represent the structure necessary for import data from a file to a database.
* Collection is meant to represent where in database the data will be stored.
* Attributes are the field to map with the file column headers */
schema: PropTypes.shape({
collection: PropTypes.string,
attributes: PropTypes.object,
}),
/** Controls whether the ImportRecordsFlow modal is opened or not.
* If true, the modal is open. */
isOpen: PropTypes.bool,
/** The action triggered when the component request to close
* (e.g click close button, press esc key or click outside the modal). */
onRequestClose: PropTypes.func,
/** The action triggered when all flow steps are completed. */
onComplete: PropTypes.func,
/** The action type to use. When set to "add-records" it will use this type and start the flow in the second step. */
actionType: PropTypes.oneOf(['add-records']),
/** A CSS class for the outer element, in addition to the component's base classes. */
className: PropTypes.string,
/** An object with custom style applied to the outer element. */
style: PropTypes.object,
/** The border radius of the modal. Valid values are square, semi-square, semi-rounded and rounded. This value defaults to rounded. */
borderRadius: PropTypes.oneOf(['square', 'semi-square', 'semi-rounded', 'rounded']),
/** A function to validate the records before importing them. This function will be invoked on each record of the CSV returning
* an object with the errors found in a record on each field. If the object doesn't have properties then the record is valid.
*/
validateRecordCallback: PropTypes.func,
};
ImportRecordsFlow.defaultProps = {
className: undefined,
style: undefined,
schema: {
attributes: {},
},
isOpen: false,
onRequestClose: () => {},
onComplete: () => {},
actionType: undefined,
validateRecordCallback: undefined,
borderRadius: 'rounded',
};
ImportRecordsFlow.MERGE_RECORDS = MERGE_RECORDS;
ImportRecordsFlow.ADD_RECORDS = ADD_RECORDS;
export default ImportRecordsFlow;