nitin
2026-03-26 20:34:03 +05:30
committed by GitHub
parent c7f6036a47
commit afeef8e04a
74 changed files with 2518 additions and 239 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 48.5,
statements: 48.4,
lines: 47.0,
functions: 39.5,
},
@@ -1,9 +1,16 @@
import { PageHeaderCommandMenuButtons } from '@/command-menu-item/components/PageHeaderCommandMenuButtons';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons';
import { RecordIndexCommandMenuDropdown } from '@/command-menu-item/components/RecordIndexCommandMenuDropdown';
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { CommandMenuItemEditButton } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditButton';
import { PinnedCommandMenuItemButtonsEditMode } from '@/command-menu-item/server-items/edit/components/PinnedCommandMenuItemButtonsEditMode';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordIndexCommandMenu = () => {
@@ -13,18 +20,36 @@ export const RecordIndexCommandMenu = () => {
);
const isMobile = useIsMobile();
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const showEditModePinnedButtons =
isCommandMenuItemEnabled && isLayoutCustomizationModeEnabled;
return (
<>
{contextStoreCurrentObjectMetadataItemId && (
<>
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
>
{!isMobile && <PageHeaderCommandMenuButtons />}
</CommandMenuContextProvider>
{!isMobile && showEditModePinnedButtons ? (
<PinnedCommandMenuItemButtonsEditMode />
) : (
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="index-page-header"
>
{!isMobile &&
(isCommandMenuItemEnabled ? (
<PinnedCommandMenuItemButtons />
) : (
<PageHeaderCommandMenuButtons />
))}
</CommandMenuContextProvider>
)}
<CommandMenuContextProvider
isInSidePanel={false}
displayType="dropdownItem"
@@ -32,6 +57,7 @@ export const RecordIndexCommandMenu = () => {
>
<RecordIndexCommandMenuDropdown />
</CommandMenuContextProvider>
<CommandMenuItemEditButton />
</>
)}
</>
@@ -1,9 +1,13 @@
import { PageHeaderCommandMenuButtons } from '@/command-menu-item/components/PageHeaderCommandMenuButtons';
import { PinnedCommandMenuItemButtons } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons';
import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider';
import { CommandMenuItemEditButton } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditButton';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useIsMobile } from 'twenty-ui/utilities';
export const RecordShowCommandMenu = () => {
@@ -22,17 +26,28 @@ export const RecordShowCommandMenu = () => {
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1;
const isMobile = useIsMobile();
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
return (
<>
{hasSelectedRecord && contextStoreCurrentObjectMetadataItemId && (
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="show-page-header"
>
{!isMobile && <PageHeaderCommandMenuButtons />}
</CommandMenuContextProvider>
<>
<CommandMenuContextProvider
isInSidePanel={false}
displayType="button"
containerType="show-page-header"
>
{!isMobile &&
(isCommandMenuItemEnabled ? (
<PinnedCommandMenuItemButtons />
) : (
<PageHeaderCommandMenuButtons />
))}
</CommandMenuContextProvider>
<CommandMenuItemEditButton />
</>
)}
</>
);
@@ -1,6 +1,6 @@
import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext';
import { CommandMenuContextProviderLegacy } from '@/command-menu-item/contexts/CommandMenuContextProviderLegacy';
import { CommandMenuContextProviderServerItems } from '@/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItems';
import { CommandMenuContextProviderServerItems } from '@/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItems';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
@@ -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';
@@ -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 = {
@@ -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';
@@ -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
}
}
`;
@@ -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);
@@ -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,
};
};
@@ -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);
};
@@ -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[];
},
});
@@ -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;
@@ -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>
);
};
@@ -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>
</>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -0,0 +1 @@
export const PINNED_COMMAND_MENU_ITEMS_GAP = 8;
@@ -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,
};
};
@@ -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: {},
},
});
@@ -0,0 +1,4 @@
export type CommandMenuPinnedInlineLayout = {
containerWidth: number;
commandMenuItemWidthsByKey: Record<string, number>;
};
@@ -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);
});
});
@@ -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;
};
@@ -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}
/>
);
};
@@ -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>
}
/>
);
};
@@ -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>
}
/>
);
};
@@ -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>
</>
);
};
@@ -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>
);
};
@@ -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 };
};
@@ -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 };
};
@@ -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 };
};
@@ -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 };
};
@@ -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',
});
@@ -0,0 +1,10 @@
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const commandMenuItemsDraftState = createAtomState<
CommandMenuItemFieldsFragment[] | null
>({
key: 'commandMenuItemsDraftState',
defaultValue: null,
});
@@ -0,0 +1,6 @@
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export type CommandMenuItemEditableFields = Pick<
CommandMenuItemFieldsFragment,
'isPinned' | 'position' | 'shortLabel'
>;
@@ -0,0 +1,39 @@
import { computeInsertPositionFromBounds } from '@/command-menu-item/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);
});
});
@@ -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();
});
});
});
@@ -0,0 +1,24 @@
import { isDefined } from 'twenty-shared/utils';
export const computeInsertPositionFromBounds = (
previousPosition: number | undefined,
nextPosition: number | undefined,
): number => {
if (!isDefined(previousPosition) && isDefined(nextPosition)) {
return nextPosition - 1;
}
if (isDefined(previousPosition) && !isDefined(nextPosition)) {
return previousPosition + 1;
}
if (isDefined(previousPosition) && isDefined(nextPosition)) {
if (previousPosition === nextPosition) {
return previousPosition - 1;
}
return (previousPosition + nextPosition) / 2;
}
return 0;
};
@@ -0,0 +1,23 @@
type PositionedItem = { id: string; position: number };
export const getPositionBoundsAtInsertionPoint = (
anchorItemId: string,
insertionSide: 'before' | 'after',
sectionItems: PositionedItem[],
) => {
const anchorIndex = sectionItems.findIndex(
(item) => item.id === anchorItemId,
);
if (anchorIndex === -1) {
return undefined;
}
const insertIndex =
insertionSide === 'before' ? anchorIndex : anchorIndex + 1;
return {
previousPosition: sectionItems[insertIndex - 1]?.position,
nextPosition: sectionItems[insertIndex]?.position,
};
};
@@ -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);
};
@@ -14,6 +14,7 @@ export type CommandMenuItemConfig = {
type: CommandMenuItemType;
scope: CommandMenuItemScope;
key: string;
id?: string;
label: Nullable<MessageDescriptor | string>;
shortLabel?: Nullable<MessageDescriptor | string>;
description?: MessageDescriptor | string;
@@ -95,7 +95,7 @@ const meta: Meta<typeof SidePanelRootPage> = {
});
jotaiStore.set(sidePanelNavigationStackState.atom, [
{
page: SidePanelPages.Root,
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command Menu',
pageIcon: IconDotsVertical,
pageId: '1',
@@ -83,14 +83,16 @@ describe('useSidePanelMenu', () => {
act(() => {
result.current.commandMenu.navigateSidePanelMenu({
page: SidePanelPages.Root,
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'First Page',
pageIcon: IconDotsVertical,
resetNavigationStack: false,
});
});
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root);
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(
SidePanelPages.CommandMenuDisplay,
);
expect(jotaiStore.get(sidePanelPageInfoState.atom).title).toBe(
'First Page',
);
@@ -109,14 +111,16 @@ describe('useSidePanelMenu', () => {
act(() => {
result.current.commandMenu.navigateSidePanelMenu({
page: SidePanelPages.Root,
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Reset Page',
pageIcon: IconDotsVertical,
resetNavigationStack: true,
});
});
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root);
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(
SidePanelPages.CommandMenuDisplay,
);
expect(jotaiStore.get(sidePanelPageInfoState.atom).title).toBe(
'Reset Page',
);
@@ -100,7 +100,7 @@ export const useCommandMenuHotKeys = () => {
}
if (
sidePanelPage === SidePanelPages.Root &&
sidePanelPage === SidePanelPages.CommandMenuDisplay &&
!(
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0
@@ -108,7 +108,7 @@ export const useCommandMenuHotKeys = () => {
) {
setGlobalCommandMenuContext();
}
if (sidePanelPage !== SidePanelPages.Root) {
if (sidePanelPage !== SidePanelPages.CommandMenuDisplay) {
goBackOneSubPageOrMainPage();
}
},
@@ -1,13 +1,30 @@
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { IconPencil } from 'twenty-ui/display';
import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState';
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useEnterLayoutCustomizationMode = () => {
const store = useStore();
const { navigateSidePanel } = useNavigateSidePanel();
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
const enterLayoutCustomizationMode = useCallback(() => {
const isLayoutCustomizationModeAlreadyEnabled = store.get(
@@ -26,10 +43,31 @@ export const useEnterLayoutCustomizationMode = () => {
);
store.set(navigationMenuItemsDraftState.atom, workspaceNavigationMenuItems);
const persistedCommandMenuItems = store.get(commandMenuItemsSelector.atom);
store.set(commandMenuItemsDraftState.atom, persistedCommandMenuItems);
store.set(activeCustomizationPageLayoutIdsState.atom, []);
store.set(isLayoutCustomizationModeEnabledState.atom, true);
}, [store]);
const isSidePanelOpened = store.get(isSidePanelOpenedState.atom);
const currentSidePanelPage = store.get(sidePanelPageState.atom);
if (
isCommandMenuItemEnabled &&
isSidePanelOpened &&
currentSidePanelPage === SidePanelPages.CommandMenuDisplay
) {
store.set(commandMenuItemEditSelectionModeState.atom, 'selection');
navigateSidePanel({
page: SidePanelPages.CommandMenuEdit,
pageTitle: t`Edit actions`,
pageIcon: IconPencil,
resetNavigationStack: true,
});
}
}, [isCommandMenuItemEnabled, navigateSidePanel, store]);
return { enterLayoutCustomizationMode };
};
@@ -1,3 +1,4 @@
import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
@@ -25,6 +26,7 @@ export const useExitLayoutCustomizationMode = () => {
const exitLayoutCustomizationMode = useCallback(() => {
setNavigationMenuItemsDraft(null);
setSelectedNavigationMenuItemIdInEditMode(null);
store.set(commandMenuItemsDraftState.atom, null);
store.set(currentPageLayoutIdState.atom, null);
store.set(activeCustomizationPageLayoutIdsState.atom, []);
@@ -1,3 +1,4 @@
import { useCommandMenuItemsDraftState } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
@@ -14,6 +15,7 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useIsLayoutCustomizationDirty = () => {
const { isDirty: isNavigationDirty } = useNavigationMenuItemsDraftState();
const { isDirty: isCommandMenuItemsDirty } = useCommandMenuItemsDraftState();
const isAnyPageLayoutDirtyAtom = useMemo(
() =>
@@ -93,5 +95,8 @@ export const useIsLayoutCustomizationDirty = () => {
const isAnyPageLayoutDirty = useAtomValue(isAnyPageLayoutDirtyAtom);
return { isDirty: isNavigationDirty || isAnyPageLayoutDirty };
return {
isDirty:
isNavigationDirty || isAnyPageLayoutDirty || isCommandMenuItemsDirty,
};
};
@@ -1,3 +1,5 @@
import { useSaveCommandMenuItemsDraft } from '@/command-menu-item/server-items/edit/hooks/useSaveCommandMenuItemsDraft';
import { useCommandMenuItemsDraftState } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState';
import { useExitLayoutCustomizationMode } from '@/layout-customization/hooks/useExitLayoutCustomizationMode';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
@@ -31,6 +33,8 @@ export const useSaveLayoutCustomization = () => {
const { t } = useLingui();
const { saveDraft } = useSaveNavigationMenuItemsDraft();
const { saveCommandMenuItemsDraft } = useSaveCommandMenuItemsDraft();
const { isDirty: isCommandMenuItemsDirty } = useCommandMenuItemsDraftState();
const { enqueueErrorSnackBar } = useSnackBar();
const { updatePageLayoutWithTabsAndWidgets } =
useUpdatePageLayoutWithTabsAndWidgets();
@@ -59,6 +63,10 @@ export const useSaveLayoutCustomization = () => {
await saveDraft();
}
if (isCommandMenuItemsDirty) {
await saveCommandMenuItemsDraft();
}
const activePageLayoutIds = store.get(
activeCustomizationPageLayoutIdsState.atom,
);
@@ -164,6 +172,8 @@ export const useSaveLayoutCustomization = () => {
}
}, [
saveDraft,
saveCommandMenuItemsDraft,
isCommandMenuItemsDirty,
updatePageLayoutWithTabsAndWidgets,
savePageLayoutWidgetsData,
exitLayoutCustomizationMode,
@@ -12,6 +12,7 @@ import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
FindAllViewsDocument,
FindManyCommandMenuItemsDocument,
FindAllRecordPageLayoutsDocument,
FindFieldsWidgetViewsDocument,
FindManyLogicFunctionsDocument,
@@ -193,6 +194,23 @@ export const useLoadStaleMetadataEntities = () => {
);
}
if (staleEntityKeys.includes('commandMenuItems')) {
fetchPromises.push(
client
.query({
query: FindManyCommandMenuItemsDocument,
fetchPolicy: 'network-only',
})
.then((result) => {
if (!isDefined(result.data?.commandMenuItems)) {
return;
}
replaceDraft('commandMenuItems', result.data.commandMenuItems);
}),
);
}
await Promise.all(fetchPromises);
applyChanges();
},
@@ -1,3 +1,3 @@
import { type CommandMenuItem } from '~/generated-metadata/graphql';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export type FlatCommandMenuItem = Omit<CommandMenuItem, 'frontComponent'>;
export type FlatCommandMenuItem = CommandMenuItemFieldsFragment;
@@ -1,17 +1,22 @@
import { RecordIndexCommandMenu } from '@/command-menu-item/components/RecordIndexCommandMenu';
import { CommandMenuItemMoreActionsButton } from '@/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { RecordIndexPageHeaderIcon } from '@/object-record/record-index/components/RecordIndexPageHeaderIcon';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { PageHeaderToggleSidePanelButton } from '@/ui/layout/page-header/components/PageHeaderToggleSidePanelButton';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const StyledTitleWithSelectedRecords = styled.div`
display: flex;
@@ -61,6 +66,12 @@ export const RecordIndexPageHeader = () => {
contextStoreCurrentViewIdComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
return (
<PageHeader
@@ -72,7 +83,13 @@ export const RecordIndexPageHeader = () => {
{isDefined(contextStoreCurrentViewId) && (
<>
<RecordIndexCommandMenu />
<PageHeaderToggleSidePanelButton />
{isCommandMenuItemEnabled ? (
!isLayoutCustomizationModeEnabled && (
<CommandMenuItemMoreActionsButton />
)
) : (
<PageHeaderToggleSidePanelButton />
)}
</>
)}
</PageHeader>
@@ -76,7 +76,7 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => {
return;
}
setSidePanelPage(SidePanelPages.Root);
setSidePanelPage(SidePanelPages.CommandMenuDisplay);
closeSidePanelMenu();
},
[
@@ -164,11 +164,13 @@ export const SidePanelTopBar = () => {
)}
</AnimatePresence>
{lastChip &&
sidePanelPage !== SidePanelPages.Root &&
sidePanelPage !== SidePanelPages.CommandMenuDisplay &&
sidePanelPage !== SidePanelPages.CommandMenuEdit &&
sidePanelPage !== SidePanelPages.SearchRecords && (
<SidePanelPageInfo pageChip={lastChip} />
)}
{(sidePanelPage === SidePanelPages.Root ||
{(sidePanelPage === SidePanelPages.CommandMenuDisplay ||
sidePanelPage === SidePanelPages.CommandMenuEdit ||
sidePanelPage === SidePanelPages.SearchRecords) && (
<>
<StyledInput
@@ -14,7 +14,7 @@ export const SidePanelTopBarInputFocusEffect = ({
useEffect(() => {
if (
sidePanelPage === SidePanelPages.Root ||
sidePanelPage === SidePanelPages.CommandMenuDisplay ||
sidePanelPage === SidePanelPages.SearchRecords
) {
inputRef.current?.focus();
@@ -1,3 +1,5 @@
import { SidePanelCommandMenuItemEditPage } from '@/command-menu-item/server-items/edit/components/SidePanelCommandMenuItemEditPage';
import { SidePanelCommandMenuItemDisplayPage } from '@/command-menu-item/server-items/display/components/SidePanelCommandMenuItemDisplayPage';
import { SidePanelAIChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/components/SidePanelAIChatThreadsPage';
import { SidePanelAskAIPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAIPage';
import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage';
@@ -23,11 +25,28 @@ import { SidePanelWorkflowEditStepType } from '@/side-panel/pages/workflow/step/
import { SidePanelWorkflowRunViewStep } from '@/side-panel/pages/workflow/step/view-run/components/SidePanelWorkflowRunViewStep';
import { SidePanelWorkflowViewStep } from '@/side-panel/pages/workflow/step/view/components/SidePanelWorkflowViewStep';
import { SidePanelWorkflowSelectTriggerType } from '@/side-panel/pages/workflow/trigger-type/components/SidePanelWorkflowSelectTriggerType';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { SidePanelPages } from 'twenty-shared/types';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const SidePanelCommandMenuDisplayPageSwitch = () => {
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
if (isCommandMenuItemEnabled) {
return <SidePanelCommandMenuItemDisplayPage />;
}
return <SidePanelRootPage />;
};
export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
[
[SidePanelPages.Root, <SidePanelRootPage />],
[
SidePanelPages.CommandMenuDisplay,
<SidePanelCommandMenuDisplayPageSwitch />,
],
[SidePanelPages.ViewRecord, <SidePanelRecordPage />],
[SidePanelPages.MergeRecords, <SidePanelMergeRecordPage />],
[SidePanelPages.UpdateRecords, <SidePanelUpdateMultipleRecords />],
@@ -73,5 +92,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
<SidePanelNavigationMenuItemEditPage />,
],
[SidePanelPages.NavigationMenuAddItem, <SidePanelNewSidebarItemPage />],
[SidePanelPages.CommandMenuEdit, <SidePanelCommandMenuItemEditPage />],
],
);
@@ -65,8 +65,8 @@ describe('useNavigateSidePanel', () => {
act(() => {
result.current.navigateSidePanel({
page: SidePanelPages.Root,
pageTitle: 'Root',
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command menu',
pageIcon: Icon123,
pageIconColor: 'red',
pageId: 'mocked-uuid',
@@ -74,18 +74,20 @@ describe('useNavigateSidePanel', () => {
});
});
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root);
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(
SidePanelPages.CommandMenuDisplay,
);
expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toEqual([
{
page: SidePanelPages.Root,
pageTitle: 'Root',
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Command menu',
pageIcon: Icon123,
pageIconColor: 'red',
pageId: 'mocked-uuid',
},
]);
expect(jotaiStore.get(sidePanelPageInfoState.atom)).toEqual({
title: 'Root',
title: 'Command menu',
Icon: Icon123,
instanceId: 'mocked-uuid',
});
@@ -136,7 +136,9 @@ describe('useSidePanelCloseAnimationCompleteCleanup', () => {
result.current.sidePanelCloseAnimationCompleteCleanup();
});
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root);
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(
SidePanelPages.CommandMenuDisplay,
);
expect(jotaiStore.get(sidePanelPageInfoState.atom)).toEqual({
title: undefined,
Icon: undefined,
@@ -104,7 +104,9 @@ describe('useSidePanelHistory', () => {
});
expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toEqual([]);
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root);
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(
SidePanelPages.CommandMenuDisplay,
);
expect(jotaiStore.get(sidePanelPageInfoState.atom)).toEqual({
title: undefined,
instanceId: '',
@@ -16,7 +16,7 @@ const mockedPageInfo = {
const mockedNavigationStack = [
{
page: SidePanelPages.Root,
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Initial Title',
pageIcon: IconDotsVertical,
pageId: 'test-page-id',
@@ -65,7 +65,7 @@ describe('useUpdateSidePanelPageInfo', () => {
);
expect(sidePanelNavigationStack).toEqual([
{
page: SidePanelPages.Root,
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'New Title',
pageIcon: IconArrowDown,
pageId: 'test-page-id',
@@ -94,7 +94,7 @@ describe('useUpdateSidePanelPageInfo', () => {
);
expect(sidePanelNavigationStack).toEqual([
{
page: SidePanelPages.Root,
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'New Title',
pageIcon: IconDotsVertical,
pageId: 'test-page-id',
@@ -123,7 +123,7 @@ describe('useUpdateSidePanelPageInfo', () => {
);
expect(sidePanelNavigationStack).toEqual([
{
page: SidePanelPages.Root,
page: SidePanelPages.CommandMenuDisplay,
pageTitle: 'Initial Title',
pageIcon: IconArrowDown,
pageId: 'test-page-id',
@@ -98,7 +98,7 @@ export const useSidePanelCloseAnimationCompleteCleanup = () => {
}
store.set(viewableRecordIdState.atom, null);
store.set(sidePanelPageState.atom, SidePanelPages.Root);
store.set(sidePanelPageState.atom, SidePanelPages.CommandMenuDisplay);
store.set(sidePanelPageInfoState.atom, {
title: undefined,
Icon: undefined,
@@ -52,7 +52,7 @@ export const useSidePanelContextChips = () => {
const contextChips = useMemo(() => {
const filteredSidePanelNavigationStack = sidePanelNavigationStack.filter(
(page) => page.page !== SidePanelPages.Root,
(page) => page.page !== SidePanelPages.CommandMenuDisplay,
);
return filteredSidePanelNavigationStack
@@ -67,7 +67,7 @@ export const useSidePanelMenu = () => {
});
} else {
navigateSidePanel({
page: SidePanelPages.Root,
page: SidePanelPages.CommandMenuDisplay,
pageTitle: t`Command Menu`,
pageIcon: IconDotsVertical,
resetNavigationStack: true,
@@ -3,5 +3,5 @@ import { SidePanelPages } from 'twenty-shared/types';
export const sidePanelPageState = createAtomState<SidePanelPages>({
key: 'side-panel/sidePanelPageState',
defaultValue: SidePanelPages.Root,
defaultValue: SidePanelPages.CommandMenuDisplay,
});
@@ -6,16 +6,16 @@ import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawe
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AnimatePresence } from 'framer-motion';
import { isDefined } from 'twenty-shared/utils';
import {
type IconComponent,
IconX,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { isDefined } from 'twenty-shared/utils';
import {
MOBILE_VIEWPORT,
ThemeContext,
@@ -34,7 +34,7 @@ const StyledTopBarContainer = styled.div<{ isMobile: boolean }>`
min-height: ${PAGE_BAR_MIN_HEIGHT}px;
padding-bottom: ${themeCssVariables.spacing[3]};
padding-left: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[3] : 0};
isMobile ? themeCssVariables.spacing[3] : themeCssVariables.spacing[4]};
padding-right: ${themeCssVariables.spacing[3]};
padding-top: ${themeCssVariables.spacing[3]};
`;
@@ -42,10 +42,11 @@ const StyledTopBarContainer = styled.div<{ isMobile: boolean }>`
const StyledLeftContainer = styled.div`
align-items: center;
display: flex;
flex: 0 1 auto;
flex-direction: row;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
overflow-x: hidden;
width: 100%;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: ${themeCssVariables.spacing[1]};
}
@@ -71,9 +72,13 @@ const StyledTopBarIconStyledTitleContainer = styled.div`
`;
const StyledPageActionContainer = styled.div`
display: inline-flex;
flex: 1 0 auto;
align-items: center;
display: flex;
flex: 1 1 0;
gap: ${themeCssVariables.spacing[2]};
justify-content: flex-end;
min-width: 0;
`;
const StyledIconContainer = styled.div`
@@ -1,10 +1,12 @@
import { useParams } from 'react-router-dom';
import { CommandMenuItemMoreActionsButton } from '@/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton';
import { RecordShowCommandMenu } from '@/command-menu-item/components/RecordShowCommandMenu';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { PageLayoutRecordPageRenderer } from '@/object-record/record-show/components/PageLayoutRecordPageRenderer';
@@ -13,10 +15,20 @@ import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordSh
import { computeRecordShowComponentInstanceId } from '@/object-record/record-show/utils/computeRecordShowComponentInstanceId';
import { PageHeaderToggleSidePanelButton } from '@/ui/layout/page-header/components/PageHeaderToggleSidePanelButton';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { RecordShowPageHeader } from '~/pages/object-record/RecordShowPageHeader';
import { RecordShowPageTitle } from '~/pages/object-record/RecordShowPageTitle';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const RecordShowPage = () => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const isCommandMenuItemEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
);
const parameters = useParams<{
objectNameSingular: string;
objectRecordId: string;
@@ -50,7 +62,13 @@ export const RecordShowPage = () => {
objectRecordId={objectRecordId}
>
<RecordShowCommandMenu />
<PageHeaderToggleSidePanelButton />
{isCommandMenuItemEnabled ? (
!isLayoutCustomizationModeEnabled && (
<CommandMenuItemMoreActionsButton />
)
) : (
<PageHeaderToggleSidePanelButton />
)}
</RecordShowPageHeader>
<MainContainerLayoutWithSidePanel>
<TimelineActivityContext.Provider
@@ -7,10 +7,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
navigateToNextRecord: {
universalIdentifier: '3db2457d-8e96-4b8e-94c9-ed95d3f95738',
label: 'Navigate to next ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: null,
icon: 'IconChevronDown',
position: 0,
isPinned: true,
position: 0,
shortLabel: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and not isInSidePanel',
@@ -23,10 +23,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
universalIdentifier: 'ec10f871-415b-420b-8150-7e09f6f04833',
label:
'Navigate to previous ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: null,
icon: 'IconChevronUp',
position: 1,
isPinned: true,
position: 1,
shortLabel: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and not isInSidePanel',
@@ -38,10 +38,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
createNewRecord: {
universalIdentifier: '08d255bf-58cd-47a5-bd82-78c5c58592f1',
label: 'Create new ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: 'New ${capitalize(objectMetadataItem.labelSingular)}',
icon: 'IconPlus',
position: 2,
isPinned: true,
position: 2,
shortLabel: 'New ${capitalize(objectMetadataItem.labelSingular)}',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression:
'pageType == "INDEX_PAGE" and objectPermissions.canUpdateObjectRecords and not hasAnySoftDeleteFilterOnView',
@@ -53,10 +53,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
deleteSingleRecord: {
universalIdentifier: '6652773f-b9a9-4fa3-a52c-e2f2e259e430',
label: 'Delete',
shortLabel: 'Delete',
icon: 'IconTrash',
position: 3,
isPinned: false,
position: 3,
shortLabel: 'Delete',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords == 1 and not hasAnySoftDeleteFilterOnView and objectPermissions.canSoftDeleteObjectRecords and noneDefined(selectedRecords, "deletedAt")',
@@ -68,10 +68,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
deleteMultipleRecords: {
universalIdentifier: 'cde86f1f-2c13-42b1-812b-f2b2b468cb83',
label: 'Delete ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Delete',
icon: 'IconTrash',
position: 4,
isPinned: false,
position: 4,
shortLabel: 'Delete',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords >= 2 and objectPermissions.canSoftDeleteObjectRecords and not hasAnySoftDeleteFilterOnView and numberOfSelectedRecords < 10000',
@@ -83,10 +83,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
restoreSingleRecord: {
universalIdentifier: '8b3a1cae-3e4d-43c1-a71f-48592b2e47ff',
label: 'Restore ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: 'Restore',
icon: 'IconRefresh',
position: 5,
isPinned: true,
position: 5,
shortLabel: 'Restore',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords == 1 and everyDefined(selectedRecords, "deletedAt") and objectPermissions.canSoftDeleteObjectRecords and (pageType == "RECORD_PAGE" or hasAnySoftDeleteFilterOnView)',
@@ -98,10 +98,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
restoreMultipleRecords: {
universalIdentifier: '8b740c9d-d99a-45a8-812f-809caaf420ac',
label: 'Restore ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Restore',
icon: 'IconRefresh',
position: 6,
isPinned: true,
position: 6,
shortLabel: 'Restore',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords >= 2 and objectPermissions.canSoftDeleteObjectRecords and hasAnySoftDeleteFilterOnView and numberOfSelectedRecords < 10000',
@@ -114,10 +114,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
universalIdentifier: '44a78417-c394-4bc8-961f-98b503030ddb',
label:
'Permanently destroy ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: 'Destroy',
icon: 'IconTrashX',
position: 7,
isPinned: false,
position: 7,
shortLabel: 'Destroy',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords == 1 and objectPermissions.canDestroyObjectRecords and everyDefined(selectedRecords, "deletedAt")',
@@ -129,10 +129,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
destroyMultipleRecords: {
universalIdentifier: 'c630b3fb-7920-40d1-9906-77d0aa797608',
label: 'Permanently destroy ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Destroy',
icon: 'IconTrashX',
position: 8,
isPinned: false,
position: 8,
shortLabel: 'Destroy',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords >= 2 and objectPermissions.canDestroyObjectRecords and hasAnySoftDeleteFilterOnView and numberOfSelectedRecords < 10000',
@@ -144,10 +144,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
addToFavorites: {
universalIdentifier: '38bf80c3-bd55-4753-80ba-38aa66429a03',
label: 'Add to Favorites',
shortLabel: null,
icon: 'IconHeart',
position: 9,
isPinned: true,
position: 9,
shortLabel: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'arrayLength(favoriteRecordIds) < numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView',
@@ -159,10 +159,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
removeFromFavorites: {
universalIdentifier: '3ea42507-44fa-4895-a36d-cbfef7355a50',
label: 'Remove from Favorites',
shortLabel: null,
icon: 'IconHeartOff',
position: 10,
isPinned: true,
position: 10,
shortLabel: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'arrayLength(favoriteRecordIds) == numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView',
@@ -174,10 +174,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
exportNoteToPdf: {
universalIdentifier: '86c8f3aa-9276-4c16-8cff-e295e34fbaf0',
label: 'Export to PDF',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 11,
isPinned: false,
position: 11,
shortLabel: 'Export',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and (objectMetadataItem.nameSingular == "note" or objectMetadataItem.nameSingular == "task") and someNonEmptyString(selectedRecords, "bodyV2.blocknote")',
@@ -189,10 +189,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
exportFromRecordIndex: {
universalIdentifier: 'a934ba8a-ac8f-487d-9cd9-06dfdaec1f49',
label: 'Export',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 12,
isPinned: false,
position: 12,
shortLabel: 'Export',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression: 'pageType == "INDEX_PAGE"',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -203,10 +203,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
exportFromRecordShow: {
universalIdentifier: 'ba339455-f3c2-4ed1-bf77-3e316d7d6a66',
label: 'Export',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 13,
isPinned: false,
position: 13,
shortLabel: 'Export',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression: 'pageType == "RECORD_PAGE"',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -217,10 +217,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
updateMultipleRecords: {
universalIdentifier: '2e080651-f098-4a78-bea9-7a70002dc57c',
label: 'Update ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Update',
icon: 'IconEdit',
position: 14,
isPinned: true,
position: 14,
shortLabel: 'Update',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords >= 2 and objectPermissions.canUpdateObjectRecords',
@@ -232,10 +232,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
mergeMultipleRecords: {
universalIdentifier: '6c14eb04-8e7e-4d47-93c0-8ec4834e2e60',
label: 'Merge ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Merge',
icon: 'IconArrowMerge',
position: 15,
isPinned: false,
position: 15,
shortLabel: 'Merge',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'numberOfSelectedRecords >= 2 and isDefined(objectMetadataItem.duplicateCriteria) and objectPermissions.canUpdateObjectRecords and objectPermissions.canDestroyObjectRecords and numberOfSelectedRecords <= 9',
@@ -247,10 +247,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
exportMultipleRecords: {
universalIdentifier: 'f71f68e5-7b6e-4c03-8161-c48434d7777c',
label: 'Export ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 16,
isPinned: false,
position: 16,
shortLabel: 'Export',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression: 'numberOfSelectedRecords >= 2',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -261,10 +261,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
importRecords: {
universalIdentifier: 'a2dc9de7-4798-422e-bb55-bfad7b9bdbe8',
label: 'Import ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Import',
icon: 'IconFileImport',
position: 17,
isPinned: false,
position: 17,
shortLabel: 'Import',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -275,10 +275,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
exportView: {
universalIdentifier: '80680f2a-c426-48b3-a839-c63a6183dc4b',
label: 'Export View',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 18,
isPinned: false,
position: 18,
shortLabel: 'Export',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
@@ -289,10 +289,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeDeletedRecords: {
universalIdentifier: 'd63c21c3-9785-4750-be87-5f36269b8e0d',
label: 'See deleted ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Deleted ${capitalize(objectMetadataItem.labelPlural)}',
icon: 'IconRotate2',
position: 19,
isPinned: false,
position: 19,
shortLabel: 'Deleted ${capitalize(objectMetadataItem.labelPlural)}',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -303,10 +303,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
createNewView: {
universalIdentifier: '6ec7c339-e167-431d-bec6-d1c737df677c',
label: 'Create View',
shortLabel: 'Create View',
icon: 'IconLayout',
position: 20,
isPinned: false,
position: 20,
shortLabel: 'Create View',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -317,10 +317,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
hideDeletedRecords: {
universalIdentifier: '1420db7f-0fba-49e2-b23e-4b7caa0fafa0',
label: 'Hide deleted ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Hide deleted',
icon: 'IconEyeOff',
position: 21,
isPinned: false,
position: 21,
shortLabel: 'Hide deleted',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'hasAnySoftDeleteFilterOnView',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -331,10 +331,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToPeople: {
universalIdentifier: 'dfe5fef8-d42c-40f0-941f-8e3b5eb01daa',
label: 'Go to People',
shortLabel: 'People',
icon: 'IconUser',
position: 23,
isPinned: false,
position: 23,
shortLabel: 'People',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.person',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -345,10 +345,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToCompanies: {
universalIdentifier: '196e4eec-bfdd-48a6-bcbb-6707ef11951a',
label: 'Go to Companies',
shortLabel: 'Companies',
icon: 'IconBuildingSkyscraper',
position: 24,
isPinned: false,
position: 24,
shortLabel: 'Companies',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.company',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -359,10 +359,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToDashboards: {
universalIdentifier: '11dc07d1-21b2-4f86-af8c-6a664c02f00c',
label: 'Go to Dashboards',
shortLabel: 'Dashboards',
icon: 'IconLayoutDashboard',
position: 25,
isPinned: false,
position: 25,
shortLabel: 'Dashboards',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.dashboard',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -373,10 +373,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToOpportunities: {
universalIdentifier: 'f04f5e00-a208-422f-acf2-ff189769510d',
label: 'Go to Opportunities',
shortLabel: 'Opportunities',
icon: 'IconTargetArrow',
position: 26,
isPinned: false,
position: 26,
shortLabel: 'Opportunities',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression:
'targetObjectReadPermissions.opportunity',
@@ -388,10 +388,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToSettings: {
universalIdentifier: 'ef9aba44-0068-453e-930a-f8c182af18ee',
label: 'Go to Settings',
shortLabel: 'Settings',
icon: 'IconSettings',
position: 27,
isPinned: false,
position: 27,
shortLabel: 'Settings',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
@@ -402,10 +402,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToTasks: {
universalIdentifier: 'e8e3bd0b-5ce9-4577-bb61-588c0b1ad063',
label: 'Go to Tasks',
shortLabel: 'Tasks',
icon: 'IconCheckbox',
position: 28,
isPinned: false,
position: 28,
shortLabel: 'Tasks',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.task',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -416,10 +416,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToNotes: {
universalIdentifier: '08e0f0cc-ac2d-46cd-9bca-39a409b9addf',
label: 'Go to Notes',
shortLabel: 'Notes',
icon: 'IconCheckbox',
position: 29,
isPinned: false,
position: 29,
shortLabel: 'Notes',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.note',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -430,10 +430,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
editRecordPageLayout: {
universalIdentifier: 'd9794c67-1799-424f-8871-5ea771dd4a6d',
label: 'Edit Page Layout',
shortLabel: 'Edit Layout',
icon: 'IconPencil',
position: 30,
isPinned: false,
position: 30,
shortLabel: 'Edit Layout',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and featureFlags.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED and noneDefined(selectedRecords, "deletedAt") and objectPermissions.canUpdateObjectRecords and objectMetadataItem.nameSingular != "dashboard"',
@@ -445,10 +445,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
editDashboardLayout: {
universalIdentifier: 'b9b53bbc-3129-4eb9-8344-c3f9628ffa7d',
label: 'Edit Dashboard',
shortLabel: 'Edit',
icon: 'IconPencil',
position: 33,
isPinned: true,
position: 33,
shortLabel: 'Edit',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and not isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords',
@@ -461,10 +461,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
saveDashboardLayout: {
universalIdentifier: '18b23908-f816-42ab-bc0a-eb5fae29c695',
label: 'Save Dashboard',
shortLabel: 'Save',
icon: 'IconDeviceFloppy',
position: 34,
isPinned: true,
position: 34,
shortLabel: 'Save',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords',
@@ -477,10 +477,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
cancelDashboardLayout: {
universalIdentifier: '030ecd01-0aaf-4e6d-8400-105996548887',
label: 'Cancel Edition',
shortLabel: 'Cancel',
icon: 'IconCancel',
position: 35,
isPinned: true,
position: 35,
shortLabel: 'Cancel',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords',
@@ -493,10 +493,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
duplicateDashboard: {
universalIdentifier: '2ee07307-60ce-41ef-bfee-7c718f67557e',
label: 'Duplicate Dashboard',
shortLabel: 'Duplicate',
icon: 'IconCopyPlus',
position: 36,
isPinned: false,
position: 36,
shortLabel: 'Duplicate',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'noneDefined(selectedRecords, "deletedAt") and objectPermissions.canUpdateObjectRecords',
@@ -509,10 +509,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToWorkflows: {
universalIdentifier: '4fa778f9-7931-4d18-b895-929e1ef9c31f',
label: 'Go to Workflows',
shortLabel: 'Workflows',
icon: 'IconSettingsAutomation',
position: 41,
isPinned: false,
position: 41,
shortLabel: 'Workflows',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.workflow',
availabilityObjectMetadataUniversalIdentifier: null,
@@ -523,10 +523,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
activateWorkflow: {
universalIdentifier: '44f19c85-0fd0-482f-a14e-da513c60b1b3',
label: 'Activate Workflow',
shortLabel: 'Activate',
icon: 'IconPower',
position: 42,
isPinned: true,
position: 42,
shortLabel: 'Activate',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and (everyEquals(selectedRecords, "currentVersion.status", "DRAFT") or includesNone(selectedRecords, "statuses", "ACTIVE")) and noneDefined(selectedRecords, "deletedAt")',
@@ -539,10 +539,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
deactivateWorkflow: {
universalIdentifier: '57f21a06-a17a-47b1-a123-90d90dbdf0b7',
label: 'Deactivate Workflow',
shortLabel: 'Deactivate',
icon: 'IconPlayerPause',
position: 43,
isPinned: true,
position: 43,
shortLabel: 'Deactivate',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'everyEquals(selectedRecords, "currentVersion.status", "ACTIVE") and noneDefined(selectedRecords, "deletedAt")',
@@ -555,10 +555,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
discardDraftWorkflow: {
universalIdentifier: '4c227f2e-03bb-4a66-9b13-49f263264f4a',
label: 'Discard Draft',
shortLabel: 'Discard Draft',
icon: 'IconNoteOff',
position: 44,
isPinned: true,
position: 44,
shortLabel: 'Discard Draft',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'every(selectedRecords, "versions.length") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt")',
@@ -571,10 +571,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
testWorkflow: {
universalIdentifier: 'f85d552a-87a3-4667-99f7-71b47917539c',
label: 'Test Workflow',
shortLabel: 'Test',
icon: 'IconPlayerPlay',
position: 45,
isPinned: true,
position: 45,
shortLabel: 'Test',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and ((everyEquals(selectedRecords, "currentVersion.trigger.type", "MANUAL") and noneDefined(selectedRecords, "currentVersion.trigger.settings.objectType")) or everyEquals(selectedRecords, "currentVersion.trigger.type", "WEBHOOK") or everyEquals(selectedRecords, "currentVersion.trigger.type", "CRON")) and noneDefined(selectedRecords, "deletedAt")',
@@ -587,10 +587,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeActiveVersionWorkflow: {
universalIdentifier: '31790508-75ff-4e4c-a768-83bd1b0718e0',
label: 'See Active Version',
shortLabel: 'See Active Version',
icon: 'IconVersions',
position: 46,
isPinned: false,
position: 46,
shortLabel: 'See Active Version',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'includesEvery(selectedRecords, "statuses", "ACTIVE") and includesEvery(selectedRecords, "statuses", "DRAFT") and noneDefined(selectedRecords, "deletedAt")',
@@ -603,10 +603,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeRunsWorkflow: {
universalIdentifier: 'e57efc2d-00a2-493a-b76c-f2dabd23a5eb',
label: 'See Runs',
shortLabel: 'See Runs',
icon: 'IconHistoryToggle',
position: 47,
isPinned: true,
position: 47,
shortLabel: 'See Runs',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'noneDefined(selectedRecords, "deletedAt")',
@@ -619,10 +619,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeVersionsWorkflow: {
universalIdentifier: '92781d24-b875-4282-8cdb-d127f04a5c7d',
label: 'See Versions History',
shortLabel: 'See Versions',
icon: 'IconVersions',
position: 48,
isPinned: false,
position: 48,
shortLabel: 'See Versions',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'noneDefined(selectedRecords, "deletedAt")',
@@ -635,10 +635,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
addNodeWorkflow: {
universalIdentifier: '818117fa-6cad-4ebc-83c1-40f4afc28d94',
label: 'Add a Node',
shortLabel: 'Add a Node',
icon: 'IconPlus',
position: 49,
isPinned: true,
position: 49,
shortLabel: 'Add a Node',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and noneDefined(selectedRecords, "deletedAt")',
@@ -651,10 +651,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
tidyUpWorkflow: {
universalIdentifier: '1f3a3cab-161a-4775-af47-11be4d0bf411',
label: 'Tidy up Workflow',
shortLabel: 'Tidy up',
icon: 'IconReorder',
position: 50,
isPinned: false,
position: 50,
shortLabel: 'Tidy up',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and noneDefined(selectedRecords, "deletedAt")',
@@ -667,10 +667,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
duplicateWorkflow: {
universalIdentifier: '91094438-b4c2-46ad-a23b-8af4b23ba514',
label: 'Duplicate Workflow',
shortLabel: 'Duplicate',
icon: 'IconCopy',
position: 51,
isPinned: false,
position: 51,
shortLabel: 'Duplicate',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'everyDefined(selectedRecords, "currentVersion") and noneDefined(selectedRecords, "deletedAt")',
@@ -683,10 +683,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
goToRuns: {
universalIdentifier: '1ba959da-ff49-4c1f-a517-2b78ee200508',
label: 'Go to Runs',
shortLabel: 'See Runs',
icon: 'IconHistoryToggle',
position: 52,
isPinned: false,
position: 52,
shortLabel: 'See Runs',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression:
'not hasAnySoftDeleteFilterOnView and objectMetadataItem.nameSingular != "workflowRun" and objectMetadataItem.nameSingular != "workflowVersion" and objectMetadataItem.nameSingular != "workflow"',
@@ -698,10 +698,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeVersionWorkflowRun: {
universalIdentifier: 'cc3a065c-c89e-40ac-9449-4272c55b1bb8',
label: 'See Version',
shortLabel: 'See Version',
icon: 'IconVersions',
position: 53,
isPinned: true,
position: 53,
shortLabel: 'See Version',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier:
@@ -713,10 +713,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeWorkflowWorkflowRun: {
universalIdentifier: '9d9cc62d-3543-45c3-93f3-23d2d8979f2b',
label: 'See Workflow',
shortLabel: 'See Workflow',
icon: 'IconSettingsAutomation',
position: 54,
isPinned: true,
position: 54,
shortLabel: 'See Workflow',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier:
@@ -728,10 +728,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
stopWorkflowRun: {
universalIdentifier: '4c186606-9515-4561-a1eb-9a072b4f5e58',
label: 'Stop',
shortLabel: 'Stop',
icon: 'IconPlayerStop',
position: 55,
isPinned: true,
position: 55,
shortLabel: 'Stop',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'isSelectAll or someEquals(selectedRecords, "status", "NOT_STARTED") or someEquals(selectedRecords, "status", "ENQUEUED") or someEquals(selectedRecords, "status", "RUNNING")',
@@ -744,10 +744,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeRunsWorkflowVersion: {
universalIdentifier: '44e305c7-4f0a-45ec-803f-6471b56455cb',
label: 'See Runs',
shortLabel: 'See Runs',
icon: 'IconHistoryToggle',
position: 56,
isPinned: true,
position: 56,
shortLabel: 'See Runs',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'everyDefined(selectedRecords, "workflow")',
@@ -760,10 +760,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeWorkflowWorkflowVersion: {
universalIdentifier: 'b43052db-023e-4083-9b63-2c2dfbfd1320',
label: 'See Workflow',
shortLabel: 'See Workflow',
icon: 'IconSettingsAutomation',
position: 57,
isPinned: true,
position: 57,
shortLabel: 'See Workflow',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'everyDefined(selectedRecords, "workflow.id")',
@@ -776,10 +776,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
useAsDraftWorkflowVersion: {
universalIdentifier: '483c0c1d-ea4d-4a4d-8a59-2dcf9f8e38f6',
label: 'Use as Draft',
shortLabel: 'Use as Draft',
icon: 'IconPencil',
position: 58,
isPinned: true,
position: 58,
shortLabel: 'Use as Draft',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'noneEquals(selectedRecords, "status", "DRAFT")',
@@ -792,10 +792,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
seeVersionsWorkflowVersion: {
universalIdentifier: '1d4abeb7-2750-4af7-9a92-fbadd2a9e4ba',
label: 'See Versions History',
shortLabel: 'See Versions',
icon: 'IconVersions',
position: 59,
isPinned: false,
position: 59,
shortLabel: 'See Versions',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'everyDefined(selectedRecords, "workflow")',
@@ -808,10 +808,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
searchRecords: {
universalIdentifier: 'fa24e25e-68f8-4548-82ff-c7b5168b7c7d',
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Search',
icon: 'IconSearch',
position: 60,
isPinned: false,
position: 60,
shortLabel: 'Search',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
@@ -822,10 +822,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
searchRecordsFallback: {
universalIdentifier: 'c659890c-7266-46c9-bfe1-75cefff8b6d0',
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Search',
icon: 'IconSearch',
position: 61,
isPinned: false,
position: 61,
shortLabel: 'Search',
availabilityType: CommandMenuItemAvailabilityType.FALLBACK,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
@@ -836,10 +836,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
askAi: {
universalIdentifier: 'ce5fb54d-2b19-4dd1-b7b4-9532a1761a41',
label: 'Ask AI',
shortLabel: 'Ask AI',
icon: 'IconSparkles',
position: 62,
isPinned: false,
position: 62,
shortLabel: 'Ask AI',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
@@ -850,10 +850,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
viewPreviousAiChats: {
universalIdentifier: '3084c3c9-cc23-4dad-9e00-92025f5cba7a',
label: 'View Previous AI Chats',
shortLabel: 'Previous AI Chats',
icon: 'IconHistory',
position: 63,
isPinned: false,
position: 63,
shortLabel: 'Previous AI Chats',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
@@ -1,5 +1,5 @@
export enum SidePanelPages {
Root = 'root',
CommandMenuDisplay = 'command-menu-display',
ViewRecord = 'view-record',
MergeRecords = 'merge-records',
UpdateRecords = 'update-records',
@@ -25,4 +25,5 @@ export enum SidePanelPages {
ViewFrontComponent = 'view-front-component',
NavigationMenuItemEdit = 'navigation-menu-item-edit',
NavigationMenuAddItem = 'navigation-menu-add-item',
CommandMenuEdit = 'command-menu-edit',
}
@@ -238,6 +238,7 @@ export {
IconLayoutList,
IconLayoutNavbar,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconLayoutSidebarRight,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
@@ -290,6 +291,7 @@ export {
IconPhoto,
IconPhotoUp,
IconPilcrow,
IconPin,
IconPinned,
IconPinnedOff,
IconPlayerPause,
@@ -351,6 +353,7 @@ export {
IconSpy,
IconSql,
IconSquare,
IconSquareCheck,
IconSquareKey,
IconSquareNumber1,
IconSquareNumber2,
@@ -364,6 +367,7 @@ export {
IconSquarePlus,
IconSquareRoundedCheck,
IconSquareRoundedX,
IconSquareX,
IconStack2,
IconStatusChange,
IconStepInto,
+4
View File
@@ -316,6 +316,7 @@ export {
IconLayoutList,
IconLayoutNavbar,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconLayoutSidebarRight,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
@@ -368,6 +369,7 @@ export {
IconPhoto,
IconPhotoUp,
IconPilcrow,
IconPin,
IconPinned,
IconPinnedOff,
IconPlayerPause,
@@ -429,6 +431,7 @@ export {
IconSpy,
IconSql,
IconSquare,
IconSquareCheck,
IconSquareKey,
IconSquareNumber1,
IconSquareNumber2,
@@ -442,6 +445,7 @@ export {
IconSquarePlus,
IconSquareRoundedCheck,
IconSquareRoundedX,
IconSquareX,
IconStack2,
IconStatusChange,
IconStepInto,
@@ -0,0 +1,69 @@
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext } from 'react';
import { type IconComponent } from '@ui/display';
import { ThemeContext } from '@ui/theme-constants';
const StyledContainer = styled.div<{ size: number }>`
height: ${({ size }) => size}px;
overflow: hidden;
position: relative;
width: ${({ size }) => size}px;
`;
const StyledLayer = styled(motion.div)`
align-items: center;
display: flex;
inset: 0;
justify-content: center;
position: absolute;
`;
type AnimatedIconCrossfadeProps = {
isActive: boolean;
ActiveIcon: IconComponent;
InactiveIcon: IconComponent;
size?: number;
};
export const AnimatedIconCrossfade = ({
isActive,
ActiveIcon,
InactiveIcon,
size,
}: AnimatedIconCrossfadeProps) => {
const { theme } = useContext(ThemeContext);
const iconSize = size ?? theme.icon.size.sm;
return (
<StyledContainer size={iconSize}>
<StyledLayer
initial={false}
animate={{
opacity: isActive ? 0 : 1,
scale: isActive ? 0.85 : 1,
}}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
>
<InactiveIcon size={iconSize} />
</StyledLayer>
<StyledLayer
initial={false}
animate={{
opacity: isActive ? 1 : 0,
scale: isActive ? 1 : 0.85,
}}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
>
<ActiveIcon size={iconSize} />
</StyledLayer>
</StyledContainer>
);
};
@@ -1,7 +1,8 @@
export * from './components/AnimatedCircleLoading';
export * from './components/AnimatedContainer';
export * from './components/AnimatedEaseIn';
export * from './components/AnimatedEaseInOut';
export * from './components/AnimatedFadeOut';
export * from './components/AnimatedIconCrossfade';
export * from './components/AnimatedTextWord';
export * from './components/AnimatedTranslation';
export * from './components/AnimatedCircleLoading';
@@ -12,6 +12,7 @@ export { AnimatedContainer } from './animation/components/AnimatedContainer';
export { AnimatedEaseIn } from './animation/components/AnimatedEaseIn';
export { AnimatedEaseInOut } from './animation/components/AnimatedEaseInOut';
export { AnimatedFadeOut } from './animation/components/AnimatedFadeOut';
export { AnimatedIconCrossfade } from './animation/components/AnimatedIconCrossfade';
export { AnimatedRotate } from './animation/components/AnimatedRotate';
export { AnimatedTextWord } from './animation/components/AnimatedTextWord';
export { AnimatedTranslation } from './animation/components/AnimatedTranslation';