Refactor command menu items deprecated code (#19508)

- Removes the intermediate `CommandMenuItemConfig` /
`CommandConfigContext` / `CommandMenuItemDisplay` abstraction layers,
replacing them with a single `CommandMenuItemRenderer` that renders
directly from the command menu items from the backend
- Eliminates the server-items/ subdirectory by moving its contents
(hooks/, contexts/, states/, display/, edit/) up into the parent
command-menu-item/ module, removing an unnecessary nesting level.
This commit is contained in:
Raphaël Bosi
2026-04-10 11:28:27 +02:00
committed by GitHub
parent f13e7e01fe
commit 4b3a46d953
132 changed files with 1376 additions and 2723 deletions
@@ -0,0 +1,61 @@
import { AnimatedIconCrossfade } from 'twenty-ui/utilities';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { SidePanelPages } from 'twenty-shared/types';
import { IconPencil, IconX } from 'twenty-ui/display';
import { AnimatedButton } from 'twenty-ui/input';
export const CommandMenuItemEditButton = () => {
const { t } = useLingui();
const { navigateSidePanel } = useNavigateSidePanel();
const { closeSidePanelMenu } = useSidePanelMenu();
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const isCommandMenuEditPageActive =
isSidePanelOpened && sidePanelPage === SidePanelPages.CommandMenuEdit;
if (!isLayoutCustomizationModeEnabled) {
return null;
}
const handleClick = () => {
if (isCommandMenuEditPageActive) {
closeSidePanelMenu();
return;
}
navigateSidePanel({
page: SidePanelPages.CommandMenuEdit,
pageTitle: t`Edit actions`,
pageIcon: IconPencil,
resetNavigationStack: true,
});
};
return (
<AnimatedButton
animatedSvg={
<AnimatedIconCrossfade
isActive={isCommandMenuEditPageActive}
ActiveIcon={IconX}
InactiveIcon={IconPencil}
/>
}
title={t`Edit actions`}
variant="secondary"
size="small"
onClick={handleClick}
/>
);
};
@@ -0,0 +1,125 @@
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
import { useSelectFirstRecordForEditMode } from '@/command-menu-item/edit/hooks/useSelectFirstRecordForEditMode';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { useResetRecordIndexSelection } from '@/object-record/record-index/hooks/useResetRecordIndexSelection';
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import {
IconChevronDown,
IconSquareCheck,
IconSquareX,
} from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const DROPDOWN_ID = 'command-menu-edit-record-selection-dropdown';
const StyledClickableArea = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
gap: ${themeCssVariables.spacing[1]};
height: 24px;
opacity: ${({ disabled }) => (disabled ? '0.5' : '1')};
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[1]};
`;
const StyledLabel = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledDropdownMenuContainer = styled.div`
width: 100%;
`;
type CommandMenuItemEditRecordSelectionDropdownProps = {
isRecordPage?: boolean;
};
export const CommandMenuItemEditRecordSelectionDropdown = ({
isRecordPage = false,
}: CommandMenuItemEditRecordSelectionDropdownProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
const { selectFirstRecordForEditMode } = useSelectFirstRecordForEditMode();
const { resetRecordIndexSelection } = useResetRecordIndexSelection(
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const isNoneSelected = !mainContextStoreHasSelectedRecords;
const handleSelectMode = (mode: 'none' | 'selection') => {
if (mode === 'selection' && isNoneSelected) {
selectFirstRecordForEditMode();
} else if (mode === 'none') {
resetRecordIndexSelection();
}
closeDropdown(DROPDOWN_ID);
};
const TriggerIcon = isNoneSelected ? IconSquareX : IconSquareCheck;
const triggerLabel = isNoneSelected
? t`No record selected`
: t`Records selected`;
return (
<Dropdown
dropdownId={DROPDOWN_ID}
disableClickForClickableComponent={isRecordPage}
clickableComponent={
<StyledClickableArea
disabled={isRecordPage}
data-click-outside-id={COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<TriggerIcon size={16} />
<StyledLabel>{triggerLabel}</StyledLabel>
<IconChevronDown size={16} />
</StyledClickableArea>
}
dropdownPlacement="bottom-start"
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<StyledDropdownMenuContainer
data-click-outside-id={COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<DropdownMenuItemsContainer>
<MenuItemSelect
LeftIcon={IconSquareX}
text={t`No record selected`}
selected={isNoneSelected}
onClick={() => handleSelectMode('none')}
/>
<MenuItemSelect
LeftIcon={IconSquareCheck}
text={t`Records selected`}
selected={!isNoneSelected}
onClick={() => handleSelectMode('selection')}
/>
</DropdownMenuItemsContainer>
</StyledDropdownMenuContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,87 @@
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/edit/hooks/useUpdateCommandMenuItemInDraft';
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useLingui } from '@lingui/react/macro';
import { type ReactElement } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconRefresh, IconTag } from 'twenty-ui/display';
import { MenuItem, MenuItemToggle } from 'twenty-ui/navigation';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
type CommandMenuItemOptionsDropdownProps = Pick<
CommandMenuItemFieldsFragment,
'shortLabel'
> & {
itemId: string;
serverShortLabel: string | null | undefined;
iconButton: ReactElement;
};
const getCommandMenuItemOptionsDropdownId = (itemId: string) =>
`command-menu-item-options-${itemId}`;
export const CommandMenuItemOptionsDropdown = ({
itemId,
shortLabel,
serverShortLabel,
iconButton,
}: CommandMenuItemOptionsDropdownProps) => {
const { t } = useLingui();
const dropdownId = getCommandMenuItemOptionsDropdownId(itemId);
const { closeDropdown } = useCloseDropdown();
const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft();
const normalizedServerShortLabel = serverShortLabel ?? null;
const normalizedShortLabel = shortLabel ?? null;
const hasNoShortLabel = normalizedServerShortLabel === null;
const isLabelHidden =
normalizedShortLabel === null && isDefined(normalizedServerShortLabel);
const hasShortLabelOverride =
normalizedShortLabel !== normalizedServerShortLabel;
const handleToggleHideLabel = (toggled: boolean) => {
updateCommandMenuItemInDraft(itemId, {
shortLabel: toggled ? null : normalizedServerShortLabel,
});
};
const handleResetLabelToDefault = () => {
updateCommandMenuItemInDraft(itemId, {
shortLabel: normalizedServerShortLabel,
});
closeDropdown(dropdownId);
};
return (
<Dropdown
dropdownId={dropdownId}
clickableComponent={iconButton}
dropdownPlacement="bottom-end"
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconTag}
text={t`Hide label`}
toggled={isLabelHidden || hasNoShortLabel}
onToggleChange={handleToggleHideLabel}
toggleSize="small"
disabled={hasNoShortLabel}
/>
<MenuItem
LeftIcon={IconRefresh}
onClick={handleResetLabelToDefault}
accent="default"
text={t`Reset label to default`}
disabled={!hasShortLabelOverride}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -0,0 +1,139 @@
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/display/components/PinnedCommandMenuItemsInlineMeasurements';
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/display/constants/PinnedCommandMenuItemsGap';
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/display/hooks/usePinnedCommandMenuItemsInlineLayout';
import { interpolateCommandMenuItemFields } from '@/command-menu-item/display/utils/interpolateCommandMenuItemFields';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext, useMemo } from 'react';
import { useIcons } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
const StyledCommandMenuItemContainer = styled(motion.div)`
align-items: center;
display: flex;
justify-content: center;
`;
const StyledWrapper = styled.div`
flex: 1 1 0;
min-width: 0;
overflow: hidden;
`;
const StyledContainer = styled.div`
display: flex;
justify-content: flex-end;
min-width: 0;
width: 100%;
`;
const StyledItemsContainer = styled.div`
display: flex;
gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px;
max-width: 100%;
overflow: hidden;
`;
export const PinnedCommandMenuItemButtonsEditMode = () => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const commandMenuContextApi = useCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const commandMenuItemsDraft =
useAtomStateValue(commandMenuItemsDraftState) ?? [];
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
const allowedAvailabilityTypes = useMemo(
() =>
new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
mainContextStoreHasSelectedRecords
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
]),
[mainContextStoreHasSelectedRecords],
);
const pinnedCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.filter((item) => item.isPinned);
const {
pinnedInlineCommandMenuItems,
pinnedOverflowCommandMenuItems,
onContainerDimensionChange,
onCommandMenuItemDimensionChange,
} = usePinnedCommandMenuItemsInlineLayout({
pinnedCommandMenuItems,
});
return (
<>
<PinnedCommandMenuItemsInlineMeasurements
pinnedCommandMenuItems={[
...pinnedInlineCommandMenuItems,
...pinnedOverflowCommandMenuItems,
]}
onPinnedCommandMenuItemDimensionChange={
onCommandMenuItemDimensionChange
}
/>
<StyledWrapper>
<NodeDimension onDimensionChange={onContainerDimensionChange}>
<StyledContainer>
<StyledItemsContainer>
{pinnedInlineCommandMenuItems.map((item) => {
const { iconKey, label, shortLabel } =
interpolateCommandMenuItemFields(item, commandMenuContextApi);
const Icon = getIcon(iconKey, COMMAND_MENU_DEFAULT_ICON);
return (
<StyledCommandMenuItemContainer
key={item.id}
layout
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'unset', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
ease: 'easeInOut',
}}
>
<CommandMenuButton
command={{
key: item.id,
label,
shortLabel,
Icon,
}}
disabled
/>
</StyledCommandMenuItemContainer>
);
})}
</StyledItemsContainer>
</StyledContainer>
</NodeDimension>
</StyledWrapper>
</>
);
};
@@ -0,0 +1,336 @@
import { useCommandMenuContextApi } from '@/command-menu-item/hooks/useCommandMenuContextApi';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { groupCommandMenuItems } from '@/command-menu-item/utils/groupCommandMenuItems';
import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/edit/components/CommandMenuItemEditRecordSelectionDropdown';
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/edit/components/CommandMenuItemOptionsDropdown';
import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/edit/hooks/useReorderCommandMenuItemsInDraft';
import { useResetCommandMenuItemsDraft } from '@/command-menu-item/edit/hooks/useResetCommandMenuItemsDraft';
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/edit/hooks/useUpdateCommandMenuItemInDraft';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { mainContextStoreHasSelectedRecordsSelector } from '@/context-store/states/selectors/mainContextStoreHasSelectedRecordsSelector';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type DropResult } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { CommandMenuContextApiPageType } from 'twenty-shared/types';
import {
interpolateCommandMenuItemTemplate,
isDefined,
} from 'twenty-shared/utils';
import {
IconDotsVertical,
IconPin,
IconPinnedOff,
IconRefresh,
useIcons,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledViewbar = styled.div`
align-items: center;
backdrop-filter: blur(5px);
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: flex;
flex-shrink: 0;
height: 40px;
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
`;
const StyledContent = styled.div`
flex: 1;
overflow: auto;
`;
export const SidePanelCommandMenuItemEditPage = () => {
const { t } = useLingui();
const { getIcon } = useIcons();
const commandMenuContextApi = useCommandMenuContextApi();
const currentObjectMetadataItemId =
commandMenuContextApi.objectMetadataItem.id;
const isRecordPage =
commandMenuContextApi.pageType ===
CommandMenuContextApiPageType.RECORD_PAGE;
const mainContextStoreHasSelectedRecords = useAtomStateValue(
mainContextStoreHasSelectedRecordsSelector,
);
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const serverItemsById = new Map(
commandMenuItems.map((item) => [item.id, item]),
);
const commandMenuItemsDraft =
useAtomStateValue(commandMenuItemsDraftState) ?? [];
const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft();
const { reorderCommandMenuItemInDraft } = useReorderCommandMenuItemsInDraft();
const { resetCommandMenuItemsDraft } = useResetCommandMenuItemsDraft();
const allowedAvailabilityTypes = new Set<CommandMenuItemAvailabilityType>([
CommandMenuItemAvailabilityType.GLOBAL,
mainContextStoreHasSelectedRecords
? CommandMenuItemAvailabilityType.RECORD_SELECTION
: CommandMenuItemAvailabilityType.FALLBACK,
]);
const filteredCommandMenuItems = commandMenuItemsDraft
.filter(
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
.sort((firstItem, secondItem) => firstItem.position - secondItem.position);
const filteredCommandMenuItemIds = new Set(
filteredCommandMenuItems.map((item) => item.id),
);
const getDisplayLabel = (item: CommandMenuItemFieldsFragment) =>
interpolateCommandMenuItemTemplate({
label: item.label,
context: commandMenuContextApi,
}) ?? item.label;
const { pinned: allPinnedItems, other: allOtherItems } =
groupCommandMenuItems(filteredCommandMenuItems);
const normalizedSearch =
sidePanelSearch.length > 0
? normalizeSearchText(sidePanelSearch)
: undefined;
const matchesSearch = (item: CommandMenuItemFieldsFragment) =>
normalizedSearch === undefined ||
normalizeSearchText(getDisplayLabel(item)).includes(normalizedSearch);
const displayedPinnedItems = allPinnedItems.filter(matchesSearch);
const displayedOtherItems = allOtherItems.filter(matchesSearch);
const selectableItemIds = [
...displayedPinnedItems.map((item) => item.id),
...displayedOtherItems.map((item) => item.id),
];
const handleTogglePin = (itemId: string, currentlyPinned: boolean) => {
if (currentlyPinned) {
const nextOtherPosition =
allOtherItems.length === 0
? 0
: allOtherItems[allOtherItems.length - 1].position + 1;
updateCommandMenuItemInDraft(itemId, {
isPinned: false,
position: nextOtherPosition,
});
return;
}
const nextPinnedPosition =
allPinnedItems.length === 0
? 0
: allPinnedItems[allPinnedItems.length - 1].position + 1;
updateCommandMenuItemInDraft(itemId, {
isPinned: true,
position: nextPinnedPosition,
});
};
const makeOptionsDropdownWrapper =
(item: Pick<CommandMenuItemFieldsFragment, 'id' | 'shortLabel'>) =>
({ iconButton }: { iconButton: React.ReactElement }) => (
<CommandMenuItemOptionsDropdown
itemId={item.id}
shortLabel={item.shortLabel}
serverShortLabel={serverItemsById.get(item.id)?.shortLabel ?? null}
iconButton={iconButton}
/>
);
const handlePinnedDragEnd = (result: DropResult) => {
const { source, destination, draggableId } = result;
if (!isDefined(destination)) {
return;
}
if (source.index === destination.index) {
return;
}
const displayedPinnedItemIdsWithoutSource = displayedPinnedItems
.map((item) => item.id)
.filter((itemId) => itemId !== draggableId);
const allPinnedItemsWithoutSource = allPinnedItems.filter(
(item) => item.id !== draggableId,
);
const nextDisplayedPinnedItemId =
displayedPinnedItemIdsWithoutSource[destination.index];
const previousDisplayedPinnedItemId =
displayedPinnedItemIdsWithoutSource[destination.index - 1];
let destinationIndexInAllPinnedItemsWithoutSource: number;
if (isDefined(nextDisplayedPinnedItemId)) {
destinationIndexInAllPinnedItemsWithoutSource =
allPinnedItemsWithoutSource.findIndex(
(item) => item.id === nextDisplayedPinnedItemId,
);
} else if (isDefined(previousDisplayedPinnedItemId)) {
const previousIndex = allPinnedItemsWithoutSource.findIndex(
(item) => item.id === previousDisplayedPinnedItemId,
);
if (previousIndex === -1) {
return;
}
destinationIndexInAllPinnedItemsWithoutSource = previousIndex + 1;
} else {
return;
}
if (destinationIndexInAllPinnedItemsWithoutSource === -1) {
return;
}
reorderCommandMenuItemInDraft(
draggableId,
destinationIndexInAllPinnedItemsWithoutSource,
'pinned',
filteredCommandMenuItemIds,
);
};
return (
<StyledContainer data-click-outside-id={COMMAND_MENU_CLICK_OUTSIDE_ID}>
<StyledViewbar>
<CommandMenuItemEditRecordSelectionDropdown
isRecordPage={isRecordPage}
/>
</StyledViewbar>
<StyledContent>
<SidePanelList selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Pinned`}>
<DraggableList
onDragEnd={handlePinnedDragEnd}
draggableItems={displayedPinnedItems.map((item, index) => {
const ItemIcon = isDefined(item.icon)
? getIcon(item.icon)
: undefined;
return (
<DraggableItem
key={item.id}
draggableId={item.id}
index={index}
itemComponent={
<SelectableListItem
itemId={item.id}
onEnter={() => handleTogglePin(item.id, true)}
>
<MenuItemDraggable
withIconContainer
LeftIcon={ItemIcon}
text={getDisplayLabel(item)}
gripMode="onHover"
isIconDisplayedOnHoverOnly={false}
iconButtons={[
{
Icon: IconPinnedOff,
onClick: (event) => {
event.stopPropagation();
handleTogglePin(item.id, true);
},
},
{
Icon: IconDotsVertical,
Wrapper: makeOptionsDropdownWrapper(item),
onClick: () => {},
},
]}
/>
</SelectableListItem>
}
/>
);
})}
/>
</SidePanelGroup>
<SidePanelGroup heading={t`Other`}>
{displayedOtherItems.map((item) => {
const ItemIcon = isDefined(item.icon)
? getIcon(item.icon)
: undefined;
return (
<SelectableListItem
key={item.id}
itemId={item.id}
onEnter={() => handleTogglePin(item.id, false)}
>
<MenuItem
withIconContainer
LeftIcon={ItemIcon}
text={getDisplayLabel(item)}
isIconDisplayedOnHoverOnly={false}
iconButtons={[
{
Icon: IconPin,
onClick: (event) => {
event.stopPropagation();
handleTogglePin(item.id, false);
},
},
]}
/>
</SelectableListItem>
);
})}
</SidePanelGroup>
</SidePanelList>
</StyledContent>
<SidePanelFooter
actions={[
<Button
key="reset"
Icon={IconRefresh}
title={t`Reset to default`}
variant="secondary"
accent="default"
size="small"
onClick={resetCommandMenuItemsDraft}
/>,
]}
/>
</StyledContainer>
);
};
@@ -0,0 +1,92 @@
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { computeInsertPositionFromBounds } from '@/command-menu-item/edit/utils/computeInsertPositionFromBounds';
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/edit/utils/getPositionBoundsAtInsertionPoint';
export const useReorderCommandMenuItemsInDraft = () => {
const store = useStore();
const reorderCommandMenuItemInDraft = useCallback(
(
sourceId: string,
destinationIndex: number,
targetSection: 'pinned' | 'other',
contextualItemIds?: ReadonlySet<string>,
) => {
const draft = store.get(commandMenuItemsDraftState.atom);
if (!isDefined(draft)) {
return;
}
const isPinned = targetSection === 'pinned';
const fullSectionItems = draft
.filter((item) => item.isPinned === isPinned)
.sort((a, b) => a.position - b.position);
const contextualSectionItems = isDefined(contextualItemIds)
? fullSectionItems.filter((item) => contextualItemIds.has(item.id))
: fullSectionItems;
const fullSectionItemsWithoutSource = fullSectionItems.filter(
(item) => item.id !== sourceId,
);
const contextualSectionItemsWithoutSource = contextualSectionItems.filter(
(item) => item.id !== sourceId,
);
const clampedDestinationIndex = Math.max(
0,
Math.min(destinationIndex, contextualSectionItemsWithoutSource.length),
);
const nextContextualItem =
contextualSectionItemsWithoutSource[clampedDestinationIndex];
const previousContextualItem =
contextualSectionItemsWithoutSource[clampedDestinationIndex - 1];
if (
!isDefined(nextContextualItem) &&
!isDefined(previousContextualItem)
) {
return;
}
const positionBounds = isDefined(nextContextualItem)
? getPositionBoundsAtInsertionPoint(
nextContextualItem.id,
'before',
fullSectionItemsWithoutSource,
)
: getPositionBoundsAtInsertionPoint(
previousContextualItem!.id,
'after',
fullSectionItemsWithoutSource,
);
if (!isDefined(positionBounds)) {
return;
}
const newPosition = computeInsertPositionFromBounds(
positionBounds.previousPosition,
positionBounds.nextPosition,
);
const updatedDraft = draft.map((item) =>
item.id === sourceId
? { ...item, isPinned, position: newPosition }
: item,
);
store.set(commandMenuItemsDraftState.atom, updatedDraft);
},
[store],
);
return { reorderCommandMenuItemInDraft };
};
@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
// Resets the draft to the current server state, discarding all user edits.
export const useResetCommandMenuItemsDraft = () => {
const store = useStore();
const resetCommandMenuItemsDraft = useCallback(() => {
const serverItems = store.get(commandMenuItemsSelector.atom);
store.set(
commandMenuItemsDraftState.atom,
serverItems.map((item) => ({ ...item })),
);
}, [store]);
return { resetCommandMenuItemsDraft };
};
@@ -0,0 +1,57 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { UPDATE_COMMAND_MENU_ITEM } from '@/command-menu-item/graphql/mutations/updateCommandMenuItem';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type UpdateCommandMenuItemInput } from '~/generated-metadata/graphql';
export const useSaveCommandMenuItemsDraft = () => {
const store = useStore();
const [updateCommandMenuItem] = useMutation(UPDATE_COMMAND_MENU_ITEM);
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const saveCommandMenuItemsDraft = useCallback(async () => {
const draft = store.get(commandMenuItemsDraftState.atom);
if (!isDefined(draft)) {
return;
}
const serverItemsById = new Map(
commandMenuItems.map((item) => [item.id, item]),
);
const changedItems = draft.filter((draftItem) => {
const serverItem = serverItemsById.get(draftItem.id);
if (!isDefined(serverItem)) {
return false;
}
return (
draftItem.isPinned !== serverItem.isPinned ||
draftItem.position !== serverItem.position ||
draftItem.shortLabel !== serverItem.shortLabel
);
});
await Promise.all(
changedItems.map((item) => {
const input: UpdateCommandMenuItemInput = {
id: item.id,
isPinned: item.isPinned,
position: item.position,
shortLabel: item.shortLabel,
};
return updateCommandMenuItem({ variables: { input } });
}),
);
}, [store, commandMenuItems, updateCommandMenuItem]);
return { saveCommandMenuItemsDraft };
};
@@ -0,0 +1,67 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { getRecordIndexId } from '@/command-menu-item/edit/utils/getRecordIndexId';
import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState';
import { useResetRecordIndexSelection } from '@/object-record/record-index/hooks/useResetRecordIndexSelection';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector';
import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState';
import { ViewType } from '@/views/types/ViewType';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useSelectFirstRecordForEditMode = () => {
const store = useStore();
const { resetRecordIndexSelection } = useResetRecordIndexSelection(
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const selectFirstRecordForEditMode = useCallback(() => {
const recordIndexId = getRecordIndexId(store);
if (!isDefined(recordIndexId)) {
return;
}
resetRecordIndexSelection();
const allRecordIds = store.get(
recordIndexAllRecordIdsComponentSelector.selectorFamily({
instanceId: recordIndexId,
}),
);
const firstRecordId = allRecordIds[0];
if (!isDefined(firstRecordId)) {
return;
}
const viewType = store.get(recordIndexViewTypeState.atom);
switch (viewType) {
case ViewType.TABLE: {
store.set(
isRowSelectedComponentFamilyState.atomFamily({
instanceId: recordIndexId,
familyKey: firstRecordId,
}),
true,
);
break;
}
case ViewType.KANBAN: {
store.set(
isRecordBoardCardSelectedComponentFamilyState.atomFamily({
instanceId: recordIndexId,
familyKey: firstRecordId,
}),
true,
);
break;
}
}
}, [store, resetRecordIndexSelection]);
return { selectFirstRecordForEditMode };
};
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState';
import { type CommandMenuItemEditableFields } from '@/command-menu-item/edit/types/CommandMenuItemEditableFields';
export const useUpdateCommandMenuItemInDraft = () => {
const store = useStore();
const updateCommandMenuItemInDraft = useCallback(
(id: string, fields: Partial<CommandMenuItemEditableFields>) => {
const draft = store.get(commandMenuItemsDraftState.atom);
if (!isDefined(draft)) {
return;
}
const updatedDraft = draft.map((item) =>
item.id === id ? { ...item, ...fields } : item,
);
store.set(commandMenuItemsDraftState.atom, updatedDraft);
},
[store],
);
return { updateCommandMenuItemInDraft };
};
@@ -0,0 +1,10 @@
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const commandMenuItemsDraftState = createAtomState<
CommandMenuItemFieldsFragment[] | null
>({
key: 'commandMenuItemsDraftState',
defaultValue: null,
});
@@ -0,0 +1,6 @@
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export type CommandMenuItemEditableFields = Pick<
CommandMenuItemFieldsFragment,
'isPinned' | 'position' | 'shortLabel'
>;
@@ -0,0 +1,39 @@
import { computeInsertPositionFromBounds } from '@/command-menu-item/edit/utils/computeInsertPositionFromBounds';
describe('computeInsertPositionFromBounds', () => {
it('returns midpoint when both bounds are defined', () => {
expect(computeInsertPositionFromBounds(2, 4)).toBe(3);
});
it('returns midpoint for non-integer result', () => {
expect(computeInsertPositionFromBounds(1, 2)).toBe(1.5);
});
it('returns previous - 1 when only next is undefined', () => {
expect(computeInsertPositionFromBounds(5, undefined)).toBe(6);
});
it('returns next - 1 when only previous is undefined', () => {
expect(computeInsertPositionFromBounds(undefined, 3)).toBe(2);
});
it('returns 0 when both bounds are undefined', () => {
expect(computeInsertPositionFromBounds(undefined, undefined)).toBe(0);
});
it('returns previous - 1 when bounds are equal', () => {
expect(computeInsertPositionFromBounds(5, 5)).toBe(4);
});
it('handles negative positions', () => {
expect(computeInsertPositionFromBounds(-4, -2)).toBe(-3);
});
it('handles zero as previous position', () => {
expect(computeInsertPositionFromBounds(0, 2)).toBe(1);
});
it('handles zero as next position', () => {
expect(computeInsertPositionFromBounds(-2, 0)).toBe(-1);
});
});
@@ -0,0 +1,105 @@
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/edit/utils/getPositionBoundsAtInsertionPoint';
const makeItems = (positions: number[]) =>
positions.map((position, index) => ({
id: `item-${index}`,
position,
}));
describe('getPositionBoundsAtInsertionPoint', () => {
const items = makeItems([10, 20, 30, 40, 50]);
describe('insert before', () => {
it('returns previous=undefined and next=10 when inserting before the first item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-0', 'before', items),
).toEqual({
previousPosition: undefined,
nextPosition: 10,
});
});
it('returns previous=10 and next=20 when inserting before the second item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-1', 'before', items),
).toEqual({
previousPosition: 10,
nextPosition: 20,
});
});
it('returns previous=40 and next=50 when inserting before the last item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-4', 'before', items),
).toEqual({
previousPosition: 40,
nextPosition: 50,
});
});
});
describe('insert after', () => {
it('returns previous=10 and next=20 when inserting after the first item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-0', 'after', items),
).toEqual({
previousPosition: 10,
nextPosition: 20,
});
});
it('returns previous=50 and next=undefined when inserting after the last item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-4', 'after', items),
).toEqual({
previousPosition: 50,
nextPosition: undefined,
});
});
it('returns previous=30 and next=40 when inserting after the middle item', () => {
expect(
getPositionBoundsAtInsertionPoint('item-2', 'after', items),
).toEqual({
previousPosition: 30,
nextPosition: 40,
});
});
});
describe('edge cases', () => {
it('returns undefined when anchor item is not found', () => {
expect(
getPositionBoundsAtInsertionPoint('nonexistent', 'before', items),
).toBeUndefined();
});
it('handles single-item list inserting before', () => {
const singleItem = makeItems([5]);
expect(
getPositionBoundsAtInsertionPoint('item-0', 'before', singleItem),
).toEqual({
previousPosition: undefined,
nextPosition: 5,
});
});
it('handles single-item list inserting after', () => {
const singleItem = makeItems([5]);
expect(
getPositionBoundsAtInsertionPoint('item-0', 'after', singleItem),
).toEqual({
previousPosition: 5,
nextPosition: undefined,
});
});
it('returns undefined for empty list', () => {
expect(
getPositionBoundsAtInsertionPoint('item-0', 'before', []),
).toBeUndefined();
});
});
});
@@ -0,0 +1,24 @@
import { isDefined } from 'twenty-shared/utils';
export const computeInsertPositionFromBounds = (
previousPosition: number | undefined,
nextPosition: number | undefined,
): number => {
if (!isDefined(previousPosition) && isDefined(nextPosition)) {
return nextPosition - 1;
}
if (isDefined(previousPosition) && !isDefined(nextPosition)) {
return previousPosition + 1;
}
if (isDefined(previousPosition) && isDefined(nextPosition)) {
if (previousPosition === nextPosition) {
return previousPosition - 1;
}
return (previousPosition + nextPosition) / 2;
}
return 0;
};
@@ -0,0 +1,23 @@
type PositionedItem = { id: string; position: number };
export const getPositionBoundsAtInsertionPoint = (
anchorItemId: string,
insertionSide: 'before' | 'after',
sectionItems: PositionedItem[],
) => {
const anchorIndex = sectionItems.findIndex(
(item) => item.id === anchorItemId,
);
if (anchorIndex === -1) {
return undefined;
}
const insertIndex =
insertionSide === 'before' ? anchorIndex : anchorIndex + 1;
return {
previousPosition: sectionItems[insertIndex - 1]?.position,
nextPosition: sectionItems[insertIndex]?.position,
};
};
@@ -0,0 +1,42 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import type { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
export const getRecordIndexId = (
store: ReturnType<typeof useStore>,
): string | null => {
const objectMetadataItemId = store.get(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
}),
);
const viewId = store.get(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
}),
);
if (!isDefined(objectMetadataItemId) || !isDefined(viewId)) {
return null;
}
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === objectMetadataItemId,
);
if (!isDefined(objectMetadataItem)) {
return null;
}
return getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
viewId,
);
};