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:
@ -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)}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const SpreadsheetMaxRecordImportCapacity = 2000;
|
||||
@ -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>(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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' }]);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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>,
|
||||
),
|
||||
];
|
||||
Reference in New Issue
Block a user