diff --git a/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts b/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts index 797a302d8781a..76cd993c0ae79 100644 --- a/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts +++ b/packages/twenty-e2e-testing/tests/workflow-creation.spec.ts @@ -7,6 +7,9 @@ test('Create workflow', async ({ page }) => { await page.goto(process.env.LINK); + const workflowsFolder = page.getByRole('button', { name: 'Workflows' }); + await workflowsFolder.click(); + const workflowsLink = page.getByRole('link', { name: 'Workflows' }); await workflowsLink.click(); diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index d3a20ae7130f4..b4c612ac90d62 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -62,9 +62,9 @@ const jestConfig = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 50, - lines: 48.9, - functions: 40.9, + statements: 49.5, + lines: 48, + functions: 40, }, }, collectCoverageFrom: ['/src/**/*.ts'], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 166745976dabd..0997f9facd336 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -994,6 +994,7 @@ export type CreateFrontComponentInput = { export type CreateNavigationMenuItemInput = { folderId?: InputMaybe; + link?: InputMaybe; name?: InputMaybe; position?: InputMaybe; targetObjectMetadataId?: InputMaybe; @@ -1422,6 +1423,7 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED', + IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED', IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED', IS_NOTE_TARGET_MIGRATED = 'IS_NOTE_TARGET_MIGRATED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', @@ -3030,6 +3032,7 @@ export type NavigationMenuItem = { createdAt: Scalars['DateTime']; folderId?: Maybe; id: Scalars['UUID']; + link?: Maybe; name?: Maybe; position: Scalars['Float']; targetObjectMetadataId?: Maybe; @@ -4393,6 +4396,7 @@ export type UpdateLogicFunctionSourceInput = { export type UpdateNavigationMenuItemInput = { folderId?: InputMaybe; + link?: InputMaybe; name?: InputMaybe; position?: InputMaybe; }; @@ -5616,42 +5620,42 @@ export type FindManyMarketplaceAppsQueryVariables = Exact<{ [key: string]: never export type FindManyMarketplaceAppsQuery = { __typename?: 'Query', findManyMarketplaceApps: Array<{ __typename?: 'MarketplaceApp', id: string, name: string, description: string, icon: string, version: string, author: string, category: string, logo?: string | null, screenshots: Array, aboutDescription: string, providers: Array, websiteUrl?: string | null, termsUrl?: string | null }> }; -export type NavigationMenuItemFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string }; +export type NavigationMenuItemFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string }; -export type NavigationMenuItemQueryFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string, targetRecordIdentifier?: { __typename?: 'RecordIdentifier', id: string, labelIdentifier: string, imageIdentifier?: string | null } | null }; +export type NavigationMenuItemQueryFieldsFragment = { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string, targetRecordIdentifier?: { __typename?: 'RecordIdentifier', id: string, labelIdentifier: string, imageIdentifier?: string | null } | null }; export type CreateNavigationMenuItemMutationVariables = Exact<{ input: CreateNavigationMenuItemInput; }>; -export type CreateNavigationMenuItemMutation = { __typename?: 'Mutation', createNavigationMenuItem: { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string } }; +export type CreateNavigationMenuItemMutation = { __typename?: 'Mutation', createNavigationMenuItem: { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string } }; export type DeleteNavigationMenuItemMutationVariables = Exact<{ id: Scalars['UUID']; }>; -export type DeleteNavigationMenuItemMutation = { __typename?: 'Mutation', deleteNavigationMenuItem: { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string } }; +export type DeleteNavigationMenuItemMutation = { __typename?: 'Mutation', deleteNavigationMenuItem: { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string } }; export type UpdateNavigationMenuItemMutationVariables = Exact<{ input: UpdateOneNavigationMenuItemInput; }>; -export type UpdateNavigationMenuItemMutation = { __typename?: 'Mutation', updateNavigationMenuItem: { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string } }; +export type UpdateNavigationMenuItemMutation = { __typename?: 'Mutation', updateNavigationMenuItem: { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string } }; export type FindManyNavigationMenuItemsQueryVariables = Exact<{ [key: string]: never; }>; -export type FindManyNavigationMenuItemsQuery = { __typename?: 'Query', navigationMenuItems: Array<{ __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string, targetRecordIdentifier?: { __typename?: 'RecordIdentifier', id: string, labelIdentifier: string, imageIdentifier?: string | null } | null }> }; +export type FindManyNavigationMenuItemsQuery = { __typename?: 'Query', navigationMenuItems: Array<{ __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string, targetRecordIdentifier?: { __typename?: 'RecordIdentifier', id: string, labelIdentifier: string, imageIdentifier?: string | null } | null }> }; export type FindOneNavigationMenuItemQueryVariables = Exact<{ id: Scalars['UUID']; }>; -export type FindOneNavigationMenuItemQuery = { __typename?: 'Query', navigationMenuItem?: { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string, targetRecordIdentifier?: { __typename?: 'RecordIdentifier', id: string, labelIdentifier: string, imageIdentifier?: string | null } | null } | null }; +export type FindOneNavigationMenuItemQuery = { __typename?: 'Query', navigationMenuItem?: { __typename?: 'NavigationMenuItem', id: string, userWorkspaceId?: string | null, targetRecordId?: string | null, targetObjectMetadataId?: string | null, viewId?: string | null, folderId?: string | null, name?: string | null, link?: string | null, position: number, applicationId?: string | null, createdAt: string, updatedAt: string, targetRecordIdentifier?: { __typename?: 'RecordIdentifier', id: string, labelIdentifier: string, imageIdentifier?: string | null } | null } | null }; export type ObjectMetadataFieldsFragment = { __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId: string, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, morphId?: string | null, applicationId: string, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }; @@ -7081,6 +7085,7 @@ export const NavigationMenuItemFieldsFragmentDoc = gql` viewId folderId name + link position applicationId createdAt diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index e71bd5539d212..10de6c93883e9 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1097,6 +1097,7 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED', + IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED', IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED', IS_NOTE_TARGET_MIGRATED = 'IS_NOTE_TARGET_MIGRATED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', @@ -1755,6 +1756,7 @@ export type NavigationMenuItem = { createdAt: Scalars['DateTime']; folderId?: Maybe; id: Scalars['UUID']; + link?: Maybe; name?: Maybe; position: Scalars['Float']; targetObjectMetadataId?: Maybe; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuAddToNavDraggablePlaceholder.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuAddToNavDraggablePlaceholder.tsx new file mode 100644 index 0000000000000..ff3b6a201da97 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuAddToNavDraggablePlaceholder.tsx @@ -0,0 +1,28 @@ +import { Draggable } from '@hello-pangea/dnd'; +import { type ReactNode } from 'react'; + +type CommandMenuAddToNavDraggablePlaceholderProps = { + index: number; + children: ReactNode; +}; + +export const CommandMenuAddToNavDraggablePlaceholder = ({ + index, + children, +}: CommandMenuAddToNavDraggablePlaceholderProps) => ( + + {(provided) => ( +
+ {children} +
+ )} +
+); diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuAddToNavDroppable.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuAddToNavDroppable.tsx new file mode 100644 index 0000000000000..58a4a2a9a523a --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuAddToNavDroppable.tsx @@ -0,0 +1,25 @@ +import { Droppable, type DroppableProvided } from '@hello-pangea/dnd'; +import { type ReactNode, useContext } from 'react'; + +import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId'; +import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext'; + +type CommandMenuAddToNavDroppableProps = { + children: (provided: DroppableProvided) => ReactNode; +}; + +export const CommandMenuAddToNavDroppable = ({ + children, +}: CommandMenuAddToNavDroppableProps) => { + const { sourceDroppableId } = useContext(NavigationDragSourceContext); + const isDropDisabled = sourceDroppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID; + + return ( + + {(provided) => children(provided)} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderLinkInfo.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderLinkInfo.tsx new file mode 100644 index 0000000000000..97ecfeaaa616b --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderLinkInfo.tsx @@ -0,0 +1,116 @@ +import { useTheme } from '@emotion/react'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilValue } from 'recoil'; +import { IconFolder, IconLink } from 'twenty-ui/display'; + +import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout'; +import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState'; +import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState'; +import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer'; +import { useUpdateFolderNameInDraft } from '@/navigation-menu-item/hooks/useUpdateFolderNameInDraft'; +import { useUpdateLinkInDraft } from '@/navigation-menu-item/hooks/useUpdateLinkInDraft'; +import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { TitleInput } from '@/ui/input/components/TitleInput'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; + +const ICON_CONFIG = { + folder: { Icon: IconFolder, colorKey: 'folder' }, + link: { Icon: IconLink, colorKey: 'link' }, +} as const; + +export const CommandMenuFolderLinkInfo = ({ + type, +}: { + type: 'folder' | 'link'; +}) => { + const theme = useTheme(); + const { t } = useLingui(); + const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState); + const [shouldFocusTitleInput, setShouldFocusTitleInput] = + useRecoilComponentState( + commandMenuShouldFocusTitleInputComponentState, + commandMenuPageInfo.instanceId, + ); + const selectedNavigationMenuItemInEditMode = useRecoilValue( + selectedNavigationMenuItemInEditModeState, + ); + const items = useWorkspaceSectionItems(); + const { updateFolderNameInDraft } = useUpdateFolderNameInDraft(); + const { updateLinkInDraft } = useUpdateLinkInDraft(); + + const defaultLabel = type === 'folder' ? t`New folder` : t`Link label`; + const placeholder = type === 'folder' ? t`Folder name` : t`Link label`; + + const selectedItem = selectedNavigationMenuItemInEditMode + ? items.find( + (item) => + item.itemType === type && + item.id === selectedNavigationMenuItemInEditMode, + ) + : undefined; + + if (!selectedItem) return null; + + const itemId = selectedItem.id; + const itemName = selectedItem.name ?? defaultLabel; + + const handleChange = (text: string) => { + if (type === 'folder') { + updateFolderNameInDraft(itemId, text); + } else { + updateLinkInDraft(itemId, { name: text }); + } + }; + + const handleSave = () => { + const trimmed = itemName.trim(); + const finalName = trimmed.length > 0 ? trimmed : defaultLabel; + + if (finalName !== itemName) { + if (type === 'folder') { + updateFolderNameInDraft(itemId, finalName); + } else { + updateLinkInDraft(itemId, { name: finalName }); + } + } + }; + + const { Icon, colorKey } = ICON_CONFIG[type]; + + return ( + + + + } + title={ + setShouldFocusTitleInput(false)} + /> + } + label={type === 'link' ? t`link` : undefined} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx index 5ddffd22b1322..eeac62e970c48 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx @@ -1,11 +1,11 @@ import { isNonEmptyString } from '@sniptt/guards'; +import { type ReactNode } from 'react'; +import { IconArrowUpRight, type IconComponent } from 'twenty-ui/display'; +import { MenuItem } from 'twenty-ui/navigation'; import { useCommandMenuOnItemClick } from '@/command-menu/hooks/useCommandMenuOnItemClick'; import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector'; import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; -import { type ReactNode } from 'react'; -import { IconArrowUpRight, type IconComponent } from 'twenty-ui/display'; -import { MenuItem } from 'twenty-ui/navigation'; export type CommandMenuItemProps = { label: string; @@ -15,6 +15,7 @@ export type CommandMenuItemProps = { onClick?: () => void; Icon?: IconComponent; hotKeys?: string[]; + LeftComponent?: ReactNode; RightComponent?: ReactNode; contextualTextPosition?: 'left' | 'right'; hasSubMenu?: boolean; @@ -31,6 +32,7 @@ export const CommandMenuItem = ({ onClick, Icon, hotKeys, + LeftComponent, RightComponent, hasSubMenu = false, isSubMenuOpened = false, @@ -49,8 +51,9 @@ export const CommandMenuItem = ({ return ( void; + payload: AddToNavigationDragPayload; + dragIndex?: number; +}; + +const StyledDraggableMenuItem = styled.div` + cursor: grab; + width: 100%; + + &:active { + cursor: grabbing; + } +`; + +export const CommandMenuItemWithAddToNavigationDrag = ({ + icon, + customIconContent, + label, + description, + id, + onClick, + payload, + dragIndex, +}: CommandMenuItemWithAddToNavigationDragProps) => { + const { t } = useLingui(); + const setRegistry = useSetRecoilState(addToNavPayloadRegistryState); + const [isHovered, setIsHovered] = useState(false); + + const contextualDescription = isHovered + ? t`Drag to add to navbar` + : description; + + const DragHandleIcon = () => ( + + ); + + const registerPayload = () => { + if (dragIndex !== undefined) { + setRegistry((prev) => new Map(prev).set(id, payload)); + } + }; + + const menuItemContent = ( + { + setIsHovered(true); + registerPayload(); + }} + onMouseLeave={() => setIsHovered(false)} + onMouseDown={registerPayload} + > + + + ); + + if (dragIndex !== undefined) { + return ( + + {(provided) => ( +
+ {menuItemContent} +
+ )} +
+ ); + } + + return menuItemContent; +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuList.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuList.tsx index a77820f2e8b48..b126bbf939a62 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuList.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuList.tsx @@ -20,6 +20,7 @@ export type CommandMenuListProps = { children?: React.ReactNode; loading?: boolean; noResults?: boolean; + noResultsText?: string; }; const StyledInnerList = styled.div` @@ -60,6 +61,7 @@ export const CommandMenuList = ({ children, loading = false, noResults = false, + noResultsText, }: CommandMenuListProps) => { const setHasUserSelectedCommand = useSetRecoilState( hasUserSelectedCommandState, @@ -91,7 +93,7 @@ export const CommandMenuList = ({ ) : null, )} {noResults && !loading && ( - {t`No results found`} + {noResultsText ?? t`No results found`} )} diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuObjectViewRecordInfo.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuObjectViewRecordInfo.tsx new file mode 100644 index 0000000000000..d2c3403d1fd03 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuObjectViewRecordInfo.tsx @@ -0,0 +1,38 @@ +import { useLingui } from '@lingui/react/macro'; +import { OverflowingTextWithTooltip } from 'twenty-ui/display'; + +import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout'; +import { useSelectedNavigationMenuItemEditData } from '@/command-menu/pages/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditData'; +import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon'; +import { ViewKey } from '@/views/types/ViewKey'; + +export const CommandMenuObjectViewRecordInfo = () => { + const { t } = useLingui(); + const { processedItem, selectedItemLabel } = + useSelectedNavigationMenuItemEditData(); + + if (!processedItem || !selectedItemLabel) { + return null; + } + + const isViewOrRecord = + processedItem.itemType === 'view' || processedItem.itemType === 'record'; + if (!isViewOrRecord) { + return null; + } + + const label = + processedItem.itemType === 'record' + ? t`record` + : processedItem.viewKey === ViewKey.Index + ? t`object` + : t`view`; + + return ( + } + title={} + label={label} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuPageInfo.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuPageInfo.tsx index 531dea39bf3f5..4309a354d8f42 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuPageInfo.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuPageInfo.tsx @@ -1,11 +1,18 @@ +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { OverflowingTextWithTooltip } from 'twenty-ui/display'; + +import { CommandMenuFolderLinkInfo } from '@/command-menu/components/CommandMenuFolderLinkInfo'; import { CommandMenuMultipleRecordsInfo } from '@/command-menu/components/CommandMenuMultipleRecordsInfo'; +import { CommandMenuObjectViewRecordInfo } from '@/command-menu/components/CommandMenuObjectViewRecordInfo'; import { CommandMenuPageLayoutInfo } from '@/command-menu/components/CommandMenuPageLayoutInfo'; import { CommandMenuRecordInfo } from '@/command-menu/components/CommandMenuRecordInfo'; import { CommandMenuWorkflowStepInfo } from '@/command-menu/components/CommandMenuWorkflowStepInfo'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; -import styled from '@emotion/styled'; -import { isDefined } from 'twenty-shared/utils'; -import { OverflowingTextWithTooltip } from 'twenty-ui/display'; +import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; + import { type CommandMenuContextChipProps } from './CommandMenuContextChip'; const StyledPageTitle = styled.div` @@ -19,10 +26,33 @@ type CommandMenuPageInfoProps = { }; export const CommandMenuPageInfo = ({ pageChip }: CommandMenuPageInfoProps) => { + const selectedNavigationMenuItemInEditMode = useRecoilValue( + selectedNavigationMenuItemInEditModeState, + ); + const items = useWorkspaceSectionItems(); + if (!isDefined(pageChip)) { return null; } + const isNavigationMenuItemEditPage = + pageChip.page?.page === CommandMenuPages.NavigationMenuItemEdit; + const selectedNavItem = isNavigationMenuItemEditPage + ? items.find((item) => item.id === selectedNavigationMenuItemInEditMode) + : undefined; + + if (isNavigationMenuItemEditPage && isDefined(selectedNavItem)) { + const itemType = selectedNavItem.itemType; + + if (itemType === 'folder' || itemType === 'link') { + return ; + } + + if (itemType === 'view' || itemType === 'record') { + return ; + } + } + const isRecordPage = pageChip.page?.page === CommandMenuPages.ViewRecord; if (isRecordPage && isDefined(pageChip.page?.pageId)) { diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuSidePanelForDesktop.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuSidePanelForDesktop.tsx index ceee4944c3caf..e4a94628f978b 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuSidePanelForDesktop.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuSidePanelForDesktop.tsx @@ -134,6 +134,7 @@ export const CommandMenuSidePanelForDesktop = () => { isOpen={isCommandMenuOpened} isResizing={isResizing} onTransitionEnd={handleTransitionEnd} + data-command-menu-panel="" > diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuSubViewWithSearch.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuSubViewWithSearch.tsx new file mode 100644 index 0000000000000..c157900b57d0e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuSubViewWithSearch.tsx @@ -0,0 +1,87 @@ +import styled from '@emotion/styled'; +import { type ReactNode } from 'react'; + +import { SidePanelSubPageNavigationHeader } from '@/command-menu/pages/common/components/SidePanelSubPageNavigationHeader'; + +const StyledSubViewContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; +`; + +const StyledSearchContainer = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + min-width: 0; + padding: ${({ theme }) => theme.spacing(2, 3)}; +`; + +const StyledSearchInput = styled.input` + background: transparent; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-sizing: border-box; + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.md}; + padding: 0; + width: 100%; + outline: none; + + &::placeholder { + color: ${({ theme }) => theme.font.color.tertiary}; + } +`; + +const StyledScrollableListWrapper = styled.div` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + + & > * { + flex: 1; + min-height: 0; + } +`; + +type CommandMenuSubViewWithSearchProps = { + backBarTitle: string; + onBack: () => void; + searchPlaceholder: string; + searchValue: string; + onSearchChange: (value: string) => void; + searchInputProps?: React.InputHTMLAttributes; + children?: ReactNode; +}; + +export const CommandMenuSubViewWithSearch = ({ + backBarTitle, + onBack, + searchPlaceholder, + searchValue, + onSearchChange, + searchInputProps, + children, +}: CommandMenuSubViewWithSearchProps) => ( + + + + onSearchChange(event.target.value)} + autoFocus + // eslint-disable-next-line react/jsx-props-no-spreading + {...searchInputProps} + /> + + {children != null && ( + {children} + )} + +); diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx index 0fb940a277813..272810176d6d0 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx @@ -4,6 +4,8 @@ import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/Com import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage'; import { CommandMenuFrontComponentPage } from '@/command-menu/pages/front-component/components/CommandMenuFrontComponentPage'; import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage'; +import { CommandMenuNavigationMenuItemEditPage } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage'; +import { CommandMenuNewSidebarItemPage } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage'; import { CommandMenuPageLayoutChartSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutChartSettings'; import { CommandMenuPageLayoutFieldsLayout } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutFieldsLayout'; import { CommandMenuPageLayoutFieldsSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutFieldsSettings'; @@ -76,4 +78,9 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map< , ], [CommandMenuPages.ViewFrontComponent, ], + [ + CommandMenuPages.NavigationMenuItemEdit, + , + ], + [CommandMenuPages.NavigationMenuAddItem, ], ]); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts index b184f50227360..509fdd0e974be 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -1,12 +1,12 @@ import { useRecoilCallback } from 'recoil'; -import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; - import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId'; import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState'; import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState'; import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown'; import { emitSidePanelOpenEvent } from '@/ui/layout/right-drawer/utils/emitSidePanelOpenEvent'; import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; @@ -29,6 +29,7 @@ export const useCommandMenu = () => { .getValue(); if (isCommandMenuOpened) { + set(addToNavPayloadRegistryState, new Map()); set(isCommandMenuOpenedState, false); set(isCommandMenuClosingState, true); closeAnyOpenDropdown(); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts index 223c325b462ab..5336f474099ea 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts @@ -86,6 +86,9 @@ export const useCommandMenuHotKeys = () => { }, focusId: SIDE_PANEL_FOCUS_ID, dependencies: [goBackFromCommandMenu], + options: { + enableOnFormTags: false, + }, }); useHotkeysOnFocusedElement({ @@ -119,6 +122,7 @@ export const useCommandMenuHotKeys = () => { ], options: { preventDefault: false, + enableOnFormTags: false, }, }); }; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useFilteredPickerItems.ts b/packages/twenty-front/src/modules/command-menu/hooks/useFilteredPickerItems.ts new file mode 100644 index 0000000000000..78e82cf5c7c25 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/hooks/useFilteredPickerItems.ts @@ -0,0 +1,43 @@ +import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; + +type UseFilteredPickerItemsParams = { + items: T[]; + searchQuery: string; + getSearchableValues: (item: T) => string[]; + appendSelectableIds?: string[]; +}; + +type UseFilteredPickerItemsResult = { + filteredItems: T[]; + selectableItemIds: string[]; + isEmpty: boolean; + hasSearchQuery: boolean; +}; + +export const useFilteredPickerItems = ({ + items, + searchQuery, + getSearchableValues, + appendSelectableIds = [], +}: UseFilteredPickerItemsParams): UseFilteredPickerItemsResult => { + const filteredItems = filterBySearchQuery({ + items, + searchQuery, + getSearchableValues, + }); + + const selectableItemIds = + filteredItems.length > 0 + ? [...filteredItems.map((item) => item.id), ...appendSelectableIds] + : appendSelectableIds; + + const isEmpty = filteredItems.length === 0; + const hasSearchQuery = searchQuery.trim().length > 0; + + return { + filteredItems, + selectableItemIds, + isEmpty, + hasSearchQuery, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditFolderPickerSubView.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditFolderPickerSubView.tsx new file mode 100644 index 0000000000000..066e8dd6c8c50 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditFolderPickerSubView.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { useLingui } from '@lingui/react/macro'; +import { IconFolderPlus } from 'twenty-ui/display'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { CommandMenuSubViewWithSearch } from '@/command-menu/components/CommandMenuSubViewWithSearch'; +import { useFolderPickerSelectionData } from '@/command-menu/pages/navigation-menu-item/hooks/useFolderPickerSelectionData'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; + +type CommandMenuEditFolderPickerSubViewProps = { + onBack: () => void; +}; + +export const CommandMenuEditFolderPickerSubView = ({ + onBack, +}: CommandMenuEditFolderPickerSubViewProps) => { + const { t } = useLingui(); + const [searchValue, setSearchValue] = useState(''); + const { foldersToShow, includeNoFolderOption, handleSelectFolder } = + useFolderPickerSelectionData(); + + const filteredFolders = filterBySearchQuery({ + items: foldersToShow, + searchQuery: searchValue, + getSearchableValues: (folder) => [folder.name], + }); + const isEmpty = filteredFolders.length === 0 && !includeNoFolderOption; + const selectableItemIds = [ + ...(includeNoFolderOption ? ['no-folder'] : []), + ...(filteredFolders.length > 0 ? filteredFolders.map((f) => f.id) : []), + ]; + const noResultsText = + searchValue.trim().length > 0 + ? t`No results found` + : t`No folders available`; + + return ( + + + + {includeNoFolderOption && ( + handleSelectFolder(null)} + > + handleSelectFolder(null)} + /> + + )} + {filteredFolders.map((folder) => ( + handleSelectFolder(folder.id)} + > + handleSelectFolder(folder.id)} + /> + + ))} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditLinkItemView.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditLinkItemView.tsx new file mode 100644 index 0000000000000..75f2d56910e02 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditLinkItemView.tsx @@ -0,0 +1,72 @@ +import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useState } from 'react'; +import { getAbsoluteUrl } from 'twenty-shared/utils'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { + type OrganizeActionsProps, + CommandMenuEditOrganizeActions, +} from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditOrganizeActions'; +import { getOrganizeActionsSelectableItemIds } from '@/command-menu/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds'; +import { CommandMenuEditOwnerSection } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditOwnerSection'; +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item'; +import { TextInput } from '@/ui/input/components/TextInput'; + +type CommandMenuEditLinkItemViewProps = OrganizeActionsProps & { + selectedItem: ProcessedNavigationMenuItem; + onUpdateLink: (linkId: string, link: string) => void; + onOpenFolderPicker: () => void; +}; + +export const CommandMenuEditLinkItemView = ({ + selectedItem, + onUpdateLink, + canMoveUp, + canMoveDown, + onOpenFolderPicker, + onMoveUp, + onMoveDown, + onRemove, + onAddBefore, + onAddAfter, +}: CommandMenuEditLinkItemViewProps) => { + const { t } = useLingui(); + const [urlEditInput, setUrlEditInput] = useState(''); + + const selectableItemIds = getOrganizeActionsSelectableItemIds(true); + + return ( + + + setUrlEditInput(value)} + onBlur={(event) => { + const value = event.target.value.trim(); + if (isNonEmptyString(value)) { + onUpdateLink(selectedItem.id, getAbsoluteUrl(value)); + setUrlEditInput(''); + } + }} + /> + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditObjectViewBase.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditObjectViewBase.tsx new file mode 100644 index 0000000000000..d69ec80d60ffe --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditObjectViewBase.tsx @@ -0,0 +1,39 @@ +import { + type OrganizeActionsProps, + CommandMenuEditOrganizeActions, +} from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditOrganizeActions'; +import { getOrganizeActionsSelectableItemIds } from '@/command-menu/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; + +type CommandMenuEditObjectViewBaseProps = OrganizeActionsProps & { + onOpenFolderPicker: () => void; +}; + +export const CommandMenuEditObjectViewBase = ({ + onOpenFolderPicker, + canMoveUp, + canMoveDown, + onMoveUp, + onMoveDown, + onRemove, + onAddBefore, + onAddAfter, +}: CommandMenuEditObjectViewBaseProps) => { + const selectableItemIds = getOrganizeActionsSelectableItemIds(true); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditOrganizeActions.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditOrganizeActions.tsx new file mode 100644 index 0000000000000..bba1f081cc671 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditOrganizeActions.tsx @@ -0,0 +1,112 @@ +import { useLingui } from '@lingui/react/macro'; +import { + IconChevronDown, + IconChevronUp, + IconFolderSymlink, + IconRowInsertBottom, + IconRowInsertTop, + IconTrash, +} from 'twenty-ui/display'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; + +export type OrganizeActionsProps = { + canMoveUp: boolean; + canMoveDown: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + onRemove: () => void; + onAddBefore?: () => void; + onAddAfter?: () => void; +}; + +type CommandMenuEditOrganizeActionsProps = OrganizeActionsProps & { + showMoveToFolder?: boolean; + onMoveToFolder?: () => void; + moveToFolderHasSubMenu?: boolean; +}; + +export const CommandMenuEditOrganizeActions = ({ + canMoveUp, + canMoveDown, + onMoveUp, + onMoveDown, + onRemove, + onAddBefore, + onAddAfter, + showMoveToFolder = false, + onMoveToFolder, + moveToFolderHasSubMenu = false, +}: CommandMenuEditOrganizeActionsProps) => { + const { t } = useLingui(); + + return ( + + + + + + + + {showMoveToFolder && onMoveToFolder && ( + + + + )} + {onAddBefore && ( + + + + )} + {onAddAfter && ( + + + + )} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditOwnerSection.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditOwnerSection.tsx new file mode 100644 index 0000000000000..e7d3981a95169 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuEditOwnerSection.tsx @@ -0,0 +1,54 @@ +import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { IconApps } from 'twenty-ui/display'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { useNavigationMenuItemEditFolderData } from '@/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditFolderData'; +import { useSelectedNavigationMenuItemEditData } from '@/command-menu/pages/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditData'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { useFindOneApplicationQuery } from '~/generated-metadata/graphql'; + +type CommandMenuEditOwnerSectionProps = { + applicationId?: string | null; +}; + +export const CommandMenuEditOwnerSection = ({ + applicationId: applicationIdProp, +}: CommandMenuEditOwnerSectionProps) => { + const { t } = useLingui(); + + const { selectedItem } = useSelectedNavigationMenuItemEditData(); + const { currentDraft } = useNavigationMenuItemEditFolderData(); + + const applicationIdFromDraft = + selectedItem && currentDraft + ? currentDraft.find((item) => item.id === selectedItem.id)?.applicationId + : undefined; + + const applicationId = applicationIdProp ?? applicationIdFromDraft; + + const { data } = useFindOneApplicationQuery({ + variables: { id: applicationId ?? '' }, + skip: !isDefined(applicationId), + }); + + const applicationName = data?.findOneApplication?.name; + + if (!isDefined(applicationName)) { + return null; + } + + return ( + + {}}> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage.tsx new file mode 100644 index 0000000000000..23e31b58f69fc --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage.tsx @@ -0,0 +1,155 @@ +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { CommandMenuEditFolderPickerSubView } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditFolderPickerSubView'; +import { CommandMenuEditLinkItemView } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditLinkItemView'; +import { CommandMenuEditObjectViewBase } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditObjectViewBase'; +import { CommandMenuEditOrganizeActions } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditOrganizeActions'; +import { CommandMenuEditOwnerSection } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditOwnerSection'; +import { useNavigationMenuItemEditOrganizeActions } from '@/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditOrganizeActions'; +import { useNavigationMenuItemEditSubView } from '@/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditSubView'; +import { useSelectedNavigationMenuItemEditData } from '@/command-menu/pages/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditData'; +import { getOrganizeActionsSelectableItemIds } from '@/command-menu/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds'; +import { useUpdateLinkInDraft } from '@/navigation-menu-item/hooks/useUpdateLinkInDraft'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; +import { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +const StyledCommandMenuPlaceholder = styled.p` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +const StyledCommandMenuPageContainer = styled.div` + padding: ${({ theme }) => theme.spacing(3)}; +`; + +export const CommandMenuNavigationMenuItemEditPage = () => { + const { t } = useLingui(); + + const selectedNavigationMenuItemInEditMode = useRecoilValue( + selectedNavigationMenuItemInEditModeState, + ); + const { + selectedItemLabel, + selectedItem, + selectedItemObjectMetadata, + selectedItemType, + } = useSelectedNavigationMenuItemEditData(); + + const { editSubView, setFolderPicker, clearSubView } = + useNavigationMenuItemEditSubView(); + + const { + canMoveUp, + canMoveDown, + onMoveUp, + onMoveDown, + onRemove, + onAddBefore, + onAddAfter, + } = useNavigationMenuItemEditOrganizeActions(); + + const { updateLinkInDraft } = useUpdateLinkInDraft(); + + if (!selectedNavigationMenuItemInEditMode || !selectedItemLabel) { + return ( + + + {t`Select a navigation item to edit`} + + + ); + } + + if (editSubView === 'folder-picker') { + return ; + } + + if ( + selectedItemType === NAVIGATION_MENU_ITEM_TYPE.VIEW && + !selectedItemObjectMetadata + ) { + return null; + } + + if (selectedItemType === NAVIGATION_MENU_ITEM_TYPE.VIEW) { + return ( + + ); + } + + if ( + isDefined(selectedItem) && + selectedItem.itemType === NAVIGATION_MENU_ITEM_TYPE.LINK + ) { + return ( + updateLinkInDraft(linkId, { link })} + onOpenFolderPicker={setFolderPicker} + canMoveUp={canMoveUp} + canMoveDown={canMoveDown} + onMoveUp={onMoveUp} + onMoveDown={onMoveDown} + onRemove={onRemove} + onAddBefore={onAddBefore} + onAddAfter={onAddAfter} + /> + ); + } + + if (selectedItemType === NAVIGATION_MENU_ITEM_TYPE.LINK) { + return null; + } + + if (selectedItemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER) { + return ( + + + + + ); + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemMainMenu.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemMainMenu.tsx new file mode 100644 index 0000000000000..14ef94cb43d1d --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemMainMenu.tsx @@ -0,0 +1,116 @@ +import { useLingui } from '@lingui/react/macro'; +import { + IconAddressBook, + IconCube, + IconFolder, + IconLink, + IconList, +} from 'twenty-ui/display'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuAddToNavDraggablePlaceholder } from '@/command-menu/components/CommandMenuAddToNavDraggablePlaceholder'; +import { CommandMenuAddToNavDroppable } from '@/command-menu/components/CommandMenuAddToNavDroppable'; +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuItemWithAddToNavigationDrag } from '@/command-menu/components/CommandMenuItemWithAddToNavigationDrag'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; + +type CommandMenuNewSidebarItemMainMenuProps = { + onSelectObject: () => void; + onSelectView: () => void; + onSelectRecord: () => void; + onAddFolder: () => void; + onAddLink: () => void; +}; + +export const CommandMenuNewSidebarItemMainMenu = ({ + onSelectObject, + onSelectView, + onSelectRecord, + onAddFolder, + onAddLink, +}: CommandMenuNewSidebarItemMainMenuProps) => { + const { t } = useLingui(); + + return ( + + {({ innerRef, droppableProps, placeholder }) => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {placeholder} +
+
+ )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage.tsx new file mode 100644 index 0000000000000..8bb8019ff0599 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage.tsx @@ -0,0 +1,265 @@ +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { IconFolder, IconLink } from 'twenty-ui/display'; + +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { CommandMenuNewSidebarItemMainMenu } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemMainMenu'; +import { CommandMenuNewSidebarItemRecordSubView } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemRecordSubView'; +import { CommandMenuNewSidebarItemViewObjectPickerSubView } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewObjectPickerSubView'; +import { CommandMenuNewSidebarItemViewPickerSubView } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewPickerSubView'; +import { CommandMenuNewSidebarItemViewSystemSubView } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewSystemSubView'; +import { CommandMenuObjectPickerSubView } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerSubView'; +import { CommandMenuSystemObjectPickerSubView } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuSystemObjectPickerSubView'; +import { useAddFolderToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddFolderToNavigationMenuDraft'; +import { useAddLinkToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddLinkToNavigationMenuDraft'; +import { useAddObjectToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddObjectToNavigationMenuDraft'; +import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState'; +import { useNavigationMenuObjectMetadataFromDraft } from '@/navigation-menu-item/hooks/useNavigationMenuObjectMetadataFromDraft'; +import { useOpenNavigationMenuItemInCommandMenu } from '@/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu'; +import { addMenuItemInsertionContextState } from '@/navigation-menu-item/states/addMenuItemInsertionContextState'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ViewKey } from '@/views/types/ViewKey'; + +type SelectedOption = + | 'object' + | 'record' + | 'system' + | 'view' + | 'view-system' + | null; + +export const CommandMenuNewSidebarItemPage = () => { + const { t } = useLingui(); + const { closeCommandMenu } = useCommandMenu(); + const [selectedOption, setSelectedOption] = useState(null); + const [selectedObjectMetadataIdForView, setSelectedObjectMetadataIdForView] = + useState(null); + const [objectSearchInput, setObjectSearchInput] = useState(''); + const [systemObjectSearchInput, setSystemObjectSearchInput] = useState(''); + + const { objectMetadataItems } = useObjectMetadataItems(); + const { addObjectToDraft } = useAddObjectToNavigationMenuDraft(); + const { addFolderToDraft } = useAddFolderToNavigationMenuDraft(); + const { addLinkToDraft } = useAddLinkToNavigationMenuDraft(); + const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState(); + const navigationMenuItemsDraft = useRecoilValue( + navigationMenuItemsDraftState, + ); + const setSelectedNavigationMenuItemInEditMode = useSetRecoilState( + selectedNavigationMenuItemInEditModeState, + ); + const { openNavigationMenuItemInCommandMenu } = + useOpenNavigationMenuItemInCommandMenu(); + const { activeNonSystemObjectMetadataItems } = + useFilteredObjectMetadataItems(); + + const currentDraft = isDefined(navigationMenuItemsDraft) + ? navigationMenuItemsDraft + : workspaceNavigationMenuItems; + + const addMenuItemInsertionContext = useRecoilValue( + addMenuItemInsertionContextState, + ); + const setAddMenuItemInsertionContext = useSetRecoilState( + addMenuItemInsertionContextState, + ); + + const { views, objectMetadataIdsWithIndexView } = + useNavigationMenuObjectMetadataFromDraft(currentDraft); + + const objectMetadataIdsWithDisplayableViews = new Set( + views + .filter((view) => view.key !== ViewKey.Index) + .map((view) => view.objectMetadataId), + ); + + const availableObjectMetadataItems = [ + ...activeNonSystemObjectMetadataItems, + ].sort((a, b) => a.labelPlural.localeCompare(b.labelPlural)); + + const activeSystemObjectMetadataItems = objectMetadataItems + .filter((item) => item.isActive && item.isSystem) + .sort((a, b) => a.labelPlural.localeCompare(b.labelPlural)); + const availableSystemObjectMetadataItems = + activeSystemObjectMetadataItems.filter((item) => + objectMetadataIdsWithIndexView.has(item.id), + ); + + const objectMetadataItemsWithViews = objectMetadataItems + .filter( + (item) => + item.isActive && objectMetadataIdsWithDisplayableViews.has(item.id), + ) + .filter((item) => !item.isSystem) + .sort((a, b) => a.labelPlural.localeCompare(b.labelPlural)); + + const availableSystemObjectMetadataItemsForView = + activeSystemObjectMetadataItems.filter((item) => + objectMetadataIdsWithDisplayableViews.has(item.id), + ); + + const handleSelectObject = ( + objectMetadataItem: ObjectMetadataItem, + defaultViewId: string, + ) => { + addObjectToDraft( + objectMetadataItem.id, + defaultViewId, + currentDraft, + addMenuItemInsertionContext?.targetFolderId, + addMenuItemInsertionContext?.targetIndex, + ); + setAddMenuItemInsertionContext(null); + closeCommandMenu(); + }; + + const handleBackToMain = () => { + setSelectedOption(null); + setSelectedObjectMetadataIdForView(null); + setObjectSearchInput(''); + setSystemObjectSearchInput(''); + }; + + const handleBackToObjectList = () => { + setSelectedOption('object'); + setSystemObjectSearchInput(''); + }; + + const handleBackToViewObjectList = () => { + const selectedObjectMetadataItem = isDefined( + selectedObjectMetadataIdForView, + ) + ? objectMetadataItems.find( + (item) => item.id === selectedObjectMetadataIdForView, + ) + : undefined; + const cameFromSystemObjects = selectedObjectMetadataItem?.isSystem ?? false; + + setSelectedObjectMetadataIdForView(null); + if (cameFromSystemObjects) { + setSelectedOption('view-system'); + } + }; + + const handleBackToViewObjectListFromSystem = () => { + setSelectedOption('view'); + setSystemObjectSearchInput(''); + }; + + const handleAddFolderAndOpenEdit = () => { + const newFolderId = addFolderToDraft( + t`New folder`, + currentDraft, + addMenuItemInsertionContext?.targetFolderId ?? null, + addMenuItemInsertionContext?.targetIndex, + ); + setAddMenuItemInsertionContext(null); + setSelectedNavigationMenuItemInEditMode(newFolderId); + openNavigationMenuItemInCommandMenu({ + pageTitle: t`Edit folder`, + pageIcon: IconFolder, + focusTitleInput: true, + }); + }; + + const handleAddLinkAndOpenEdit = () => { + const newLinkId = addLinkToDraft( + t`Link label`, + 'www.example.com', + currentDraft, + addMenuItemInsertionContext?.targetFolderId ?? null, + addMenuItemInsertionContext?.targetIndex, + ); + setAddMenuItemInsertionContext(null); + setSelectedNavigationMenuItemInEditMode(newLinkId); + openNavigationMenuItemInCommandMenu({ + pageTitle: t`Edit link`, + pageIcon: IconLink, + focusTitleInput: true, + }); + }; + + switch (selectedOption) { + case 'view': + if (isDefined(selectedObjectMetadataIdForView)) { + return ( + + ); + } + return ( + setSelectedOption('view-system')} + onSelectObject={(item) => setSelectedObjectMetadataIdForView(item.id)} + showSystemObjectsOption={ + availableSystemObjectMetadataItemsForView.length > 0 + } + /> + ); + case 'view-system': + return ( + { + setSelectedObjectMetadataIdForView(item.id); + setSelectedOption('view'); + }} + /> + ); + case 'object': + return ( + setSelectedOption('system')} + isViewItem={false} + onChangeObject={handleSelectObject} + objectMenuItemVariant="add" + /> + ); + case 'system': + return ( + + ); + case 'record': + return ( + + ); + default: + return ( + setSelectedOption('object')} + onSelectView={() => setSelectedOption('view')} + onSelectRecord={() => setSelectedOption('record')} + onAddFolder={handleAddFolderAndOpenEdit} + onAddLink={handleAddLinkAndOpenEdit} + /> + ); + } +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemRecordItem.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemRecordItem.tsx new file mode 100644 index 0000000000000..1c5078a54b5d8 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemRecordItem.tsx @@ -0,0 +1,94 @@ +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { Avatar } from 'twenty-ui/display'; + +import { CommandMenuItemWithAddToNavigationDrag } from '@/command-menu/components/CommandMenuItemWithAddToNavigationDrag'; +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { useNavigationMenuItemEditFolderData } from '@/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditFolderData'; +import { useAddRecordToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddRecordToNavigationMenuDraft'; +import { addMenuItemInsertionContextState } from '@/navigation-menu-item/states/addMenuItemInsertionContextState'; +import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; + +type SearchRecord = { + recordId: string; + objectNameSingular: string; + label: string; + imageUrl?: string | null; +}; + +type CommandMenuNewSidebarItemRecordItemProps = { + record: SearchRecord; + dragIndex?: number; +}; + +export const CommandMenuNewSidebarItemRecordItem = ({ + record, + dragIndex, +}: CommandMenuNewSidebarItemRecordItemProps) => { + const { closeCommandMenu } = useCommandMenu(); + const { addRecordToDraft } = useAddRecordToNavigationMenuDraft(); + const { currentDraft } = useNavigationMenuItemEditFolderData(); + const addMenuItemInsertionContext = useRecoilValue( + addMenuItemInsertionContextState, + ); + const setAddMenuItemInsertionContext = useSetRecoilState( + addMenuItemInsertionContextState, + ); + const { objectMetadataItems } = useObjectMetadataItems(); + const objectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === record.objectNameSingular, + ); + const recordPayload: AddToNavigationDragPayload = { + type: 'record', + recordId: record.recordId, + objectMetadataId: objectMetadataItem?.id ?? '', + objectNameSingular: record.objectNameSingular, + label: record.label, + imageUrl: record.imageUrl, + }; + + const handleSelectRecord = () => { + addRecordToDraft( + { + recordId: record.recordId, + objectNameSingular: record.objectNameSingular, + label: record.label, + imageUrl: record.imageUrl, + }, + currentDraft, + addMenuItemInsertionContext?.targetFolderId ?? null, + addMenuItemInsertionContext?.targetIndex, + ); + setAddMenuItemInsertionContext(null); + closeCommandMenu(); + }; + + return ( + + + } + label={record.label} + description={ + objectMetadataItem?.labelSingular ?? record.objectNameSingular + } + id={record.recordId} + onClick={handleSelectRecord} + dragIndex={dragIndex} + payload={recordPayload} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemRecordSubView.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemRecordSubView.tsx new file mode 100644 index 0000000000000..88b37e2429e50 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemRecordSubView.tsx @@ -0,0 +1,119 @@ +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { useDebounce } from 'use-debounce'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuAddToNavDroppable } from '@/command-menu/components/CommandMenuAddToNavDroppable'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { CommandMenuSubViewWithSearch } from '@/command-menu/components/CommandMenuSubViewWithSearch'; +import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults'; +import { CommandMenuNewSidebarItemRecordItem } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemRecordItem'; +import { useNavigationMenuItemEditFolderData } from '@/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditFolderData'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; +import { useSearchQuery } from '~/generated/graphql'; + +type SearchRecordBase = { + recordId: string; + objectNameSingular: string; + label: string; + imageUrl?: string | null; +}; + +type CommandMenuNewSidebarItemRecordSubViewProps = { + onBack: () => void; +}; + +export const CommandMenuNewSidebarItemRecordSubView = ({ + onBack, +}: CommandMenuNewSidebarItemRecordSubViewProps) => { + const { t } = useLingui(); + const { currentDraft } = useNavigationMenuItemEditFolderData(); + const { objectMetadataItems } = useObjectMetadataItems(); + const [recordSearchInput, setRecordSearchInput] = useState(''); + const [deferredRecordSearchInput] = useDebounce(recordSearchInput, 300); + const coreClient = useApolloCoreClient(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const nonReadableObjectMetadataItemsNameSingular = objectMetadataItems + .filter( + (objectMetadataItem) => + !getObjectPermissionsFromMapByObjectMetadataId({ + objectPermissionsByObjectMetadataId, + objectMetadataId: objectMetadataItem.id, + })?.canReadObjectRecords, + ) + .map((objectMetadataItem) => objectMetadataItem.nameSingular); + + const { data: searchData, loading: recordSearchLoading } = useSearchQuery({ + client: coreClient, + variables: { + searchInput: deferredRecordSearchInput ?? '', + limit: MAX_SEARCH_RESULTS, + excludedObjectNameSingulars: [ + 'workspaceMember', + ...nonReadableObjectMetadataItemsNameSingular, + ], + }, + }); + + const workspaceRecordIds = new Set( + currentDraft.flatMap((item) => + isDefined(item.targetRecordId) ? [item.targetRecordId] : [], + ), + ); + + const searchRecords = + searchData?.search?.edges?.map((edge) => edge.node) ?? []; + const availableSearchRecords = searchRecords.filter( + (record) => !workspaceRecordIds.has(record.recordId), + ) as SearchRecordBase[]; + + const isEmpty = availableSearchRecords.length === 0 && !recordSearchLoading; + const selectableItemIds = isEmpty + ? [] + : availableSearchRecords.map((record) => record.recordId); + const noResultsText = + deferredRecordSearchInput.length > 0 + ? t`No results found` + : t`Type to search records`; + + return ( + + + {({ innerRef, droppableProps, placeholder }) => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+ + {availableSearchRecords.map((record, index) => ( + + ))} + + {placeholder} +
+
+ )} +
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewObjectPickerSubView.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewObjectPickerSubView.tsx new file mode 100644 index 0000000000000..7d08c7b796af0 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewObjectPickerSubView.tsx @@ -0,0 +1,101 @@ +import { useTheme } from '@emotion/react'; +import { useLingui } from '@lingui/react/macro'; +import { IconSettings, useIcons } from 'twenty-ui/display'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { CommandMenuSubViewWithSearch } from '@/command-menu/components/CommandMenuSubViewWithSearch'; +import { useFilteredPickerItems } from '@/command-menu/hooks/useFilteredPickerItems'; +import { IconWithBackground } from '@/navigation-menu-item/components/IconWithBackground'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; + +type CommandMenuNewSidebarItemViewObjectPickerSubViewProps = { + objects: ObjectMetadataItem[]; + searchValue: string; + onSearchChange: (value: string) => void; + onBack: () => void; + onOpenSystemPicker: () => void; + onSelectObject: (objectMetadataItem: ObjectMetadataItem) => void; + showSystemObjectsOption?: boolean; +}; + +export const CommandMenuNewSidebarItemViewObjectPickerSubView = ({ + objects, + searchValue, + onSearchChange, + onBack, + onOpenSystemPicker, + onSelectObject, + showSystemObjectsOption = true, +}: CommandMenuNewSidebarItemViewObjectPickerSubViewProps) => { + const { t } = useLingui(); + const theme = useTheme(); + const { getIcon } = useIcons(); + const iconColors = getNavigationMenuItemIconColors(theme); + const { filteredItems, selectableItemIds, isEmpty, hasSearchQuery } = + useFilteredPickerItems({ + items: objects, + searchQuery: searchValue, + getSearchableValues: (item) => [item.labelPlural], + appendSelectableIds: showSystemObjectsOption ? ['system'] : [], + }); + const noResultsText = hasSearchQuery + ? t`No results found` + : t`No objects with views found`; + + return ( + + + + {filteredItems.map((objectMetadataItem) => ( + onSelectObject(objectMetadataItem)} + > + ( + + )} + label={objectMetadataItem.labelPlural} + id={objectMetadataItem.id} + hasSubMenu + onClick={() => onSelectObject(objectMetadataItem)} + /> + + ))} + {showSystemObjectsOption && ( + + + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewPickerSubView.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewPickerSubView.tsx new file mode 100644 index 0000000000000..8b24441685c19 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewPickerSubView.tsx @@ -0,0 +1,132 @@ +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useIcons } from 'twenty-ui/display'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuAddToNavDroppable } from '@/command-menu/components/CommandMenuAddToNavDroppable'; +import { CommandMenuItemWithAddToNavigationDrag } from '@/command-menu/components/CommandMenuItemWithAddToNavigationDrag'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { CommandMenuSubViewWithSearch } from '@/command-menu/components/CommandMenuSubViewWithSearch'; +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { useFilteredPickerItems } from '@/command-menu/hooks/useFilteredPickerItems'; +import { useNavigationMenuItemEditFolderData } from '@/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditFolderData'; +import { useAddViewToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddViewToNavigationMenuDraft'; +import { useNavigationMenuObjectMetadataFromDraft } from '@/navigation-menu-item/hooks/useNavigationMenuObjectMetadataFromDraft'; +import { addMenuItemInsertionContextState } from '@/navigation-menu-item/states/addMenuItemInsertionContextState'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { type View } from '@/views/types/View'; +import { ViewKey } from '@/views/types/ViewKey'; + +type CommandMenuNewSidebarItemViewPickerSubViewProps = { + selectedObjectMetadataIdForView: string; + onBack: () => void; +}; + +export const CommandMenuNewSidebarItemViewPickerSubView = ({ + selectedObjectMetadataIdForView, + onBack, +}: CommandMenuNewSidebarItemViewPickerSubViewProps) => { + const { t } = useLingui(); + const { getIcon } = useIcons(); + const [searchValue, setSearchValue] = useState(''); + const { closeCommandMenu } = useCommandMenu(); + const { addViewToDraft } = useAddViewToNavigationMenuDraft(); + const { currentDraft } = useNavigationMenuItemEditFolderData(); + const addMenuItemInsertionContext = useRecoilValue( + addMenuItemInsertionContextState, + ); + const setAddMenuItemInsertionContext = useSetRecoilState( + addMenuItemInsertionContextState, + ); + const { objectMetadataItems } = useObjectMetadataItems(); + const { views } = useNavigationMenuObjectMetadataFromDraft(currentDraft); + + const viewsForSelectedObject = views + .filter( + (view) => + view.objectMetadataId === selectedObjectMetadataIdForView && + view.key !== ViewKey.Index, + ) + .sort((a, b) => a.position - b.position); + + const selectedObjectMetadataItem = objectMetadataItems.find( + (item) => item.id === selectedObjectMetadataIdForView, + ); + const backBarTitle = + selectedObjectMetadataItem?.labelPlural ?? t`Pick a view`; + + const { + filteredItems: filteredViews, + selectableItemIds, + isEmpty, + hasSearchQuery, + } = useFilteredPickerItems({ + items: viewsForSelectedObject, + searchQuery: searchValue, + getSearchableValues: (view) => [view.name], + }); + const noResultsText = hasSearchQuery + ? t`No results found` + : t`No custom views available`; + + const handleSelectView = (view: View) => { + addViewToDraft( + view.id, + currentDraft, + addMenuItemInsertionContext?.targetFolderId ?? null, + addMenuItemInsertionContext?.targetIndex, + ); + setAddMenuItemInsertionContext(null); + closeCommandMenu(); + }; + + return ( + + + {({ innerRef, droppableProps, placeholder }) => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+ + {filteredViews.map((view, index) => ( + handleSelectView(view)} + > + handleSelectView(view)} + dragIndex={index} + payload={{ + type: 'view', + viewId: view.id, + label: view.name, + }} + /> + + ))} + + {placeholder} +
+
+ )} +
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewSystemSubView.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewSystemSubView.tsx new file mode 100644 index 0000000000000..22f12177a2a9e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemViewSystemSubView.tsx @@ -0,0 +1,88 @@ +import { useTheme } from '@emotion/react'; +import { useLingui } from '@lingui/react/macro'; +import { useIcons } from 'twenty-ui/display'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { CommandMenuSubViewWithSearch } from '@/command-menu/components/CommandMenuSubViewWithSearch'; +import { IconWithBackground } from '@/navigation-menu-item/components/IconWithBackground'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { useFilteredPickerItems } from '@/command-menu/hooks/useFilteredPickerItems'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; + +type CommandMenuNewSidebarItemViewSystemSubViewProps = { + systemObjects: ObjectMetadataItem[]; + searchValue: string; + onSearchChange: (value: string) => void; + onBack: () => void; + onSelectObject: (objectMetadataItem: ObjectMetadataItem) => void; +}; + +export const CommandMenuNewSidebarItemViewSystemSubView = ({ + systemObjects, + searchValue, + onSearchChange, + onBack, + onSelectObject, +}: CommandMenuNewSidebarItemViewSystemSubViewProps) => { + const { t } = useLingui(); + const theme = useTheme(); + const { getIcon } = useIcons(); + const iconColors = getNavigationMenuItemIconColors(theme); + const { + filteredItems: filteredSystemObjectMetadataItems, + selectableItemIds, + isEmpty, + hasSearchQuery, + } = useFilteredPickerItems({ + items: systemObjects, + searchQuery: searchValue, + getSearchableValues: (item) => [item.labelPlural], + }); + const noResultsText = hasSearchQuery + ? t`No results found` + : t`No system objects with views found`; + + return ( + + + + {filteredSystemObjectMetadataItems.map((objectMetadataItem) => ( + onSelectObject(objectMetadataItem)} + > + ( + + )} + label={objectMetadataItem.labelPlural} + id={objectMetadataItem.id} + onClick={() => onSelectObject(objectMetadataItem)} + /> + + ))} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectMenuItem.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectMenuItem.tsx new file mode 100644 index 0000000000000..ff63b53b9a1b3 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectMenuItem.tsx @@ -0,0 +1,82 @@ +import { useTheme } from '@emotion/react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { useIcons } from 'twenty-ui/display'; + +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuItemWithAddToNavigationDrag } from '@/command-menu/components/CommandMenuItemWithAddToNavigationDrag'; +import { IconWithBackground } from '@/navigation-menu-item/components/IconWithBackground'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector'; + +type CommandMenuObjectMenuItemProps = { + objectMetadataItem: ObjectMetadataItem; + onSelect: ( + objectMetadataItem: ObjectMetadataItem, + defaultViewId: string, + ) => void; + variant: 'add' | 'edit'; + dragIndex?: number; +}; + +export const CommandMenuObjectMenuItem = ({ + objectMetadataItem, + onSelect, + variant, + dragIndex, +}: CommandMenuObjectMenuItemProps) => { + const theme = useTheme(); + const { getIcon } = useIcons(); + const iconColors = getNavigationMenuItemIconColors(theme); + const defaultViewId = useRecoilValue( + coreIndexViewIdFromObjectMetadataItemFamilySelector({ + objectMetadataItemId: objectMetadataItem.id, + }), + ); + const Icon = getIcon(objectMetadataItem.icon); + const isDisabled = !isDefined(defaultViewId); + + const handleClick = () => { + if (!defaultViewId) { + return; + } + onSelect(objectMetadataItem, defaultViewId); + }; + + return ( + + {variant === 'add' && !isDisabled ? ( + + ) : ( + ( + + )} + label={objectMetadataItem.labelPlural} + id={objectMetadataItem.id} + onClick={handleClick} + disabled={isDisabled} + /> + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerItem.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerItem.tsx new file mode 100644 index 0000000000000..a6b767a459d1f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerItem.tsx @@ -0,0 +1,67 @@ +import { useTheme } from '@emotion/react'; +import { isDefined } from 'twenty-shared/utils'; +import { useIcons } from 'twenty-ui/display'; + +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuObjectMenuItem } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuObjectMenuItem'; +import { IconWithBackground } from '@/navigation-menu-item/components/IconWithBackground'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; + +type CommandMenuObjectPickerItemProps = { + objectMetadataItem: ObjectMetadataItem; + isViewItem: boolean; + onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void; + onChangeObject: ( + objectMetadataItem: ObjectMetadataItem, + defaultViewId: string, + ) => void; + objectMenuItemVariant?: 'add' | 'edit'; + dragIndex?: number; +}; + +export const CommandMenuObjectPickerItem = ({ + objectMetadataItem, + isViewItem, + onSelectObjectForViewEdit, + onChangeObject, + objectMenuItemVariant = 'edit', + dragIndex, +}: CommandMenuObjectPickerItemProps) => { + const theme = useTheme(); + const { getIcon } = useIcons(); + const iconColors = getNavigationMenuItemIconColors(theme); + + if (isViewItem && isDefined(onSelectObjectForViewEdit)) { + return ( + onSelectObjectForViewEdit(objectMetadataItem)} + > + ( + + )} + label={objectMetadataItem.labelPlural} + id={objectMetadataItem.id} + onClick={() => onSelectObjectForViewEdit(objectMetadataItem)} + /> + + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerSubView.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerSubView.tsx new file mode 100644 index 0000000000000..94a985a49546f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerSubView.tsx @@ -0,0 +1,134 @@ +import { useLingui } from '@lingui/react/macro'; +import { IconSettings } from 'twenty-ui/display'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuAddToNavDraggablePlaceholder } from '@/command-menu/components/CommandMenuAddToNavDraggablePlaceholder'; +import { CommandMenuAddToNavDroppable } from '@/command-menu/components/CommandMenuAddToNavDroppable'; +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { CommandMenuSubViewWithSearch } from '@/command-menu/components/CommandMenuSubViewWithSearch'; +import { useFilteredPickerItems } from '@/command-menu/hooks/useFilteredPickerItems'; +import { CommandMenuObjectPickerItem } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; + +type CommandMenuObjectPickerSubViewProps = { + objects: ObjectMetadataItem[]; + searchValue: string; + onSearchChange: (value: string) => void; + onBack: () => void; + onOpenSystemPicker: () => void; + isViewItem: boolean; + onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void; + onChangeObject: ( + objectMetadataItem: ObjectMetadataItem, + defaultViewId: string, + ) => void; + objectMenuItemVariant?: 'add' | 'edit'; + emptyNoResultsText?: string; +}; + +export const CommandMenuObjectPickerSubView = ({ + objects, + searchValue, + onSearchChange, + onBack, + onOpenSystemPicker, + isViewItem, + onSelectObjectForViewEdit, + onChangeObject, + objectMenuItemVariant = 'edit', + emptyNoResultsText, +}: CommandMenuObjectPickerSubViewProps) => { + const { t } = useLingui(); + const { filteredItems, selectableItemIds, isEmpty, hasSearchQuery } = + useFilteredPickerItems({ + items: objects, + searchQuery: searchValue, + getSearchableValues: (item) => [item.labelPlural], + appendSelectableIds: ['system'], + }); + + const noResultsText = hasSearchQuery + ? t`No results found` + : (emptyNoResultsText ?? t`All objects are already in the sidebar`); + + const isAddVariant = objectMenuItemVariant === 'add'; + + const listContent = ( + + {filteredItems.map((objectMetadataItem, index) => ( + + ))} + {isAddVariant ? ( + + + + + + ) : ( + + + + )} + + ); + + return ( + + {isAddVariant ? ( + + {({ innerRef, droppableProps, placeholder }) => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+ {listContent} + {placeholder} +
+
+ )} +
+ ) : ( + + {listContent} + + )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuSystemObjectPickerSubView.tsx b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuSystemObjectPickerSubView.tsx new file mode 100644 index 0000000000000..8f7d5028579b8 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/components/CommandMenuSystemObjectPickerSubView.tsx @@ -0,0 +1,77 @@ +import { useLingui } from '@lingui/react/macro'; + +import { CommandGroup } from '@/command-menu/components/CommandGroup'; +import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { CommandMenuObjectPickerItem } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuObjectPickerItem'; +import { CommandMenuSubViewWithSearch } from '@/command-menu/components/CommandMenuSubViewWithSearch'; +import { useFilteredPickerItems } from '@/command-menu/hooks/useFilteredPickerItems'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +type CommandMenuSystemObjectPickerSubViewProps = { + systemObjects: ObjectMetadataItem[]; + searchValue: string; + onSearchChange: (value: string) => void; + onBack: () => void; + isViewItem: boolean; + onSelectObjectForViewEdit?: (objectMetadataItem: ObjectMetadataItem) => void; + onChangeObject: ( + objectMetadataItem: ObjectMetadataItem, + defaultViewId: string, + ) => void; + objectMenuItemVariant?: 'add' | 'edit'; + emptyNoResultsText?: string; +}; + +export const CommandMenuSystemObjectPickerSubView = ({ + systemObjects, + searchValue, + onSearchChange, + onBack, + isViewItem, + onSelectObjectForViewEdit, + onChangeObject, + objectMenuItemVariant = 'edit', + emptyNoResultsText, +}: CommandMenuSystemObjectPickerSubViewProps) => { + const { t } = useLingui(); + const { filteredItems, selectableItemIds, isEmpty, hasSearchQuery } = + useFilteredPickerItems({ + items: systemObjects, + searchQuery: searchValue, + getSearchableValues: (item) => [item.labelPlural], + }); + + const noResultsText = hasSearchQuery + ? t`No results found` + : (emptyNoResultsText ?? t`No system objects available`); + + return ( + + + + {filteredItems.map((objectMetadataItem) => ( + + ))} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useFolderPickerSelectionData.ts b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useFolderPickerSelectionData.ts new file mode 100644 index 0000000000000..47b521dfe358b --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useFolderPickerSelectionData.ts @@ -0,0 +1,102 @@ +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { useNavigationMenuItemEditFolderData } from '@/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditFolderData'; +import { useNavigationMenuItemEditSubView } from '@/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditSubView'; +import { useSelectedNavigationMenuItemEditData } from '@/command-menu/pages/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditData'; +import { useNavigationMenuItemMoveRemove } from '@/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; +import { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; + +type FolderOption = { + id: string; + name: string; + folderId?: string; +}; + +const getDescendantFolderIds = ( + folderId: string, + allFolders: FolderOption[], +): Set => { + const result = new Set(); + for (const folder of allFolders) { + if (folder.folderId !== folderId) continue; + result.add(folder.id); + getDescendantFolderIds(folder.id, allFolders).forEach((id) => + result.add(id), + ); + } + return result; +}; + +const excludeCurrentFolder = ( + folders: T[], + currentFolderId: string | null, +): T[] => + !isDefined(currentFolderId) + ? folders + : folders.filter((folder) => folder.id !== currentFolderId); + +export const useFolderPickerSelectionData = () => { + const { closeCommandMenu } = useCommandMenu(); + const { clearSubView } = useNavigationMenuItemEditSubView(); + const { moveToFolder } = useNavigationMenuItemMoveRemove(); + const selectedNavigationMenuItemInEditMode = useRecoilValue( + selectedNavigationMenuItemInEditModeState, + ); + const { selectedItem, selectedItemType } = + useSelectedNavigationMenuItemEditData(); + const { allFolders, workspaceFolders } = + useNavigationMenuItemEditFolderData(); + + const selectedFolderId = + selectedItemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER + ? selectedNavigationMenuItemInEditMode + : null; + const currentFolderId = + selectedItemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER + ? (selectedItem?.id ?? null) + : (selectedItem?.folderId ?? null); + + const descendantFolderIds = + selectedItemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER && + isDefined(selectedFolderId) + ? getDescendantFolderIds(selectedFolderId, allFolders) + : new Set(); + + const includeNoFolderOption = + (selectedItemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER && + isDefined(selectedFolderId)) || + (selectedItemType === NAVIGATION_MENU_ITEM_TYPE.LINK && + isDefined(currentFolderId)); + + const folders = + includeNoFolderOption && + selectedItemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER && + isDefined(selectedFolderId) + ? allFolders.filter( + (folder) => + folder.id !== selectedFolderId && + !descendantFolderIds.has(folder.id), + ) + : excludeCurrentFolder(allFolders, currentFolderId); + + const foldersToShow = includeNoFolderOption + ? folders + : excludeCurrentFolder(workspaceFolders, currentFolderId); + + const handleSelectFolder = (folderId: string | null) => { + if (isDefined(selectedNavigationMenuItemInEditMode)) { + moveToFolder(selectedNavigationMenuItemInEditMode, folderId); + clearSubView(); + closeCommandMenu(); + } + }; + + return { + foldersToShow, + includeNoFolderOption, + handleSelectFolder, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditFolderData.ts b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditFolderData.ts new file mode 100644 index 0000000000000..dc731c1239281 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditFolderData.ts @@ -0,0 +1,37 @@ +import { useRecoilValue } from 'recoil'; + +import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder'; +import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; + +export const useNavigationMenuItemEditFolderData = () => { + const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState(); + const navigationMenuItemsDraft = useRecoilValue( + navigationMenuItemsDraftState, + ); + const { workspaceNavigationMenuItemsByFolder } = + useNavigationMenuItemsByFolder(); + + const currentDraft = navigationMenuItemsDraft ?? workspaceNavigationMenuItems; + + const workspaceFolders = workspaceNavigationMenuItemsByFolder.map( + (folder) => ({ + id: folder.id, + name: folder.folderName, + }), + ); + + const allFolders = + currentDraft?.filter(isNavigationMenuItemFolder).map((item) => ({ + id: item.id, + name: item.name ?? 'Folder', + folderId: item.folderId ?? undefined, + })) ?? []; + + return { + allFolders, + workspaceFolders, + currentDraft, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditOrganizeActions.ts b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditOrganizeActions.ts new file mode 100644 index 0000000000000..9e929fe50f992 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditOrganizeActions.ts @@ -0,0 +1,138 @@ +import { useLingui } from '@lingui/react/macro'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { IconPlus } from 'twenty-ui/display'; + +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { type OrganizeActionsProps } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuEditOrganizeActions'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { useNavigationMenuItemMoveRemove } from '@/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove'; +import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState'; +import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; +import { addMenuItemInsertionContextState } from '@/navigation-menu-item/states/addMenuItemInsertionContextState'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; +import { type AddMenuItemInsertionContext } from '@/navigation-menu-item/types/AddMenuItemInsertionContext'; + +const getAddMenuItemInsertionContext = ( + selectedItem: { id: string; folderId?: string | null }, + workspaceNavigationMenuItems: Array<{ + id: string; + folderId?: string | null; + userWorkspaceId?: string | null; + }>, + offset: 0 | 1, +): AddMenuItemInsertionContext | null => { + const targetFolderId = selectedItem.folderId ?? null; + const itemsInFolder = workspaceNavigationMenuItems.filter( + (item) => + (item.folderId ?? null) === targetFolderId && + !isDefined(item.userWorkspaceId), + ); + const selectedIndexInFolder = itemsInFolder.findIndex( + (item) => item.id === selectedItem.id, + ); + + if (selectedIndexInFolder < 0) { + return null; + } + + return { + targetFolderId, + targetIndex: selectedIndexInFolder + offset, + }; +}; + +export const useNavigationMenuItemEditOrganizeActions = + (): OrganizeActionsProps => { + const { t } = useLingui(); + const { closeCommandMenu } = useCommandMenu(); + const { navigateCommandMenu } = useNavigateCommandMenu(); + const selectedNavigationMenuItemInEditMode = useRecoilValue( + selectedNavigationMenuItemInEditModeState, + ); + const setSelectedNavigationMenuItemInEditMode = useSetRecoilState( + selectedNavigationMenuItemInEditModeState, + ); + const setAddMenuItemInsertionContext = useSetRecoilState( + addMenuItemInsertionContextState, + ); + const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState(); + const items = useWorkspaceSectionItems(); + const { moveUp, moveDown, remove } = useNavigationMenuItemMoveRemove(); + + const selectedItem = selectedNavigationMenuItemInEditMode + ? items.find((item) => item.id === selectedNavigationMenuItemInEditMode) + : undefined; + + const folderId = selectedItem?.folderId ?? null; + const siblings = items + .filter( + (item) => + ('folderId' in item ? (item.folderId ?? null) : null) === folderId, + ) + .sort((a, b) => a.position - b.position); + const selectedIndexInSiblings = selectedItem + ? siblings.findIndex((item) => item.id === selectedItem.id) + : -1; + + const canMoveUp = + selectedIndexInSiblings > 0 && + selectedItem != null && + isDefined(selectedNavigationMenuItemInEditMode); + const canMoveDown = + selectedIndexInSiblings >= 0 && + selectedIndexInSiblings < siblings.length - 1 && + selectedItem != null && + isDefined(selectedNavigationMenuItemInEditMode); + + const handleMoveUp = () => { + if (canMoveUp && isDefined(selectedNavigationMenuItemInEditMode)) { + moveUp(selectedNavigationMenuItemInEditMode); + } + }; + + const handleMoveDown = () => { + if (canMoveDown && isDefined(selectedNavigationMenuItemInEditMode)) { + moveDown(selectedNavigationMenuItemInEditMode); + } + }; + + const handleRemove = () => { + if (isDefined(selectedNavigationMenuItemInEditMode)) { + remove(selectedNavigationMenuItemInEditMode); + setSelectedNavigationMenuItemInEditMode(null); + closeCommandMenu(); + } + }; + + const handleAddAtOffset = (offset: 0 | 1) => { + if (!isDefined(selectedItem)) return; + const context = getAddMenuItemInsertionContext( + selectedItem, + workspaceNavigationMenuItems, + offset, + ); + if (!context) return; + setAddMenuItemInsertionContext(context); + navigateCommandMenu({ + page: CommandMenuPages.NavigationMenuAddItem, + pageTitle: t`New sidebar item`, + pageIcon: IconPlus, + resetNavigationStack: true, + }); + }; + + const handleAddBefore = () => handleAddAtOffset(0); + const handleAddAfter = () => handleAddAtOffset(1); + + return { + canMoveUp, + canMoveDown, + onMoveUp: handleMoveUp, + onMoveDown: handleMoveDown, + onRemove: handleRemove, + onAddBefore: handleAddBefore, + onAddAfter: handleAddAfter, + }; + }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditSubView.ts b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditSubView.ts new file mode 100644 index 0000000000000..440ae63e210ad --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useNavigationMenuItemEditSubView.ts @@ -0,0 +1,16 @@ +import { useState } from 'react'; + +export type EditSubView = 'folder-picker' | null; + +export const useNavigationMenuItemEditSubView = () => { + const [editSubView, setEditSubView] = useState(null); + + const setFolderPicker = () => setEditSubView('folder-picker'); + const clearSubView = () => setEditSubView(null); + + return { + editSubView, + setFolderPicker, + clearSubView, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditData.ts b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditData.ts new file mode 100644 index 0000000000000..45a6520d8801c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditData.ts @@ -0,0 +1,52 @@ +import { useRecoilValue } from 'recoil'; + +import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; +import { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; +import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; + +export const useSelectedNavigationMenuItemEditData = () => { + const selectedNavigationMenuItemInEditMode = useRecoilValue( + selectedNavigationMenuItemInEditModeState, + ); + const items = useWorkspaceSectionItems(); + const coreViews = useRecoilValue(coreViewsState); + const views = coreViews.map(convertCoreViewToView); + const { objectMetadataItems } = useObjectMetadataItems(); + + const selectedItem = selectedNavigationMenuItemInEditMode + ? items.find((item) => item.id === selectedNavigationMenuItemInEditMode) + : undefined; + + const selectedItemType = selectedItem?.itemType ?? null; + const selectedItemObjectMetadata = selectedItem + ? getObjectMetadataForNavigationMenuItem( + selectedItem, + objectMetadataItems, + views, + ) + : null; + const selectedItemLabel = selectedItem + ? selectedItemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER + ? (selectedItem.name ?? 'Folder') + : selectedItemType === NAVIGATION_MENU_ITEM_TYPE.LINK + ? (selectedItem.name ?? 'Link') + : (selectedItemObjectMetadata?.labelPlural ?? '') + : null; + + const processedItem = + selectedItem && selectedItem.itemType !== NAVIGATION_MENU_ITEM_TYPE.FOLDER + ? selectedItem + : undefined; + + return { + selectedItem, + selectedItemType, + selectedItemObjectMetadata, + selectedItemLabel, + processedItem, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds.ts b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds.ts new file mode 100644 index 0000000000000..b1f26f0a8472e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/navigation-menu-item/utils/getOrganizeActionsSelectableItemIds.ts @@ -0,0 +1,10 @@ +export const getOrganizeActionsSelectableItemIds = ( + includeMoveToFolder: boolean, +) => [ + 'move-up', + 'move-down', + ...(includeMoveToFolder ? ['move-to-folder'] : []), + 'add-before', + 'add-after', + 'remove', +]; diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts index 1af134d3f783f..f48559eece180 100644 --- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts +++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts @@ -24,4 +24,6 @@ export enum CommandMenuPages { PageLayoutFieldsSettings = 'page-layout-fields-settings', PageLayoutFieldsLayout = 'page-layout-fields-layout', ViewFrontComponent = 'view-front-component', + NavigationMenuItemEdit = 'navigation-menu-item-edit', + NavigationMenuAddItem = 'navigation-menu-add-item', } diff --git a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavoritesFolders.tsx b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavoritesFolders.tsx index 8e728a4525d37..5a69f05f656da 100644 --- a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavoritesFolders.tsx +++ b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavoritesFolders.tsx @@ -1,6 +1,5 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CurrentWorkspaceMemberOrphanFavorites } from '@/favorites/components/CurrentWorkspaceMemberOrphanFavorites'; -import { FavoritesDragProvider } from '@/favorites/components/FavoritesDragProvider'; import { FavoriteFolders } from '@/favorites/components/FavoritesFolders'; import { FavoritesSkeletonLoader } from '@/favorites/components/FavoritesSkeletonLoader'; import { useFavorites } from '@/favorites/hooks/useFavorites'; @@ -69,10 +68,10 @@ export const CurrentWorkspaceMemberFavoritesFolders = () => { /> {isNavigationSectionOpen && ( - + <> - + )} ); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/AddToNavigationDragHandle.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/AddToNavigationDragHandle.tsx new file mode 100644 index 0000000000000..7dac8787dcd9d --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/AddToNavigationDragHandle.tsx @@ -0,0 +1,100 @@ +import { css, useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import type { ReactNode } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { IconGripVertical, type IconComponent } from 'twenty-ui/display'; + +import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer'; +import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload'; +import { getIconBackgroundColorForPayload } from '@/navigation-menu-item/utils/getIconBackgroundColorForPayload'; + +const StyledIconSlot = styled.div<{ $hasFixedSize: boolean }>` + align-items: center; + cursor: grab; + display: flex; + flex-shrink: 0; + justify-content: center; + + ${({ theme, $hasFixedSize }) => + $hasFixedSize && + css` + height: ${theme.spacing(4.5)}; + width: ${theme.spacing(4.5)}; + `} + + &:active { + cursor: grabbing; + } +`; + +type AddToNavigationDragHandleIconProps = { + icon?: IconComponent; + customIconContent?: ReactNode; +}; + +const AddToNavigationDragHandleIcon = ({ + icon, + customIconContent, +}: AddToNavigationDragHandleIconProps) => { + const theme = useTheme(); + const iconSize = theme.icon.size.md; + const iconStroke = theme.icon.stroke.sm; + + if (isDefined(customIconContent)) { + return <>{customIconContent}; + } + + if (isDefined(icon)) { + const Icon = icon; + return ( + + ); + } +}; + +type AddToNavigationDragHandleProps = { + icon?: IconComponent; + customIconContent?: ReactNode; + payload: AddToNavigationDragPayload; + isHovered: boolean; +}; + +export const AddToNavigationDragHandle = ({ + icon, + customIconContent, + payload, + isHovered, +}: AddToNavigationDragHandleProps) => { + const theme = useTheme(); + const iconBackgroundColor = getIconBackgroundColorForPayload(payload, theme); + const hasBackgroundColor = !!iconBackgroundColor && !isHovered; + const payloadHasBackgroundColor = !!iconBackgroundColor; + const iconSize = theme.icon.size.md; + const iconStroke = theme.icon.stroke.sm; + + return ( + + {isHovered ? ( + + ) : hasBackgroundColor ? ( + + + + ) : ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders.tsx index a3f2053aaa54c..ae99e7f48c22a 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders.tsx @@ -6,7 +6,6 @@ import { LightIconButton } from 'twenty-ui/input'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CurrentWorkspaceMemberOrphanNavigationMenuItems } from '@/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems'; -import { NavigationMenuItemDragProvider } from '@/navigation-menu-item/components/NavigationMenuItemDragProvider'; import { NavigationMenuItemFolders } from '@/navigation-menu-item/components/NavigationMenuItemFolders'; import { NavigationMenuItemSkeletonLoader } from '@/navigation-menu-item/components/NavigationMenuItemSkeletonLoader'; import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder'; @@ -21,7 +20,7 @@ import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/us export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const { navigationMenuItemsSorted } = useSortedNavigationMenuItems(); - const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder(); + const { userNavigationMenuItemsByFolder } = useNavigationMenuItemsByFolder(); const [ isNavigationMenuItemFolderCreating, @@ -51,7 +50,8 @@ export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => { if ( (!navigationMenuItemsSorted || navigationMenuItemsSorted.length === 0) && !isNavigationMenuItemFolderCreating && - (!navigationMenuItemsByFolder || navigationMenuItemsByFolder.length === 0) + (!userNavigationMenuItemsByFolder || + userNavigationMenuItemsByFolder.length === 0) ) { return null; } @@ -72,12 +72,12 @@ export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => { /> {isNavigationSectionOpen && ( - + <> - + )} ); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx index 22d2d52d8fdd3..a90b5104de481 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx @@ -1,14 +1,21 @@ +import { useTheme } from '@emotion/react'; import { Droppable } from '@hello-pangea/dnd'; import { useLingui } from '@lingui/react/macro'; import { useContext, useState } from 'react'; + +import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection'; +import { NAVIGATION_SECTIONS } from '@/navigation-menu-item/constants/NavigationSections.constants'; import { createPortal } from 'react-dom'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { IconFolder, IconFolderOpen, IconHeartOff } from 'twenty-ui/display'; + +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; import { LightIconButton } from 'twenty-ui/input'; import { AnimatedExpandableContainer } from 'twenty-ui/layout'; import { useIsMobile } from 'twenty-ui/utilities'; +import { NavigationItemDropTarget } from '@/navigation-menu-item/components/NavigationItemDropTarget'; import { NavigationMenuItemDroppable } from '@/navigation-menu-item/components/NavigationMenuItemDroppable'; import { NavigationMenuItemFolderNavigationDrawerItemDropdown } from '@/navigation-menu-item/components/NavigationMenuItemFolderNavigationDrawerItemDropdown'; import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon'; @@ -35,24 +42,32 @@ import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/compo import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState'; import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; type CurrentWorkspaceMemberNavigationMenuItemsProps = { folder: { - folderId: string; + id: string; folderName: string; navigationMenuItems: ProcessedNavigationMenuItem[]; }; isGroup: boolean; + isWorkspaceFolder?: boolean; }; export const CurrentWorkspaceMemberNavigationMenuItems = ({ folder, isGroup, + isWorkspaceFolder = false, }: CurrentWorkspaceMemberNavigationMenuItemsProps) => { const { t } = useLingui(); + const theme = useTheme(); + const iconColors = getNavigationMenuItemIconColors(theme); const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - const currentPath = useLocation().pathname; - const currentViewPath = useLocation().pathname + useLocation().search; + const location = useLocation(); + const navigate = useNavigate(); + const currentPath = location.pathname; + const currentViewPath = location.pathname + location.search; const { isDragging } = useContext(NavigationMenuItemDragContext); const [ isNavigationMenuItemFolderRenaming, @@ -71,22 +86,29 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({ currentNavigationMenuItemFolderIdState, ); - const isOpen = openNavigationMenuItemFolderIds.includes(folder.folderId); + const isOpen = openNavigationMenuItemFolderIds.includes(folder.id); const handleToggle = () => { if (isMobile) { - setCurrentFolderId((prev) => - prev === folder.folderId ? null : folder.folderId, - ); + setCurrentFolderId((prev) => (prev === folder.id ? null : folder.id)); } else { setOpenNavigationMenuItemFolderIds((currentOpenFolders) => { if (isOpen) { - return currentOpenFolders.filter((id) => id !== folder.folderId); + return currentOpenFolders.filter((id) => id !== folder.id); } else { - return [...currentOpenFolders, folder.folderId]; + return [...currentOpenFolders, folder.id]; } }); } + + if (!isOpen) { + const firstNonLinkItem = folder.navigationMenuItems.find( + (item) => item.itemType !== 'link' && isNonEmptyString(item.link), + ); + if (isDefined(firstNonLinkItem?.link)) { + navigate(firstNonLinkItem.link); + } + } }; const { renameNavigationMenuItemFolder } = @@ -94,7 +116,7 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({ const { deleteNavigationMenuItemFolder } = useDeleteNavigationMenuItemFolder(); - const dropdownId = `navigation-menu-item-folder-edit-${folder.folderId}`; + const dropdownId = `navigation-menu-item-folder-edit-${folder.id}`; const isDropdownOpenComponent = useRecoilComponentValue( isDropdownOpenComponentState, @@ -109,13 +131,15 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({ ); const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem(); + const folderContentDropDisabled = + useIsDropDisabledForSection(isWorkspaceFolder); const navigationMenuItemFolderContentLength = folder.navigationMenuItems.length; const handleSubmitRename = async (value: string) => { if (value === '') return; - await renameNavigationMenuItemFolder(folder.folderId, value); + await renameNavigationMenuItemFolder(folder.id, value); setIsNavigationMenuItemFolderRenaming(false); return true; }; @@ -134,29 +158,29 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({ return; } - await renameNavigationMenuItemFolder(folder.folderId, value); + await renameNavigationMenuItemFolder(folder.id, value); setIsNavigationMenuItemFolderRenaming(false); }; - const modalId = `${NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID}-${folder.folderId}`; + const modalId = `${NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID}-${folder.id}`; const handleNavigationMenuItemFolderDelete = async () => { if (folder.navigationMenuItems.length > 0) { openModal(modalId); closeDropdown(dropdownId); } else { - await deleteNavigationMenuItemFolder(folder.folderId); + await deleteNavigationMenuItemFolder(folder.id); closeDropdown(dropdownId); } }; const handleConfirmDelete = async () => { - await deleteNavigationMenuItemFolder(folder.folderId); + await deleteNavigationMenuItemFolder(folder.id); }; - const rightOptions = ( + const rightOptions = isWorkspaceFolder ? undefined : ( setIsNavigationMenuItemFolderRenaming(true)} onDelete={handleNavigationMenuItemFolderDelete} closeDropdown={() => { @@ -175,7 +199,7 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({ return ( <> {isNavigationMenuItemFolderRenaming ? ( @@ -189,18 +213,30 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({ /> ) : ( - + + + )} @@ -210,7 +246,10 @@ export const CurrentWorkspaceMemberNavigationMenuItems = ({ mode="fit-content" containAnimation > - + {(provided) => (
- deleteNavigationMenuItem(navigationMenuItem.id) - } - accent="tertiary" - /> + isWorkspaceFolder ? undefined : ( + + deleteNavigationMenuItem(navigationMenuItem.id) + } + accent="tertiary" + /> + ) } isDragging={isDragging} triggerEvent="CLICK" diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems.tsx index 4b3e6bb3c7aac..8c35b4a48920d 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems.tsx @@ -5,9 +5,11 @@ import { useRecoilValue } from 'recoil'; import { IconHeartOff } from 'twenty-ui/display'; import { LightIconButton } from 'twenty-ui/input'; +import { NavigationItemDropTarget } from '@/navigation-menu-item/components/NavigationItemDropTarget'; +import { NAVIGATION_SECTIONS } from '@/navigation-menu-item/constants/NavigationSections.constants'; import { NavigationMenuItemDroppable } from '@/navigation-menu-item/components/NavigationMenuItemDroppable'; import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon'; -import { ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; +import { NAVIGATION_MENU_ITEM_DROPPABLE_IDS } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem'; import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems'; @@ -39,51 +41,67 @@ export const CurrentWorkspaceMemberOrphanNavigationMenuItems = () => { return ( {orphanNavigationMenuItems.length > 0 ? ( - orphanNavigationMenuItems.map((navigationMenuItem, index) => ( - - ( - - )} - active={isLocationMatchingNavigationMenuItem( - currentPath, - currentViewPath, - navigationMenuItem, - )} - to={isDragging ? undefined : navigationMenuItem.link} - rightOptions={ - - deleteNavigationMenuItem(navigationMenuItem.id) + <> + {orphanNavigationMenuItems.map((navigationMenuItem, index) => ( + + + ( + + )} + active={isLocationMatchingNavigationMenuItem( + currentPath, + currentViewPath, + navigationMenuItem, + )} + to={isDragging ? undefined : navigationMenuItem.link} + rightOptions={ + + deleteNavigationMenuItem(navigationMenuItem.id) + } + accent="tertiary" + /> } - accent="tertiary" + isDragging={isDragging} + triggerEvent="CLICK" /> - } - isDragging={isDragging} - triggerEvent="CLICK" - /> - - } + + } + /> + + ))} + - )) + ) : ( )} diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/IconWithBackground.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/IconWithBackground.tsx new file mode 100644 index 0000000000000..112c4d829aa49 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/IconWithBackground.tsx @@ -0,0 +1,29 @@ +import { useTheme } from '@emotion/react'; +import type { IconComponent } from 'twenty-ui/display'; + +import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer'; + +type IconWithBackgroundProps = { + Icon: IconComponent; + backgroundColor: string; + size?: number | string; + stroke?: number | string; +}; + +export const IconWithBackground = ({ + Icon, + backgroundColor, + size, + stroke, +}: IconWithBackgroundProps) => { + const theme = useTheme(); + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationItemDropTarget.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationItemDropTarget.tsx new file mode 100644 index 0000000000000..908a30ea1c37f --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationItemDropTarget.tsx @@ -0,0 +1,85 @@ +import styled from '@emotion/styled'; +import { type ReactNode, useContext } from 'react'; + +import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext'; +import type { NavigationSectionId } from '@/navigation-menu-item/types/NavigationSectionId'; + +const StyledDropTarget = styled.div<{ + $isDragOver: boolean; + $isDropForbidden: boolean; + $compact?: boolean; +}>` + min-height: ${({ theme, $compact }) => ($compact ? 0 : theme.spacing(2))}; + position: relative; + transition: all 150ms ease-in-out; + + ${({ $isDragOver, theme }) => + $isDragOver && + ` + background-color: ${theme.background.transparent.blue}; + + &::before { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + background-color: ${theme.color.blue}; + border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0; + } + `} + + ${({ $isDropForbidden, theme }) => + $isDropForbidden && + ` + background-color: ${theme.background.transparent.danger}; + cursor: not-allowed; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 2px; + background-color: ${theme.color.red}; + border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0; + } + `} +`; + +export type NavigationItemDropTargetSectionId = NavigationSectionId; + +type NavigationItemDropTargetProps = { + folderId: string | null; + index: number; + sectionId: NavigationItemDropTargetSectionId; + children?: ReactNode; + compact?: boolean; +}; + +export const NavigationItemDropTarget = ({ + folderId, + index, + sectionId, + children, + compact = false, +}: NavigationItemDropTargetProps) => { + const { activeDropTargetId, forbiddenDropTargetId } = useContext( + NavigationDropTargetContext, + ); + const dropTargetId = `${sectionId}-${folderId ?? 'orphan'}-${index}`; + const isDragOver = activeDropTargetId === dropTargetId; + const isDropForbidden = forbiddenDropTargetId === dropTargetId; + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuEditModeBar.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuEditModeBar.tsx new file mode 100644 index 0000000000000..314b5b1395921 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuEditModeBar.tsx @@ -0,0 +1,110 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { IconCheck, useIcons } from 'twenty-ui/display'; + +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState'; +import { useSaveNavigationMenuItemsDraft } from '@/navigation-menu-item/hooks/useSaveNavigationMenuItemsDraft'; +import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; + +const StyledContainer = styled.div` + align-items: center; + background: ${({ theme }) => theme.color.blue}; + box-sizing: border-box; + color: ${({ theme }) => theme.font.color.inverted}; + display: flex; + justify-content: space-between; + padding: ${({ theme }) => theme.spacing(2, 3)}; + width: 100%; +`; + +const StyledTitle = styled.span` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +export const NavigationMenuEditModeBar = () => { + const theme = useTheme(); + const { t } = useLingui(); + const { getIcon } = useIcons(); + const [isSaving, setIsSaving] = useState(false); + const { closeCommandMenu } = useCommandMenu(); + const { enqueueErrorSnackBar } = useSnackBar(); + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + const setSelectedNavigationMenuItemInEditMode = useSetRecoilState( + selectedNavigationMenuItemInEditModeState, + ); + const setIsNavigationMenuInEditMode = useSetRecoilState( + isNavigationMenuInEditModeState, + ); + const { saveDraft } = useSaveNavigationMenuItemsDraft(); + const { isDirty } = useNavigationMenuItemsDraftState(); + + const cancelEditMode = () => { + setNavigationMenuItemsDraft(null); + setSelectedNavigationMenuItemInEditMode(null); + setIsNavigationMenuInEditMode(false); + }; + + const isNavigationMenuInEditMode = useRecoilValue( + isNavigationMenuInEditModeState, + ); + const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED, + ); + + const showNavigationMenuEditModeBar = + isNavigationMenuItemEditingEnabled && isNavigationMenuInEditMode; + + const handleSave = async () => { + if (!isDirty) return; + + setIsSaving(true); + try { + await saveDraft(); + cancelEditMode(); + closeCommandMenu(); + } catch { + enqueueErrorSnackBar({ + message: t`Failed to save navigation layout`, + }); + } finally { + setIsSaving(false); + } + }; + + const IconPaint = getIcon('IconPaint'); + + if (!showNavigationMenuEditModeBar) { + return null; + } + + return ( + + + + {t`Layout customization`} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemDroppable.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemDroppable.tsx index 8201e86121fca..8576f7831d9e6 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemDroppable.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemDroppable.tsx @@ -1,11 +1,14 @@ import styled from '@emotion/styled'; import { Droppable } from '@hello-pangea/dnd'; +import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection'; + type NavigationMenuItemDroppableProps = { droppableId: string; children: React.ReactNode; isDragIndicatorVisible?: boolean; showDropLine?: boolean; + isWorkspaceSection?: boolean; }; const StyledDroppableWrapper = styled.div<{ @@ -46,9 +49,12 @@ export const NavigationMenuItemDroppable = ({ children, isDragIndicatorVisible = true, showDropLine = true, + isWorkspaceSection = false, }: NavigationMenuItemDroppableProps) => { + const isDropDisabled = useIsDropDisabledForSection(isWorkspaceSection); + return ( - + {(provided, snapshot) => ( - - - {(provided) => ( -
- {navigationMenuItems.map((navigationMenuItem, index) => ( - ( - - )} - rightOptions={ - - deleteNavigationMenuItem(navigationMenuItem.id) - } - accent="tertiary" - /> - } - triggerEvent="CLICK" - to={navigationMenuItem.link} - /> - } - /> - ))} - {provided.placeholder} -
- )} -
-
+ + {(provided) => ( +
+ {navigationMenuItems.map((navigationMenuItem, index) => ( + ( + + )} + rightOptions={ + + deleteNavigationMenuItem(navigationMenuItem.id) + } + accent="tertiary" + /> + } + triggerEvent="CLICK" + to={navigationMenuItem.link} + /> + } + /> + ))} + {provided.placeholder} +
+ )} +
); }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolders.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolders.tsx index fc7f30faf1284..84f09a2afdca1 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolders.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolders.tsx @@ -18,7 +18,7 @@ export const NavigationMenuItemFolders = ({ }: NavigationMenuItemFoldersProps) => { const [newFolderName, setNewFolderName] = useState(''); - const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder(); + const { userNavigationMenuItemsByFolder } = useNavigationMenuItemsByFolder(); const { createNewNavigationMenuItemFolder } = useCreateNavigationMenuItemFolder(); @@ -79,11 +79,11 @@ export const NavigationMenuItemFolders = ({ /> )} - {navigationMenuItemsByFolder.map((folder) => ( + {userNavigationMenuItemsByFolder.map((folder) => ( 1} + isGroup={userNavigationMenuItemsByFolder.length > 1} /> ))} diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemIcon.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemIcon.tsx index 9315c3c3a4639..204f3a071fc01 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemIcon.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemIcon.tsx @@ -1,8 +1,11 @@ import { useTheme } from '@emotion/react'; import { Avatar, useIcons } from 'twenty-ui/display'; +import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon'; +import { ViewKey } from '@/views/types/ViewKey'; export const NavigationMenuItemIcon = ({ navigationMenuItem, @@ -17,13 +20,32 @@ export const NavigationMenuItemIcon = ({ const IconToUse = StandardIcon || (navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined); - const iconColorToUse = StandardIcon ? IconColor : theme.font.color.secondary; const placeholderColorSeed = navigationMenuItem.targetRecordId ?? undefined; - return ( + const isRecord = navigationMenuItem.itemType === 'record'; + const isLink = navigationMenuItem.itemType === 'link'; + const iconColors = getNavigationMenuItemIconColors(theme); + const isObjectIndexView = + navigationMenuItem.itemType === 'view' && + navigationMenuItem.viewKey === ViewKey.Index; + const iconBackgroundColor = isRecord + ? undefined + : isLink + ? iconColors.link + : navigationMenuItem.itemType === 'view' && !isObjectIndexView + ? iconColors.view + : iconColors.object; + + const iconColorToUse = iconBackgroundColor + ? theme.grayScale.gray1 + : StandardIcon + ? IconColor + : theme.font.color.secondary; + + const avatar = ( ); + + if (!iconBackgroundColor) { + return avatar; + } + + return ( + + {avatar} + + ); }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemIconContainer.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemIconContainer.tsx new file mode 100644 index 0000000000000..af565eacc9ee4 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemIconContainer.tsx @@ -0,0 +1,16 @@ +import styled from '@emotion/styled'; + +export const StyledNavigationMenuItemIconContainer = styled.div<{ + $backgroundColor?: string; +}>` + align-items: center; + border-radius: ${({ theme }) => theme.border.radius.xs}; + display: flex; + flex-shrink: 0; + height: ${({ theme }) => theme.spacing(4.5)}; + justify-content: center; + width: ${({ theme }) => theme.spacing(4.5)}; + + ${({ $backgroundColor }) => + $backgroundColor ? `background-color: ${$backgroundColor};` : ''} +`; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemFolderDragClone.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemFolderDragClone.tsx new file mode 100644 index 0000000000000..b37745214ac09 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemFolderDragClone.tsx @@ -0,0 +1,83 @@ +import { useTheme } from '@emotion/react'; +import { + type DraggableProvided, + type DraggableRubric, + type DraggableStateSnapshot, +} from '@hello-pangea/dnd'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon'; +import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel'; +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; +import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment'; +import { ViewKey } from '@/views/types/ViewKey'; + +type WorkspaceNavigationMenuItemFolderDragCloneProps = { + draggableProvided: DraggableProvided; + draggableSnapshot: DraggableStateSnapshot; + rubric: DraggableRubric; + navigationMenuItems: ProcessedNavigationMenuItem[]; + navigationMenuItemFolderContentLength: number; + selectedNavigationMenuItemIndex: number; +}; + +export const WorkspaceNavigationMenuItemFolderDragClone = ({ + draggableProvided, + draggableSnapshot, + rubric, + navigationMenuItems, + navigationMenuItemFolderContentLength, + selectedNavigationMenuItemIndex, +}: WorkspaceNavigationMenuItemFolderDragCloneProps) => { + const theme = useTheme(); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const navigationMenuItem = navigationMenuItems[rubric.source.index]; + + if (!isDefined(navigationMenuItem)) { + return null; + } + + return ( +
+ ( + + )} + to={undefined} + active={false} + isDragging={true} + subItemState={getNavigationSubItemLeftAdornment({ + index: rubric.source.index, + arrayLength: navigationMenuItemFolderContentLength, + selectedIndex: selectedNavigationMenuItemIndex, + })} + triggerEvent="CLICK" + /> +
+ ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx index 6705ca20d8787..3e82852ec4553 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx @@ -1,26 +1,188 @@ +import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; +import { + useRecoilCallback, + useRecoilState, + useRecoilValue, + useSetRecoilState, +} from 'recoil'; +import { + IconFolder, + IconLink, + IconPlus, + IconTool, + useIcons, +} from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; -import { useWorkspaceNavigationMenuItems } from '@/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems'; -import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems'; +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { useOpenNavigationMenuItemInCommandMenu } from '@/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu'; +import { + type NavigationMenuItemClickParams, + useWorkspaceSectionItems, +} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; +import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState'; +import { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems'; +import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader'; +import { NavigationDrawerSectionForWorkspaceItems } from '@/object-metadata/components/NavigationDrawerSectionForWorkspaceItems'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; +import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { isDefined } from 'twenty-shared/utils'; + +const StyledRightIconsContainer = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; export const WorkspaceNavigationMenuItems = () => { - const { workspaceNavigationMenuItemsObjectMetadataItems } = - useWorkspaceNavigationMenuItems(); + const items = useWorkspaceSectionItems(); + const enterEditMode = useRecoilCallback( + ({ set, snapshot }) => + () => { + const prefetchNavigationMenuItems = snapshot + .getLoadable(prefetchNavigationMenuItemsState) + .getValue(); + const workspaceNavigationMenuItems = filterWorkspaceNavigationMenuItems( + prefetchNavigationMenuItems, + ); + set(navigationMenuItemsDraftState, workspaceNavigationMenuItems); + set(isNavigationMenuInEditModeState, true); + }, + [], + ); + const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED, + ); + const isNavigationMenuInEditMode = useRecoilValue( + isNavigationMenuInEditModeState, + ); + const [ + selectedNavigationMenuItemInEditMode, + setSelectedNavigationMenuItemInEditMode, + ] = useRecoilState(selectedNavigationMenuItemInEditModeState); + const setOpenNavigationMenuItemFolderIds = useSetRecoilState( + openNavigationMenuItemFolderIdsState, + ); + const { navigateCommandMenu } = useNavigateCommandMenu(); + const { openNavigationMenuItemInCommandMenu } = + useOpenNavigationMenuItemInCommandMenu(); + const { getIcon } = useIcons(); const loading = useIsPrefetchLoading(); const { t } = useLingui(); + const handleEditClick = (event: React.MouseEvent) => { + event.stopPropagation(); + enterEditMode(); + }; + + const handleNavigationMenuItemClick = ( + params: NavigationMenuItemClickParams, + ) => { + const { item, objectMetadataItem } = params; + const id = item.id; + setSelectedNavigationMenuItemInEditMode(id); + if (item.itemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER) { + setOpenNavigationMenuItemFolderIds((currentOpenFolders) => + currentOpenFolders.includes(id) + ? currentOpenFolders + : [...currentOpenFolders, id], + ); + openNavigationMenuItemInCommandMenu({ + pageTitle: t`Edit folder`, + pageIcon: IconFolder, + }); + } else if (item.itemType === NAVIGATION_MENU_ITEM_TYPE.LINK) { + openNavigationMenuItemInCommandMenu({ + pageTitle: t`Edit link`, + pageIcon: IconLink, + }); + } else if (isDefined(objectMetadataItem)) { + openNavigationMenuItemInCommandMenu({ + pageTitle: objectMetadataItem.labelPlural, + pageIcon: getIcon(objectMetadataItem.icon), + }); + } + }; + + const handleActiveObjectMetadataItemClick = ( + objectMetadataItem: ObjectMetadataItem, + navigationMenuItemId: string, + ) => { + enterEditMode(); + setSelectedNavigationMenuItemInEditMode(navigationMenuItemId); + openNavigationMenuItemInCommandMenu({ + pageTitle: objectMetadataItem.labelPlural, + pageIcon: getIcon(objectMetadataItem.icon), + }); + }; + + const handleAddMenuItem = (event?: React.MouseEvent) => { + event?.stopPropagation(); + navigateCommandMenu({ + page: CommandMenuPages.NavigationMenuAddItem, + pageTitle: t`New sidebar item`, + pageIcon: IconPlus, + resetNavigationStack: true, + }); + }; + + const isEditMode = + isNavigationMenuItemEditingEnabled && isNavigationMenuInEditMode; + if (loading) { return ; } return ( - + {isEditMode ? ( + + ) : ( + + )} + + ) : undefined + } + onAddMenuItem={ + isNavigationMenuItemEditingEnabled && isEditMode + ? handleAddMenuItem + : undefined + } + isEditMode={isEditMode} + selectedNavigationMenuItemId={selectedNavigationMenuItemInEditMode} + onNavigationMenuItemClick={ + isEditMode ? handleNavigationMenuItemClick : undefined + } + onActiveObjectMetadataItemClick={ + isNavigationMenuItemEditingEnabled + ? handleActiveObjectMetadataItemClick + : undefined + } /> ); }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder.tsx new file mode 100644 index 0000000000000..083edbde7dec8 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder.tsx @@ -0,0 +1,272 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { Droppable } from '@hello-pangea/dnd'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { IconFolder, IconFolderOpen } from 'twenty-ui/display'; +import { AnimatedExpandableContainer } from 'twenty-ui/layout'; +import { useIsMobile } from 'twenty-ui/utilities'; + +import { NavigationMenuItemDroppable } from '@/navigation-menu-item/components/NavigationMenuItemDroppable'; +import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon'; +import { WorkspaceNavigationMenuItemFolderDragClone } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItemFolderDragClone'; +import { NAVIGATION_MENU_ITEM_DROPPABLE_IDS } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; +import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; +import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; +import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel'; +import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem'; +import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem'; +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; +import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer'; +import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; +import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState'; +import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { ViewKey } from '@/views/types/ViewKey'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; + +const StyledFolderContainer = styled.div<{ $isSelectedInEditMode: boolean }>` + border: ${({ theme, $isSelectedInEditMode }) => + $isSelectedInEditMode + ? `1px solid ${theme.color.blue}` + : '1px solid transparent'}; + border-radius: ${({ theme }) => theme.border.radius.sm}; +`; + +const StyledFolderDroppableContent = styled.div<{ + $compact: boolean; +}>` + padding-bottom: ${({ theme, $compact }) => ($compact ? 0 : theme.spacing(2))}; +`; + +const StyledFolderExpandableWrapper = styled.div` + & > div { + overflow: visible !important; + } +`; + +type WorkspaceNavigationMenuItemsFolderProps = { + folderId: string; + folderName: string; + navigationMenuItems: ProcessedNavigationMenuItem[]; + isGroup: boolean; + isEditMode?: boolean; + isSelectedInEditMode?: boolean; + onEditModeClick?: () => void; + onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void; + selectedNavigationMenuItemId?: string | null; + isDragging?: boolean; +}; + +export const WorkspaceNavigationMenuItemsFolder = ({ + folderId, + folderName, + navigationMenuItems, + isGroup, + isEditMode = false, + isSelectedInEditMode = false, + onEditModeClick, + onNavigationMenuItemClick, + selectedNavigationMenuItemId = null, + isDragging = false, +}: WorkspaceNavigationMenuItemsFolderProps) => { + const theme = useTheme(); + const iconColors = getNavigationMenuItemIconColors(theme); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const coreViews = useRecoilValue(coreViewsState); + const views = coreViews.map(convertCoreViewToView); + const location = useLocation(); + const navigate = useNavigate(); + const currentPath = location.pathname; + const currentViewPath = location.pathname + location.search; + const isMobile = useIsMobile(); + + const [openNavigationMenuItemFolderIds, setOpenNavigationMenuItemFolderIds] = + useRecoilState(openNavigationMenuItemFolderIdsState); + + const setCurrentFolderId = useSetRecoilState( + currentNavigationMenuItemFolderIdState, + ); + + const isOpen = openNavigationMenuItemFolderIds.includes(folderId); + + const handleToggle = () => { + if (isMobile) { + setCurrentFolderId((prev) => (prev === folderId ? null : folderId)); + } else { + setOpenNavigationMenuItemFolderIds((current) => + isOpen + ? current.filter((id) => id !== folderId) + : [...current, folderId], + ); + } + + if (!isOpen) { + const firstNonLinkItem = navigationMenuItems.find( + (item) => item.itemType !== 'link' && isNonEmptyString(item.link), + ); + if (isDefined(firstNonLinkItem?.link)) { + navigate(firstNonLinkItem.link); + } + } + }; + + const shouldUseEditModeClick = isEditMode && isDefined(onEditModeClick); + const handleClick = shouldUseEditModeClick ? onEditModeClick : handleToggle; + + const selectedNavigationMenuItemIndex = navigationMenuItems.findIndex( + (item) => + isLocationMatchingNavigationMenuItem(currentPath, currentViewPath, item), + ); + + const navigationMenuItemFolderContentLength = navigationMenuItems.length; + const { isDragging: isContextDragging } = useContext( + NavigationMenuItemDragContext, + ); + const folderContentDropDisabled = useIsDropDisabledForSection(true); + + return ( + + + + + + + + + ( + + )} + getContainerForClone={() => document.body} + > + {(provided) => ( + + {navigationMenuItems.map((navigationMenuItem, index) => { + const objectMetadataItem = + navigationMenuItem.itemType === 'view' || + navigationMenuItem.itemType === 'record' + ? getObjectMetadataForNavigationMenuItem( + navigationMenuItem, + objectMetadataItems, + views, + ) + : null; + const handleEditModeClick = + isEditMode && + isDefined(onNavigationMenuItemClick) && + (navigationMenuItem.itemType === 'link' || + isDefined(objectMetadataItem)) + ? () => + onNavigationMenuItemClick({ + item: navigationMenuItem, + objectMetadataItem: + objectMetadataItem ?? undefined, + }) + : undefined; + + return ( + ( + + )} + to={ + isContextDragging || handleEditModeClick + ? undefined + : navigationMenuItem.link + } + onClick={handleEditModeClick} + active={index === selectedNavigationMenuItemIndex} + isSelectedInEditMode={ + selectedNavigationMenuItemId === + navigationMenuItem.id + } + subItemState={getNavigationSubItemLeftAdornment({ + index, + arrayLength: + navigationMenuItemFolderContentLength, + selectedIndex: selectedNavigationMenuItemIndex, + })} + isDragging={isContextDragging} + triggerEvent="CLICK" + /> + } + /> + ); + })} + {provided.placeholder} + + )} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/constants/AddToNavSourceDroppableId.ts b/packages/twenty-front/src/modules/navigation-menu-item/constants/AddToNavSourceDroppableId.ts new file mode 100644 index 0000000000000..885949cc32762 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/constants/AddToNavSourceDroppableId.ts @@ -0,0 +1 @@ +export const ADD_TO_NAV_SOURCE_DROPPABLE_ID = 'add-to-nav-source'; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationMenuItemDroppableIds.ts b/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationMenuItemDroppableIds.ts index 024fc34b23ef0..16d1824b44e46 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationMenuItemDroppableIds.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationMenuItemDroppableIds.ts @@ -1,2 +1,7 @@ -export const ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID = - 'orphan-navigation-menu-items'; +export const NAVIGATION_MENU_ITEM_DROPPABLE_IDS = { + ORPHAN_NAVIGATION_MENU_ITEMS: 'orphan-navigation-menu-items', + WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS: + 'workspace-orphan-navigation-menu-items', + WORKSPACE_FOLDER_PREFIX: 'workspace-folder-', + WORKSPACE_FOLDER_HEADER_PREFIX: 'workspace-folder-header-', +} as const; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationSections.constants.ts b/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationSections.constants.ts new file mode 100644 index 0000000000000..d072a986a0710 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationSections.constants.ts @@ -0,0 +1,4 @@ +export const NAVIGATION_SECTIONS = { + WORKSPACE: 'workspace', + FAVORITES: 'favorites', +} as const; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationDragSourceContext.tsx b/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationDragSourceContext.tsx new file mode 100644 index 0000000000000..28e4a5b87daf1 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationDragSourceContext.tsx @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +type NavigationDragSourceContextType = { + sourceDroppableId: string | null; +}; + +export const NavigationDragSourceContext = + createContext({ + sourceDroppableId: null, + }); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationDropTargetContext.tsx b/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationDropTargetContext.tsx new file mode 100644 index 0000000000000..9f12488d0c5ab --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationDropTargetContext.tsx @@ -0,0 +1,16 @@ +import { createContext } from 'react'; + +type NavigationDropTargetContextType = { + activeDropTargetId: string | null; + setActiveDropTargetId: (id: string | null) => void; + forbiddenDropTargetId: string | null; + setForbiddenDropTargetId: (id: string | null) => void; +}; + +export const NavigationDropTargetContext = + createContext({ + activeDropTargetId: null, + setActiveDropTargetId: () => {}, + forbiddenDropTargetId: null, + setForbiddenDropTargetId: () => {}, + }); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/graphql/fragments/navigationMenuItemFragment.ts b/packages/twenty-front/src/modules/navigation-menu-item/graphql/fragments/navigationMenuItemFragment.ts index b3a7dcf7ff8a8..885682d35b691 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/graphql/fragments/navigationMenuItemFragment.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/graphql/fragments/navigationMenuItemFragment.ts @@ -9,6 +9,7 @@ export const NAVIGATION_MENU_ITEM_FRAGMENT = gql` viewId folderId name + link position applicationId createdAt diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddFolderToNavigationMenuDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddFolderToNavigationMenuDraft.ts new file mode 100644 index 0000000000000..33f51893eb9e5 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddFolderToNavigationMenuDraft.ts @@ -0,0 +1,61 @@ +import { useSetRecoilState } from 'recoil'; +import { v4 } from 'uuid'; +import { isDefined } from 'twenty-shared/utils'; +import type { NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { computeInsertIndexAndPosition } from '@/navigation-menu-item/utils/add-to-navigation-draft.utils'; + +export const useAddFolderToNavigationMenuDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + + const addFolderToDraft = ( + name: string, + currentDraft: NavigationMenuItem[], + targetFolderId?: string | null, + targetIndex?: number, + ): string => { + const folderId = targetFolderId ?? null; + + const itemsInFolder = currentDraft.filter( + (item) => + (item.folderId ?? null) === folderId && + !isDefined(item.userWorkspaceId), + ); + const index = targetIndex ?? itemsInFolder.length; + + const { flatIndex, position } = computeInsertIndexAndPosition( + currentDraft, + folderId, + index, + ); + + const newItemId = v4(); + const newItem: NavigationMenuItem = { + __typename: 'NavigationMenuItem', + id: newItemId, + viewId: undefined, + targetObjectMetadataId: undefined, + targetRecordId: undefined, + folderId: folderId ?? undefined, + position, + userWorkspaceId: undefined, + name: name.trim(), + applicationId: undefined, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const newDraft = [ + ...currentDraft.slice(0, flatIndex), + newItem, + ...currentDraft.slice(flatIndex), + ]; + setNavigationMenuItemsDraft(newDraft); + return newItemId; + }; + + return { addFolderToDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddLinkToNavigationMenuDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddLinkToNavigationMenuDraft.ts new file mode 100644 index 0000000000000..70ae5a5933297 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddLinkToNavigationMenuDraft.ts @@ -0,0 +1,67 @@ +import { useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; +import type { NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { + computeInsertIndexAndPosition, + normalizeUrl, +} from '@/navigation-menu-item/utils/add-to-navigation-draft.utils'; + +export const useAddLinkToNavigationMenuDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + + const addLinkToDraft = ( + label: string, + url: string, + currentDraft: NavigationMenuItem[], + targetFolderId?: string | null, + targetIndex?: number, + ): string => { + const normalizedUrl = normalizeUrl(url); + const folderId = targetFolderId ?? null; + + const itemsInFolder = currentDraft.filter( + (item) => + (item.folderId ?? null) === folderId && + !isDefined(item.userWorkspaceId), + ); + const index = targetIndex ?? itemsInFolder.length; + + const { flatIndex, position } = computeInsertIndexAndPosition( + currentDraft, + folderId, + index, + ); + + const newItemId = v4(); + const newItem: NavigationMenuItem = { + __typename: 'NavigationMenuItem', + id: newItemId, + viewId: undefined, + targetObjectMetadataId: undefined, + targetRecordId: undefined, + folderId: folderId ?? undefined, + position, + userWorkspaceId: undefined, + name: label.trim() || 'Link', + link: normalizedUrl, + applicationId: undefined, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const newDraft = [ + ...currentDraft.slice(0, flatIndex), + newItem, + ...currentDraft.slice(flatIndex), + ]; + setNavigationMenuItemsDraft(newDraft); + return newItemId; + }; + + return { addLinkToDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddObjectToNavigationMenuDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddObjectToNavigationMenuDraft.ts new file mode 100644 index 0000000000000..160c58c1747f8 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddObjectToNavigationMenuDraft.ts @@ -0,0 +1,62 @@ +import { useSetRecoilState } from 'recoil'; +import { v4 } from 'uuid'; +import { isDefined } from 'twenty-shared/utils'; +import type { NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { computeInsertIndexAndPosition } from '@/navigation-menu-item/utils/add-to-navigation-draft.utils'; + +export const useAddObjectToNavigationMenuDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + + const addObjectToDraft = ( + objectMetadataId: string, + defaultViewId: string, + currentDraft: NavigationMenuItem[], + targetFolderId?: string | null, + targetIndex?: number, + ): string => { + const folderId = targetFolderId ?? null; + + const itemsInFolder = currentDraft.filter( + (item) => + (item.folderId ?? null) === folderId && + !isDefined(item.userWorkspaceId), + ); + const index = targetIndex ?? itemsInFolder.length; + + const { flatIndex, position } = computeInsertIndexAndPosition( + currentDraft, + folderId, + index, + ); + + const newItemId = v4(); + const newItem: NavigationMenuItem = { + __typename: 'NavigationMenuItem', + id: newItemId, + viewId: defaultViewId, + targetObjectMetadataId: objectMetadataId, + position, + userWorkspaceId: undefined, + targetRecordId: undefined, + folderId: folderId ?? undefined, + name: undefined, + applicationId: undefined, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const newDraft = [ + ...currentDraft.slice(0, flatIndex), + newItem, + ...currentDraft.slice(flatIndex), + ]; + setNavigationMenuItemsDraft(newDraft); + return newItemId; + }; + + return { addObjectToDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddRecordToNavigationMenuDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddRecordToNavigationMenuDraft.ts new file mode 100644 index 0000000000000..94cd11b3050c3 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddRecordToNavigationMenuDraft.ts @@ -0,0 +1,89 @@ +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { v4 } from 'uuid'; +import { isDefined } from 'twenty-shared/utils'; +import type { NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { computeInsertIndexAndPosition } from '@/navigation-menu-item/utils/add-to-navigation-draft.utils'; + +type SearchRecord = { + recordId: string; + objectNameSingular: string; + label: string; + imageUrl?: string | null; +}; + +type SearchRecordWithOptionalMetadataId = SearchRecord & { + objectMetadataId?: string; +}; + +export const useAddRecordToNavigationMenuDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const addRecordToDraft = ( + searchRecord: SearchRecordWithOptionalMetadataId, + currentDraft: NavigationMenuItem[], + targetFolderId?: string | null, + targetIndex?: number, + ): string | undefined => { + const objectMetadataId = + searchRecord.objectMetadataId ?? + objectMetadataItems.find( + (item) => item.nameSingular === searchRecord.objectNameSingular, + )?.id; + + if (!isDefined(objectMetadataId)) { + return undefined; + } + + const folderId = targetFolderId ?? null; + + const itemsInFolder = currentDraft.filter( + (item) => + (item.folderId ?? null) === folderId && + !isDefined(item.userWorkspaceId), + ); + const index = targetIndex ?? itemsInFolder.length; + + const { flatIndex, position } = computeInsertIndexAndPosition( + currentDraft, + folderId, + index, + ); + + const newItemId = v4(); + const newItem: NavigationMenuItem = { + __typename: 'NavigationMenuItem', + id: newItemId, + viewId: undefined, + targetObjectMetadataId: objectMetadataId, + targetRecordId: searchRecord.recordId, + targetRecordIdentifier: { + id: searchRecord.recordId, + labelIdentifier: searchRecord.label, + imageIdentifier: searchRecord.imageUrl ?? null, + }, + position, + userWorkspaceId: undefined, + folderId: folderId ?? undefined, + name: undefined, + applicationId: undefined, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const newDraft = [ + ...currentDraft.slice(0, flatIndex), + newItem, + ...currentDraft.slice(flatIndex), + ]; + setNavigationMenuItemsDraft(newDraft); + return newItemId; + }; + + return { addRecordToDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddViewToNavigationMenuDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddViewToNavigationMenuDraft.ts new file mode 100644 index 0000000000000..9830657f26f97 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useAddViewToNavigationMenuDraft.ts @@ -0,0 +1,61 @@ +import { useSetRecoilState } from 'recoil'; +import { v4 } from 'uuid'; +import { isDefined } from 'twenty-shared/utils'; +import type { NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { computeInsertIndexAndPosition } from '@/navigation-menu-item/utils/add-to-navigation-draft.utils'; + +export const useAddViewToNavigationMenuDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + + const addViewToDraft = ( + viewId: string, + currentDraft: NavigationMenuItem[], + targetFolderId?: string | null, + targetIndex?: number, + ): string => { + const folderId = targetFolderId ?? null; + + const itemsInFolder = currentDraft.filter( + (item) => + (item.folderId ?? null) === folderId && + !isDefined(item.userWorkspaceId), + ); + const index = targetIndex ?? itemsInFolder.length; + + const { flatIndex, position } = computeInsertIndexAndPosition( + currentDraft, + folderId, + index, + ); + + const newItemId = v4(); + const newItem: NavigationMenuItem = { + __typename: 'NavigationMenuItem', + id: newItemId, + viewId, + targetObjectMetadataId: undefined, + position, + userWorkspaceId: undefined, + targetRecordId: undefined, + folderId: folderId ?? undefined, + name: undefined, + applicationId: undefined, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const newDraft = [ + ...currentDraft.slice(0, flatIndex), + newItem, + ...currentDraft.slice(flatIndex), + ]; + setNavigationMenuItemsDraft(newDraft); + return newItemId; + }; + + return { addViewToDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleAddToNavigationDrop.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleAddToNavigationDrop.ts new file mode 100644 index 0000000000000..e152c2c460d4b --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleAddToNavigationDrop.ts @@ -0,0 +1,209 @@ +import type { DropResult, ResponderProvided } from '@hello-pangea/dnd'; +import { t } from '@lingui/core/macro'; +import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { IconFolder, IconLink, useIcons } from 'twenty-ui/display'; + +import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId'; +import { useAddFolderToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddFolderToNavigationMenuDraft'; +import { useAddLinkToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddLinkToNavigationMenuDraft'; +import { useAddObjectToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddObjectToNavigationMenuDraft'; +import { useAddRecordToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddRecordToNavigationMenuDraft'; +import { useAddViewToNavigationMenuDraft } from '@/navigation-menu-item/hooks/useAddViewToNavigationMenuDraft'; +import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState'; +import { useOpenNavigationMenuItemInCommandMenu } from '@/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu'; +import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState'; +import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState'; +import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState'; +import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId'; +import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; + +export const useHandleAddToNavigationDrop = () => { + const { addObjectToDraft } = useAddObjectToNavigationMenuDraft(); + const { addViewToDraft } = useAddViewToNavigationMenuDraft(); + const { addRecordToDraft } = useAddRecordToNavigationMenuDraft(); + const { addFolderToDraft } = useAddFolderToNavigationMenuDraft(); + const { addLinkToDraft } = useAddLinkToNavigationMenuDraft(); + const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState(); + const navigationMenuItemsDraft = useRecoilValue( + navigationMenuItemsDraftState, + ); + const { openNavigationMenuItemInCommandMenu } = + useOpenNavigationMenuItemInCommandMenu(); + const { objectMetadataItems } = useObjectMetadataItems(); + const coreViews = useRecoilValue(coreViewsState); + const { getIcon } = useIcons(); + const setSelectedNavigationMenuItemInEditMode = useSetRecoilState( + selectedNavigationMenuItemInEditModeState, + ); + const setIsNavigationMenuInEditMode = useSetRecoilState( + isNavigationMenuInEditModeState, + ); + const setOpenNavigationMenuItemFolderIds = useSetRecoilState( + openNavigationMenuItemFolderIdsState, + ); + + const handleAddToNavigationDrop = useRecoilCallback( + ({ snapshot }) => + (result: DropResult, _provided: ResponderProvided) => { + const { source, destination, draggableId } = result; + if ( + source.droppableId !== ADD_TO_NAV_SOURCE_DROPPABLE_ID || + !destination || + !isWorkspaceDroppableId(destination.droppableId) + ) { + return; + } + + const payload = + getSnapshotValue(snapshot, addToNavPayloadRegistryState).get( + draggableId, + ) ?? null; + if (!payload) { + return; + } + + const currentDraft = isDefined(navigationMenuItemsDraft) + ? navigationMenuItemsDraft + : workspaceNavigationMenuItems; + const folderId = validateAndExtractWorkspaceFolderId( + destination.droppableId, + ); + const index = destination.index; + + if (payload.type === 'folder' && folderId !== null) { + return; + } + + if (isDefined(folderId)) { + setOpenNavigationMenuItemFolderIds((current) => + current.includes(folderId) ? current : [...current, folderId], + ); + } + + const openEditForNewNavItem = ( + newItemId: string, + options: Parameters[0], + ) => { + setIsNavigationMenuInEditMode(true); + setSelectedNavigationMenuItemInEditMode(newItemId); + openNavigationMenuItemInCommandMenu(options); + }; + + switch (payload.type) { + case 'folder': { + const newFolderId = addFolderToDraft( + payload.name, + currentDraft, + null, + index, + ); + openEditForNewNavItem(newFolderId, { + pageTitle: t`Edit folder`, + pageIcon: IconFolder, + focusTitleInput: true, + }); + return; + } + case 'link': { + const newLinkId = addLinkToDraft( + payload.name || t`Link label`, + payload.link, + currentDraft, + folderId, + index, + ); + openEditForNewNavItem(newLinkId, { + pageTitle: t`Edit link`, + pageIcon: IconLink, + focusTitleInput: true, + }); + return; + } + case 'object': { + const newItemId = addObjectToDraft( + payload.objectMetadataId, + payload.defaultViewId, + currentDraft, + folderId, + index, + ); + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === payload.objectMetadataId, + ); + openEditForNewNavItem(newItemId, { + pageTitle: objectMetadataItem?.labelPlural ?? payload.label, + pageIcon: objectMetadataItem + ? getIcon(objectMetadataItem.icon) + : IconFolder, + }); + return; + } + case 'view': { + const newItemId = addViewToDraft( + payload.viewId, + currentDraft, + folderId, + index, + ); + const views = coreViews.map(convertCoreViewToView); + const view = views.find((v) => v.id === payload.viewId); + openEditForNewNavItem(newItemId, { + pageTitle: view?.name ?? payload.label, + pageIcon: view ? getIcon(view.icon) : IconFolder, + }); + return; + } + case 'record': { + const newItemId = addRecordToDraft( + { + recordId: payload.recordId, + objectMetadataId: payload.objectMetadataId, + objectNameSingular: payload.objectNameSingular, + label: payload.label, + imageUrl: payload.imageUrl, + }, + currentDraft, + folderId, + index, + ); + if (!isDefined(newItemId)) return; + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === payload.objectMetadataId, + ); + openEditForNewNavItem(newItemId, { + pageTitle: payload.label, + pageIcon: objectMetadataItem + ? getIcon(objectMetadataItem.icon) + : IconFolder, + }); + return; + } + } + }, + [ + addFolderToDraft, + addLinkToDraft, + addObjectToDraft, + addRecordToDraft, + addViewToDraft, + coreViews, + getIcon, + navigationMenuItemsDraft, + objectMetadataItems, + openNavigationMenuItemInCommandMenu, + setOpenNavigationMenuItemFolderIds, + setIsNavigationMenuInEditMode, + setSelectedNavigationMenuItemInEditMode, + workspaceNavigationMenuItems, + ], + ); + + return { handleAddToNavigationDrop }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop.ts index c87d2a99b80f3..3f8a567687f8d 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop.ts @@ -1,7 +1,8 @@ import { type OnDragEndResponder } from '@hello-pangea/dnd'; import { useSetRecoilState } from 'recoil'; -import { ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; +import { NAVIGATION_MENU_ITEM_DROPPABLE_IDS } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; +import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId'; import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems'; import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItem'; import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState'; @@ -48,6 +49,10 @@ export const useHandleNavigationMenuItemDragAndDrop = () => { return; } + if (isWorkspaceDroppableId(destination.droppableId)) { + return; + } + const draggedNavigationMenuItem = navigationMenuItems.find( (item) => item.id === draggableId, ); @@ -57,11 +62,13 @@ export const useHandleNavigationMenuItemDragAndDrop = () => { const destinationFolderId = validateAndExtractFolderId({ droppableId: destination.droppableId, - orphanDroppableId: ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID, + orphanDroppableId: + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.ORPHAN_NAVIGATION_MENU_ITEMS, }); const sourceFolderId = validateAndExtractFolderId({ droppableId: source.droppableId, - orphanDroppableId: ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID, + orphanDroppableId: + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.ORPHAN_NAVIGATION_MENU_ITEMS, }); if ( diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop.ts new file mode 100644 index 0000000000000..64e14d11f7d3f --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop.ts @@ -0,0 +1,177 @@ +import { type OnDragEndResponder } from '@hello-pangea/dnd'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { NAVIGATION_MENU_ITEM_DROPPABLE_IDS } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; +import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState'; +import { + matchesWorkspaceFolderId, + validateAndExtractWorkspaceFolderId, +} from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId'; + +import { isDefined } from 'twenty-shared/utils'; +import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData'; + +export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => { + const { workspaceNavigationMenuItems } = + usePrefetchedNavigationMenuItemsData(); + const isNavigationMenuInEditMode = useRecoilValue( + isNavigationMenuInEditModeState, + ); + const navigationMenuItemsDraft = useRecoilValue( + navigationMenuItemsDraftState, + ); + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + const setOpenNavigationMenuItemFolderIds = useSetRecoilState( + openNavigationMenuItemFolderIdsState, + ); + + const openDestinationFolder = (folderId: string | null) => { + if (!folderId) { + return; + } + + setOpenNavigationMenuItemFolderIds((current) => { + if (!current.includes(folderId)) { + return [...current, folderId]; + } + return current; + }); + }; + + const handleWorkspaceNavigationMenuItemDragAndDrop: OnDragEndResponder = ( + result, + ) => { + const { destination, source, draggableId } = result; + + if (!destination) { + return; + } + + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) { + return; + } + + const isWorkspaceDrop = + source.droppableId.startsWith('workspace-') && + destination.droppableId.startsWith('workspace-'); + + if (!isWorkspaceDrop) { + return; + } + + if (!isNavigationMenuInEditMode || !navigationMenuItemsDraft) { + return; + } + + const draggedItem = workspaceNavigationMenuItems.find( + (item) => item.id === draggableId, + ); + + if (!draggedItem) { + return; + } + + const destinationFolderId = validateAndExtractWorkspaceFolderId( + destination.droppableId, + ); + const sourceFolderId = validateAndExtractWorkspaceFolderId( + source.droppableId, + ); + + const isDropOnFolderHeader = destination.droppableId.startsWith( + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_FOLDER_HEADER_PREFIX, + ); + + if (isDropOnFolderHeader && isDefined(destinationFolderId)) { + openDestinationFolder(destinationFolderId); + } + + const sourceList = (navigationMenuItemsDraft ?? []) + .filter((item) => matchesWorkspaceFolderId(item, sourceFolderId)) + .sort((a, b) => a.position - b.position); + + const destinationList = (navigationMenuItemsDraft ?? []) + .filter((item) => matchesWorkspaceFolderId(item, destinationFolderId)) + .sort((a, b) => a.position - b.position); + + if (!sourceList.some((item) => item.id === draggableId)) { + return; + } + + const isSameList = sourceFolderId === destinationFolderId; + let reorderedDestinationList: NavigationMenuItem[]; + + if (isSameList) { + const listWithoutDragged = sourceList.filter( + (item) => item.id !== draggableId, + ); + reorderedDestinationList = [ + ...listWithoutDragged.slice(0, destination.index), + draggedItem, + ...listWithoutDragged.slice(destination.index), + ]; + } else { + const destinationListWithInsertedItem = [ + ...destinationList.slice(0, destination.index), + { ...draggedItem, folderId: destinationFolderId }, + ...destinationList.slice(destination.index), + ]; + reorderedDestinationList = destinationListWithInsertedItem; + } + + const destinationWithNormalizedPositions = reorderedDestinationList.map( + (item, index) => ({ + ...item, + position: index, + folderId: isSameList ? item.folderId : destinationFolderId, + }), + ); + + const positionUpdates = new Map< + string, + { position: number; folderId: string | null } + >(); + destinationWithNormalizedPositions.forEach((item) => { + positionUpdates.set(item.id, { + position: item.position, + folderId: item.folderId ?? null, + }); + }); + + if (!isSameList) { + const sourceListWithoutDragged = sourceList.filter( + (item) => item.id !== draggableId, + ); + sourceListWithoutDragged.forEach((item, index) => { + positionUpdates.set(item.id, { + position: index, + folderId: sourceFolderId, + }); + }); + } + + const updatedDraft = navigationMenuItemsDraft.map( + (item): NavigationMenuItem => { + const update = positionUpdates.get(item.id); + if (!update) return item; + return { + ...item, + position: update.position, + folderId: update.folderId, + }; + }, + ); + + setNavigationMenuItemsDraft(updatedDraft); + }; + + return { handleWorkspaceNavigationMenuItemDragAndDrop }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useIsDropDisabledForSection.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useIsDropDisabledForSection.ts new file mode 100644 index 0000000000000..ffb69c514b2b4 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useIsDropDisabledForSection.ts @@ -0,0 +1,16 @@ +import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId'; +import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext'; +import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId'; +import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const useIsDropDisabledForSection = (isWorkspaceSection: boolean) => { + const { sourceDroppableId } = useContext(NavigationDragSourceContext); + if (!isDefined(sourceDroppableId)) { + return false; + } + if (sourceDroppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) { + return !isWorkspaceSection; + } + return isWorkspaceDroppableId(sourceDroppableId) !== isWorkspaceSection; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove.ts new file mode 100644 index 0000000000000..da5935db33acc --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemMoveRemove.ts @@ -0,0 +1,159 @@ +import { useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import type { NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; + +const swapPositionsInDraft = ( + draft: NavigationMenuItem[], + itemA: NavigationMenuItem, + itemB: NavigationMenuItem, +): NavigationMenuItem[] => + draft.map((item) => { + if (item.id === itemA.id) { + return { ...item, position: itemB.position }; + } + if (item.id === itemB.id) { + return { ...item, position: itemA.position }; + } + return item; + }); + +export const useNavigationMenuItemMoveRemove = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + + const moveUp = (navigationMenuItemId: string) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + const currentItem = draft.find( + (item) => item.id === navigationMenuItemId, + ); + if (!currentItem) return draft; + + const folderId = currentItem.folderId ?? null; + const siblings = draft + .filter((item) => (item.folderId ?? null) === folderId) + .sort((a, b) => a.position - b.position); + + const currentIndex = siblings.findIndex( + (item) => item.id === navigationMenuItemId, + ); + if (currentIndex <= 0) return draft; + + const itemAbove = siblings[currentIndex - 1]; + return swapPositionsInDraft(draft, currentItem, itemAbove); + }); + }; + + const moveDown = (navigationMenuItemId: string) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + const currentItem = draft.find( + (item) => item.id === navigationMenuItemId, + ); + if (!currentItem) return draft; + + const folderId = currentItem.folderId ?? null; + const siblings = draft + .filter((item) => (item.folderId ?? null) === folderId) + .sort((a, b) => a.position - b.position); + + const currentIndex = siblings.findIndex( + (item) => item.id === navigationMenuItemId, + ); + if (currentIndex < 0 || currentIndex >= siblings.length - 1) { + return draft; + } + + const itemBelow = siblings[currentIndex + 1]; + return swapPositionsInDraft(draft, currentItem, itemBelow); + }); + }; + + const remove = (navigationMenuItemId: string) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + const itemToRemove = draft.find( + (item) => item.id === navigationMenuItemId, + ); + if (!itemToRemove) return draft; + + const isFolder = isNavigationMenuItemFolder(itemToRemove); + + if (isFolder) { + return draft.filter( + (item) => + item.id !== navigationMenuItemId && + item.folderId !== navigationMenuItemId, + ); + } + + return draft.filter((item) => item.id !== navigationMenuItemId); + }); + }; + + const moveToFolder = ( + navigationMenuItemId: string, + targetFolderId: string | null, + ) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + const itemToMove = draft.find((item) => item.id === navigationMenuItemId); + if (!itemToMove) return draft; + + const isFolder = isNavigationMenuItemFolder(itemToMove); + if (isFolder && targetFolderId === navigationMenuItemId) { + return draft; + } + + if (isFolder && isDefined(targetFolderId)) { + const descendantFolderIds = new Set(); + const collectDescendants = (folderId: string) => { + draft + ?.filter( + (item) => + isNavigationMenuItemFolder(item) && item.folderId === folderId, + ) + .forEach((item) => { + descendantFolderIds.add(item.id); + collectDescendants(item.id); + }); + }; + collectDescendants(navigationMenuItemId); + if (descendantFolderIds.has(targetFolderId)) { + return draft; + } + } + + const itemsInTargetFolder = draft.filter((item) => + targetFolderId === null + ? !isDefined(item.folderId) + : item.folderId === targetFolderId, + ); + const maxPositionInTarget = + itemsInTargetFolder.length > 0 + ? Math.max(...itemsInTargetFolder.map((item) => item.position)) + : -1; + const newPosition = maxPositionInTarget + 1; + + return draft.map((item) => + item.id === navigationMenuItemId + ? { + ...item, + folderId: targetFolderId ?? undefined, + position: newPosition, + } + : item, + ); + }); + }; + + return { moveUp, moveDown, remove, moveToFolder }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts index 27016674cb29f..4a19c01ae49ed 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts @@ -2,6 +2,7 @@ import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { type NavigationMenuItem } from '~/generated-metadata/graphql'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier'; import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; @@ -12,7 +13,7 @@ import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData'; type NavigationMenuItemFolder = { - folderId: string; + id: string; folderName: string; navigationMenuItems: ReturnType[number][]; }; @@ -27,91 +28,114 @@ export const useNavigationMenuItemsByFolder = () => { const views = coreViews.map(convertCoreViewToView); const allNavigationMenuItems = [ - ...navigationMenuItems, ...workspaceNavigationMenuItems, + ...navigationMenuItems, ]; - const { folders, itemsByFolderId } = allNavigationMenuItems.reduce<{ - folders: Array<{ id: string; name: string }>; - itemsByFolderId: Map; - }>( - (acc, item) => { - const isFolder = - isDefined(item.name) && - !isDefined(item.folderId) && - !isDefined(item.targetRecordId) && - !isDefined(item.targetObjectMetadataId) && - !isDefined(item.viewId); - - if (isFolder) { - acc.folders.push({ id: item.id, name: item.name || 'Folder' }); - } else if (isDefined(item.folderId)) { - const existingItems = acc.itemsByFolderId.get(item.folderId); - if (isDefined(existingItems)) { - existingItems.push(item); - } else { - acc.itemsByFolderId.set(item.folderId, [item]); + const { workspaceFolders, userFolders, itemsByFolderId } = + allNavigationMenuItems.reduce<{ + workspaceFolders: Array<{ id: string; name: string }>; + userFolders: Array<{ id: string; name: string }>; + itemsByFolderId: Map; + }>( + (acc, item) => { + if (isNavigationMenuItemFolder(item)) { + const folderEntry = { id: item.id, name: item.name || 'Folder' }; + if (isDefined(item.userWorkspaceId)) { + acc.userFolders.push(folderEntry); + } else { + acc.workspaceFolders.push(folderEntry); + } + } else if (isDefined(item.folderId)) { + const existingItems = acc.itemsByFolderId.get(item.folderId); + if (isDefined(existingItems)) { + existingItems.push(item); + } else { + acc.itemsByFolderId.set(item.folderId, [item]); + } } - } - return acc; - }, - { folders: [], itemsByFolderId: new Map() }, - ); - - const navigationMenuItemsByFolder = folders.reduce< - NavigationMenuItemFolder[] - >((acc, folder) => { - const itemsInFolder = itemsByFolderId.get(folder.id) || []; - - const targetRecordIdentifiersMap = itemsInFolder.reduce< - Map - >((map, item) => { - const itemTargetRecordId = item.targetRecordId; - if (!isDefined(itemTargetRecordId) || isDefined(item.viewId)) { - return map; - } + return acc; + }, + { + workspaceFolders: [], + userFolders: [], + itemsByFolderId: new Map(), + }, + ); - const targetRecordIdentifier = item.targetRecordIdentifier; + const buildFoldersList = (folders: Array<{ id: string; name: string }>) => { + const sortedFolders = [...folders].sort((a, b) => { + const folderA = allNavigationMenuItems.find((item) => item.id === a.id); + const folderB = allNavigationMenuItems.find((item) => item.id === b.id); + const positionA = folderA?.position ?? 0; + const positionB = folderB?.position ?? 0; + return positionA - positionB; + }); - if (!isDefined(targetRecordIdentifier)) { - return map; - } + return sortedFolders.reduce((acc, folder) => { + const itemsInFolder = itemsByFolderId.get(folder.id) || []; - const itemObjectMetadata = objectMetadataItems.find( - (meta) => meta.id === item.targetObjectMetadataId, - ); + const targetRecordIdentifiersMap = itemsInFolder.reduce< + Map + >((map, item) => { + const itemTargetRecordId = item.targetRecordId; + if (!isDefined(itemTargetRecordId) || isDefined(item.viewId)) { + return map; + } + + const targetRecordIdentifier = item.targetRecordIdentifier; - if (isDefined(itemObjectMetadata)) { - const objectRecordIdentifier = recordIdentifierToObjectRecordIdentifier( - { - recordIdentifier: targetRecordIdentifier, - objectMetadataItem: itemObjectMetadata, - }, + if (!isDefined(targetRecordIdentifier)) { + return map; + } + + const itemObjectMetadata = objectMetadataItems.find( + (meta) => meta.id === item.targetObjectMetadataId, ); - map.set(itemTargetRecordId, objectRecordIdentifier); - } + if (isDefined(itemObjectMetadata)) { + const objectRecordIdentifier = + recordIdentifierToObjectRecordIdentifier({ + recordIdentifier: targetRecordIdentifier, + objectMetadataItem: itemObjectMetadata, + }); - return map; - }, new Map()); + map.set(itemTargetRecordId, objectRecordIdentifier); + } - const sortedItems = sortNavigationMenuItems( - itemsInFolder, - true, - views, - objectMetadataItems, - targetRecordIdentifiersMap, - ); + return map; + }, new Map()); + + const sortedItems = sortNavigationMenuItems( + itemsInFolder, + true, + views, + objectMetadataItems, + targetRecordIdentifiersMap, + ); - acc.push({ - folderId: folder.id, - folderName: folder.name, - navigationMenuItems: sortedItems, - }); + acc.push({ + id: folder.id, + folderName: folder.name, + navigationMenuItems: sortedItems, + }); - return acc; - }, []); + return acc; + }, []); + }; + + const workspaceNavigationMenuItemsByFolder = + buildFoldersList(workspaceFolders); + const userNavigationMenuItemsByFolder = buildFoldersList(userFolders); + const navigationMenuItemsByFolder = [ + ...workspaceNavigationMenuItemsByFolder, + ...userNavigationMenuItemsByFolder, + ]; - return { navigationMenuItemsByFolder }; + return { + navigationMenuItemsByFolder, + workspaceNavigationMenuItemsByFolder, + userNavigationMenuItemsByFolder, + }; }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsDraftState.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsDraftState.ts new file mode 100644 index 0000000000000..ae6fb67d626b9 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsDraftState.ts @@ -0,0 +1,41 @@ +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState'; +import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState'; + +export const useNavigationMenuItemsDraftState = () => { + const isNavigationMenuInEditMode = useRecoilValue( + isNavigationMenuInEditModeState, + ); + const prefetchNavigationMenuItems = useRecoilValue( + prefetchNavigationMenuItemsState, + ); + const navigationMenuItemsDraft = useRecoilValue( + navigationMenuItemsDraftState, + ); + + const workspaceNavigationMenuItemsFromPrefetch = + filterWorkspaceNavigationMenuItems(prefetchNavigationMenuItems); + + const workspaceNavigationMenuItems = + isNavigationMenuInEditMode && isDefined(navigationMenuItemsDraft) + ? navigationMenuItemsDraft + : workspaceNavigationMenuItemsFromPrefetch; + + const isDirty = + isNavigationMenuInEditMode && + isDefined(navigationMenuItemsDraft) && + !isDeeplyEqual( + navigationMenuItemsDraft, + workspaceNavigationMenuItemsFromPrefetch, + ); + + return { + workspaceNavigationMenuItems, + isDirty, + }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuObjectMetadataFromDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuObjectMetadataFromDraft.ts new file mode 100644 index 0000000000000..f2f0d15cc833b --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuObjectMetadataFromDraft.ts @@ -0,0 +1,59 @@ +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +import { coreViewsState } from '@/views/states/coreViewState'; +import { ViewKey } from '@/views/types/ViewKey'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; + +type NavigationMenuItemDraft = { + id?: string; + viewId?: string | null; + targetObjectMetadataId?: string | null; +}; + +export const useNavigationMenuObjectMetadataFromDraft = ( + currentDraft: NavigationMenuItemDraft[], +) => { + const coreViews = useRecoilValue(coreViewsState); + const views = coreViews.map(convertCoreViewToView); + + const objectMetadataIdsInWorkspace = currentDraft.reduce>( + (ids, item) => { + const view = isDefined(item.viewId) + ? views.find((view) => view.id === item.viewId) + : undefined; + if (isDefined(view)) { + ids.add(view.objectMetadataId); + } + if (isDefined(item.targetObjectMetadataId)) { + ids.add(item.targetObjectMetadataId); + } + return ids; + }, + new Set(), + ); + + const objectMetadataIdsWithIndexView = new Set( + views + .filter((view) => view.key === ViewKey.Index) + .map((view) => view.objectMetadataId), + ); + + const objectMetadataIdsWithAnyView = new Set( + views.map((view) => view.objectMetadataId), + ); + + const viewIdsInWorkspace = new Set( + currentDraft.flatMap((item) => + isDefined(item.viewId) ? [item.viewId] : [], + ), + ); + + return { + views, + objectMetadataIdsInWorkspace, + objectMetadataIdsWithIndexView, + objectMetadataIdsWithAnyView, + viewIdsInWorkspace, + }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu.ts new file mode 100644 index 0000000000000..fef054d522565 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu.ts @@ -0,0 +1,29 @@ +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import type { IconComponent } from 'twenty-ui/display'; + +export const useOpenNavigationMenuItemInCommandMenu = () => { + const { navigateCommandMenu } = useNavigateCommandMenu(); + + const openNavigationMenuItemInCommandMenu = ({ + pageTitle, + pageIcon, + focusTitleInput = false, + }: { + pageTitle: string; + pageIcon: IconComponent; + focusTitleInput?: boolean; + }) => { + navigateCommandMenu({ + page: CommandMenuPages.NavigationMenuItemEdit, + pageTitle, + pageIcon, + resetNavigationStack: true, + focusTitleInput, + }); + }; + + return { + openNavigationMenuItemInCommandMenu, + }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData.ts index 4ce8dc91ed792..167f8f6858f6d 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData.ts @@ -1,4 +1,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems'; import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; @@ -17,14 +20,24 @@ export const usePrefetchedNavigationMenuItemsData = const prefetchNavigationMenuItems = useRecoilValue( prefetchNavigationMenuItemsState, ); + const isNavigationMenuInEditMode = useRecoilValue( + isNavigationMenuInEditModeState, + ); + const navigationMenuItemsDraft = useRecoilValue( + navigationMenuItemsDraftState, + ); const navigationMenuItems = prefetchNavigationMenuItems.filter((item) => isDefined(item.userWorkspaceId), ); - const workspaceNavigationMenuItems = prefetchNavigationMenuItems.filter( - (item) => !isDefined(item.userWorkspaceId), - ); + const workspaceNavigationMenuItemsFromPrefetch = + filterWorkspaceNavigationMenuItems(prefetchNavigationMenuItems); + + const workspaceNavigationMenuItems = + isNavigationMenuInEditMode && isDefined(navigationMenuItemsDraft) + ? navigationMenuItemsDraft + : workspaceNavigationMenuItemsFromPrefetch; return { navigationMenuItems, diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSaveNavigationMenuItemsDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSaveNavigationMenuItemsDraft.ts new file mode 100644 index 0000000000000..4bde76119a7ba --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSaveNavigationMenuItemsDraft.ts @@ -0,0 +1,194 @@ +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +import { useCreateNavigationMenuItemMutation } from '~/generated-metadata/graphql'; + +import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem'; +import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItem'; +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; +import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink'; +import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState'; + +export const useSaveNavigationMenuItemsDraft = () => { + const { updateNavigationMenuItem } = useUpdateNavigationMenuItem(); + const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem(); + const [createNavigationMenuItemMutation] = + useCreateNavigationMenuItemMutation({ + refetchQueries: ['FindManyNavigationMenuItems'], + }); + + const saveDraft = useRecoilCallback( + ({ snapshot }) => + async () => { + const draft = snapshot + .getLoadable(navigationMenuItemsDraftState) + .getValue(); + const prefetch = snapshot + .getLoadable(prefetchNavigationMenuItemsState) + .getValue(); + + if (!draft) return; + + const workspacePrefetch = filterWorkspaceNavigationMenuItems(prefetch); + const topLevelWorkspace = workspacePrefetch.filter( + (item) => !isDefined(item.folderId), + ); + const draftIds = new Set(draft.map((i) => i.id)); + + const topLevelToDelete = topLevelWorkspace.filter( + (item) => !draftIds.has(item.id), + ); + const folderIdsToDelete = new Set( + topLevelToDelete + .filter(isNavigationMenuItemFolder) + .map((item) => item.id), + ); + const folderChildrenToDelete = prefetch.filter( + (item) => + isDefined(item.folderId) && folderIdsToDelete.has(item.folderId), + ); + + for (const item of folderChildrenToDelete) { + await deleteNavigationMenuItem(item.id); + } + for (const item of topLevelToDelete) { + await deleteNavigationMenuItem(item.id); + } + + const prefetchIds = new Set(workspacePrefetch.map((i) => i.id)); + const workspacePrefetchById = new Map( + workspacePrefetch.map((i) => [i.id, i]), + ); + const idsToCreate = draft.filter((item) => !prefetchIds.has(item.id)); + const idsToRecreate = draft.filter((item) => { + const original = workspacePrefetchById.get(item.id); + if (!original) return false; + return ( + original.viewId !== item.viewId || + original.targetObjectMetadataId !== item.targetObjectMetadataId || + original.targetRecordId !== item.targetRecordId + ); + }); + + for (const draftItem of idsToRecreate) { + await deleteNavigationMenuItem(draftItem.id); + } + + const idsToCreateIncludingRecreated = [ + ...idsToCreate, + ...idsToRecreate, + ]; + + for (const draftItem of idsToCreateIncludingRecreated) { + const input: { + position: number; + folderId?: string | null; + name?: string; + link?: string; + viewId?: string; + targetObjectMetadataId?: string; + targetRecordId?: string; + } = { + position: Math.max(0, Math.round(draftItem.position)), + }; + + if (isNavigationMenuItemFolder(draftItem)) { + input.name = draftItem.name ?? undefined; + } else if (isNavigationMenuItemLink(draftItem)) { + input.name = draftItem.name ?? 'Link'; + const linkUrl = (draftItem.link ?? '').trim(); + input.link = + linkUrl.startsWith('http://') || linkUrl.startsWith('https://') + ? linkUrl + : linkUrl + ? `https://${linkUrl}` + : undefined; + } else if (isDefined(draftItem.viewId)) { + input.viewId = draftItem.viewId; + } else if (isDefined(draftItem.targetRecordId)) { + input.targetRecordId = draftItem.targetRecordId; + input.targetObjectMetadataId = + draftItem.targetObjectMetadataId ?? undefined; + } + + if (isDefined(draftItem.folderId)) { + input.folderId = draftItem.folderId; + } + + await createNavigationMenuItemMutation({ + variables: { input }, + }); + } + + const idsToRecreateSet = new Set(idsToRecreate.map((i) => i.id)); + for (const draftItem of draft) { + if (idsToRecreateSet.has(draftItem.id)) continue; + + const original = workspacePrefetchById.get(draftItem.id); + if (!original) continue; + + const positionChanged = original.position !== draftItem.position; + const folderIdChanged = + (original.folderId ?? null) !== (draftItem.folderId ?? null); + const nameChanged = + (isNavigationMenuItemFolder(draftItem) || + isNavigationMenuItemLink(draftItem)) && + (original.name ?? null) !== (draftItem.name ?? null); + const linkChanged = + isNavigationMenuItemLink(draftItem) && + (original.link ?? null) !== (draftItem.link ?? null); + + if ( + positionChanged || + folderIdChanged || + nameChanged || + linkChanged + ) { + const updateInput: { + id: string; + position?: number; + folderId?: string | null; + name?: string; + link?: string | null; + } = { id: draftItem.id }; + + if (positionChanged) { + updateInput.position = Math.max( + 0, + Math.round(draftItem.position), + ); + } + if (folderIdChanged) { + updateInput.folderId = draftItem.folderId ?? null; + } + if (nameChanged && isNavigationMenuItemFolder(draftItem)) { + updateInput.name = draftItem.name ?? undefined; + } + if (nameChanged && isNavigationMenuItemLink(draftItem)) { + updateInput.name = draftItem.name ?? undefined; + } + if (linkChanged && isNavigationMenuItemLink(draftItem)) { + const linkUrl = (draftItem.link ?? '').trim(); + updateInput.link = linkUrl + ? linkUrl.startsWith('http://') || + linkUrl.startsWith('https://') + ? linkUrl + : `https://${linkUrl}` + : null; + } + + await updateNavigationMenuItem(updateInput); + } + } + }, + [ + updateNavigationMenuItem, + deleteNavigationMenuItem, + createNavigationMenuItemMutation, + ], + ); + + return { saveDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSortedNavigationMenuItems.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSortedNavigationMenuItems.ts index c2b53db25be6c..db6869c67c93b 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSortedNavigationMenuItems.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSortedNavigationMenuItems.ts @@ -2,6 +2,8 @@ import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; +import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink'; import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier'; import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; @@ -76,6 +78,12 @@ export const useSortedNavigationMenuItems = () => { const workspaceNavigationMenuItemsSorted = useMemo(() => { const filtered = workspaceNavigationMenuItems.filter((item) => { + if (isNavigationMenuItemFolder(item)) { + return true; + } + if (isNavigationMenuItemLink(item)) { + return true; + } if (isDefined(item.viewId)) { return coreViews.some((view) => view.id === item.viewId); } @@ -90,7 +98,7 @@ export const useSortedNavigationMenuItems = () => { }); return sortNavigationMenuItems( filtered, - false, + true, coreViews, objectMetadataItems, targetRecordIdentifiers, diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateFolderNameInDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateFolderNameInDraft.ts new file mode 100644 index 0000000000000..ba3792346201d --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateFolderNameInDraft.ts @@ -0,0 +1,24 @@ +import { useSetRecoilState } from 'recoil'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; + +export const useUpdateFolderNameInDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + + const updateFolderNameInDraft = (folderId: string, name: string) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + return draft.map((item) => + isNavigationMenuItemFolder(item) && item.id === folderId + ? { ...item, name } + : item, + ); + }); + }; + + return { updateFolderNameInDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateLinkInDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateLinkInDraft.ts new file mode 100644 index 0000000000000..494dce2677e7a --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateLinkInDraft.ts @@ -0,0 +1,34 @@ +import { useSetRecoilState } from 'recoil'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink'; + +export const useUpdateLinkInDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + + const updateLinkInDraft = ( + linkId: string, + updates: { name?: string; link?: string }, + ) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + return draft.map((item) => { + if (item.id !== linkId || !isNavigationMenuItemLink(item)) return item; + + const updated = { ...item }; + if (updates.name !== undefined) { + updated.name = updates.name.trim() || 'Link'; + } + if (updates.link !== undefined && updates.link.trim() !== '') { + updated.link = updates.link.trim(); + } + return updated; + }); + }); + }; + + return { updateLinkInDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateViewInDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateViewInDraft.ts new file mode 100644 index 0000000000000..0328fed7ce889 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateViewInDraft.ts @@ -0,0 +1,29 @@ +import { useSetRecoilState } from 'recoil'; + +import { navigationMenuItemsDraftState } from '@/navigation-menu-item/states/navigationMenuItemsDraftState'; +import { type View } from '@/views/types/View'; + +export const useUpdateViewInDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilState( + navigationMenuItemsDraftState, + ); + + const updateViewInDraft = (navigationMenuItemId: string, view: View) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + return draft.map((item) => + item.id === navigationMenuItemId + ? { + ...item, + viewId: view.id, + targetObjectMetadataId: undefined, + targetRecordId: undefined, + } + : item, + ); + }); + }; + + return { updateViewInDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems.ts index 5a494e83a89b5..548bc223ad7a1 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems.ts @@ -1,5 +1,7 @@ +import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { coreViewsState } from '@/views/states/coreViewState'; @@ -7,20 +9,38 @@ import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; import { isDefined } from 'twenty-shared/utils'; import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData'; -import { useSortedNavigationMenuItems } from './useSortedNavigationMenuItems'; export const useWorkspaceNavigationMenuItems = (): { workspaceNavigationMenuItemsObjectMetadataItems: ObjectMetadataItem[]; } => { - const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems(); const { workspaceNavigationMenuItems: rawWorkspaceNavigationMenuItems } = usePrefetchedNavigationMenuItemsData(); const coreViews = useRecoilValue(coreViewsState); const views = coreViews.map(convertCoreViewToView); + const workspaceFolderIds = useMemo( + () => + new Set( + rawWorkspaceNavigationMenuItems + .filter(isNavigationMenuItemFolder) + .map((item) => item.id), + ), + [rawWorkspaceNavigationMenuItems], + ); + + const workspaceNavigationMenuItemsIncludingFolderItems = useMemo( + () => + rawWorkspaceNavigationMenuItems.filter( + (item) => + !isDefined(item.folderId) || + (isDefined(item.folderId) && workspaceFolderIds.has(item.folderId)), + ), + [rawWorkspaceNavigationMenuItems, workspaceFolderIds], + ); + const workspaceNavigationMenuItemViewIds = new Set( - workspaceNavigationMenuItemsSorted + workspaceNavigationMenuItemsIncludingFolderItems .map((item) => item.viewId) .filter((viewId) => isDefined(viewId)), ); @@ -35,7 +55,7 @@ export const useWorkspaceNavigationMenuItems = (): { ); const navigationMenuItemRecordObjectMetadataIds = new Set( - rawWorkspaceNavigationMenuItems + workspaceNavigationMenuItemsIncludingFolderItems .map((item) => item.targetObjectMetadataId) .filter((objectMetadataId) => isDefined(objectMetadataId)), ); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts new file mode 100644 index 0000000000000..abd8ee48e4786 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts @@ -0,0 +1,86 @@ +import { useRecoilValue } from 'recoil'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; +import { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; +import { isDefined } from 'twenty-shared/utils'; + +import { useNavigationMenuItemsByFolder } from './useNavigationMenuItemsByFolder'; +import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData'; +import { useSortedNavigationMenuItems } from './useSortedNavigationMenuItems'; + +export type FlatWorkspaceItem = + | ProcessedNavigationMenuItem + | (NavigationMenuItem & { + itemType: typeof NAVIGATION_MENU_ITEM_TYPE.FOLDER; + }); + +export type NavigationMenuItemClickParams = { + item: FlatWorkspaceItem; + objectMetadataItem?: ObjectMetadataItem | null; +}; + +export const useWorkspaceSectionItems = (): FlatWorkspaceItem[] => { + const { workspaceNavigationMenuItems } = + usePrefetchedNavigationMenuItemsData(); + const { workspaceNavigationMenuItemsSorted } = useSortedNavigationMenuItems(); + const { workspaceNavigationMenuItemsByFolder } = + useNavigationMenuItemsByFolder(); + const coreViews = useRecoilValue(coreViewsState); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const views = coreViews.map(convertCoreViewToView); + + const flatWorkspaceItems = workspaceNavigationMenuItems + .filter((item) => !isDefined(item.folderId)) + .sort((a, b) => a.position - b.position); + + const processedObjectViewsById = new Map( + workspaceNavigationMenuItemsSorted.map((item) => [item.id, item]), + ); + + const folderChildrenById = new Map( + workspaceNavigationMenuItemsByFolder.map((folder) => [ + folder.id, + folder.navigationMenuItems, + ]), + ); + + const flatItems: FlatWorkspaceItem[] = flatWorkspaceItems.reduce< + FlatWorkspaceItem[] + >((acc, item) => { + if (isNavigationMenuItemFolder(item)) { + acc.push({ ...item, itemType: NAVIGATION_MENU_ITEM_TYPE.FOLDER }); + } else { + const processedItem = processedObjectViewsById.get(item.id); + if (!isDefined(processedItem)) { + return acc; + } + if (processedItem.itemType === NAVIGATION_MENU_ITEM_TYPE.LINK) { + acc.push(processedItem); + } else { + const objectMetadataItem = getObjectMetadataForNavigationMenuItem( + processedItem, + objectMetadataItems, + views, + ); + if (isDefined(objectMetadataItem)) { + acc.push(processedItem); + } + } + } + return acc; + }, []); + + return flatItems.flatMap((item) => + item.itemType === NAVIGATION_MENU_ITEM_TYPE.FOLDER + ? [item, ...(folderChildrenById.get(item.id) ?? [])] + : [item], + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/states/addMenuItemInsertionContextState.ts b/packages/twenty-front/src/modules/navigation-menu-item/states/addMenuItemInsertionContextState.ts new file mode 100644 index 0000000000000..e844385f191f7 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/states/addMenuItemInsertionContextState.ts @@ -0,0 +1,9 @@ +import { atom } from 'recoil'; + +import type { AddMenuItemInsertionContext } from '@/navigation-menu-item/types/AddMenuItemInsertionContext'; + +export const addMenuItemInsertionContextState = + atom({ + key: 'addMenuItemInsertionContextState', + default: null, + }); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/states/addToNavPayloadRegistryState.ts b/packages/twenty-front/src/modules/navigation-menu-item/states/addToNavPayloadRegistryState.ts new file mode 100644 index 0000000000000..d8d199b0addff --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/states/addToNavPayloadRegistryState.ts @@ -0,0 +1,10 @@ +import { atom } from 'recoil'; + +import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload'; + +export const addToNavPayloadRegistryState = atom< + Map +>({ + key: 'navigation-menu-item/addToNavPayloadRegistryState', + default: new Map(), +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/states/isNavigationMenuInEditModeState.ts b/packages/twenty-front/src/modules/navigation-menu-item/states/isNavigationMenuInEditModeState.ts new file mode 100644 index 0000000000000..1436f18e3241e --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/states/isNavigationMenuInEditModeState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isNavigationMenuInEditModeState = atom({ + key: 'isNavigationMenuInEditModeState', + default: false, +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/states/navigationMenuItemsDraftState.ts b/packages/twenty-front/src/modules/navigation-menu-item/states/navigationMenuItemsDraftState.ts new file mode 100644 index 0000000000000..7673331d23533 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/states/navigationMenuItemsDraftState.ts @@ -0,0 +1,7 @@ +import { atom } from 'recoil'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +export const navigationMenuItemsDraftState = atom({ + key: 'navigationMenuItemsDraftState', + default: null, +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState.ts b/packages/twenty-front/src/modules/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState.ts new file mode 100644 index 0000000000000..bb3de30a44967 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const selectedNavigationMenuItemInEditModeState = atom({ + key: 'selectedNavigationMenuItemInEditModeState', + default: null, +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/types/AddMenuItemInsertionContext.ts b/packages/twenty-front/src/modules/navigation-menu-item/types/AddMenuItemInsertionContext.ts new file mode 100644 index 0000000000000..54c1a526faeec --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/types/AddMenuItemInsertionContext.ts @@ -0,0 +1,4 @@ +export type AddMenuItemInsertionContext = { + targetFolderId: string | null; + targetIndex: number; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/types/NavigationSectionId.ts b/packages/twenty-front/src/modules/navigation-menu-item/types/NavigationSectionId.ts new file mode 100644 index 0000000000000..ace7d5e1979be --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/types/NavigationSectionId.ts @@ -0,0 +1,4 @@ +import { type NAVIGATION_SECTIONS } from '@/navigation-menu-item/constants/NavigationSections.constants'; + +export type NavigationSectionId = + (typeof NAVIGATION_SECTIONS)[keyof typeof NAVIGATION_SECTIONS]; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/types/add-to-navigation-drag-payload.ts b/packages/twenty-front/src/modules/navigation-menu-item/types/add-to-navigation-drag-payload.ts new file mode 100644 index 0000000000000..77a973253fe3c --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/types/add-to-navigation-drag-payload.ts @@ -0,0 +1,41 @@ +export type AddToNavigationDragPayloadObject = { + type: 'object'; + objectMetadataId: string; + defaultViewId: string; + label: string; +}; + +export type AddToNavigationDragPayloadView = { + type: 'view'; + viewId: string; + label: string; +}; + +export type AddToNavigationDragPayloadRecord = { + type: 'record'; + recordId: string; + objectMetadataId: string; + objectNameSingular: string; + label: string; + imageUrl?: string | null; +}; + +export type AddToNavigationDragPayloadFolder = { + type: 'folder'; + folderId: string; + name: string; +}; + +export type AddToNavigationDragPayloadLink = { + type: 'link'; + linkId: string; + name: string; + link: string; +}; + +export type AddToNavigationDragPayload = + | AddToNavigationDragPayloadObject + | AddToNavigationDragPayloadView + | AddToNavigationDragPayloadRecord + | AddToNavigationDragPayloadFolder + | AddToNavigationDragPayloadLink; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/types/navigation-menu-item-type.ts b/packages/twenty-front/src/modules/navigation-menu-item/types/navigation-menu-item-type.ts new file mode 100644 index 0000000000000..f63af354780b5 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/types/navigation-menu-item-type.ts @@ -0,0 +1,10 @@ +export const NAVIGATION_MENU_ITEM_TYPE = { + FOLDER: 'folder', + LINK: 'link', + OBJECT: 'object', + RECORD: 'record', + VIEW: 'view', +} as const; + +export type NavigationMenuItemType = + (typeof NAVIGATION_MENU_ITEM_TYPE)[keyof typeof NAVIGATION_MENU_ITEM_TYPE]; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/types/processed-navigation-menu-item.ts b/packages/twenty-front/src/modules/navigation-menu-item/types/processed-navigation-menu-item.ts new file mode 100644 index 0000000000000..db0913f5e3a98 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/types/processed-navigation-menu-item.ts @@ -0,0 +1,11 @@ +import { type ViewKey } from '@/views/types/ViewKey'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { type NavigationMenuItemDisplayFields } from '@/navigation-menu-item/utils/computeNavigationMenuItemDisplayFields'; +import { type NavigationMenuItemType } from '@/navigation-menu-item/types/navigation-menu-item-type'; + +export type ProcessedNavigationMenuItem = NavigationMenuItem & + NavigationMenuItemDisplayFields & { + viewKey?: ViewKey | null; + itemType: NavigationMenuItemType; + }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/add-to-navigation-draft.utils.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/add-to-navigation-draft.utils.test.ts new file mode 100644 index 0000000000000..a195b29f5640d --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/add-to-navigation-draft.utils.test.ts @@ -0,0 +1,54 @@ +import { + computeInsertIndexAndPosition, + normalizeUrl, +} from '@/navigation-menu-item/utils/add-to-navigation-draft.utils'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +describe('normalizeUrl', () => { + it('should leave url unchanged when it has protocol, otherwise prepend https', () => { + expect(normalizeUrl('https://example.com')).toBe('https://example.com'); + expect(normalizeUrl('example.com')).toBe('https://example.com'); + expect(normalizeUrl(' example.com ')).toBe('https://example.com'); + }); +}); + +describe('computeInsertIndexAndPosition', () => { + it('should compute flatIndex and position for insert in folder', () => { + const empty: NavigationMenuItem[] = []; + expect(computeInsertIndexAndPosition(empty, null, 0)).toEqual({ + flatIndex: 0, + position: 0.5, + }); + + const draft: NavigationMenuItem[] = [ + { id: '1', folderId: null, position: 10 } as NavigationMenuItem, + { id: '2', folderId: null, position: 20 } as NavigationMenuItem, + ]; + const between = computeInsertIndexAndPosition(draft, null, 1); + expect(between.flatIndex).toBe(1); + expect(between.position).toBe(15); + }); + + it('should only consider items in target folder and exclude userWorkspaceId', () => { + const draft: NavigationMenuItem[] = [ + { id: '1', folderId: 'folder-a', position: 10 } as NavigationMenuItem, + { id: '2', folderId: 'folder-b', position: 20 } as NavigationMenuItem, + { id: '3', folderId: 'folder-b', position: 30 } as NavigationMenuItem, + ]; + const result = computeInsertIndexAndPosition(draft, 'folder-b', 1); + expect(result.flatIndex).toBe(2); + expect(result.position).toBe(25); + + const withWorkspace: NavigationMenuItem[] = [ + { id: '1', folderId: null, position: 10 } as NavigationMenuItem, + { + id: '2', + folderId: null, + position: 20, + userWorkspaceId: 'ws-1', + } as NavigationMenuItem, + ]; + const excluded = computeInsertIndexAndPosition(withWorkspace, null, 1); + expect(excluded.position).toBe(10.5); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/computeNavigationMenuItemDisplayFields.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/computeNavigationMenuItemDisplayFields.test.ts index 9e0823a7b67d4..907aa76b62525 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/computeNavigationMenuItemDisplayFields.test.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/computeNavigationMenuItemDisplayFields.test.ts @@ -19,31 +19,16 @@ describe('computeNavigationMenuItemDisplayFields', () => { linkToShowPage: '/app/objects/people/record-id', }; - it('should return null when objectMetadataItem is null', () => { - const result = computeNavigationMenuItemDisplayFields( - null, - mockObjectRecordIdentifier, - ); - - expect(result).toBeNull(); - }); - - it('should return null when objectRecordIdentifier is null', () => { - const result = computeNavigationMenuItemDisplayFields( - mockObjectMetadataItem, - null, - ); - - expect(result).toBeNull(); - }); - - it('should return null when both objectMetadataItem and objectRecordIdentifier are null', () => { - const result = computeNavigationMenuItemDisplayFields(null, null); - - expect(result).toBeNull(); + it('should return null when objectMetadataItem or objectRecordIdentifier is null', () => { + expect( + computeNavigationMenuItemDisplayFields(null, mockObjectRecordIdentifier), + ).toBeNull(); + expect( + computeNavigationMenuItemDisplayFields(mockObjectMetadataItem, null), + ).toBeNull(); }); - it('should return complete display fields when all parameters are provided', () => { + it('should return display fields from metadata and record identifier', () => { const result = computeNavigationMenuItemDisplayFields( mockObjectMetadataItem, mockObjectRecordIdentifier, @@ -58,97 +43,18 @@ describe('computeNavigationMenuItemDisplayFields', () => { }); }); - it('should handle objectRecordIdentifier with undefined optional fields', () => { - const identifierWithoutOptionalFields: ObjectRecordIdentifier = { + it('should default optional identifier fields to empty string or icon', () => { + const minimal: ObjectRecordIdentifier = { id: 'record-id', name: 'Jane Doe', }; - const result = computeNavigationMenuItemDisplayFields( mockObjectMetadataItem, - identifierWithoutOptionalFields, - ); - - expect(result).toEqual({ - labelIdentifier: 'Jane Doe', - avatarUrl: '', - avatarType: 'icon', - link: '', - objectNameSingular: 'person', - }); - }); - - it('should use objectMetadataItem nameSingular for objectNameSingular', () => { - const customMetadataItem: ObjectMetadataItem = { - ...mockObjectMetadataItem, - nameSingular: 'company', - } as ObjectMetadataItem; - - const result = computeNavigationMenuItemDisplayFields( - customMetadataItem, - mockObjectRecordIdentifier, - ); - - expect(result?.objectNameSingular).toBe('company'); - }); - - it('should handle objectRecordIdentifier with null avatarType', () => { - const identifierWithNullAvatarType: ObjectRecordIdentifier = { - id: 'record-id', - name: 'Test User', - avatarType: null, - }; - - const result = computeNavigationMenuItemDisplayFields( - mockObjectMetadataItem, - identifierWithNullAvatarType, - ); - - expect(result?.avatarType).toBe('icon'); - }); - - it('should handle objectRecordIdentifier with undefined linkToShowPage', () => { - const identifierWithoutLink: ObjectRecordIdentifier = { - id: 'record-id', - name: 'Test User', - linkToShowPage: undefined, - }; - - const result = computeNavigationMenuItemDisplayFields( - mockObjectMetadataItem, - identifierWithoutLink, - ); - - expect(result?.link).toBe(''); - }); - - it('should handle objectRecordIdentifier with undefined avatarUrl', () => { - const identifierWithoutAvatarUrl: ObjectRecordIdentifier = { - id: 'record-id', - name: 'Test User', - avatarUrl: undefined, - }; - - const result = computeNavigationMenuItemDisplayFields( - mockObjectMetadataItem, - identifierWithoutAvatarUrl, + minimal, ); expect(result?.avatarUrl).toBe(''); - }); - - it('should handle objectRecordIdentifier with undefined avatarType', () => { - const identifierWithoutAvatarType: ObjectRecordIdentifier = { - id: 'record-id', - name: 'Test User', - avatarType: undefined, - }; - - const result = computeNavigationMenuItemDisplayFields( - mockObjectMetadataItem, - identifierWithoutAvatarType, - ); - expect(result?.avatarType).toBe('icon'); + expect(result?.link).toBe(''); }); }); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/filterWorkspaceNavigationMenuItems.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/filterWorkspaceNavigationMenuItems.test.ts new file mode 100644 index 0000000000000..13c5372c3645a --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/filterWorkspaceNavigationMenuItems.test.ts @@ -0,0 +1,17 @@ +import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +describe('filterWorkspaceNavigationMenuItems', () => { + it('should filter out items that have userWorkspaceId', () => { + const items: NavigationMenuItem[] = [ + { id: '1', position: 1 } as NavigationMenuItem, + { id: '2', position: 2, userWorkspaceId: 'ws-1' } as NavigationMenuItem, + { id: '3', position: 3 } as NavigationMenuItem, + ]; + + const result = filterWorkspaceNavigationMenuItems(items); + + expect(result).toHaveLength(2); + expect(result.map((item) => item.id)).toEqual(['1', '3']); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getDropTargetIdFromDestination.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getDropTargetIdFromDestination.test.ts new file mode 100644 index 0000000000000..356559a111de1 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getDropTargetIdFromDestination.test.ts @@ -0,0 +1,37 @@ +import { getDropTargetIdFromDestination } from '@/navigation-menu-item/utils/getDropTargetIdFromDestination'; + +describe('getDropTargetIdFromDestination', () => { + it('should return null when destination is null or droppableId is not workspace', () => { + expect(getDropTargetIdFromDestination(null)).toBe(null); + expect( + getDropTargetIdFromDestination({ + droppableId: 'favorites-orphan', + index: 0, + }), + ).toBe(null); + }); + + it('should return workspace-orphan-index for workspace orphan droppable', () => { + const result = getDropTargetIdFromDestination({ + droppableId: 'workspace-orphan-navigation-menu-items', + index: 2, + }); + expect(result).toBe('workspace-orphan-2'); + }); + + it('should return workspace-folderId-index for workspace folder droppable', () => { + const result = getDropTargetIdFromDestination({ + droppableId: 'workspace-folder-folder-123', + index: 1, + }); + expect(result).toBe('workspace-folder-123-1'); + }); + + it('should return workspace-folderId-index for workspace folder header droppable', () => { + const result = getDropTargetIdFromDestination({ + droppableId: 'workspace-folder-header-folder-456', + index: 0, + }); + expect(result).toBe('workspace-folder-456-0'); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getObjectMetadataForNavigationMenuItem.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getObjectMetadataForNavigationMenuItem.test.ts new file mode 100644 index 0000000000000..abdc5eccb568c --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getObjectMetadataForNavigationMenuItem.test.ts @@ -0,0 +1,112 @@ +import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type View } from '@/views/types/View'; + +const mockObjectMetadataItems: ObjectMetadataItem[] = [ + { + id: 'metadata-1', + nameSingular: 'person', + namePlural: 'people', + } as ObjectMetadataItem, + { + id: 'metadata-2', + nameSingular: 'company', + namePlural: 'companies', + } as ObjectMetadataItem, +]; + +const mockViews: View[] = [ + { + id: 'view-1', + objectMetadataId: 'metadata-1', + } as View, + { + id: 'view-2', + objectMetadataId: 'metadata-2', + } as View, +]; + +describe('getObjectMetadataForNavigationMenuItem', () => { + it('should return null for link item type', () => { + const result = getObjectMetadataForNavigationMenuItem( + { itemType: 'link' }, + mockObjectMetadataItems, + mockViews, + ); + expect(result).toBeNull(); + }); + + it('should return object metadata for view item when view and metadata exist', () => { + const result = getObjectMetadataForNavigationMenuItem( + { itemType: 'view', viewId: 'view-1' }, + mockObjectMetadataItems, + mockViews, + ); + expect(result).toEqual(mockObjectMetadataItems[0]); + expect(result?.nameSingular).toBe('person'); + }); + + it('should return null for view item when view is not found', () => { + const result = getObjectMetadataForNavigationMenuItem( + { itemType: 'view', viewId: 'non-existent-view' }, + mockObjectMetadataItems, + mockViews, + ); + expect(result).toBeNull(); + }); + + it('should return null for view item when view has no matching object metadata', () => { + const viewsWithOrphanView: View[] = [ + { id: 'orphan-view', objectMetadataId: 'non-existent-metadata' } as View, + ]; + const result = getObjectMetadataForNavigationMenuItem( + { itemType: 'view', viewId: 'orphan-view' }, + mockObjectMetadataItems, + viewsWithOrphanView, + ); + expect(result).toBeNull(); + }); + + it('should return object metadata for record item when metadata exists', () => { + const result = getObjectMetadataForNavigationMenuItem( + { + itemType: 'record', + targetObjectMetadataId: 'metadata-2', + }, + mockObjectMetadataItems, + mockViews, + ); + expect(result).toEqual(mockObjectMetadataItems[1]); + expect(result?.nameSingular).toBe('company'); + }); + + it('should return null for record item when targetObjectMetadataId is undefined', () => { + const result = getObjectMetadataForNavigationMenuItem( + { itemType: 'record' }, + mockObjectMetadataItems, + mockViews, + ); + expect(result).toBeNull(); + }); + + it('should return null for record item when metadata is not found', () => { + const result = getObjectMetadataForNavigationMenuItem( + { + itemType: 'record', + targetObjectMetadataId: 'non-existent-metadata', + }, + mockObjectMetadataItems, + mockViews, + ); + expect(result).toBeNull(); + }); + + it('should return null for view item when viewId is undefined', () => { + const result = getObjectMetadataForNavigationMenuItem( + { itemType: 'view' }, + mockObjectMetadataItems, + mockViews, + ); + expect(result).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isLocationMatchingNavigationMenuItem.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isLocationMatchingNavigationMenuItem.test.ts index bf07694e14c9f..35f4863a0935b 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isLocationMatchingNavigationMenuItem.test.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isLocationMatchingNavigationMenuItem.test.ts @@ -1,88 +1,50 @@ +import { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem'; describe('isLocationMatchingNavigationMenuItem', () => { - it('should return true if navigation menu item link matches current path for non-view items', () => { - const currentPath = '/app/objects/people'; - const currentViewPath = '/app/objects/people?viewId=123'; - const navigationMenuItem = { - objectNameSingular: 'person', - link: '/app/objects/people', - }; - + it('should return true when item link matches current path (non-view) or current view path (view)', () => { expect( isLocationMatchingNavigationMenuItem( - currentPath, - currentViewPath, - navigationMenuItem, + '/app/objects/people', + '/app/objects/people?viewId=123', + { + itemType: NAVIGATION_MENU_ITEM_TYPE.RECORD, + link: '/app/objects/people', + }, ), ).toBe(true); - }); - - it('should return true if navigation menu item link matches current view path for view items', () => { - const currentPath = '/app/objects/companies'; - const currentViewPath = '/app/objects/companies?viewId=123'; - const navigationMenuItem = { - objectNameSingular: 'view', - link: '/app/objects/companies?viewId=123', - }; - expect( isLocationMatchingNavigationMenuItem( - currentPath, - currentViewPath, - navigationMenuItem, + '/app/objects/companies', + '/app/objects/companies?viewId=123', + { + itemType: NAVIGATION_MENU_ITEM_TYPE.VIEW, + link: '/app/objects/companies?viewId=123', + }, ), ).toBe(true); }); - it('should return false if navigation menu item link does not match current path for non-view items', () => { - const currentPath = '/app/objects/people'; - const currentViewPath = '/app/objects/people?viewId=123'; - const navigationMenuItem = { - objectNameSingular: 'person', - link: '/app/objects/company', - }; - + it('should return false when item link does not match path', () => { expect( isLocationMatchingNavigationMenuItem( - currentPath, - currentViewPath, - navigationMenuItem, + '/app/objects/people', + '/app/objects/people?viewId=123', + { + itemType: NAVIGATION_MENU_ITEM_TYPE.RECORD, + link: '/app/objects/company', + }, ), ).toBe(false); - }); - - it('should return false if navigation menu item link does not match current view path for view items', () => { - const currentPath = '/app/objects/companies'; - const currentViewPath = '/app/objects/companies?viewId=123'; - const navigationMenuItem = { - objectNameSingular: 'view', - link: '/app/objects/companies?viewId=456', - }; - expect( isLocationMatchingNavigationMenuItem( - currentPath, - currentViewPath, - navigationMenuItem, + '/app/objects/companies', + '/app/objects/companies?viewId=123', + { + itemType: NAVIGATION_MENU_ITEM_TYPE.VIEW, + link: '/app/objects/companies?viewId=456', + }, ), ).toBe(false); }); - - it('should use current path for non-view items even if view path is different', () => { - const currentPath = '/app/objects/people'; - const currentViewPath = '/app/objects/people?viewId=999'; - const navigationMenuItem = { - objectNameSingular: 'person', - link: '/app/objects/people', - }; - - expect( - isLocationMatchingNavigationMenuItem( - currentPath, - currentViewPath, - navigationMenuItem, - ), - ).toBe(true); - }); }); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isNavigationMenuItemFolder.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isNavigationMenuItemFolder.test.ts new file mode 100644 index 0000000000000..3227682fa4b7b --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isNavigationMenuItemFolder.test.ts @@ -0,0 +1,45 @@ +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; + +describe('isNavigationMenuItemFolder', () => { + it('should return true only when item has name and no link/view/record metadata', () => { + expect( + isNavigationMenuItemFolder({ + name: 'My Folder', + link: null, + viewId: null, + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(true); + }); + + it('should return false when name is missing or when link/view/record is defined', () => { + expect( + isNavigationMenuItemFolder({ + name: undefined, + link: null, + viewId: null, + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(false); + expect( + isNavigationMenuItemFolder({ + name: 'My Folder', + link: 'https://example.com', + viewId: null, + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(false); + expect( + isNavigationMenuItemFolder({ + name: 'My Folder', + link: null, + viewId: 'view-1', + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isNavigationMenuItemLink.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isNavigationMenuItemLink.test.ts new file mode 100644 index 0000000000000..91c58ece0a6c8 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isNavigationMenuItemLink.test.ts @@ -0,0 +1,60 @@ +import { isNavigationMenuItemLink } from '@/navigation-menu-item/utils/isNavigationMenuItemLink'; + +describe('isNavigationMenuItemLink', () => { + it('should return true only when item has non-empty link and no view/record metadata', () => { + expect( + isNavigationMenuItemLink({ + link: 'https://example.com', + viewId: null, + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(true); + }); + + it('should return false when link is missing, empty or only whitespace', () => { + expect( + isNavigationMenuItemLink({ + link: '', + viewId: null, + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(false); + expect( + isNavigationMenuItemLink({ + link: ' ', + viewId: null, + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(false); + expect( + isNavigationMenuItemLink({ + link: undefined, + viewId: null, + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(false); + }); + + it('should return false when viewId, targetRecordId or targetObjectMetadataId is defined', () => { + expect( + isNavigationMenuItemLink({ + link: 'https://example.com', + viewId: 'view-1', + targetRecordId: null, + targetObjectMetadataId: null, + }), + ).toBe(false); + expect( + isNavigationMenuItemLink({ + link: 'https://example.com', + viewId: null, + targetRecordId: 'record-1', + targetObjectMetadataId: null, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/recordIdentifierToObjectRecordIdentifier.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/recordIdentifierToObjectRecordIdentifier.test.ts new file mode 100644 index 0000000000000..f80e724313671 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/recordIdentifierToObjectRecordIdentifier.test.ts @@ -0,0 +1,71 @@ +import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +jest.mock('@/object-metadata/utils/getAvatarType', () => ({ + getAvatarType: jest.fn(() => 'rounded'), +})); + +jest.mock('@/object-metadata/utils/getBasePathToShowPage', () => ({ + getBasePathToShowPage: jest.fn( + ({ objectNameSingular }: { objectNameSingular: string }) => + `/object/${objectNameSingular}/`, + ), +})); + +describe('recordIdentifierToObjectRecordIdentifier', () => { + const baseRecordIdentifier = { + id: 'record-123', + labelIdentifier: 'John Doe', + imageIdentifier: 'https://example.com/avatar.jpg', + }; + + const baseObjectMetadataItem: ObjectMetadataItem = { + nameSingular: 'person', + } as ObjectMetadataItem; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return ObjectRecordIdentifier with id, name, avatarUrl, avatarType, and linkToShowPage', () => { + const result = recordIdentifierToObjectRecordIdentifier({ + recordIdentifier: baseRecordIdentifier, + objectMetadataItem: baseObjectMetadataItem, + }); + + expect(result).toEqual({ + id: 'record-123', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.jpg', + avatarType: 'rounded', + linkToShowPage: '/object/person/record-123', + }); + }); + + it('should use undefined for avatarUrl when imageIdentifier is null', () => { + const result = recordIdentifierToObjectRecordIdentifier({ + recordIdentifier: { ...baseRecordIdentifier, imageIdentifier: null }, + objectMetadataItem: baseObjectMetadataItem, + }); + + expect(result.avatarUrl).toBeUndefined(); + }); + + it('should return empty linkToShowPage for targets and workspace member', () => { + const objectMetadataItems: ObjectMetadataItem[] = [ + { nameSingular: CoreObjectNameSingular.NoteTarget } as ObjectMetadataItem, + { + nameSingular: CoreObjectNameSingular.WorkspaceMember, + } as ObjectMetadataItem, + ]; + + objectMetadataItems.forEach((objectMetadataItem) => { + const result = recordIdentifierToObjectRecordIdentifier({ + recordIdentifier: baseRecordIdentifier, + objectMetadataItem, + }); + expect(result.linkToShowPage).toBe(''); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/sortNavigationMenuItems.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/sortNavigationMenuItems.test.ts index 0f08a1fa3e6bc..4736b6ad5f886 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/sortNavigationMenuItems.test.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/sortNavigationMenuItems.test.ts @@ -2,6 +2,7 @@ import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNaviga import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; import { type View } from '@/views/types/View'; +import { ViewKey } from '@/views/types/ViewKey'; import { type NavigationMenuItem } from '~/generated-metadata/graphql'; jest.mock('@/favorites/utils/getObjectMetadataNamePluralFromViewId', () => ({ @@ -36,13 +37,19 @@ describe('sortNavigationMenuItems', () => { id: 'metadata-id', nameSingular: 'person', namePlural: 'people', + labelPlural: 'People', + icon: 'IconUser', } as ObjectMetadataItem; - const mockView: Pick = { + const mockView: Pick< + View, + 'id' | 'name' | 'objectMetadataId' | 'icon' | 'key' + > = { id: 'view-id', name: 'All People', objectMetadataId: 'metadata-id', icon: 'IconUser', + key: ViewKey.Index, }; const mockObjectRecordIdentifier: ObjectRecordIdentifier = { @@ -83,8 +90,8 @@ describe('sortNavigationMenuItems', () => { id: 'item-id', viewId: 'view-id', position: 1, - labelIdentifier: 'All People', - objectNameSingular: 'view', + labelIdentifier: 'People', + objectNameSingular: 'person', Icon: 'IconUser', }); expect(result[0].link).toContain('viewId=view-id'); @@ -297,30 +304,11 @@ describe('sortNavigationMenuItems', () => { expect(result).toHaveLength(2); expect(result[0].id).toBe('view-item'); - expect(result[0].objectNameSingular).toBe('view'); + expect(result[0].objectNameSingular).toBe('person'); expect(result[1].id).toBe('record-item'); expect(result[1].objectNameSingular).toBe('person'); }); - it('should handle empty targetRecordIdentifiers map', () => { - const navigationMenuItem: NavigationMenuItem = { - id: 'item-id', - targetRecordId: 'non-existent-record-id', - targetObjectMetadataId: 'metadata-id', - position: 1, - } as NavigationMenuItem; - - const result = sortNavigationMenuItems( - [navigationMenuItem], - true, - [], - [mockObjectMetadataItem], - new Map(), - ); - - expect(result).toHaveLength(0); - }); - it('should handle navigationMenuItem with both viewId and targetRecordId (viewId takes precedence)', () => { const navigationMenuItem: NavigationMenuItem = { id: 'item-id', @@ -339,26 +327,42 @@ describe('sortNavigationMenuItems', () => { ); expect(result).toHaveLength(1); - expect(result[0].objectNameSingular).toBe('view'); + expect(result[0].objectNameSingular).toBe('person'); expect(result[0].viewId).toBe('view-id'); }); - it('should handle navigationMenuItem with undefined targetRecordId', () => { - const navigationMenuItem: NavigationMenuItem = { - id: 'item-id', - targetRecordId: undefined, - targetObjectMetadataId: 'metadata-id', - position: 1, - } as NavigationMenuItem; - - const result = sortNavigationMenuItems( - [navigationMenuItem], + it('should process link items with protocol normalization and label from name or link', () => { + const withProtocol = sortNavigationMenuItems( + [ + { + id: 'link-1', + link: 'https://example.com', + name: 'My Link', + position: 1, + } as NavigationMenuItem, + ], true, [], - [mockObjectMetadataItem], + [], new Map(), ); - - expect(result).toHaveLength(0); + expect(withProtocol[0].labelIdentifier).toBe('My Link'); + expect(withProtocol[0].link).toBe('https://example.com'); + + const noProtocol = sortNavigationMenuItems( + [ + { + id: 'link-2', + link: 'example.com', + position: 2, + } as NavigationMenuItem, + ], + true, + [], + [], + new Map(), + ); + expect(noProtocol[0].link).toBe('https://example.com'); + expect(noProtocol[0].labelIdentifier).toBe('example.com'); }); }); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/validateAndExtractWorkspaceFolderId.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/validateAndExtractWorkspaceFolderId.test.ts new file mode 100644 index 0000000000000..1da5429d7e748 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/validateAndExtractWorkspaceFolderId.test.ts @@ -0,0 +1,54 @@ +import { + matchesWorkspaceFolderId, + validateAndExtractWorkspaceFolderId, +} from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +describe('matchesWorkspaceFolderId', () => { + it('should return true when folderId and item folder match (including null for orphan)', () => { + expect( + matchesWorkspaceFolderId({ folderId: null } as NavigationMenuItem, null), + ).toBe(true); + expect(matchesWorkspaceFolderId({} as NavigationMenuItem, null)).toBe(true); + expect( + matchesWorkspaceFolderId({ folderId: 'f1' } as NavigationMenuItem, 'f1'), + ).toBe(true); + }); + + it('should return false when folderId and item folder do not match', () => { + expect( + matchesWorkspaceFolderId({ folderId: 'f1' } as NavigationMenuItem, null), + ).toBe(false); + expect( + matchesWorkspaceFolderId({ folderId: 'f1' } as NavigationMenuItem, 'f2'), + ).toBe(false); + }); +}); + +describe('validateAndExtractWorkspaceFolderId', () => { + it('should return null for orphan and extract folder id from header or folder prefix', () => { + expect( + validateAndExtractWorkspaceFolderId( + 'workspace-orphan-navigation-menu-items', + ), + ).toBe(null); + expect( + validateAndExtractWorkspaceFolderId('workspace-folder-header-folder-123'), + ).toBe('folder-123'); + expect( + validateAndExtractWorkspaceFolderId('workspace-folder-folder-456'), + ).toBe('folder-456'); + }); + + it('should throw for invalid or empty folder id after prefix', () => { + expect(() => + validateAndExtractWorkspaceFolderId('workspace-folder-header-'), + ).toThrow('Invalid workspace folder header ID'); + expect(() => + validateAndExtractWorkspaceFolderId('workspace-folder-'), + ).toThrow('Invalid workspace folder ID'); + expect(() => + validateAndExtractWorkspaceFolderId('invalid-droppable-id'), + ).toThrow('Invalid workspace droppable ID format'); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/add-to-navigation-draft.utils.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/add-to-navigation-draft.utils.ts new file mode 100644 index 0000000000000..5860f544bc90b --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/add-to-navigation-draft.utils.ts @@ -0,0 +1,32 @@ +import { isDefined } from 'twenty-shared/utils'; +import type { NavigationMenuItem } from '~/generated-metadata/graphql'; + +export const normalizeUrl = (url: string) => { + const trimmed = url.trim(); + return trimmed.startsWith('http://') || trimmed.startsWith('https://') + ? trimmed + : `https://${trimmed}`; +}; + +export const computeInsertIndexAndPosition = ( + currentDraft: NavigationMenuItem[], + targetFolderId: string | null, + targetIndex: number, +) => { + const itemsInFolder = currentDraft.filter( + (item) => + (item.folderId ?? null) === targetFolderId && + !isDefined(item.userWorkspaceId), + ); + const insertRef = itemsInFolder[targetIndex]; + const lastInFolder = itemsInFolder[itemsInFolder.length - 1]; + const flatIndex = insertRef + ? currentDraft.indexOf(insertRef) + : lastInFolder + ? currentDraft.indexOf(lastInFolder) + 1 + : currentDraft.length; + const prevPosition = itemsInFolder[targetIndex - 1]?.position ?? 0; + const nextPosition = itemsInFolder[targetIndex]?.position ?? prevPosition + 1; + const position = (prevPosition + nextPosition) / 2; + return { flatIndex, position }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems.ts new file mode 100644 index 0000000000000..ea487f4f77e7d --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems.ts @@ -0,0 +1,8 @@ +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +export const filterWorkspaceNavigationMenuItems = ( + items: NavigationMenuItem[], +): NavigationMenuItem[] => + items.filter((item) => !isDefined(item.userWorkspaceId)); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/getDropTargetIdFromDestination.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/getDropTargetIdFromDestination.ts new file mode 100644 index 0000000000000..d992a1235c742 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/getDropTargetIdFromDestination.ts @@ -0,0 +1,15 @@ +import { NAVIGATION_SECTIONS } from '@/navigation-menu-item/constants/NavigationSections.constants'; +import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId'; +import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId'; +import type { DropResult } from '@hello-pangea/dnd'; + +export const getDropTargetIdFromDestination = ( + destination: DropResult['destination'], +): string | null => { + if (!destination || !isWorkspaceDroppableId(destination.droppableId)) { + return null; + } + const folderId = validateAndExtractWorkspaceFolderId(destination.droppableId); + const folderSegment = folderId ?? 'orphan'; + return `${NAVIGATION_SECTIONS.WORKSPACE}-${folderSegment}-${destination.index}`; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/getIconBackgroundColorForPayload.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/getIconBackgroundColorForPayload.ts new file mode 100644 index 0000000000000..20ce481c76bc1 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/getIconBackgroundColorForPayload.ts @@ -0,0 +1,25 @@ +import type { Theme } from '@emotion/react'; + +import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; + +export const getIconBackgroundColorForPayload = ( + payload: AddToNavigationDragPayload, + theme: Theme, +): string | undefined => { + const colors = getNavigationMenuItemIconColors(theme); + switch (payload.type) { + case 'object': + return colors.object; + case 'view': + return colors.view; + case 'folder': + return colors.folder; + case 'link': + return colors.link; + case 'record': + return undefined; + default: + return undefined; + } +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/getNavigationMenuItemIconColors.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/getNavigationMenuItemIconColors.ts new file mode 100644 index 0000000000000..ce983aa6d94a0 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/getNavigationMenuItemIconColors.ts @@ -0,0 +1,8 @@ +import type { Theme } from '@emotion/react'; + +export const getNavigationMenuItemIconColors = (theme: Theme) => ({ + folder: theme.color.orange, + link: theme.color.red, + view: theme.grayScale.gray8, + object: theme.color.blue, +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem.ts new file mode 100644 index 0000000000000..03e6844ee72ca --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem.ts @@ -0,0 +1,46 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type View } from '@/views/types/View'; + +type NavigationMenuItemWithItemType = Pick< + ProcessedNavigationMenuItem, + 'itemType' | 'viewId' | 'targetObjectMetadataId' +>; + +export const getObjectMetadataForNavigationMenuItem = ( + navigationMenuItem: NavigationMenuItemWithItemType, + objectMetadataItems: ObjectMetadataItem[], + views: View[], +): ObjectMetadataItem | null => { + if (navigationMenuItem.itemType === 'link') { + return null; + } + + if ( + navigationMenuItem.itemType === 'view' && + isDefined(navigationMenuItem.viewId) + ) { + const view = views.find((view) => view.id === navigationMenuItem.viewId); + if (!isDefined(view)) { + return null; + } + const objectMetadataItem = objectMetadataItems.find( + (meta) => meta.id === view.objectMetadataId, + ); + return objectMetadataItem ?? null; + } + + if ( + navigationMenuItem.itemType === 'record' && + isDefined(navigationMenuItem.targetObjectMetadataId) + ) { + const objectMetadataItem = objectMetadataItems.find( + (meta) => meta.id === navigationMenuItem.targetObjectMetadataId, + ); + return objectMetadataItem ?? null; + } + + return null; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem.ts index 240843b5e7f85..4f3711ceac600 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem.ts @@ -1,14 +1,14 @@ +import { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; export const isLocationMatchingNavigationMenuItem = ( currentPath: string, currentViewPath: string, - navigationMenuItem: Pick< - ProcessedNavigationMenuItem, - 'objectNameSingular' | 'link' - >, + navigationMenuItem: Pick, ) => { - return navigationMenuItem.objectNameSingular === 'view' + const isViewItem = + navigationMenuItem.itemType === NAVIGATION_MENU_ITEM_TYPE.VIEW; + return isViewItem ? navigationMenuItem.link === currentViewPath : navigationMenuItem.link === currentPath; }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemFolder.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemFolder.ts new file mode 100644 index 0000000000000..1db7923550a09 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemFolder.ts @@ -0,0 +1,15 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const isNavigationMenuItemFolder = (item: { + name?: string | null; + link?: string | null; + folderId?: string | null; + viewId?: string | null; + targetRecordId?: string | null; + targetObjectMetadataId?: string | null; +}) => + isDefined(item.name) && + !isDefined(item.link) && + !isDefined(item.targetRecordId) && + !isDefined(item.targetObjectMetadataId) && + !isDefined(item.viewId); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemLink.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemLink.ts new file mode 100644 index 0000000000000..dcc3ba6986506 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/isNavigationMenuItemLink.ts @@ -0,0 +1,13 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const isNavigationMenuItemLink = (item: { + link?: string | null; + viewId?: string | null; + targetRecordId?: string | null; + targetObjectMetadataId?: string | null; +}) => + isDefined(item.link) && + (item.link ?? '').trim() !== '' && + !isDefined(item.viewId) && + !isDefined(item.targetRecordId) && + !isDefined(item.targetObjectMetadataId); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/isWorkspaceDroppableId.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/isWorkspaceDroppableId.ts new file mode 100644 index 0000000000000..e6bc574ac1056 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/isWorkspaceDroppableId.ts @@ -0,0 +1,21 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { NAVIGATION_MENU_ITEM_DROPPABLE_IDS } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; + +export const isWorkspaceDroppableId = ( + droppableId: string | null | undefined, +): boolean => { + if (!isDefined(droppableId)) { + return false; + } + return ( + droppableId === + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS || + droppableId.startsWith( + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_FOLDER_PREFIX, + ) || + droppableId.startsWith( + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_FOLDER_HEADER_PREFIX, + ) + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/sortNavigationMenuItems.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/sortNavigationMenuItems.ts index 659f32cd8fee7..2a554fc10a51f 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/utils/sortNavigationMenuItems.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/sortNavigationMenuItems.ts @@ -1,23 +1,28 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; import { type View } from '@/views/types/View'; +import { ViewKey } from '@/views/types/ViewKey'; import { AppPath } from 'twenty-shared/types'; import { getAppPath, isDefined } from 'twenty-shared/utils'; import { type NavigationMenuItem } from '~/generated-metadata/graphql'; import { getObjectMetadataNamePluralFromViewId } from '@/favorites/utils/getObjectMetadataNamePluralFromViewId'; +import { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item'; import { computeNavigationMenuItemDisplayFields, type NavigationMenuItemDisplayFields, } from './computeNavigationMenuItemDisplayFields'; +import { isNavigationMenuItemLink } from './isNavigationMenuItemLink'; -export type ProcessedNavigationMenuItem = NavigationMenuItem & - NavigationMenuItemDisplayFields; +export { NAVIGATION_MENU_ITEM_TYPE } from '@/navigation-menu-item/types/navigation-menu-item-type'; +export type { NavigationMenuItemType } from '@/navigation-menu-item/types/navigation-menu-item-type'; +export type { ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item'; export const sortNavigationMenuItems = ( navigationMenuItems: NavigationMenuItem[], hasLinkToShowPage: boolean, - views: Pick[], + views: Pick[], objectMetadataItems: ObjectMetadataItem[], targetRecordIdentifiers: Map, ): ProcessedNavigationMenuItem[] => { @@ -33,9 +38,23 @@ export const sortNavigationMenuItems = ( view, objectMetadataItems, ); + const objectMetadataItem = objectMetadataItems.find( + (meta) => meta.id === view.objectMetadataId, + ); + const isIndexView = view.key === ViewKey.Index; + const labelIdentifier = + isIndexView && isDefined(objectMetadataItem) + ? objectMetadataItem.labelPlural + : view.name; + const icon = + isIndexView && + isDefined(objectMetadataItem) && + isDefined(objectMetadataItem.icon) + ? objectMetadataItem.icon + : view.icon; const displayFields: NavigationMenuItemDisplayFields = { - labelIdentifier: view.name, + labelIdentifier, avatarUrl: '', avatarType: 'icon', link: getAppPath( @@ -43,19 +62,42 @@ export const sortNavigationMenuItems = ( { objectNamePlural: namePlural }, { viewId: navigationMenuItem.viewId }, ), - objectNameSingular: 'view', - Icon: view.icon, + objectNameSingular: objectMetadataItem?.nameSingular ?? 'view', + Icon: icon, }; return { ...navigationMenuItem, ...displayFields, + viewKey: view.key, + itemType: NAVIGATION_MENU_ITEM_TYPE.VIEW, }; } return null; } + if (isNavigationMenuItemLink(navigationMenuItem)) { + const linkUrl = (navigationMenuItem.link ?? '').trim(); + const normalizedLink = + linkUrl.startsWith('http://') || linkUrl.startsWith('https://') + ? linkUrl + : `https://${linkUrl}`; + const displayFields: NavigationMenuItemDisplayFields = { + labelIdentifier: (navigationMenuItem.name ?? linkUrl) || 'Link', + avatarUrl: '', + avatarType: 'icon', + link: normalizedLink, + objectNameSingular: 'link', + Icon: 'IconLink', + }; + return { + ...navigationMenuItem, + ...displayFields, + itemType: NAVIGATION_MENU_ITEM_TYPE.LINK, + }; + } + if (!isDefined(navigationMenuItem.targetRecordId)) { return null; } @@ -89,6 +131,7 @@ export const sortNavigationMenuItems = ( ...navigationMenuItem, ...displayFields, link: hasLinkToShowPage ? displayFields.link : '', + itemType: NAVIGATION_MENU_ITEM_TYPE.RECORD, }; }) .filter(isDefined) diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId.ts new file mode 100644 index 0000000000000..f0fe66eeb0d83 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId.ts @@ -0,0 +1,61 @@ +import { CustomError, isDefined } from 'twenty-shared/utils'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { NAVIGATION_MENU_ITEM_DROPPABLE_IDS } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; + +export const matchesWorkspaceFolderId = ( + item: NavigationMenuItem, + folderId: string | null, +): boolean => + (folderId === null && !isDefined(item.folderId)) || + (isDefined(folderId) && item.folderId === folderId); + +export const validateAndExtractWorkspaceFolderId = ( + droppableId: string, +): string | null => { + if ( + droppableId === + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS + ) { + return null; + } + + if ( + droppableId.startsWith( + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_FOLDER_HEADER_PREFIX, + ) + ) { + const folderId = droppableId.replace( + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_FOLDER_HEADER_PREFIX, + '', + ); + if (!folderId) + throw new CustomError( + `Invalid workspace folder header ID: ${droppableId}`, + 'INVALID_WORKSPACE_FOLDER_HEADER_ID', + ); + return folderId; + } + + if ( + droppableId.startsWith( + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_FOLDER_PREFIX, + ) + ) { + const folderId = droppableId.replace( + NAVIGATION_MENU_ITEM_DROPPABLE_IDS.WORKSPACE_FOLDER_PREFIX, + '', + ); + if (!folderId) + throw new CustomError( + `Invalid workspace folder ID: ${droppableId}`, + 'INVALID_WORKSPACE_FOLDER_ID', + ); + return folderId; + } + + throw new CustomError( + `Invalid workspace droppable ID format: ${droppableId}`, + 'INVALID_WORKSPACE_DROPPABLE_ID_FORMAT', + ); +}; diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx index 4c793ec582aaf..f60f934cb48cf 100644 --- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx @@ -1,4 +1,5 @@ import { useRecoilValue } from 'recoil'; +import styled from '@emotion/styled'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder'; @@ -14,6 +15,11 @@ import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigati import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { FeatureFlagKey } from '~/generated/graphql'; +const StyledScrollableContent = styled.div` + height: 100%; + min-height: 0; +`; + export const MainNavigationDrawer = ({ className }: { className?: string }) => { const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentFavoriteFolderId = useRecoilValue(currentFavoriteFolderIdState); @@ -31,13 +37,15 @@ export const MainNavigationDrawer = ({ className }: { className?: string }) => { ); const openedNavigationMenuItemFolder = navigationMenuItemsByFolder.find( - (f) => f.folderId === currentNavigationMenuItemFolderId, + (f) => f.id === currentNavigationMenuItemFolderId, ); const openedFolder = isNavigationMenuItemEnabled ? openedNavigationMenuItemFolder : openedFavoriteFolder; + const openedFolderId = openedNavigationMenuItemFolder?.id ?? ''; + return ( { - {openedFolder ? ( + {isNavigationMenuItemEnabled ? ( + + {openedFolder ? ( + + ) : ( + + )} + + ) : openedFolder ? ( { + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + const [isDragging, setIsDragging] = useState(false); + const [sourceDroppableId, setSourceDroppableId] = useState( + null, + ); + const { handleNavigationMenuItemDragAndDrop } = + useHandleNavigationMenuItemDragAndDrop(); + const { handleWorkspaceNavigationMenuItemDragAndDrop } = + useHandleWorkspaceNavigationMenuItemDragAndDrop(); + const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop(); + + const handleDragStart = (dragStart: DragStart) => { + setIsDragging(true); + setSourceDroppableId(dragStart.source.droppableId); + }; + + const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { + setIsDragging(false); + setSourceDroppableId(null); + + if (isNavigationMenuItemEnabled) { + const isWorkspaceDrop = + (result.source?.droppableId?.startsWith('workspace-') ?? false) && + (result.destination?.droppableId?.startsWith('workspace-') ?? false); + if (isWorkspaceDrop) { + handleWorkspaceNavigationMenuItemDragAndDrop(result, provided); + } else { + handleNavigationMenuItemDragAndDrop(result, provided); + } + } else { + handleFavoriteDragAndDrop(result, provided); + } + }; + + return ( + + + + + {children} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation/components/PageDragDropProvider.tsx b/packages/twenty-front/src/modules/navigation/components/PageDragDropProvider.tsx new file mode 100644 index 0000000000000..b39a2a28631df --- /dev/null +++ b/packages/twenty-front/src/modules/navigation/components/PageDragDropProvider.tsx @@ -0,0 +1,139 @@ +import { + DragDropContext, + type DragStart, + type DropResult, + type OnDragUpdateResponder, + type ResponderProvided, +} from '@hello-pangea/dnd'; +import { type ReactNode, useState } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; + +import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext'; +import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop'; +import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId'; +import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext'; +import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext'; +import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; +import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/hooks/useHandleAddToNavigationDrop'; +import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop'; +import { useHandleWorkspaceNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop'; +import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState'; +import { getDropTargetIdFromDestination } from '@/navigation-menu-item/utils/getDropTargetIdFromDestination'; +import { isWorkspaceDroppableId } from '@/navigation-menu-item/utils/isWorkspaceDroppableId'; +import { validateAndExtractWorkspaceFolderId } from '@/navigation-menu-item/utils/validateAndExtractWorkspaceFolderId'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +type PageDragDropProviderProps = { + children: ReactNode; +}; + +export const PageDragDropProvider = ({ + children, +}: PageDragDropProviderProps) => { + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + const [isDragging, setIsDragging] = useState(false); + const [sourceDroppableId, setSourceDroppableId] = useState( + null, + ); + const [activeDropTargetId, setActiveDropTargetId] = useState( + null, + ); + const [forbiddenDropTargetId, setForbiddenDropTargetId] = useState< + string | null + >(null); + + const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop(); + const { handleNavigationMenuItemDragAndDrop } = + useHandleNavigationMenuItemDragAndDrop(); + const { handleWorkspaceNavigationMenuItemDragAndDrop } = + useHandleWorkspaceNavigationMenuItemDragAndDrop(); + const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop(); + + const handleDragStart = (dragStart: DragStart) => { + setIsDragging(true); + setSourceDroppableId(dragStart.source.droppableId); + }; + + const handleDragUpdate = useRecoilCallback( + ({ snapshot }) => + ((update: Parameters[0]) => { + const { source, destination } = update; + if (source.droppableId !== ADD_TO_NAV_SOURCE_DROPPABLE_ID) { + return; + } + if (!destination || !isWorkspaceDroppableId(destination.droppableId)) { + setActiveDropTargetId(null); + setForbiddenDropTargetId(null); + return; + } + const dropTargetId = getDropTargetIdFromDestination(destination); + setActiveDropTargetId(dropTargetId); + + const payload = + getSnapshotValue(snapshot, addToNavPayloadRegistryState).get( + update.draggableId, + ) ?? null; + const folderId = validateAndExtractWorkspaceFolderId( + destination.droppableId, + ); + const isFolderOverFolder = + payload?.type === 'folder' && folderId !== null; + setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null); + }) as OnDragUpdateResponder, + [], + ); + + const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { + setIsDragging(false); + setSourceDroppableId(null); + setActiveDropTargetId(null); + setForbiddenDropTargetId(null); + + if (result.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) { + handleAddToNavigationDrop(result, provided); + return; + } + + if (isNavigationMenuItemEnabled) { + const isWorkspaceDrop = + isWorkspaceDroppableId(result.source?.droppableId) && + isWorkspaceDroppableId(result.destination?.droppableId); + if (isWorkspaceDrop) { + handleWorkspaceNavigationMenuItemDragAndDrop(result, provided); + } else { + handleNavigationMenuItemDragAndDrop(result, provided); + } + } else { + handleFavoriteDragAndDrop(result, provided); + } + }; + + return ( + + + + + + {children} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerItemForObjectMetadataItem.tsx b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerItemForObjectMetadataItem.tsx index e152f436129e3..f5ce4b7e526f3 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerItemForObjectMetadataItem.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerItemForObjectMetadataItem.tsx @@ -1,38 +1,38 @@ -import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; -import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; -import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer'; -import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; -import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { coreViewsFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreViewsFromObjectMetadataItemFamilySelector'; +import { ViewKey } from '@/views/types/ViewKey'; +import { useTheme } from '@emotion/react'; import { useLocation } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { AppPath } from 'twenty-shared/types'; -import { getAppPath } from 'twenty-shared/utils'; -import { useIcons } from 'twenty-ui/display'; -import { AnimatedExpandableContainer } from 'twenty-ui/layout'; +import { getAppPath, isDefined } from 'twenty-shared/utils'; +import { Avatar, useIcons } from 'twenty-ui/display'; export type NavigationDrawerItemForObjectMetadataItemProps = { objectMetadataItem: ObjectMetadataItem; + navigationMenuItem?: ProcessedNavigationMenuItem; + isEditMode?: boolean; + isSelectedInEditMode?: boolean; + onEditModeClick?: () => void; + onActiveItemClickWhenNotInEditMode?: () => void; + isDragging?: boolean; }; export const NavigationDrawerItemForObjectMetadataItem = ({ objectMetadataItem, + navigationMenuItem, + isEditMode = false, + isSelectedInEditMode = false, + onEditModeClick, + onActiveItemClickWhenNotInEditMode, + isDragging = false, }: NavigationDrawerItemForObjectMetadataItemProps) => { - const views = useRecoilValue( - coreViewsFromObjectMetadataItemFamilySelector({ - objectMetadataItemId: objectMetadataItem.id, - }), - ); - - const contextStoreCurrentViewId = useRecoilComponentValue( - contextStoreCurrentViewIdComponentState, - MAIN_CONTEXT_STORE_INSTANCE_ID, - ); - + const theme = useTheme(); + const iconColors = getNavigationMenuItemIconColors(theme); const lastVisitedViewPerObjectMetadataItem = useRecoilValue( lastVisitedViewPerObjectMetadataItemState, ); @@ -41,75 +41,106 @@ export const NavigationDrawerItemForObjectMetadataItem = ({ lastVisitedViewPerObjectMetadataItem?.[objectMetadataItem.id]; const { getIcon } = useIcons(); - const currentPath = useLocation().pathname; + const location = useLocation(); + const currentPath = location.pathname; + const currentPathWithSearch = `${location.pathname}${location.search}`; - const navigationPath = getAppPath( - AppPath.RecordIndexPage, - { objectNamePlural: objectMetadataItem.namePlural }, - lastVisitedViewId ? { viewId: lastVisitedViewId } : undefined, - ); + const isRecord = navigationMenuItem?.itemType === 'record'; + const isView = navigationMenuItem?.itemType === 'view'; + const hasCustomLink = isRecord || isView; - const isActive = - currentPath === - getAppPath(AppPath.RecordIndexPage, { - objectNamePlural: objectMetadataItem.namePlural, - }) || - currentPath.includes( - getAppPath(AppPath.RecordShowPage, { - objectNameSingular: objectMetadataItem.nameSingular, - objectRecordId: '', - }) + '/', - ); - - const shouldSubItemsBeDisplayed = isActive && views.length > 1; - - const sortedObjectMetadataViews = [...views].sort( - (viewA, viewB) => viewA.position - viewB.position, - ); + const navigationPath = hasCustomLink + ? navigationMenuItem!.link + : getAppPath( + AppPath.RecordIndexPage, + { objectNamePlural: objectMetadataItem.namePlural }, + lastVisitedViewId ? { viewId: lastVisitedViewId } : undefined, + ); - const selectedSubItemIndex = sortedObjectMetadataViews.findIndex( - (view) => contextStoreCurrentViewId === view.id, - ); + const isActive = hasCustomLink + ? (isView ? currentPathWithSearch : currentPath) === + navigationMenuItem!.link + : currentPath === + getAppPath(AppPath.RecordIndexPage, { + objectNamePlural: objectMetadataItem.namePlural, + }) || + currentPath.includes( + getAppPath(AppPath.RecordShowPage, { + objectNameSingular: objectMetadataItem.nameSingular, + objectRecordId: '', + }) + '/', + ); + + const shouldUseClickHandler = isEditMode + ? Boolean(onEditModeClick) + : isActive && Boolean(onActiveItemClickWhenNotInEditMode); + + const handleClick = shouldUseClickHandler + ? isEditMode + ? onEditModeClick + : onActiveItemClickWhenNotInEditMode + : undefined; + + const shouldNavigate = + !isEditMode && !(isActive && onActiveItemClickWhenNotInEditMode); + + const isViewWithCustomName = + isView && + navigationMenuItem?.viewKey !== ViewKey.Index && + isDefined(navigationMenuItem?.labelIdentifier); + + const label = isRecord + ? navigationMenuItem!.labelIdentifier + : isViewWithCustomName + ? navigationMenuItem!.labelIdentifier + : objectMetadataItem.labelPlural; + + const Icon = isRecord + ? () => ( + + ) + : isViewWithCustomName && isDefined(navigationMenuItem?.Icon) + ? getIcon(navigationMenuItem.Icon) + : getIcon(objectMetadataItem.icon); + + const iconBackgroundColor = isRecord + ? undefined + : isViewWithCustomName + ? iconColors.view + : iconColors.object; - const subItemArrayLength = sortedObjectMetadataViews.length; + const secondaryLabel = + isRecord || isViewWithCustomName + ? objectMetadataItem.labelSingular + : undefined; return ( - - - - - {sortedObjectMetadataViews.map((view, index) => ( - - ))} - - + ); }; diff --git a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerOpenedSection.tsx b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerOpenedSection.tsx index 58b6df95b4453..c943e3b07a24e 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerOpenedSection.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerOpenedSection.tsx @@ -5,11 +5,18 @@ import { useWorkspaceNavigationMenuItems } from '@/navigation-menu-item/hooks/us import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems'; import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useLingui } from '@lingui/react/macro'; import { FeatureFlagKey } from '~/generated/graphql'; +const WORKFLOW_OBJECTS_IN_SIDEBAR = [ + CoreObjectNameSingular.Workflow, + CoreObjectNameSingular.WorkflowRun, + CoreObjectNameSingular.WorkflowVersion, +]; + export const NavigationDrawerOpenedSection = () => { const { t } = useLingui(); @@ -49,9 +56,15 @@ export const NavigationDrawerOpenedSection = () => { ? workspaceNavigationMenuItemsObjectMetadataItems : workspaceFavoritesObjectMetadataItems; - const shouldDisplayObjectInOpenedSection = !workspaceItemsToExclude - .map((item) => item.id) - .includes(objectMetadataItem.id); + const isWorkflowObjectInSidebar = WORKFLOW_OBJECTS_IN_SIDEBAR.includes( + objectMetadataItem.nameSingular as CoreObjectNameSingular, + ); + + const shouldDisplayObjectInOpenedSection = + !isWorkflowObjectInSidebar && + !workspaceItemsToExclude + .map((item) => item.id) + .includes(objectMetadataItem.id); if (loading) { return ; diff --git a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems.tsx b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems.tsx index a2d1f2001bb1e..0b921654fe127 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems.tsx @@ -19,7 +19,6 @@ const ORDERED_FIRST_STANDARD_OBJECTS: string[] = [ ]; const ORDERED_LAST_STANDARD_OBJECTS: string[] = [ - CoreObjectNameSingular.Workflow, CoreObjectNameSingular.Dashboard, ]; @@ -27,12 +26,24 @@ type NavigationDrawerSectionForObjectMetadataItemsProps = { sectionTitle: string; isRemote: boolean; objectMetadataItems: ObjectMetadataItem[]; + rightIcon?: React.ReactNode; + isEditMode?: boolean; + selectedObjectMetadataItemId?: string | null; + onObjectMetadataItemClick?: (objectMetadataItem: ObjectMetadataItem) => void; + onActiveObjectMetadataItemClick?: ( + objectMetadataItem: ObjectMetadataItem, + ) => void; }; export const NavigationDrawerSectionForObjectMetadataItems = ({ sectionTitle, isRemote, objectMetadataItems, + rightIcon, + isEditMode = false, + selectedObjectMetadataItemId = null, + onObjectMetadataItemClick, + onActiveObjectMetadataItemClick, }: NavigationDrawerSectionForObjectMetadataItemsProps) => { const { toggleNavigationSection, isNavigationSectionOpenState } = useNavigationSection('Objects' + (isRemote ? 'Remote' : 'Workspace')); @@ -103,6 +114,7 @@ export const NavigationDrawerSectionForObjectMetadataItems = ({ toggleNavigationSection()} + rightIcon={rightIcon} /> {isNavigationSectionOpen && @@ -111,6 +123,20 @@ export const NavigationDrawerSectionForObjectMetadataItems = ({ onObjectMetadataItemClick(objectMetadataItem) + : undefined + } + onActiveItemClickWhenNotInEditMode={ + onActiveObjectMetadataItemClick + ? () => onActiveObjectMetadataItemClick(objectMetadataItem) + : undefined + } /> ), )} diff --git a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx new file mode 100644 index 0000000000000..11b8570d358c6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx @@ -0,0 +1,323 @@ +import { useTheme } from '@emotion/react'; +import { Droppable } from '@hello-pangea/dnd'; +import { useLingui } from '@lingui/react/macro'; +import { useContext } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { IconLink, IconPlus } from 'twenty-ui/display'; + +import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection'; +import { NAVIGATION_SECTIONS } from '@/navigation-menu-item/constants/NavigationSections.constants'; + +import { NavigationItemDropTarget } from '@/navigation-menu-item/components/NavigationItemDropTarget'; +import { WorkspaceNavigationMenuItemsFolder } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder'; +import { NAVIGATION_MENU_ITEM_DROPPABLE_IDS } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; +import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; +import { + type FlatWorkspaceItem, + type NavigationMenuItemClickParams, +} from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem'; +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; +import { NavigationDrawerItemForObjectMetadataItem } from '@/object-metadata/components/NavigationDrawerItemForObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; +import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; +import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; +import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; + +type NavigationDrawerSectionForWorkspaceItemsProps = { + sectionTitle: string; + items: FlatWorkspaceItem[]; + rightIcon?: React.ReactNode; + onAddMenuItem?: () => void; + isEditMode?: boolean; + selectedNavigationMenuItemId?: string | null; + onNavigationMenuItemClick?: (params: NavigationMenuItemClickParams) => void; + onActiveObjectMetadataItemClick?: ( + objectMetadataItem: ObjectMetadataItem, + navigationMenuItemId: string, + ) => void; +}; + +export const NavigationDrawerSectionForWorkspaceItems = ({ + sectionTitle, + items, + rightIcon, + onAddMenuItem, + isEditMode = false, + selectedNavigationMenuItemId = null, + onNavigationMenuItemClick, + onActiveObjectMetadataItemClick, +}: NavigationDrawerSectionForWorkspaceItemsProps) => { + const { t } = useLingui(); + const theme = useTheme(); + const workspaceDropDisabled = useIsDropDisabledForSection(true); + const { toggleNavigationSection, isNavigationSectionOpenState } = + useNavigationSection('Workspace'); + const isNavigationSectionOpen = useRecoilValue(isNavigationSectionOpenState); + const coreViews = useRecoilValue(coreViewsState); + const views = coreViews.map(convertCoreViewToView); + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const { isDragging } = useContext(NavigationMenuItemDragContext); + + const flatItems = items.filter((item) => !isDefined(item.folderId)); + const folderChildrenById = items.reduce< + Map + >((acc, item) => { + const folderId = item.folderId; + if (isDefined(folderId)) { + const children = acc.get(folderId) ?? []; + children.push(item as ProcessedNavigationMenuItem); + acc.set(folderId, children); + } + return acc; + }, new Map()); + + const folderCount = flatItems.filter( + (item) => item.itemType === 'folder', + ).length; + + const filteredItems = flatItems.filter((item) => { + const type = item.itemType; + if (type === 'folder' || type === 'link') { + return true; + } + if (type === 'view' || type === 'record') { + const objectMetadataItem = getObjectMetadataForNavigationMenuItem( + item as ProcessedNavigationMenuItem, + objectMetadataItems, + views, + ); + return ( + isDefined(objectMetadataItem) && + getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + objectMetadataItem.id, + ).canReadObjectRecords + ); + } + return false; + }); + + const getEditModeProps = (item: FlatWorkspaceItem) => { + const itemId = item.id; + return { + isSelectedInEditMode: selectedNavigationMenuItemId === itemId, + onEditModeClick: onNavigationMenuItemClick + ? () => { + const type = item.itemType; + const objectMetadataItem = + type === 'view' || type === 'record' + ? getObjectMetadataForNavigationMenuItem( + item as ProcessedNavigationMenuItem, + objectMetadataItems, + views, + ) + : null; + onNavigationMenuItemClick({ + item, + objectMetadataItem: objectMetadataItem ?? undefined, + }); + } + : undefined, + }; + }; + + if (flatItems.length === 0) { + return null; + } + + return ( + + + toggleNavigationSection()} + rightIcon={rightIcon} + alwaysShowRightIcon={isEditMode} + /> + + {isNavigationSectionOpen && ( + + {(provided) => ( +
+ {filteredItems.map((item, index) => { + const type = item.itemType; + const editModeProps = getEditModeProps(item); + + if (type === 'folder') { + return ( + + 1} + isEditMode={isEditMode} + isSelectedInEditMode={ + editModeProps.isSelectedInEditMode + } + onEditModeClick={editModeProps.onEditModeClick} + onNavigationMenuItemClick={ + onNavigationMenuItemClick + } + selectedNavigationMenuItemId={ + selectedNavigationMenuItemId + } + isDragging={isDragging} + /> + } + /> + + ); + } + + if (type === 'link') { + const linkItem = item as ProcessedNavigationMenuItem; + const iconColors = getNavigationMenuItemIconColors(theme); + return ( + + + } + /> + + ); + } + + const objectMetadataItem = + getObjectMetadataForNavigationMenuItem( + item as ProcessedNavigationMenuItem, + objectMetadataItems, + views, + ); + if (!objectMetadataItem) return null; + + return ( + + + onActiveObjectMetadataItemClick( + objectMetadataItem, + item.id, + ) + : undefined + } + /> + } + /> + + ); + })} + + {isEditMode && onAddMenuItem && ( + + )} + + {provided.placeholder} +
+ )} +
+ )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFilteredObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFilteredObjectMetadataItems.ts index ccf519da24b23..63a6efd159fda 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useFilteredObjectMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFilteredObjectMetadataItems.ts @@ -22,16 +22,17 @@ export const useFilteredObjectMetadataItems = () => { [objectMetadataItems], ); - const alphaSortedActiveNonSystemObjectMetadataItems = - activeNonSystemObjectMetadataItems.sort((a, b) => { - if (a.nameSingular < b.nameSingular) { - return -1; - } - if (a.nameSingular > b.nameSingular) { - return 1; - } - return 0; - }); + const alphaSortedActiveNonSystemObjectMetadataItems = [ + ...activeNonSystemObjectMetadataItems, + ].sort((a, b) => { + if (a.nameSingular < b.nameSingular) { + return -1; + } + if (a.nameSingular > b.nameSingular) { + return 1; + } + return 0; + }); const inactiveNonSystemObjectMetadataItems = objectMetadataItems.filter( ({ isActive, isSystem }) => !isActive && !isSystem, diff --git a/packages/twenty-front/src/modules/settings/components/SaveAndCancelButtons/CancelButton.tsx b/packages/twenty-front/src/modules/settings/components/SaveAndCancelButtons/CancelButton.tsx index 871f0964837b9..80582641a0612 100644 --- a/packages/twenty-front/src/modules/settings/components/SaveAndCancelButtons/CancelButton.tsx +++ b/packages/twenty-front/src/modules/settings/components/SaveAndCancelButtons/CancelButton.tsx @@ -1,16 +1,33 @@ import { useLingui } from '@lingui/react/macro'; -import { LightButton } from 'twenty-ui/input'; +import { Button, LightButton } from 'twenty-ui/input'; type CancelButtonProps = { onCancel?: () => void; disabled?: boolean; + inverted?: boolean; }; export const CancelButton = ({ onCancel, disabled = false, + inverted = false, }: CancelButtonProps) => { const { t } = useLingui(); + + if (inverted) { + return ( +