import { Theme } from '@mui/material'; import { FC, ReactElement, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { parse } from 'papaparse'; import { useTranslation } from 'react-i18next'; import DialogTemplate from '@/components/customDialog/DialogTemplate'; import icons from '@/components/icons/Icons'; import { PartitionService } from '@/http'; import { rootContext } from '@/context'; import { combineHeadsAndData, convertVectorFields } from '@/utils'; import { FILE_MIME_TYPE } from '@/consts'; import InsertImport from './Import'; import InsertPreview from './Preview'; import InsertStatus from './Status'; import { InsertStatusEnum, InsertStepperEnum } from './consts'; import { DataService } from '@/http'; import type { InsertContentProps } from './Types'; import type { Option } from '@/components/customSelector/Types'; import type { InsertDataParam } from '@/pages/databases/collections/Types'; /** * this component contains processes during insert * including import, preview and status */ const InsertContainer: FC = ({ collections = [], defaultSelectedCollection, defaultSelectedPartition, partitions, schema, onInsert, }) => { const { t: insertTrans } = useTranslation('insert'); const { t: btnTrans } = useTranslation('btn'); const { handleCloseDialog, openSnackBar } = useContext(rootContext); const [activeStep, setActiveStep] = useState( InsertStepperEnum.import ); const [insertStatus, setInsertStatus] = useState( InsertStatusEnum.init ); const [insertFailMsg, setInsertFailMsg] = useState(''); const [nextDisabled, setNextDisabled] = useState(false); // selected collection name const [collectionValue, setCollectionValue] = useState( defaultSelectedCollection ); // selected partition name const [partitionValue, setPartitionValue] = useState( defaultSelectedPartition ); // use contain field names yes as default const [isContainFieldNames, setIsContainFieldNames] = useState(1); // uploaded file name const [fileName, setFileName] = useState(''); const [file, setFile] = useState(null); // uploaded csv data (type: string) const [csvData, setCsvData] = useState([]); const [jsonData, setJsonData] = useState< Record | undefined >(); // handle changed table heads const [tableHeads, setTableHeads] = useState([]); const [partitionOptions, setPartitionOptions] = useState([]); const previewData = useMemo(() => { // we only show top 4 results of uploaded csv data const end = isContainFieldNames ? 5 : 4; return csvData.slice(0, end); }, [csvData, isContainFieldNames]); useEffect(() => { if (activeStep === InsertStepperEnum.import) { /** * 1. must choose collection and partition * 2. must upload a csv file */ const selectValid = collectionValue !== '' && partitionValue !== ''; const uploadValid = csvData.length > 0 || typeof jsonData !== 'undefined'; const condition = selectValid && uploadValid; setNextDisabled(!condition); } if (activeStep === InsertStepperEnum.preview) { /** * table heads shouldn't be empty */ const headsValid = tableHeads.every(h => h !== ''); setNextDisabled(!headsValid); } }, [ activeStep, collectionValue, partitionValue, csvData, tableHeads, jsonData, ]); useEffect(() => { const heads = isContainFieldNames ? previewData[0] : new Array(previewData[0].length).fill(''); setTableHeads(heads); }, [previewData, isContainFieldNames]); // every time selected collection value change, partition options and default value will change const fetchPartition = useCallback(async () => { if (collectionValue) { const partitions = await PartitionService.getPartitions(collectionValue); const partitionOptions: Option[] = partitions.map(p => ({ label: p.name === '_default' ? insertTrans('defaultPartition') : p.name, value: p.name, })); setPartitionOptions(partitionOptions); if (partitionOptions.length > 0) { // set first partition option value as default value const [{ value: defaultPartitionValue }] = partitionOptions; setPartitionValue(defaultPartitionValue as string); } } }, [collectionValue]); useEffect(() => { // if not on partitions page, we need to fetch partitions according to selected collection if (!partitions || partitions.length === 0) { fetchPartition(); } else { const options = partitions .map(p => ({ label: p.name, value: p.name, })) // when there's single selected partition // insert dialog partitions shouldn't selectable .filter( partition => partition.label === defaultSelectedPartition || defaultSelectedPartition === '' ); setPartitionOptions(options); } }, [partitions, fetchPartition, defaultSelectedPartition]); const BackIcon = icons.back; // modal actions part, buttons label text or component const { confirm, cancel } = useMemo(() => { const labelMap: { [key in InsertStepperEnum]: { confirm: string; cancel: string | ReactElement; }; } = { [InsertStepperEnum.import]: { confirm: btnTrans('next'), cancel: btnTrans('cancel'), }, [InsertStepperEnum.preview]: { confirm: btnTrans('importFile'), cancel: ( <> {btnTrans('previous')} ), }, [InsertStepperEnum.status]: { confirm: btnTrans('done'), cancel: '', }, }; return labelMap[activeStep]; }, [activeStep, btnTrans, BackIcon]); const { showActions, showCancel } = useMemo(() => { return { showActions: insertStatus !== InsertStatusEnum.loading, showCancel: insertStatus === InsertStatusEnum.init, }; }, [insertStatus]); // props children component needed: const collectionOptions: Option[] = useMemo( () => defaultSelectedCollection === '' ? collections.map(c => ({ label: c.collection_name, value: c.collection_name, })) : [ { label: defaultSelectedCollection, value: defaultSelectedCollection, }, ], [collections, defaultSelectedCollection] ); const { schemaOptions, autoIdFieldName, }: { schemaOptions: Option[]; autoIdFieldName: string } = useMemo(() => { /** * on collection page, we get schema data from collection * on partition page, we pass schema as props */ const list = schema ? schema.fields : collections.find(c => c.collection_name === collectionValue)?.schema ?.fields; const autoIdFieldName = list?.find(item => item.is_primary_key && item.autoID)?.name || ''; /** * if below conditions all met, this schema shouldn't be selectable as head: * 1. this field is primary key * 2. this field auto id is true */ const options = (list || []) .filter(s => !s.autoID || !s.is_primary_key) .map(s => ({ label: s.name, value: s.name, })); return { schemaOptions: options, autoIdFieldName, }; }, [schema, collectionValue, collections]); const checkUploadFileValidation = (firstRowItems: string[]): boolean => { return checkIsAutoIdFieldValid(firstRowItems); }; /** * when primary key field auto id is true * no need to upload this field data * @param firstRowItems uploaded file first row items * @returns whether invalid, true means invalid */ const checkIsAutoIdFieldValid = (firstRowItems: string[]): boolean => { const isContainAutoIdField = firstRowItems.includes(autoIdFieldName); isContainAutoIdField && openSnackBar( insertTrans('uploadAutoIdFieldWarning', { fieldName: autoIdFieldName }), 'error' ); return isContainAutoIdField; }; const handleUploadedData = ( content: string, uploader: HTMLFormElement, type: FILE_MIME_TYPE ) => { // if json, just parse json to object if (type === FILE_MIME_TYPE.JSON) { setJsonData(JSON.parse(content)); setCsvData([]); return; } const { data } = parse(content); // if uploaded csv contains heads, firstRowItems is the list of all heads const [firstRowItems = []] = data as string[][]; const invalid = checkUploadFileValidation(firstRowItems); if (invalid) { // reset uploader value and filename setFileName(''); setFile(null); uploader.value = null; return; } setCsvData(data); setJsonData(undefined); }; const handleInsertData = async () => { const fields = schema ? schema.fields : collections.find(c => c.collection_name === collectionValue)?.schema ?.fields; // start loading setInsertStatus(InsertStatusEnum.loading); // process data const data = typeof jsonData !== 'undefined' ? convertVectorFields(jsonData, fields!) : combineHeadsAndData( tableHeads, isContainFieldNames ? csvData.slice(1) : csvData, fields! ); const param: InsertDataParam = { partition_name: partitionValue, fields_data: data, }; try { const inserted = await DataService.insertData(collectionValue, param); if (inserted.status.error_code !== 'Success') { setInsertFailMsg(inserted.status.reason); setInsertStatus(InsertStatusEnum.error); } else { await DataService.flush(collectionValue); // update collections await onInsert(collectionValue); setInsertStatus(InsertStatusEnum.success); } } catch (err: any) { const { response: { data: { message }, }, } = err; setInsertFailMsg(message); setInsertStatus(InsertStatusEnum.error); } }; const handleCollectionChange = (name: string) => { setCollectionValue(name); }; const handleNext = () => { const isJSON = typeof jsonData !== 'undefined'; switch (activeStep) { case InsertStepperEnum.import: setActiveStep(activeStep => activeStep + (isJSON ? 2 : 1)); if (isJSON) { handleInsertData(); } break; case InsertStepperEnum.preview: setActiveStep(activeStep => activeStep + 1); handleInsertData(); break; // default represent InsertStepperEnum.status default: handleCloseDialog(); break; } }; const handleUploadFileChange = (file: File, upload: HTMLFormElement) => { setFile(file); }; const handleBack = () => { switch (activeStep) { case InsertStepperEnum.import: handleCloseDialog(); break; case InsertStepperEnum.preview: setActiveStep(activeStep => activeStep - 1); break; // default represent InsertStepperEnum.status // status don't have cancel button default: break; } }; const generateContent = (activeStep: InsertStepperEnum) => { switch (activeStep) { case InsertStepperEnum.import: return ( ); case InsertStepperEnum.preview: return ( ); // default represents InsertStepperEnum.status default: return ; } }; return ( {generateContent(activeStep)} ); }; export default InsertContainer;