Separate code pathways for IS_COMMAND_MENU_ITEM_ENABLED flag (#18542)

This commit is contained in:
nitin
2026-03-11 19:00:22 +05:30
committed by GitHub
parent 6cbc7725b7
commit 2c5af2654d
10 changed files with 330 additions and 117 deletions
@@ -0,0 +1,61 @@
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 { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent';
import { CommandMenuContextProviderServerItemsWithWorkflowEnrichment } from './CommandMenuContextProviderServerItemsWithWorkflowEnrichment';
type CommandMenuContextProviderServerItemsProps = {
isInSidePanel: CommandMenuContextType['isInSidePanel'];
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
};
export const CommandMenuContextProviderServerItems = ({
isInSidePanel,
displayType,
containerType,
children,
}: CommandMenuContextProviderServerItemsProps) => {
const commandMenuContextApi = useCommandMenuContextApi();
const currentObjectNameSingular =
commandMenuContextApi.objectMetadataItem.nameSingular;
const isWorkflow =
currentObjectNameSingular === CoreObjectNameSingular.Workflow;
const selectedWorkflowRecordIds = isWorkflow
? commandMenuContextApi.selectedRecords
.map((record) => record.id)
.filter(isDefined)
: [];
if (selectedWorkflowRecordIds.length > 0) {
return (
<CommandMenuContextProviderServerItemsWithWorkflowEnrichment
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
selectedWorkflowRecordIds={selectedWorkflowRecordIds}
>
{children}
</CommandMenuContextProviderServerItemsWithWorkflowEnrichment>
);
}
return (
<CommandMenuContextProviderServerItemsContent
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
commandMenuContextApi={commandMenuContextApi}
>
{children}
</CommandMenuContextProviderServerItemsContent>
);
};
@@ -0,0 +1,39 @@
import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { useCommandMenuItemFrontComponentCommands } from '@/command-menu-item/server-items/hooks/useCommandMenuItemFrontComponentCommands';
import { type CommandMenuContextApi } from 'twenty-shared/types';
type CommandMenuContextProviderServerItemsContentProps = {
isInSidePanel: CommandMenuContextType['isInSidePanel'];
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
};
export const CommandMenuContextProviderServerItemsContent = ({
isInSidePanel,
displayType,
containerType,
children,
commandMenuContextApi,
}: CommandMenuContextProviderServerItemsContentProps & {
commandMenuContextApi: CommandMenuContextApi;
}) => {
const commandMenuItemFrontComponentActions =
useCommandMenuItemFrontComponentCommands(commandMenuContextApi);
return (
<CommandMenuContext.Provider
value={{
isInSidePanel,
displayType,
containerType,
commandMenuItems: commandMenuItemFrontComponentActions,
}}
>
{children}
</CommandMenuContext.Provider>
);
};
@@ -0,0 +1,65 @@
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 { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent';
type CommandMenuContextProviderServerItemsWithWorkflowEnrichmentProps = {
isInSidePanel: CommandMenuContextType['isInSidePanel'];
displayType: CommandMenuContextType['displayType'];
containerType: CommandMenuContextType['containerType'];
children: React.ReactNode;
};
export const CommandMenuContextProviderServerItemsWithWorkflowEnrichment = ({
isInSidePanel,
displayType,
containerType,
children,
commandMenuContextApi,
selectedWorkflowRecordIds,
}: CommandMenuContextProviderServerItemsWithWorkflowEnrichmentProps & {
commandMenuContextApi: CommandMenuContextApi;
selectedWorkflowRecordIds: string[];
}) => {
const workflowsWithCurrentVersions = useWorkflowsWithCurrentVersions(
selectedWorkflowRecordIds,
);
const enrichedSelectedRecords = commandMenuContextApi.selectedRecords.map(
(record) => {
const workflowWithCurrentVersion = workflowsWithCurrentVersions.find(
(workflow) => workflow.id === record.id,
);
if (!isDefined(workflowWithCurrentVersion)) {
return record;
}
return {
...record,
currentVersion: workflowWithCurrentVersion.currentVersion,
versions: workflowWithCurrentVersion.versions,
statuses: workflowWithCurrentVersion.statuses,
};
},
);
const enrichedCommandMenuContextApi = {
...commandMenuContextApi,
selectedRecords: enrichedSelectedRecords,
};
return (
<CommandMenuContextProviderServerItemsContent
isInSidePanel={isInSidePanel}
displayType={displayType}
containerType={containerType}
commandMenuContextApi={enrichedCommandMenuContextApi}
>
{children}
</CommandMenuContextProviderServerItemsContent>
);
};
@@ -0,0 +1,168 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector';
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 { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isNonEmptyArray } from '@sniptt/guards';
import { useStore } from 'jotai';
import { useContext } from 'react';
import {
CommandMenuContextApiPageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useCommandMenuContextApi = (): CommandMenuContextApi => {
const store = useStore();
const { isInSidePanel } = useContext(CommandMenuContext);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED,
);
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataItemId,
);
const { sortedFavorites: favorites } = useFavorites();
const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData();
const recordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: undefined;
const favoriteRecordIds = (() => {
if (!isNonEmptyArray(recordIds)) {
return [];
}
if (isNavigationMenuItemEditingEnabled && isDefined(objectMetadataItem)) {
return recordIds.filter((recordId) =>
navigationMenuItems?.some(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
),
);
}
return recordIds.filter((recordId) =>
favorites?.some((favorite) => favorite.recordId === recordId),
);
})();
const selectedRecords = useAtomFamilySelectorValue(
recordStoreRecordsSelector,
{ recordIds: recordIds ?? [] },
);
const objectPermissionsFromHook = useObjectPermissionsForObject(
objectMetadataItem?.id ?? '',
);
const objectPermissions = isDefined(objectMetadataItem)
? objectPermissionsFromHook
: {
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
objectMetadataId: '',
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
};
const { recordIndexId } = useRecordIndexIdFromCurrentContextStore();
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
recordIndexId,
);
const contextStoreCurrentViewType = useAtomComponentStateValue(
contextStoreCurrentViewTypeComponentState,
);
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
);
const pageType =
contextStoreCurrentViewType === ContextStoreViewType.ShowPage
? CommandMenuContextApiPageType.RECORD_PAGE
: CommandMenuContextApiPageType.INDEX_PAGE;
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
);
const isSelectAll = contextStoreTargetedRecordsRule.mode === 'exclusion';
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const featureFlags: Record<string, boolean> = {};
for (const flag of currentWorkspace?.featureFlags ?? []) {
featureFlags[flag.key] = flag.value === true;
}
const targetObjectReadPermissions: Record<string, boolean> = {};
const targetObjectWritePermissions: Record<string, boolean> = {};
for (const metadataItem of objectMetadataItems) {
const permissions = store.get(
objectPermissionsFamilySelector.selectorFamily({
objectNameSingular: metadataItem.nameSingular,
}),
);
targetObjectReadPermissions[metadataItem.nameSingular] =
permissions.canRead;
targetObjectWritePermissions[metadataItem.nameSingular] =
permissions.canUpdate;
}
return {
pageType,
isInSidePanel,
isPageInEditMode: contextStoreIsPageInEditMode,
favoriteRecordIds,
isSelectAll,
hasAnySoftDeleteFilterOnView,
numberOfSelectedRecords: contextStoreNumberOfSelectedRecords,
objectPermissions,
selectedRecords,
featureFlags,
targetObjectReadPermissions,
targetObjectWritePermissions,
objectMetadataItem: objectMetadataItem ?? {},
};
};
@@ -0,0 +1,238 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { HeadlessFrontComponentCommandMenuItem } from '@/command-menu-item/display/components/HeadlessFrontComponentCommandMenuItem';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useMountHeadlessFrontComponent } from '@/front-components/hooks/useMountHeadlessFrontComponent';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import {
evaluateConditionalAvailabilityExpression,
isDefined,
} from 'twenty-shared/utils';
import { type IconComponent, useIcons } from 'twenty-ui/display';
import { type HeadlessFrontComponentMountContext } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
useFindManyCommandMenuItemsQuery,
} from '~/generated-metadata/graphql';
type CommandMenuItemWithFrontComponent = CommandMenuItemFieldsFragment & {
frontComponentId: string;
conditionalAvailabilityExpression?: string | null;
};
type BuildCommandMenuItemFromFrontComponentParams = {
item: CommandMenuItemWithFrontComponent;
type?: CommandMenuItemType;
scope: CommandMenuItemScope;
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
openFrontComponentInSidePanel: (params: {
frontComponentId: string;
pageTitle: string;
pageIcon: IconComponent;
recordContext?: {
recordId: string;
objectNameSingular: string;
};
}) => void;
mountHeadlessFrontComponent: (
frontComponentId: string,
context?: HeadlessFrontComponentMountContext,
) => void;
mountContext?: HeadlessFrontComponentMountContext;
commandMenuContextApi: CommandMenuContextApi;
};
// TODO: we should remove this backward compatibility logic in the future
// once we have migrated all command menu items
const buildCommandMenuItemFromFrontComponent = ({
item,
type = CommandMenuItemType.FrontComponent,
scope,
isPinned,
getIcon,
openFrontComponentInSidePanel,
mountHeadlessFrontComponent,
mountContext,
commandMenuContextApi,
}: BuildCommandMenuItemFromFrontComponentParams) => {
const displayLabel = item.label;
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const isHeadless = item.frontComponent?.isHeadless === true;
const handleClick = () => {
if (isHeadless) {
mountHeadlessFrontComponent(item.frontComponentId, mountContext);
} else {
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel,
pageIcon: Icon,
recordContext: isDefined(mountContext)
? {
recordId: mountContext.recordId,
objectNameSingular: mountContext.objectNameSingular,
}
: undefined,
});
}
};
return {
type,
key: `command-menu-item-front-component-${item.id}`,
scope,
label: displayLabel,
shortLabel: item.shortLabel ?? undefined,
position: item.position,
isPinned,
Icon,
shouldBeRegistered: () =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component: isHeadless ? (
<HeadlessFrontComponentCommandMenuItem
frontComponentId={item.frontComponentId}
onClick={handleClick}
/>
) : (
<Command onClick={handleClick} />
),
};
};
export const useCommandMenuItemFrontComponentCommands = (
commandMenuContextApi: CommandMenuContextApi,
) => {
const { getIcon } = useIcons();
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const mountHeadlessFrontComponent = useMountHeadlessFrontComponent();
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const currentObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataItemId,
);
const selectedRecordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: [];
const hasRecordSelection =
selectedRecordIds.length >= 1 ||
contextStoreTargetedRecordsRule.mode === 'exclusion';
const mountContext: HeadlessFrontComponentMountContext | undefined =
selectedRecordIds.length === 1 && isDefined(currentObjectMetadataItem)
? {
recordId: selectedRecordIds[0],
objectNameSingular: currentObjectMetadataItem.nameSingular,
}
: undefined;
const { data } = useFindManyCommandMenuItemsQuery();
const frontComponentItems =
data?.commandMenuItems?.filter(
(item): item is CommandMenuItemWithFrontComponent =>
isDefined(item.frontComponentId),
) ?? [];
const objectMatches = (item: CommandMenuItemWithFrontComponent) =>
!isDefined(item.availabilityObjectMetadataId) ||
item.availabilityObjectMetadataId ===
contextStoreCurrentObjectMetadataItemId;
const frontComponentItemsWithObjectMatches =
frontComponentItems.filter(objectMatches);
const globalItems = frontComponentItemsWithObjectMatches.filter(
(item) => item.availabilityType === CommandMenuItemAvailabilityType.GLOBAL,
);
const recordScopedItems = frontComponentItemsWithObjectMatches.filter(
(item) =>
item.availabilityType ===
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
const fallbackItems = frontComponentItemsWithObjectMatches.filter(
(item) =>
item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK,
);
const globalCommandMenuItems = globalItems.map((item) =>
buildCommandMenuItemFromFrontComponent({
item,
scope: CommandMenuItemScope.Global,
isPinned: !contextStoreIsPageInEditMode && item.isPinned,
getIcon,
openFrontComponentInSidePanel,
mountHeadlessFrontComponent,
commandMenuContextApi,
}),
);
const recordScopedCommandMenuItems = hasRecordSelection
? recordScopedItems.map((item) =>
buildCommandMenuItemFromFrontComponent({
item,
scope: CommandMenuItemScope.RecordSelection,
isPinned: !contextStoreIsPageInEditMode && item.isPinned,
getIcon,
openFrontComponentInSidePanel,
mountHeadlessFrontComponent,
commandMenuContextApi,
mountContext,
}),
)
: [];
const fallbackCommandMenuItems = fallbackItems.map((item) =>
buildCommandMenuItemFromFrontComponent({
item,
type: CommandMenuItemType.Fallback,
scope: CommandMenuItemScope.Global,
isPinned: false,
getIcon,
openFrontComponentInSidePanel,
mountHeadlessFrontComponent,
commandMenuContextApi,
}),
);
return [
...globalCommandMenuItems,
...recordScopedCommandMenuItems,
...fallbackCommandMenuItems,
]
.filter((item) => item.shouldBeRegistered())
.sort((a, b) => a.position - b.position);
};
@@ -0,0 +1,73 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import {
type Workflow,
type WorkflowVersion,
type WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
const getCurrentVersionId = (workflow: Workflow): string | undefined => {
const draftVersion = workflow.versions.find(
(version) => version.status === 'DRAFT',
);
const sortedVersions = workflow.versions.toSorted((a, b) =>
a.createdAt > b.createdAt ? -1 : 1,
);
const latestVersion = sortedVersions[0];
return (draftVersion ?? latestVersion)?.id;
};
export const useWorkflowsWithCurrentVersions = (
workflowIds: string[],
): WorkflowWithCurrentVersion[] => {
const { records: workflows } = useFindManyRecords<Workflow>({
objectNameSingular: CoreObjectNameSingular.Workflow,
filter: { id: { in: workflowIds } },
recordGqlFields: {
id: true,
name: true,
statuses: true,
lastPublishedVersionId: true,
versions: {
id: true,
status: true,
name: true,
createdAt: true,
},
},
skip: workflowIds.length === 0,
});
const currentVersionIds = workflows
.map(getCurrentVersionId)
.filter(isDefined);
const { records: currentVersions } = useFindManyRecords<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
filter: { id: { in: currentVersionIds } },
skip: currentVersionIds.length === 0,
});
return workflows
.map((workflow) => {
const currentVersionId = getCurrentVersionId(workflow);
const currentVersion = currentVersions.find(
(version) => version.id === currentVersionId,
);
if (!isDefined(currentVersion)) {
return undefined;
}
return {
...workflow,
currentVersion,
};
})
.filter(isDefined);
};