7338 refactor actionbar and contextmenu to use the context store (#7462)

Closes #7338
This commit is contained in:
Raphaël Bosi
2024-10-10 13:26:19 +02:00
committed by GitHub
parent 54c328a7e6
commit a7d5aa933d
84 changed files with 1481 additions and 954 deletions

View File

@ -0,0 +1,49 @@
import styled from '@emotion/styled';
import { ActionMenuBarEntry } from '@/action-menu/components/ActionMenuBarEntry';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionBarHotkeyScope } from '@/action-menu/types/ActionBarHotKeyScope';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
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';
import { useRecoilValue } from 'recoil';
const StyledLabel = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const ActionMenuBar = () => {
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return (
<BottomBar
bottomBarId={`action-bar-${actionMenuId}`}
bottomBarHotkeyScopeFromParent={{
scope: ActionBarHotkeyScope.ActionBar,
}}
>
<StyledLabel>
{contextStoreTargetedRecordIds.length} selected:
</StyledLabel>
{actionMenuEntries.map((entry, index) => (
<ActionMenuBarEntry key={index} entry={entry} />
))}
</BottomBar>
);
};

View File

@ -0,0 +1,49 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
type ActionMenuBarEntryProps = {
entry: ActionMenuEntry;
};
const StyledButton = styled.div<{ accent: MenuItemAccent }>`
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${(props) =>
props.accent === 'danger'
? props.theme.color.red
: props.theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background 0.1s ease;
user-select: none;
&:hover {
background: ${({ theme, accent }) =>
accent === 'danger'
? theme.background.danger
: theme.background.tertiary};
}
`;
const StyledButtonLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
export const ActionMenuBarEntry = ({ entry }: ActionMenuBarEntryProps) => {
const theme = useTheme();
return (
<StyledButton
accent={entry.accent ?? 'default'}
onClick={() => entry.onClick?.()}
>
{entry.Icon && <entry.Icon size={theme.icon.size.md} />}
<StyledButtonLabel>{entry.label}</StyledButtonLabel>
</StyledButton>
);
};

View File

@ -0,0 +1,18 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const ActionMenuConfirmationModals = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return (
<div data-select-disable>
{actionMenuEntries.map((actionMenuEntry, index) =>
actionMenuEntry.ConfirmationModal ? (
<div key={index}>{actionMenuEntry.ConfirmationModal}</div>
) : null,
)}
</div>
);
};

View File

@ -0,0 +1,84 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { PositionType } from '../types/PositionType';
import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDropdownHotKeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
type StyledContainerProps = {
position: PositionType;
};
const StyledContainerActionMenuDropdown = styled.div<StyledContainerProps>`
align-items: flex-start;
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
left: ${(props) => `${props.position.x}px`};
position: fixed;
top: ${(props) => `${props.position.y}px`};
transform: translateX(-50%);
width: auto;
`;
export const ActionMenuDropdown = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
const actionMenuDropdownPosition = useRecoilValue(
extractComponentState(
actionMenuDropdownPositionComponentState,
`action-menu-dropdown-${actionMenuId}`,
),
);
//TODO: remove this
const width = actionMenuEntries.some(
(actionMenuEntry) => actionMenuEntry.label === 'Remove from favorites',
)
? 200
: undefined;
return (
<StyledContainerActionMenuDropdown
position={actionMenuDropdownPosition}
className="context-menu"
>
<Dropdown
dropdownId={`action-menu-dropdown-${actionMenuId}`}
dropdownHotkeyScope={{
scope: ActionMenuDropdownHotkeyScope.ActionMenuDropdown,
}}
data-select-disable
dropdownMenuWidth={width}
dropdownComponents={actionMenuEntries.map((item, index) => (
<MenuItem
key={index}
LeftIcon={item.Icon}
onClick={item.onClick}
accent={item.accent}
text={item.label}
/>
))}
/>
</StyledContainerActionMenuDropdown>
);
};

View File

@ -0,0 +1,46 @@
import { useActionMenu } from '@/action-menu/hooks/useActionMenu';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState';
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';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
export const ActionMenuEffect = () => {
const contextStoreTargetedRecordIds = useRecoilValue(
contextStoreTargetedRecordIdsState,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
const { openActionBar, closeActionBar } = useActionMenu(actionMenuId);
const isDropdownOpen = useRecoilValue(
extractComponentState(
isDropdownOpenComponentState,
`action-menu-dropdown-${actionMenuId}`,
),
);
useEffect(() => {
if (contextStoreTargetedRecordIds.length > 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) {
closeActionBar();
}
}, [
contextStoreTargetedRecordIds,
openActionBar,
closeActionBar,
isDropdownOpen,
]);
return null;
};

View File

@ -0,0 +1,25 @@
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

@ -0,0 +1,14 @@
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

@ -0,0 +1,28 @@
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

@ -0,0 +1,101 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
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 { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState';
import { userEvent, waitFor, within } from '@storybook/test';
import { IconCheckbox, IconTrash } from 'twenty-ui';
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
const meta: Meta<typeof ActionMenuBar> = {
title: 'Modules/ActionMenu/ActionMenuBar',
component: ActionMenuBar,
decorators: [
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set(contextStoreTargetedRecordIdsState, ['1', '2', '3']);
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
[
{
label: 'Delete',
Icon: IconTrash,
onClick: deleteMock,
},
{
label: 'Mark as done',
Icon: IconCheckbox,
onClick: markAsDoneMock,
},
],
);
set(
isBottomBarOpenedComponentState.atomFamily({
instanceId: 'action-bar-story-action-menu',
}),
true,
);
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'story-action-menu' }}
>
<Story />
</ActionMenuComponentInstanceContext.Provider>
</RecoilRoot>
),
],
args: {
actionMenuId: 'story-action-menu',
},
};
export default meta;
type Story = StoryObj<typeof ActionMenuBar>;
export const Default: Story = {
args: {
actionMenuId: 'story-action-menu',
},
};
export const WithCustomSelection: Story = {
args: {
actionMenuId: 'story-action-menu',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectionText = await canvas.findByText('3 selected:');
expect(selectionText).toBeInTheDocument();
},
};
export const WithButtonClicks: Story = {
args: {
actionMenuId: 'story-action-menu',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
const markAsDoneButton = await canvas.findByText('Mark as done');
await userEvent.click(markAsDoneButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalled();
expect(markAsDoneMock).toHaveBeenCalled();
});
},
};

View File

@ -0,0 +1,56 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { ComponentDecorator, IconCheckbox, IconTrash } from 'twenty-ui';
import { ActionMenuBarEntry } from '../ActionMenuBarEntry';
const meta: Meta<typeof ActionMenuBarEntry> = {
title: 'Modules/ActionMenu/ActionMenuBarEntry',
component: ActionMenuBarEntry,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof ActionMenuBarEntry>;
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
export const Default: Story = {
args: {
entry: {
label: 'Delete',
Icon: IconTrash,
onClick: deleteMock,
},
},
};
export const WithDangerAccent: Story = {
args: {
entry: {
label: 'Delete',
Icon: IconTrash,
onClick: deleteMock,
accent: 'danger',
},
},
};
export const WithInteraction: Story = {
args: {
entry: {
label: 'Mark as done',
Icon: IconCheckbox,
onClick: markAsDoneMock,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByText('Mark as done');
await userEvent.click(button);
expect(markAsDoneMock).toHaveBeenCalled();
},
};

View File

@ -0,0 +1,102 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { RecoilRoot } from 'recoil';
import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown';
import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui';
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
const addToFavoritesMock = jest.fn();
const meta: Meta<typeof ActionMenuDropdown> = {
title: 'Modules/ActionMenu/ActionMenuDropdown',
component: ActionMenuDropdown,
decorators: [
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set(
extractComponentState(
actionMenuDropdownPositionComponentState,
'action-menu-dropdown-story',
),
{ x: 10, y: 10 },
);
set(
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,
},
],
);
set(
extractComponentState(
isDropdownOpenComponentState,
'action-menu-dropdown-story-action-menu',
),
true,
);
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'story-action-menu' }}
>
<Story />
</ActionMenuComponentInstanceContext.Provider>
</RecoilRoot>
),
],
};
export default meta;
type Story = StoryObj<typeof ActionMenuDropdown>;
export const Default: Story = {
args: {
actionMenuId: 'story',
},
};
export const WithInteractions: Story = {
args: {
actionMenuId: 'story',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
expect(deleteMock).toHaveBeenCalled();
const markAsDoneButton = await canvas.findByText('Mark as done');
await userEvent.click(markAsDoneButton);
expect(markAsDoneMock).toHaveBeenCalled();
const addToFavoritesButton = await canvas.findByText('Add to favorites');
await userEvent.click(addToFavoritesButton);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};

View File

@ -0,0 +1,83 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { useActionMenu } from '../useActionMenu';
const openBottomBar = jest.fn();
const closeBottomBar = jest.fn();
const openDropdown = jest.fn();
const closeDropdown = jest.fn();
jest.mock('@/ui/layout/bottom-bar/hooks/useBottomBar', () => ({
useBottomBar: jest.fn(() => ({
openBottomBar: openBottomBar,
closeBottomBar: closeBottomBar,
})),
}));
jest.mock('@/ui/layout/dropdown/hooks/useDropdownV2', () => ({
useDropdownV2: jest.fn(() => ({
openDropdown: openDropdown,
closeDropdown: closeDropdown,
})),
}));
describe('useActionMenu', () => {
const actionMenuId = 'test-action-menu';
it('should return the correct functions', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
expect(result.current).toHaveProperty('openActionMenuDropdown');
expect(result.current).toHaveProperty('openActionBar');
expect(result.current).toHaveProperty('closeActionBar');
expect(result.current).toHaveProperty('closeActionMenuDropdown');
});
it('should call the correct functions when opening action menu dropdown', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
act(() => {
result.current.openActionMenuDropdown();
});
expect(closeBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`);
expect(openDropdown).toHaveBeenCalledWith(
`action-menu-dropdown-${actionMenuId}`,
);
});
it('should call the correct functions when opening action bar', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
act(() => {
result.current.openActionBar();
});
expect(closeDropdown).toHaveBeenCalledWith(
`action-menu-dropdown-${actionMenuId}`,
);
expect(openBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`);
});
it('should call the correct function when closing action menu dropdown', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
act(() => {
result.current.closeActionMenuDropdown();
});
expect(closeDropdown).toHaveBeenCalledWith(
`action-menu-dropdown-${actionMenuId}`,
);
});
it('should call the correct function when closing action bar', () => {
const { result } = renderHook(() => useActionMenu(actionMenuId));
act(() => {
result.current.closeActionBar();
});
expect(closeBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`);
});
});

View File

@ -0,0 +1,32 @@
import { useBottomBar } from '@/ui/layout/bottom-bar/hooks/useBottomBar';
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
export const useActionMenu = (actionMenuId: string) => {
const { openDropdown, closeDropdown } = useDropdownV2();
const { openBottomBar, closeBottomBar } = useBottomBar();
const openActionMenuDropdown = () => {
closeBottomBar(`action-bar-${actionMenuId}`);
openDropdown(`action-menu-dropdown-${actionMenuId}`);
};
const openActionBar = () => {
closeDropdown(`action-menu-dropdown-${actionMenuId}`);
openBottomBar(`action-bar-${actionMenuId}`);
};
const closeActionMenuDropdown = () => {
closeDropdown(`action-menu-dropdown-${actionMenuId}`);
};
const closeActionBar = () => {
closeBottomBar(`action-bar-${actionMenuId}`);
};
return {
openActionMenuDropdown,
openActionBar,
closeActionBar,
closeActionMenuDropdown,
};
};

View File

@ -0,0 +1,148 @@
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 { 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 isRemoteObject = objectMetadataItem.isRemote;
const numberOfSelectedRecords = contextStoreTargetedRecordIds.length;
const canDelete =
!isRemoteObject && 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,
...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected
? [
{
label: 'Remove from favorites',
Icon: IconHeartOff,
onClick: handleFavoriteButtonClick,
},
]
: []),
...(!isRemoteObject && !isFavorite && hasOnlyOneRecordSelected
? [
{
label: 'Add to favorites',
Icon: IconHeart,
onClick: handleFavoriteButtonClick,
},
]
: []),
],
};
};

View File

@ -0,0 +1,49 @@
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,11 @@
import { PositionType } from '@/action-menu/types/PositionType';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const actionMenuDropdownPositionComponentState =
createComponentState<PositionType>({
key: 'actionMenuDropdownPositionComponentState',
defaultValue: {
x: null,
y: null,
},
});

View File

@ -0,0 +1,11 @@
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { ActionMenuEntry } from '../types/ActionMenuEntry';
export const actionMenuEntriesComponentState = createComponentStateV2<
ActionMenuEntry[]
>({
key: 'actionMenuEntriesComponentState',
defaultValue: [],
componentInstanceContext: ActionMenuComponentInstanceContext,
});

View File

@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const ActionMenuComponentInstanceContext =
createComponentInstanceContext();

View File

@ -0,0 +1,3 @@
export enum ActionBarHotkeyScope {
ActionBar = 'action-bar',
}

View File

@ -0,0 +1 @@
export type ActionBarItemAccent = 'standard' | 'danger';

View File

@ -0,0 +1,3 @@
export enum ActionMenuDropdownHotkeyScope {
ActionMenuDropdown = 'action-menu-dropdown',
}

View File

@ -0,0 +1,12 @@
import { MouseEvent, ReactNode } from 'react';
import { IconComponent } from 'twenty-ui';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
export type ActionMenuEntry = {
label: string;
Icon: IconComponent;
accent?: MenuItemAccent;
onClick?: (event?: MouseEvent<HTMLElement>) => void;
ConfirmationModal?: ReactNode;
};

View File

@ -0,0 +1,4 @@
export type PositionType = {
x: number | null;
y: number | null;
};