feat: add command menu item to layout customization (#18764)
figma https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=93630-365000&t=QesAkY3JOyI9D3UU-0 https://github.com/user-attachments/assets/cf3b354d-0b08-41ae-91ae-386054b5cb2e https://github.com/user-attachments/assets/73bfd676-f823-44c9-a70d-107c20523664 --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+1
-1
@@ -1,8 +1,8 @@
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/hooks/useCommandMenuContextApi';
|
||||
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi';
|
||||
|
||||
import { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent';
|
||||
import { CommandMenuContextProviderServerItemsWithWorkflowEnrichment } from './CommandMenuContextProviderServerItemsWithWorkflowEnrichment';
|
||||
+1
-1
@@ -2,7 +2,7 @@ import {
|
||||
CommandMenuContext,
|
||||
type CommandMenuContextType,
|
||||
} from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend';
|
||||
import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend';
|
||||
import { type CommandMenuContextApi } from 'twenty-shared/types';
|
||||
|
||||
type CommandMenuContextProviderServerItemsContentProps = {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { type CommandMenuContextApi } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { useWorkflowsWithCurrentVersions } from '@/command-menu-item/server-items/hooks/useWorkflowsWithCurrentVersions';
|
||||
import { useWorkflowsWithCurrentVersions } from '@/command-menu-item/server-items/common/hooks/useWorkflowsWithCurrentVersions';
|
||||
|
||||
import { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent';
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { COMMAND_MENU_ITEM_FRAGMENT } from '@/command-menu-item/graphql/fragments/commandMenuItemFragment';
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_COMMAND_MENU_ITEM = gql`
|
||||
${COMMAND_MENU_ITEM_FRAGMENT}
|
||||
mutation UpdateCommandMenuItem($input: UpdateCommandMenuItemInput!) {
|
||||
updateCommandMenuItem(input: $input) {
|
||||
...CommandMenuItemFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
+22
-20
@@ -1,6 +1,6 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
|
||||
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
|
||||
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
|
||||
@@ -13,13 +13,14 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe
|
||||
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
|
||||
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
|
||||
import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector';
|
||||
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
import { useStore } from 'jotai';
|
||||
import { useContext } from 'react';
|
||||
import {
|
||||
CommandMenuContextApiPageType,
|
||||
type CommandMenuContextApi,
|
||||
@@ -29,7 +30,11 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
|
||||
const store = useStore();
|
||||
|
||||
const { isInSidePanel } = useContext(CommandMenuContext);
|
||||
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
|
||||
ContextStoreComponentInstanceContext,
|
||||
);
|
||||
const isInSidePanel =
|
||||
contextStoreInstanceId === SIDE_PANEL_COMPONENT_INSTANCE_ID;
|
||||
|
||||
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
@@ -39,6 +44,10 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
|
||||
contextStoreTargetedRecordsRuleComponentState,
|
||||
);
|
||||
|
||||
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
|
||||
contextStoreNumberOfSelectedRecordsComponentState,
|
||||
);
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
@@ -52,19 +61,16 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
|
||||
? contextStoreTargetedRecordsRule.selectedRecordIds
|
||||
: undefined;
|
||||
|
||||
const favoriteRecordIds = (() => {
|
||||
if (!isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return recordIds.filter((recordId) =>
|
||||
navigationMenuItems?.some(
|
||||
(item) =>
|
||||
item.targetRecordId === recordId &&
|
||||
item.targetObjectMetadataId === objectMetadataItem.id,
|
||||
),
|
||||
);
|
||||
})();
|
||||
const favoriteRecordIds =
|
||||
!isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem)
|
||||
? []
|
||||
: recordIds.filter((recordId) =>
|
||||
navigationMenuItems?.some(
|
||||
(item) =>
|
||||
item.targetRecordId === recordId &&
|
||||
item.targetObjectMetadataId === objectMetadataItem.id,
|
||||
),
|
||||
);
|
||||
|
||||
const selectedRecords = useAtomFamilySelectorValue(
|
||||
recordStoreRecordsSelector,
|
||||
@@ -107,10 +113,6 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => {
|
||||
? CommandMenuContextApiPageType.RECORD_PAGE
|
||||
: CommandMenuContextApiPageType.INDEX_PAGE;
|
||||
|
||||
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
|
||||
contextStoreNumberOfSelectedRecordsComponentState,
|
||||
);
|
||||
|
||||
const isSelectAll = contextStoreTargetedRecordsRule.mode === 'exclusion';
|
||||
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
|
||||
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
|
||||
export const useCommandMenuItemsDraftState = () => {
|
||||
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
|
||||
const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState);
|
||||
|
||||
const isDirty =
|
||||
isDefined(commandMenuItemsDraft) &&
|
||||
!isDeeplyEqual(
|
||||
commandMenuItemsDraft.map(({ id, isPinned, position, shortLabel }) => ({
|
||||
id,
|
||||
isPinned,
|
||||
position,
|
||||
shortLabel,
|
||||
})),
|
||||
commandMenuItems.map(({ id, isPinned, position, shortLabel }) => ({
|
||||
id,
|
||||
isPinned,
|
||||
position,
|
||||
shortLabel,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
commandMenuItemsDraft,
|
||||
isDirty,
|
||||
};
|
||||
};
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { FrontComponentCommandMenuItem } from '@/command-menu-item/display/components/FrontComponentCommandMenuItem';
|
||||
import { HeadlessCommandMenuItem } from '@/command-menu-item/display/components/HeadlessCommandMenuItem';
|
||||
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
|
||||
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
|
||||
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
|
||||
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
|
||||
|
||||
import { type CommandMenuContextApi } from 'twenty-shared/types';
|
||||
import {
|
||||
evaluateConditionalAvailabilityExpression,
|
||||
interpolateCommandMenuItemLabel,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
|
||||
import {
|
||||
CommandMenuItemAvailabilityType,
|
||||
type CommandMenuItemFieldsFragment,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type CommandMenuItemWithFrontComponent = CommandMenuItemFieldsFragment & {
|
||||
frontComponentId: string;
|
||||
conditionalAvailabilityExpression?: string | null;
|
||||
};
|
||||
|
||||
type CommandMenuItemWithSource = CommandMenuItemFieldsFragment & {
|
||||
conditionalAvailabilityExpression?: string | null;
|
||||
};
|
||||
|
||||
type BuildCommandMenuItemFromFrontComponentParams = {
|
||||
item: CommandMenuItemWithFrontComponent;
|
||||
type?: CommandMenuItemType;
|
||||
scope: CommandMenuItemScope;
|
||||
isPinned: boolean;
|
||||
getIcon: ReturnType<typeof useIcons>['getIcon'];
|
||||
commandMenuContextApi: CommandMenuContextApi;
|
||||
};
|
||||
|
||||
const buildCommandMenuItemFromFrontComponent = ({
|
||||
item,
|
||||
type = CommandMenuItemType.FrontComponent,
|
||||
scope,
|
||||
isPinned,
|
||||
getIcon,
|
||||
commandMenuContextApi,
|
||||
}: BuildCommandMenuItemFromFrontComponentParams) => {
|
||||
const displayLabel = interpolateCommandMenuItemLabel({
|
||||
label: item.label,
|
||||
context: commandMenuContextApi,
|
||||
});
|
||||
|
||||
const displayShortLabel = interpolateCommandMenuItemLabel({
|
||||
label: item.shortLabel,
|
||||
context: commandMenuContextApi,
|
||||
});
|
||||
|
||||
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
|
||||
|
||||
const isHeadless = item.frontComponent?.isHeadless === true;
|
||||
|
||||
return {
|
||||
type,
|
||||
key: `command-menu-item-front-component-${item.id}`,
|
||||
id: item.id,
|
||||
scope,
|
||||
label: displayLabel,
|
||||
shortLabel: displayShortLabel,
|
||||
position: item.position,
|
||||
isPinned,
|
||||
Icon,
|
||||
hotKeys: item.hotKeys,
|
||||
shouldBeRegistered: () =>
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
item.conditionalAvailabilityExpression,
|
||||
commandMenuContextApi,
|
||||
),
|
||||
component: isHeadless ? (
|
||||
<HeadlessCommandMenuItem item={item} />
|
||||
) : (
|
||||
<FrontComponentCommandMenuItem frontComponentId={item.frontComponentId} />
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
type BuildCommandMenuItemFromStandardKeyParams = {
|
||||
item: CommandMenuItemWithSource;
|
||||
type?: CommandMenuItemType;
|
||||
scope: CommandMenuItemScope;
|
||||
isPinned: boolean;
|
||||
getIcon: ReturnType<typeof useIcons>['getIcon'];
|
||||
commandMenuContextApi: CommandMenuContextApi;
|
||||
};
|
||||
|
||||
const buildCommandItemFromEngineKey = ({
|
||||
item,
|
||||
type = CommandMenuItemType.Standard,
|
||||
scope,
|
||||
isPinned,
|
||||
getIcon,
|
||||
commandMenuContextApi,
|
||||
}: BuildCommandMenuItemFromStandardKeyParams) => {
|
||||
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
|
||||
|
||||
return {
|
||||
type,
|
||||
key: `command-menu-item-engine-${item.id}`,
|
||||
id: item.id,
|
||||
scope,
|
||||
label: interpolateCommandMenuItemLabel({
|
||||
label: item.label,
|
||||
context: commandMenuContextApi,
|
||||
}),
|
||||
shortLabel: interpolateCommandMenuItemLabel({
|
||||
label: item.shortLabel,
|
||||
context: commandMenuContextApi,
|
||||
}),
|
||||
position: item.position,
|
||||
isPinned,
|
||||
Icon,
|
||||
hotKeys: item.hotKeys,
|
||||
shouldBeRegistered: () =>
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
item.conditionalAvailabilityExpression,
|
||||
commandMenuContextApi,
|
||||
),
|
||||
component: <HeadlessCommandMenuItem item={item} />,
|
||||
};
|
||||
};
|
||||
|
||||
export const useCommandMenuItemsFromBackend = (
|
||||
commandMenuContextApi: CommandMenuContextApi,
|
||||
) => {
|
||||
const { getIcon } = useIcons();
|
||||
const currentObjectMetadataItemId =
|
||||
commandMenuContextApi.objectMetadataItem.id;
|
||||
|
||||
const hasRecordSelection = commandMenuContextApi.numberOfSelectedRecords >= 1;
|
||||
|
||||
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
|
||||
|
||||
const itemsWithObjectMatches = commandMenuItems.filter(
|
||||
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
|
||||
);
|
||||
|
||||
const buildCommandMenuItem = ({
|
||||
item,
|
||||
scope,
|
||||
isPinned,
|
||||
typeOverride,
|
||||
}: {
|
||||
item: CommandMenuItemFieldsFragment;
|
||||
scope: CommandMenuItemScope;
|
||||
isPinned: boolean;
|
||||
typeOverride?: CommandMenuItemType;
|
||||
}) => {
|
||||
if (isDefined(item.engineComponentKey)) {
|
||||
return buildCommandItemFromEngineKey({
|
||||
item,
|
||||
type: typeOverride,
|
||||
scope,
|
||||
isPinned,
|
||||
getIcon,
|
||||
commandMenuContextApi,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(item.frontComponentId)) {
|
||||
return buildCommandMenuItemFromFrontComponent({
|
||||
item: item as CommandMenuItemWithFrontComponent,
|
||||
type: typeOverride,
|
||||
scope,
|
||||
isPinned,
|
||||
getIcon,
|
||||
commandMenuContextApi,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const globalItems = itemsWithObjectMatches.filter(
|
||||
(item) => item.availabilityType === CommandMenuItemAvailabilityType.GLOBAL,
|
||||
);
|
||||
|
||||
const recordScopedItems = itemsWithObjectMatches.filter(
|
||||
(item) =>
|
||||
item.availabilityType ===
|
||||
CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
);
|
||||
|
||||
const fallbackItems = itemsWithObjectMatches.filter(
|
||||
(item) =>
|
||||
item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK,
|
||||
);
|
||||
|
||||
const globalCommandMenuItems = globalItems
|
||||
.map((item) =>
|
||||
buildCommandMenuItem({
|
||||
item,
|
||||
scope: CommandMenuItemScope.Global,
|
||||
isPinned: item.isPinned,
|
||||
}),
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
const recordScopedCommandMenuItems = hasRecordSelection
|
||||
? recordScopedItems
|
||||
.map((item) =>
|
||||
buildCommandMenuItem({
|
||||
item,
|
||||
scope: CommandMenuItemScope.RecordSelection,
|
||||
isPinned: item.isPinned,
|
||||
}),
|
||||
)
|
||||
.filter(isDefined)
|
||||
: [];
|
||||
|
||||
const fallbackCommandMenuItems = fallbackItems
|
||||
.map((item) =>
|
||||
buildCommandMenuItem({
|
||||
item,
|
||||
scope: CommandMenuItemScope.Global,
|
||||
isPinned: false,
|
||||
typeOverride: CommandMenuItemType.Fallback,
|
||||
}),
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
return [
|
||||
...globalCommandMenuItems,
|
||||
...recordScopedCommandMenuItems,
|
||||
...fallbackCommandMenuItems,
|
||||
]
|
||||
.filter((item) => item.shouldBeRegistered())
|
||||
.sort((a, b) => a.position - b.position);
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
|
||||
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
|
||||
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
|
||||
|
||||
export const commandMenuItemsSelector = createAtomSelector<
|
||||
CommandMenuItemFieldsFragment[]
|
||||
>({
|
||||
key: 'commandMenuItemsSelector',
|
||||
get: ({ get }) => {
|
||||
const storeItem = get(metadataStoreState, 'commandMenuItems');
|
||||
|
||||
return storeItem.current as CommandMenuItemFieldsFragment[];
|
||||
},
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
|
||||
|
||||
export const doesCommandMenuItemMatchObjectMetadataId =
|
||||
(objectMetadataItemId: unknown) => (item: CommandMenuItemFieldsFragment) =>
|
||||
!isDefined(item.availabilityObjectMetadataId) ||
|
||||
item.availabilityObjectMetadataId === objectMetadataItemId;
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
AppTooltip,
|
||||
IconLayoutSidebarRightExpand,
|
||||
IconX,
|
||||
TooltipDelay,
|
||||
TooltipPosition,
|
||||
} from 'twenty-ui/display';
|
||||
import { AnimatedButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { AnimatedIconCrossfade, useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
const StyledTooltipWrapper = styled.div`
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
`;
|
||||
|
||||
export const CommandMenuItemMoreActionsButton = () => {
|
||||
const { toggleSidePanelMenu } = useSidePanelMenu();
|
||||
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const ariaLabel = isSidePanelOpened
|
||||
? t`Close side panel`
|
||||
: t`Open side panel`;
|
||||
|
||||
return (
|
||||
<div id="toggle-side-panel-button">
|
||||
<AnimatedButton
|
||||
animatedSvg={
|
||||
<AnimatedIconCrossfade
|
||||
isActive={isSidePanelOpened}
|
||||
ActiveIcon={IconX}
|
||||
InactiveIcon={IconLayoutSidebarRightExpand}
|
||||
/>
|
||||
}
|
||||
dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
|
||||
dataTestId="page-header-side-panel-button"
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
variant="secondary"
|
||||
accent="default"
|
||||
title={t`More`}
|
||||
ariaLabel={ariaLabel}
|
||||
onClick={toggleSidePanelMenu}
|
||||
/>
|
||||
<StyledTooltipWrapper>
|
||||
<AppTooltip
|
||||
anchorSelect="#toggle-side-panel-button"
|
||||
content={i18n._(ariaLabel)}
|
||||
delay={TooltipDelay.longDelay}
|
||||
place={TooltipPosition.Bottom}
|
||||
offset={5}
|
||||
noArrow
|
||||
/>
|
||||
</StyledTooltipWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent';
|
||||
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements';
|
||||
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
|
||||
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout';
|
||||
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
|
||||
import { styled } from '@linaria/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledActionContainer = 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 PinnedCommandMenuItemButtons = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { commandMenuItems } = useContext(CommandMenuContext);
|
||||
|
||||
const pinnedCommandMenuItems = useMemo(
|
||||
() =>
|
||||
commandMenuItems.filter(
|
||||
(commandMenuItem) => commandMenuItem.isPinned === true,
|
||||
),
|
||||
[commandMenuItems],
|
||||
);
|
||||
|
||||
const {
|
||||
pinnedInlineCommandMenuItems,
|
||||
pinnedOverflowCommandMenuItems,
|
||||
onContainerDimensionChange,
|
||||
onCommandMenuItemDimensionChange,
|
||||
} = usePinnedCommandMenuItemsInlineLayout({
|
||||
pinnedCommandMenuItems,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PinnedCommandMenuItemsInlineMeasurements
|
||||
pinnedCommandMenuItems={[
|
||||
...pinnedInlineCommandMenuItems,
|
||||
...pinnedOverflowCommandMenuItems,
|
||||
]}
|
||||
onPinnedCommandMenuItemDimensionChange={
|
||||
onCommandMenuItemDimensionChange
|
||||
}
|
||||
/>
|
||||
<StyledWrapper>
|
||||
<NodeDimension onDimensionChange={onContainerDimensionChange}>
|
||||
<StyledContainer>
|
||||
<StyledItemsContainer>
|
||||
{pinnedInlineCommandMenuItems.map(
|
||||
(pinnedInlineCommandMenuItem) => (
|
||||
<StyledActionContainer
|
||||
key={pinnedInlineCommandMenuItem.key}
|
||||
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={pinnedInlineCommandMenuItem}
|
||||
/>
|
||||
</StyledActionContainer>
|
||||
),
|
||||
)}
|
||||
</StyledItemsContainer>
|
||||
</StyledContainer>
|
||||
</NodeDimension>
|
||||
</StyledWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
|
||||
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
|
||||
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
|
||||
import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
type ElementDimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type PinnedCommandMenuItemsInlineMeasurementsProps = {
|
||||
pinnedCommandMenuItems: CommandMenuItemConfig[];
|
||||
onPinnedCommandMenuItemDimensionChange: (
|
||||
commandMenuItemKey: string,
|
||||
) => (dimensions: ElementDimensions) => void;
|
||||
};
|
||||
|
||||
const StyledHiddenMeasurementsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export const PinnedCommandMenuItemsInlineMeasurements = ({
|
||||
pinnedCommandMenuItems,
|
||||
onPinnedCommandMenuItemDimensionChange,
|
||||
}: PinnedCommandMenuItemsInlineMeasurementsProps) => {
|
||||
return (
|
||||
<StyledHiddenMeasurementsContainer>
|
||||
{pinnedCommandMenuItems.map((pinnedCommandMenuItem) => (
|
||||
<NodeDimension
|
||||
key={pinnedCommandMenuItem.key}
|
||||
onDimensionChange={onPinnedCommandMenuItemDimensionChange(
|
||||
pinnedCommandMenuItem.key,
|
||||
)}
|
||||
>
|
||||
<CommandMenuButton
|
||||
command={{
|
||||
key: `${pinnedCommandMenuItem.key}-inline-measurement`,
|
||||
label: pinnedCommandMenuItem.label,
|
||||
shortLabel: pinnedCommandMenuItem.shortLabel,
|
||||
Icon: pinnedCommandMenuItem.Icon,
|
||||
isPrimaryCTA: pinnedCommandMenuItem.isPrimaryCTA,
|
||||
}}
|
||||
/>
|
||||
</NodeDimension>
|
||||
))}
|
||||
</StyledHiddenMeasurementsContainer>
|
||||
);
|
||||
};
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
|
||||
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState';
|
||||
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
||||
import { SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelPreviousComponentInstanceId';
|
||||
import { SIDE_PANEL_RESET_CONTEXT_TO_SELECTION } from '@/side-panel/constants/SidePanelResetContextToSelection';
|
||||
import { SidePanelResetContextToSelectionButton } from '@/side-panel/pages/root/components/SidePanelResetContextToSelectionButton';
|
||||
import { useFilterActionsWithSidePanelSearch } from '@/side-panel/pages/root/hooks/useFilterActionsWithSidePanelSearch';
|
||||
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
|
||||
import { type SidePanelCommandMenuItemGroupConfig } from '@/side-panel/types/SidePanelCommandMenuItemGroupConfig';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext } from 'react';
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const SidePanelCommandMenuItemDisplayPage = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
|
||||
const { commandMenuItems } = useContext(CommandMenuContext);
|
||||
const commandMenuPinnedInlineLayout = useAtomStateValue(
|
||||
commandMenuPinnedInlineLayoutState,
|
||||
);
|
||||
|
||||
const { filterActionsWithSidePanelSearch } =
|
||||
useFilterActionsWithSidePanelSearch({
|
||||
sidePanelSearch,
|
||||
});
|
||||
|
||||
const pinnedCommandMenuItems = commandMenuItems
|
||||
.filter((commandMenuItem) => commandMenuItem.isPinned === true)
|
||||
.sort(
|
||||
(firstPinnedCommandMenuItem, secondPinnedCommandMenuItem) =>
|
||||
firstPinnedCommandMenuItem.position -
|
||||
secondPinnedCommandMenuItem.position,
|
||||
);
|
||||
|
||||
const unpinnedCommandMenuItems = commandMenuItems
|
||||
.filter((commandMenuItem) => commandMenuItem.isPinned !== true)
|
||||
.sort(
|
||||
(firstUnpinnedCommandMenuItem, secondUnpinnedCommandMenuItem) =>
|
||||
firstUnpinnedCommandMenuItem.position -
|
||||
secondUnpinnedCommandMenuItem.position,
|
||||
);
|
||||
|
||||
const pinnedCommandMenuItemKeysInDisplayOrder = pinnedCommandMenuItems.map(
|
||||
(pinnedCommandMenuItem) => pinnedCommandMenuItem.key,
|
||||
);
|
||||
|
||||
const visiblePinnedCommandMenuItemCount =
|
||||
getVisibleCommandMenuItemCountForContainerWidth({
|
||||
commandMenuItemKeysInDisplayOrder:
|
||||
pinnedCommandMenuItemKeysInDisplayOrder,
|
||||
commandMenuItemWidthsByKey:
|
||||
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
|
||||
commandMenuItemsContainerWidth:
|
||||
commandMenuPinnedInlineLayout.containerWidth,
|
||||
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
|
||||
});
|
||||
|
||||
const hasKnownPinnedInlineLayout =
|
||||
commandMenuPinnedInlineLayout.containerWidth > 0 &&
|
||||
pinnedCommandMenuItemKeysInDisplayOrder.every((commandMenuItemKey) =>
|
||||
isNumber(
|
||||
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
|
||||
commandMenuItemKey
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const pinnedOverflowCommandMenuItems = hasKnownPinnedInlineLayout
|
||||
? pinnedCommandMenuItems.slice(visiblePinnedCommandMenuItemCount)
|
||||
: pinnedCommandMenuItems;
|
||||
|
||||
const matchingPinnedItems = filterActionsWithSidePanelSearch(
|
||||
pinnedOverflowCommandMenuItems,
|
||||
);
|
||||
const matchingOtherItems = filterActionsWithSidePanelSearch(
|
||||
unpinnedCommandMenuItems,
|
||||
);
|
||||
|
||||
const noResults = !matchingPinnedItems.length && !matchingOtherItems.length;
|
||||
|
||||
const commandGroups: SidePanelCommandMenuItemGroupConfig[] = [
|
||||
{
|
||||
heading: t`Pinned`,
|
||||
items: matchingPinnedItems,
|
||||
},
|
||||
{
|
||||
heading: t`Other`,
|
||||
items: matchingOtherItems,
|
||||
},
|
||||
];
|
||||
|
||||
const selectableItems = commandGroups.flatMap((group) => group.items ?? []);
|
||||
const selectableItemIds = selectableItems.map((item) => item.key);
|
||||
|
||||
// oxlint-disable-next-line twenty/matching-state-variable
|
||||
const previousContextStoreCurrentObjectMetadataItemId =
|
||||
useAtomComponentStateValue(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID,
|
||||
);
|
||||
|
||||
if (isDefined(previousContextStoreCurrentObjectMetadataItemId)) {
|
||||
selectableItemIds.unshift(SIDE_PANEL_RESET_CONTEXT_TO_SELECTION);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanelList
|
||||
commandGroups={commandGroups}
|
||||
selectableItemIds={selectableItemIds}
|
||||
noResults={noResults}
|
||||
>
|
||||
{isDefined(previousContextStoreCurrentObjectMetadataItemId) && (
|
||||
<SidePanelGroup heading={t`Context`}>
|
||||
<SidePanelResetContextToSelectionButton />
|
||||
</SidePanelGroup>
|
||||
)}
|
||||
</SidePanelList>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const PINNED_COMMAND_MENU_ITEMS_GAP = 8;
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
|
||||
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
|
||||
import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState';
|
||||
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
type ElementDimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type UsePinnedCommandMenuItemsInlineLayoutParams = {
|
||||
pinnedCommandMenuItems: CommandMenuItemConfig[];
|
||||
};
|
||||
|
||||
export const usePinnedCommandMenuItemsInlineLayout = ({
|
||||
pinnedCommandMenuItems,
|
||||
}: UsePinnedCommandMenuItemsInlineLayoutParams) => {
|
||||
const [commandMenuPinnedInlineLayout, setCommandMenuPinnedInlineLayout] =
|
||||
useAtomState(commandMenuPinnedInlineLayoutState);
|
||||
|
||||
const pinnedCommandMenuItemsSortedByPosition = useMemo(
|
||||
() =>
|
||||
[...pinnedCommandMenuItems].sort(
|
||||
(firstPinnedCommandMenuItem, secondPinnedCommandMenuItem) =>
|
||||
firstPinnedCommandMenuItem.position -
|
||||
secondPinnedCommandMenuItem.position,
|
||||
),
|
||||
[pinnedCommandMenuItems],
|
||||
);
|
||||
|
||||
const pinnedCommandMenuItemKeysInDisplayOrder = useMemo(
|
||||
() =>
|
||||
pinnedCommandMenuItemsSortedByPosition.map(
|
||||
(pinnedCommandMenuItem) => pinnedCommandMenuItem.key,
|
||||
),
|
||||
[pinnedCommandMenuItemsSortedByPosition],
|
||||
);
|
||||
|
||||
const visiblePinnedCommandMenuItemCount = useMemo(
|
||||
() =>
|
||||
getVisibleCommandMenuItemCountForContainerWidth({
|
||||
commandMenuItemKeysInDisplayOrder:
|
||||
pinnedCommandMenuItemKeysInDisplayOrder,
|
||||
commandMenuItemWidthsByKey:
|
||||
commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
|
||||
commandMenuItemsContainerWidth:
|
||||
commandMenuPinnedInlineLayout.containerWidth,
|
||||
commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP,
|
||||
}),
|
||||
[commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder],
|
||||
);
|
||||
|
||||
const pinnedInlineCommandMenuItems = useMemo(
|
||||
() =>
|
||||
pinnedCommandMenuItemsSortedByPosition.slice(
|
||||
0,
|
||||
visiblePinnedCommandMenuItemCount,
|
||||
),
|
||||
[pinnedCommandMenuItemsSortedByPosition, visiblePinnedCommandMenuItemCount],
|
||||
);
|
||||
|
||||
const pinnedOverflowCommandMenuItems = useMemo(
|
||||
() =>
|
||||
pinnedCommandMenuItemsSortedByPosition.slice(
|
||||
visiblePinnedCommandMenuItemCount,
|
||||
),
|
||||
[pinnedCommandMenuItemsSortedByPosition, visiblePinnedCommandMenuItemCount],
|
||||
);
|
||||
|
||||
const onContainerDimensionChange = useCallback(
|
||||
(dimensions: ElementDimensions) => {
|
||||
setCommandMenuPinnedInlineLayout(
|
||||
(previousCommandMenuPinnedInlineLayout) =>
|
||||
previousCommandMenuPinnedInlineLayout.containerWidth !==
|
||||
dimensions.width
|
||||
? {
|
||||
...previousCommandMenuPinnedInlineLayout,
|
||||
containerWidth: dimensions.width,
|
||||
}
|
||||
: previousCommandMenuPinnedInlineLayout,
|
||||
);
|
||||
},
|
||||
[setCommandMenuPinnedInlineLayout],
|
||||
);
|
||||
|
||||
const onCommandMenuItemDimensionChange = useCallback(
|
||||
(commandMenuItemKey: string) => (dimensions: ElementDimensions) => {
|
||||
setCommandMenuPinnedInlineLayout(
|
||||
(previousCommandMenuPinnedInlineLayout) =>
|
||||
previousCommandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[
|
||||
commandMenuItemKey
|
||||
] !== dimensions.width
|
||||
? {
|
||||
...previousCommandMenuPinnedInlineLayout,
|
||||
commandMenuItemWidthsByKey: {
|
||||
...previousCommandMenuPinnedInlineLayout.commandMenuItemWidthsByKey,
|
||||
[commandMenuItemKey]: dimensions.width,
|
||||
},
|
||||
}
|
||||
: previousCommandMenuPinnedInlineLayout,
|
||||
);
|
||||
},
|
||||
[setCommandMenuPinnedInlineLayout],
|
||||
);
|
||||
|
||||
return {
|
||||
pinnedInlineCommandMenuItems,
|
||||
pinnedOverflowCommandMenuItems,
|
||||
onContainerDimensionChange,
|
||||
onCommandMenuItemDimensionChange,
|
||||
};
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { type CommandMenuPinnedInlineLayout } from '@/command-menu-item/server-items/display/types/CommandMenuPinnedInlineLayout';
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const commandMenuPinnedInlineLayoutState =
|
||||
createAtomState<CommandMenuPinnedInlineLayout>({
|
||||
key: 'commandMenuPinnedInlineLayoutState',
|
||||
defaultValue: {
|
||||
containerWidth: 0,
|
||||
commandMenuItemWidthsByKey: {},
|
||||
},
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type CommandMenuPinnedInlineLayout = {
|
||||
containerWidth: number;
|
||||
commandMenuItemWidthsByKey: Record<string, number>;
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth';
|
||||
|
||||
describe('getVisibleCommandMenuItemCountForContainerWidth', () => {
|
||||
it('should return all items when container width is zero', () => {
|
||||
const visibleCommandMenuItemCount =
|
||||
getVisibleCommandMenuItemCountForContainerWidth({
|
||||
commandMenuItemKeysInDisplayOrder: ['a', 'b', 'c'],
|
||||
commandMenuItemWidthsByKey: { a: 20, b: 20, c: 20 },
|
||||
commandMenuItemsContainerWidth: 0,
|
||||
commandMenuItemsGapWidth: 8,
|
||||
});
|
||||
|
||||
expect(visibleCommandMenuItemCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should return all items when at least one width is missing', () => {
|
||||
const visibleCommandMenuItemCount =
|
||||
getVisibleCommandMenuItemCountForContainerWidth({
|
||||
commandMenuItemKeysInDisplayOrder: ['a', 'b'],
|
||||
commandMenuItemWidthsByKey: { a: 20 },
|
||||
commandMenuItemsContainerWidth: 40,
|
||||
commandMenuItemsGapWidth: 8,
|
||||
});
|
||||
|
||||
expect(visibleCommandMenuItemCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should stop before exceeding the container width with gaps', () => {
|
||||
const visibleCommandMenuItemCount =
|
||||
getVisibleCommandMenuItemCountForContainerWidth({
|
||||
commandMenuItemKeysInDisplayOrder: ['a', 'b', 'c'],
|
||||
commandMenuItemWidthsByKey: { a: 40, b: 40, c: 40 },
|
||||
commandMenuItemsContainerWidth: 100,
|
||||
commandMenuItemsGapWidth: 8,
|
||||
});
|
||||
|
||||
expect(visibleCommandMenuItemCount).toBe(2);
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
type GetVisibleCommandMenuItemCountForContainerWidthParams = {
|
||||
commandMenuItemKeysInDisplayOrder: string[];
|
||||
commandMenuItemWidthsByKey: Record<string, number>;
|
||||
commandMenuItemsContainerWidth: number;
|
||||
commandMenuItemsGapWidth: number;
|
||||
};
|
||||
|
||||
export const getVisibleCommandMenuItemCountForContainerWidth = ({
|
||||
commandMenuItemKeysInDisplayOrder,
|
||||
commandMenuItemWidthsByKey,
|
||||
commandMenuItemsContainerWidth,
|
||||
commandMenuItemsGapWidth,
|
||||
}: GetVisibleCommandMenuItemCountForContainerWidthParams): number => {
|
||||
if (commandMenuItemsContainerWidth <= 0) {
|
||||
return commandMenuItemKeysInDisplayOrder.length;
|
||||
}
|
||||
|
||||
let usedWidth = 0;
|
||||
let visibleCommandMenuItemCount = 0;
|
||||
|
||||
for (const commandMenuItemKey of commandMenuItemKeysInDisplayOrder) {
|
||||
const commandMenuItemWidth = commandMenuItemWidthsByKey[commandMenuItemKey];
|
||||
|
||||
if (typeof commandMenuItemWidth !== 'number') {
|
||||
return commandMenuItemKeysInDisplayOrder.length;
|
||||
}
|
||||
|
||||
const nextWidth =
|
||||
visibleCommandMenuItemCount === 0
|
||||
? commandMenuItemWidth
|
||||
: usedWidth + commandMenuItemsGapWidth + commandMenuItemWidth;
|
||||
|
||||
if (nextWidth > commandMenuItemsContainerWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
usedWidth = nextWidth;
|
||||
visibleCommandMenuItemCount += 1;
|
||||
}
|
||||
|
||||
return visibleCommandMenuItemCount;
|
||||
};
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { AnimatedIconCrossfade } from 'twenty-ui/utilities';
|
||||
import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
|
||||
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 { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useStore } from 'jotai';
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
import { IconPencil, IconX } from 'twenty-ui/display';
|
||||
import { AnimatedButton } from 'twenty-ui/input';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
export const CommandMenuItemEditButton = () => {
|
||||
const { t } = useLingui();
|
||||
const store = useStore();
|
||||
const { navigateSidePanel } = useNavigateSidePanel();
|
||||
const { closeSidePanelMenu } = useSidePanelMenu();
|
||||
|
||||
const isLayoutCustomizationModeEnabled = useAtomStateValue(
|
||||
isLayoutCustomizationModeEnabledState,
|
||||
);
|
||||
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
|
||||
const sidePanelPage = useAtomStateValue(sidePanelPageState);
|
||||
|
||||
const isCommandMenuItemEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
|
||||
);
|
||||
|
||||
const isCommandMenuEditPageActive =
|
||||
isSidePanelOpened && sidePanelPage === SidePanelPages.CommandMenuEdit;
|
||||
|
||||
if (!isLayoutCustomizationModeEnabled || !isCommandMenuItemEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (isCommandMenuEditPageActive) {
|
||||
closeSidePanelMenu();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
store.set(commandMenuItemEditSelectionModeState.atom, 'selection');
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
|
||||
import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
|
||||
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 { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
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`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
height: 24px;
|
||||
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%;
|
||||
`;
|
||||
|
||||
export const CommandMenuItemEditRecordSelectionDropdown = () => {
|
||||
const { t } = useLingui();
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const commandMenuItemEditSelectionMode = useAtomStateValue(
|
||||
commandMenuItemEditSelectionModeState,
|
||||
);
|
||||
const setCommandMenuItemEditSelectionMode = useSetAtomState(
|
||||
commandMenuItemEditSelectionModeState,
|
||||
);
|
||||
|
||||
const isNoneSelected = commandMenuItemEditSelectionMode === 'none';
|
||||
|
||||
const handleSelectMode = (mode: 'none' | 'selection') => {
|
||||
setCommandMenuItemEditSelectionMode(mode);
|
||||
closeDropdown(DROPDOWN_ID);
|
||||
};
|
||||
|
||||
const TriggerIcon = isNoneSelected ? IconSquareX : IconSquareCheck;
|
||||
const triggerLabel = isNoneSelected
|
||||
? t`No record selected`
|
||||
: t`Records selected`;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={DROPDOWN_ID}
|
||||
clickableComponent={
|
||||
<StyledClickableArea
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/server-items/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 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}
|
||||
onToggleChange={handleToggleHideLabel}
|
||||
toggleSize="small"
|
||||
/>
|
||||
<MenuItem
|
||||
LeftIcon={IconRefresh}
|
||||
onClick={handleResetLabelToDefault}
|
||||
accent="default"
|
||||
text={t`Reset label to default`}
|
||||
disabled={!hasShortLabelOverride}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton';
|
||||
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi';
|
||||
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
|
||||
import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements';
|
||||
import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap';
|
||||
import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout';
|
||||
import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
|
||||
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
|
||||
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
|
||||
import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig';
|
||||
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
|
||||
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 { interpolateCommandMenuItemLabel } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
CommandMenuItemAvailabilityType,
|
||||
type CommandMenuItemFieldsFragment,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledActionContainer = 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 commandMenuItemEditSelectionMode = useAtomStateValue(
|
||||
commandMenuItemEditSelectionModeState,
|
||||
);
|
||||
|
||||
const allowedAvailabilityTypes = useMemo(
|
||||
() =>
|
||||
new Set<CommandMenuItemAvailabilityType>([
|
||||
CommandMenuItemAvailabilityType.GLOBAL,
|
||||
commandMenuItemEditSelectionMode === 'selection'
|
||||
? CommandMenuItemAvailabilityType.RECORD_SELECTION
|
||||
: CommandMenuItemAvailabilityType.FALLBACK,
|
||||
]),
|
||||
[commandMenuItemEditSelectionMode],
|
||||
);
|
||||
|
||||
const interpolateLabel = (rawLabel: string | null | undefined) =>
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: rawLabel,
|
||||
context: commandMenuContextApi,
|
||||
});
|
||||
|
||||
const pinnedCommandMenuItems: CommandMenuItemConfig[] = useMemo(
|
||||
() =>
|
||||
commandMenuItemsDraft
|
||||
.filter(
|
||||
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
|
||||
)
|
||||
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType))
|
||||
.filter((item) => item.isPinned)
|
||||
.map((item) => {
|
||||
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
|
||||
const label = interpolateLabel(item.label) ?? item.label;
|
||||
const shortLabel = interpolateLabel(item.shortLabel);
|
||||
const key = `edit-preview-${item.id}`;
|
||||
|
||||
return {
|
||||
type: CommandMenuItemType.Standard,
|
||||
scope: CommandMenuItemScope.Global,
|
||||
key,
|
||||
label,
|
||||
shortLabel,
|
||||
position: item.position,
|
||||
Icon,
|
||||
isPinned: true,
|
||||
shouldBeRegistered: () => true,
|
||||
component: (
|
||||
<CommandMenuButton
|
||||
command={{ key, label, shortLabel, Icon }}
|
||||
disabled
|
||||
/>
|
||||
),
|
||||
};
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
commandMenuItemsDraft,
|
||||
currentObjectMetadataItemId,
|
||||
allowedAvailabilityTypes,
|
||||
commandMenuContextApi,
|
||||
getIcon,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
pinnedInlineCommandMenuItems,
|
||||
pinnedOverflowCommandMenuItems,
|
||||
onContainerDimensionChange,
|
||||
onCommandMenuItemDimensionChange,
|
||||
} = usePinnedCommandMenuItemsInlineLayout({
|
||||
pinnedCommandMenuItems,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PinnedCommandMenuItemsInlineMeasurements
|
||||
pinnedCommandMenuItems={[
|
||||
...pinnedInlineCommandMenuItems,
|
||||
...pinnedOverflowCommandMenuItems,
|
||||
]}
|
||||
onPinnedCommandMenuItemDimensionChange={
|
||||
onCommandMenuItemDimensionChange
|
||||
}
|
||||
/>
|
||||
<StyledWrapper>
|
||||
<NodeDimension onDimensionChange={onContainerDimensionChange}>
|
||||
<StyledContainer>
|
||||
<StyledItemsContainer>
|
||||
{pinnedInlineCommandMenuItems.map(
|
||||
(pinnedInlineCommandMenuItem) => (
|
||||
<StyledActionContainer
|
||||
key={pinnedInlineCommandMenuItem.key}
|
||||
layout
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'unset', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{
|
||||
duration: theme.animation.duration.instant,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{pinnedInlineCommandMenuItem.component}
|
||||
</StyledActionContainer>
|
||||
),
|
||||
)}
|
||||
</StyledItemsContainer>
|
||||
</StyledContainer>
|
||||
</NodeDimension>
|
||||
</StyledWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi';
|
||||
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
|
||||
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId';
|
||||
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
|
||||
import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditRecordSelectionDropdown';
|
||||
import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/server-items/edit/components/CommandMenuItemOptionsDropdown';
|
||||
import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/server-items/edit/hooks/useReorderCommandMenuItemsInDraft';
|
||||
import { useResetCommandMenuItemsDraft } from '@/command-menu-item/server-items/edit/hooks/useResetCommandMenuItemsDraft';
|
||||
import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
|
||||
import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft';
|
||||
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 {
|
||||
interpolateCommandMenuItemLabel,
|
||||
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 partitionByPinned = (items: CommandMenuItemFieldsFragment[]) => {
|
||||
const pinned = items
|
||||
.filter((item) => item.isPinned)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
const other = items
|
||||
.filter((item) => !item.isPinned)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
return { pinned, other };
|
||||
};
|
||||
|
||||
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 commandMenuItemEditSelectionMode = useAtomStateValue(
|
||||
commandMenuItemEditSelectionModeState,
|
||||
);
|
||||
|
||||
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,
|
||||
commandMenuItemEditSelectionMode === 'selection'
|
||||
? CommandMenuItemAvailabilityType.RECORD_SELECTION
|
||||
: CommandMenuItemAvailabilityType.FALLBACK,
|
||||
]);
|
||||
|
||||
const filteredCommandMenuItems = commandMenuItemsDraft
|
||||
.filter(
|
||||
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
|
||||
)
|
||||
.filter((item) => allowedAvailabilityTypes.has(item.availabilityType));
|
||||
|
||||
const filteredCommandMenuItemIds = new Set(
|
||||
filteredCommandMenuItems.map((item) => item.id),
|
||||
);
|
||||
|
||||
const getDisplayLabel = (item: CommandMenuItemFieldsFragment) =>
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: item.label,
|
||||
context: commandMenuContextApi,
|
||||
}) ?? item.label;
|
||||
|
||||
const { pinned: allPinnedItems, other: allOtherItems } = partitionByPinned(
|
||||
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 />
|
||||
</StyledViewbar>
|
||||
<StyledContent>
|
||||
<SidePanelList commandGroups={[]} 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/server-items/edit/states/commandMenuItemsDraftState';
|
||||
import { computeInsertPositionFromBounds } from '@/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds';
|
||||
import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/server-items/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/server-items/edit/states/commandMenuItemsDraftState';
|
||||
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/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/server-items/edit/states/commandMenuItemsDraftState';
|
||||
import { UPDATE_COMMAND_MENU_ITEM } from '@/command-menu-item/server-items/common/graphql/mutations/updateCommandMenuItem';
|
||||
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/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 };
|
||||
};
|
||||
+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/server-items/edit/states/commandMenuItemsDraftState';
|
||||
import { type CommandMenuItemEditableFields } from '@/command-menu-item/server-items/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 };
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export type CommandMenuItemEditSelectionMode = 'none' | 'selection';
|
||||
|
||||
export const commandMenuItemEditSelectionModeState =
|
||||
createAtomState<CommandMenuItemEditSelectionMode>({
|
||||
key: 'commandMenuItemEditSelectionModeState',
|
||||
defaultValue: 'selection',
|
||||
});
|
||||
+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/server-items/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/server-items/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,
|
||||
};
|
||||
};
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { useConvertBackendItemToCommandMenuItemConfig } from '@/command-menu-item/server-items/hooks/useConvertBackendItemToCommandMenuItemConfig';
|
||||
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { type CommandMenuContextApi } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
type CommandMenuItemFieldsFragment,
|
||||
FindManyCommandMenuItemsDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const useCommandMenuItemsFromBackend = (
|
||||
commandMenuContextApi: CommandMenuContextApi,
|
||||
) => {
|
||||
const { convertBackendItemToCommandMenuItemConfig } =
|
||||
useConvertBackendItemToCommandMenuItemConfig();
|
||||
|
||||
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
|
||||
contextStoreCurrentObjectMetadataItemIdComponentState,
|
||||
);
|
||||
|
||||
const { data } = useQuery(FindManyCommandMenuItemsDocument);
|
||||
|
||||
const allItems = data?.commandMenuItems ?? [];
|
||||
|
||||
const objectMatches = (item: CommandMenuItemFieldsFragment) =>
|
||||
!isDefined(item.availabilityObjectMetadataId) ||
|
||||
item.availabilityObjectMetadataId ===
|
||||
contextStoreCurrentObjectMetadataItemId;
|
||||
|
||||
return allItems
|
||||
.filter(objectMatches)
|
||||
.map((item) =>
|
||||
convertBackendItemToCommandMenuItemConfig(item, commandMenuContextApi),
|
||||
)
|
||||
.filter(isDefined)
|
||||
.filter((item) => item.shouldBeRegistered())
|
||||
.sort((a, b) => a.position - b.position);
|
||||
};
|
||||
Reference in New Issue
Block a user