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:
@ -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;
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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} />
|
||||
))}
|
||||
|
||||
@ -64,7 +64,7 @@ export const ActionMenuDropdown = () => {
|
||||
return (
|
||||
<StyledContainerActionMenuDropdown
|
||||
position={actionMenuDropdownPosition}
|
||||
className="context-menu"
|
||||
className="action-menu-dropdown"
|
||||
>
|
||||
<Dropdown
|
||||
dropdownId={`action-menu-dropdown-${actionMenuId}`}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user