mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-24 12:34:10 +03:00
Refactor action menu (#7586)
Introduces effects to set the actionMenuEntries
This commit is contained in:
parent
9b9b34f991
commit
3761fbf86f
@ -0,0 +1,94 @@
|
||||
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 { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
|
||||
import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconTrash } from 'twenty-ui';
|
||||
|
||||
export const DeleteRecordsActionEffect = ({
|
||||
position,
|
||||
}: {
|
||||
position: number;
|
||||
}) => {
|
||||
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 handleDeleteClick = useCallback(() => {
|
||||
deleteTableData(contextStoreTargetedRecordIds);
|
||||
}, [deleteTableData, contextStoreTargetedRecordIds]);
|
||||
|
||||
const isRemoteObject = objectMetadataItem?.isRemote ?? false;
|
||||
|
||||
const numberOfSelectedRecords = contextStoreTargetedRecordIds.length;
|
||||
|
||||
const canDelete =
|
||||
!isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT;
|
||||
|
||||
useEffect(() => {
|
||||
if (canDelete) {
|
||||
addActionMenuEntry({
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
position,
|
||||
Icon: IconTrash,
|
||||
accent: 'danger',
|
||||
onClick: () => {
|
||||
setIsDeleteRecordsModalOpen(true);
|
||||
},
|
||||
ConfirmationModal: (
|
||||
<ConfirmationModal
|
||||
isOpen={isDeleteRecordsModalOpen}
|
||||
setIsOpen={setIsDeleteRecordsModalOpen}
|
||||
title={`Delete ${numberOfSelectedRecords} ${
|
||||
numberOfSelectedRecords === 1 ? `record` : 'records'
|
||||
}`}
|
||||
subtitle={`Are you sure you want to delete ${
|
||||
numberOfSelectedRecords === 1 ? 'this record' : 'these records'
|
||||
}? ${
|
||||
numberOfSelectedRecords === 1 ? 'It' : 'They'
|
||||
} can be recovered from the Options menu.`}
|
||||
onConfirmClick={() => handleDeleteClick()}
|
||||
deleteButtonText={`Delete ${
|
||||
numberOfSelectedRecords > 1 ? 'Records' : 'Record'
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
removeActionMenuEntry('delete');
|
||||
}
|
||||
}, [
|
||||
canDelete,
|
||||
addActionMenuEntry,
|
||||
removeActionMenuEntry,
|
||||
isDeleteRecordsModalOpen,
|
||||
numberOfSelectedRecords,
|
||||
handleDeleteClick,
|
||||
position,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
@ -0,0 +1,53 @@
|
||||
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';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconFileExport } from 'twenty-ui';
|
||||
|
||||
export const ExportRecordsActionEffect = ({
|
||||
position,
|
||||
}: {
|
||||
position: number;
|
||||
}) => {
|
||||
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
|
||||
|
||||
const contextStoreCurrentObjectMetadataId = useRecoilValue(
|
||||
contextStoreCurrentObjectMetadataIdState,
|
||||
);
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItemById({
|
||||
objectId: contextStoreCurrentObjectMetadataId,
|
||||
});
|
||||
|
||||
const baseTableDataParams = {
|
||||
delayMs: 100,
|
||||
objectNameSingular: objectMetadataItem?.nameSingular ?? '',
|
||||
recordIndexId: objectMetadataItem?.namePlural ?? '',
|
||||
};
|
||||
|
||||
const { progress, download } = useExportTableData({
|
||||
...baseTableDataParams,
|
||||
filename: `${objectMetadataItem?.nameSingular}.csv`,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addActionMenuEntry({
|
||||
key: 'export',
|
||||
position,
|
||||
label: displayedExportProgress(progress),
|
||||
Icon: IconFileExport,
|
||||
accent: 'default',
|
||||
onClick: () => download(),
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeActionMenuEntry('export');
|
||||
};
|
||||
}, [download, progress, addActionMenuEntry, removeActionMenuEntry, position]);
|
||||
return null;
|
||||
};
|
@ -0,0 +1,78 @@
|
||||
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
|
||||
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
|
||||
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
|
||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconHeart, IconHeartOff, isDefined } from 'twenty-ui';
|
||||
|
||||
export const ManageFavoritesActionEffect = ({
|
||||
position,
|
||||
}: {
|
||||
position: number;
|
||||
}) => {
|
||||
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
|
||||
|
||||
const contextStoreTargetedRecordIds = useRecoilValue(
|
||||
contextStoreTargetedRecordIdsState,
|
||||
);
|
||||
const contextStoreCurrentObjectMetadataId = useRecoilValue(
|
||||
contextStoreCurrentObjectMetadataIdState,
|
||||
);
|
||||
|
||||
const { favorites, createFavorite, deleteFavorite } = useFavorites();
|
||||
|
||||
const selectedRecordId = contextStoreTargetedRecordIds[0];
|
||||
|
||||
const selectedRecord = useRecoilValue(
|
||||
recordStoreFamilyState(selectedRecordId),
|
||||
);
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItemById({
|
||||
objectId: contextStoreCurrentObjectMetadataId,
|
||||
});
|
||||
|
||||
const foundFavorite = favorites?.find(
|
||||
(favorite) => favorite.recordId === selectedRecordId,
|
||||
);
|
||||
|
||||
const isFavorite = !!selectedRecordId && !!foundFavorite;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(objectMetadataItem) || objectMetadataItem.isRemote) {
|
||||
return;
|
||||
}
|
||||
|
||||
addActionMenuEntry({
|
||||
key: 'manage-favorites',
|
||||
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
|
||||
position,
|
||||
Icon: isFavorite ? IconHeartOff : IconHeart,
|
||||
onClick: () => {
|
||||
if (isFavorite && isDefined(foundFavorite?.id)) {
|
||||
deleteFavorite(foundFavorite.id);
|
||||
} else if (isDefined(selectedRecord)) {
|
||||
createFavorite(selectedRecord, objectMetadataItem.nameSingular);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeActionMenuEntry('manage-favorites');
|
||||
};
|
||||
}, [
|
||||
addActionMenuEntry,
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
foundFavorite?.id,
|
||||
isFavorite,
|
||||
objectMetadataItem,
|
||||
position,
|
||||
removeActionMenuEntry,
|
||||
selectedRecord,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect';
|
||||
import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect';
|
||||
|
||||
const actionEffects = [ExportRecordsActionEffect, DeleteRecordsActionEffect];
|
||||
|
||||
export const MultipleRecordsActionMenuEntriesSetter = () => {
|
||||
return (
|
||||
<>
|
||||
{actionEffects.map((ActionEffect, index) => (
|
||||
<ActionEffect key={index} position={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
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 { useRecoilValue } from 'recoil';
|
||||
|
||||
export const RecordActionMenuEntriesSetter = () => {
|
||||
const contextStoreTargetedRecordIds = useRecoilValue(
|
||||
contextStoreTargetedRecordIdsState,
|
||||
);
|
||||
|
||||
if (contextStoreTargetedRecordIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contextStoreTargetedRecordIds.length === 1) {
|
||||
return <SingleRecordActionMenuEntriesSetter />;
|
||||
}
|
||||
|
||||
return <MultipleRecordsActionMenuEntriesSetter />;
|
||||
};
|
@ -0,0 +1,18 @@
|
||||
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';
|
||||
|
||||
export const SingleRecordActionMenuEntriesSetter = () => {
|
||||
const actionEffects = [
|
||||
ExportRecordsActionEffect,
|
||||
DeleteRecordsActionEffect,
|
||||
ManageFavoritesActionEffect,
|
||||
];
|
||||
return (
|
||||
<>
|
||||
{actionEffects.map((ActionEffect, index) => (
|
||||
<ActionEffect key={index} position={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,7 +1,7 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { ActionMenuBarEntry } from '@/action-menu/components/ActionMenuBarEntry';
|
||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||
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';
|
||||
@ -28,9 +28,13 @@ export const ActionMenuBar = () => {
|
||||
);
|
||||
|
||||
const actionMenuEntries = useRecoilComponentValueV2(
|
||||
actionMenuEntriesComponentState,
|
||||
actionMenuEntriesComponentSelector,
|
||||
);
|
||||
|
||||
if (actionMenuEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomBar
|
||||
bottomBarId={`action-bar-${actionMenuId}`}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
|
||||
export const ActionMenuConfirmationModals = () => {
|
||||
const actionMenuEntries = useRecoilComponentValueV2(
|
||||
actionMenuEntriesComponentState,
|
||||
actionMenuEntriesComponentSelector,
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -4,7 +4,7 @@ import { useRecoilValue } from 'recoil';
|
||||
import { PositionType } from '../types/PositionType';
|
||||
|
||||
import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState';
|
||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
|
||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||
import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDropdownHotKeyScope';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
@ -36,7 +36,7 @@ const StyledContainerActionMenuDropdown = styled.div<StyledContainerProps>`
|
||||
|
||||
export const ActionMenuDropdown = () => {
|
||||
const actionMenuEntries = useRecoilComponentValueV2(
|
||||
actionMenuEntriesComponentState,
|
||||
actionMenuEntriesComponentSelector,
|
||||
);
|
||||
|
||||
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
|
||||
@ -50,6 +50,10 @@ export const ActionMenuDropdown = () => {
|
||||
),
|
||||
);
|
||||
|
||||
if (actionMenuEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
//TODO: remove this
|
||||
const width = actionMenuEntries.some(
|
||||
(actionMenuEntry) => actionMenuEntry.label === 'Remove from favorites',
|
||||
|
@ -1,25 +0,0 @@
|
||||
import { EmptyActionMenuEntriesEffect } from '@/action-menu/components/EmptyActionMenuEntriesEffect';
|
||||
import { NonEmptyActionMenuEntriesEffect } from '@/action-menu/components/NonEmptyActionMenuEntriesEffect';
|
||||
import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export const ActionMenuEntriesProvider = () => {
|
||||
//TODO: Refactor this
|
||||
const contextStoreCurrentObjectMetadataId = useRecoilValue(
|
||||
contextStoreCurrentObjectMetadataIdState,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextStoreCurrentObjectMetadataId ? (
|
||||
<NonEmptyActionMenuEntriesEffect
|
||||
contextStoreCurrentObjectMetadataId={
|
||||
contextStoreCurrentObjectMetadataId
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyActionMenuEntriesEffect />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,14 +0,0 @@
|
||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const EmptyActionMenuEntriesEffect = () => {
|
||||
const setActionMenuEntries = useSetRecoilComponentStateV2(
|
||||
actionMenuEntriesComponentState,
|
||||
);
|
||||
useEffect(() => {
|
||||
setActionMenuEntries([]);
|
||||
}, [setActionMenuEntries]);
|
||||
|
||||
return null;
|
||||
};
|
@ -1,28 +0,0 @@
|
||||
import { useComputeActionsBasedOnContextStore } from '@/action-menu/hooks/useComputeActionsBasedOnContextStore';
|
||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const NonEmptyActionMenuEntriesEffect = ({
|
||||
contextStoreCurrentObjectMetadataId,
|
||||
}: {
|
||||
contextStoreCurrentObjectMetadataId: string;
|
||||
}) => {
|
||||
const { objectMetadataItem } = useObjectMetadataItemById({
|
||||
objectId: contextStoreCurrentObjectMetadataId,
|
||||
});
|
||||
const { availableActionsInContext } = useComputeActionsBasedOnContextStore({
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const setActionMenuEntries = useSetRecoilComponentStateV2(
|
||||
actionMenuEntriesComponentState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActionMenuEntries(availableActionsInContext);
|
||||
}, [availableActionsInContext, setActionMenuEntries]);
|
||||
|
||||
return null;
|
||||
};
|
@ -25,18 +25,28 @@ const meta: Meta<typeof ActionMenuBar> = {
|
||||
actionMenuEntriesComponentState.atomFamily({
|
||||
instanceId: 'story-action-menu',
|
||||
}),
|
||||
[
|
||||
{
|
||||
label: 'Delete',
|
||||
Icon: IconTrash,
|
||||
onClick: deleteMock,
|
||||
},
|
||||
{
|
||||
label: 'Mark as done',
|
||||
Icon: IconCheckbox,
|
||||
onClick: markAsDoneMock,
|
||||
},
|
||||
],
|
||||
new Map([
|
||||
[
|
||||
'delete',
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
position: 0,
|
||||
Icon: IconTrash,
|
||||
onClick: deleteMock,
|
||||
},
|
||||
],
|
||||
[
|
||||
'markAsDone',
|
||||
{
|
||||
key: 'markAsDone',
|
||||
label: 'Mark as done',
|
||||
position: 1,
|
||||
Icon: IconCheckbox,
|
||||
onClick: markAsDoneMock,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
set(
|
||||
isBottomBarOpenedComponentState.atomFamily({
|
||||
|
@ -21,7 +21,9 @@ const markAsDoneMock = jest.fn();
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
entry: {
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
position: 0,
|
||||
Icon: IconTrash,
|
||||
onClick: deleteMock,
|
||||
},
|
||||
@ -31,7 +33,9 @@ export const Default: Story = {
|
||||
export const WithDangerAccent: Story = {
|
||||
args: {
|
||||
entry: {
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
position: 0,
|
||||
Icon: IconTrash,
|
||||
onClick: deleteMock,
|
||||
accent: 'danger',
|
||||
@ -42,7 +46,9 @@ export const WithDangerAccent: Story = {
|
||||
export const WithInteraction: Story = {
|
||||
args: {
|
||||
entry: {
|
||||
key: 'markAsDone',
|
||||
label: 'Mark as done',
|
||||
position: 0,
|
||||
Icon: IconCheckbox,
|
||||
onClick: markAsDoneMock,
|
||||
},
|
||||
|
@ -33,23 +33,38 @@ const meta: Meta<typeof ActionMenuDropdown> = {
|
||||
actionMenuEntriesComponentState.atomFamily({
|
||||
instanceId: 'story-action-menu',
|
||||
}),
|
||||
[
|
||||
{
|
||||
label: 'Delete',
|
||||
Icon: IconTrash,
|
||||
onClick: deleteMock,
|
||||
},
|
||||
{
|
||||
label: 'Mark as done',
|
||||
Icon: IconCheckbox,
|
||||
onClick: markAsDoneMock,
|
||||
},
|
||||
{
|
||||
label: 'Add to favorites',
|
||||
Icon: IconHeart,
|
||||
onClick: addToFavoritesMock,
|
||||
},
|
||||
],
|
||||
new Map([
|
||||
[
|
||||
'delete',
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
position: 0,
|
||||
Icon: IconTrash,
|
||||
onClick: deleteMock,
|
||||
},
|
||||
],
|
||||
[
|
||||
'markAsDone',
|
||||
{
|
||||
key: 'markAsDone',
|
||||
label: 'Mark as done',
|
||||
position: 1,
|
||||
Icon: IconCheckbox,
|
||||
onClick: markAsDoneMock,
|
||||
},
|
||||
],
|
||||
[
|
||||
'addToFavorites',
|
||||
{
|
||||
key: 'addToFavorites',
|
||||
label: 'Add to favorites',
|
||||
position: 2,
|
||||
Icon: IconHeart,
|
||||
onClick: addToFavoritesMock,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
set(
|
||||
extractComponentState(
|
||||
|
@ -0,0 +1,28 @@
|
||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
|
||||
export const useActionMenuEntries = () => {
|
||||
const setActionMenuEntries = useSetRecoilComponentStateV2(
|
||||
actionMenuEntriesComponentState,
|
||||
);
|
||||
|
||||
const addActionMenuEntry = (entry: ActionMenuEntry) => {
|
||||
setActionMenuEntries(
|
||||
(prevEntries) => new Map([...prevEntries, [entry.key, entry]]),
|
||||
);
|
||||
};
|
||||
|
||||
const removeActionMenuEntry = (key: string) => {
|
||||
setActionMenuEntries((prevEntries) => {
|
||||
const newMap = new Map(prevEntries);
|
||||
newMap.delete(key);
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
addActionMenuEntry,
|
||||
removeActionMenuEntry,
|
||||
};
|
||||
};
|
@ -1,150 +0,0 @@
|
||||
import { useHandleFavoriteButton } from '@/action-menu/hooks/useHandleFavoriteButton';
|
||||
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
|
||||
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
|
||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetadataReadOnly';
|
||||
import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount';
|
||||
import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData';
|
||||
import {
|
||||
displayedExportProgress,
|
||||
useExportTableData,
|
||||
} from '@/object-record/record-index/options/hooks/useExportTableData';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import {
|
||||
IconFileExport,
|
||||
IconHeart,
|
||||
IconHeartOff,
|
||||
IconTrash,
|
||||
isDefined,
|
||||
} from 'twenty-ui';
|
||||
|
||||
export const useComputeActionsBasedOnContextStore = ({
|
||||
objectMetadataItem,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
}) => {
|
||||
const contextStoreTargetedRecordIds = useRecoilValue(
|
||||
contextStoreTargetedRecordIdsState,
|
||||
);
|
||||
|
||||
const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const { handleFavoriteButtonClick } = useHandleFavoriteButton(
|
||||
contextStoreTargetedRecordIds,
|
||||
objectMetadataItem,
|
||||
);
|
||||
|
||||
const baseTableDataParams = {
|
||||
delayMs: 100,
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
recordIndexId: objectMetadataItem.namePlural,
|
||||
};
|
||||
|
||||
const { deleteTableData } = useDeleteTableData(baseTableDataParams);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
deleteTableData(contextStoreTargetedRecordIds);
|
||||
}, [deleteTableData, contextStoreTargetedRecordIds]);
|
||||
|
||||
const { progress, download } = useExportTableData({
|
||||
...baseTableDataParams,
|
||||
filename: `${objectMetadataItem.nameSingular}.csv`,
|
||||
});
|
||||
|
||||
const isRemote = objectMetadataItem.isRemote;
|
||||
|
||||
const numberOfSelectedRecords = contextStoreTargetedRecordIds.length;
|
||||
|
||||
const canDelete =
|
||||
!isObjectMetadataReadOnly(objectMetadataItem) &&
|
||||
numberOfSelectedRecords < DELETE_MAX_COUNT;
|
||||
|
||||
const menuActions: ActionMenuEntry[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
label: displayedExportProgress(progress),
|
||||
Icon: IconFileExport,
|
||||
accent: 'default',
|
||||
onClick: () => download(),
|
||||
} satisfies ActionMenuEntry,
|
||||
canDelete
|
||||
? ({
|
||||
label: 'Delete',
|
||||
Icon: IconTrash,
|
||||
accent: 'danger',
|
||||
onClick: () => {
|
||||
setIsDeleteRecordsModalOpen(true);
|
||||
},
|
||||
ConfirmationModal: (
|
||||
<ConfirmationModal
|
||||
isOpen={isDeleteRecordsModalOpen}
|
||||
setIsOpen={setIsDeleteRecordsModalOpen}
|
||||
title={`Delete ${numberOfSelectedRecords} ${
|
||||
numberOfSelectedRecords === 1 ? `record` : 'records'
|
||||
}`}
|
||||
subtitle={`Are you sure you want to delete ${
|
||||
numberOfSelectedRecords === 1
|
||||
? 'this record'
|
||||
: 'these records'
|
||||
}? ${
|
||||
numberOfSelectedRecords === 1 ? 'It' : 'They'
|
||||
} can be recovered from the Options menu.`}
|
||||
onConfirmClick={() => handleDeleteClick()}
|
||||
deleteButtonText={`Delete ${
|
||||
numberOfSelectedRecords > 1 ? 'Records' : 'Record'
|
||||
}`}
|
||||
/>
|
||||
),
|
||||
} satisfies ActionMenuEntry)
|
||||
: undefined,
|
||||
].filter(isDefined),
|
||||
[
|
||||
download,
|
||||
progress,
|
||||
canDelete,
|
||||
handleDeleteClick,
|
||||
isDeleteRecordsModalOpen,
|
||||
numberOfSelectedRecords,
|
||||
],
|
||||
);
|
||||
|
||||
const hasOnlyOneRecordSelected = contextStoreTargetedRecordIds.length === 1;
|
||||
|
||||
const { favorites } = useFavorites();
|
||||
|
||||
const isFavorite =
|
||||
isNonEmptyString(contextStoreTargetedRecordIds[0]) &&
|
||||
!!favorites?.find(
|
||||
(favorite) => favorite.recordId === contextStoreTargetedRecordIds[0],
|
||||
);
|
||||
|
||||
return {
|
||||
availableActionsInContext: [
|
||||
...menuActions,
|
||||
...(!isRemote && isFavorite && hasOnlyOneRecordSelected
|
||||
? [
|
||||
{
|
||||
label: 'Remove from favorites',
|
||||
Icon: IconHeartOff,
|
||||
onClick: handleFavoriteButtonClick,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isRemote && !isFavorite && hasOnlyOneRecordSelected
|
||||
? [
|
||||
{
|
||||
label: 'Add to favorites',
|
||||
Icon: IconHeart,
|
||||
onClick: handleFavoriteButtonClick,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
@ -1,49 +0,0 @@
|
||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const useHandleFavoriteButton = (
|
||||
selectedRecordIds: string[],
|
||||
objectMetadataItem: ObjectMetadataItem,
|
||||
callback?: () => void,
|
||||
) => {
|
||||
const { createFavorite, favorites, deleteFavorite } = useFavorites();
|
||||
|
||||
const handleFavoriteButtonClick = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
() => {
|
||||
if (selectedRecordIds.length > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRecordId = selectedRecordIds[0];
|
||||
const selectedRecord = snapshot
|
||||
.getLoadable(recordStoreFamilyState(selectedRecordId))
|
||||
.getValue();
|
||||
|
||||
const foundFavorite = favorites?.find(
|
||||
(favorite) => favorite.recordId === selectedRecordId,
|
||||
);
|
||||
|
||||
const isFavorite = !!selectedRecordId && !!foundFavorite;
|
||||
|
||||
if (isFavorite) {
|
||||
deleteFavorite(foundFavorite.id);
|
||||
} else if (isDefined(selectedRecord)) {
|
||||
createFavorite(selectedRecord, objectMetadataItem.nameSingular);
|
||||
}
|
||||
callback?.();
|
||||
},
|
||||
[
|
||||
callback,
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
favorites,
|
||||
objectMetadataItem.nameSingular,
|
||||
selectedRecordIds,
|
||||
],
|
||||
);
|
||||
return { handleFavoriteButtonClick };
|
||||
};
|
@ -0,0 +1,22 @@
|
||||
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
|
||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
|
||||
import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2';
|
||||
import { isDefined } from 'twenty-ui';
|
||||
|
||||
export const actionMenuEntriesComponentSelector = createComponentSelectorV2<
|
||||
ActionMenuEntry[]
|
||||
>({
|
||||
key: 'actionMenuEntriesComponentSelector',
|
||||
instanceContext: ActionMenuComponentInstanceContext,
|
||||
get:
|
||||
({ instanceId }) =>
|
||||
({ get }) =>
|
||||
Array.from(
|
||||
get(
|
||||
actionMenuEntriesComponentState.atomFamily({ instanceId }),
|
||||
).values(),
|
||||
)
|
||||
.filter(isDefined)
|
||||
.sort((a, b) => a.position - b.position),
|
||||
});
|
@ -3,9 +3,9 @@ import { createComponentStateV2 } from '@/ui/utilities/state/component-state/uti
|
||||
import { ActionMenuEntry } from '../types/ActionMenuEntry';
|
||||
|
||||
export const actionMenuEntriesComponentState = createComponentStateV2<
|
||||
ActionMenuEntry[]
|
||||
Map<string, ActionMenuEntry>
|
||||
>({
|
||||
key: 'actionMenuEntriesComponentState',
|
||||
defaultValue: [],
|
||||
defaultValue: new Map(),
|
||||
componentInstanceContext: ActionMenuComponentInstanceContext,
|
||||
});
|
||||
|
@ -4,7 +4,9 @@ import { IconComponent } from 'twenty-ui';
|
||||
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
|
||||
|
||||
export type ActionMenuEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
position: number;
|
||||
Icon: IconComponent;
|
||||
accent?: MenuItemAccent;
|
||||
onClick?: (event?: MouseEvent<HTMLElement>) => void;
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||
@ -31,14 +30,19 @@ describe('useObjectMetadataItemById', () => {
|
||||
|
||||
const { objectMetadataItem } = result.current;
|
||||
|
||||
expect(objectMetadataItem.id).toBe(opportunityObjectMetadata.id);
|
||||
expect(objectMetadataItem?.id).toBe(opportunityObjectMetadata.id);
|
||||
});
|
||||
|
||||
it('should throw an error when invalid ID is provided', async () => {
|
||||
expect(() =>
|
||||
renderHook(() => useObjectMetadataItemById({ objectId: 'invalid-id' }), {
|
||||
it('should return null when invalid ID is provided', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useObjectMetadataItemById({ objectId: 'invalid-id' }),
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
}),
|
||||
).toThrow(ObjectMetadataItemNotFoundError);
|
||||
},
|
||||
);
|
||||
|
||||
const { objectMetadataItem } = result.current;
|
||||
|
||||
expect(objectMetadataItem).toBeNull();
|
||||
});
|
||||
});
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const useObjectMetadataItemById = ({
|
||||
objectId,
|
||||
}: {
|
||||
objectId: string;
|
||||
objectId: string | null;
|
||||
}) => {
|
||||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
||||
|
||||
@ -16,7 +15,9 @@ export const useObjectMetadataItemById = ({
|
||||
);
|
||||
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
throw new ObjectMetadataItemNotFoundError(objectId, objectMetadataItems);
|
||||
return {
|
||||
objectMetadataItem: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -24,11 +24,11 @@ import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-
|
||||
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
|
||||
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
|
||||
|
||||
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 { ActionMenuEntriesProvider } from '@/action-menu/components/ActionMenuEntriesProvider';
|
||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||
import { ViewBar } from '@/views/components/ViewBar';
|
||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||
@ -202,7 +202,7 @@ export const RecordIndexContainer = () => {
|
||||
value={{ instanceId: recordIndexId }}
|
||||
>
|
||||
<ActionMenuEffect />
|
||||
<ActionMenuEntriesProvider />
|
||||
<RecordActionMenuEntriesSetter />
|
||||
<ActionMenuBar />
|
||||
<ActionMenuDropdown />
|
||||
<ActionMenuConfirmationModals />
|
||||
|
@ -7,7 +7,10 @@ import { tableRowIdsComponentState } from '@/object-record/record-table/states/t
|
||||
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
type UseDeleteTableDataOptions = Omit<UseTableDataOptions, 'callback'>;
|
||||
type UseDeleteTableDataOptions = Pick<
|
||||
UseTableDataOptions,
|
||||
'objectNameSingular' | 'recordIndexId'
|
||||
>;
|
||||
|
||||
export const useDeleteTableData = ({
|
||||
objectNameSingular,
|
||||
|
@ -1,10 +1,15 @@
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { ComponentReadOnlySelectorV2 } from '@/ui/utilities/state/component-state/types/ComponentReadOnlySelectorV2';
|
||||
import { ComponentSelectorV2 } from '@/ui/utilities/state/component-state/types/ComponentSelectorV2';
|
||||
import { ComponentStateV2 } from '@/ui/utilities/state/component-state/types/ComponentStateV2';
|
||||
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { RecoilState, RecoilValueReadOnly, useRecoilValue } from 'recoil';
|
||||
|
||||
export const useRecoilComponentValueV2 = <StateType>(
|
||||
componentStateV2: ComponentStateV2<StateType>,
|
||||
componentStateV2:
|
||||
| ComponentStateV2<StateType>
|
||||
| ComponentSelectorV2<StateType>
|
||||
| ComponentReadOnlySelectorV2<StateType>,
|
||||
instanceIdFromProps?: string,
|
||||
) => {
|
||||
const instanceContext = globalComponentInstanceContextMap.get(
|
||||
@ -22,5 +27,18 @@ export const useRecoilComponentValueV2 = <StateType>(
|
||||
instanceIdFromProps,
|
||||
);
|
||||
|
||||
return useRecoilValue(componentStateV2.atomFamily({ instanceId }));
|
||||
let state: RecoilState<StateType> | RecoilValueReadOnly<StateType>;
|
||||
|
||||
if (componentStateV2.type === 'ComponentState') {
|
||||
state = componentStateV2.atomFamily({ instanceId });
|
||||
} else if (
|
||||
componentStateV2.type === 'ComponentSelector' ||
|
||||
componentStateV2.type === 'ComponentReadOnlySelector'
|
||||
) {
|
||||
state = componentStateV2.selectorFamily({ instanceId });
|
||||
} else {
|
||||
throw new Error('Invalid component state type');
|
||||
}
|
||||
|
||||
return useRecoilValue(state);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user