Dialog.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import { makeStyles, Theme } from '@material-ui/core';
  2. import {
  3. FC,
  4. ReactElement,
  5. useCallback,
  6. useContext,
  7. useEffect,
  8. useMemo,
  9. useState,
  10. } from 'react';
  11. import { parse } from 'papaparse';
  12. import { useTranslation } from 'react-i18next';
  13. import DialogTemplate from '@/components/customDialog/DialogTemplate';
  14. import icons from '@/components/icons/Icons';
  15. import { rootContext } from '@/context';
  16. import { Option } from '@/components/customSelector/Types';
  17. import { PartitionHttp } from '@/http/Partition';
  18. import { combineHeadsAndData } from '@/utils';
  19. import InsertImport from './Import';
  20. import InsertPreview from './Preview';
  21. import InsertStatus from './Status';
  22. import {
  23. InsertContentProps,
  24. InsertStatusEnum,
  25. InsertStepperEnum,
  26. } from './Types';
  27. const getStyles = makeStyles((theme: Theme) => ({
  28. icon: {
  29. fontSize: '16px',
  30. },
  31. }));
  32. /**
  33. * this component contains processes during insert
  34. * including import, preview and status
  35. */
  36. const InsertContainer: FC<InsertContentProps> = ({
  37. collections = [],
  38. defaultSelectedCollection,
  39. defaultSelectedPartition,
  40. partitions,
  41. schema,
  42. handleInsert,
  43. }) => {
  44. const classes = getStyles();
  45. const { t: insertTrans } = useTranslation('insert');
  46. const { t: btnTrans } = useTranslation('btn');
  47. const { handleCloseDialog, openSnackBar } = useContext(rootContext);
  48. const [activeStep, setActiveStep] = useState<InsertStepperEnum>(
  49. InsertStepperEnum.import
  50. );
  51. const [insertStatus, setInsertStatus] = useState<InsertStatusEnum>(
  52. InsertStatusEnum.init
  53. );
  54. const [insertFailMsg, setInsertFailMsg] = useState<string>('');
  55. const [nextDisabled, setNextDisabled] = useState<boolean>(false);
  56. // selected collection name
  57. const [collectionValue, setCollectionValue] = useState<string>(
  58. defaultSelectedCollection
  59. );
  60. // selected partition name
  61. const [partitionValue, setPartitionValue] = useState<string>(
  62. defaultSelectedPartition
  63. );
  64. // use contain field names yes as default
  65. const [isContainFieldNames, setIsContainFieldNames] = useState<number>(1);
  66. // uploaded file name
  67. const [fileName, setFileName] = useState<string>('');
  68. const [file, setFile] = useState<File | null>(null);
  69. // uploaded csv data (type: string)
  70. const [csvData, setCsvData] = useState<any[]>([]);
  71. // handle changed table heads
  72. const [tableHeads, setTableHeads] = useState<string[]>([]);
  73. const [partitionOptions, setPartitionOptions] = useState<Option[]>([]);
  74. const previewData = useMemo(() => {
  75. // we only show top 4 results of uploaded csv data
  76. const end = isContainFieldNames ? 5 : 4;
  77. return csvData.slice(0, end);
  78. }, [csvData, isContainFieldNames]);
  79. useEffect(() => {
  80. if (activeStep === InsertStepperEnum.import) {
  81. /**
  82. * 1. must choose collection and partition
  83. * 2. must upload a csv file
  84. */
  85. const selectValid = collectionValue !== '' && partitionValue !== '';
  86. const uploadValid = csvData.length > 0;
  87. const condition = selectValid && uploadValid;
  88. setNextDisabled(!condition);
  89. }
  90. if (activeStep === InsertStepperEnum.preview) {
  91. /**
  92. * table heads shouldn't be empty
  93. */
  94. const headsValid = tableHeads.every(h => h !== '');
  95. setNextDisabled(!headsValid);
  96. }
  97. }, [activeStep, collectionValue, partitionValue, csvData, tableHeads]);
  98. useEffect(() => {
  99. const heads = isContainFieldNames
  100. ? previewData[0]
  101. : new Array(previewData[0].length).fill('');
  102. setTableHeads(heads);
  103. }, [previewData, isContainFieldNames]);
  104. // every time selected collection value change, partition options and default value will change
  105. const fetchPartition = useCallback(async () => {
  106. if (collectionValue) {
  107. const partitions = await PartitionHttp.getPartitions(collectionValue);
  108. const partitionOptions: Option[] = partitions.map(p => ({
  109. label: p._formatName,
  110. value: p._name,
  111. }));
  112. setPartitionOptions(partitionOptions);
  113. if (partitionOptions.length > 0) {
  114. // set first partition option value as default value
  115. const [{ value: defaultPartitionValue }] = partitionOptions;
  116. setPartitionValue(defaultPartitionValue as string);
  117. }
  118. }
  119. }, [collectionValue]);
  120. useEffect(() => {
  121. // if not on partitions page, we need to fetch partitions according to selected collection
  122. if (!partitions || partitions.length === 0) {
  123. fetchPartition();
  124. } else {
  125. const options = partitions
  126. .map(p => ({
  127. label: p._formatName,
  128. value: p._name,
  129. }))
  130. // when there's single selected partition
  131. // insert dialog partitions shouldn't selectable
  132. .filter(
  133. partition =>
  134. partition.label === defaultSelectedPartition ||
  135. defaultSelectedPartition === ''
  136. );
  137. setPartitionOptions(options);
  138. }
  139. }, [partitions, fetchPartition, defaultSelectedPartition]);
  140. const BackIcon = icons.back;
  141. // modal actions part, buttons label text or component
  142. const { confirm, cancel } = useMemo(() => {
  143. const labelMap: {
  144. [key in InsertStepperEnum]: {
  145. confirm: string;
  146. cancel: string | ReactElement;
  147. };
  148. } = {
  149. [InsertStepperEnum.import]: {
  150. confirm: btnTrans('next'),
  151. cancel: btnTrans('cancel'),
  152. },
  153. [InsertStepperEnum.preview]: {
  154. confirm: btnTrans('insert'),
  155. cancel: (
  156. <>
  157. <BackIcon classes={{ root: classes.icon }} />
  158. {btnTrans('previous')}
  159. </>
  160. ),
  161. },
  162. [InsertStepperEnum.status]: {
  163. confirm: btnTrans('done'),
  164. cancel: '',
  165. },
  166. };
  167. return labelMap[activeStep];
  168. }, [activeStep, btnTrans, BackIcon, classes.icon]);
  169. const { showActions, showCancel } = useMemo(() => {
  170. return {
  171. showActions: insertStatus !== InsertStatusEnum.loading,
  172. showCancel: insertStatus === InsertStatusEnum.init,
  173. };
  174. }, [insertStatus]);
  175. // props children component needed:
  176. const collectionOptions: Option[] = useMemo(
  177. () =>
  178. defaultSelectedCollection === ''
  179. ? collections.map(c => ({
  180. label: c._name,
  181. value: c._name,
  182. }))
  183. : [
  184. {
  185. label: defaultSelectedCollection,
  186. value: defaultSelectedCollection,
  187. },
  188. ],
  189. [collections, defaultSelectedCollection]
  190. );
  191. const {
  192. schemaOptions,
  193. autoIdFieldName,
  194. }: { schemaOptions: Option[]; autoIdFieldName: string } = useMemo(() => {
  195. /**
  196. * on collection page, we get schema data from collection
  197. * on partition page, we pass schema as props
  198. */
  199. const list =
  200. schema && schema.length > 0
  201. ? schema
  202. : collections.find(c => c._name === collectionValue)?._fields;
  203. const autoIdFieldName =
  204. list?.find(item => item._isPrimaryKey && item._isAutoId)?._fieldName ||
  205. '';
  206. /**
  207. * if below conditions all met, this schema shouldn't be selectable as head:
  208. * 1. this field is primary key
  209. * 2. this field auto id is true
  210. */
  211. const options = (list || [])
  212. .filter(s => !s._isAutoId || !s._isPrimaryKey)
  213. .map(s => ({
  214. label: s._fieldName,
  215. value: s._fieldId,
  216. }));
  217. return {
  218. schemaOptions: options,
  219. autoIdFieldName,
  220. };
  221. }, [schema, collectionValue, collections]);
  222. const checkUploadFileValidation = (firstRowItems: string[]): boolean => {
  223. const uploadFieldNamesLength = firstRowItems.length;
  224. return (
  225. checkIsAutoIdFieldValid(firstRowItems) ||
  226. checkColumnLength(uploadFieldNamesLength)
  227. );
  228. };
  229. /**
  230. * when primary key field auto id is true
  231. * no need to upload this field data
  232. * @param firstRowItems uploaded file first row items
  233. * @returns whether invalid, true means invalid
  234. */
  235. const checkIsAutoIdFieldValid = (firstRowItems: string[]): boolean => {
  236. const isContainAutoIdField = firstRowItems.includes(autoIdFieldName);
  237. isContainAutoIdField &&
  238. openSnackBar(
  239. insertTrans('uploadAutoIdFieldWarning', { fieldName: autoIdFieldName }),
  240. 'error'
  241. );
  242. return isContainAutoIdField;
  243. };
  244. /**
  245. * uploaded file column length should be equal to schema length
  246. * @param fieldNamesLength every row items length
  247. * @returns whether invalid, true means invalid
  248. */
  249. const checkColumnLength = (fieldNamesLength: number): boolean => {
  250. const isLengthEqual = schemaOptions.length === fieldNamesLength;
  251. // if not equal, open warning snackbar
  252. !isLengthEqual &&
  253. openSnackBar(insertTrans('uploadFieldNamesLenWarning'), 'error');
  254. return !isLengthEqual;
  255. };
  256. const handleUploadedData = (csv: string, uploader: HTMLFormElement) => {
  257. const { data } = parse(csv);
  258. // if uploaded csv contains heads, firstRowItems is the list of all heads
  259. const [firstRowItems = []] = data as string[][];
  260. const invalid = checkUploadFileValidation(firstRowItems);
  261. if (invalid) {
  262. // reset uploader value and filename
  263. setFileName('');
  264. setFile(null);
  265. uploader.value = null;
  266. return;
  267. }
  268. setCsvData(data);
  269. };
  270. const handleInsertData = async () => {
  271. // start loading
  272. setInsertStatus(InsertStatusEnum.loading);
  273. // combine table heads and data
  274. const tableData = isContainFieldNames ? csvData.slice(1) : csvData;
  275. const data = combineHeadsAndData(tableHeads, tableData);
  276. const { result, msg } = await handleInsert(
  277. collectionValue,
  278. partitionValue,
  279. data
  280. );
  281. if (!result) {
  282. setInsertFailMsg(msg);
  283. }
  284. const status = result ? InsertStatusEnum.success : InsertStatusEnum.error;
  285. setInsertStatus(status);
  286. };
  287. const handleCollectionChange = (name: string) => {
  288. setCollectionValue(name);
  289. };
  290. const handleNext = () => {
  291. switch (activeStep) {
  292. case InsertStepperEnum.import:
  293. setActiveStep(activeStep => activeStep + 1);
  294. break;
  295. case InsertStepperEnum.preview:
  296. setActiveStep(activeStep => activeStep + 1);
  297. handleInsertData();
  298. break;
  299. // default represent InsertStepperEnum.status
  300. default:
  301. handleCloseDialog();
  302. break;
  303. }
  304. };
  305. const handleUploadFileChange = (file: File, upload: HTMLFormElement) => {
  306. setFile(file);
  307. };
  308. const handleBack = () => {
  309. switch (activeStep) {
  310. case InsertStepperEnum.import:
  311. handleCloseDialog();
  312. break;
  313. case InsertStepperEnum.preview:
  314. setActiveStep(activeStep => activeStep - 1);
  315. break;
  316. // default represent InsertStepperEnum.status
  317. // status don't have cancel button
  318. default:
  319. break;
  320. }
  321. };
  322. const generateContent = (activeStep: InsertStepperEnum) => {
  323. switch (activeStep) {
  324. case InsertStepperEnum.import:
  325. return (
  326. <InsertImport
  327. collectionOptions={collectionOptions}
  328. partitionOptions={partitionOptions}
  329. selectedCollection={collectionValue}
  330. selectedPartition={partitionValue}
  331. handleCollectionChange={handleCollectionChange}
  332. handlePartitionChange={setPartitionValue}
  333. handleUploadedData={handleUploadedData}
  334. handleUploadFileChange={handleUploadFileChange}
  335. fileName={fileName}
  336. setFileName={setFileName}
  337. />
  338. );
  339. case InsertStepperEnum.preview:
  340. return (
  341. <InsertPreview
  342. schemaOptions={schemaOptions}
  343. data={previewData}
  344. tableHeads={tableHeads}
  345. setTableHeads={setTableHeads}
  346. isContainFieldNames={isContainFieldNames}
  347. file={file}
  348. handleIsContainedChange={setIsContainFieldNames}
  349. />
  350. );
  351. // default represents InsertStepperEnum.status
  352. default:
  353. return <InsertStatus status={insertStatus} failMsg={insertFailMsg} />;
  354. }
  355. };
  356. return (
  357. <DialogTemplate
  358. title={insertTrans('import')}
  359. handleClose={handleCloseDialog}
  360. confirmLabel={confirm}
  361. cancelLabel={cancel}
  362. handleCancel={handleBack}
  363. handleConfirm={handleNext}
  364. confirmDisabled={nextDisabled}
  365. showActions={showActions}
  366. showCancel={showCancel}
  367. // don't show close icon when insert not finish
  368. showCloseIcon={insertStatus !== InsertStatusEnum.loading}
  369. >
  370. {generateContent(activeStep)}
  371. </DialogTemplate>
  372. );
  373. };
  374. export default InsertContainer;