download record sample - Import (#12489)

<img width="400" alt="Screenshot 2025-06-10 at 18 14 17"
src="https://github.com/user-attachments/assets/05591b46-c36d-45c6-a236-3469c29d7420"
/>


closes https://github.com/twentyhq/core-team-issues/issues/915

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2025-06-16 16:01:27 +02:00
committed by GitHub
parent 79b8c4660c
commit c16ba6a7d7
32 changed files with 753 additions and 481 deletions

View File

@ -58,8 +58,8 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
const fieldMetadataItemSettings =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataItem.type];
const subFieldNamesThatExistInOptions = fieldMetadataItemSettings.subFields
.filter((subFieldName) => {
const subFieldsThatExistInOptions = fieldMetadataItemSettings.subFields
.filter(({ subFieldName }) => {
const optionKey = getSubFieldOptionKey(fieldMetadataItem, subFieldName);
const correspondingOption = options.find(
@ -68,7 +68,7 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
return isDefined(correspondingOption);
})
.filter((subFieldName) =>
.filter(({ subFieldName }) =>
getCompositeSubFieldLabel(
fieldMetadataItem.type as CompositeFieldType,
subFieldName,
@ -96,7 +96,7 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{subFieldNamesThatExistInOptions.map((subFieldName) => (
{subFieldsThatExistInOptions.map(({ subFieldName }) => (
<MenuItem
key={subFieldName}
onClick={() => handleSubFieldSelect(subFieldName)}

View File

@ -0,0 +1 @@
export const SpreadsheetMaxRecordImportCapacity = 2000;

View File

@ -1,6 +1,7 @@
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { SpreadSheetImportModalWrapper } from '@/spreadsheet-import/components/SpreadSheetImportModalWrapper';
import { SPREADSHEET_IMPORT_MODAL_ID } from '@/spreadsheet-import/constants/SpreadsheetImportModalId';
import { SpreadsheetMaxRecordImportCapacity } from '@/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity';
import { SpreadsheetImportStepperContainer } from '@/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer';
import { SpreadsheetImportDialogOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types';
@ -19,7 +20,7 @@ export const defaultSpreadsheetImportProps: Partial<
dateFormat: 'yyyy-mm-dd', // ISO 8601,
parseRaw: true,
selectHeader: false,
maxRecords: 2000,
maxRecords: SpreadsheetMaxRecordImportCapacity,
} as const;
export const SpreadsheetImport = <T extends string>(

View File

@ -3,7 +3,9 @@ import { useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { read, WorkBook } from 'xlsx-ugnis';
import { SpreadsheetMaxRecordImportCapacity } from '@/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { useDownloadFakeRecords } from '@/spreadsheet-import/steps/components/UploadStep/hooks/useDownloadFakeRecords';
import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -83,6 +85,23 @@ const StyledText = styled.span`
padding: 16px;
`;
const StyledFooterText = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.regular};
text-align: center;
position: absolute;
bottom: ${({ theme }) => theme.spacing(4)};
left: 50%;
transform: translateX(-50%);
width: 100%;
`;
const StyledTextAction = styled.span`
cursor: pointer;
text-decoration: underline;
`;
type DropZoneProps = {
onContinue: (data: WorkBook, file: File) => void;
isLoading: boolean;
@ -95,6 +114,8 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
const { enqueueSnackBar } = useSnackBar();
const { downloadSample } = useDownloadFakeRecords();
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
noClick: true,
noKeyboard: true,
@ -157,6 +178,12 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
<Trans>Upload .xlsx, .xls or .csv file</Trans>
</StyledText>
<MainButton onClick={open} title={t`Select file`} />
<StyledFooterText>
{t`Max import capacity: ${SpreadsheetMaxRecordImportCapacity} records. Otherwise, consider splitting your file or using the API.`}{' '}
<StyledTextAction onClick={downloadSample}>
{t`Download sample file.`}
</StyledTextAction>
</StyledFooterText>
</>
)}
</StyledContainer>

View File

@ -1,26 +0,0 @@
import { useMemo } from 'react';
import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable';
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow';
import { generateColumns } from './columns';
interface ExampleTableProps<T extends string> {
fields: SpreadsheetImportFields<T>;
}
export const ExampleTable = <T extends string>({
fields,
}: ExampleTableProps<T>) => {
const data = useMemo(() => generateExampleRow(fields), [fields]);
const columns = useMemo(() => generateColumns(fields), [fields]);
return (
<SpreadsheetImportTable
rows={data}
columns={columns}
className={'rdg-example'}
/>
);
};

View File

@ -0,0 +1,138 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs';
import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue';
import { saveAs } from 'file-saver';
import { FieldMetadataType } from 'twenty-shared/types';
export const useDownloadFakeRecords = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const availableFieldMetadataItems =
spreadsheetImportFilterAvailableFieldMetadataItems(
objectMetadataItem.fields,
);
const buildTableWithFakeRecords = () => {
const headerRow: string[] = [];
const bodyRows: string[][] = [[], [], []];
availableFieldMetadataItems.forEach((field) => {
switch (field.type) {
case FieldMetadataType.RATING:
case FieldMetadataType.ARRAY:
case FieldMetadataType.RAW_JSON:
case FieldMetadataType.UUID:
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.DATE:
case FieldMetadataType.BOOLEAN:
case FieldMetadataType.NUMBER:
case FieldMetadataType.TEXT: {
headerRow.push(field.label);
const exampleValues =
SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].exampleValues;
bodyRows.forEach((_, index) => {
bodyRows[index].push(exampleValues?.[index] || '');
});
break;
}
case FieldMetadataType.ACTOR:
case FieldMetadataType.EMAILS:
case FieldMetadataType.CURRENCY:
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.LINKS:
case FieldMetadataType.PHONES:
case FieldMetadataType.ADDRESS: {
const compositeFieldSettings =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type];
const subFields = compositeFieldSettings.subFields.filter(
(subField) => subField.isImportable,
);
const exampleValues =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].exampleValues;
headerRow.push(
...subFields.map(
({ subFieldLabel }) => `${field.label} / ${subFieldLabel}`,
),
);
bodyRows.forEach((_, index) => {
subFields.forEach(({ subFieldName }) => {
bodyRows[index].push(
exampleValues?.[index]?.[
subFieldName as keyof (typeof exampleValues)[typeof index]
] || '',
);
});
});
break;
}
case FieldMetadataType.RELATION: {
headerRow.push(`${field.label} (ID)`);
const exampleValues =
SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS[FieldMetadataType.UUID]
.exampleValues;
bodyRows.forEach((_, index) => {
bodyRows[index].push(exampleValues?.[index] || '');
});
break;
}
case FieldMetadataType.MULTI_SELECT:
headerRow.push(field.label);
bodyRows.forEach((_, index) => {
bodyRows[index].push(
JSON.stringify(
field?.options
?.map((option) => option?.value)
.slice(0, index) || [],
),
);
});
break;
case FieldMetadataType.SELECT:
headerRow.push(field.label);
bodyRows.forEach((_, index) => {
bodyRows[index].push(field?.options?.[index]?.value || '');
});
break;
}
});
return { headerRow, bodyRows };
};
const formatToCsvContent = (rows: string[][]) => {
const escapedRows = rows.map((row) => {
return row.map((value) => escapeCSVValue(value));
});
const csvContent = [...escapedRows.map((row) => row.join(','))].join('\n');
return [csvContent];
};
const downloadSample = () => {
const { headerRow, bodyRows } = buildTableWithFakeRecords();
const csvContent = formatToCsvContent([headerRow, ...bodyRows]);
const blob = new Blob(csvContent, { type: 'text/csv' });
saveAs(blob, `${objectMetadataItem.labelPlural.toLowerCase()}-sample.csv`);
};
return { downloadSample };
};

View File

@ -4,7 +4,9 @@ import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/Componen
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { stepBarInternalState } from '@/ui/navigation/step-bar/states/stepBarInternalState';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SpreadsheetImportStepperContainer } from '../SpreadsheetImportStepperContainer';
const meta: Meta<typeof SpreadsheetImportStepperContainer> = {
@ -14,6 +16,8 @@ const meta: Meta<typeof SpreadsheetImportStepperContainer> = {
ComponentWithRecoilScopeDecorator,
SnackBarDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
ContextStoreDecorator,
],
parameters: {
initialRecoilState: {

View File

@ -8,7 +8,9 @@ import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/Spre
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { RecoilRoot } from 'recoil';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
const meta: Meta<typeof UploadStep> = {
@ -18,6 +20,8 @@ const meta: Meta<typeof UploadStep> = {
layout: 'fullscreen',
},
decorators: [
ObjectMetadataItemsDecorator,
ContextStoreDecorator,
(Story) => (
<RecoilRoot
initializeState={({ set }) => {

View File

@ -0,0 +1,24 @@
import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue';
describe('escapeCSVValue', () => {
it('should escape values with commas, quotes, newlines and carriage returns', () => {
expect(escapeCSVValue('test,test')).toBe('"test,test"');
});
it('should escape array or JSON values', () => {
expect(escapeCSVValue(['test', 'test'])).toBe('"[""test"",""test""]"');
expect(escapeCSVValue({ test: 'test' })).toBe('"{""test"":""test""}"');
});
it('should escape null values', () => {
expect(escapeCSVValue(null)).toBe('');
});
it('should escape simple string value', () => {
expect(escapeCSVValue('test')).toBe('test');
});
it('should escape simple number value', () => {
expect(escapeCSVValue(1)).toBe('1');
});
});

View File

@ -1,65 +0,0 @@
import { SpreadsheetImportField } from '@/spreadsheet-import/types';
import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow';
import { FieldMetadataType } from 'twenty-shared/types';
describe('generateExampleRow', () => {
const defaultField: SpreadsheetImportField<'defaultField'> = {
key: 'defaultField',
Icon: null,
label: 'label',
fieldType: {
type: 'input',
},
fieldMetadataType: FieldMetadataType.TEXT,
};
it('should generate an example row from input field type', () => {
const fields: SpreadsheetImportField<'defaultField'>[] = [defaultField];
const result = generateExampleRow(fields);
expect(result).toStrictEqual([{ defaultField: 'Text' }]);
});
it('should generate an example row from checkbox field type', () => {
const fields: SpreadsheetImportField<'defaultField'>[] = [
{
...defaultField,
fieldType: { type: 'checkbox' },
fieldMetadataType: FieldMetadataType.BOOLEAN,
},
];
const result = generateExampleRow(fields);
expect(result).toStrictEqual([{ defaultField: 'Boolean' }]);
});
it('should generate an example row from select field type', () => {
const fields: SpreadsheetImportField<'defaultField'>[] = [
{
...defaultField,
fieldType: { type: 'select', options: [] },
fieldMetadataType: FieldMetadataType.SELECT,
},
];
const result = generateExampleRow(fields);
expect(result).toStrictEqual([{ defaultField: 'Options' }]);
});
it('should generate an example row with provided example values for fields', () => {
const fields: SpreadsheetImportField<'defaultField'>[] = [
{
...defaultField,
example: 'Example',
fieldMetadataType: FieldMetadataType.TEXT,
},
];
const result = generateExampleRow(fields);
expect(result).toStrictEqual([{ defaultField: 'Example' }]);
});
});

View File

@ -0,0 +1,18 @@
import { isString } from '@sniptt/guards';
export const escapeCSVValue = (value: any) => {
if (value == null) return '';
const stringValue = isString(value) ? value : JSON.stringify(value);
if (
stringValue.includes(',') ||
stringValue.includes('"') ||
stringValue.includes('\n') ||
stringValue.includes('\r')
) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
};

View File

@ -1,26 +0,0 @@
import {
SpreadsheetImportField,
SpreadsheetImportFields,
} from '@/spreadsheet-import/types';
const titleMap: Record<
SpreadsheetImportField<string>['fieldType']['type'],
string
> = {
checkbox: 'Boolean',
select: 'Options',
multiSelect: 'Options',
input: 'Text',
};
export const generateExampleRow = <T extends string>(
fields: SpreadsheetImportFields<T>,
) => [
fields.reduce(
(acc, field) => {
acc[field.key as T] = field.example || titleMap[field.fieldType.type];
return acc;
},
{} as Record<T, string>,
),
];