Import - Increase record import limit (#12627)

<img width="700" alt="Screenshot 2025-06-16 at 15 05 09"
src="https://github.com/user-attachments/assets/a09c3fae-c0ae-4a63-8bda-9d29c97a6a66"
/>


closes https://github.com/twentyhq/twenty/issues/11980
This commit is contained in:
Etienne
2025-06-18 12:13:24 +02:00
committed by GitHub
parent 83f28f113a
commit 78d39294ef
26 changed files with 331 additions and 54 deletions

View File

@ -69,7 +69,9 @@ export const useCreateActivityInDB = ({
activityToCreate.noteTargets ?? activityToCreate.taskTargets ?? []; activityToCreate.noteTargets ?? activityToCreate.taskTargets ?? [];
if (isNonEmptyArray(activityTargetsToCreate)) { if (isNonEmptyArray(activityTargetsToCreate)) {
await createManyActivityTargets(activityTargetsToCreate); await createManyActivityTargets({
recordsToCreate: activityTargetsToCreate,
});
} }
const activityTargetsConnection = getRecordConnectionFromRecords({ const activityTargetsConnection = getRecordConnectionFromRecords({

View File

@ -76,7 +76,9 @@ describe('useCreateManyRecords', () => {
); );
await act(async () => { await act(async () => {
const res = await result.current.createManyRecords(input); const res = await result.current.createManyRecords({
recordsToCreate: input,
});
expect(res).toEqual(response); expect(res).toEqual(response);
}); });
@ -96,7 +98,10 @@ describe('useCreateManyRecords', () => {
); );
await act(async () => { await act(async () => {
const res = await result.current.createManyRecords(input, true); const res = await result.current.createManyRecords({
recordsToCreate: input,
upsert: true,
});
expect(res).toEqual(response); expect(res).toEqual(response);
}); });

View File

@ -0,0 +1,106 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import {
useCreateManyRecords,
useCreateManyRecordsProps,
} from '@/object-record/hooks/useCreateManyRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { formatNumber } from '~/utils/format/number';
export const useBatchCreateManyRecords = <
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
recordGqlFields,
skipPostOptimisticEffect = false,
shouldMatchRootQueryFilter,
mutationBatchSize = DEFAULT_MUTATION_BATCH_SIZE,
setBatchedRecordsCount,
abortController,
}: useCreateManyRecordsProps & {
mutationBatchSize?: number;
setBatchedRecordsCount?: (count: number) => void;
abortController?: AbortController;
}) => {
const { createManyRecords } = useCreateManyRecords({
objectNameSingular,
recordGqlFields,
skipPostOptimisticEffect,
shouldMatchRootQueryFilter,
shouldRefetchAggregateQueries: false,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { refetchAggregateQueries } = useRefetchAggregateQueries({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const { enqueueSnackBar } = useSnackBar();
const batchCreateManyRecords = async ({
recordsToCreate,
upsert,
}: {
recordsToCreate: Partial<CreatedObjectRecord>[];
upsert?: boolean;
}) => {
const numberOfBatches = Math.ceil(
recordsToCreate.length / mutationBatchSize,
);
setBatchedRecordsCount?.(0);
const allCreatedRecords = [];
let createdRecordsCount = 0;
try {
for (let batchIndex = 0; batchIndex < numberOfBatches; batchIndex++) {
const batchedRecordsToCreate = recordsToCreate.slice(
batchIndex * mutationBatchSize,
(batchIndex + 1) * mutationBatchSize,
);
createdRecordsCount =
batchIndex + 1 === numberOfBatches
? recordsToCreate.length
: (batchIndex + 1) * mutationBatchSize;
const createdRecords = await createManyRecords({
recordsToCreate: batchedRecordsToCreate,
upsert,
abortController,
});
setBatchedRecordsCount?.(createdRecordsCount);
allCreatedRecords.push(...createdRecords);
}
} catch (error) {
if (error instanceof ApolloError && error.message.includes('aborted')) {
const formattedCreatedRecordsCount = formatNumber(createdRecordsCount);
enqueueSnackBar(
t`Record creation stopped. ${formattedCreatedRecordsCount} records created.`,
{
variant: SnackBarVariant.Warning,
duration: 5000,
},
);
} else {
throw error;
}
}
await refetchAggregateQueries();
return allCreatedRecords;
};
return {
batchCreateManyRecords,
};
};

View File

@ -32,11 +32,12 @@ type PartialObjectRecordWithOptionalId = Partial<ObjectRecord> & {
id?: string; id?: string;
}; };
type useCreateManyRecordsProps = { export type useCreateManyRecordsProps = {
objectNameSingular: string; objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields; recordGqlFields?: RecordGqlOperationGqlRecordFields;
skipPostOptimisticEffect?: boolean; skipPostOptimisticEffect?: boolean;
shouldMatchRootQueryFilter?: boolean; shouldMatchRootQueryFilter?: boolean;
shouldRefetchAggregateQueries?: boolean;
}; };
export const useCreateManyRecords = < export const useCreateManyRecords = <
@ -46,6 +47,7 @@ export const useCreateManyRecords = <
recordGqlFields, recordGqlFields,
skipPostOptimisticEffect = false, skipPostOptimisticEffect = false,
shouldMatchRootQueryFilter, shouldMatchRootQueryFilter,
shouldRefetchAggregateQueries = true,
}: useCreateManyRecordsProps) => { }: useCreateManyRecordsProps) => {
const apolloClient = useApolloClient(); const apolloClient = useApolloClient();
@ -76,10 +78,17 @@ export const useCreateManyRecords = <
objectMetadataNamePlural: objectMetadataItem.namePlural, objectMetadataNamePlural: objectMetadataItem.namePlural,
}); });
const createManyRecords = async ( type createManyRecordsProps = {
recordsToCreate: Partial<CreatedObjectRecord>[], recordsToCreate: Partial<CreatedObjectRecord>[];
upsert?: boolean, upsert?: boolean;
) => { abortController?: AbortController;
};
const createManyRecords = async ({
recordsToCreate,
upsert,
abortController,
}: createManyRecordsProps) => {
const sanitizedCreateManyRecordsInput: PartialObjectRecordWithOptionalId[] = const sanitizedCreateManyRecordsInput: PartialObjectRecordWithOptionalId[] =
[]; [];
const recordOptimisticRecordsInput: PartialObjectRecordWithId[] = []; const recordOptimisticRecordsInput: PartialObjectRecordWithId[] = [];
@ -169,6 +178,11 @@ export const useCreateManyRecords = <
data: sanitizedCreateManyRecordsInput, data: sanitizedCreateManyRecordsInput,
upsert: upsert, upsert: upsert,
}, },
context: {
fetchOptions: {
signal: abortController?.signal,
},
},
update: (cache, { data }) => { update: (cache, { data }) => {
const records = data?.[mutationResponseField]; const records = data?.[mutationResponseField];
@ -205,7 +219,8 @@ export const useCreateManyRecords = <
throw error; throw error;
}); });
await refetchAggregateQueries(); if (shouldRefetchAggregateQueries) await refetchAggregateQueries();
return createdObjects.data?.[mutationResponseField] ?? []; return createdObjects.data?.[mutationResponseField] ?? [];
}; };

View File

@ -1,12 +1,15 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; import { useBatchCreateManyRecords } from '@/object-record/hooks/useBatchCreateManyRecords';
import { useBuildAvailableFieldsForImport } from '@/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport'; import { useBuildAvailableFieldsForImport } from '@/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport';
import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow'; import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow';
import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts'; import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts';
import { SpreadsheetImportCreateRecordsBatchSize } from '@/spreadsheet-import/constants/SpreadsheetImportCreateRecordsBatchSize';
import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog'; import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog';
import { spreadsheetImportCreatedRecordsProgressState } from '@/spreadsheet-import/states/spreadsheetImportCreatedRecordsProgressState';
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSetRecoilState } from 'recoil';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useOpenObjectRecordsSpreadsheetImportDialog = ( export const useOpenObjectRecordsSpreadsheetImportDialog = (
@ -19,8 +22,17 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
objectNameSingular, objectNameSingular,
}); });
const { createManyRecords } = useCreateManyRecords({ const setCreatedRecordsProgress = useSetRecoilState(
spreadsheetImportCreatedRecordsProgressState,
);
const abortController = new AbortController();
const { batchCreateManyRecords } = useBatchCreateManyRecords({
objectNameSingular, objectNameSingular,
mutationBatchSize: SpreadsheetImportCreateRecordsBatchSize,
setBatchedRecordsCount: setCreatedRecordsProgress,
abortController,
}); });
const { buildAvailableFieldsForImport } = useBuildAvailableFieldsForImport(); const { buildAvailableFieldsForImport } = useBuildAvailableFieldsForImport();
@ -61,8 +73,10 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
}); });
try { try {
const upsert = true; await batchCreateManyRecords({
await createManyRecords(createInputs, upsert); recordsToCreate: createInputs,
upsert: true,
});
} catch (error: any) { } catch (error: any) {
enqueueSnackBar(error?.message || 'Something went wrong', { enqueueSnackBar(error?.message || 'Something went wrong', {
variant: SnackBarVariant.Error, variant: SnackBarVariant.Error,
@ -71,6 +85,9 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
}, },
fields: availableFieldsForMatching, fields: availableFieldsForMatching,
availableFieldMetadataItems: availableFieldMetadataItemsToImport, availableFieldMetadataItems: availableFieldMetadataItemsToImport,
onAbortSubmit: () => {
abortController.abort();
},
}); });
}; };

View File

@ -1,6 +1,7 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
import { t } from '@lingui/core/macro';
import { CircularProgressBar } from 'twenty-ui/feedback'; import { CircularProgressBar } from 'twenty-ui/feedback';
import { MainButton } from 'twenty-ui/input'; import { MainButton } from 'twenty-ui/input';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@ -15,37 +16,41 @@ const StyledFooter = styled(Modal.Footer)`
`; `;
type StepNavigationButtonProps = { type StepNavigationButtonProps = {
onClick: () => void; onContinue?: () => void;
title: string; continueTitle?: string;
isContinueDisabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
onBack?: () => void; onBack?: () => void;
isNextDisabled?: boolean; backTitle?: string;
}; };
export const StepNavigationButton = ({ export const StepNavigationButton = ({
onClick, onContinue,
title, continueTitle = t`Continue`,
isLoading, isLoading,
onBack, onBack,
isNextDisabled = false, backTitle = t`Back`,
isContinueDisabled = false,
}: StepNavigationButtonProps) => { }: StepNavigationButtonProps) => {
return ( return (
<StyledFooter> <StyledFooter>
{!isUndefinedOrNull(onBack) && ( {!isUndefinedOrNull(onBack) && (
<MainButton <MainButton
Icon={isLoading ? CircularProgressBar : undefined} Icon={isLoading ? CircularProgressBar : undefined}
title="Back" title={backTitle}
onClick={!isLoading ? onBack : undefined} onClick={!isLoading ? onBack : undefined}
variant="secondary" variant="secondary"
/> />
)} )}
<MainButton {!isUndefinedOrNull(onContinue) && (
Icon={isLoading ? CircularProgressBar : undefined} <MainButton
title={title} Icon={isLoading ? CircularProgressBar : undefined}
onClick={!isLoading ? onClick : undefined} title={continueTitle}
variant="primary" onClick={!isLoading ? onContinue : undefined}
disabled={isNextDisabled} variant="primary"
/> disabled={isContinueDisabled}
/>
)}
</StyledFooter> </StyledFooter>
); );
}; };

View File

@ -0,0 +1 @@
export const SpreadsheetImportCreateRecordsBatchSize = 500;

View File

@ -1 +1 @@
export const SpreadsheetMaxRecordImportCapacity = 2000; export const SpreadsheetMaxRecordImportCapacity = 10000;

View File

@ -60,6 +60,7 @@ describe('useSpreadsheetImport', () => {
); );
expect(result.current.spreadsheetImportState).toStrictEqual({ expect(result.current.spreadsheetImportState).toStrictEqual({
isOpen: false, isOpen: false,
isStepBarVisible: true,
options: null, options: null,
}); });
act(() => { act(() => {
@ -69,6 +70,7 @@ describe('useSpreadsheetImport', () => {
}); });
expect(result.current.spreadsheetImportState).toStrictEqual({ expect(result.current.spreadsheetImportState).toStrictEqual({
isOpen: true, isOpen: true,
isStepBarVisible: true,
options: mockedSpreadsheetOptions, options: mockedSpreadsheetOptions,
}); });
}); });

View File

@ -0,0 +1,23 @@
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { useRecoilCallback } from 'recoil';
export const useHideStepBar = () => {
const hideStepBar = useRecoilCallback(
({ set, snapshot }) =>
() => {
const isStepBarVisible = snapshot
.getLoadable(spreadsheetImportDialogState)
.getValue().isStepBarVisible;
if (isStepBarVisible) {
set(spreadsheetImportDialogState, (state) => ({
...state,
isStepBarVisible: false,
}));
}
},
[],
);
return hideStepBar;
};

View File

@ -15,6 +15,7 @@ export const useOpenSpreadsheetImportDialog = <T extends string>() => {
openModal(SPREADSHEET_IMPORT_MODAL_ID); openModal(SPREADSHEET_IMPORT_MODAL_ID);
setSpreadSheetImport({ setSpreadSheetImport({
isOpen: true, isOpen: true,
isStepBarVisible: true,
options, options,
}); });
}; };

View File

@ -43,8 +43,10 @@ export const SpreadsheetImportProvider = (
const { closeModal } = useModal(); const { closeModal } = useModal();
const handleClose = () => { const handleClose = () => {
spreadsheetImportDialog.options?.onAbortSubmit?.();
setSpreadsheetImportDialog({ setSpreadsheetImportDialog({
isOpen: false, isOpen: false,
isStepBarVisible: true,
options: null, options: null,
}); });

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const spreadsheetImportCreatedRecordsProgressState = createState({
key: 'spreadsheetImportCreatedRecordsProgressState',
defaultValue: 0,
});

View File

@ -1,8 +1,9 @@
import { SpreadsheetImportDialogOptions } from '../types';
import { createState } from 'twenty-ui/utilities'; import { createState } from 'twenty-ui/utilities';
import { SpreadsheetImportDialogOptions } from '../types';
export type SpreadsheetImportDialogState<T extends string> = { export type SpreadsheetImportDialogState<T extends string> = {
isOpen: boolean; isOpen: boolean;
isStepBarVisible: boolean;
options: Omit<SpreadsheetImportDialogOptions<T>, 'isOpen' | 'onClose'> | null; options: Omit<SpreadsheetImportDialogOptions<T>, 'isOpen' | 'onClose'> | null;
}; };
@ -12,6 +13,7 @@ export const spreadsheetImportDialogState = createState<
key: 'spreadsheetImportDialogState', key: 'spreadsheetImportDialogState',
defaultValue: { defaultValue: {
isOpen: false, isOpen: false,
isStepBarVisible: true,
options: null, options: null,
}, },
}); });

View File

@ -0,0 +1,64 @@
import { useRecoilValue } from 'recoil';
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
import { useHideStepBar } from '@/spreadsheet-import/hooks/useHideStepBar';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { spreadsheetImportCreatedRecordsProgressState } from '@/spreadsheet-import/states/spreadsheetImportCreatedRecordsProgressState';
import { Modal } from '@/ui/layout/modal/components/Modal';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Loader } from 'twenty-ui/feedback';
import { formatNumber } from '~/utils/format/number';
const StyledContent = styled(Modal.Content)`
align-items: center;
display: flex;
justify-content: center;
padding: 0px;
`;
const StyledHeader = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledDescription = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-bottom: ${({ theme }) => theme.spacing(5)};
`;
type ImportDataStepProps = {
recordsToImportCount: number;
};
export const ImportDataStep = ({
recordsToImportCount,
}: ImportDataStepProps) => {
const hideStepBar = useHideStepBar();
hideStepBar();
const { onClose } = useSpreadsheetImportInternal();
const spreadsheetImportCreatedRecordsProgress = useRecoilValue(
spreadsheetImportCreatedRecordsProgressState,
);
const formattedCreatedRecordsProgress = formatNumber(
spreadsheetImportCreatedRecordsProgress,
);
const formattedRecordsToImportCount = formatNumber(recordsToImportCount);
return (
<>
<StyledContent>
<StyledHeader>{t`Importing Data ...`}</StyledHeader>
<StyledDescription>{t`${formattedCreatedRecordsProgress} out of ${formattedRecordsToImportCount} records imported.`}</StyledDescription>
<Loader />
</StyledContent>
<StepNavigationButton onBack={onClose} backTitle={t`Cancel`} />
</>
);
};

View File

@ -279,14 +279,14 @@ export const MatchColumnsStep = <T extends string>({
</ScrollWrapper> </ScrollWrapper>
</StyledContent> </StyledContent>
<StepNavigationButton <StepNavigationButton
onClick={handleOnContinue} onContinue={handleOnContinue}
isLoading={isLoading} isLoading={isLoading}
title={t`Next Step`} continueTitle={t`Next Step`}
onBack={() => { onBack={() => {
onBack?.(); onBack?.();
setColumns([]); setColumns([]);
}} }}
isNextDisabled={!hasMatchedColumns} isContinueDisabled={!hasMatchedColumns}
/> />
</> </>
); );

View File

@ -114,9 +114,9 @@ export const SelectHeaderStep = ({
</StyledTableContainer> </StyledTableContainer>
</Modal.Content> </Modal.Content>
<StepNavigationButton <StepNavigationButton
onClick={handleOnContinue} onContinue={handleOnContinue}
onBack={onBack} onBack={onBack}
title={t`Continue`} continueTitle={t`Continue`}
isLoading={isLoading} isLoading={isLoading}
/> />
</> </>

View File

@ -11,8 +11,8 @@ import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { WorkBook } from 'xlsx-ugnis';
import { Radio, RadioGroup } from 'twenty-ui/input'; import { Radio, RadioGroup } from 'twenty-ui/input';
import { WorkBook } from 'xlsx-ugnis';
const StyledContent = styled(Modal.Content)` const StyledContent = styled(Modal.Content)`
align-items: center; align-items: center;
@ -116,10 +116,10 @@ export const SelectSheetStep = ({
</StyledRadioContainer> </StyledRadioContainer>
</StyledContent> </StyledContent>
<StepNavigationButton <StepNavigationButton
onClick={() => handleOnContinue(value)} onContinue={() => handleOnContinue(value)}
onBack={onBack} onBack={onBack}
isLoading={isLoading} isLoading={isLoading}
title={t`Next Step`} continueTitle={t`Next Step`}
/> />
</> </>
); );

View File

@ -7,14 +7,15 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
import { ImportDataStep } from '@/spreadsheet-import/steps/components/ImportDataStep';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { CircularProgressBar } from 'twenty-ui/feedback';
import { MatchColumnsStep } from './MatchColumnsStep/MatchColumnsStep'; import { MatchColumnsStep } from './MatchColumnsStep/MatchColumnsStep';
import { SelectHeaderStep } from './SelectHeaderStep/SelectHeaderStep'; import { SelectHeaderStep } from './SelectHeaderStep/SelectHeaderStep';
import { SelectSheetStep } from './SelectSheetStep/SelectSheetStep'; import { SelectSheetStep } from './SelectSheetStep/SelectSheetStep';
import { UploadStep } from './UploadStep/UploadStep'; import { UploadStep } from './UploadStep/UploadStep';
import { ValidationStep } from './ValidationStep/ValidationStep'; import { ValidationStep } from './ValidationStep/ValidationStep';
import { CircularProgressBar } from 'twenty-ui/feedback';
const StyledProgressBarContainer = styled(Modal.Content)` const StyledProgressBarContainer = styled(Modal.Content)`
align-items: center; align-items: center;
@ -128,6 +129,12 @@ export const SpreadsheetImportStepper = ({
}} }}
/> />
); );
case SpreadsheetImportStepType.importData:
return (
<ImportDataStep
recordsToImportCount={currentStepState.recordsToImportCount}
/>
);
case SpreadsheetImportStepType.loading: case SpreadsheetImportStepType.loading:
default: default:
return ( return (

View File

@ -6,8 +6,10 @@ import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpre
import { StepBar } from '@/ui/navigation/step-bar/components/StepBar'; import { StepBar } from '@/ui/navigation/step-bar/components/StepBar';
import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar'; import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar';
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { SpreadsheetImportStepper } from './SpreadsheetImportStepper'; import { SpreadsheetImportStepper } from './SpreadsheetImportStepper';
@ -26,6 +28,8 @@ const StyledHeader = styled(Modal.Header)`
export const SpreadsheetImportStepperContainer = () => { export const SpreadsheetImportStepperContainer = () => {
const { t } = useLingui(); const { t } = useLingui();
const spreadsheetImportDialog = useRecoilValue(spreadsheetImportDialogState);
const stepTitles = { const stepTitles = {
uploadStep: t`Upload File`, uploadStep: t`Upload File`,
matchColumnsStep: t`Match Columns`, matchColumnsStep: t`Match Columns`,
@ -45,15 +49,17 @@ export const SpreadsheetImportStepperContainer = () => {
return ( return (
<> <>
<StyledHeader> <StyledHeader>
<StepBar activeStep={activeStep}> {spreadsheetImportDialog.isStepBarVisible && (
{steps.map((key) => ( <StepBar activeStep={activeStep}>
<StepBar.Step {steps.map((key) => (
activeStep={activeStep} <StepBar.Step
label={stepTitles[key]} activeStep={activeStep}
key={key} label={stepTitles[key]}
/> key={key}
))} />
</StepBar> ))}
</StepBar>
)}
</StyledHeader> </StyledHeader>
<SpreadsheetImportStepper nextStep={nextStep} prevStep={prevStep} /> <SpreadsheetImportStepper nextStep={nextStep} prevStep={prevStep} />
</> </>

View File

@ -11,6 +11,7 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { MainButton } from 'twenty-ui/input'; import { MainButton } from 'twenty-ui/input';
import { formatNumber } from '~/utils/format/number';
const StyledContainer = styled.div` const StyledContainer = styled.div`
align-items: center; align-items: center;
@ -154,6 +155,10 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
const { t } = useLingui(); const { t } = useLingui();
const formatSpreadsheetMaxRecordImportCapacity = formatNumber(
SpreadsheetMaxRecordImportCapacity,
);
return ( return (
<StyledContainer <StyledContainer
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
@ -179,7 +184,7 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
</StyledText> </StyledText>
<MainButton onClick={open} title={t`Select file`} /> <MainButton onClick={open} title={t`Select file`} />
<StyledFooterText> <StyledFooterText>
{t`Max import capacity: ${SpreadsheetMaxRecordImportCapacity} records. Otherwise, consider splitting your file or using the API.`}{' '} {t`Max import capacity: ${formatSpreadsheetMaxRecordImportCapacity} records. Otherwise, consider splitting your file or using the API.`}{' '}
<StyledTextAction onClick={downloadSample}> <StyledTextAction onClick={downloadSample}>
{t`Download sample file.`} {t`Download sample file.`}
</StyledTextAction> </StyledTextAction>

View File

@ -237,7 +237,8 @@ export const ValidationStep = <T extends string>({
); );
setCurrentStepState({ setCurrentStepState({
type: SpreadsheetImportStepType.loading, type: SpreadsheetImportStepType.importData,
recordsToImportCount: calculatedData.validStructuredRows.length,
}); });
await onSubmit(calculatedData, file); await onSubmit(calculatedData, file);
@ -321,9 +322,9 @@ export const ValidationStep = <T extends string>({
</StyledToolbar> </StyledToolbar>
</StyledContent> </StyledContent>
<StepNavigationButton <StepNavigationButton
onClick={onContinue} onContinue={onContinue}
onBack={onBack} onBack={onBack}
title={t`Confirm`} continueTitle={t`Confirm`}
/> />
</> </>
); );

View File

@ -27,4 +27,8 @@ export type SpreadsheetImportStep =
} }
| { | {
type: SpreadsheetImportStepType.loading; type: SpreadsheetImportStepType.loading;
}
| {
type: SpreadsheetImportStepType.importData;
recordsToImportCount: number;
}; };

View File

@ -4,5 +4,6 @@ export enum SpreadsheetImportStepType {
selectHeader = 'selectHeader', selectHeader = 'selectHeader',
matchColumns = 'matchColumns', matchColumns = 'matchColumns',
validateData = 'validateData', validateData = 'validateData',
importData = 'importData',
loading = 'loading', loading = 'loading',
} }

View File

@ -35,6 +35,8 @@ export type SpreadsheetImportDialogOptions<FieldNames extends string> = {
validationResult: SpreadsheetImportImportValidationResult<FieldNames>, validationResult: SpreadsheetImportImportValidationResult<FieldNames>,
file: File, file: File,
) => Promise<void>; ) => Promise<void>;
// Function called when user aborts the importing flow
onAbortSubmit?: () => void;
// Allows submitting with errors. Default: true // Allows submitting with errors. Default: true
allowInvalidSubmit?: boolean; allowInvalidSubmit?: boolean;
// Theme configuration passed to underlying Chakra-UI // Theme configuration passed to underlying Chakra-UI

View File

@ -31,12 +31,12 @@ export const usePersistViewGroupRecords = () => {
({ viewGroupsToCreate, viewId }: CreateViewGroupRecordsArgs) => { ({ viewGroupsToCreate, viewId }: CreateViewGroupRecordsArgs) => {
if (viewGroupsToCreate.length === 0) return; if (viewGroupsToCreate.length === 0) return;
return createManyRecords( return createManyRecords({
viewGroupsToCreate.map((viewGroup) => ({ recordsToCreate: viewGroupsToCreate.map((viewGroup) => ({
...viewGroup, ...viewGroup,
viewId, viewId,
})), })),
); });
}, },
[createManyRecords], [createManyRecords],
); );