7338 refactor actionbar and contextmenu to use the context store (#7462)
Closes #7338
This commit is contained in:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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}`);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const ActionMenuComponentInstanceContext =
|
||||
createComponentInstanceContext();
|
||||
@ -0,0 +1,3 @@
|
||||
export enum ActionBarHotkeyScope {
|
||||
ActionBar = 'action-bar',
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export type ActionBarItemAccent = 'standard' | 'danger';
|
||||
@ -0,0 +1,3 @@
|
||||
export enum ActionMenuDropdownHotkeyScope {
|
||||
ActionMenuDropdown = 'action-menu-dropdown',
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
export type PositionType = {
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
};
|
||||
Reference in New Issue
Block a user