From 015ccbf0a73a719de6dcbccb5c1ab68505c3fa8e Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:23:52 +0530 Subject: [PATCH] Navbar customization improvements (#17863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add folder icon editing support - And other navbar customization fixes/improvements. --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Devessier Co-authored-by: Félix Malfait --- .../src/generated-metadata/graphql.ts | 18 +++-- .../effect-components/PageChangeEffect.tsx | 22 +++++- .../components/CommandMenuFolderInfo.tsx | 52 ++++++++---- .../CommandMenuObjectViewRecordInfo.tsx | 20 ++--- ...andMenuNewSidebarItemViewPickerSubView.tsx | 17 +++- .../components/AddToNavigationDragHandle.tsx | 10 ++- .../components/NavigationMenuEditModeBar.tsx | 25 ++++-- .../components/NavigationMenuItemIcon.tsx | 67 ++++++++++++---- .../components/ObjectIconWithViewOverlay.tsx | 72 +++++++++++++++++ .../WorkspaceNavigationMenuItems.tsx | 30 +++---- .../WorkspaceNavigationMenuItemsFolder.tsx | 40 ++++++++-- .../constants/FolderIconDefault.ts | 1 + .../contexts/NavigationDropTargetContext.tsx | 7 ++ .../fragments/navigationMenuItemFragment.ts | 1 + .../hooks/useSaveNavigationMenuItemsDraft.ts | 26 +++--- .../hooks/useUpdateFolderInDraft.ts | 32 ++++++++ .../useUpdateNavigationMenuItemInDraft.ts | 31 ++++++++ .../hooks/useWorkspaceSectionItems.ts | 14 +++- .../components/PageDragDropProvider.tsx | 79 ++++++++++++++----- ...igationDrawerItemForObjectMetadataItem.tsx | 23 ++++-- ...vigationDrawerSectionForWorkspaceItems.tsx | 49 +++++++++--- .../PageHeaderToggleCommandMenuButton.tsx | 28 ++++++- .../components/NavigationDrawerItem.tsx | 1 + ...orites-to-navigation-menu-items.command.ts | 12 ++- ...-link-and-icon-to-navigation-menu-item.ts} | 0 ...783542-add-icon-to-navigation-menu-item.ts | 19 +++++ ...niversal-flat-navigation-menu-item.util.ts | 1 + ...ompare-and-stringify.constant.spec.ts.snap | 3 +- ...configuration-by-metadata-name.constant.ts | 1 + ...-menu-item-editable-properties.constant.ts | 1 + ...lat-navigation-menu-item-to-create.util.ts | 1 + ...u-item-to-navigation-menu-item-dto.util.ts | 1 + ...ntity-to-flat-navigation-menu-item.util.ts | 1 + .../dtos/create-navigation-menu-item.input.ts | 5 ++ .../dtos/navigation-menu-item.dto.ts | 4 + .../dtos/update-navigation-menu-item.input.ts | 5 ++ .../entities/navigation-menu-item.entity.ts | 3 + .../object-metadata.service.ts | 3 +- .../core/utils/seed-feature-flags.util.ts | 5 ++ .../standard-navigation-menu-item.constant.ts | 1 + ...ard-flat-navigation-menu-item-maps.util.ts | 1 + ...navigation-menu-item-flat-metadata.util.ts | 3 +- ...ion-menu-item-folder-flat-metadata.util.ts | 6 +- .../navigationMenuItemManifestType.ts | 1 + 44 files changed, 601 insertions(+), 141 deletions(-) create mode 100644 packages/twenty-front/src/modules/navigation-menu-item/components/ObjectIconWithViewOverlay.tsx create mode 100644 packages/twenty-front/src/modules/navigation-menu-item/constants/FolderIconDefault.ts create mode 100644 packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateFolderInDraft.ts create mode 100644 packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateNavigationMenuItemInDraft.ts rename packages/twenty-server/src/database/typeorm/core/migrations/common/{1770256542802-add-link-to-navigation-menu-item.ts => 1770699268900-add-link-and-icon-to-navigation-menu-item.ts} (100%) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1771247783542-add-icon-to-navigation-menu-item.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 3dfad2ab26..13467de80b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1042,6 +1042,7 @@ export type CreateLogicFunctionFromSourceInput = { export type CreateNavigationMenuItemInput = { folderId?: InputMaybe; + icon?: InputMaybe; link?: InputMaybe; name?: InputMaybe; position?: InputMaybe; @@ -3229,6 +3230,7 @@ export type NavigationMenuItem = { applicationId?: Maybe; createdAt: Scalars['DateTime']; folderId?: Maybe; + icon?: Maybe; id: Scalars['UUID']; link?: Maybe; name?: Maybe; @@ -4548,6 +4550,7 @@ export type UpdateLogicFunctionFromSourceInputUpdates = { export type UpdateNavigationMenuItemInput = { folderId?: InputMaybe; + icon?: InputMaybe; link?: InputMaybe; name?: InputMaybe; position?: InputMaybe; @@ -5836,42 +5839,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, objects: Array<{ __typename?: 'MarketplaceAppObject', universalIdentifier: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, fields: Array<{ __typename?: 'MarketplaceAppField', universalIdentifier?: string | null, name: string, type: string, label: string, description?: string | null, icon?: string | null }> }>, fields: Array<{ __typename?: 'MarketplaceAppField', name: string, type: string, label: string, description?: string | null, icon?: string | null, objectUniversalIdentifier?: string | null }>, logicFunctions: Array<{ __typename?: 'MarketplaceAppLogicFunction', name: string, description?: string | null, timeoutSeconds?: number | null }>, frontComponents: Array<{ __typename?: 'MarketplaceAppFrontComponent', name: string, description?: string | null }>, defaultRole?: { __typename?: 'MarketplaceAppDefaultRole', id: string, label: string, description?: string | null, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canUpdateAllSettings: boolean, canAccessAllTools: boolean, permissionFlags: Array, objectPermissions: Array<{ __typename?: 'MarketplaceAppRoleObjectPermission', objectUniversalIdentifier: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null }>, fieldPermissions: Array<{ __typename?: 'MarketplaceAppRoleFieldPermission', objectUniversalIdentifier: string, fieldUniversalIdentifier: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null }> } | 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, link?: 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, icon?: 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, 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 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, icon?: 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, link?: 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, icon?: 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, link?: 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, icon?: 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, link?: 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, icon?: 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, 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 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, icon?: 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, 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 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, icon?: 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 }> }; @@ -7361,6 +7364,7 @@ export const NavigationMenuItemFieldsFragmentDoc = gql` folderId name link + icon position applicationId createdAt diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index ac878e0e93..6cd97dc19f 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -5,7 +5,7 @@ import { useNavigate, useParams, } from 'react-router-dom'; -import { useRecoilValue } from 'recoil'; +import { useRecoilCallback, useRecoilValue } from 'recoil'; import { setSessionId, @@ -17,6 +17,8 @@ import { useRequestFreshCaptchaToken } from '@/captcha/hooks/useRequestFreshCapt import { isCaptchaScriptLoadedState } from '@/captcha/states/isCaptchaScriptLoadedState'; import { isCaptchaRequiredForPath } from '@/captcha/utils/isCaptchaRequiredForPath'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; @@ -97,6 +99,20 @@ export const PageChangeEffect = () => { const { closeCommandMenu } = useCommandMenu(); + const closeCommandMenuUnlessOnEditPage = useRecoilCallback( + ({ snapshot }) => + () => { + const currentPage = snapshot + .getLoadable(commandMenuPageState) + .getValue(); + if (currentPage === CommandMenuPages.NavigationMenuItemEdit) { + return; + } + closeCommandMenu(); + }, + [closeCommandMenu], + ); + const { resetFocusStackToFocusItem } = useResetFocusStackToFocusItem(); const { resetFocusStackToRecordIndex } = useResetFocusStackToRecordIndex(); @@ -104,8 +120,8 @@ export const PageChangeEffect = () => { const { openNewRecordTitleCell } = useOpenNewRecordTitleCell(); useEffect(() => { - closeCommandMenu(); - }, [location.pathname, closeCommandMenu]); + closeCommandMenuUnlessOnEditPage(); + }, [location.pathname, closeCommandMenuUnlessOnEditPage]); useEffect(() => { if (!previousLocation || previousLocation !== location.pathname) { diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderInfo.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderInfo.tsx index ae3a7ac243..0c74b8e275 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderInfo.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuFolderInfo.tsx @@ -1,24 +1,32 @@ import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; import { useRecoilValue } from 'recoil'; -import { IconFolder } from 'twenty-ui/display'; +import { useIcons } 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 { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer'; -import { useUpdateFolderNameInDraft } from '@/navigation-menu-item/hooks/useUpdateFolderNameInDraft'; +import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault'; +import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; +import { useUpdateFolderInDraft } from '@/navigation-menu-item/hooks/useUpdateFolderInDraft'; import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; import { selectedNavigationMenuItemInEditModeStateV2 } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeStateV2'; import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2'; import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; +import { IconPicker } from '@/ui/input/components/IconPicker'; import { TitleInput } from '@/ui/input/components/TitleInput'; import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +const StyledClickableIconWrapper = styled.div` + cursor: pointer; +`; + export const CommandMenuFolderInfo = () => { const theme = useTheme(); const { t } = useLingui(); + const { getIcon } = useIcons(); const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState); const [shouldFocusTitleInput, setShouldFocusTitleInput] = useRecoilComponentState( @@ -29,7 +37,7 @@ export const CommandMenuFolderInfo = () => { selectedNavigationMenuItemInEditModeStateV2, ); const items = useWorkspaceSectionItems(); - const { updateFolderNameInDraft } = useUpdateFolderNameInDraft(); + const { updateFolderInDraft } = useUpdateFolderInDraft(); const defaultLabel = t`New folder`; const placeholder = t`Folder name`; @@ -48,7 +56,7 @@ export const CommandMenuFolderInfo = () => { const itemName = selectedItem.name ?? defaultLabel; const handleChange = (text: string) => { - updateFolderNameInDraft(itemId, text); + updateFolderInDraft(itemId, { name: text }); }; const handleSave = () => { @@ -56,22 +64,36 @@ export const CommandMenuFolderInfo = () => { const finalName = trimmed.length > 0 ? trimmed : defaultLabel; if (finalName !== itemName) { - updateFolderNameInDraft(itemId, finalName); + updateFolderInDraft(itemId, { name: finalName }); } }; + const selectedIconKey = selectedItem.icon ?? FOLDER_ICON_DEFAULT; + const FolderIconComponent = getIcon(selectedIconKey); + return ( - - + + updateFolderInDraft(itemId, { icon: iconKey }) + } + clickableComponent={ + + + + + + } + /> } title={ { const { t } = useLingui(); @@ -22,19 +21,20 @@ export const CommandMenuObjectViewRecordInfo = () => { return null; } - const isViewOrRecord = - processedItem.itemType === NavigationMenuItemType.VIEW || - processedItem.itemType === NavigationMenuItemType.RECORD; + const isViewOrRecord = [ + NavigationMenuItemType.VIEW, + NavigationMenuItemType.RECORD, + ].includes(processedItem.itemType); + if (!isViewOrRecord) { return null; } - const label = - processedItem.itemType === NavigationMenuItemType.RECORD + const label = isViewOrRecord + ? processedItem.itemType === NavigationMenuItemType.RECORD ? t`record` - : processedItem.viewKey === ViewKey.Index - ? t`object` - : t`view`; + : t`view` + : t`object`; return ( handleSelectView(view)} > + ) : undefined + } + icon={ + selectedObjectMetadataItem + ? undefined + : getIcon(view.icon) + } label={view.name} id={view.id} onClick={() => handleSelectView(view)} 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 index 7dac8787dc..38eef0d73f 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/AddToNavigationDragHandle.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/AddToNavigationDragHandle.tsx @@ -72,14 +72,22 @@ export const AddToNavigationDragHandle = ({ const iconSize = theme.icon.size.md; const iconStroke = theme.icon.stroke.sm; + const showCustomContentWithoutWrapper = isDefined(customIconContent); + return ( - + {isHovered ? ( + ) : showCustomContentWithoutWrapper ? ( + customIconContent ) : hasBackgroundColor ? ( { const { getIcon } = useIcons(); const [isSaving, setIsSaving] = useState(false); const { closeCommandMenu } = useCommandMenu(); + const commandMenuPage = useRecoilValue(commandMenuPageState); const { enqueueErrorSnackBar } = useSnackBar(); const setNavigationMenuItemsDraft = useSetRecoilStateV2( navigationMenuItemsDraftStateV2, @@ -57,6 +60,12 @@ export const NavigationMenuEditModeBar = () => { setNavigationMenuItemsDraft(null); setSelectedNavigationMenuItemInEditMode(null); setIsNavigationMenuInEditMode(false); + const isNavItemPageOpen = + commandMenuPage === CommandMenuPages.NavigationMenuAddItem || + commandMenuPage === CommandMenuPages.NavigationMenuItemEdit; + if (isNavItemPageOpen) { + closeCommandMenu(); + } }; const isNavigationMenuInEditMode = useRecoilValueV2( 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 1e5c02f9c6..fbf75e8754 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,12 +1,18 @@ import { useTheme } from '@emotion/react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; import { Avatar, useIcons } from 'twenty-ui/display'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer'; +import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/ObjectIconWithViewOverlay'; import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; 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 { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { ViewKey } from '@/views/types/ViewKey'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; export const NavigationMenuItemIcon = ({ navigationMenuItem, @@ -15,30 +21,61 @@ export const NavigationMenuItemIcon = ({ }) => { const theme = useTheme(); const { getIcon } = useIcons(); + const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED, + ); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const { Icon: StandardIcon, IconColor } = useGetStandardObjectIcon( navigationMenuItem.objectNameSingular || '', ); + + const isRecord = + navigationMenuItem.itemType === NavigationMenuItemType.RECORD; + const isLink = navigationMenuItem.itemType === NavigationMenuItemType.LINK; + const isObjectIndexView = + navigationMenuItem.itemType === NavigationMenuItemType.VIEW && + navigationMenuItem.viewKey === ViewKey.Index; + const isViewWithOverlay = + navigationMenuItem.itemType === NavigationMenuItemType.VIEW && + !isObjectIndexView; + + const objectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === navigationMenuItem.objectNameSingular, + ); + const ObjectIconForView = + objectMetadataItem?.icon != null + ? getIcon(objectMetadataItem.icon) + : StandardIcon; + + if ( + isViewWithOverlay && + isDefined(ObjectIconForView) && + isDefined(navigationMenuItem.Icon) + ) { + const ViewIcon = getIcon(navigationMenuItem.Icon); + return ( + + ); + } + const IconToUse = StandardIcon || (navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined); const placeholderColorSeed = navigationMenuItem.targetRecordId ?? undefined; - - const isRecord = - navigationMenuItem.itemType === NavigationMenuItemType.RECORD; - const isLink = navigationMenuItem.itemType === NavigationMenuItemType.LINK; const iconColors = getNavigationMenuItemIconColors(theme); - const isObjectIndexView = - navigationMenuItem.itemType === NavigationMenuItemType.VIEW && - navigationMenuItem.viewKey === ViewKey.Index; - const iconBackgroundColor = isRecord - ? undefined - : isLink - ? iconColors.link - : navigationMenuItem.itemType === NavigationMenuItemType.VIEW && - !isObjectIndexView - ? iconColors.view - : iconColors.object; + const iconBackgroundColor = isNavigationMenuItemEditingEnabled + ? isRecord + ? undefined + : isLink + ? iconColors.link + : isViewWithOverlay + ? iconColors.view + : iconColors.object + : undefined; const iconColorToUse = iconBackgroundColor ? theme.grayScale.gray1 diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/ObjectIconWithViewOverlay.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/ObjectIconWithViewOverlay.tsx new file mode 100644 index 0000000000..32cbf51277 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/ObjectIconWithViewOverlay.tsx @@ -0,0 +1,72 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import type { IconComponent } from 'twenty-ui/display'; + +import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; + +const StyledCompositeContainer = styled.div` + align-items: center; + border-radius: ${({ theme }) => theme.border.radius.xs}; + display: flex; + flex-shrink: 0; + height: ${({ theme }) => theme.spacing(4.5)}; + justify-content: center; + position: relative; + width: ${({ theme }) => theme.spacing(4.5)}; +`; + +const StyledObjectIconWrapper = styled.div<{ $backgroundColor: string }>` + position: absolute; + inset: 0; + border-radius: ${({ theme }) => theme.border.radius.xs}; + background-color: ${({ $backgroundColor }) => $backgroundColor}; + display: flex; + align-items: center; + justify-content: center; +`; + +const StyledViewOverlay = styled.div<{ $backgroundColor: string }>` + align-items: center; + background-color: ${({ $backgroundColor }) => $backgroundColor}; + border: 1px solid ${({ theme }) => theme.background.primary}; + border-radius: ${({ theme }) => theme.border.radius.xs}; + bottom: -7px; + display: flex; + height: ${({ theme }) => theme.spacing(3.5)}; + justify-content: center; + position: absolute; + right: -7px; + width: ${({ theme }) => theme.spacing(3.5)}; +`; + +export type ObjectIconWithViewOverlayProps = { + ObjectIcon: IconComponent; + ViewIcon: IconComponent; +}; + +export const ObjectIconWithViewOverlay = ({ + ObjectIcon, + ViewIcon, +}: ObjectIconWithViewOverlayProps) => { + const theme = useTheme(); + const iconColors = getNavigationMenuItemIconColors(theme); + + return ( + + + + + + + + + ); +}; 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 bd6eb530e2..3a7945e2a3 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,39 +1,36 @@ import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; -import { - IconFolder, - IconLink, - IconPlus, - IconTool, - useIcons, -} from 'twenty-ui/display'; +import { useNavigate } from 'react-router-dom'; +import { useRecoilCallback } from 'recoil'; +import { IconLink, IconPlus, IconTool, useIcons } from 'twenty-ui/display'; import { LightIconButton } from 'twenty-ui/input'; import { FeatureFlagKey } from '~/generated-metadata/graphql'; import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault'; +import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; import { useOpenNavigationMenuItemInCommandMenu } from '@/navigation-menu-item/hooks/useOpenNavigationMenuItemInCommandMenu'; import { type NavigationMenuItemClickParams, useWorkspaceSectionItems, } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; import { isNavigationMenuInEditModeStateV2 } from '@/navigation-menu-item/states/isNavigationMenuInEditModeStateV2'; -import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; import { navigationMenuItemsDraftStateV2 } from '@/navigation-menu-item/states/navigationMenuItemsDraftStateV2'; -import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems'; import { openNavigationMenuItemFolderIdsStateV2 } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsStateV2'; import { selectedNavigationMenuItemInEditModeStateV2 } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeStateV2'; -import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2'; -import { useRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilStateV2'; -import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2'; +import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/utils/filterWorkspaceNavigationMenuItems'; 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 { useRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilStateV2'; +import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2'; +import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2'; import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; -import { useRecoilCallback } from 'recoil'; +import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; const StyledRightIconsContainer = styled.div` @@ -74,6 +71,7 @@ export const WorkspaceNavigationMenuItems = () => { const setOpenNavigationMenuItemFolderIds = useSetRecoilStateV2( openNavigationMenuItemFolderIdsStateV2, ); + const navigate = useNavigate(); const { navigateCommandMenu } = useNavigateCommandMenu(); const { openNavigationMenuItemInCommandMenu } = useOpenNavigationMenuItemInCommandMenu(); @@ -101,7 +99,7 @@ export const WorkspaceNavigationMenuItems = () => { ); openNavigationMenuItemInCommandMenu({ pageTitle: t`Edit folder`, - pageIcon: IconFolder, + pageIcon: getIcon(item.icon ?? item.Icon ?? FOLDER_ICON_DEFAULT), }); } else if (item.itemType === NavigationMenuItemType.LINK) { openNavigationMenuItemInCommandMenu({ @@ -113,6 +111,10 @@ export const WorkspaceNavigationMenuItems = () => { pageTitle: objectMetadataItem.labelPlural, pageIcon: getIcon(objectMetadataItem.icon), }); + + if (isNonEmptyString(item.link)) { + navigate(item.link); + } } }; 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 index f1f79e35fe..edf3d519aa 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder.tsx @@ -8,19 +8,19 @@ import { isDefined } from 'twenty-shared/utils'; import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection'; import { useLocation, useNavigate } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; -import { IconFolder, IconFolderOpen } from 'twenty-ui/display'; +import { IconChevronDown, IconChevronRight, useIcons } 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 { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault'; import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems'; import { openNavigationMenuItemFolderIdsStateV2 } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsStateV2'; -import { useRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilStateV2'; import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors'; import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel'; import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem'; @@ -32,11 +32,14 @@ import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/componen import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer'; import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; import { currentNavigationMenuItemFolderIdStateV2 } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdStateV2'; -import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2'; import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment'; +import { useRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilStateV2'; +import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2'; import { coreViewsState } from '@/views/states/coreViewState'; import { ViewKey } from '@/views/types/ViewKey'; import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; const StyledFolderContainer = styled.div<{ $isSelectedInEditMode: boolean }>` border: ${({ theme, $isSelectedInEditMode }) => @@ -49,6 +52,9 @@ const StyledFolderContainer = styled.div<{ $isSelectedInEditMode: boolean }>` const StyledFolderDroppableContent = styled.div<{ $compact: boolean; }>` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.betweenSiblingsGap}; padding-bottom: ${({ theme, $compact }) => ($compact ? 0 : theme.spacing(2))}; `; @@ -61,6 +67,7 @@ const StyledFolderExpandableWrapper = styled.div` type WorkspaceNavigationMenuItemsFolderProps = { folderId: string; folderName: string; + folderIconKey?: string | null; navigationMenuItems: ProcessedNavigationMenuItem[]; isGroup: boolean; isEditMode?: boolean; @@ -74,6 +81,7 @@ type WorkspaceNavigationMenuItemsFolderProps = { export const WorkspaceNavigationMenuItemsFolder = ({ folderId, folderName, + folderIconKey, navigationMenuItems, isGroup, isEditMode = false, @@ -84,7 +92,12 @@ export const WorkspaceNavigationMenuItemsFolder = ({ isDragging = false, }: WorkspaceNavigationMenuItemsFolderProps) => { const theme = useTheme(); + const { getIcon } = useIcons(); + const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED, + ); const iconColors = getNavigationMenuItemIconColors(theme); + const FolderIcon = getIcon(folderIconKey ?? FOLDER_ICON_DEFAULT); const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const coreViews = useRecoilValue(coreViewsState); const views = coreViews.map(convertCoreViewToView); @@ -149,13 +162,30 @@ export const WorkspaceNavigationMenuItemsFolder = ({ > + ) : ( + + ) + } /> diff --git a/packages/twenty-front/src/modules/navigation-menu-item/constants/FolderIconDefault.ts b/packages/twenty-front/src/modules/navigation-menu-item/constants/FolderIconDefault.ts new file mode 100644 index 0000000000..a5625c6c14 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/constants/FolderIconDefault.ts @@ -0,0 +1 @@ +export const FOLDER_ICON_DEFAULT = 'IconFolder'; 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 index 9f12488d0c..a15256f23d 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationDropTargetContext.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationDropTargetContext.tsx @@ -1,10 +1,16 @@ import { createContext } from 'react'; +export type AddToNavigationFallbackDestination = { + droppableId: string; + index: number; +} | null; + type NavigationDropTargetContextType = { activeDropTargetId: string | null; setActiveDropTargetId: (id: string | null) => void; forbiddenDropTargetId: string | null; setForbiddenDropTargetId: (id: string | null) => void; + addToNavigationFallbackDestination: AddToNavigationFallbackDestination; }; export const NavigationDropTargetContext = @@ -13,4 +19,5 @@ export const NavigationDropTargetContext = setActiveDropTargetId: () => {}, forbiddenDropTargetId: null, setForbiddenDropTargetId: () => {}, + addToNavigationFallbackDestination: null, }); 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 885682d35b..5e30e2a577 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 @@ -10,6 +10,7 @@ export const NAVIGATION_MENU_ITEM_FRAGMENT = gql` folderId name link + icon position applicationId createdAt 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 index 529eb105e0..3153b8d7e0 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSaveNavigationMenuItemsDraft.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSaveNavigationMenuItemsDraft.ts @@ -1,7 +1,10 @@ import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; -import { useCreateNavigationMenuItemMutation } from '~/generated-metadata/graphql'; +import { + type CreateNavigationMenuItemInput, + useCreateNavigationMenuItemMutation, +} from '~/generated-metadata/graphql'; import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem'; import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItem'; @@ -81,20 +84,13 @@ export const useSaveNavigationMenuItemsDraft = () => { ]; for (const draftItem of idsToCreateIncludingRecreated) { - const input: { - position: number; - folderId?: string | null; - name?: string; - link?: string; - viewId?: string; - targetObjectMetadataId?: string; - targetRecordId?: string; - } = { + const input: CreateNavigationMenuItemInput = { position: Math.max(0, Math.round(draftItem.position)), }; if (isNavigationMenuItemFolder(draftItem)) { input.name = draftItem.name ?? undefined; + input.icon = draftItem.icon ?? null; } else if (isNavigationMenuItemLink(draftItem)) { input.name = draftItem.name ?? 'Link'; const linkUrl = (draftItem.link ?? '').trim(); @@ -138,12 +134,16 @@ export const useSaveNavigationMenuItemsDraft = () => { const linkChanged = isNavigationMenuItemLink(draftItem) && (original.link ?? null) !== (draftItem.link ?? null); + const iconChanged = + isNavigationMenuItemFolder(draftItem) && + (original.icon ?? null) !== (draftItem.icon ?? null); if ( positionChanged || folderIdChanged || nameChanged || - linkChanged + linkChanged || + iconChanged ) { const updateInput: { id: string; @@ -151,6 +151,7 @@ export const useSaveNavigationMenuItemsDraft = () => { folderId?: string | null; name?: string; link?: string | null; + icon?: string | null; } = { id: draftItem.id }; if (positionChanged) { @@ -177,6 +178,9 @@ export const useSaveNavigationMenuItemsDraft = () => { : `https://${linkUrl}` : null; } + if (iconChanged && isNavigationMenuItemFolder(draftItem)) { + updateInput.icon = draftItem.icon ?? null; + } await updateNavigationMenuItem(updateInput); } diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateFolderInDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateFolderInDraft.ts new file mode 100644 index 0000000000..d0e7e0367b --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateFolderInDraft.ts @@ -0,0 +1,32 @@ +import { navigationMenuItemsDraftStateV2 } from '@/navigation-menu-item/states/navigationMenuItemsDraftStateV2'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; +import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2'; + +export type UpdateFolderInDraftUpdates = { + name?: string; + icon?: string; +}; + +export const useUpdateFolderInDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilStateV2( + navigationMenuItemsDraftStateV2, + ); + + const updateFolderInDraft = ( + folderId: string, + updates: UpdateFolderInDraftUpdates, + ) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + return draft.map((item) => { + if (!isNavigationMenuItemFolder(item) || item.id !== folderId) { + return item; + } + return { ...item, ...updates }; + }); + }); + }; + + return { updateFolderInDraft }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateNavigationMenuItemInDraft.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateNavigationMenuItemInDraft.ts new file mode 100644 index 0000000000..1b2b82f8b3 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateNavigationMenuItemInDraft.ts @@ -0,0 +1,31 @@ +import { navigationMenuItemsDraftStateV2 } from '@/navigation-menu-item/states/navigationMenuItemsDraftStateV2'; +import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2'; + +export type UpdateNavigationMenuItemInDraftUpdates = { + color?: string; + name?: string; + icon?: string; + link?: string; +}; + +export const useUpdateNavigationMenuItemInDraft = () => { + const setNavigationMenuItemsDraft = useSetRecoilStateV2( + navigationMenuItemsDraftStateV2, + ); + + const updateNavigationMenuItemInDraft = ( + navigationMenuItemId: string, + updates: UpdateNavigationMenuItemInDraftUpdates, + ) => { + setNavigationMenuItemsDraft((draft) => { + if (!draft) return draft; + + return draft.map((item) => { + if (item.id !== navigationMenuItemId) return item; + return { ...item, ...updates }; + }); + }); + }; + + return { updateNavigationMenuItemInDraft }; +}; 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 index 3110d2e8bc..bdda9815e5 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceSectionItems.ts @@ -1,12 +1,13 @@ 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 { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault'; import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/types/processed-navigation-menu-item'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem'; +import { isNavigationMenuItemFolder } from '@/navigation-menu-item/utils/isNavigationMenuItemFolder'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { coreViewsState } from '@/views/states/coreViewState'; import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; import { isDefined } from 'twenty-shared/utils'; @@ -19,6 +20,7 @@ export type FlatWorkspaceItem = | ProcessedNavigationMenuItem | (NavigationMenuItem & { itemType: NavigationMenuItemType.FOLDER; + Icon: string; }); export type NavigationMenuItemClickParams = { @@ -56,7 +58,11 @@ export const useWorkspaceSectionItems = (): FlatWorkspaceItem[] => { FlatWorkspaceItem[] >((acc, item) => { if (isNavigationMenuItemFolder(item)) { - acc.push({ ...item, itemType: NavigationMenuItemType.FOLDER }); + acc.push({ + ...item, + itemType: NavigationMenuItemType.FOLDER, + Icon: item.icon ?? FOLDER_ICON_DEFAULT, + }); } else { const processedItem = processedObjectViewsById.get(item.id); if (!isDefined(processedItem)) { diff --git a/packages/twenty-front/src/modules/navigation/components/PageDragDropProvider.tsx b/packages/twenty-front/src/modules/navigation/components/PageDragDropProvider.tsx index f6f3095cd0..d8af3b7765 100644 --- a/packages/twenty-front/src/modules/navigation/components/PageDragDropProvider.tsx +++ b/packages/twenty-front/src/modules/navigation/components/PageDragDropProvider.tsx @@ -12,18 +12,21 @@ 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 { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; 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 { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/hooks/useNavigationMenuItemsDraftState'; import { addToNavPayloadRegistryStateV2 } from '@/navigation-menu-item/states/addToNavPayloadRegistryStateV2'; 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 { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { isDefined } from 'twenty-shared/utils'; type PageDragDropProviderProps = { children: ReactNode; @@ -45,7 +48,12 @@ export const PageDragDropProvider = ({ const [forbiddenDropTargetId, setForbiddenDropTargetId] = useState< string | null >(null); + const [ + addToNavigationFallbackDestination, + setAddToNavigationFallbackDestination, + ] = useState<{ droppableId: string; index: number } | null>(null); + const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState(); const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop(); const { handleNavigationMenuItemDragAndDrop } = useHandleNavigationMenuItemDragAndDrop(); @@ -53,9 +61,22 @@ export const PageDragDropProvider = ({ useHandleWorkspaceNavigationMenuItemDragAndDrop(); const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop(); + const orphanItemCount = workspaceNavigationMenuItems.filter( + (item) => !isDefined(item.folderId), + ).length; + const handleDragStart = (dragStart: DragStart) => { setIsDragging(true); setSourceDroppableId(dragStart.source.droppableId); + if (dragStart.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) { + const defaultDestination = { + droppableId: + NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS, + index: orphanItemCount, + }; + setAddToNavigationFallbackDestination(defaultDestination); + setActiveDropTargetId(getDropTargetIdFromDestination(defaultDestination)); + } }; const handleDragUpdate = useRecoilCallback( @@ -65,36 +86,53 @@ export const PageDragDropProvider = ({ 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); + if ( + destination !== null && + isWorkspaceDroppableId(destination.droppableId) + ) { + setAddToNavigationFallbackDestination(destination); + const dropTargetId = getDropTargetIdFromDestination(destination); + setActiveDropTargetId(dropTargetId); - const payload = - jotaiStore - .get(addToNavPayloadRegistryStateV2.atom) - .get(update.draggableId) ?? null; - const folderId = validateAndExtractWorkspaceFolderId( - destination.droppableId, - ); - const isFolderOverFolder = - payload?.type === 'folder' && folderId !== null; - setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null); + const payload = + jotaiStore + .get(addToNavPayloadRegistryStateV2.atom) + .get(update.draggableId) ?? null; + const folderId = validateAndExtractWorkspaceFolderId( + destination.droppableId, + ); + const isFolderOverFolder = + payload?.type === 'folder' && folderId !== null; + setForbiddenDropTargetId(isFolderOverFolder ? dropTargetId : null); + } else { + setForbiddenDropTargetId(null); + const fallback = addToNavigationFallbackDestination; + setActiveDropTargetId( + fallback ? getDropTargetIdFromDestination(fallback) : null, + ); + } }) as OnDragUpdateResponder, - [], + [addToNavigationFallbackDestination], ); const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { + const isAddToNavigationSource = + result.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID; + const effectiveResult: DropResult = + isAddToNavigationSource && + !result.destination && + addToNavigationFallbackDestination + ? { ...result, destination: addToNavigationFallbackDestination } + : result; + setIsDragging(false); setSourceDroppableId(null); setActiveDropTargetId(null); setForbiddenDropTargetId(null); + setAddToNavigationFallbackDestination(null); - if (result.source.droppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID) { - handleAddToNavigationDrop(result, provided); + if (isAddToNavigationSource) { + handleAddToNavigationDrop(effectiveResult, provided); return; } @@ -122,6 +160,7 @@ export const PageDragDropProvider = ({ setActiveDropTargetId, forbiddenDropTargetId, setForbiddenDropTargetId, + addToNavigationFallbackDestination, }} > { const theme = useTheme(); + const isNavigationMenuItemEditingEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED, + ); const iconColors = getNavigationMenuItemIconColors(theme); const lastVisitedViewPerObjectMetadataItem = useRecoilValue( lastVisitedViewPerObjectMetadataItemState, @@ -111,14 +118,18 @@ export const NavigationDrawerItemForObjectMetadataItem = ({ /> ) : isViewWithCustomName && isDefined(navigationMenuItem?.Icon) - ? getIcon(navigationMenuItem.Icon) + ? () => ( + + ) : getIcon(objectMetadataItem.icon); - const iconBackgroundColor = isRecord - ? undefined - : isViewWithCustomName - ? iconColors.view - : iconColors.object; + const iconBackgroundColor = + isNavigationMenuItemEditingEnabled && !isRecord && !isViewWithCustomName + ? iconColors.object + : undefined; const secondaryLabel = isRecord || isViewWithCustomName diff --git a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx index 1f9cfde822..d14ff0fd41 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForWorkspaceItems.tsx @@ -1,4 +1,6 @@ +import { NavigationDropTargetContext } from '@/navigation-menu-item/contexts/NavigationDropTargetContext'; import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; import { Droppable } from '@hello-pangea/dnd'; import { useLingui } from '@lingui/react/macro'; import { useContext } from 'react'; @@ -7,12 +9,12 @@ import { isDefined } from 'twenty-shared/utils'; import { IconLink, IconPlus } from 'twenty-ui/display'; import { NavigationItemDropTarget } from '@/navigation-menu-item/components/NavigationItemDropTarget'; -import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; -import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection'; -import { NavigationSections } from '@/navigation-menu-item/constants/NavigationSections.constants'; import { WorkspaceNavigationMenuItemsFolder } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItemsFolder'; import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/constants/NavigationMenuItemDroppableIds'; +import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType'; +import { NavigationSections } from '@/navigation-menu-item/constants/NavigationSections.constants'; import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; +import { useIsDropDisabledForSection } from '@/navigation-menu-item/hooks/useIsDropDisabledForSection'; import { type FlatWorkspaceItem, type NavigationMenuItemClickParams, @@ -25,15 +27,21 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; 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'; +const StyledWorkspaceDroppableList = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.betweenSiblingsGap}; +`; + type NavigationDrawerSectionForWorkspaceItemsProps = { sectionTitle: string; items: FlatWorkspaceItem[]; @@ -70,8 +78,14 @@ export const NavigationDrawerSectionForWorkspaceItems = ({ const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const { isDragging } = useContext(NavigationMenuItemDragContext); + const { addToNavigationFallbackDestination } = useContext( + NavigationDropTargetContext, + ); const flatItems = items.filter((item) => !isDefined(item.folderId)); + const isAddToNavigationDropTargetVisible = + addToNavigationFallbackDestination?.droppableId === + NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS; const folderChildrenById = items.reduce< Map >((acc, item) => { @@ -140,7 +154,10 @@ export const NavigationDrawerSectionForWorkspaceItems = ({ }; }; - if (flatItems.length === 0) { + const isAddMenuItemButtonVisible = + isEditMode && isDefined(onAddMenuItem) && !isDragging; + + if (flatItems.length === 0 && !isAddToNavigationDropTargetVisible) { return null; } @@ -154,7 +171,7 @@ export const NavigationDrawerSectionForWorkspaceItems = ({ alwaysShowRightIcon={isEditMode} /> - {isNavigationSectionOpen && ( + {(isNavigationSectionOpen || isAddToNavigationDropTargetVisible) && ( {(provided) => ( -
- {isEditMode && onAddMenuItem && ( + {isAddMenuItemButtonVisible && ( )} + {addToNavigationFallbackDestination?.droppableId === + NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS && + addToNavigationFallbackDestination.index > + filteredItems.length && ( + + )} {provided.placeholder} -
+ )}
)} diff --git a/packages/twenty-front/src/modules/ui/layout/page-header/components/PageHeaderToggleCommandMenuButton.tsx b/packages/twenty-front/src/modules/ui/layout/page-header/components/PageHeaderToggleCommandMenuButton.tsx index 35fcfa2a54..4480001752 100644 --- a/packages/twenty-front/src/modules/ui/layout/page-header/components/PageHeaderToggleCommandMenuButton.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page-header/components/PageHeaderToggleCommandMenuButton.tsx @@ -1,9 +1,11 @@ +import { COMMAND_MENU_SEARCH_BAR_HEIGHT_MOBILE } from '@/command-menu/constants/CommandMenuSearchBarHeightMobile'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { isCommandMenuOpenedStateV2 } from '@/command-menu/states/isCommandMenuOpenedStateV2'; +import { isNavigationMenuInEditModeStateV2 } from '@/navigation-menu-item/states/isNavigationMenuInEditModeStateV2'; import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderCommandMenuButtonClickOutsideId'; import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2'; -import { useTheme } from '@emotion/react'; +import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { i18n } from '@lingui/core'; import { t } from '@lingui/core/macro'; @@ -12,8 +14,20 @@ import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/display'; import { AnimatedButton } from 'twenty-ui/input'; import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities'; -const StyledButtonWrapper = styled.div` +const StyledButtonWrapper = styled.div<{ + alignWithCommandMenuTopBar: boolean; +}>` z-index: ${RootStackingContextZIndices.CommandMenuButton}; + ${({ alignWithCommandMenuTopBar, theme }) => + alignWithCommandMenuTopBar && + css` + align-items: center; + display: flex; + height: ${COMMAND_MENU_SEARCH_BAR_HEIGHT_MOBILE}px; + position: fixed; + right: ${theme.spacing(3)}; + top: 0; + `} `; const StyledTooltipWrapper = styled.div` @@ -109,9 +123,15 @@ const AnimatedIcon = ({ export const PageHeaderToggleCommandMenuButton = () => { const { toggleCommandMenu } = useCommandMenu(); const isCommandMenuOpened = useRecoilValueV2(isCommandMenuOpenedStateV2); + const isNavigationMenuInEditMode = useRecoilValueV2( + isNavigationMenuInEditModeStateV2, + ); const isMobile = useIsMobile(); + const alignWithCommandMenuTopBar = + isMobile && isNavigationMenuInEditMode && isCommandMenuOpened; + const ariaLabel = isCommandMenuOpened ? t`Close command menu` : t`Open command menu`; @@ -119,7 +139,9 @@ export const PageHeaderToggleCommandMenuButton = () => { const theme = useTheme(); return ( - +
{ + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" ADD "icon" text`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" DROP COLUMN "icon"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util.ts index 4ed6d87400..39c2cabcb8 100644 --- a/packages/twenty-server/src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util.ts @@ -16,6 +16,7 @@ export const fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem = universalIdentifier: navigationMenuItemManifest.universalIdentifier, applicationUniversalIdentifier, name: navigationMenuItemManifest.name ?? null, + icon: navigationMenuItemManifest.icon ?? null, position: navigationMenuItemManifest.position, viewUniversalIdentifier: navigationMenuItemManifest.viewUniversalIdentifier ?? null, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap index ad3c9a0b50..3bab5b4738 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should match snapshot 1`] = ` { @@ -103,6 +103,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma "folderUniversalIdentifier", "name", "link", + "icon", ], "propertiesToStringify": [], }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts index a48ffa68a5..ba8edf0d8e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts @@ -1019,6 +1019,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { }, name: { toCompare: true, toStringify: false, universalProperty: undefined }, link: { toCompare: true, toStringify: false, universalProperty: undefined }, + icon: { toCompare: true, toStringify: false, universalProperty: undefined }, createdAt: { toCompare: false, toStringify: false, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant.ts index 05c77afb86..00bea27ab1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant.ts @@ -5,4 +5,5 @@ export const FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES = [ 'folderId', 'name', 'link', + 'icon', ] as const satisfies MetadataEntityPropertyName<'navigationMenuItem'>[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util.ts index c472be7fb8..831eb3e844 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util.ts @@ -81,6 +81,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate = folderUniversalIdentifier, name: createNavigationMenuItemInput.name ?? null, link: createNavigationMenuItemInput.link ?? null, + icon: createNavigationMenuItemInput.icon ?? null, position, workspaceId, applicationId: flatApplication.id, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util.ts index 24eab4587b..bc4c74468d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util.ts @@ -13,6 +13,7 @@ export const fromFlatNavigationMenuItemToNavigationMenuItemDto = ( folderId: flatNavigationMenuItem.folderId ?? undefined, name: flatNavigationMenuItem.name ?? undefined, link: flatNavigationMenuItem.link ?? undefined, + icon: flatNavigationMenuItem.icon ?? undefined, position: flatNavigationMenuItem.position, workspaceId: flatNavigationMenuItem.workspaceId, applicationId: flatNavigationMenuItem.applicationId ?? undefined, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util.ts index ee9265ed3b..00679ca296 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util.ts @@ -82,6 +82,7 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({ folderId: navigationMenuItemEntity.folderId, name: navigationMenuItemEntity.name, link: navigationMenuItemEntity.link, + icon: navigationMenuItemEntity.icon, position: navigationMenuItemEntity.position, workspaceId: navigationMenuItemEntity.workspaceId, universalIdentifier: navigationMenuItemEntity.universalIdentifier, diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input.ts index 9f516f1076..43274663b9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input.ts @@ -36,6 +36,11 @@ export class CreateNavigationMenuItemInput { @Field(() => String, { nullable: true }) link?: string | null; + @IsOptional() + @IsString() + @Field(() => String, { nullable: true }) + icon?: string | null; + @IsUUID() @IsOptional() @Field(() => UUIDScalarType, { nullable: true }) diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto.ts index da0dbc2722..67c8055c07 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto.ts @@ -47,6 +47,10 @@ export class NavigationMenuItemDTO { @Field(() => String, { nullable: true }) link?: string | null; + @IsOptional() + @Field(() => String, { nullable: true }) + icon?: string | null; + @IsUUID() @IsOptional() @Field(() => UUIDScalarType, { nullable: true }) diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input.ts index f87bd1ab60..210b0fd32f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input.ts @@ -35,6 +35,11 @@ export class UpdateNavigationMenuItemInput { @IsString() @Field(() => String, { nullable: true }) link?: string | null; + + @IsOptional() + @IsString() + @Field(() => String, { nullable: true }) + icon?: string | null; } @InputType() diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity.ts index ff48530017..91ec722f85 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity.ts @@ -84,6 +84,9 @@ export class NavigationMenuItemEntity @Column({ nullable: true, type: 'text' }) link: string | null; + @Column({ nullable: true, type: 'text' }) + icon: string | null; + @ManyToOne(() => NavigationMenuItemEntity, { onDelete: 'CASCADE', nullable: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index a3b008ec5f..0b1598c841 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -898,8 +898,9 @@ export class ObjectMetadataService extends TypeOrmQueryService