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:
+61
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+125
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+87
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+139
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+336
@@ -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>
|
||||
);
|
||||
};
|
||||
+92
@@ -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 };
|
||||
};
|
||||
+21
@@ -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 };
|
||||
};
|
||||
+57
@@ -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 };
|
||||
};
|
||||
+67
@@ -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 };
|
||||
};
|
||||
+29
@@ -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 };
|
||||
};
|
||||
+10
@@ -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,
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
|
||||
|
||||
export type CommandMenuItemEditableFields = Pick<
|
||||
CommandMenuItemFieldsFragment,
|
||||
'isPinned' | 'position' | 'shortLabel'
|
||||
>;
|
||||
+39
@@ -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);
|
||||
});
|
||||
});
|
||||
+105
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+24
@@ -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;
|
||||
};
|
||||
+23
@@ -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,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user