Refactor action menu (#7586)

Introduces effects to set the actionMenuEntries
This commit is contained in:
Raphaël Bosi 2024-10-11 15:25:35 +02:00 committed by GitHub
parent 9b9b34f991
commit 3761fbf86f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 447 additions and 319 deletions

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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} />
))}
</>
);
};

View File

@ -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 />;
};

View File

@ -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} />
))}
</>
);
};

View File

@ -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}`}

View File

@ -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 (

View File

@ -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',

View File

@ -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 />
)}
</>
);
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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({

View File

@ -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,
},

View File

@ -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(

View File

@ -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,
};
};

View File

@ -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,
},
]
: []),
],
};
};

View File

@ -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 };
};

View File

@ -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),
});

View File

@ -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,
});

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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 {

View File

@ -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 />

View File

@ -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,

View File

@ -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);
};