7665 handle the select all case inside the action menu (#7742)

Closes #7665 
- Handle select all
- Handle Filters

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2024-10-21 14:22:03 +02:00
committed by GitHub
parent eaab2d0dd2
commit 40152d3b92
52 changed files with 788 additions and 427 deletions

View File

@ -1,51 +1,91 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { contextStoreNumberOfSelectedRecordsState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsState';
import { contextStoreTargetedRecordsRuleState } from '@/context-store/states/contextStoreTargetedRecordsRuleState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useFetchAllRecordIds } from '@/object-record/hooks/useFetchAllRecordIds';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useCallback, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { IconTrash } from 'twenty-ui';
import { IconTrash, isDefined } from 'twenty-ui';
export const DeleteRecordsActionEffect = ({
position,
objectMetadataItem,
}: {
position: number;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
);
const contextStoreCurrentObjectMetadataId = useRecoilValue(
contextStoreCurrentObjectMetadataIdState,
);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataId,
});
const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] =
useState(false);
const { deleteTableData } = useDeleteTableData({
objectNameSingular: objectMetadataItem?.nameSingular ?? '',
recordIndexId: objectMetadataItem?.namePlural ?? '',
const { resetTableRowSelection } = useRecordTable({
recordTableId: objectMetadataItem.namePlural,
});
const handleDeleteClick = useCallback(() => {
deleteTableData(contextStoreTargetedRecordIds);
}, [deleteTableData, contextStoreTargetedRecordIds]);
const { deleteManyRecords } = useDeleteManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
});
const isRemoteObject = objectMetadataItem?.isRemote ?? false;
const { favorites, deleteFavorite } = useFavorites();
const numberOfSelectedRecords = contextStoreTargetedRecordIds.length;
const contextStoreNumberOfSelectedRecords = useRecoilValue(
contextStoreNumberOfSelectedRecordsState,
);
const contextStoreTargetedRecordsRule = useRecoilValue(
contextStoreTargetedRecordsRuleState,
);
const graphqlFilter = computeContextStoreFilters(
contextStoreTargetedRecordsRule,
objectMetadataItem,
);
const { fetchAllRecordIds } = useFetchAllRecordIds({
objectNameSingular: objectMetadataItem.nameSingular,
filter: graphqlFilter,
});
const handleDeleteClick = useCallback(async () => {
const recordIdsToDelete = await fetchAllRecordIds();
resetTableRowSelection();
for (const recordIdToDelete of recordIdsToDelete) {
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordIdToDelete,
);
if (foundFavorite !== undefined) {
deleteFavorite(foundFavorite.id);
}
}
await deleteManyRecords(recordIdsToDelete, {
delayInMsBetweenRequests: 50,
});
}, [
deleteFavorite,
deleteManyRecords,
favorites,
fetchAllRecordIds,
resetTableRowSelection,
]);
const isRemoteObject = objectMetadataItem.isRemote;
const canDelete =
!isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT;
!isRemoteObject &&
isDefined(contextStoreNumberOfSelectedRecords) &&
contextStoreNumberOfSelectedRecords < DELETE_MAX_COUNT &&
contextStoreNumberOfSelectedRecords > 0;
useEffect(() => {
if (canDelete) {
@ -62,17 +102,19 @@ export const DeleteRecordsActionEffect = ({
<ConfirmationModal
isOpen={isDeleteRecordsModalOpen}
setIsOpen={setIsDeleteRecordsModalOpen}
title={`Delete ${numberOfSelectedRecords} ${
numberOfSelectedRecords === 1 ? `record` : 'records'
title={`Delete ${contextStoreNumberOfSelectedRecords} ${
contextStoreNumberOfSelectedRecords === 1 ? `record` : 'records'
}`}
subtitle={`Are you sure you want to delete ${
numberOfSelectedRecords === 1 ? 'this record' : 'these records'
contextStoreNumberOfSelectedRecords === 1
? 'this record'
: 'these records'
}? ${
numberOfSelectedRecords === 1 ? 'It' : 'They'
contextStoreNumberOfSelectedRecords === 1 ? 'It' : 'They'
} can be recovered from the Options menu.`}
onConfirmClick={() => handleDeleteClick()}
deleteButtonText={`Delete ${
numberOfSelectedRecords > 1 ? 'Records' : 'Record'
contextStoreNumberOfSelectedRecords > 1 ? 'Records' : 'Record'
}`}
/>
),
@ -80,14 +122,18 @@ export const DeleteRecordsActionEffect = ({
} else {
removeActionMenuEntry('delete');
}
return () => {
removeActionMenuEntry('delete');
};
}, [
canDelete,
addActionMenuEntry,
removeActionMenuEntry,
isDeleteRecordsModalOpen,
numberOfSelectedRecords,
canDelete,
contextStoreNumberOfSelectedRecords,
handleDeleteClick,
isDeleteRecordsModalOpen,
position,
removeActionMenuEntry,
]);
return null;

View File

@ -1,38 +1,27 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import {
displayedExportProgress,
useExportTableData,
} from '@/object-record/record-index/options/hooks/useExportTableData';
useExportRecordData,
} from '@/action-menu/hooks/useExportRecordData';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { IconFileExport } from 'twenty-ui';
export const ExportRecordsActionEffect = ({
position,
objectMetadataItem,
}: {
position: number;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreCurrentObjectMetadataId = useRecoilValue(
contextStoreCurrentObjectMetadataIdState,
);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataId,
});
const baseTableDataParams = {
const { progress, download } = useExportRecordData({
delayMs: 100,
objectNameSingular: objectMetadataItem?.nameSingular ?? '',
recordIndexId: objectMetadataItem?.namePlural ?? '',
};
const { progress, download } = useExportTableData({
...baseTableDataParams,
filename: `${objectMetadataItem?.nameSingular}.csv`,
objectMetadataItem,
recordIndexId: objectMetadataItem.namePlural,
filename: `${objectMetadataItem.nameSingular}.csv`,
});
useEffect(() => {

View File

@ -1,8 +1,7 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { contextStoreTargetedRecordsRuleState } from '@/context-store/states/contextStoreTargetedRecordsRuleState';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
@ -10,30 +9,28 @@ import { IconHeart, IconHeartOff, isDefined } from 'twenty-ui';
export const ManageFavoritesActionEffect = ({
position,
objectMetadataItem,
}: {
position: number;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
);
const contextStoreCurrentObjectMetadataId = useRecoilValue(
contextStoreCurrentObjectMetadataIdState,
const contextStoreTargetedRecordsRule = useRecoilValue(
contextStoreTargetedRecordsRuleState,
);
const { favorites, createFavorite, deleteFavorite } = useFavorites();
const selectedRecordId = contextStoreTargetedRecordIds[0];
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const selectedRecord = useRecoilValue(
recordStoreFamilyState(selectedRecordId),
recordStoreFamilyState(selectedRecordId ?? ''),
);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataId,
});
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === selectedRecordId,
);

View File

@ -1,13 +1,22 @@
import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect';
import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
const actionEffects = [ExportRecordsActionEffect, DeleteRecordsActionEffect];
export const MultipleRecordsActionMenuEntriesSetter = () => {
export const MultipleRecordsActionMenuEntriesSetter = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
return (
<>
{actionEffects.map((ActionEffect, index) => (
<ActionEffect key={index} position={index} />
<ActionEffect
key={index}
position={index}
objectMetadataItem={objectMetadataItem}
/>
))}
</>
);

View File

@ -1,20 +1,44 @@
import { MultipleRecordsActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter';
import { SingleRecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
import { contextStoreNumberOfSelectedRecordsState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useRecoilValue } from 'recoil';
export const RecordActionMenuEntriesSetter = () => {
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
const contextStoreNumberOfSelectedRecords = useRecoilValue(
contextStoreNumberOfSelectedRecordsState,
);
if (contextStoreTargetedRecordIds.length === 0) {
const contextStoreCurrentObjectMetadataId = useRecoilValue(
contextStoreCurrentObjectMetadataIdState,
);
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataId ?? '',
});
if (!objectMetadataItem) {
throw new Error(
`Object metadata item not found for id ${contextStoreCurrentObjectMetadataId}`,
);
}
if (!contextStoreNumberOfSelectedRecords) {
return null;
}
if (contextStoreTargetedRecordIds.length === 1) {
return <SingleRecordActionMenuEntriesSetter />;
if (contextStoreNumberOfSelectedRecords === 1) {
return (
<SingleRecordActionMenuEntriesSetter
objectMetadataItem={objectMetadataItem}
/>
);
}
return <MultipleRecordsActionMenuEntriesSetter />;
return (
<MultipleRecordsActionMenuEntriesSetter
objectMetadataItem={objectMetadataItem}
/>
);
};

View File

@ -1,8 +1,13 @@
import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect';
import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect';
import { ManageFavoritesActionEffect } from '@/action-menu/actions/record-actions/components/ManageFavoritesActionEffect';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const SingleRecordActionMenuEntriesSetter = () => {
export const SingleRecordActionMenuEntriesSetter = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const actionEffects = [
ManageFavoritesActionEffect,
ExportRecordsActionEffect,
@ -11,7 +16,11 @@ export const SingleRecordActionMenuEntriesSetter = () => {
return (
<>
{actionEffects.map((ActionEffect, index) => (
<ActionEffect key={index} position={index} />
<ActionEffect
key={index}
position={index}
objectMetadataItem={objectMetadataItem}
/>
))}
</>
);

View File

@ -0,0 +1,30 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown';
import { ActionMenuEffect } from '@/action-menu/components/ActionMenuEffect';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
import { useRecoilValue } from 'recoil';
export const ActionMenu = ({ actionMenuId }: { actionMenuId: string }) => {
const contextStoreCurrentObjectMetadataId = useRecoilValue(
contextStoreCurrentObjectMetadataIdState,
);
return (
<>
{contextStoreCurrentObjectMetadataId && (
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: actionMenuId }}
>
<ActionMenuBar />
<ActionMenuDropdown />
<ActionMenuConfirmationModals />
<ActionMenuEffect />
<RecordActionMenuEntriesSetter />
</ActionMenuComponentInstanceContext.Provider>
)}
</>
);
};

View File

@ -4,7 +4,7 @@ import { ActionMenuBarEntry } from '@/action-menu/components/ActionMenuBarEntry'
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionBarHotkeyScope } from '@/action-menu/types/ActionBarHotKeyScope';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { contextStoreNumberOfSelectedRecordsState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsState';
import { BottomBar } from '@/ui/layout/bottom-bar/components/BottomBar';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@ -19,8 +19,8 @@ const StyledLabel = styled.div`
`;
export const ActionMenuBar = () => {
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
const contextStoreNumberOfSelectedRecords = useRecoilValue(
contextStoreNumberOfSelectedRecordsState,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
@ -42,9 +42,7 @@ export const ActionMenuBar = () => {
scope: ActionBarHotkeyScope.ActionBar,
}}
>
<StyledLabel>
{contextStoreTargetedRecordIds.length} selected:
</StyledLabel>
<StyledLabel>{contextStoreNumberOfSelectedRecords} selected:</StyledLabel>
{actionMenuEntries.map((entry, index) => (
<ActionMenuBarEntry key={index} entry={entry} />
))}

View File

@ -64,7 +64,7 @@ export const ActionMenuDropdown = () => {
return (
<StyledContainerActionMenuDropdown
position={actionMenuDropdownPosition}
className="context-menu"
className="action-menu-dropdown"
>
<Dropdown
dropdownId={`action-menu-dropdown-${actionMenuId}`}

View File

@ -1,6 +1,6 @@
import { useActionMenu } from '@/action-menu/hooks/useActionMenu';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { contextStoreNumberOfSelectedRecordsState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsState';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
@ -8,8 +8,8 @@ import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
export const ActionMenuEffect = () => {
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
const contextStoreNumberOfSelectedRecords = useRecoilValue(
contextStoreNumberOfSelectedRecordsState,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
@ -26,17 +26,17 @@ export const ActionMenuEffect = () => {
);
useEffect(() => {
if (contextStoreTargetedRecordIds.length > 0 && !isDropdownOpen) {
if (contextStoreNumberOfSelectedRecords > 0 && !isDropdownOpen) {
// We only handle opening the ActionMenuBar here, not the Dropdown.
// The Dropdown is already managed by sync handlers for events like
// right-click to open and click outside to close.
openActionBar();
}
if (contextStoreTargetedRecordIds.length === 0) {
if (contextStoreNumberOfSelectedRecords === 0 && isDropdownOpen) {
closeActionBar();
}
}, [
contextStoreTargetedRecordIds,
contextStoreNumberOfSelectedRecords,
openActionBar,
closeActionBar,
isDropdownOpen,

View File

@ -5,7 +5,8 @@ import { RecoilRoot } from 'recoil';
import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
import { contextStoreNumberOfSelectedRecordsState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsState';
import { contextStoreTargetedRecordsRuleState } from '@/context-store/states/contextStoreTargetedRecordsRuleState';
import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState';
import { userEvent, waitFor, within } from '@storybook/test';
import { IconCheckbox, IconTrash } from 'twenty-ui';
@ -20,7 +21,11 @@ const meta: Meta<typeof ActionMenuBar> = {
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set(contextStoreTargetedRecordIdsState, ['1', '2', '3']);
set(contextStoreTargetedRecordsRuleState, {
mode: 'selection',
selectedRecordIds: ['1', '2', '3'],
});
set(contextStoreNumberOfSelectedRecordsState, 3);
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',

View File

@ -0,0 +1,107 @@
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import {
csvDownloader,
displayedExportProgress,
download,
generateCsv,
} from '../useExportRecordData';
jest.useFakeTimers();
describe('download', () => {
it('creates a download link and clicks it', () => {
const link = document.createElement('a');
document.createElement = jest.fn().mockReturnValue(link);
const appendChild = jest.spyOn(document.body, 'appendChild');
const click = jest.spyOn(link, 'click');
URL.createObjectURL = jest.fn().mockReturnValue('fake-url');
download(new Blob(['test'], { type: 'text/plain' }), 'test.txt');
expect(appendChild).toHaveBeenCalledWith(link);
expect(link.href).toEqual('http://localhost/fake-url');
expect(link.getAttribute('download')).toEqual('test.txt');
expect(click).toHaveBeenCalledTimes(1);
});
});
describe('generateCsv', () => {
it('generates a csv with formatted headers', async () => {
const columns = [
{ label: 'Foo', metadata: { fieldName: 'foo' } },
{ label: 'Empty', metadata: { fieldName: 'empty' } },
{ label: 'Nested', metadata: { fieldName: 'nested' } },
{
label: 'Relation',
metadata: {
fieldName: 'relation',
relationType: RelationDefinitionType.ManyToOne,
},
},
] as ColumnDefinition<FieldMetadata>[];
const rows = [
{
id: '1',
bar: 'another field',
empty: null,
foo: 'some field',
nested: { __typename: 'type', foo: 'foo', nested: 'nested' },
relation: 'a relation',
},
];
const csv = generateCsv({ columns, rows });
expect(csv).toEqual(`Id,Foo,Empty,Nested Foo,Nested Nested,Relation
1,some field,,foo,nested,a relation`);
});
});
describe('csvDownloader', () => {
it('downloads a csv', () => {
const filename = 'test.csv';
const data = {
rows: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Alice' },
],
columns: [],
objectNameSingular: '',
};
const link = document.createElement('a');
document.createElement = jest.fn().mockReturnValue(link);
const createObjectURL = jest.spyOn(URL, 'createObjectURL');
csvDownloader(filename, data);
expect(link.getAttribute('download')).toEqual('test.csv');
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
expect(createObjectURL).toHaveBeenCalledWith(
expect.objectContaining({ type: 'text/csv' }),
);
});
});
describe('displayedExportProgress', () => {
it.each([
[undefined, undefined, 'percentage', 'Export'],
[20, 50, 'percentage', 'Export (40%)'],
[0, 100, 'number', 'Export (0)'],
[10, 10, 'percentage', 'Export (100%)'],
[10, 10, 'number', 'Export (10)'],
[7, 9, 'percentage', 'Export (78%)'],
])(
'displays the export progress',
(exportedRecordCount, totalRecordCount, displayType, expected) => {
expect(
displayedExportProgress({
exportedRecordCount,
totalRecordCount,
displayType: displayType as 'percentage' | 'number',
}),
).toEqual(expected);
},
);
});

View File

@ -0,0 +1,176 @@
import { json2csv } from 'json-2-csv';
import { useMemo } from 'react';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/record-index/options/constants/ExportTableDataDefaultPageSize';
import { useProcessRecordsForCSVExport } from '@/object-record/record-index/options/hooks/useProcessRecordsForCSVExport';
import {
UseRecordDataOptions,
useRecordData,
} from '@/object-record/record-index/options/hooks/useRecordData';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const download = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
};
type GenerateExportOptions = {
columns: ColumnDefinition<FieldMetadata>[];
rows: object[];
};
type GenerateExport = (data: GenerateExportOptions) => string;
type ExportProgress = {
exportedRecordCount?: number;
totalRecordCount?: number;
displayType: 'percentage' | 'number';
};
export const generateCsv: GenerateExport = ({
columns,
rows,
}: GenerateExportOptions): string => {
const columnsToExport = columns.filter(
(col) =>
!('relationType' in col.metadata && col.metadata.relationType) ||
col.metadata.relationType === RelationDefinitionType.ManyToOne,
);
const objectIdColumn: ColumnDefinition<FieldMetadata> = {
fieldMetadataId: '',
type: FieldMetadataType.Uuid,
iconName: '',
label: `Id`,
metadata: {
fieldName: 'id',
},
position: 0,
size: 0,
};
const columnsToExportWithIdColumn = [objectIdColumn, ...columnsToExport];
const keys = columnsToExportWithIdColumn.flatMap((col) => {
const column = {
field: `${col.metadata.fieldName}${col.type === 'RELATION' ? 'Id' : ''}`,
title: [col.label, col.type === 'RELATION' ? 'Id' : null]
.filter(isDefined)
.join(' '),
};
const fieldsWithSubFields = rows.find((row) => {
const fieldValue = (row as any)[column.field];
const hasSubFields =
fieldValue &&
typeof fieldValue === 'object' &&
!Array.isArray(fieldValue);
return hasSubFields;
});
if (isDefined(fieldsWithSubFields)) {
const nestedFieldsWithoutTypename = Object.keys(
(fieldsWithSubFields as any)[column.field],
)
.filter((key) => key !== '__typename')
.map((key) => ({
field: `${column.field}.${key}`,
title: `${column.title} ${key[0].toUpperCase() + key.slice(1)}`,
}));
return nestedFieldsWithoutTypename;
}
return [column];
});
return json2csv(rows, {
keys,
emptyFieldValue: '',
});
};
const percentage = (part: number, whole: number): number => {
return Math.round((part / whole) * 100);
};
export const displayedExportProgress = (progress?: ExportProgress): string => {
if (isUndefinedOrNull(progress?.exportedRecordCount)) {
return 'Export';
}
if (
progress.displayType === 'percentage' &&
isDefined(progress?.totalRecordCount)
) {
return `Export (${percentage(
progress.exportedRecordCount,
progress.totalRecordCount,
)}%)`;
}
return `Export (${progress.exportedRecordCount})`;
};
const downloader = (mimeType: string, generator: GenerateExport) => {
return (filename: string, data: GenerateExportOptions) => {
const blob = new Blob([generator(data)], { type: mimeType });
download(blob, filename);
};
};
export const csvDownloader = downloader('text/csv', generateCsv);
type UseExportTableDataOptions = Omit<UseRecordDataOptions, 'callback'> & {
filename: string;
};
export const useExportRecordData = ({
delayMs,
filename,
maximumRequests = 100,
objectMetadataItem,
pageSize = EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE,
recordIndexId,
viewType,
}: UseExportTableDataOptions) => {
const { processRecordsForCSVExport } = useProcessRecordsForCSVExport(
objectMetadataItem.nameSingular,
);
const downloadCsv = useMemo(
() =>
(records: ObjectRecord[], columns: ColumnDefinition<FieldMetadata>[]) => {
const recordsProcessedForExport = processRecordsForCSVExport(records);
csvDownloader(filename, { rows: recordsProcessedForExport, columns });
},
[filename, processRecordsForCSVExport],
);
const { getTableData: download, progress } = useRecordData({
delayMs,
maximumRequests,
objectMetadataItem,
pageSize,
recordIndexId,
callback: downloadCsv,
viewType,
});
return { progress, download };
};