refactor(command-menu-item): rename Actions to CommandMenuItem (#18489)

actions are being renamed to command menu item, they will be migrated to
server and will be served as headless front components

---------

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
nitin
2026-03-09 19:33:12 +05:30
committed by GitHub
parent 06bdb5ad6a
commit 36bcc71f3d
275 changed files with 4544 additions and 4436 deletions
@@ -0,0 +1,47 @@
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext } from 'react';
import { ThemeContext } from 'twenty-ui/theme-constants';
const StyledActionContainer = styled(motion.div)`
align-items: center;
display: flex;
justify-content: center;
`;
export const PageHeaderCommandMenuButtons = () => {
const { theme } = useContext(ThemeContext);
const { commandMenuItems } = useContext(CommandMenuContext);
const pinnedActions = commandMenuItems
.filter((entry) => entry.isPinned)
.toReversed();
const actionsWithPositionForAnimation = pinnedActions.map(
(action, index) => ({
action,
position: pinnedActions.length - index - 1,
}),
);
return (
<>
{actionsWithPositionForAnimation.map(({ action, position }) => (
<StyledActionContainer
key={position}
layout
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'unset', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
ease: 'easeInOut',
}}
>
<CommandMenuItemComponent action={action} />
</StyledActionContainer>
))}
</>
);
};
@@ -0,0 +1,39 @@
import { PageHeaderCommandMenuButtons } from '@/command-menu-item/components/PageHeaderCommandMenuButtons';
import { RecordIndexCommandMenuDropdown } from '@/command-menu-item/components/RecordIndexCommandMenuDropdown';
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordIndexCommandMenu = () => {
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const isMobile = useIsMobile();
return (
<>
{contextStoreCurrentObjectMetadataItemId && (
<>
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
>
{!isMobile && <PageHeaderCommandMenuButtons />}
</CommandMenuContextProvider>
<CommandMenuContextProvider
isInSidePanel={false}
displayType="dropdownItem"
containerType="index-page-dropdown"
>
<RecordIndexCommandMenuDropdown />
</CommandMenuContextProvider>
</>
)}
</>
);
};
@@ -0,0 +1,118 @@
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { recordIndexCommandMenuDropdownPositionComponentState } from '@/command-menu-item/states/recordIndexCommandMenuDropdownPositionComponentState';
import { getCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getCommandMenuDropdownIdFromCommandMenuId';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { IconLayoutSidebarRightExpand } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
const StyledDropdownMenuContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
export const RecordIndexCommandMenuDropdown = () => {
const { t } = useLingui();
const { commandMenuItems } = useContext(CommandMenuContext);
const recordIndexActions = commandMenuItems.filter(
(action) =>
action.type === CommandMenuItemType.Standard &&
action.scope === CommandMenuItemScope.RecordSelection,
);
const commandMenuId = useAvailableComponentInstanceIdOrThrow(
CommandMenuComponentInstanceContext,
);
const dropdownId = getCommandMenuDropdownIdFromCommandMenuId(commandMenuId);
const { closeDropdown } = useCloseDropdown();
const recordIndexCommandMenuDropdownPosition = useAtomComponentStateValue(
recordIndexCommandMenuDropdownPositionComponentState,
dropdownId,
);
const { openSidePanelMenu } = useSidePanelMenu();
const selectedItemIdArray = [
...recordIndexActions.map((action) => action.key),
'more-actions',
];
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
return (
<Dropdown
dropdownId={dropdownId}
data-select-disable
dropdownPlacement="bottom-start"
dropdownStrategy="absolute"
dropdownOffset={{
x: recordIndexCommandMenuDropdownPosition.x ?? 0,
y: recordIndexCommandMenuDropdownPosition.y ?? 0,
}}
dropdownComponents={
<DropdownContent>
<StyledDropdownMenuContainer
data-click-outside-id={COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<DropdownMenuItemsContainer>
<SelectableList
focusId={dropdownId}
selectableItemIdArray={selectedItemIdArray}
selectableListInstanceId={dropdownId}
>
{recordIndexActions.map((action) => (
<CommandMenuItemComponent action={action} key={action.key} />
))}
<SelectableListItem
itemId="more-actions"
key="more-actions"
onEnter={() => {
closeDropdown(dropdownId);
openSidePanelMenu();
}}
>
<MenuItem
LeftIcon={IconLayoutSidebarRightExpand}
onClick={() => {
closeDropdown(dropdownId);
openSidePanelMenu();
}}
focused={selectedItemId === 'more-actions'}
text={t`More actions`}
/>
</SelectableListItem>
</SelectableList>
</DropdownMenuItemsContainer>
</StyledDropdownMenuContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,24 @@
import { RecordPageSidePanelCommandMenuDropdown } from '@/command-menu-item/components/RecordPageSidePanelCommandMenuDropdown';
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
export const RecordPageSidePanelCommandMenu = () => {
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
return (
<>
{contextStoreCurrentObjectMetadataItemId && (
<CommandMenuContextProvider
isInSidePanel={true}
displayType="dropdownItem"
containerType="command-menu-show-page-dropdown"
>
<RecordPageSidePanelCommandMenuDropdown />
</CommandMenuContextProvider>
)}
</>
);
};
@@ -0,0 +1,39 @@
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useContext } from 'react';
export const RecordPageSidePanelCommandMenuDropdown = () => {
const { commandMenuItems } = useContext(CommandMenuContext);
const commandMenuId = useAvailableComponentInstanceIdOrThrow(
CommandMenuComponentInstanceContext,
);
const dropdownId =
getSidePanelCommandMenuDropdownIdFromCommandMenuId(commandMenuId);
const recordSelectionActions = commandMenuItems.filter(
(action) => action.scope === CommandMenuItemScope.RecordSelection,
);
const selectableItemIdArray = recordSelectionActions.map(
(action) => action.key,
);
return (
<OptionsDropdownMenu
dropdownId={dropdownId}
selectableListId={commandMenuId}
selectableItemIdArray={selectableItemIdArray}
>
{recordSelectionActions.map((action) => (
<CommandMenuItemComponent action={action} key={action.key} />
))}
</OptionsDropdownMenu>
);
};
@@ -0,0 +1,39 @@
import { PageHeaderCommandMenuButtons } from '@/command-menu-item/components/PageHeaderCommandMenuButtons';
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordShowCommandMenu = () => {
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const hasSelectedRecord =
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1;
const isMobile = useIsMobile();
return (
<>
{hasSelectedRecord && contextStoreCurrentObjectMetadataItemId && (
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="show-page-header"
>
{!isMobile && <PageHeaderCommandMenuButtons />}
</CommandMenuContextProvider>
)}
</>
);
};
@@ -0,0 +1,151 @@
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconBrowserMaximize } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol } from 'twenty-ui/utilities';
import { useNavigateApp } from '~/hooks/useNavigateApp';
type RecordShowSidePanelOpenRecordButtonProps = {
objectNameSingular: string;
recordId: string;
};
export const RecordShowSidePanelOpenRecordButton = ({
objectNameSingular,
recordId,
}: RecordShowSidePanelOpenRecordButtonProps) => {
const record = useAtomFamilyStateValue(recordStoreFamilyState, recordId) as
| ObjectRecord
| null
| undefined;
const { closeSidePanelMenu } = useSidePanelMenu();
const sidePanelPageComponentInstance = useComponentInstanceStateContext(
SidePanelPageComponentInstanceContext,
);
const tabListComponentId = getShowPageTabListComponentId({
pageId: sidePanelPageComponentInstance?.instanceId,
targetObjectId: recordId,
});
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
tabListComponentId,
);
const tabListComponentIdInRecordPage = getShowPageTabListComponentId({
targetObjectId: recordId,
});
const setActiveTabId = useSetAtomComponentState(
activeTabIdComponentState,
tabListComponentIdInRecordPage,
);
const parentViewState = useAtomComponentStateCallbackState(
contextStoreRecordShowParentViewComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const store = useStore();
const navigate = useNavigateApp();
const commandMenuId = useAvailableComponentInstanceIdOrThrow(
CommandMenuComponentInstanceContext,
);
const { closeDropdown } = useCloseDropdown();
const handleOpenRecord = useCallback(() => {
const tabIdToOpen =
activeTabId === 'home'
? objectNameSingular === CoreObjectNameSingular.Note ||
objectNameSingular === CoreObjectNameSingular.Task
? 'richText'
: 'timeline'
: activeTabId;
setActiveTabId(tabIdToOpen);
const parentView = store.get(parentViewState);
if (
isDefined(parentView) &&
parentView.parentViewObjectNameSingular !== objectNameSingular
) {
store.set(parentViewState, undefined);
}
store.set(sidePanelNavigationStackState.atom, []);
navigate(AppPath.RecordShowPage, {
objectNameSingular,
objectRecordId: recordId,
});
closeDropdown(
getSidePanelCommandMenuDropdownIdFromCommandMenuId(commandMenuId),
);
closeSidePanelMenu();
}, [
commandMenuId,
activeTabId,
closeSidePanelMenu,
closeDropdown,
navigate,
objectNameSingular,
parentViewState,
recordId,
setActiveTabId,
store,
]);
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: handleOpenRecord,
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [handleOpenRecord],
});
if (!isDefined(record)) {
return null;
}
return (
<Button
title={t`Open`}
variant="primary"
accent="blue"
size="small"
Icon={IconBrowserMaximize}
hotkeys={[getOsControlSymbol(), '⏎']}
onClick={handleOpenRecord}
/>
);
};
@@ -0,0 +1,102 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
import * as test from 'storybook/test';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { RecordIndexCommandMenuDropdown } from '@/command-menu-item/components/RecordIndexCommandMenuDropdown';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { recordIndexCommandMenuDropdownPositionComponentState } from '@/command-menu-item/states/recordIndexCommandMenuDropdownPositionComponentState';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { RouterDecorator } from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
const deleteMock = test.fn();
const addToFavoritesMock = test.fn();
const exportMock = test.fn();
const meta: Meta<typeof RecordIndexCommandMenuDropdown> = {
title: 'Modules/CommandMenu/RecordIndexCommandMenuDropdown',
component: RecordIndexCommandMenuDropdown,
decorators: [
(Story) => {
jotaiStore.set(
isDropdownOpenComponentState.atomFamily({
instanceId: 'command-menu-dropdown-story-command-menu',
}),
true,
);
jotaiStore.set(
recordIndexCommandMenuDropdownPositionComponentState.atomFamily({
instanceId: 'command-menu-dropdown-story',
}),
{ x: 10, y: 10 },
);
return (
<JotaiProvider store={jotaiStore}>
<CommandMenuComponentInstanceContext.Provider
value={{ instanceId: 'story-command-menu' }}
>
<CommandMenuContext.Provider
value={{
isInSidePanel: true,
displayType: 'dropdownItem',
containerType: 'index-page-dropdown',
commandMenuItems: createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
exportMock,
}),
}}
>
<Story />
</CommandMenuContext.Provider>
</CommandMenuComponentInstanceContext.Provider>
</JotaiProvider>
);
},
ContextStoreDecorator,
RouterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof RecordIndexCommandMenuDropdown>;
export const Default: Story = {
args: {
commandMenuId: 'story',
},
};
export const WithInteractions: Story = {
args: {
commandMenuId: 'story',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
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();
});
},
};
@@ -0,0 +1,112 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { Provider as JotaiProvider } from 'jotai';
import * as test from 'storybook/test';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { RecordPageSidePanelCommandMenuDropdown } from '@/command-menu-item/components/RecordPageSidePanelCommandMenuDropdown';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
const deleteMock = test.fn();
const addToFavoritesMock = test.fn();
const exportMock = test.fn();
const meta: Meta<typeof RecordPageSidePanelCommandMenuDropdown> = {
title: 'Modules/CommandMenu/RecordPageSidePanelCommandMenuDropdown',
component: RecordPageSidePanelCommandMenuDropdown,
decorators: [
(Story) => (
<JotaiProvider store={jotaiStore}>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID }}
>
<JestContextStoreSetter
contextStoreTargetedRecordsRule={{
mode: 'selection',
selectedRecordIds: ['1'],
}}
contextStoreNumberOfSelectedRecords={1}
>
<CommandMenuComponentInstanceContext.Provider
value={{ instanceId: 'story-command-menu' }}
>
<CommandMenuContext.Provider
value={{
isInSidePanel: true,
displayType: 'dropdownItem',
containerType: 'command-menu-show-page-dropdown',
commandMenuItems: createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
exportMock,
}),
}}
>
<Story />
</CommandMenuContext.Provider>
</CommandMenuComponentInstanceContext.Provider>
</JestContextStoreSetter>
</ContextStoreComponentInstanceContext.Provider>
</JotaiProvider>
),
ComponentDecorator,
ContextStoreDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
],
args: {
commandMenuId: 'story-command-menu',
},
};
export default meta;
type Story = StoryObj<typeof RecordPageSidePanelCommandMenuDropdown>;
export const Default: Story = {
args: {
commandMenuId: 'story-command-menu',
},
};
export const WithButtonClicks: Story = {
args: {
commandMenuId: 'story-command-menu',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
let actionButton = await canvas.findByText('Options');
await userEvent.click(actionButton);
const deleteButton = await canvas.findByText('Delete');
await userEvent.click(deleteButton);
actionButton = await canvas.findByText('Options');
await userEvent.click(actionButton);
const addToFavoritesButton = await canvas.findByText('Add to favorites');
await userEvent.click(addToFavoritesButton);
actionButton = await canvas.findByText('Options');
await userEvent.click(actionButton);
const exportButton = await canvas.findByText('Export');
await userEvent.click(exportButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalled();
expect(addToFavoritesMock).toHaveBeenCalled();
expect(exportMock).toHaveBeenCalled();
});
},
};
@@ -0,0 +1,69 @@
import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName';
import { COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalId';
import { commandMenuItemConfirmationModalConfigState } from '@/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState';
import {
type CommandMenuConfirmationModalResult,
type CommandMenuConfirmationModalResultBrowserEventDetail,
} from '@/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const CommandMenuConfirmationModalManager = () => {
const commandMenuItemConfirmationModalConfig = useAtomStateValue(
commandMenuItemConfirmationModalConfigState,
);
const isModalOpened = useAtomComponentStateValue(
isModalOpenedComponentState,
COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID,
);
const setCommandMenuItemConfirmationModalConfig = useSetAtomState(
commandMenuItemConfirmationModalConfigState,
);
const callerId = commandMenuItemConfirmationModalConfig?.frontComponentId;
const emitConfirmationResult = (
confirmationResult: CommandMenuConfirmationModalResult,
) => {
if (!callerId) {
return;
}
window.dispatchEvent(
new CustomEvent<CommandMenuConfirmationModalResultBrowserEventDetail>(
COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
{
detail: {
frontComponentId: callerId,
confirmationResult,
},
},
),
);
setCommandMenuItemConfirmationModalConfig(null);
};
if (!commandMenuItemConfirmationModalConfig || !isModalOpened) {
return null;
}
return (
<ConfirmationModal
modalInstanceId={COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID}
title={commandMenuItemConfirmationModalConfig.title}
subtitle={commandMenuItemConfirmationModalConfig.subtitle}
onConfirmClick={() => emitConfirmationResult('confirm')}
onClose={() => emitConfirmationResult('cancel')}
confirmButtonText={
commandMenuItemConfirmationModalConfig.confirmButtonText
}
confirmButtonAccent={
commandMenuItemConfirmationModalConfig.confirmButtonAccent
}
/>
);
};
@@ -0,0 +1,2 @@
export const COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID =
'command-menu-item-confirmation-modal';
@@ -0,0 +1,2 @@
export const COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME =
'command-menu-item-confirmation-modal-result';
@@ -0,0 +1,48 @@
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalId';
import {
type CommandMenuItemConfirmationModalConfig,
commandMenuItemConfirmationModalConfigState,
} from '@/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useCommandMenuConfirmationModal = () => {
const store = useStore();
const setCommandMenuItemConfirmationModalConfig = useSetAtomState(
commandMenuItemConfirmationModalConfigState,
);
const { openModal } = useModal();
const openConfirmationModal = useCallback(
(config: CommandMenuItemConfirmationModalConfig) => {
const existingCommandMenuItemConfirmationModalConfig = store.get(
commandMenuItemConfirmationModalConfigState.atom,
);
const isCommandMenuItemConfirmationModalOpened = store.get(
isModalOpenedComponentState.atomFamily({
instanceId: COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID,
}),
);
if (
existingCommandMenuItemConfirmationModalConfig !== null ||
isCommandMenuItemConfirmationModalOpened
) {
throw new Error(
'Command menu item confirmation modal is already active for another front component',
);
}
setCommandMenuItemConfirmationModalConfig(config);
openModal(COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID);
},
[store, setCommandMenuItemConfirmationModalConfig, openModal],
);
return { openConfirmationModal };
};
@@ -0,0 +1,18 @@
import { type ReactNode } from 'react';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { type ButtonAccent } from 'twenty-ui/input';
export type CommandMenuItemConfirmationModalConfig = {
frontComponentId: string;
title: string;
subtitle: ReactNode;
confirmButtonText?: string;
confirmButtonAccent?: ButtonAccent;
};
export const commandMenuItemConfirmationModalConfigState =
createAtomState<CommandMenuItemConfirmationModalConfig | null>({
key: 'commandMenuItemConfirmationModalConfigState',
defaultValue: null,
});
@@ -0,0 +1,6 @@
export type CommandMenuConfirmationModalResult = 'confirm' | 'cancel';
export type CommandMenuConfirmationModalResultBrowserEventDetail = {
frontComponentId: string;
confirmationResult: CommandMenuConfirmationModalResult;
};
@@ -0,0 +1 @@
export const COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID = 'command-menu-dropdown';
@@ -0,0 +1,6 @@
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { createContext } from 'react';
export const CommandConfigContext = createContext<CommandMenuItemConfig | null>(
null,
);
@@ -0,0 +1,17 @@
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { type CommandMenuItemContainerType } from '@/command-menu-item/types/CommandMenuItemContainerType';
import { createContext } from 'react';
export type CommandMenuContextType = {
isInSidePanel: boolean;
displayType: 'button' | 'listItem' | 'dropdownItem';
containerType: CommandMenuItemContainerType;
commandMenuItems: CommandMenuItemConfig[];
};
export const CommandMenuContext = createContext<CommandMenuContextType>({
isInSidePanel: false,
containerType: 'command-menu-list',
displayType: 'button',
commandMenuItems: [],
});
@@ -0,0 +1,69 @@
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuContextProviderDefault } from '@/command-menu-item/contexts/CommandMenuContextProviderDefault';
import { CommandMenuContextProviderWorkflowObjects } from '@/command-menu-item/contexts/CommandMenuContextProviderWorkflowObjects';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuContextProvider = ({
children,
isInSidePanel,
displayType,
containerType,
objectMetadataItemOverride,
}: Omit<CommandMenuContextType, 'commandMenuItems'> & {
children: React.ReactNode;
objectMetadataItemOverride?: ObjectMetadataItem;
}) => {
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const objectMetadataItem =
objectMetadataItemOverride ??
objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId,
);
if (!isDefined(objectMetadataItem)) {
return null;
}
const isWorkflowObject =
objectMetadataItem?.nameSingular === CoreObjectNameSingular.Workflow;
if (!isDefined(objectMetadataItem)) {
return null;
}
if (isWorkflowObject) {
return (
<CommandMenuContextProviderWorkflowObjects
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
objectMetadataItem={objectMetadataItem}
>
{children}
</CommandMenuContextProviderWorkflowObjects>
);
}
return (
<CommandMenuContextProviderDefault
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
objectMetadataItem={objectMetadataItem}
>
{children}
</CommandMenuContextProviderDefault>
);
};
@@ -0,0 +1,78 @@
import { useRunWorkflowRecordCommands } from '@/command-menu-item/record/workflow/hooks/useRunWorkflowRecordCommands';
import { useRunWorkflowRecordAgnosticCommands } from '@/command-menu-item/record-agnostic/workflow/hooks/useRunWorkflowRecordAgnosticCommands';
import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { useRegisteredCommandMenuItems } from '@/command-menu-item/hooks/useRegisteredCommandMenuItems';
import { useShouldCommandMenuItemBeRegisteredParams } from '@/command-menu-item/hooks/useShouldCommandMenuItemBeRegisteredParams';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { useCommandMenuItemFrontComponentActions } from '@/command-menu-item/hooks/useCommandMenuItemFrontComponentActions';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
export const CommandMenuContextProviderDefault = ({
objectMetadataItem,
isInSidePanel,
displayType,
containerType,
children,
}: {
objectMetadataItem: ObjectMetadataItem;
isInSidePanel: CommandMenuContextType['isInSidePanel'];
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
}) => {
const params = useShouldCommandMenuItemBeRegisteredParams({
objectMetadataItem,
});
const shouldBeRegisteredParams = {
...params,
};
const commandMenuItems = useRegisteredCommandMenuItems(
shouldBeRegisteredParams,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const isRecordSelection =
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length > 0;
const runWorkflowRecordCommands = useRunWorkflowRecordCommands({
objectMetadataItem,
skip: !isRecordSelection,
});
const runWorkflowRecordAgnosticCommands =
useRunWorkflowRecordAgnosticCommands();
const commandMenuContextApi = useCommandMenuContextApi();
const commandMenuItemFrontComponentActions =
useCommandMenuItemFrontComponentActions(commandMenuContextApi);
return (
<CommandMenuContext.Provider
value={{
isInSidePanel,
displayType,
containerType,
commandMenuItems: [
...commandMenuItems,
...runWorkflowRecordCommands,
...runWorkflowRecordAgnosticCommands,
...commandMenuItemFrontComponentActions,
],
}}
>
{children}
</CommandMenuContext.Provider>
);
};
@@ -0,0 +1,173 @@
import { useRunWorkflowRecordAgnosticCommands } from '@/command-menu-item/record-agnostic/workflow/hooks/useRunWorkflowRecordAgnosticCommands';
import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { useRegisteredCommandMenuItems } from '@/command-menu-item/hooks/useRegisteredCommandMenuItems';
import { useShouldCommandMenuItemBeRegisteredParams } from '@/command-menu-item/hooks/useShouldCommandMenuItemBeRegisteredParams';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { useCommandMenuItemFrontComponentActions } from '@/command-menu-item/hooks/useCommandMenuItemFrontComponentActions';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { type WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-shared/utils';
type CommandMenuContextProviderWorkflowObjectsProps = {
objectMetadataItem: ObjectMetadataItem;
isInSidePanel: CommandMenuContextType['isInSidePanel'];
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
};
const CommandMenuContextProviderWorkflowObjectsContent = ({
objectMetadataItem,
isInSidePanel,
displayType,
containerType,
children,
selectedRecordId,
}: CommandMenuContextProviderWorkflowObjectsProps & {
selectedRecordId: string;
}) => {
const params = useShouldCommandMenuItemBeRegisteredParams({
objectMetadataItem,
});
const workflowWithCurrentVersion =
useWorkflowWithCurrentVersion(selectedRecordId);
const shouldBeRegisteredParams = {
...params,
workflowWithCurrentVersion,
};
const commandMenuItems = useRegisteredCommandMenuItems(
shouldBeRegisteredParams,
);
const runWorkflowRecordAgnosticCommands =
useRunWorkflowRecordAgnosticCommands();
const commandMenuContextApi = useCommandMenuContextApi();
const commandMenuItemFrontComponentActions =
useCommandMenuItemFrontComponentActions(commandMenuContextApi);
return (
<CommandMenuContext.Provider
value={{
isInSidePanel,
displayType,
containerType,
commandMenuItems: [
...commandMenuItems,
...runWorkflowRecordAgnosticCommands,
...commandMenuItemFrontComponentActions,
],
}}
>
{children}
</CommandMenuContext.Provider>
);
};
const CommandMenuContextProviderWorkflowObjectsWithoutWorkflow = ({
objectMetadataItem,
isInSidePanel,
displayType,
containerType,
children,
}: CommandMenuContextProviderWorkflowObjectsProps & {
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined;
}) => {
const params = useShouldCommandMenuItemBeRegisteredParams({
objectMetadataItem,
});
const shouldBeRegisteredParams = {
...params,
workflowWithCurrentVersion: undefined,
};
const commandMenuItems = useRegisteredCommandMenuItems(
shouldBeRegisteredParams,
);
const runWorkflowRecordAgnosticCommands =
useRunWorkflowRecordAgnosticCommands();
const commandMenuContextApi = useCommandMenuContextApi();
const commandMenuItemFrontComponentActions =
useCommandMenuItemFrontComponentActions(commandMenuContextApi);
return (
<CommandMenuContext.Provider
value={{
isInSidePanel,
displayType,
containerType,
commandMenuItems: [
...commandMenuItems,
...runWorkflowRecordAgnosticCommands,
...commandMenuItemFrontComponentActions,
],
}}
>
{children}
</CommandMenuContext.Provider>
);
};
export const CommandMenuContextProviderWorkflowObjects = ({
objectMetadataItem,
isInSidePanel,
displayType,
containerType,
children,
}: CommandMenuContextProviderWorkflowObjectsProps) => {
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const recordId =
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const selectedRecord =
useAtomFamilyStateValue(recordStoreFamilyState, recordId ?? '') ||
undefined;
if (isDefined(selectedRecord?.id)) {
return (
<CommandMenuContextProviderWorkflowObjectsContent
objectMetadataItem={objectMetadataItem}
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
selectedRecordId={selectedRecord.id}
>
{children}
</CommandMenuContextProviderWorkflowObjectsContent>
);
}
return (
<CommandMenuContextProviderWorkflowObjectsWithoutWorkflow
objectMetadataItem={objectMetadataItem}
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
workflowWithCurrentVersion={undefined}
>
{children}
</CommandMenuContextProviderWorkflowObjectsWithoutWorkflow>
);
};
@@ -0,0 +1,32 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { useContext } from 'react';
export const Command = ({
onClick,
closeSidePanelOnShowPageOptionsExecution = false,
closeSidePanelOnCommandMenuListExecution = true,
}: {
onClick: () => void;
closeSidePanelOnShowPageOptionsExecution?: boolean;
closeSidePanelOnCommandMenuListExecution?: boolean;
}) => {
const commandMenuItemConfig = useContext(CommandConfigContext);
const { closeCommandMenu } = useCloseCommandMenu({
closeSidePanelOnShowPageOptionsExecution,
closeSidePanelOnCommandMenuListExecution,
});
if (!commandMenuItemConfig) {
return null;
}
const handleClick = () => {
closeCommandMenu();
onClick();
};
return <CommandMenuItemDisplay onClick={handleClick} />;
};
@@ -0,0 +1,51 @@
import { type CommandMenuItemDisplayProps } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { MenuItem } from 'twenty-ui/navigation';
export const CommandDropdownItem = ({
action,
onClick,
to,
}: {
action: CommandMenuItemDisplayProps;
onClick?: () => void;
to?: string;
}) => {
const navigate = useNavigate();
const handleClick = () => {
onClick?.();
if (isDefined(to)) {
navigate(to);
}
};
const selectableListInstanceId = useAvailableComponentInstanceIdOrThrow(
SelectableListComponentInstanceContext,
);
const isSelectedItemId = useAtomComponentFamilyStateValue(
isSelectedItemIdComponentFamilyState,
action.key,
selectableListInstanceId,
);
return (
<SelectableListItem itemId={action.key} onEnter={handleClick}>
<MenuItem
focused={isSelectedItemId}
key={action.key}
LeftIcon={action.Icon}
onClick={handleClick}
text={getCommandMenuItemLabel(action.label)}
/>
</SelectableListItem>
);
};
@@ -0,0 +1,21 @@
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { type PathParam } from 'react-router-dom';
import { type AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
export const CommandLink = <T extends AppPath>({
to,
params,
queryParams,
}: {
to: T;
params?: { [key in PathParam<T>]: string | null };
queryParams?: Record<string, any>;
}) => {
const { closeCommandMenu } = useCloseCommandMenu();
const path = getAppPath(to, params, queryParams);
return <CommandMenuItemDisplay onClick={closeCommandMenu} to={path} />;
};
@@ -0,0 +1,48 @@
import { type CommandMenuItemDisplayProps } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useNavigate } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { Loader } from 'twenty-ui/feedback';
export const CommandListItem = ({
action,
onClick,
to,
disabled = false,
}: {
action: CommandMenuItemDisplayProps;
onClick?: () => void;
to?: string;
disabled?: boolean;
}) => {
const navigate = useNavigate();
const handleClick = () => {
if (disabled) {
return;
}
onClick?.();
if (isDefined(to)) {
navigate(to);
}
};
return (
<SelectableListItem itemId={action.key} onEnter={handleClick}>
<CommandMenuItem
id={action.key}
Icon={action.Icon}
label={getCommandMenuItemLabel(action.label)}
description={getCommandMenuItemLabel(action.description ?? '')}
to={to}
onClick={disabled ? undefined : onClick}
hotKeys={action.hotKeys}
disabled={disabled}
RightComponent={disabled ? <Loader /> : undefined}
/>
</SelectableListItem>
);
};
@@ -0,0 +1,14 @@
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { type CommandMenuItemDisplayProps } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
export const CommandMenuItemButton = ({
action,
onClick,
to,
}: {
action: CommandMenuItemDisplayProps;
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
to?: string;
}) => {
return <CommandMenuButton command={action} to={to} onClick={onClick} />;
};
@@ -0,0 +1,14 @@
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
export const CommandMenuItemComponent = ({
action,
}: {
action: CommandMenuItemConfig;
}) => {
return (
<CommandConfigContext.Provider value={action}>
{action.component}
</CommandConfigContext.Provider>
);
};
@@ -0,0 +1,59 @@
import { CommandMenuItemButton } from '@/command-menu-item/display/components/CommandMenuItemButton';
import { CommandDropdownItem } from '@/command-menu-item/display/components/CommandDropdownItem';
import { CommandListItem } from '@/command-menu-item/display/components/CommandListItem';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { type MessageDescriptor } from '@lingui/core';
import { useContext } from 'react';
import { assertUnreachable } from 'twenty-shared/utils';
import { type IconComponent } from 'twenty-ui/display';
import { type MenuItemAccent } from 'twenty-ui/navigation';
export type CommandMenuItemDisplayProps = {
key: string;
label: MessageDescriptor | string;
shortLabel?: MessageDescriptor | string;
description?: MessageDescriptor | string;
Icon: IconComponent;
isPrimaryCTA?: boolean;
accent?: MenuItemAccent;
hotKeys?: string[];
};
export const CommandMenuItemDisplay = ({
onClick,
to,
disabled,
}: {
onClick?: (event?: React.MouseEvent<HTMLElement>) => void;
to?: string;
disabled?: boolean;
}) => {
const action = useContext(CommandConfigContext);
const { displayType } = useContext(CommandMenuContext);
if (!action) {
return null;
}
if (displayType === 'button') {
return <CommandMenuItemButton action={action} onClick={onClick} to={to} />;
}
if (displayType === 'listItem') {
return (
<CommandListItem
action={action}
onClick={onClick}
to={to}
disabled={disabled}
/>
);
}
if (displayType === 'dropdownItem') {
return <CommandDropdownItem action={action} onClick={onClick} to={to} />;
}
return assertUnreachable(displayType, 'Unsupported display type');
};
@@ -0,0 +1,50 @@
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type MessageDescriptor } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { type SidePanelPages } from 'twenty-shared/types';
import { type IconComponent } from 'twenty-ui/display';
export const CommandMenuItemOpenSidePanelPage = ({
page,
pageTitle,
pageIcon,
onClick,
shouldResetSearchState = false,
}: {
page: SidePanelPages;
pageTitle: MessageDescriptor;
pageIcon: IconComponent;
onClick?: () => void;
shouldResetSearchState?: boolean;
}) => {
const actionConfig = useContext(CommandConfigContext);
const { navigateSidePanel } = useNavigateSidePanel();
const setSidePanelSearch = useSetAtomState(sidePanelSearchState);
if (!actionConfig) {
return null;
}
const handleClick = () => {
onClick?.();
navigateSidePanel({
page,
pageTitle: t(pageTitle),
pageIcon,
});
if (shouldResetSearchState) {
setSidePanelSearch('');
}
};
return <CommandMenuItemDisplay onClick={handleClick} />;
};
@@ -0,0 +1,79 @@
import { type ReactNode, useContext } from 'react';
import { t } from '@lingui/core/macro';
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type ButtonAccent } from 'twenty-ui/input';
export type CommandModalProps = {
title: string;
subtitle: ReactNode;
onConfirmClick: () => void | Promise<void>;
confirmButtonText?: string;
confirmButtonAccent?: ButtonAccent;
isLoading?: boolean;
closeSidePanelOnShowPageOptionsExecution?: boolean;
closeSidePanelOnCommandMenuListExecution?: boolean;
};
export const CommandModal = ({
title,
subtitle,
onConfirmClick,
confirmButtonText = t`Confirm`,
confirmButtonAccent = 'danger',
isLoading = false,
closeSidePanelOnShowPageOptionsExecution,
closeSidePanelOnCommandMenuListExecution,
}: CommandModalProps) => {
const { openModal } = useModal();
const { closeCommandMenu } = useCloseCommandMenu({
closeSidePanelOnShowPageOptionsExecution,
closeSidePanelOnCommandMenuListExecution,
});
const handleConfirmClick = async () => {
await onConfirmClick();
closeCommandMenu();
};
const commandMenuItemConfig = useContext(CommandConfigContext);
const { containerType } = useContext(CommandMenuContext);
const modalId = `${commandMenuItemConfig?.key}-command-menu-item-modal-${containerType}`;
const isModalOpened = useAtomComponentStateValue(
isModalOpenedComponentState,
modalId,
);
if (!commandMenuItemConfig) {
return null;
}
const handleClick = () => openModal(modalId);
return (
<>
<CommandMenuItemDisplay onClick={handleClick} />
{isModalOpened && (
<ConfirmationModal
modalInstanceId={modalId}
title={title}
subtitle={subtitle}
onConfirmClick={handleConfirmClick}
confirmButtonText={confirmButtonText}
confirmButtonAccent={confirmButtonAccent}
loading={isLoading}
/>
)}
</>
);
};
@@ -0,0 +1,42 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { isHeadlessFrontComponentMountedFamilySelector } from '@/front-components/selectors/isHeadlessFrontComponentMountedFamilySelector';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useContext } from 'react';
import { CommandMenuItemDisplay } from './CommandMenuItemDisplay';
export const HeadlessFrontComponentCommandMenuItem = ({
frontComponentId,
onClick,
}: {
frontComponentId: string;
onClick: () => void;
}) => {
const commandMenuItemConfig = useContext(CommandConfigContext);
const { closeCommandMenu } = useCloseCommandMenu({
closeSidePanelOnShowPageOptionsExecution: false,
closeSidePanelOnCommandMenuListExecution: false,
});
const isMounted = useAtomFamilySelectorValue(
isHeadlessFrontComponentMountedFamilySelector,
frontComponentId,
);
if (!commandMenuItemConfig) {
return null;
}
const handleClick = () => {
if (isMounted) {
return;
}
closeCommandMenu();
onClick();
};
return <CommandMenuItemDisplay onClick={handleClick} disabled={isMounted} />;
};
@@ -0,0 +1,67 @@
import { CommandMenuItemButton } from '@/command-menu-item/display/components/CommandMenuItemButton';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof CommandMenuItemButton> = {
title: 'Modules/CommandMenuItem/Display/CommandMenuItemButton',
component: CommandMenuItemButton,
decorators: [ComponentDecorator, RouterDecorator],
};
export default meta;
type Story = StoryObj<typeof CommandMenuItemButton>;
const deleteMock = fn();
const addToFavoritesMock = fn();
const mockActions = createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === SingleRecordCommandKeys.ADD_TO_FAVORITES,
);
const goToPeopleCommandMenuItem = mockActions.find(
(action) => action.key === NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
);
export const Default: Story = {
args: {
action: addToFavoritesCommandMenuItem,
onClick: addToFavoritesMock,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const WithLink: Story = {
args: {
action: goToPeopleCommandMenuItem,
to: '/objects/people',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const menuItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.shortLabel ?? ''),
);
expect(menuItem).toBeVisible();
expect(canvas.getByRole('link')).toHaveAttribute('href', '/objects/people');
},
};
@@ -0,0 +1,69 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { ComponentDecorator } from 'twenty-ui/testing';
const mockActions = createMockCommandMenuItems({});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === SingleRecordCommandKeys.ADD_TO_FAVORITES,
);
if (!addToFavoritesCommandMenuItem) {
throw new Error('Add to favorites action not found');
}
const meta: Meta<typeof CommandMenuItemComponent> = {
title: 'Modules/CommandMenuItem/Display/CommandMenuItemComponent',
component: CommandMenuItemComponent,
decorators: [
ComponentDecorator,
(Story) => (
<CommandMenuComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<CommandMenuContext.Provider
value={{
isInSidePanel: false,
containerType: 'index-page-header',
displayType: 'button',
commandMenuItems: [addToFavoritesCommandMenuItem],
}}
>
<Story />
</CommandMenuContext.Provider>
</CommandMenuComponentInstanceContext.Provider>
),
],
args: {
action: addToFavoritesCommandMenuItem,
},
parameters: {
container: {
width: 'auto',
},
},
};
export default meta;
type Story = StoryObj<typeof CommandMenuItemComponent>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText(
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
),
).toBeVisible();
},
};
@@ -0,0 +1,147 @@
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
type Story = StoryObj<typeof CommandMenuItemDisplay>;
const deleteMock = fn();
const addToFavoritesMock = fn();
const mockActions = createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === SingleRecordCommandKeys.ADD_TO_FAVORITES,
);
if (!addToFavoritesCommandMenuItem) {
throw new Error('addToFavoritesCommandMenuItem not found');
}
const meta: Meta<typeof CommandMenuItemDisplay> = {
title: 'Modules/CommandMenuItem/Display/CommandMenuItemDisplay',
component: CommandMenuItemDisplay,
decorators: [
(Story) => (
<CommandConfigContext.Provider value={addToFavoritesCommandMenuItem}>
<Story />
</CommandConfigContext.Provider>
),
ComponentDecorator,
RouterDecorator,
],
};
export default meta;
export const AsButton: Story = {
args: {
onClick: addToFavoritesMock,
},
decorators: [
(Story) => (
<CommandMenuContext.Provider
value={{
isInSidePanel: false,
containerType: 'command-menu-list',
displayType: 'button',
commandMenuItems: [],
}}
>
<Story />
</CommandMenuContext.Provider>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const AsListItem: Story = {
args: {
onClick: addToFavoritesMock,
},
decorators: [
(Story) => (
<SelectableListComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<Story />
</SelectableListComponentInstanceContext.Provider>
),
(Story) => (
<CommandMenuContext.Provider
value={{
isInSidePanel: false,
containerType: 'command-menu-list',
displayType: 'listItem',
commandMenuItems: [],
}}
>
<Story />
</CommandMenuContext.Provider>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const AsDropdownItem: Story = {
args: {
onClick: addToFavoritesMock,
},
decorators: [
(Story) => (
<SelectableListComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<Story />
</SelectableListComponentInstanceContext.Provider>
),
(Story) => (
<CommandMenuContext.Provider
value={{
isInSidePanel: false,
containerType: 'command-menu-list',
displayType: 'dropdownItem',
commandMenuItems: [],
}}
>
<Story />
</CommandMenuContext.Provider>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
@@ -0,0 +1,75 @@
import { CommandDropdownItem } from '@/command-menu-item/display/components/CommandDropdownItem';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof CommandDropdownItem> = {
title: 'Modules/CommandMenuItem/Display/CommandDropdownItem',
component: CommandDropdownItem,
decorators: [
(Story) => (
<SelectableListComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<Story />
</SelectableListComponentInstanceContext.Provider>
),
ComponentDecorator,
RouterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof CommandDropdownItem>;
const deleteMock = fn();
const addToFavoritesMock = fn();
const mockActions = createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === SingleRecordCommandKeys.ADD_TO_FAVORITES,
);
const goToPeopleCommandMenuItem = mockActions.find(
(action) => action.key === NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
);
export const Default: Story = {
args: {
action: addToFavoritesCommandMenuItem,
onClick: addToFavoritesMock,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const WithLink: Story = {
args: {
action: goToPeopleCommandMenuItem,
to: '/objects/people',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdownItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label ?? ''),
);
expect(dropdownItem).toBeVisible();
},
};
@@ -0,0 +1,75 @@
import { CommandListItem } from '@/command-menu-item/display/components/CommandListItem';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
type Story = StoryObj<typeof CommandListItem>;
const deleteMock = fn();
const addToFavoritesMock = fn();
const mockActions = createMockCommandMenuItems({
deleteMock,
addToFavoritesMock,
});
const addToFavoritesCommandMenuItem = mockActions.find(
(action) => action.key === SingleRecordCommandKeys.ADD_TO_FAVORITES,
);
const goToPeopleCommandMenuItem = mockActions.find(
(action) => action.key === NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
);
const meta: Meta<typeof CommandListItem> = {
title: 'Modules/CommandMenuItem/Display/CommandListItem',
component: CommandListItem,
decorators: [
(Story) => (
<SelectableListComponentInstanceContext.Provider
value={{ instanceId: 'story' }}
>
<Story />
</SelectableListComponentInstanceContext.Provider>
),
ComponentDecorator,
RouterDecorator,
],
};
export default meta;
export const Default: Story = {
args: {
action: addToFavoritesCommandMenuItem,
onClick: addToFavoritesMock,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
},
};
export const WithLink: Story = {
args: {
action: goToPeopleCommandMenuItem,
to: '/objects/people',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const listItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label ?? ''),
);
expect(listItem).toBeVisible();
},
};
@@ -0,0 +1,60 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getCommandMenuDropdownIdFromCommandMenuId';
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useCloseCommandMenu = ({
closeSidePanelOnShowPageOptionsExecution = false,
closeSidePanelOnCommandMenuListExecution = true,
}: {
closeSidePanelOnShowPageOptionsExecution?: boolean;
closeSidePanelOnCommandMenuListExecution?: boolean;
} = {}) => {
const { containerType, isInSidePanel } = useContext(CommandMenuContext);
const { closeSidePanelMenu } = useSidePanelMenu();
const { closeDropdown } = useCloseDropdown();
const commandMenuId = useAvailableComponentInstanceIdOrThrow(
CommandMenuComponentInstanceContext,
);
const dropdownId = isInSidePanel
? getSidePanelCommandMenuDropdownIdFromCommandMenuId(commandMenuId)
: getCommandMenuDropdownIdFromCommandMenuId(commandMenuId);
const closeCommandMenu = () => {
if (containerType === 'command-menu-list') {
if (
isDefined(closeSidePanelOnCommandMenuListExecution) &&
!closeSidePanelOnCommandMenuListExecution
) {
return;
}
closeSidePanelMenu();
}
if (
containerType === 'index-page-dropdown' ||
containerType === 'command-menu-show-page-dropdown'
) {
closeDropdown(dropdownId);
}
if (
containerType === 'command-menu-show-page-dropdown' &&
isDefined(closeSidePanelOnShowPageOptionsExecution) &&
closeSidePanelOnShowPageOptionsExecution
) {
closeSidePanelMenu();
}
};
return { closeCommandMenu };
};
@@ -0,0 +1,156 @@
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useStore } from 'jotai';
import { useContext } from 'react';
import {
CoreObjectNameSingular,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
const store = useStore();
const { isInSidePanel } = useContext(CommandMenuContext);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataItemId,
);
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const recordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const isFavorite = (() => {
if (!isDefined(recordId)) return false;
if (isNavigationMenuItemEditingEnabled && isDefined(objectMetadataItem)) {
return !!navigationMenuItems?.find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
);
}
return !!favorites?.find((favorite) => favorite.recordId === recordId);
})();
const selectedRecord =
useAtomFamilyStateValue(recordStoreFamilyState, recordId ?? '') ||
undefined;
const objectPermissionsFromHook = useObjectPermissionsForObject(
objectMetadataItem?.id ?? '',
);
const objectPermissions = isDefined(objectMetadataItem)
? objectPermissionsFromHook
: {
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
objectMetadataId: '',
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
};
const isNoteOrTask =
objectMetadataItem?.nameSingular === CoreObjectNameSingular.Note ||
objectMetadataItem?.nameSingular === CoreObjectNameSingular.Task;
const isRemote = objectMetadataItem?.isRemote ?? false;
const { recordIndexId } = useRecordIndexIdFromCurrentContextStore();
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
recordIndexId,
);
const isShowPage =
useAtomComponentStateValue(contextStoreCurrentViewTypeComponentState) ===
ContextStoreViewType.ShowPage;
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
);
const isSelectAll = contextStoreTargetedRecordsRule.mode === 'exclusion';
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const featureFlags: Record<string, boolean> = {};
for (const flag of currentWorkspace?.featureFlags ?? []) {
featureFlags[flag.key] = flag.value === true;
}
const targetObjectReadPermissions: Record<string, boolean> = {};
const targetObjectWritePermissions: Record<string, boolean> = {};
for (const metadataItem of objectMetadataItems) {
const permissions = store.get(
objectPermissionsFamilySelector.selectorFamily({
objectNameSingular: metadataItem.nameSingular,
}),
);
targetObjectReadPermissions[metadataItem.nameSingular] =
permissions.canRead;
targetObjectWritePermissions[metadataItem.nameSingular] =
permissions.canUpdate;
}
return {
isShowPage,
isInSidePanel,
isFavorite,
isRemote,
isNoteOrTask,
isSelectAll,
hasAnySoftDeleteFilterOnView,
numberOfSelectedRecords: contextStoreNumberOfSelectedRecords,
objectPermissions,
selectedRecord: selectedRecord as CommandMenuContextApi['selectedRecord'],
featureFlags,
targetObjectReadPermissions,
targetObjectWritePermissions,
};
};
@@ -1,8 +1,8 @@
import { Action } from '@/action-menu/actions/components/Action';
import { HeadlessFrontComponentAction } from '@/action-menu/actions/display/components/HeadlessFrontComponentAction';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { Command } from '@/command-menu-item/display/components/Command';
import { HeadlessFrontComponentCommandMenuItem } from '@/command-menu-item/display/components/HeadlessFrontComponentCommandMenuItem';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
@@ -34,9 +34,9 @@ type CommandMenuItemWithFrontComponent = CommandMenuItemFieldsFragment & {
conditionalAvailabilityExpression?: string | null;
};
type BuildActionFromItemParams = {
type BuildCommandMenuItemFromFrontComponentParams = {
item: CommandMenuItemWithFrontComponent;
scope: ActionScope;
scope: CommandMenuItemScope;
index: number;
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
@@ -59,7 +59,7 @@ type BuildActionFromItemParams = {
// TODO: we should remove this backward compatibility logic in the future
// once we have migrated all command menu items
const buildActionFromItem = ({
const buildCommandMenuItemFromFrontComponent = ({
item,
scope,
index,
@@ -69,7 +69,7 @@ const buildActionFromItem = ({
mountHeadlessFrontComponent,
mountContext,
commandMenuContextApi,
}: BuildActionFromItemParams) => {
}: BuildCommandMenuItemFromFrontComponentParams) => {
const displayLabel = item.label;
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
@@ -95,7 +95,7 @@ const buildActionFromItem = ({
};
return {
type: ActionType.FrontComponent,
type: CommandMenuItemType.FrontComponent,
key: `command-menu-item-front-component-${item.id}`,
scope,
label: displayLabel,
@@ -109,12 +109,12 @@ const buildActionFromItem = ({
commandMenuContextApi,
),
component: isHeadless ? (
<HeadlessFrontComponentAction
<HeadlessFrontComponentCommandMenuItem
frontComponentId={item.frontComponentId}
onClick={handleClick}
/>
) : (
<Action onClick={handleClick} />
<Command onClick={handleClick} />
),
};
};
@@ -130,7 +130,7 @@ export const useCommandMenuItemFrontComponentActions = (
contextStoreIsPageInEditModeComponentState,
);
const { actionMenuType } = useContext(ActionMenuContext);
const { containerType } = useContext(CommandMenuContext);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
@@ -166,8 +166,8 @@ export const useCommandMenuItemFrontComponentActions = (
const { data } = useFindManyCommandMenuItemsQuery({
skip:
!isCommandMenuItemEnabled ||
(actionMenuType !== 'command-menu' &&
actionMenuType !== 'command-menu-show-page-action-menu-dropdown'),
(containerType !== 'command-menu-list' &&
containerType !== 'command-menu-show-page-dropdown'),
});
const frontComponentItems =
@@ -200,10 +200,10 @@ export const useCommandMenuItemFrontComponentActions = (
);
});
const globalActions = globalItems.map((item, index) =>
buildActionFromItem({
const globalCommandMenuItems = globalItems.map((item, index) =>
buildCommandMenuItemFromFrontComponent({
item,
scope: ActionScope.Global,
scope: CommandMenuItemScope.Global,
index,
isPinned: !contextStoreIsPageInEditMode && item.isPinned,
getIcon,
@@ -213,10 +213,10 @@ export const useCommandMenuItemFrontComponentActions = (
}),
);
const recordScopedActions = recordScopedItems.map((item, index) =>
buildActionFromItem({
const recordScopedCommandMenuItems = recordScopedItems.map((item, index) =>
buildCommandMenuItemFromFrontComponent({
item,
scope: ActionScope.RecordSelection,
scope: CommandMenuItemScope.RecordSelection,
index,
isPinned: !contextStoreIsPageInEditMode && item.isPinned,
getIcon,
@@ -227,5 +227,5 @@ export const useCommandMenuItemFrontComponentActions = (
}),
);
return [...globalActions, ...recordScopedActions];
return [...globalCommandMenuItems, ...recordScopedCommandMenuItems];
};
@@ -0,0 +1,96 @@
import { useRecordAgnosticCommands } from '@/command-menu-item/record-agnostic/hooks/useRecordAgnosticCommands';
import { useRelatedRecordCommands } from '@/command-menu-item/record-agnostic/hooks/useRelatedRecordCommands';
import { CommandMenuItemViewType } from 'twenty-shared/types';
import { type ShouldBeRegisteredFunctionParams } from '@/command-menu-item/types/ShouldBeRegisteredFunctionParams';
import { getCommandMenuItemConfig } from '@/command-menu-item/utils/getCommandMenuItemConfig';
import { getCommandMenuItemViewType } from '@/command-menu-item/utils/getCommandMenuItemViewType';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const useRegisteredCommandMenuItems = (
shouldBeRegisteredParams: ShouldBeRegisteredFunctionParams,
) => {
const { objectMetadataItem } = shouldBeRegisteredParams;
const { getIcon } = useIcons();
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreCurrentViewType = useAtomComponentStateValue(
contextStoreCurrentViewTypeComponentState,
);
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
);
const viewType = getCommandMenuItemViewType(
contextStoreCurrentViewType,
contextStoreTargetedRecordsRule,
);
const recordCommandMenuItemsConfig = getCommandMenuItemConfig({
objectMetadataItem,
});
const relatedRecordCommandMenuItemsConfig = useRelatedRecordCommands({
sourceObjectMetadataItem: objectMetadataItem,
getIcon,
startPosition: Object.keys(recordCommandMenuItemsConfig).length + 1,
});
const recordAgnosticCommandMenuItemsConfig = useRecordAgnosticCommands();
const commandMenuItemsConfig = {
...recordCommandMenuItemsConfig,
...relatedRecordCommandMenuItemsConfig,
...recordAgnosticCommandMenuItemsConfig,
};
const permissionMap = usePermissionFlagMap();
const commandMenuItemsToRegister = Object.values(
commandMenuItemsConfig,
).filter((commandMenuItem) => {
if (contextStoreIsPageInEditMode) {
return (
isDefined(commandMenuItem.availableOn) &&
commandMenuItem.availableOn.includes(
CommandMenuItemViewType.PAGE_EDIT_MODE,
)
);
}
if (isDefined(viewType)) {
return (
commandMenuItem.availableOn?.includes(viewType) ||
commandMenuItem.availableOn?.includes(CommandMenuItemViewType.GLOBAL)
);
}
return commandMenuItem.availableOn?.includes(
CommandMenuItemViewType.GLOBAL,
);
});
const commandMenuItems = commandMenuItemsToRegister
.filter((commandMenuItem) => {
if (
isDefined(commandMenuItem.requiredPermissionFlag) &&
!permissionMap[commandMenuItem.requiredPermissionFlag]
) {
return false;
}
return commandMenuItem.shouldBeRegistered(shouldBeRegisteredParams);
})
.sort((a, b) => a.position - b.position);
return commandMenuItems;
};
@@ -0,0 +1,163 @@
import { type ShouldBeRegisteredFunctionParams } from '@/command-menu-item/types/ShouldBeRegisteredFunctionParams';
import { getCommandMenuItemViewType } from '@/command-menu-item/utils/getCommandMenuItemViewType';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useStore } from 'jotai';
import { useCallback, useContext, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useShouldCommandMenuItemBeRegisteredParams = ({
objectMetadataItem,
}: {
objectMetadataItem?: ObjectMetadataItem;
}): ShouldBeRegisteredFunctionParams => {
const store = useStore();
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const recordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const isFavorite = useMemo(() => {
if (!isDefined(recordId)) {
return false;
}
if (isNavigationMenuItemEditingEnabled && isDefined(objectMetadataItem)) {
const foundNavigationMenuItem = navigationMenuItems?.find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
);
return !!foundNavigationMenuItem;
}
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
return !!foundFavorite;
}, [
recordId,
isNavigationMenuItemEditingEnabled,
objectMetadataItem,
navigationMenuItems,
favorites,
]);
const selectedRecord =
useAtomFamilyStateValue(recordStoreFamilyState, recordId ?? '') ||
undefined;
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem?.id ?? '',
);
const isNoteOrTask =
objectMetadataItem?.nameSingular === CoreObjectNameSingular.Note ||
objectMetadataItem?.nameSingular === CoreObjectNameSingular.Task;
const { isInSidePanel } = useContext(CommandMenuContext);
const { recordIndexId } = useRecordIndexIdFromCurrentContextStore();
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
recordIndexId,
);
const isShowPage =
useAtomComponentStateValue(contextStoreCurrentViewTypeComponentState) ===
ContextStoreViewType.ShowPage;
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
);
const contextStoreCurrentViewType = useAtomComponentStateValue(
contextStoreCurrentViewTypeComponentState,
);
const viewType = getCommandMenuItemViewType(
contextStoreCurrentViewType,
contextStoreTargetedRecordsRule,
);
const isSelectAll = contextStoreTargetedRecordsRule.mode === 'exclusion';
const getObjectReadPermission = useCallback(
(objectMetadataNameSingular: string) => {
return store.get(
objectPermissionsFamilySelector.selectorFamily({
objectNameSingular: objectMetadataNameSingular,
}),
).canRead;
},
[store],
);
const getObjectWritePermission = useCallback(
(objectMetadataNameSingular: string) => {
return store.get(
objectPermissionsFamilySelector.selectorFamily({
objectNameSingular: objectMetadataNameSingular,
}),
).canUpdate;
},
[store],
);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const isFeatureFlagEnabled = (featureFlagKey: FeatureFlagKey) => {
const featureFlag = currentWorkspace?.featureFlags?.find(
(flag) => flag.key === featureFlagKey,
);
return featureFlag?.value === true;
};
return {
objectMetadataItem,
isFavorite,
objectPermissions,
isNoteOrTask,
isInSidePanel,
hasAnySoftDeleteFilterOnView,
isShowPage,
isSelectAll,
selectedRecord,
numberOfSelectedRecords: contextStoreNumberOfSelectedRecords,
viewType: viewType ?? undefined,
getTargetObjectReadPermission: getObjectReadPermission,
getTargetObjectWritePermission: getObjectWritePermission,
isFeatureFlagEnabled,
};
};
@@ -0,0 +1,104 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import {
CommandMenuItemViewType,
CoreObjectNameSingular,
AppPath,
} from 'twenty-shared/types';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { msg } from '@lingui/core/macro';
import {
IconFileExport,
IconHeart,
IconTrash,
IconUser,
} from 'twenty-ui/display';
export const createMockCommandMenuItems = ({
deleteMock = () => {},
addToFavoritesMock = () => {},
exportMock = () => {},
}: {
deleteMock?: () => void;
addToFavoritesMock?: () => void;
exportMock?: () => void;
}): CommandMenuItemConfig[] => [
{
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.ADD_TO_FAVORITES,
label: msg`Add to favorites`,
shortLabel: msg`Add to favorites`,
position: 2,
isPinned: true,
Icon: IconHeart,
shouldBeRegistered: () => true,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <Command onClick={addToFavoritesMock} />,
},
{
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX,
label: msg`Export`,
shortLabel: msg`Export`,
position: 4,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
shouldBeRegistered: () => true,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION],
component: <Command onClick={exportMock} />,
},
{
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.DELETE,
label: msg`Delete`,
shortLabel: msg`Delete`,
position: 7,
Icon: IconTrash,
accent: 'default',
isPinned: true,
shouldBeRegistered: () => true,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <Command onClick={deleteMock} />,
},
{
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
label: msg`Go to People`,
shortLabel: msg`People`,
position: 19,
Icon: IconUser,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Person ||
viewType === CommandMenuItemViewType.SHOW_PAGE,
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Person }}
/>
),
hotKeys: ['G', 'P'],
},
];
@@ -0,0 +1,117 @@
import { CommandMenuItemOpenSidePanelPage } from '@/command-menu-item/display/components/CommandMenuItemOpenSidePanelPage';
import { RecordAgnosticCommandKeys } from '@/command-menu-item/record-agnostic/types/RecordAgnosticCommandKeys';
import { EditNavigationSidebarNoSelectionRecordCommand } from '@/command-menu-item/record/no-selection/components/EditNavigationSidebarNoSelectionRecordCommand';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CommandMenuItemViewType, SidePanelPages } from 'twenty-shared/types';
import { msg } from '@lingui/core/macro';
import {
IconHistory,
IconLayout,
IconSearch,
IconSparkles,
} from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG: Record<
string,
CommandMenuItemConfig
> = {
[RecordAgnosticCommandKeys.SEARCH_RECORDS]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Global,
key: RecordAgnosticCommandKeys.SEARCH_RECORDS,
label: msg`Search records`,
shortLabel: msg`Search`,
position: 0,
isPinned: false,
Icon: IconSearch,
availableOn: [CommandMenuItemViewType.GLOBAL],
component: (
<CommandMenuItemOpenSidePanelPage
page={SidePanelPages.SearchRecords}
pageTitle={msg`Search`}
pageIcon={IconSearch}
shouldResetSearchState={true}
/>
),
hotKeys: ['/'],
shouldBeRegistered: () => true,
},
[RecordAgnosticCommandKeys.SEARCH_RECORDS_FALLBACK]: {
type: CommandMenuItemType.Fallback,
scope: CommandMenuItemScope.Global,
key: RecordAgnosticCommandKeys.SEARCH_RECORDS_FALLBACK,
label: msg`Search records`,
shortLabel: msg`Search`,
position: 1,
isPinned: false,
Icon: IconSearch,
availableOn: [CommandMenuItemViewType.GLOBAL],
component: (
<CommandMenuItemOpenSidePanelPage
page={SidePanelPages.SearchRecords}
pageTitle={msg`Search`}
pageIcon={IconSearch}
/>
),
hotKeys: ['/'],
shouldBeRegistered: () => true,
},
[RecordAgnosticCommandKeys.ASK_AI]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Global,
key: RecordAgnosticCommandKeys.ASK_AI,
label: msg`Ask AI`,
shortLabel: msg`Ask AI`,
position: 2,
isPinned: false,
Icon: IconSparkles,
availableOn: [CommandMenuItemViewType.GLOBAL],
component: (
<CommandMenuItemOpenSidePanelPage
page={SidePanelPages.AskAI}
pageTitle={msg`Ask AI`}
pageIcon={IconSparkles}
/>
),
hotKeys: ['@'],
shouldBeRegistered: () => true,
},
[RecordAgnosticCommandKeys.VIEW_PREVIOUS_AI_CHATS]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Global,
key: RecordAgnosticCommandKeys.VIEW_PREVIOUS_AI_CHATS,
label: msg`View Previous AI Chats`,
shortLabel: msg`Previous AI Chats`,
position: 3,
isPinned: false,
Icon: IconHistory,
availableOn: [CommandMenuItemViewType.GLOBAL],
component: (
<CommandMenuItemOpenSidePanelPage
page={SidePanelPages.ViewPreviousAIChats}
pageTitle={msg`View Previous AI Chats`}
pageIcon={IconSparkles}
/>
),
shouldBeRegistered: () => true,
},
[RecordAgnosticCommandKeys.EDIT_NAVIGATION_SIDEBAR]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: RecordAgnosticCommandKeys.EDIT_NAVIGATION_SIDEBAR,
label: msg`Edit navigation sidebar`,
shortLabel: msg`Edit sidebar`,
position: 4,
Icon: IconLayout,
isPinned: false,
availableOn: [CommandMenuItemViewType.GLOBAL],
shouldBeRegistered: ({ isFeatureFlagEnabled }) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
),
component: <EditNavigationSidebarNoSelectionRecordCommand />,
},
};
@@ -0,0 +1,251 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { renderHook } from '@testing-library/react';
import { useRelatedRecordCommands } from '@/command-menu-item/record-agnostic/hooks/useRelatedRecordCommands';
jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({
useObjectMetadataItems: () => ({
objectMetadataItems: [
{
id: 'person-id',
nameSingular: CoreObjectNameSingular.Person,
namePlural: 'People',
labelSingular: 'Person',
},
{
id: 'company-id',
nameSingular: CoreObjectNameSingular.Company,
namePlural: 'Companies',
labelSingular: 'Company',
},
],
}),
}));
describe('useRelatedRecordCommands', () => {
const mockGetIcon = jest.fn();
beforeEach(() => {
mockGetIcon.mockClear();
});
it('should return empty object when objectMetadataItem has no fields', () => {
const objectMetadataItem = {
fields: [],
readableFields: [],
updatableFields: [],
} as unknown as ObjectMetadataItem;
const { result } = renderHook(() =>
useRelatedRecordCommands({
sourceObjectMetadataItem: objectMetadataItem,
getIcon: mockGetIcon,
startPosition: 18,
}),
);
expect(result.current).toEqual({});
});
it('should return empty object when objectMetadataItem is undefined', () => {
const objectMetadataItem = undefined as unknown as ObjectMetadataItem;
const { result } = renderHook(() =>
useRelatedRecordCommands({
sourceObjectMetadataItem: objectMetadataItem,
getIcon: mockGetIcon,
startPosition: 18,
}),
);
expect(result.current).toEqual({});
});
it('should generate actions for one-to-many relations', () => {
const fields = [
{
type: 'RELATION',
relation: {
type: 'ONE_TO_MANY',
targetObjectMetadata: {
nameSingular: CoreObjectNameSingular.Person,
namePlural: 'People',
},
},
label: 'person',
isSystem: false,
},
{
type: 'RELATION',
relation: {
type: 'ONE_TO_MANY',
targetObjectMetadata: {
nameSingular: CoreObjectNameSingular.Company,
namePlural: 'Companies',
},
},
label: 'company',
isSystem: false,
},
];
const objectMetadataItem = {
fields,
readableFields: fields,
updatableFields: fields,
} as unknown as ObjectMetadataItem;
const { result } = renderHook(() =>
useRelatedRecordCommands({
sourceObjectMetadataItem: objectMetadataItem,
getIcon: mockGetIcon,
startPosition: 18,
}),
);
expect(Object.keys(result.current)).toHaveLength(2);
expect(result.current['create-related-person']).toBeDefined();
expect(result.current['create-related-company']).toBeDefined();
});
it('should filter out non-one-to-many relations', () => {
const fields = [
{
type: 'RELATION',
relation: {
type: 'MANY_TO_ONE',
targetObjectMetadata: {
nameSingular: CoreObjectNameSingular.Person,
namePlural: 'People',
},
},
label: 'person',
isSystem: false,
},
{
type: 'TEXT',
},
{
type: 'RELATION',
relation: {
type: 'ONE_TO_MANY',
targetObjectMetadata: {
nameSingular: CoreObjectNameSingular.Company,
namePlural: 'Companies',
},
},
label: 'company',
isSystem: false,
},
];
const objectMetadataItem = {
fields,
readableFields: fields,
updatableFields: fields,
} as unknown as ObjectMetadataItem;
const { result } = renderHook(() =>
useRelatedRecordCommands({
sourceObjectMetadataItem: objectMetadataItem,
getIcon: mockGetIcon,
startPosition: 18,
}),
);
expect(Object.keys(result.current)).toHaveLength(1);
expect(result.current['create-related-company']).toBeDefined();
expect(result.current['create-related-person']).toBeUndefined();
});
it('should assign correct positions to each action', () => {
const fields = [
{
type: 'RELATION',
relation: {
type: 'ONE_TO_MANY',
targetObjectMetadata: {
nameSingular: CoreObjectNameSingular.Person,
namePlural: 'People',
},
},
label: 'person',
isSystem: false,
},
{
type: 'RELATION',
relation: {
type: 'ONE_TO_MANY',
targetObjectMetadata: {
nameSingular: CoreObjectNameSingular.Company,
namePlural: 'Companies',
},
},
label: 'company',
isSystem: false,
},
];
const objectMetadataItem = {
fields,
readableFields: fields,
updatableFields: fields,
} as unknown as ObjectMetadataItem;
const { result } = renderHook(() =>
useRelatedRecordCommands({
sourceObjectMetadataItem: objectMetadataItem,
getIcon: mockGetIcon,
startPosition: 18,
}),
);
expect(result.current['create-related-person'].position).toBe(18);
expect(result.current['create-related-company'].position).toBe(19);
});
it('should filter out hidden system fields', () => {
const fields = [
{
type: 'RELATION',
name: 'position',
relation: {
type: 'ONE_TO_MANY',
targetObjectMetadata: {
nameSingular: CoreObjectNameSingular.Person,
namePlural: 'People',
},
},
label: 'person',
isSystem: true,
},
{
type: 'RELATION',
relation: {
type: 'ONE_TO_MANY',
targetObjectMetadata: {
nameSingular: CoreObjectNameSingular.Company,
namePlural: 'Companies',
},
},
label: 'company',
isSystem: false,
},
];
const objectMetadataItem = {
fields,
readableFields: fields,
updatableFields: fields,
} as unknown as ObjectMetadataItem;
const { result } = renderHook(() =>
useRelatedRecordCommands({
sourceObjectMetadataItem: objectMetadataItem,
getIcon: mockGetIcon,
startPosition: 18,
}),
);
expect(Object.keys(result.current)).toHaveLength(1);
expect(result.current['create-related-company']).toBeDefined();
expect(result.current['create-related-person']).toBeUndefined();
});
});
@@ -0,0 +1,37 @@
import { RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG } from '@/command-menu-item/record-agnostic/constants/RecordAgnosticCommandMenuItemsConfig';
import { RecordAgnosticCommandKeys } from '@/command-menu-item/record-agnostic/types/RecordAgnosticCommandKeys';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useRecordAgnosticCommands = () => {
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const commandMenuItems: Record<string, CommandMenuItemConfig> = {
[RecordAgnosticCommandKeys.SEARCH_RECORDS]:
RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG[
RecordAgnosticCommandKeys.SEARCH_RECORDS
],
[RecordAgnosticCommandKeys.SEARCH_RECORDS_FALLBACK]:
RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG[
RecordAgnosticCommandKeys.SEARCH_RECORDS_FALLBACK
],
[RecordAgnosticCommandKeys.EDIT_NAVIGATION_SIDEBAR]:
RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG[
RecordAgnosticCommandKeys.EDIT_NAVIGATION_SIDEBAR
],
};
if (isAiEnabled) {
commandMenuItems[RecordAgnosticCommandKeys.ASK_AI] =
RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG[
RecordAgnosticCommandKeys.ASK_AI
];
commandMenuItems[RecordAgnosticCommandKeys.VIEW_PREVIOUS_AI_CHATS] =
RECORD_AGNOSTIC_COMMAND_MENU_ITEMS_CONFIG[
RecordAgnosticCommandKeys.VIEW_PREVIOUS_AI_CHATS
];
}
return commandMenuItems;
};
@@ -0,0 +1,124 @@
import { CreateRelatedRecordCommand } from '@/command-menu-item/record/single-record/components/CreateRelatedRecordCommand';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import {
CommandMenuItemViewType,
CoreObjectNameSingular,
} from 'twenty-shared/types';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { isRecordReadOnly } from '@/object-record/read-only/utils/isRecordReadOnly';
import { msg } from '@lingui/core/macro';
import React from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type IconComponent, IconPlus } from 'twenty-ui/display';
interface GenerateRelatedRecordCommandsParams {
sourceObjectMetadataItem?: ObjectMetadataItem;
getIcon: (iconKey: string) => IconComponent;
startPosition: number;
}
export const useRelatedRecordCommands = ({
sourceObjectMetadataItem,
getIcon,
startPosition,
}: GenerateRelatedRecordCommandsParams): Record<
string,
CommandMenuItemConfig
> => {
const relatedCommands: Record<string, CommandMenuItemConfig> = {};
const { objectMetadataItems } = useObjectMetadataItems();
if (!sourceObjectMetadataItem?.fields) {
return relatedCommands;
}
const oneToManyFields = sourceObjectMetadataItem.readableFields.filter(
(field) =>
field.type === 'RELATION' &&
field.relation?.type === 'ONE_TO_MANY' &&
!isHiddenSystemField(field),
);
let currentPosition = startPosition;
for (const field of oneToManyFields) {
if (!isDefined(field.relation)) {
throw new Error(`Field relation is undefined for field: ${field.id}`);
}
const targetObjectName = field.relation.targetObjectMetadata.nameSingular;
const targetObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectName,
);
if (!isDefined(targetObjectMetadataItem)) {
throw new Error(
`Target object metadata item is undefined for field: ${field.id}`,
);
}
const targetObjectNameSingular = targetObjectMetadataItem.nameSingular;
const targetObjectLabelSingular =
targetObjectNameSingular === CoreObjectNameSingular.TaskTarget
? 'task'
: targetObjectNameSingular === CoreObjectNameSingular.NoteTarget
? 'note'
: targetObjectMetadataItem.labelSingular.toLowerCase();
const actionKey = `create-related-${targetObjectLabelSingular}`;
relatedCommands[actionKey] = {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.CreateRelatedRecord,
key: actionKey,
label: msg`Create ${targetObjectLabelSingular}`,
shortLabel: msg`Create ${targetObjectLabelSingular}`,
position: currentPosition,
Icon: field.icon ? getIcon(field.icon) : IconPlus,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({
selectedRecord,
objectPermissions,
getTargetObjectWritePermission,
objectMetadataItem,
}) =>
(isDefined(selectedRecord) &&
isDefined(objectMetadataItem) &&
isRecordReadOnly({
objectPermissions: {
canUpdateObjectRecords: objectPermissions.canUpdateObjectRecords,
objectMetadataId: objectMetadataItem.id,
},
objectMetadataItem,
isRecordDeleted: isDefined(selectedRecord.deletedAt),
}) &&
objectPermissions.canUpdateObjectRecords &&
getTargetObjectWritePermission(
targetObjectNameSingular === CoreObjectNameSingular.TaskTarget
? CoreObjectNameSingular.Task
: targetObjectNameSingular === CoreObjectNameSingular.NoteTarget
? CoreObjectNameSingular.Note
: targetObjectNameSingular,
)) ??
false,
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: React.createElement(CreateRelatedRecordCommand, {
targetFieldMetadataItemRelation: field.relation,
}),
};
currentPosition++;
}
return relatedCommands;
};
@@ -0,0 +1,7 @@
export enum RecordAgnosticCommandKeys {
SEARCH_RECORDS = 'search-records',
SEARCH_RECORDS_FALLBACK = 'search-records-fallback',
ASK_AI = 'ask-ai',
VIEW_PREVIOUS_AI_CHATS = 'view-previous-ai-chats',
EDIT_NAVIGATION_SIDEBAR = 'edit-navigation-sidebar',
}
@@ -0,0 +1,71 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useActiveWorkflowVersionsWithManualTrigger } from '@/workflow/hooks/useActiveWorkflowVersionsWithManualTrigger';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { useContext } from 'react';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const useRunWorkflowRecordAgnosticCommands = () => {
const { getIcon } = useIcons();
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
);
const { containerType } = useContext(CommandMenuContext);
const { records: activeWorkflowVersions } =
useActiveWorkflowVersionsWithManualTrigger({
skip:
containerType !== 'command-menu-list' &&
containerType !== 'command-menu-show-page-dropdown',
});
const { runWorkflowVersion } = useRunWorkflowVersion();
return activeWorkflowVersions
.map((activeWorkflowVersion, index) => {
if (!isDefined(activeWorkflowVersion.workflow)) {
return undefined;
}
const name = capitalize(activeWorkflowVersion.workflow.name);
const Icon = getIcon(
activeWorkflowVersion.trigger?.settings.icon,
COMMAND_MENU_DEFAULT_ICON,
);
return {
type: CommandMenuItemType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`,
scope: CommandMenuItemScope.Global,
label: name,
shortLabel: name,
position: index,
isPinned:
!contextStoreIsPageInEditMode &&
activeWorkflowVersion.trigger?.settings?.isPinned,
Icon,
shouldBeRegistered: () => true,
component: (
<Command
onClick={() => {
runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
workflowId: activeWorkflowVersion.workflowId,
});
}}
closeSidePanelOnCommandMenuListExecution={false}
/>
),
};
})
.filter(isDefined);
};
@@ -0,0 +1,204 @@
import { MultipleRecordsCommandKeys } from '@/command-menu-item/record/multiple-records/types/MultipleRecordsCommandKeys';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { CancelDashboardSingleRecordCommand } from '@/command-menu-item/record/single-record/dashboard/components/CancelDashboardSingleRecordCommand';
import { DuplicateDashboardSingleRecordCommand } from '@/command-menu-item/record/single-record/dashboard/components/DuplicateDashboardSingleRecordCommand';
import { EditDashboardSingleRecordCommand } from '@/command-menu-item/record/single-record/dashboard/components/EditDashboardSingleRecordCommand';
import { SaveDashboardSingleRecordCommand } from '@/command-menu-item/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand';
import { DashboardSingleRecordCommandKeys } from '@/command-menu-item/record/single-record/dashboard/types/DashboardSingleRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { inheritCommandMenuItemsFromDefaultConfig } from '@/command-menu-item/record/utils/inheritCommandMenuItemsFromDefaultConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CommandMenuItemViewType } from 'twenty-shared/types';
import { PageLayoutSingleRecordActionKeys } from '@/page-layout/actions/PageLayoutSingleRecordActionKeys';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconCancel,
IconCopyPlus,
IconDeviceFloppy,
IconPencil,
} from 'twenty-ui/display';
export const DASHBOARD_COMMAND_MENU_ITEMS_CONFIG =
inheritCommandMenuItemsFromDefaultConfig({
config: {
[PageLayoutSingleRecordActionKeys.EDIT_LAYOUT]: {
key: PageLayoutSingleRecordActionKeys.EDIT_LAYOUT,
label: msg`Edit Dashboard`,
shortLabel: msg`Edit`,
isPinned: true,
position: 3,
Icon: IconPencil,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, objectPermissions }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
isDefined(selectedRecord?.pageLayoutId) &&
objectPermissions.canUpdateObjectRecords,
availableOn: [CommandMenuItemViewType.SHOW_PAGE],
component: <EditDashboardSingleRecordCommand />,
},
[PageLayoutSingleRecordActionKeys.SAVE_LAYOUT]: {
key: PageLayoutSingleRecordActionKeys.SAVE_LAYOUT,
label: msg`Save Dashboard`,
shortLabel: msg`Save`,
isPinned: true,
isPrimaryCTA: true,
position: 4,
Icon: IconDeviceFloppy,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, objectPermissions }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
isDefined(selectedRecord?.pageLayoutId) &&
objectPermissions.canUpdateObjectRecords,
availableOn: [CommandMenuItemViewType.PAGE_EDIT_MODE],
component: <SaveDashboardSingleRecordCommand />,
},
[PageLayoutSingleRecordActionKeys.CANCEL_LAYOUT_EDITION]: {
key: PageLayoutSingleRecordActionKeys.CANCEL_LAYOUT_EDITION,
label: msg`Cancel Edition`,
shortLabel: msg`Cancel`,
isPinned: true,
position: 5,
Icon: IconCancel,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, objectPermissions }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
isDefined(selectedRecord?.pageLayoutId) &&
objectPermissions.canUpdateObjectRecords,
availableOn: [CommandMenuItemViewType.PAGE_EDIT_MODE],
component: <CancelDashboardSingleRecordCommand />,
},
[DashboardSingleRecordCommandKeys.DUPLICATE_DASHBOARD]: {
key: DashboardSingleRecordCommandKeys.DUPLICATE_DASHBOARD,
label: msg`Duplicate Dashboard`,
shortLabel: msg`Duplicate`,
isPinned: false,
position: 6,
Icon: IconCopyPlus,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, objectPermissions }) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
objectPermissions.canUpdateObjectRecords,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <DuplicateDashboardSingleRecordCommand />,
},
},
commandKeys: [
NoSelectionRecordCommandKeys.CREATE_NEW_RECORD,
SingleRecordCommandKeys.ADD_TO_FAVORITES,
SingleRecordCommandKeys.REMOVE_FROM_FAVORITES,
SingleRecordCommandKeys.DELETE,
SingleRecordCommandKeys.DESTROY,
SingleRecordCommandKeys.RESTORE,
MultipleRecordsCommandKeys.DELETE,
MultipleRecordsCommandKeys.DESTROY,
MultipleRecordsCommandKeys.RESTORE,
NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW,
SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD,
SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD,
NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS,
NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
NoSelectionRecordCommandKeys.GO_TO_COMPANIES,
NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES,
NoSelectionRecordCommandKeys.GO_TO_SETTINGS,
NoSelectionRecordCommandKeys.GO_TO_TASKS,
NoSelectionRecordCommandKeys.GO_TO_NOTES,
],
propertiesToOverwrite: {
[SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD]: {
position: 0,
label: msg`Navigate to next dashboard`,
},
[SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
position: 1,
label: msg`Navigate to previous dashboard`,
},
[NoSelectionRecordCommandKeys.CREATE_NEW_RECORD]: {
position: 2,
label: msg`Create new dashboard`,
},
[SingleRecordCommandKeys.DELETE]: {
position: 7,
label: msg`Delete dashboard`,
},
[MultipleRecordsCommandKeys.DELETE]: {
position: 12,
label: msg`Delete dashboards`,
},
[SingleRecordCommandKeys.ADD_TO_FAVORITES]: {
position: 8,
isPinned: true,
},
[SingleRecordCommandKeys.REMOVE_FROM_FAVORITES]: {
position: 9,
isPinned: true,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 10,
label: msg`Export dashboard`,
},
[SingleRecordCommandKeys.DESTROY]: {
position: 11,
label: msg`Permanently destroy dashboard`,
},
[MultipleRecordsCommandKeys.DESTROY]: {
position: 13,
label: msg`Permanently destroy dashboards`,
},
[SingleRecordCommandKeys.RESTORE]: {
position: 14,
label: msg`Restore dashboard`,
},
[MultipleRecordsCommandKeys.RESTORE]: {
position: 15,
label: msg`Restore dashboards`,
},
[NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS]: {
position: 22,
label: msg`See deleted dashboards`,
},
[NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS]: {
position: 23,
label: msg`Hide deleted dashboards`,
},
[NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS]: {
position: 24,
},
[NoSelectionRecordCommandKeys.GO_TO_PEOPLE]: {
position: 25,
},
[NoSelectionRecordCommandKeys.GO_TO_COMPANIES]: {
position: 26,
},
[NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES]: {
position: 27,
},
[NoSelectionRecordCommandKeys.GO_TO_SETTINGS]: {
position: 28,
},
[NoSelectionRecordCommandKeys.GO_TO_TASKS]: {
position: 29,
},
[NoSelectionRecordCommandKeys.GO_TO_NOTES]: {
position: 30,
},
},
});
@@ -0,0 +1,843 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { DeleteMultipleRecordsCommand } from '@/command-menu-item/record/multiple-records/components/DeleteMultipleRecordsCommand';
import { DestroyMultipleRecordsCommand } from '@/command-menu-item/record/multiple-records/components/DestroyMultipleRecordsCommand';
import { ExportMultipleRecordsCommand } from '@/command-menu-item/record/multiple-records/components/ExportMultipleRecordsCommand';
import { MergeMultipleRecordsCommand } from '@/command-menu-item/record/multiple-records/components/MergeMultipleRecordsCommand';
import { RestoreMultipleRecordsCommand } from '@/command-menu-item/record/multiple-records/components/RestoreMultipleRecordsCommand';
import { UpdateMultipleRecordsCommand } from '@/command-menu-item/record/multiple-records/components/UpdateMultipleRecordsCommand';
import { MultipleRecordsCommandKeys } from '@/command-menu-item/record/multiple-records/types/MultipleRecordsCommandKeys';
import { CreateNewIndexRecordNoSelectionRecordCommand } from '@/command-menu-item/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand';
import { CreateNewViewNoSelectionRecordCommand } from '@/command-menu-item/record/no-selection/components/CreateNewViewNoSelectionRecordCommand';
import { HideDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand';
import { ImportRecordsNoSelectionRecordCommand } from '@/command-menu-item/record/no-selection/components/ImportRecordsNoSelectionRecordCommand';
import { SeeDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/record/no-selection/components/SeeDeletedRecordsNoSelectionRecordCommand';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { AddToFavoritesSingleRecordCommand } from '@/command-menu-item/record/single-record/components/AddToFavoritesSingleRecordCommand';
import { DeleteSingleRecordCommand } from '@/command-menu-item/record/single-record/components/DeleteSingleRecordCommand';
import { DestroySingleRecordCommand } from '@/command-menu-item/record/single-record/components/DestroySingleRecordCommand';
import { ExportNoteSingleRecordCommand } from '@/command-menu-item/record/single-record/components/ExportNoteSingleRecordCommand';
import { ExportSingleRecordCommand } from '@/command-menu-item/record/single-record/components/ExportSingleRecordCommand';
import { NavigateToNextRecordSingleRecordCommand } from '@/command-menu-item/record/single-record/components/NavigateToNextRecordSingleRecordCommand';
import { NavigateToPreviousRecordSingleRecordCommand } from '@/command-menu-item/record/single-record/components/NavigateToPreviousRecordSingleRecordCommand';
import { RemoveFromFavoritesSingleRecordCommand } from '@/command-menu-item/record/single-record/components/RemoveFromFavoritesSingleRecordCommand';
import { RestoreSingleRecordCommand } from '@/command-menu-item/record/single-record/components/RestoreSingleRecordCommand';
import { CancelRecordPageLayoutSingleRecordCommand } from '@/command-menu-item/record/single-record/record-page-layout/components/CancelRecordPageLayoutSingleRecordCommand';
import { EditRecordPageLayoutSingleRecordCommand } from '@/command-menu-item/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand';
import { SaveRecordPageLayoutSingleRecordCommand } from '@/command-menu-item/record/single-record/record-page-layout/components/SaveRecordPageLayoutSingleRecordCommand';
import { RecordPageLayoutSingleRecordCommandKeys } from '@/command-menu-item/record/single-record/record-page-layout/types/RecordPageLayoutSingleRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import {
CommandMenuItemViewType,
CoreObjectNameSingular,
AppPath,
SettingsPath,
} from 'twenty-shared/types';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import {
BACKEND_BATCH_REQUEST_MAX_COUNT,
MUTATION_MAX_MERGE_RECORDS,
} from 'twenty-shared/constants';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import {
IconArrowMerge,
IconBuildingSkyscraper,
IconCancel,
IconCheckbox,
IconChevronDown,
IconChevronUp,
IconDeviceFloppy,
IconEdit,
IconEyeOff,
IconFileExport,
IconFileImport,
IconHeart,
IconHeartOff,
IconLayout,
IconLayoutDashboard,
IconPencil,
IconPlus,
IconRefresh,
IconRotate2,
IconSettings,
IconSettingsAutomation,
IconTargetArrow,
IconTrash,
IconTrashX,
IconUser,
} from 'twenty-ui/display';
import { isDefined } from 'twenty-shared/utils';
import {
PermissionFlagType,
FeatureFlagKey,
} from '~/generated-metadata/graphql';
export const DEFAULT_RECORD_COMMAND_MENU_ITEMS_CONFIG: Record<
| NoSelectionRecordCommandKeys
| SingleRecordCommandKeys
| MultipleRecordsCommandKeys
| RecordPageLayoutSingleRecordCommandKeys,
CommandMenuItemConfig
> = {
[SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD,
label: msg`Navigate to next record`,
position: 0,
isPinned: true,
Icon: IconChevronDown,
shouldBeRegistered: ({ isInSidePanel }) => !isInSidePanel,
availableOn: [CommandMenuItemViewType.SHOW_PAGE],
component: <NavigateToNextRecordSingleRecordCommand />,
},
[SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD,
label: msg`Navigate to previous record`,
position: 1,
isPinned: true,
Icon: IconChevronUp,
shouldBeRegistered: ({ isInSidePanel }) => !isInSidePanel,
availableOn: [CommandMenuItemViewType.SHOW_PAGE],
component: <NavigateToPreviousRecordSingleRecordCommand />,
},
[NoSelectionRecordCommandKeys.CREATE_NEW_RECORD]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Object,
key: NoSelectionRecordCommandKeys.CREATE_NEW_RECORD,
label: msg`Create new record`,
shortLabel: msg`New record`,
position: 2,
isPinned: true,
Icon: IconPlus,
shouldBeRegistered: ({ objectPermissions, hasAnySoftDeleteFilterOnView }) =>
(objectPermissions.canUpdateObjectRecords &&
!hasAnySoftDeleteFilterOnView) ??
false,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: <CreateNewIndexRecordNoSelectionRecordCommand />,
},
[SingleRecordCommandKeys.DELETE]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.DELETE,
label: msg`Delete`,
shortLabel: msg`Delete`,
position: 3,
Icon: IconTrash,
accent: 'default',
isPinned: true,
shouldBeRegistered: ({
selectedRecord,
hasAnySoftDeleteFilterOnView,
objectPermissions,
}) =>
(isDefined(selectedRecord) &&
!selectedRecord.isRemote &&
!hasAnySoftDeleteFilterOnView &&
objectPermissions.canSoftDeleteObjectRecords &&
!isDefined(selectedRecord?.deletedAt)) ??
false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <DeleteSingleRecordCommand />,
},
[MultipleRecordsCommandKeys.DELETE]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: MultipleRecordsCommandKeys.DELETE,
label: msg`Delete records`,
shortLabel: msg`Delete`,
position: 4,
Icon: IconTrash,
accent: 'default',
isPinned: true,
shouldBeRegistered: ({
objectPermissions,
isRemote,
hasAnySoftDeleteFilterOnView,
numberOfSelectedRecords,
}) =>
(objectPermissions.canSoftDeleteObjectRecords &&
!isRemote &&
!hasAnySoftDeleteFilterOnView &&
isDefined(numberOfSelectedRecords) &&
numberOfSelectedRecords < BACKEND_BATCH_REQUEST_MAX_COUNT) ??
false,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION],
component: <DeleteMultipleRecordsCommand />,
},
[SingleRecordCommandKeys.RESTORE]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.RESTORE,
label: msg`Restore record`,
shortLabel: msg`Restore`,
position: 5,
Icon: IconRefresh,
accent: 'default',
isPinned: true,
shouldBeRegistered: ({
selectedRecord,
objectPermissions,
isRemote,
isShowPage,
hasAnySoftDeleteFilterOnView,
}) =>
(!isRemote &&
isDefined(selectedRecord?.deletedAt) &&
objectPermissions.canSoftDeleteObjectRecords &&
((isDefined(isShowPage) && isShowPage) ||
(isDefined(hasAnySoftDeleteFilterOnView) &&
hasAnySoftDeleteFilterOnView))) ??
false,
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <RestoreSingleRecordCommand />,
},
[MultipleRecordsCommandKeys.RESTORE]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: MultipleRecordsCommandKeys.RESTORE,
label: msg`Restore records`,
shortLabel: msg`Restore`,
position: 6,
Icon: IconRefresh,
accent: 'default',
isPinned: true,
shouldBeRegistered: ({
objectPermissions,
isRemote,
hasAnySoftDeleteFilterOnView,
numberOfSelectedRecords,
}) =>
(objectPermissions.canSoftDeleteObjectRecords &&
!isRemote &&
isDefined(hasAnySoftDeleteFilterOnView) &&
hasAnySoftDeleteFilterOnView &&
isDefined(numberOfSelectedRecords) &&
numberOfSelectedRecords < BACKEND_BATCH_REQUEST_MAX_COUNT) ??
false,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION],
component: <RestoreMultipleRecordsCommand />,
},
[SingleRecordCommandKeys.DESTROY]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.DESTROY,
label: msg`Permanently destroy record`,
shortLabel: msg`Destroy`,
position: 7,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
shouldBeRegistered: ({ selectedRecord, objectPermissions, isRemote }) =>
(objectPermissions.canDestroyObjectRecords &&
!isRemote &&
isDefined(selectedRecord?.deletedAt)) ??
false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <DestroySingleRecordCommand />,
},
[MultipleRecordsCommandKeys.DESTROY]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: MultipleRecordsCommandKeys.DESTROY,
label: msg`Permanently destroy records`,
shortLabel: msg`Destroy`,
position: 8,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
shouldBeRegistered: ({
objectPermissions,
isRemote,
hasAnySoftDeleteFilterOnView,
numberOfSelectedRecords,
}) =>
(objectPermissions.canDestroyObjectRecords &&
!isRemote &&
isDefined(hasAnySoftDeleteFilterOnView) &&
hasAnySoftDeleteFilterOnView &&
isDefined(numberOfSelectedRecords) &&
numberOfSelectedRecords < BACKEND_BATCH_REQUEST_MAX_COUNT) ??
false,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION],
component: <DestroyMultipleRecordsCommand />,
},
[SingleRecordCommandKeys.ADD_TO_FAVORITES]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.ADD_TO_FAVORITES,
label: msg`Add to favorites`,
shortLabel: msg`Add to favorites`,
position: 9,
isPinned: true,
Icon: IconHeart,
shouldBeRegistered: ({
selectedRecord,
isFavorite,
hasAnySoftDeleteFilterOnView,
}) =>
!selectedRecord?.isRemote &&
!isFavorite &&
!isDefined(selectedRecord?.deletedAt) &&
!hasAnySoftDeleteFilterOnView,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <AddToFavoritesSingleRecordCommand />,
},
[SingleRecordCommandKeys.REMOVE_FROM_FAVORITES]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.REMOVE_FROM_FAVORITES,
label: msg`Remove from favorites`,
shortLabel: msg`Remove from favorites`,
isPinned: true,
position: 10,
Icon: IconHeartOff,
shouldBeRegistered: ({
selectedRecord,
isFavorite,
hasAnySoftDeleteFilterOnView,
}) =>
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
isDefined(isFavorite) &&
isFavorite &&
!isDefined(selectedRecord?.deletedAt) &&
!hasAnySoftDeleteFilterOnView,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
component: <RemoveFromFavoritesSingleRecordCommand />,
},
[SingleRecordCommandKeys.EXPORT_NOTE_TO_PDF]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.EXPORT_NOTE_TO_PDF,
label: msg`Export to PDF`,
shortLabel: msg`Export`,
position: 11,
isPinned: false,
Icon: IconFileExport,
shouldBeRegistered: ({ selectedRecord, isNoteOrTask }) =>
isDefined(isNoteOrTask) &&
isNoteOrTask &&
isNonEmptyString(selectedRecord?.bodyV2?.blocknote),
availableOn: [CommandMenuItemViewType.SHOW_PAGE],
component: <ExportNoteSingleRecordCommand />,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX,
label: msg`Export`,
shortLabel: msg`Export`,
position: 12,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) && !selectedRecord.isRemote,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION],
component: <ExportMultipleRecordsCommand />,
requiredPermissionFlag: PermissionFlagType.EXPORT_CSV,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW,
label: msg`Export`,
shortLabel: msg`Export`,
position: 13,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) && !selectedRecord.isRemote,
availableOn: [CommandMenuItemViewType.SHOW_PAGE],
component: <ExportSingleRecordCommand />,
requiredPermissionFlag: PermissionFlagType.EXPORT_CSV,
},
[MultipleRecordsCommandKeys.UPDATE]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: MultipleRecordsCommandKeys.UPDATE,
label: msg`Update records`,
shortLabel: msg`Update`,
position: 14,
Icon: IconEdit,
accent: 'default',
isPinned: true,
shouldBeRegistered: ({ objectPermissions, isRemote }) =>
objectPermissions.canUpdateObjectRecords && !isRemote,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION],
component: <UpdateMultipleRecordsCommand />,
},
[MultipleRecordsCommandKeys.MERGE]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: MultipleRecordsCommandKeys.MERGE,
label: msg`Merge records`,
shortLabel: msg`Merge`,
position: 15,
Icon: IconArrowMerge,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({
objectMetadataItem,
numberOfSelectedRecords,
objectPermissions,
}) =>
isDefined(objectMetadataItem?.duplicateCriteria) &&
isDefined(numberOfSelectedRecords) &&
Boolean(objectPermissions.canUpdateObjectRecords) &&
Boolean(objectPermissions.canDestroyObjectRecords) &&
numberOfSelectedRecords <= MUTATION_MAX_MERGE_RECORDS,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION],
component: <MergeMultipleRecordsCommand />,
},
[MultipleRecordsCommandKeys.EXPORT]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
key: MultipleRecordsCommandKeys.EXPORT,
label: msg`Export records`,
shortLabel: msg`Export`,
position: 16,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
shouldBeRegistered: () => true,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION],
component: <ExportMultipleRecordsCommand />,
requiredPermissionFlag: PermissionFlagType.EXPORT_CSV,
},
[NoSelectionRecordCommandKeys.IMPORT_RECORDS]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Object,
key: NoSelectionRecordCommandKeys.IMPORT_RECORDS,
label: msg`Import records`,
shortLabel: msg`Import`,
position: 17,
Icon: IconFileImport,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({ hasAnySoftDeleteFilterOnView }) =>
!hasAnySoftDeleteFilterOnView,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: <ImportRecordsNoSelectionRecordCommand />,
requiredPermissionFlag: PermissionFlagType.IMPORT_CSV,
},
[NoSelectionRecordCommandKeys.EXPORT_VIEW]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Object,
key: NoSelectionRecordCommandKeys.EXPORT_VIEW,
label: msg`Export view`,
shortLabel: msg`Export`,
position: 18,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
shouldBeRegistered: () => true,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: <ExportMultipleRecordsCommand />,
requiredPermissionFlag: PermissionFlagType.EXPORT_CSV,
},
[NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Object,
key: NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS,
label: msg`See deleted records`,
shortLabel: msg`Deleted records`,
position: 19,
Icon: IconRotate2,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({ hasAnySoftDeleteFilterOnView }) =>
!hasAnySoftDeleteFilterOnView,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: <SeeDeletedRecordsNoSelectionRecordCommand />,
},
[NoSelectionRecordCommandKeys.CREATE_NEW_VIEW]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Object,
key: NoSelectionRecordCommandKeys.CREATE_NEW_VIEW,
label: msg`Create View`,
shortLabel: msg`Create View`,
position: 20,
Icon: IconLayout,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({ hasAnySoftDeleteFilterOnView }) =>
!hasAnySoftDeleteFilterOnView,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: <CreateNewViewNoSelectionRecordCommand />,
},
[NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS]: {
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.Object,
key: NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS,
label: msg`Hide deleted records`,
shortLabel: msg`Hide deleted`,
position: 21,
Icon: IconEyeOff,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({ hasAnySoftDeleteFilterOnView }) =>
isDefined(hasAnySoftDeleteFilterOnView) && hasAnySoftDeleteFilterOnView,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: <HideDeletedRecordsNoSelectionRecordCommand />,
},
[NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS,
label: msg`Go to Workflows`,
shortLabel: msg`See Workflows`,
position: 22,
Icon: IconSettingsAutomation,
accent: 'default',
isPinned: false,
shouldBeRegistered: ({
objectMetadataItem,
viewType,
getTargetObjectReadPermission,
}) =>
getTargetObjectReadPermission(CoreObjectNameSingular.Workflow) &&
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Workflow ||
viewType === CommandMenuItemViewType.SHOW_PAGE),
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.PAGE_EDIT_MODE,
],
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Workflow }}
/>
),
hotKeys: ['G', 'W'],
},
[NoSelectionRecordCommandKeys.GO_TO_PEOPLE]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
label: msg`Go to People`,
shortLabel: msg`People`,
position: 23,
Icon: IconUser,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.PAGE_EDIT_MODE,
],
shouldBeRegistered: ({
objectMetadataItem,
viewType,
getTargetObjectReadPermission,
}) =>
getTargetObjectReadPermission(CoreObjectNameSingular.Person) &&
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Person ||
viewType === CommandMenuItemViewType.SHOW_PAGE),
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Person }}
/>
),
hotKeys: ['G', 'P'],
},
[NoSelectionRecordCommandKeys.GO_TO_COMPANIES]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_COMPANIES,
label: msg`Go to Companies`,
shortLabel: msg`Companies`,
position: 24,
Icon: IconBuildingSkyscraper,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.PAGE_EDIT_MODE,
],
shouldBeRegistered: ({
objectMetadataItem,
viewType,
getTargetObjectReadPermission,
}) =>
getTargetObjectReadPermission(CoreObjectNameSingular.Company) &&
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Company ||
viewType === CommandMenuItemViewType.SHOW_PAGE),
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Company }}
/>
),
hotKeys: ['G', 'C'],
},
[NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS,
label: msg`Go to Dashboards`,
shortLabel: msg`Dashboards`,
position: 25,
Icon: IconLayoutDashboard,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
shouldBeRegistered: ({
objectMetadataItem,
viewType,
getTargetObjectReadPermission,
}) =>
getTargetObjectReadPermission(CoreObjectNameSingular.Dashboard) &&
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Dashboard ||
viewType === CommandMenuItemViewType.SHOW_PAGE),
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Dashboard }}
/>
),
hotKeys: ['G', 'D'],
},
[NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES,
label: msg`Go to Opportunities`,
shortLabel: msg`Opportunities`,
position: 26,
Icon: IconTargetArrow,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.PAGE_EDIT_MODE,
],
shouldBeRegistered: ({
objectMetadataItem,
viewType,
getTargetObjectReadPermission,
}) =>
getTargetObjectReadPermission(CoreObjectNameSingular.Opportunity) &&
(objectMetadataItem?.nameSingular !==
CoreObjectNameSingular.Opportunity ||
viewType === CommandMenuItemViewType.SHOW_PAGE),
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Opportunity }}
/>
),
hotKeys: ['G', 'O'],
},
[NoSelectionRecordCommandKeys.GO_TO_SETTINGS]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_SETTINGS,
label: msg`Go to Settings`,
shortLabel: msg`Settings`,
position: 27,
Icon: IconSettings,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
shouldBeRegistered: () => true,
component: (
<CommandLink
to={AppPath.SettingsCatchAll}
params={{
'*': SettingsPath.ProfilePage,
}}
/>
),
hotKeys: ['G', 'S'],
},
[NoSelectionRecordCommandKeys.GO_TO_TASKS]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_TASKS,
label: msg`Go to Tasks`,
shortLabel: msg`Tasks`,
position: 28,
Icon: IconCheckbox,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.PAGE_EDIT_MODE,
],
shouldBeRegistered: ({
objectMetadataItem,
viewType,
getTargetObjectReadPermission,
}) =>
getTargetObjectReadPermission(CoreObjectNameSingular.Task) &&
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Task ||
viewType === CommandMenuItemViewType.SHOW_PAGE),
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Task }}
/>
),
hotKeys: ['G', 'T'],
},
[NoSelectionRecordCommandKeys.GO_TO_NOTES]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_NOTES,
label: msg`Go to Notes`,
shortLabel: msg`Notes`,
position: 29,
Icon: IconCheckbox,
isPinned: false,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.PAGE_EDIT_MODE,
],
shouldBeRegistered: ({
objectMetadataItem,
viewType,
getTargetObjectReadPermission,
}) =>
getTargetObjectReadPermission(CoreObjectNameSingular.Note) &&
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Note ||
viewType === CommandMenuItemViewType.SHOW_PAGE),
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Note }}
/>
),
hotKeys: ['G', 'N'],
},
[RecordPageLayoutSingleRecordCommandKeys.EDIT_RECORD_PAGE_LAYOUT]: {
key: RecordPageLayoutSingleRecordCommandKeys.EDIT_RECORD_PAGE_LAYOUT,
label: msg`Edit Page Layout`,
shortLabel: msg`Edit Layout`,
isPinned: true,
position: 30,
Icon: IconPencil,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
requiredPermissionFlag: PermissionFlagType.LAYOUTS,
shouldBeRegistered: ({
selectedRecord,
objectPermissions,
objectMetadataItem,
isFeatureFlagEnabled,
}) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
) &&
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
objectPermissions.canUpdateObjectRecords &&
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Dashboard,
availableOn: [CommandMenuItemViewType.SHOW_PAGE],
component: <EditRecordPageLayoutSingleRecordCommand />,
},
[RecordPageLayoutSingleRecordCommandKeys.SAVE_RECORD_PAGE_LAYOUT]: {
key: RecordPageLayoutSingleRecordCommandKeys.SAVE_RECORD_PAGE_LAYOUT,
label: msg`Save Page Layout`,
shortLabel: msg`Save`,
isPinned: true,
isPrimaryCTA: true,
position: 31,
Icon: IconDeviceFloppy,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
requiredPermissionFlag: PermissionFlagType.LAYOUTS,
shouldBeRegistered: ({
selectedRecord,
objectPermissions,
objectMetadataItem,
isFeatureFlagEnabled,
}) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
) &&
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
objectPermissions.canUpdateObjectRecords &&
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Dashboard,
availableOn: [CommandMenuItemViewType.PAGE_EDIT_MODE],
component: <SaveRecordPageLayoutSingleRecordCommand />,
},
[RecordPageLayoutSingleRecordCommandKeys.CANCEL_RECORD_PAGE_LAYOUT_EDITION]: {
key: RecordPageLayoutSingleRecordCommandKeys.CANCEL_RECORD_PAGE_LAYOUT_EDITION,
label: msg`Cancel Edition`,
shortLabel: msg`Cancel`,
isPinned: true,
position: 32,
Icon: IconCancel,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
requiredPermissionFlag: PermissionFlagType.LAYOUTS,
shouldBeRegistered: ({
selectedRecord,
objectPermissions,
objectMetadataItem,
isFeatureFlagEnabled,
}) =>
isFeatureFlagEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
) &&
isDefined(selectedRecord) &&
!selectedRecord?.isRemote &&
!isDefined(selectedRecord?.deletedAt) &&
objectPermissions.canUpdateObjectRecords &&
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Dashboard,
availableOn: [CommandMenuItemViewType.PAGE_EDIT_MODE],
component: <CancelRecordPageLayoutSingleRecordCommand />,
},
};
@@ -0,0 +1,390 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { MultipleRecordsCommandKeys } from '@/command-menu-item/record/multiple-records/types/MultipleRecordsCommandKeys';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { NoSelectionWorkflowRecordCommandKeys } from '@/command-menu-item/record/no-selection/workflow/types/NoSelectionWorkflowRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { ActivateWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/ActivateWorkflowSingleRecordCommand';
import { AddNodeWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/AddNodeWorkflowSingleRecordCommand';
import { DeactivateWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/DeactivateWorkflowSingleRecordCommand';
import { DiscardDraftWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/DiscardDraftWorkflowSingleRecordCommand';
import { DuplicateWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/DuplicateWorkflowSingleRecordCommand';
import { SeeActiveVersionWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/SeeActiveVersionWorkflowSingleRecordCommand';
import { SeeRunsWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/SeeRunsWorkflowSingleRecordCommand';
import { SeeVersionsWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/SeeVersionsWorkflowSingleRecordCommand';
import { TestWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/TestWorkflowSingleRecordCommand';
import { TidyUpWorkflowSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand';
import { WorkflowSingleRecordCommandKeys } from '@/command-menu-item/record/single-record/workflow/types/WorkflowSingleRecordCommandKeys';
import { inheritCommandMenuItemsFromDefaultConfig } from '@/command-menu-item/record/utils/inheritCommandMenuItemsFromDefaultConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CommandMenuItemViewType, AppPath } from 'twenty-shared/types';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import {
type WorkflowStep,
type WorkflowTrigger,
type WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconCopy,
IconHistoryToggle,
IconNoteOff,
IconPlayerPause,
IconPlayerPlay,
IconPlus,
IconPower,
IconReorder,
IconVersions,
} from 'twenty-ui/display';
const areWorkflowTriggerAndStepsDefined = (
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined,
): workflowWithCurrentVersion is WorkflowWithCurrentVersion & {
currentVersion: {
trigger: WorkflowTrigger;
steps: Array<WorkflowStep>;
};
} => {
return (
isDefined(workflowWithCurrentVersion?.currentVersion?.trigger) &&
isDefined(workflowWithCurrentVersion.currentVersion?.steps) &&
workflowWithCurrentVersion.currentVersion.steps.length > 0
);
};
export const WORKFLOW_COMMAND_MENU_ITEMS_CONFIG =
inheritCommandMenuItemsFromDefaultConfig({
config: {
[WorkflowSingleRecordCommandKeys.ACTIVATE]: {
key: WorkflowSingleRecordCommandKeys.ACTIVATE,
label: msg`Activate Workflow`,
shortLabel: msg`Activate`,
isPinned: true,
isPrimaryCTA: true,
position: 3,
Icon: IconPower,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
areWorkflowTriggerAndStepsDefined(workflowWithCurrentVersion) &&
(workflowWithCurrentVersion.currentVersion.status === 'DRAFT' ||
!workflowWithCurrentVersion.versions?.some(
(version) => version.status === 'ACTIVE',
)) &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <ActivateWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.DEACTIVATE]: {
key: WorkflowSingleRecordCommandKeys.DEACTIVATE,
label: msg`Deactivate Workflow`,
shortLabel: msg`Deactivate`,
isPinned: true,
position: 4,
Icon: IconPlayerPause,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
isDefined(workflowWithCurrentVersion) &&
workflowWithCurrentVersion.currentVersion.status === 'ACTIVE' &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <DeactivateWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.DISCARD_DRAFT]: {
key: WorkflowSingleRecordCommandKeys.DISCARD_DRAFT,
label: msg`Discard Draft`,
shortLabel: msg`Discard Draft`,
isPinned: true,
position: 5,
Icon: IconNoteOff,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
isDefined(workflowWithCurrentVersion) &&
workflowWithCurrentVersion.versions.length > 1 &&
workflowWithCurrentVersion.currentVersion.status === 'DRAFT' &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <DiscardDraftWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.TEST]: {
key: WorkflowSingleRecordCommandKeys.TEST,
label: msg`Test Workflow`,
shortLabel: msg`Test`,
isPinned: true,
position: 6,
Icon: IconPlayerPlay,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
areWorkflowTriggerAndStepsDefined(workflowWithCurrentVersion) &&
((workflowWithCurrentVersion.currentVersion.trigger.type ===
'MANUAL' &&
!isDefined(
workflowWithCurrentVersion.currentVersion.trigger.settings
.objectType,
)) ||
workflowWithCurrentVersion.currentVersion.trigger.type ===
'WEBHOOK' ||
workflowWithCurrentVersion.currentVersion.trigger.type ===
'CRON') &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <TestWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.SEE_ACTIVE_VERSION]: {
key: WorkflowSingleRecordCommandKeys.SEE_ACTIVE_VERSION,
label: msg`See active version`,
shortLabel: msg`See active version`,
isPinned: false,
position: 7,
Icon: IconVersions,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ workflowWithCurrentVersion, selectedRecord }) =>
(workflowWithCurrentVersion?.statuses?.includes('ACTIVE') || false) &&
(workflowWithCurrentVersion?.statuses?.includes('DRAFT') || false) &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <SeeActiveVersionWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.SEE_RUNS]: {
key: WorkflowSingleRecordCommandKeys.SEE_RUNS,
label: msg`See runs`,
shortLabel: msg`See runs`,
isPinned: true,
position: 8,
Icon: IconHistoryToggle,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
isDefined(workflowWithCurrentVersion) &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <SeeRunsWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.SEE_VERSIONS]: {
key: WorkflowSingleRecordCommandKeys.SEE_VERSIONS,
label: msg`See versions history`,
shortLabel: msg`See versions`,
isPinned: false,
position: 9,
Icon: IconVersions,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
isDefined(workflowWithCurrentVersion) &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <SeeVersionsWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.ADD_NODE]: {
key: WorkflowSingleRecordCommandKeys.ADD_NODE,
label: msg`Add a node`,
shortLabel: msg`Add a node`,
isPinned: true,
position: 10,
Icon: IconPlus,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
areWorkflowTriggerAndStepsDefined(workflowWithCurrentVersion) &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [CommandMenuItemViewType.SHOW_PAGE],
component: <AddNodeWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.TIDY_UP]: {
key: WorkflowSingleRecordCommandKeys.TIDY_UP,
label: msg`Tidy up workflow`,
shortLabel: msg`Tidy up`,
isPinned: false,
position: 11,
Icon: IconReorder,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
areWorkflowTriggerAndStepsDefined(workflowWithCurrentVersion) &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [CommandMenuItemViewType.SHOW_PAGE],
component: <TidyUpWorkflowSingleRecordCommand />,
},
[WorkflowSingleRecordCommandKeys.DUPLICATE_WORKFLOW]: {
key: WorkflowSingleRecordCommandKeys.DUPLICATE_WORKFLOW,
label: msg`Duplicate Workflow`,
shortLabel: msg`Duplicate`,
isPinned: false,
position: 12,
Icon: IconCopy,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
shouldBeRegistered: ({ selectedRecord, workflowWithCurrentVersion }) =>
isDefined(workflowWithCurrentVersion) &&
isDefined(workflowWithCurrentVersion.currentVersion) &&
!isDefined(selectedRecord?.deletedAt),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <DuplicateWorkflowSingleRecordCommand />,
},
[NoSelectionWorkflowRecordCommandKeys.GO_TO_RUNS]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionWorkflowRecordCommandKeys.GO_TO_RUNS,
label: msg`Go to runs`,
shortLabel: msg`See runs`,
position: 22,
Icon: IconHistoryToggle,
accent: 'default',
isPinned: true,
shouldBeRegistered: ({ hasAnySoftDeleteFilterOnView }) =>
!hasAnySoftDeleteFilterOnView,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowRun }}
/>
),
},
},
commandKeys: [
NoSelectionRecordCommandKeys.CREATE_NEW_RECORD,
SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD,
SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD,
SingleRecordCommandKeys.ADD_TO_FAVORITES,
SingleRecordCommandKeys.REMOVE_FROM_FAVORITES,
SingleRecordCommandKeys.DELETE,
SingleRecordCommandKeys.DESTROY,
SingleRecordCommandKeys.RESTORE,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW,
MultipleRecordsCommandKeys.DELETE,
MultipleRecordsCommandKeys.DESTROY,
MultipleRecordsCommandKeys.RESTORE,
MultipleRecordsCommandKeys.EXPORT,
NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
NoSelectionRecordCommandKeys.GO_TO_COMPANIES,
NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES,
NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS,
NoSelectionRecordCommandKeys.GO_TO_SETTINGS,
NoSelectionRecordCommandKeys.GO_TO_TASKS,
NoSelectionRecordCommandKeys.GO_TO_NOTES,
],
propertiesToOverwrite: {
[SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD]: {
position: 0,
label: msg`Navigate to next workflow`,
},
[SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
position: 1,
label: msg`Navigate to previous workflow`,
},
[NoSelectionRecordCommandKeys.CREATE_NEW_RECORD]: {
position: 2,
label: msg`Create new workflow`,
},
[SingleRecordCommandKeys.DELETE]: {
position: 12,
label: msg`Delete workflow`,
},
[MultipleRecordsCommandKeys.DELETE]: {
position: 13,
label: msg`Delete workflows`,
},
[SingleRecordCommandKeys.ADD_TO_FAVORITES]: {
position: 14,
isPinned: false,
},
[SingleRecordCommandKeys.REMOVE_FROM_FAVORITES]: {
position: 15,
isPinned: false,
},
[SingleRecordCommandKeys.DESTROY]: {
position: 16,
label: msg`Permanently destroy workflow`,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX]: {
position: 17,
label: msg`Export workflow`,
shouldBeRegistered: ({ selectedRecord }) =>
!isDefined(selectedRecord?.deletedAt),
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 18,
label: msg`Export workflow`,
},
[MultipleRecordsCommandKeys.EXPORT]: {
position: 19,
label: msg`Export workflows`,
},
[NoSelectionRecordCommandKeys.EXPORT_VIEW]: {
position: 20,
label: msg`Export view`,
},
[MultipleRecordsCommandKeys.DESTROY]: {
position: 21,
label: msg`Permanently destroy workflows`,
},
[NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS]: {
position: 22,
label: msg`See deleted workflows`,
},
[NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS]: {
position: 23,
label: msg`Hide deleted workflows`,
},
[NoSelectionRecordCommandKeys.IMPORT_RECORDS]: {
position: 24,
label: msg`Import workflows`,
},
[NoSelectionRecordCommandKeys.GO_TO_PEOPLE]: {
position: 25,
},
[NoSelectionRecordCommandKeys.GO_TO_COMPANIES]: {
position: 26,
},
[NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES]: {
position: 27,
},
[NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS]: {
position: 28,
},
[NoSelectionRecordCommandKeys.GO_TO_SETTINGS]: {
position: 29,
},
[NoSelectionRecordCommandKeys.GO_TO_TASKS]: {
position: 30,
},
[NoSelectionRecordCommandKeys.GO_TO_NOTES]: {
position: 31,
},
},
});
@@ -0,0 +1,165 @@
import { MultipleRecordsCommandKeys } from '@/command-menu-item/record/multiple-records/types/MultipleRecordsCommandKeys';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { SeeVersionWorkflowRunSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand';
import { SeeWorkflowWorkflowRunSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand';
import { StopWorkflowRunSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand';
import { WorkflowRunSingleRecordCommandKeys } from '@/command-menu-item/record/single-record/workflow-runs/types/WorkflowRunSingleRecordCommandKeys';
import { inheritCommandMenuItemsFromDefaultConfig } from '@/command-menu-item/record/utils/inheritCommandMenuItemsFromDefaultConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CommandMenuItemViewType } from 'twenty-shared/types';
import { msg } from '@lingui/core/macro';
import {
IconPlayerStop,
IconSettingsAutomation,
IconVersions,
} from 'twenty-ui/display';
export const WORKFLOW_RUNS_COMMAND_MENU_ITEMS_CONFIG =
inheritCommandMenuItemsFromDefaultConfig({
config: {
[WorkflowRunSingleRecordCommandKeys.SEE_VERSION]: {
key: WorkflowRunSingleRecordCommandKeys.SEE_VERSION,
label: msg`See version`,
shortLabel: msg`See version`,
position: 0,
isPinned: true,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
Icon: IconVersions,
shouldBeRegistered: () => true,
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <SeeVersionWorkflowRunSingleRecordCommand />,
},
[WorkflowRunSingleRecordCommandKeys.SEE_WORKFLOW]: {
key: WorkflowRunSingleRecordCommandKeys.SEE_WORKFLOW,
label: msg`See workflow`,
shortLabel: msg`See workflow`,
position: 1,
isPinned: true,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
Icon: IconSettingsAutomation,
shouldBeRegistered: () => true,
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <SeeWorkflowWorkflowRunSingleRecordCommand />,
},
[WorkflowRunSingleRecordCommandKeys.STOP]: {
key: WorkflowRunSingleRecordCommandKeys.STOP,
label: msg`Stop`,
shortLabel: msg`Stop`,
position: 2,
isPinned: true,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
Icon: IconPlayerStop,
shouldBeRegistered: ({ selectedRecord, isSelectAll }) => {
if (isSelectAll === true) {
return true;
}
const stoppableStatuses = ['NOT_STARTED', 'ENQUEUED', 'RUNNING'];
return stoppableStatuses.includes(selectedRecord?.status);
},
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
],
component: <StopWorkflowRunSingleRecordCommand />,
},
},
commandKeys: [
SingleRecordCommandKeys.ADD_TO_FAVORITES,
SingleRecordCommandKeys.REMOVE_FROM_FAVORITES,
SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD,
SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW,
MultipleRecordsCommandKeys.EXPORT,
NoSelectionRecordCommandKeys.EXPORT_VIEW,
NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS,
NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
NoSelectionRecordCommandKeys.GO_TO_COMPANIES,
NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES,
NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS,
NoSelectionRecordCommandKeys.GO_TO_SETTINGS,
NoSelectionRecordCommandKeys.GO_TO_TASKS,
NoSelectionRecordCommandKeys.GO_TO_NOTES,
],
propertiesToOverwrite: {
[SingleRecordCommandKeys.ADD_TO_FAVORITES]: {
isPinned: false,
position: 3,
},
[SingleRecordCommandKeys.REMOVE_FROM_FAVORITES]: {
isPinned: false,
position: 4,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX]: {
position: 5,
label: msg`Export run`,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 5,
label: msg`Export run`,
},
[MultipleRecordsCommandKeys.EXPORT]: {
position: 6,
label: msg`Export runs`,
},
[NoSelectionRecordCommandKeys.EXPORT_VIEW]: {
position: 7,
label: msg`Export view`,
},
[NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS]: {
position: 8,
label: msg`See deleted runs`,
},
[NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS]: {
position: 9,
label: msg`Hide deleted runs`,
},
[SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
position: 10,
},
[SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD]: {
position: 11,
},
[NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS]: {
position: 12,
isPinned: true,
},
[NoSelectionRecordCommandKeys.GO_TO_PEOPLE]: {
position: 13,
},
[NoSelectionRecordCommandKeys.GO_TO_COMPANIES]: {
position: 14,
},
[NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES]: {
position: 15,
},
[NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS]: {
position: 16,
},
[NoSelectionRecordCommandKeys.GO_TO_SETTINGS]: {
position: 17,
},
[NoSelectionRecordCommandKeys.GO_TO_TASKS]: {
position: 18,
},
[NoSelectionRecordCommandKeys.GO_TO_NOTES]: {
position: 19,
},
},
});
@@ -0,0 +1,199 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { MultipleRecordsCommandKeys } from '@/command-menu-item/record/multiple-records/types/MultipleRecordsCommandKeys';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { NoSelectionWorkflowRecordCommandKeys } from '@/command-menu-item/record/no-selection/workflow/types/NoSelectionWorkflowRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { SeeRunsWorkflowVersionSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow-versions/components/SeeRunsWorkflowVersionSingleRecordCommand';
import { SeeVersionsWorkflowVersionSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow-versions/components/SeeVersionsWorkflowVersionSingleRecordCommand';
import { SeeWorkflowWorkflowVersionSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow-versions/components/SeeWorkflowWorkflowVersionSingleRecordCommand';
import { UseAsDraftWorkflowVersionSingleRecordCommand } from '@/command-menu-item/record/single-record/workflow-versions/components/UseAsDraftWorkflowVersionSingleRecordCommand';
import { WorkflowVersionSingleRecordCommandKeys } from '@/command-menu-item/record/single-record/workflow-versions/types/WorkflowVersionSingleRecordCommandKeys';
import { inheritCommandMenuItemsFromDefaultConfig } from '@/command-menu-item/record/utils/inheritCommandMenuItemsFromDefaultConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { CommandMenuItemViewType, AppPath } from 'twenty-shared/types';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconHistoryToggle,
IconPencil,
IconSettingsAutomation,
IconVersions,
} from 'twenty-ui/display';
export const WORKFLOW_VERSIONS_COMMAND_MENU_ITEMS_CONFIG =
inheritCommandMenuItemsFromDefaultConfig({
config: {
[WorkflowVersionSingleRecordCommandKeys.SEE_RUNS]: {
key: WorkflowVersionSingleRecordCommandKeys.SEE_RUNS,
label: msg`See runs`,
shortLabel: msg`See runs`,
position: 1,
isPinned: true,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
Icon: IconHistoryToggle,
shouldBeRegistered: ({ workflowWithCurrentVersion }) =>
isDefined(workflowWithCurrentVersion),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <SeeRunsWorkflowVersionSingleRecordCommand />,
},
[WorkflowVersionSingleRecordCommandKeys.SEE_WORKFLOW]: {
key: WorkflowVersionSingleRecordCommandKeys.SEE_WORKFLOW,
label: msg`See workflow`,
shortLabel: msg`See workflow`,
position: 2,
isPinned: true,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
Icon: IconSettingsAutomation,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord?.workflow?.id),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <SeeWorkflowWorkflowVersionSingleRecordCommand />,
},
[WorkflowVersionSingleRecordCommandKeys.USE_AS_DRAFT]: {
key: WorkflowVersionSingleRecordCommandKeys.USE_AS_DRAFT,
label: msg`Use as draft`,
shortLabel: msg`Use as draft`,
position: 3,
isPinned: true,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
Icon: IconPencil,
shouldBeRegistered: ({ selectedRecord }) =>
isDefined(selectedRecord) && selectedRecord.status !== 'DRAFT',
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <UseAsDraftWorkflowVersionSingleRecordCommand />,
},
[WorkflowVersionSingleRecordCommandKeys.SEE_VERSIONS]: {
key: WorkflowVersionSingleRecordCommandKeys.SEE_VERSIONS,
label: msg`See versions history`,
shortLabel: msg`See versions`,
position: 4,
type: CommandMenuItemType.Standard,
scope: CommandMenuItemScope.RecordSelection,
Icon: IconVersions,
shouldBeRegistered: ({ workflowWithCurrentVersion }) =>
isDefined(workflowWithCurrentVersion),
availableOn: [
CommandMenuItemViewType.SHOW_PAGE,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
],
component: <SeeVersionsWorkflowVersionSingleRecordCommand />,
},
[NoSelectionWorkflowRecordCommandKeys.GO_TO_RUNS]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionWorkflowRecordCommandKeys.GO_TO_RUNS,
label: msg`Go to runs`,
shortLabel: msg`See runs`,
position: 14,
Icon: IconHistoryToggle,
accent: 'default',
isPinned: true,
shouldBeRegistered: () => true,
availableOn: [CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION],
component: (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowRun }}
/>
),
},
},
commandKeys: [
SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD,
SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD,
SingleRecordCommandKeys.ADD_TO_FAVORITES,
SingleRecordCommandKeys.REMOVE_FROM_FAVORITES,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW,
MultipleRecordsCommandKeys.EXPORT,
NoSelectionRecordCommandKeys.EXPORT_VIEW,
NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS,
NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
NoSelectionRecordCommandKeys.GO_TO_COMPANIES,
NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES,
NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS,
NoSelectionRecordCommandKeys.GO_TO_SETTINGS,
NoSelectionRecordCommandKeys.GO_TO_TASKS,
NoSelectionRecordCommandKeys.GO_TO_NOTES,
],
propertiesToOverwrite: {
[SingleRecordCommandKeys.ADD_TO_FAVORITES]: {
position: 5,
isPinned: false,
},
[SingleRecordCommandKeys.REMOVE_FROM_FAVORITES]: {
position: 6,
isPinned: false,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX]: {
position: 7,
label: msg`Export version`,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 7,
label: msg`Export version`,
},
[MultipleRecordsCommandKeys.EXPORT]: {
position: 8,
label: msg`Export versions`,
},
[NoSelectionRecordCommandKeys.EXPORT_VIEW]: {
position: 9,
},
[NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS]: {
position: 10,
},
[NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS]: {
position: 11,
},
[SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
position: 12,
label: msg`Navigate to previous version`,
},
[SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD]: {
position: 13,
label: msg`Navigate to next version`,
},
[NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS]: {
position: 15,
isPinned: true,
},
[NoSelectionRecordCommandKeys.GO_TO_PEOPLE]: {
position: 16,
},
[NoSelectionRecordCommandKeys.GO_TO_COMPANIES]: {
position: 17,
},
[NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES]: {
position: 18,
},
[NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS]: {
position: 19,
},
[NoSelectionRecordCommandKeys.GO_TO_SETTINGS]: {
position: 20,
},
[NoSelectionRecordCommandKeys.GO_TO_TASKS]: {
position: 21,
},
[NoSelectionRecordCommandKeys.GO_TO_NOTES]: {
position: 22,
},
},
});
@@ -0,0 +1,126 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { MultipleRecordsCommandKeys } from '@/command-menu-item/record/multiple-records/types/MultipleRecordsCommandKeys';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { inheritCommandMenuItemsFromDefaultConfig } from '@/command-menu-item/record/utils/inheritCommandMenuItemsFromDefaultConfig';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import {
CommandMenuItemViewType,
AppPath,
SettingsPath,
} from 'twenty-shared/types';
import { msg } from '@lingui/core/macro';
import { IconSettings } from 'twenty-ui/display';
export const WORKSPACE_MEMBERS_COMMAND_MENU_ITEMS_CONFIG =
inheritCommandMenuItemsFromDefaultConfig({
config: {
[NoSelectionRecordCommandKeys.GO_TO_SETTINGS]: {
type: CommandMenuItemType.Navigation,
scope: CommandMenuItemScope.Global,
key: NoSelectionRecordCommandKeys.GO_TO_SETTINGS,
label: msg`Manage members in settings`,
shortLabel: msg`Manage in settings`,
position: 14,
Icon: IconSettings,
isPinned: true,
availableOn: [
CommandMenuItemViewType.INDEX_PAGE_NO_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_SINGLE_RECORD_SELECTION,
CommandMenuItemViewType.INDEX_PAGE_BULK_SELECTION,
CommandMenuItemViewType.SHOW_PAGE,
],
shouldBeRegistered: () => true,
component: (
<CommandLink
to={AppPath.SettingsCatchAll}
params={{
'*': SettingsPath.WorkspaceMembersPage,
}}
/>
),
hotKeys: ['G', 'S'],
},
},
commandKeys: [
SingleRecordCommandKeys.ADD_TO_FAVORITES,
SingleRecordCommandKeys.REMOVE_FROM_FAVORITES,
SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD,
SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX,
SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW,
MultipleRecordsCommandKeys.EXPORT,
NoSelectionRecordCommandKeys.EXPORT_VIEW,
NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS,
NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS,
NoSelectionRecordCommandKeys.GO_TO_PEOPLE,
NoSelectionRecordCommandKeys.GO_TO_COMPANIES,
NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES,
NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS,
NoSelectionRecordCommandKeys.GO_TO_TASKS,
NoSelectionRecordCommandKeys.GO_TO_NOTES,
],
propertiesToOverwrite: {
[SingleRecordCommandKeys.ADD_TO_FAVORITES]: {
isPinned: false,
position: 0,
},
[SingleRecordCommandKeys.REMOVE_FROM_FAVORITES]: {
isPinned: false,
position: 1,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_INDEX]: {
position: 2,
label: msg`Export member`,
},
[SingleRecordCommandKeys.EXPORT_FROM_RECORD_SHOW]: {
position: 2,
label: msg`Export member`,
},
[MultipleRecordsCommandKeys.EXPORT]: {
position: 3,
label: msg`Export members`,
},
[NoSelectionRecordCommandKeys.EXPORT_VIEW]: {
position: 4,
label: msg`Export view`,
},
[NoSelectionRecordCommandKeys.SEE_DELETED_RECORDS]: {
position: 5,
label: msg`See deleted members`,
},
[NoSelectionRecordCommandKeys.HIDE_DELETED_RECORDS]: {
position: 6,
label: msg`Hide deleted members`,
},
[SingleRecordCommandKeys.NAVIGATE_TO_PREVIOUS_RECORD]: {
position: 7,
},
[SingleRecordCommandKeys.NAVIGATE_TO_NEXT_RECORD]: {
position: 8,
},
[NoSelectionRecordCommandKeys.GO_TO_WORKFLOWS]: {
position: 9,
},
[NoSelectionRecordCommandKeys.GO_TO_PEOPLE]: {
position: 10,
},
[NoSelectionRecordCommandKeys.GO_TO_COMPANIES]: {
position: 11,
},
[NoSelectionRecordCommandKeys.GO_TO_OPPORTUNITIES]: {
position: 12,
},
[NoSelectionRecordCommandKeys.GO_TO_DASHBOARDS]: {
position: 13,
},
[NoSelectionRecordCommandKeys.GO_TO_TASKS]: {
position: 15,
},
[NoSelectionRecordCommandKeys.GO_TO_NOTES]: {
position: 16,
},
},
});
@@ -0,0 +1,104 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { computeProgressText } from '@/command-menu-item/utils/computeProgressText';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFilterGroupsComponentState } from '@/context-store/states/contextStoreFilterGroupsComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useIncrementalDeleteManyRecords } from '@/object-record/hooks/useIncrementalDeleteManyRecords';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const DeleteMultipleRecordsCommand = () => {
const { recordIndexId, objectMetadataItem } =
useRecordIndexIdFromCurrentContextStore();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreFilters = useAtomComponentStateValue(
contextStoreFiltersComponentState,
);
const contextStoreFilterGroups = useAtomComponentStateValue(
contextStoreFilterGroupsComponentState,
);
const contextStoreAnyFieldFilterValue = useAtomComponentStateValue(
contextStoreAnyFieldFilterValueComponentState,
);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { filterValueDependencies } = useFilterValueDependencies();
const graphqlFilter = computeContextStoreFilters({
contextStoreTargetedRecordsRule,
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
const { incrementalDeleteManyRecords, progress } =
useIncrementalDeleteManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: graphqlFilter,
pageSize: DEFAULT_QUERY_PAGE_SIZE,
delayInMsBetweenMutations: 50,
});
const actionConfig = useContext(CommandConfigContext);
if (!isDefined(actionConfig)) {
return null;
}
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const progressText = computeProgressText(progress);
const actionConfigWithProgress = {
...actionConfig,
label: `${originalLabel}${progressText}`,
shortLabel: `${originalShortLabel}${progressText}`,
};
const handleDeleteClick = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
await incrementalDeleteManyRecords();
};
return (
<CommandConfigContext.Provider value={actionConfigWithProgress}>
<Command onClick={handleDeleteClick} />
</CommandConfigContext.Provider>
);
};
@@ -0,0 +1,116 @@
import { CommandModal } from '@/command-menu-item/display/components/CommandModal';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { computeProgressText } from '@/command-menu-item/utils/computeProgressText';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFilterGroupsComponentState } from '@/context-store/states/contextStoreFilterGroupsComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useIncrementalDestroyManyRecords } from '@/object-record/hooks/useIncrementalDestroyManyRecords';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const DestroyMultipleRecordsCommand = () => {
const { recordIndexId, objectMetadataItem } =
useRecordIndexIdFromCurrentContextStore();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreFilters = useAtomComponentStateValue(
contextStoreFiltersComponentState,
);
const contextStoreFilterGroups = useAtomComponentStateValue(
contextStoreFilterGroupsComponentState,
);
const contextStoreAnyFieldFilterValue = useAtomComponentStateValue(
contextStoreAnyFieldFilterValueComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const deletedAtFilter: RecordGqlOperationFilter = {
deletedAt: { is: 'NOT_NULL' },
};
const graphqlFilter = {
...computeContextStoreFilters({
contextStoreTargetedRecordsRule,
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
}),
...deletedAtFilter,
};
const { incrementalDestroyManyRecords, progress } =
useIncrementalDestroyManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: graphqlFilter,
pageSize: DEFAULT_QUERY_PAGE_SIZE,
delayInMsBetweenMutations: 50,
});
const actionConfig = useContext(CommandConfigContext);
if (!isDefined(actionConfig)) {
return null;
}
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const progressText = computeProgressText(progress);
const actionConfigWithProgress = {
...actionConfig,
label: `${originalLabel}${progressText}`,
shortLabel: `${originalShortLabel}${progressText}`,
};
const handleDestroyClick = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
await incrementalDestroyManyRecords();
};
return (
<CommandConfigContext.Provider value={actionConfigWithProgress}>
<CommandModal
title={t`Permanently Destroy Records`}
subtitle={t`Are you sure you want to destroy these records? They won't be recoverable anymore.`}
onConfirmClick={handleDestroyClick}
confirmButtonText={t`Destroy Records`}
/>
</CommandConfigContext.Provider>
);
};
@@ -0,0 +1,79 @@
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { useCloseCommandMenu } from '@/command-menu-item/hooks/useCloseCommandMenu';
import { computeProgressText } from '@/command-menu-item/utils/computeProgressText';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useRecordIndexExportRecords } from '@/object-record/record-index/export/hooks/useRecordIndexExportRecords';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const ExportMultipleRecordsCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const { download, progress } = useRecordIndexExportRecords({
delayMs: 100,
objectMetadataItem,
recordIndexId: getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
contextStoreCurrentViewId,
),
filename: `${objectMetadataItem.nameSingular}.csv`,
});
const { closeCommandMenu } = useCloseCommandMenu({});
const exportProgress = isDefined(progress)
? {
processedRecordCount: progress.processedRecordCount,
totalRecordCount: progress.totalRecordCount,
}
: undefined;
const actionConfig = useContext(CommandConfigContext);
if (!isDefined(actionConfig)) {
return null;
}
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const progressText = computeProgressText(exportProgress);
const actionConfigWithProgress = {
...actionConfig,
label: `${originalLabel}${progressText}`,
shortLabel: `${originalShortLabel}${progressText}`,
};
const handleClick = async () => {
try {
await download();
closeCommandMenu();
} catch (error) {
closeCommandMenu();
throw error;
}
};
return (
<CommandConfigContext.Provider value={actionConfigWithProgress}>
<CommandMenuItemDisplay onClick={handleClick} />
</CommandConfigContext.Provider>
);
};
@@ -0,0 +1,31 @@
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { useSelectedRecordIds } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIds';
import { useOpenMergeRecordsPageInSidePanel } from '@/side-panel/hooks/useOpenMergeRecordsPageInSidePanel';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
export const MergeMultipleRecordsCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const selectedRecordIds = useSelectedRecordIds();
const { openMergeRecordsPageInSidePanel } =
useOpenMergeRecordsPageInSidePanel({
objectNameSingular: objectMetadataItem.nameSingular,
objectRecordIds: selectedRecordIds,
});
const handleClick = () => {
openMergeRecordsPageInSidePanel();
};
return <CommandMenuItemDisplay onClick={handleClick} />;
};
@@ -0,0 +1,101 @@
import { CommandModal } from '@/command-menu-item/display/components/CommandModal';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFilterGroupsComponentState } from '@/context-store/states/contextStoreFilterGroupsComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
export const RestoreMultipleRecordsCommand = () => {
const { recordIndexId, objectMetadataItem } =
useRecordIndexIdFromCurrentContextStore();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { restoreManyRecords } = useRestoreManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
});
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreFilters = useAtomComponentStateValue(
contextStoreFiltersComponentState,
);
const contextStoreFilterGroups = useAtomComponentStateValue(
contextStoreFilterGroupsComponentState,
);
const contextStoreAnyFieldFilterValue = useAtomComponentStateValue(
contextStoreAnyFieldFilterValueComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const deletedAtFilter: RecordGqlOperationFilter = {
deletedAt: { is: 'NOT_NULL' },
};
const graphqlFilter = {
...computeContextStoreFilters({
contextStoreTargetedRecordsRule,
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
}),
...deletedAtFilter,
};
const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: graphqlFilter,
limit: DEFAULT_QUERY_PAGE_SIZE,
recordGqlFields: { id: true },
});
const handleRestoreClick = async () => {
removeSelectedRecordsFromRecordBoard();
const recordsToRestore = await fetchAllRecordIds();
const recordIdsToRestore = recordsToRestore.map((record) => record.id);
resetTableRowSelection();
await restoreManyRecords({
idsToRestore: recordIdsToRestore,
});
};
return (
<CommandModal
title={t`Restore Records`}
subtitle={t`Are you sure you want to restore these records?`}
onConfirmClick={handleRestoreClick}
confirmButtonText={t`Restore Records`}
confirmButtonAccent="default"
/>
);
};
@@ -0,0 +1,21 @@
import { CommandMenuItemDisplay } from '@/command-menu-item/display/components/CommandMenuItemDisplay';
import { useOpenUpdateMultipleRecordsPageInSidePanel } from '@/side-panel/hooks/useOpenUpdateMultipleRecordsPageInSidePanel';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
export const UpdateMultipleRecordsCommand = () => {
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
ContextStoreComponentInstanceContext,
);
const { openUpdateMultipleRecordsPageInSidePanel } =
useOpenUpdateMultipleRecordsPageInSidePanel({
contextStoreInstanceId,
});
const handleClick = () => {
openUpdateMultipleRecordsPageInSidePanel();
};
return <CommandMenuItemDisplay onClick={handleClick} />;
};
@@ -0,0 +1,8 @@
export enum MultipleRecordsCommandKeys {
UPDATE = 'update-multiple-records',
DELETE = 'delete-multiple-records',
EXPORT = 'export-multiple-records',
MERGE = 'merge-multiple-records',
DESTROY = 'destroy-multiple-records',
RESTORE = 'restore-multiple-records',
}
@@ -0,0 +1,18 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
export const CreateNewIndexRecordNoSelectionRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { createNewIndexRecord } = useCreateNewIndexRecord({
objectMetadataItem,
});
return (
<Command
onClick={() => createNewIndexRecord({ position: 'first' })}
closeSidePanelOnCommandMenuListExecution={false}
/>
);
};
@@ -0,0 +1,45 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId';
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
export const CreateNewViewNoSelectionRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { openDropdown } = useOpenDropdown();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
contextStoreCurrentViewId,
);
const setViewPickerReferenceViewId = useSetAtomComponentState(
viewPickerReferenceViewIdComponentState,
recordIndexId,
);
const { setViewPickerMode } = useViewPickerMode(recordIndexId);
const handleAddViewButtonClick = () => {
setViewPickerReferenceViewId(contextStoreCurrentViewId);
setViewPickerMode('create-empty');
openDropdown({
dropdownComponentInstanceIdFromProps: VIEW_PICKER_DROPDOWN_ID,
});
};
return <Command onClick={handleAddViewButtonClick} />;
};
@@ -0,0 +1,16 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const EditNavigationSidebarNoSelectionRecordCommand = () => {
const setIsNavigationMenuInEditMode = useSetAtomState(
isNavigationMenuInEditModeState,
);
return (
<Command
onClick={() => setIsNavigationMenuInEditMode(true)}
closeSidePanelOnCommandMenuListExecution
/>
);
};
@@ -0,0 +1,56 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/useCheckIsSoftDeleteFilter';
import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
export const HideDeletedRecordsNoSelectionRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
contextStoreCurrentViewId,
);
const { toggleSoftDeleteFilterState } = useHandleToggleTrashColumnFilter({
objectNameSingular: objectMetadataItem.nameSingular,
viewBarId: recordIndexId,
});
const { isRecordFilterAboutSoftDelete } = useCheckIsSoftDeleteFilter();
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
recordIndexId,
);
const deletedFilter = currentRecordFilters.find(
isRecordFilterAboutSoftDelete,
);
const { removeRecordFilter } = useRemoveRecordFilter();
const handleClick = () => {
if (!isDefined(deletedFilter)) {
return;
}
removeRecordFilter({ recordFilterId: deletedFilter.id });
toggleSoftDeleteFilterState(false);
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,14 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useOpenObjectRecordsSpreadsheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog';
export const ImportRecordsNoSelectionRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { openObjectRecordsSpreadsheetImportDialog } =
useOpenObjectRecordsSpreadsheetImportDialog(
objectMetadataItem.nameSingular,
);
return <Command onClick={openObjectRecordsSpreadsheetImportDialog} />;
};
@@ -0,0 +1,38 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
export const SeeDeletedRecordsNoSelectionRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
contextStoreCurrentViewId,
);
const { handleToggleTrashColumnFilter, toggleSoftDeleteFilterState } =
useHandleToggleTrashColumnFilter({
objectNameSingular: objectMetadataItem.nameSingular,
viewBarId: recordIndexId,
});
return (
<Command
onClick={() => {
handleToggleTrashColumnFilter();
toggleSoftDeleteFilterState(true);
}}
/>
);
};
@@ -0,0 +1,16 @@
export enum NoSelectionRecordCommandKeys {
EXPORT_VIEW = 'export-view',
CREATE_NEW_RECORD = 'create-new-record',
SEE_DELETED_RECORDS = 'see-deleted-records',
HIDE_DELETED_RECORDS = 'hide-deleted-records',
IMPORT_RECORDS = 'import-records',
GO_TO_WORKFLOWS = 'go-to-workflows',
GO_TO_PEOPLE = 'go-to-people',
GO_TO_COMPANIES = 'go-to-companies',
GO_TO_DASHBOARDS = 'go-to-dashboards',
GO_TO_OPPORTUNITIES = 'go-to-opportunities',
GO_TO_SETTINGS = 'go-to-settings',
GO_TO_TASKS = 'go-to-tasks',
GO_TO_NOTES = 'go-to-notes',
CREATE_NEW_VIEW = 'create-view',
}
@@ -0,0 +1,3 @@
export enum NoSelectionWorkflowRecordCommandKeys {
GO_TO_RUNS = 'go-to-runs',
}
@@ -0,0 +1,27 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { isDefined } from 'twenty-shared/utils';
export const AddToFavoritesSingleRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const { createFavorite } = useCreateFavorite();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const handleClick = () => {
if (!isDefined(recordStore)) {
return;
}
createFavorite(recordStore, objectMetadataItem.nameSingular);
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,103 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
interface CreateRelatedRecordCommandProps {
targetFieldMetadataItemRelation: FieldMetadataItemRelation;
}
export const CreateRelatedRecordCommand = ({
targetFieldMetadataItemRelation,
}: CreateRelatedRecordCommandProps) => {
const sourceRecordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem: targetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular:
targetFieldMetadataItemRelation.targetObjectMetadata.nameSingular,
});
const { objectMetadataItem: taskObjectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Task,
});
const { objectMetadataItem: noteObjectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Note,
});
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const { createOneRecord: createOneTaskTarget } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.TaskTarget,
});
const { createOneRecord: createOneNoteTarget } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.NoteTarget,
});
const targetObject =
targetObjectMetadataItem.nameSingular === CoreObjectNameSingular.TaskTarget
? taskObjectMetadataItem
: targetObjectMetadataItem.nameSingular ===
CoreObjectNameSingular.NoteTarget
? noteObjectMetadataItem
: targetObjectMetadataItem;
const { createOneRecord } = useCreateOneRecord({
objectNameSingular: targetObject.nameSingular,
});
const handleCreateRelatedRecord = async () => {
const foreignKeyFieldName =
targetFieldMetadataItemRelation.targetFieldMetadata.name;
const foreignKeyIdFieldName =
getForeignKeyNameFromRelationFieldName(foreignKeyFieldName);
let createdRecord: ObjectRecord;
switch (targetObjectMetadataItem.nameSingular) {
case CoreObjectNameSingular.TaskTarget: {
createdRecord = await createOneRecord({});
await createOneTaskTarget({
taskId: createdRecord.id,
[foreignKeyIdFieldName]: sourceRecordId,
});
break;
}
case CoreObjectNameSingular.NoteTarget: {
createdRecord = await createOneRecord({});
await createOneNoteTarget({
noteId: createdRecord.id,
[foreignKeyIdFieldName]: sourceRecordId,
});
break;
}
default:
createdRecord = await createOneRecord({
[foreignKeyIdFieldName]: sourceRecordId,
});
break;
}
openRecordInSidePanel({
recordId: createdRecord.id,
objectNameSingular: targetObject.nameSingular,
isNewRecord: true,
});
};
return (
<Command
onClick={handleCreateRelatedRecord}
closeSidePanelOnCommandMenuListExecution={false}
/>
);
};
@@ -0,0 +1,62 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { isDefined } from 'twenty-shared/utils';
export const DeleteSingleRecordCommand = () => {
const { recordIndexId, objectMetadataItem } =
useRecordIndexIdFromCurrentContextStore();
const recordId = useSelectedRecordIdOrThrow();
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const handleDeleteClick = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
if (isDefined(foundFavorite)) {
deleteFavorite(foundFavorite.id);
}
const foundNavigationMenuItem = [
...navigationMenuItems,
...workspaceNavigationMenuItems,
].find((item) => item.targetRecordId === recordId);
if (isDefined(foundNavigationMenuItem)) {
removeNavigationMenuItemsByTargetRecordIds([recordId]);
}
await deleteOneRecord(recordId);
};
return <Command onClick={handleDeleteClick} />;
};
@@ -0,0 +1,47 @@
import { CommandModal } from '@/command-menu-item/display/components/CommandModal';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
import { AppPath } from 'twenty-shared/types';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const DestroySingleRecordCommand = () => {
const { recordIndexId, objectMetadataItem } =
useRecordIndexIdFromCurrentContextStore();
const recordId = useSelectedRecordIdOrThrow();
const navigateApp = useNavigateApp();
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { destroyOneRecord } = useDestroyOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
});
const handleDeleteClick = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
await destroyOneRecord(recordId);
navigateApp(AppPath.RecordIndexPage, {
objectNamePlural: objectMetadataItem.namePlural,
});
};
return (
<CommandModal
title={t`Permanently Destroy Record`}
subtitle={t`Are you sure you want to destroy this record? It cannot be recovered anymore.`}
onConfirmClick={handleDeleteClick}
confirmButtonText={t`Permanently Destroy Record`}
closeSidePanelOnShowPageOptionsExecution={true}
/>
);
};
@@ -0,0 +1,49 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { isDefined } from 'twenty-shared/utils';
export const ExportNoteSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const filename = `${(recordStore?.title || 'Untitled Note').replace(/[<>:"/\\|?*]/g, '-')}`;
const handleClick = async () => {
if (!isDefined(recordStore)) {
return;
}
const initialBody = recordStore.bodyV2?.blocknote;
let parsedBody = [];
// TODO: Remove this once we have removed the old rich text
try {
parsedBody = JSON.parse(initialBody);
} catch {
// oxlint-disable-next-line no-console
console.warn(
`Failed to parse body for record ${recordId}, for rich text version 'v2'`,
);
// oxlint-disable-next-line no-console
console.warn(initialBody);
}
const { exportBlockNoteEditorToPdf } = await import(
'@/command-menu-item/record/single-record/utils/exportBlockNoteEditorToPdf'
);
await exportBlockNoteEditorToPdf(parsedBody, filename);
// TODO later: implement DOCX export
// const { exportBlockNoteEditorToDocx } = await import(
// '@/command-menu-item/record/single-record/utils/exportBlockNoteEditorToDocx'
// );
// await exportBlockNoteEditorToDocx(editor, filename);
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,30 @@
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useExportSingleRecord } from '@/object-record/record-show/hooks/useExportSingleRecord';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { Command } from '@/command-menu-item/display/components/Command';
export const ExportSingleRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
if (!contextStoreCurrentViewId) {
throw new Error('Current view ID is not defined');
}
const recordId = useSelectedRecordIdOrThrow();
const filename = `${objectMetadataItem.nameSingular}.csv`;
const { download } = useExportSingleRecord({
filename,
objectMetadataItem,
recordId,
});
return <Command onClick={download} />;
};
@@ -0,0 +1,17 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
export const NavigateToNextRecordSingleRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const { navigateToNextRecord } = useRecordShowPagePagination(
objectMetadataItem.nameSingular,
recordId,
);
return <Command onClick={navigateToNextRecord} />;
};
@@ -0,0 +1,17 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
export const NavigateToPreviousRecordSingleRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const { navigateToPreviousRecord } = useRecordShowPagePagination(
objectMetadataItem.nameSingular,
recordId,
);
return <Command onClick={navigateToPreviousRecord} />;
};
@@ -0,0 +1,44 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { isDefined } from 'twenty-shared/utils';
export const RemoveFromFavoritesSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems, workspaceNavigationMenuItems } =
usePrefetchedNavigationMenuItemsData();
const { deleteFavorite } = useDeleteFavorite();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordId,
);
const foundNavigationMenuItem = [
...navigationMenuItems,
...workspaceNavigationMenuItems,
].find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
);
const handleClick = () => {
if (!isDefined(foundNavigationMenuItem) || !isDefined(foundFavorite)) {
return;
}
deleteNavigationMenuItem(foundNavigationMenuItem.id);
deleteFavorite(foundFavorite.id);
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,41 @@
import { CommandModal } from '@/command-menu-item/display/components/CommandModal';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
export const RestoreSingleRecordCommand = () => {
const { recordIndexId, objectMetadataItem } =
useRecordIndexIdFromCurrentContextStore();
const recordId = useSelectedRecordIdOrThrow();
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { restoreManyRecords } = useRestoreManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
});
const handleRestoreClick = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
await restoreManyRecords({
idsToRestore: [recordId],
});
};
return (
<CommandModal
title={t`Restore Record`}
subtitle={t`Are you sure you want to restore this record?`}
onConfirmClick={handleRestoreClick}
confirmButtonText={t`Restore Record`}
confirmButtonAccent="default"
/>
);
};
@@ -0,0 +1,32 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
export const CancelDashboardSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const pageLayoutId = recordStore?.pageLayoutId;
const { closeSidePanelMenu } = useSidePanelMenu();
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetDraftPageLayoutToPersistedPageLayout } =
useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId);
const handleClick = () => {
closeSidePanelMenu();
resetDraftPageLayoutToPersistedPageLayout();
setIsPageLayoutInEditMode(false);
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,37 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useDuplicateDashboard } from '@/dashboards/hooks/useDuplicateDashboard';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const DuplicateDashboardSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const { duplicateDashboard } = useDuplicateDashboard();
const navigate = useNavigateApp();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
const handleClick = async () => {
const result = await duplicateDashboard(recordId);
if (isDefined(result) && isNonEmptyString(result.id)) {
enqueueSuccessSnackBar({
message: t`Dashboard duplicated successfully`,
});
navigate(AppPath.RecordShowPage, {
objectNameSingular: CoreObjectNameSingular.Dashboard,
objectRecordId: result.id,
});
} else {
enqueueErrorSnackBar({
message: t`Failed to duplicate dashboard`,
});
}
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,26 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useResetLocationHash } from 'twenty-ui/utilities';
export const EditDashboardSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const pageLayoutId = recordStore?.pageLayoutId;
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetLocationHash } = useResetLocationHash();
const handleClick = () => {
setIsPageLayoutInEditMode(true);
resetLocationHash();
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,33 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
export const SaveDashboardSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const pageLayoutId = recordStore?.pageLayoutId;
const { savePageLayout } = useSavePageLayout(pageLayoutId);
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { closeSidePanelMenu } = useSidePanelMenu();
const handleClick = async () => {
const result = await savePageLayout();
if (result.status === 'successful') {
closeSidePanelMenu();
setIsPageLayoutInEditMode(false);
}
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,3 @@
export enum DashboardSingleRecordCommandKeys {
DUPLICATE_DASHBOARD = 'duplicate-dashboard-single-record',
}
@@ -0,0 +1,18 @@
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
export const useSelectedRecordIdOrThrow = () => {
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
if (
contextStoreTargetedRecordsRule.mode === 'exclusion' ||
(contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0)
) {
throw new Error('Selected record ID is required');
}
return contextStoreTargetedRecordsRule.selectedRecordIds[0];
};
@@ -0,0 +1,18 @@
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
export const useSelectedRecordIds = () => {
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
if (
contextStoreTargetedRecordsRule.mode === 'exclusion' ||
(contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0)
) {
return [];
}
return contextStoreTargetedRecordsRule.selectedRecordIds;
};
@@ -0,0 +1,31 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
export const CancelRecordPageLayoutSingleRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { closeSidePanelMenu } = useSidePanelMenu();
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetDraftPageLayoutToPersistedPageLayout } =
useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId);
const handleClick = () => {
closeSidePanelMenu();
resetDraftPageLayoutToPersistedPageLayout();
setIsPageLayoutInEditMode(false);
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,25 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useResetLocationHash } from 'twenty-ui/utilities';
export const EditRecordPageLayoutSingleRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetLocationHash } = useResetLocationHash();
const handleClick = () => {
setIsPageLayoutInEditMode(true);
resetLocationHash();
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,38 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
export const SaveRecordPageLayoutSingleRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { savePageLayout } = useSavePageLayout(pageLayoutId);
const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups({
pageLayoutId,
});
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { closeSidePanelMenu } = useSidePanelMenu();
const handleClick = async () => {
const result = await savePageLayout();
if (result.status === 'successful') {
await saveFieldsWidgetGroups();
closeSidePanelMenu();
setIsPageLayoutInEditMode(false);
}
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,5 @@
export enum RecordPageLayoutSingleRecordCommandKeys {
EDIT_RECORD_PAGE_LAYOUT = 'edit-record-page-layout-single-record',
SAVE_RECORD_PAGE_LAYOUT = 'save-record-page-layout-single-record',
CANCEL_RECORD_PAGE_LAYOUT_EDITION = 'cancel-record-page-layout-edition-single-record',
}
@@ -0,0 +1,12 @@
export enum SingleRecordCommandKeys {
DELETE = 'delete-single-record',
DESTROY = 'destroy-single-record',
ADD_TO_FAVORITES = 'add-to-favorites-single-record',
REMOVE_FROM_FAVORITES = 'remove-from-favorites-single-record',
NAVIGATE_TO_NEXT_RECORD = 'navigate-to-next-record-single-record',
NAVIGATE_TO_PREVIOUS_RECORD = 'navigate-to-previous-record-single-record',
EXPORT_NOTE_TO_PDF = 'export-note-to-pdf-single-record',
EXPORT_FROM_RECORD_INDEX = 'export-from-record-index-single-record',
EXPORT_FROM_RECORD_SHOW = 'export-from-record-show-single-record',
RESTORE = 'restore-single-record',
}
@@ -0,0 +1,16 @@
import { type BlockNoteEditor } from '@blocknote/core';
import {
docxDefaultSchemaMappings,
DOCXExporter,
} from '@blocknote/xl-docx-exporter';
import { saveAs } from 'file-saver';
export const exportBlockNoteEditorToDocx = async (
editor: BlockNoteEditor,
filename: string,
) => {
const exporter = new DOCXExporter(editor.schema, docxDefaultSchemaMappings);
const blob = await exporter.toBlob(editor.document);
saveAs(blob, `${filename}.docx`);
};
@@ -0,0 +1,81 @@
import { BlockNoteEditor, type PartialBlock } from '@blocknote/core';
import {
PDFExporter,
pdfDefaultSchemaMappings,
} from '@blocknote/xl-pdf-exporter';
import { Font, pdf } from '@react-pdf/renderer';
import { saveAs } from 'file-saver';
const registerInterFonts = (() => {
let registrationPromise: Promise<void> | null = null;
return () => {
if (!registrationPromise) {
registrationPromise = Promise.resolve().then(() => {
Font.register({
family: 'Inter',
fonts: [
{
src: 'https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf',
fontWeight: 400,
},
{
src: 'https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fMZg.ttf',
fontWeight: 500,
},
{
src: 'https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf',
fontWeight: 600,
},
],
});
});
}
return registrationPromise;
};
})();
export const exportBlockNoteEditorToPdf = async (
parsedBody: PartialBlock[],
filename: string,
) => {
await registerInterFonts();
const editor = BlockNoteEditor.create({
initialContent: parsedBody,
});
const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings, {
resolveFileUrl: async (url: string) => {
try {
const response = await fetch(url, {
mode: 'cors',
credentials: 'omit',
});
if (!response.ok) {
throw new Error(
`Failed to fetch asset at ${url}: ${response.status} ${response.statusText}`,
);
}
return await response.blob();
} catch (error) {
if (
error instanceof Error &&
error.message.includes('Failed to fetch asset')
) {
throw error;
}
throw new Error(
`Failed to fetch asset at ${url}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
},
});
const pdfDocument = await exporter.toReactPDFDocument(editor.document);
const blob = await pdf(pdfDocument).toBlob();
saveAs(blob, `${filename}.pdf`);
};
@@ -0,0 +1,25 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { isDefined } from 'twenty-shared/utils';
export const SeeVersionWorkflowRunSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
if (!isDefined(recordStore) || !isDefined(recordStore?.workflowVersion?.id)) {
return null;
}
return (
<CommandLink
to={AppPath.RecordShowPage}
params={{
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
objectRecordId: recordStore.workflowVersion.id,
}}
/>
);
};
@@ -0,0 +1,25 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { isDefined } from 'twenty-shared/utils';
export const SeeWorkflowWorkflowRunSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
if (!isDefined(recordStore) || !isDefined(recordStore?.workflow?.id)) {
return null;
}
return (
<CommandLink
to={AppPath.RecordShowPage}
params={{
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: recordStore.workflow.id,
}}
/>
);
};
@@ -0,0 +1,69 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreFilterGroupsComponentState } from '@/context-store/states/contextStoreFilterGroupsComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useStopWorkflowRun } from '@/workflow/hooks/useStopWorkflowRun';
export const StopWorkflowRunSingleRecordCommand = () => {
const { objectMetadataItem } = useRecordIndexIdFromCurrentContextStore();
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreFilters = useAtomComponentStateValue(
contextStoreFiltersComponentState,
);
const contextStoreFilterGroups = useAtomComponentStateValue(
contextStoreFilterGroupsComponentState,
);
const contextStoreAnyFieldFilterValue = useAtomComponentStateValue(
contextStoreAnyFieldFilterValueComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const graphqlFilter = computeContextStoreFilters({
contextStoreTargetedRecordsRule,
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
filter: graphqlFilter,
limit: DEFAULT_QUERY_PAGE_SIZE,
recordGqlFields: { id: true },
});
const { stopWorkflowRun } = useStopWorkflowRun();
const handleClick = async () => {
if (contextStoreTargetedRecordsRule.mode === 'selection') {
for (const selectedRecordId of contextStoreTargetedRecordsRule.selectedRecordIds) {
await stopWorkflowRun(selectedRecordId);
}
} else {
const records = await fetchAllRecordIds();
for (const record of records) {
await stopWorkflowRun(record.id);
}
}
};
return <Command onClick={handleClick} />;
};
@@ -0,0 +1,5 @@
export enum WorkflowRunSingleRecordCommandKeys {
STOP = 'stop-workflow-run-single-record',
SEE_WORKFLOW = 'see-workflow-workflow-run-single-record',
SEE_VERSION = 'see-version-workflow-run-single-record',
}
@@ -0,0 +1,57 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { AppPath, ViewFilterOperand } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const SeeRunsWorkflowVersionSingleRecordCommandContent = ({
workflowId,
recordId,
}: {
workflowId: string;
recordId: string;
}) => {
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
return (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowRun }}
queryParams={{
filter: {
workflow: {
[ViewFilterOperand.IS]: {
selectedRecordIds: [workflowWithCurrentVersion?.id],
},
},
recordStore: {
[ViewFilterOperand.IS]: {
selectedRecordIds: [recordId],
},
},
},
}}
/>
);
};
export const SeeRunsWorkflowVersionSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const workflowId = recordStore?.workflow?.id;
if (!isDefined(workflowId)) {
return null;
}
return (
<SeeRunsWorkflowVersionSingleRecordCommandContent
workflowId={workflowId}
recordId={recordId}
/>
);
};
@@ -0,0 +1,47 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { AppPath, ViewFilterOperand } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const SeeVersionsWorkflowVersionSingleRecordCommandContent = ({
workflowId,
}: {
workflowId: string;
}) => {
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
return (
<CommandLink
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowVersion }}
queryParams={{
filter: {
workflow: {
[ViewFilterOperand.IS]: {
selectedRecordIds: [workflowWithCurrentVersion?.id],
},
},
},
}}
/>
);
};
export const SeeVersionsWorkflowVersionSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
if (!isDefined(recordStore) || !isDefined(recordStore.workflowId)) {
return null;
}
return (
<SeeVersionsWorkflowVersionSingleRecordCommandContent
workflowId={recordStore.workflowId}
/>
);
};
@@ -0,0 +1,20 @@
import { CommandLink } from '@/command-menu-item/display/components/CommandLink';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
export const SeeWorkflowWorkflowVersionSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
return (
<CommandLink
to={AppPath.RecordShowPage}
params={{
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: recordStore?.workflow?.id,
}}
/>
);
};

Some files were not shown because too many files have changed in this diff Show More