Action menu refactoring (#11454)

# Description

Closes [#696](https://github.com/twentyhq/core-team-issues/issues/696)

- `useAction` hooks have been removed for all actions
- Every action can now declare a react component
- Some standard action components have been introduced: `Action`,
`ActionLink` and `ActionModal`
- The `ActionDisplay` component uses the new `displayType` prop of the
`ActionMenuContext` to render the right component for the action
according to its container: `ActionButton`, `ActionDropdownItem` or
`ActionListItem`
- The `ActionDisplayer` wraps the action component inside a context
which gives it all the information about the action
-`actionMenuEntriesComponenState` has been removed and now all actions
are computed directly using `useRegisteredAction`
- This computation is done inside `ActionMenuContextProvider` and the
actions are passed inside a context
- `actionMenuType` gives information about the container of the action,
so the action can know wether or not to close this container upon
execution
This commit is contained in:
Raphaël Bosi
2025-04-09 15:12:49 +02:00
committed by GitHub
parent 1834b38d04
commit 9e0402e691
235 changed files with 6252 additions and 7590 deletions

View File

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

View File

@ -1,6 +1,7 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuEntryScope } from '@/action-menu/types/ActionMenuEntry';
import { CommandMenuActionMenuDropdownHotkeyScope } from '@/action-menu/types/CommandMenuActionMenuDropdownHotkeyScope';
import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@ -9,17 +10,13 @@ import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useTheme } from '@emotion/react';
import { i18n } from '@lingui/core';
import { useContext } from 'react';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
import { MenuItem } from 'twenty-ui/navigation';
export const CommandMenuActionMenuDropdown = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const { actions } = useContext(ActionMenuContext);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
@ -61,25 +58,10 @@ export const CommandMenuActionMenuDropdown = () => {
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
dropdownComponents={
<DropdownMenuItemsContainer>
{actionMenuEntries
.filter(
(actionMenuEntry) =>
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
)
.map((actionMenuEntry, index) => (
<MenuItem
key={index}
LeftIcon={actionMenuEntry.Icon}
onClick={() => {
toggleDropdown(
getRightDrawerActionMenuDropdownIdFromActionMenuId(
actionMenuId,
),
);
actionMenuEntry.onClick?.();
}}
text={i18n._(actionMenuEntry.label)}
/>
{actions
.filter((action) => action.scope === ActionScope.RecordSelection)
.map((action) => (
<ActionComponent action={action} key={action.key} />
))}
</DropdownMenuItemsContainer>
}

View File

@ -1,59 +1,13 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
import { i18n } from '@lingui/core';
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/display';
import { Button, IconButton } from 'twenty-ui/input';
const StyledWrapper = styled.div`
font-size: ${({ theme }) => theme.font.size.md};
`;
import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useContext } from 'react';
export const PageHeaderActionMenuButtons = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const { actions } = useContext(ActionMenuContext);
const pinnedEntries = actionMenuEntries.filter((entry) => entry.isPinned);
const pinnedActions = actions.filter((entry) => entry.isPinned);
return (
<>
{pinnedEntries.map((entry) =>
entry.shortLabel ? (
<Button
key={entry.key}
Icon={entry.Icon}
size="small"
variant="secondary"
accent="default"
title={entry.shortLabel ? i18n._(entry.shortLabel) : ''}
onClick={() => entry.onClick?.()}
ariaLabel={i18n._(entry.label)}
/>
) : (
<div id={`action-menu-entry-${entry.key}`} key={entry.key}>
<IconButton
Icon={entry.Icon}
size="small"
variant="secondary"
accent="default"
onClick={() => entry.onClick?.()}
ariaLabel={i18n._(entry.label)}
/>
<StyledWrapper>
<AppTooltip
// eslint-disable-next-line
anchorSelect={`#action-menu-entry-${entry.key}`}
content={i18n._(entry.label)}
delay={TooltipDelay.longDelay}
place={TooltipPosition.Bottom}
offset={5}
noArrow
/>
</StyledWrapper>
</div>
),
)}
</>
);
return pinnedActions.map((action) => (
<ActionComponent key={action.key} action={action} />
));
};

View File

@ -1,62 +1,36 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { RecordAgnosticActionMenuEntriesSetter } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionMenuEntriesSetter';
import { RunWorkflowRecordAgnosticActionMenuEntriesSetter } from '@/action-menu/actions/record-agnostic-actions/components/RunWorkflowRecordAgnosticActionMenuEntriesSetter';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { PageHeaderActionMenuButtons } from '@/action-menu/components/PageHeaderActionMenuButtons';
import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIndexActionMenuDropdown';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuContextProvider } from '@/action-menu/contexts/ActionMenuContextProvider';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { isRecordIndexLoadMoreLockedComponentState } from '@/object-record/record-index/states/isRecordIndexLoadMoreLockedComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordIndexActionMenu = ({ indexId }: { indexId: string }) => {
export const RecordIndexActionMenu = () => {
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const isWorkflowEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsWorkflowEnabled,
);
const isMobile = useIsMobile();
const setIsLoadMoreLocked = useSetRecoilComponentStateV2(
isRecordIndexLoadMoreLockedComponentState,
indexId,
);
return (
<>
{contextStoreCurrentObjectMetadataItemId && (
<ActionMenuContext.Provider
value={{
isInRightDrawer: false,
onActionStartedCallback: (action) => {
if (action.key === MultipleRecordsActionKeys.DELETE) {
setIsLoadMoreLocked(true);
}
},
onActionExecutedCallback: (action) => {
if (action.key === MultipleRecordsActionKeys.DELETE) {
setIsLoadMoreLocked(false);
}
},
}}
>
{!isMobile && <PageHeaderActionMenuButtons />}
<RecordIndexActionMenuDropdown />
<ActionMenuConfirmationModals />
<RecordActionMenuEntriesSetter />
<RecordAgnosticActionMenuEntriesSetter />
{isWorkflowEnabled && (
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />
)}
</ActionMenuContext.Provider>
<>
<ActionMenuContextProvider
isInRightDrawer={false}
displayType="button"
actionMenuType="index-page-action-menu"
>
{!isMobile && <PageHeaderActionMenuButtons />}
</ActionMenuContextProvider>
<ActionMenuContextProvider
isInRightDrawer={false}
displayType="dropdownItem"
actionMenuType="index-page-action-menu-dropdown"
>
<RecordIndexActionMenuDropdown />
</ActionMenuContextProvider>
</>
)}
</>
);

View File

@ -1,54 +0,0 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconLayoutSidebarRightExpand } from 'twenty-ui/display';
import { getOsControlSymbol } from 'twenty-ui/utilities';
const StyledButton = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background ${({ theme }) => theme.animation.duration.fast} ease;
user-select: none;
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
`;
const StyledButtonLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledShortcutLabel = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledSeparator = styled.div<{ size: 'sm' | 'md' }>`
background: ${({ theme }) => theme.border.color.light};
height: ${({ theme, size }) => theme.spacing(size === 'sm' ? 4 : 8)};
margin: 0 ${({ theme }) => theme.spacing(1)};
width: 1px;
`;
export const RecordIndexActionMenuBarAllActionsButton = () => {
const theme = useTheme();
const { openCommandMenu } = useCommandMenu();
return (
<>
<StyledSeparator size="md" />
<StyledButton onClick={openCommandMenu}>
<IconLayoutSidebarRightExpand size={theme.icon.size.md} />
<StyledButtonLabel>All Actions</StyledButtonLabel>
<StyledSeparator size="sm" />
<StyledShortcutLabel>{getOsControlSymbol()}K</StyledShortcutLabel>
</StyledButton>
</>
);
};

View File

@ -1,40 +0,0 @@
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { i18n } from '@lingui/core';
type RecordIndexActionMenuBarEntryProps = {
entry: ActionMenuEntry;
};
const StyledButton = styled.div`
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)};
transition: background ${({ theme }) => theme.animation.duration.fast} ease;
user-select: none;
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
`;
const StyledButtonLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
export const RecordIndexActionMenuBarEntry = ({
entry,
}: RecordIndexActionMenuBarEntryProps) => {
const theme = useTheme();
return (
<StyledButton onClick={() => entry.onClick?.()}>
{entry.Icon && <entry.Icon size={theme.icon.size.md} />}
<StyledButtonLabel>{i18n._(entry.label)}</StyledButtonLabel>
</StyledButton>
);
};

View File

@ -1,21 +1,19 @@
import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector';
import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDropdownHotKeyScope';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
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';
import styled from '@emotion/styled';
import { i18n } from '@lingui/core';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { IconLayoutSidebarRightExpand } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
@ -31,14 +29,12 @@ const StyledDropdownMenuContainer = styled.div`
`;
export const RecordIndexActionMenuDropdown = () => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentSelector,
);
const { actions } = useContext(ActionMenuContext);
const recordIndexActions = actionMenuEntries.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Standard &&
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
const recordIndexActions = actions.filter(
(action) =>
action.type === ActionType.Standard &&
action.scope === ActionScope.RecordSelection,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
@ -57,14 +53,6 @@ export const RecordIndexActionMenuDropdown = () => {
const { openCommandMenu } = useCommandMenu();
//TODO: remove this
const width = recordIndexActions.some(
(actionMenuEntry) =>
i18n._(actionMenuEntry.label) === 'Remove from favorites',
)
? 200
: undefined;
return (
<Dropdown
dropdownId={dropdownId}
@ -72,7 +60,6 @@ export const RecordIndexActionMenuDropdown = () => {
scope: ActionMenuDropdownHotkeyScope.ActionMenuDropdown,
}}
data-select-disable
dropdownWidth={width}
dropdownPlacement="bottom-start"
dropdownStrategy="absolute"
dropdownOffset={{
@ -82,17 +69,8 @@ export const RecordIndexActionMenuDropdown = () => {
dropdownComponents={
<StyledDropdownMenuContainer className="action-menu-dropdown">
<DropdownMenuItemsContainer>
{recordIndexActions.map((item) => (
<MenuItem
key={item.key}
LeftIcon={item.Icon}
onClick={() => {
closeDropdown();
item.onClick?.();
}}
accent={item.accent}
text={i18n._(item.label)}
/>
{recordIndexActions.map((action) => (
<ActionComponent action={action} key={action.key} />
))}
<MenuItem
key="more-actions"

View File

@ -1,14 +1,8 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { RecordAgnosticActionMenuEntriesSetter } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionMenuEntriesSetter';
import { RunWorkflowRecordAgnosticActionMenuEntriesSetter } from '@/action-menu/actions/record-agnostic-actions/components/RunWorkflowRecordAgnosticActionMenuEntriesSetter';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { PageHeaderActionMenuButtons } from '@/action-menu/components/PageHeaderActionMenuButtons';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuContextProvider } from '@/action-menu/contexts/ActionMenuContextProvider';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordShowActionMenu = () => {
@ -24,29 +18,18 @@ export const RecordShowActionMenu = () => {
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1;
const isWorkflowEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsWorkflowEnabled,
);
const isMobile = useIsMobile();
return (
<>
{hasSelectedRecord && contextStoreCurrentObjectMetadataItemId && (
<ActionMenuContext.Provider
value={{
isInRightDrawer: false,
onActionExecutedCallback: () => {},
}}
<ActionMenuContextProvider
isInRightDrawer={false}
displayType="button"
actionMenuType="show-page-action-menu"
>
{!isMobile && <PageHeaderActionMenuButtons />}
<ActionMenuConfirmationModals />
<RecordActionMenuEntriesSetter />
<RecordAgnosticActionMenuEntriesSetter />
{isWorkflowEnabled && (
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />
)}
</ActionMenuContext.Provider>
</ActionMenuContextProvider>
)}
</>
);

View File

@ -1,62 +1,23 @@
import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter';
import { SingleRecordActionKeys } from '@/action-menu/actions/record-actions/single-record/types/SingleRecordActionsKey';
import { RecordAgnosticActionMenuEntriesSetter } from '@/action-menu/actions/record-agnostic-actions/components/RecordAgnosticActionMenuEntriesSetter';
import { RunWorkflowRecordAgnosticActionMenuEntriesSetter } from '@/action-menu/actions/record-agnostic-actions/components/RunWorkflowRecordAgnosticActionMenuEntriesSetter';
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { CommandMenuActionMenuDropdown } from '@/action-menu/components/CommandMenuActionMenuDropdown';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { ActionMenuContextProvider } from '@/action-menu/contexts/ActionMenuContextProvider';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
export const RecordShowRightDrawerActionMenu = () => {
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const isWorkflowEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsWorkflowEnabled,
);
const { toggleCommandMenu } = useCommandMenu();
return (
<>
{contextStoreCurrentObjectMetadataItemId && (
<ActionMenuContext.Provider
value={{
isInRightDrawer: true,
onActionExecutedCallback: ({ key }) => {
if (
key === SingleRecordActionKeys.DELETE ||
key === SingleRecordActionKeys.DESTROY
) {
toggleCommandMenu();
}
},
}}
<ActionMenuContextProvider
isInRightDrawer={true}
displayType="dropdownItem"
actionMenuType="command-menu-show-page-action-menu-dropdown"
>
<CommandMenuActionMenuDropdown />
<ActionMenuConfirmationModals />
{isDefined(contextStoreTargetedRecordsRule) &&
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length > 0 && (
<RecordActionMenuEntriesSetter />
)}
<RecordAgnosticActionMenuEntriesSetter />
{isWorkflowEnabled && (
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />
)}
</ActionMenuContext.Provider>
</ActionMenuContextProvider>
)}
</>
);

View File

@ -3,25 +3,19 @@ import { Meta, StoryObj } from '@storybook/react';
import { RecoilRoot } from 'recoil';
import { CommandMenuActionMenuDropdown } from '@/action-menu/components/CommandMenuActionMenuDropdown';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { createMockActionMenuActions } from '@/action-menu/mock/action-menu-actions.mock';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import {
ActionMenuEntry,
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { msg } from '@lingui/core/macro';
import { userEvent, waitFor, within } from '@storybook/test';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import {
ComponentDecorator,
RouterDecorator,
getCanvasElementForDropdownTesting,
} from 'twenty-ui/testing';
import { IconFileExport, IconHeart, IconTrash } from 'twenty-ui/display';
import { MenuItemAccent } from 'twenty-ui/navigation';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
const deleteMock = jest.fn();
const addToFavoritesMock = jest.fn();
const exportMock = jest.fn();
@ -49,56 +43,31 @@ const meta: Meta<typeof CommandMenuActionMenuDropdown> = {
}),
1,
);
const map = new Map<string, ActionMenuEntry>();
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
map,
);
map.set('addToFavorites', {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'addToFavorites',
label: msg`Add to favorites`,
position: 0,
Icon: IconHeart,
onClick: addToFavoritesMock,
});
map.set('export', {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'export',
label: msg`Export`,
position: 1,
Icon: IconFileExport,
onClick: exportMock,
});
map.set('delete', {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete',
label: msg`Delete`,
position: 2,
Icon: IconTrash,
onClick: deleteMock,
accent: 'danger' as MenuItemAccent,
});
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'story-action-menu' }}
>
<Story />
<ActionMenuContext.Provider
value={{
isInRightDrawer: true,
displayType: 'dropdownItem',
actionMenuType: 'command-menu-show-page-action-menu-dropdown',
actions: createMockActionMenuActions({
deleteMock,
addToFavoritesMock,
exportMock,
}),
}}
>
<Story />
</ActionMenuContext.Provider>
</ActionMenuComponentInstanceContext.Provider>
</RecoilRoot>
),
ComponentDecorator,
ContextStoreDecorator,
RouterDecorator,
],
args: {
actionMenuId: 'story-action-menu',

View File

@ -1,73 +0,0 @@
import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { msg } from '@lingui/core/macro';
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ComponentDecorator } from 'twenty-ui/testing';
import { IconCheckbox, IconTrash } from 'twenty-ui/display';
const meta: Meta<typeof RecordIndexActionMenuBarEntry> = {
title: 'Modules/ActionMenu/RecordIndexActionMenuBarEntry',
component: RecordIndexActionMenuBarEntry,
decorators: [ComponentDecorator, I18nFrontDecorator],
};
export default meta;
type Story = StoryObj<typeof RecordIndexActionMenuBarEntry>;
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
export const Default: Story = {
args: {
entry: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete',
label: msg`Delete`,
position: 0,
Icon: IconTrash,
onClick: deleteMock,
},
},
};
export const WithDangerAccent: Story = {
args: {
entry: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete',
label: msg`Delete`,
position: 0,
Icon: IconTrash,
onClick: deleteMock,
accent: 'danger',
},
},
};
export const WithInteraction: Story = {
args: {
entry: {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'markAsDone',
label: msg`Mark as done`,
position: 0,
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

@ -1,27 +1,25 @@
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { RecoilRoot } from 'recoil';
import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIndexActionMenuDropdown';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { createMockActionMenuActions } from '@/action-menu/mock/action-menu-actions.mock';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
import {
ActionMenuEntry,
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { msg } from '@lingui/core/macro';
import {
RouterDecorator,
getCanvasElementForDropdownTesting,
} from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui/display';
import { getCanvasElementForDropdownTesting } from 'twenty-ui/testing';
const deleteMock = jest.fn();
const markAsDoneMock = jest.fn();
const addToFavoritesMock = jest.fn();
const exportMock = jest.fn();
const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
title: 'Modules/ActionMenu/RecordIndexActionMenuDropdown',
@ -39,45 +37,6 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
{ x: 10, y: 10 },
);
const map = new Map<string, ActionMenuEntry>();
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
map,
);
map.set('delete', {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'delete',
label: msg`Delete`,
position: 0,
Icon: IconTrash,
onClick: deleteMock,
});
map.set('markAsDone', {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'markAsDone',
label: msg`Mark as done`,
position: 1,
Icon: IconCheckbox,
onClick: markAsDoneMock,
});
map.set('addToFavorites', {
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
key: 'addToFavorites',
label: msg`Add to favorites`,
position: 2,
Icon: IconHeart,
onClick: addToFavoritesMock,
});
set(
extractComponentState(
isDropdownOpenComponentState,
@ -90,10 +49,25 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: 'story-action-menu' }}
>
<Story />
<ActionMenuContext.Provider
value={{
isInRightDrawer: true,
displayType: 'dropdownItem',
actionMenuType: 'index-page-action-menu-dropdown',
actions: createMockActionMenuActions({
deleteMock,
addToFavoritesMock,
exportMock,
}),
}}
>
<Story />
</ActionMenuContext.Provider>
</ActionMenuComponentInstanceContext.Provider>
</RecoilRoot>
),
ContextStoreDecorator,
RouterDecorator,
],
};
@ -112,12 +86,24 @@ export const WithInteractions: Story = {
actionMenuId: 'story',
},
play: async () => {
const canvasElement = getCanvasElementForDropdownTesting();
const canvas = within(canvasElement);
const canvas = within(getCanvasElementForDropdownTesting());
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
expect(deleteMock).toHaveBeenCalled();
const addToFavoritesButton = await canvas.findByText('Add to favorites');
await userEvent.click(addToFavoritesButton);
const exportButton = await canvas.findByText('Export');
await userEvent.click(exportButton);
const moreActionsButton = await canvas.findByText('More actions');
await waitFor(() => {
expect(deleteMock).toHaveBeenCalled();
expect(addToFavoritesMock).toHaveBeenCalled();
expect(exportMock).toHaveBeenCalled();
expect(moreActionsButton).toBeInTheDocument();
});
},
};