diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index 10978f53dd..56b8dd80dc 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -23,6 +23,7 @@ module.exports = { './src/modules/workflow/**/graphql/**/*.{ts,tsx}', './src/modules/analytics/graphql/**/*.{ts,tsx}', './src/modules/object-metadata/graphql/**/*.{ts,tsx}', + './src/modules/navigation-menu-item/graphql/**/*.{ts,tsx}', './src/modules/attachments/graphql/**/*.{ts,tsx}', './src/modules/file/graphql/**/*.{ts,tsx}', './src/modules/onboarding/graphql/**/*.{ts,tsx}', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index eb4fc9d41f..e1648d76d0 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1459,6 +1459,7 @@ export enum FeatureFlagKey { IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', + IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED', IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED', @@ -3184,6 +3185,7 @@ export type NavigationMenuItem = { position: Scalars['Float']; targetObjectMetadataId?: Maybe; targetRecordId?: Maybe; + targetRecordIdentifier?: Maybe; updatedAt: Scalars['DateTime']; userWorkspaceId?: Maybe; viewId?: Maybe; @@ -4086,6 +4088,13 @@ export type RatioAggregateConfig = { optionValue: Scalars['String']; }; +export type RecordIdentifier = { + __typename?: 'RecordIdentifier'; + id: Scalars['UUID']; + imageIdentifier?: Maybe; + labelIdentifier: Scalars['String']; +}; + export type Relation = { __typename?: 'Relation'; sourceFieldMetadata: Field; @@ -6050,6 +6059,43 @@ export type DeleteFileMutationVariables = Exact<{ export type DeleteFileMutation = { __typename?: 'Mutation', deleteFile: { __typename?: 'File', id: string, path: string, size: number, createdAt: 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, 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 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 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 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 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 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 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 }> }; export type CreateOneObjectMetadataItemMutationVariables = Exact<{ @@ -7387,6 +7433,31 @@ export const BillingPriceMeteredFragmentFragmentDoc = gql` } } `; +export const NavigationMenuItemFieldsFragmentDoc = gql` + fragment NavigationMenuItemFields on NavigationMenuItem { + id + userWorkspaceId + targetRecordId + targetObjectMetadataId + viewId + folderId + name + position + applicationId + createdAt + updatedAt +} + `; +export const NavigationMenuItemQueryFieldsFragmentDoc = gql` + fragment NavigationMenuItemQueryFields on NavigationMenuItem { + ...NavigationMenuItemFields + targetRecordIdentifier { + id + labelIdentifier + imageIdentifier + } +} + ${NavigationMenuItemFieldsFragmentDoc}`; export const ApiKeyFragmentFragmentDoc = gql` fragment ApiKeyFragment on ApiKey { id @@ -10314,6 +10385,174 @@ export function useDeleteFileMutation(baseOptions?: Apollo.MutationHookOptions; export type DeleteFileMutationResult = Apollo.MutationResult; export type DeleteFileMutationOptions = Apollo.BaseMutationOptions; +export const CreateNavigationMenuItemDocument = gql` + mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) { + createNavigationMenuItem(input: $input) { + ...NavigationMenuItemFields + } +} + ${NavigationMenuItemFieldsFragmentDoc}`; +export type CreateNavigationMenuItemMutationFn = Apollo.MutationFunction; + +/** + * __useCreateNavigationMenuItemMutation__ + * + * To run a mutation, you first call `useCreateNavigationMenuItemMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateNavigationMenuItemMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createNavigationMenuItemMutation, { data, loading, error }] = useCreateNavigationMenuItemMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateNavigationMenuItemMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateNavigationMenuItemDocument, options); + } +export type CreateNavigationMenuItemMutationHookResult = ReturnType; +export type CreateNavigationMenuItemMutationResult = Apollo.MutationResult; +export type CreateNavigationMenuItemMutationOptions = Apollo.BaseMutationOptions; +export const DeleteNavigationMenuItemDocument = gql` + mutation DeleteNavigationMenuItem($id: UUID!) { + deleteNavigationMenuItem(id: $id) { + ...NavigationMenuItemFields + } +} + ${NavigationMenuItemFieldsFragmentDoc}`; +export type DeleteNavigationMenuItemMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteNavigationMenuItemMutation__ + * + * To run a mutation, you first call `useDeleteNavigationMenuItemMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteNavigationMenuItemMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteNavigationMenuItemMutation, { data, loading, error }] = useDeleteNavigationMenuItemMutation({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useDeleteNavigationMenuItemMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteNavigationMenuItemDocument, options); + } +export type DeleteNavigationMenuItemMutationHookResult = ReturnType; +export type DeleteNavigationMenuItemMutationResult = Apollo.MutationResult; +export type DeleteNavigationMenuItemMutationOptions = Apollo.BaseMutationOptions; +export const UpdateNavigationMenuItemDocument = gql` + mutation UpdateNavigationMenuItem($input: UpdateOneNavigationMenuItemInput!) { + updateNavigationMenuItem(input: $input) { + ...NavigationMenuItemFields + } +} + ${NavigationMenuItemFieldsFragmentDoc}`; +export type UpdateNavigationMenuItemMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateNavigationMenuItemMutation__ + * + * To run a mutation, you first call `useUpdateNavigationMenuItemMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateNavigationMenuItemMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateNavigationMenuItemMutation, { data, loading, error }] = useUpdateNavigationMenuItemMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateNavigationMenuItemMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateNavigationMenuItemDocument, options); + } +export type UpdateNavigationMenuItemMutationHookResult = ReturnType; +export type UpdateNavigationMenuItemMutationResult = Apollo.MutationResult; +export type UpdateNavigationMenuItemMutationOptions = Apollo.BaseMutationOptions; +export const FindManyNavigationMenuItemsDocument = gql` + query FindManyNavigationMenuItems { + navigationMenuItems { + ...NavigationMenuItemQueryFields + } +} + ${NavigationMenuItemQueryFieldsFragmentDoc}`; + +/** + * __useFindManyNavigationMenuItemsQuery__ + * + * To run a query within a React component, call `useFindManyNavigationMenuItemsQuery` and pass it any options that fit your needs. + * When your component renders, `useFindManyNavigationMenuItemsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFindManyNavigationMenuItemsQuery({ + * variables: { + * }, + * }); + */ +export function useFindManyNavigationMenuItemsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindManyNavigationMenuItemsDocument, options); + } +export function useFindManyNavigationMenuItemsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindManyNavigationMenuItemsDocument, options); + } +export type FindManyNavigationMenuItemsQueryHookResult = ReturnType; +export type FindManyNavigationMenuItemsLazyQueryHookResult = ReturnType; +export type FindManyNavigationMenuItemsQueryResult = Apollo.QueryResult; +export const FindOneNavigationMenuItemDocument = gql` + query FindOneNavigationMenuItem($id: UUID!) { + navigationMenuItem(id: $id) { + ...NavigationMenuItemQueryFields + } +} + ${NavigationMenuItemQueryFieldsFragmentDoc}`; + +/** + * __useFindOneNavigationMenuItemQuery__ + * + * To run a query within a React component, call `useFindOneNavigationMenuItemQuery` and pass it any options that fit your needs. + * When your component renders, `useFindOneNavigationMenuItemQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFindOneNavigationMenuItemQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useFindOneNavigationMenuItemQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindOneNavigationMenuItemDocument, options); + } +export function useFindOneNavigationMenuItemLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindOneNavigationMenuItemDocument, options); + } +export type FindOneNavigationMenuItemQueryHookResult = ReturnType; +export type FindOneNavigationMenuItemLazyQueryHookResult = ReturnType; +export type FindOneNavigationMenuItemQueryResult = Apollo.QueryResult; export const CreateOneObjectMetadataItemDocument = gql` mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) { createOneObject(input: $input) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 8e916a23ca..f4fb83326d 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1426,6 +1426,7 @@ export enum FeatureFlagKey { IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', + IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED', IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED', @@ -3066,6 +3067,7 @@ export type NavigationMenuItem = { position: Scalars['Float']; targetObjectMetadataId?: Maybe; targetRecordId?: Maybe; + targetRecordIdentifier?: Maybe; updatedAt: Scalars['DateTime']; userWorkspaceId?: Maybe; viewId?: Maybe; @@ -3889,6 +3891,13 @@ export type RatioAggregateConfig = { optionValue: Scalars['String']; }; +export type RecordIdentifier = { + __typename?: 'RecordIdentifier'; + id: Scalars['UUID']; + imageIdentifier?: Maybe; + labelIdentifier: Scalars['String']; +}; + export type Relation = { __typename?: 'Relation'; sourceFieldMetadata: Field; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/AddToFavoritesSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/AddToFavoritesSingleRecordAction.tsx index cac5779fb9..bfbc5ba636 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/AddToFavoritesSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/AddToFavoritesSingleRecordAction.tsx @@ -2,16 +2,24 @@ import { Action } from '@/action-menu/actions/components/Action'; import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow'; import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite'; +import { useCreateNavigationMenuItem } from '@/navigation-menu-item/hooks/useCreateNavigationMenuItem'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; export const AddToFavoritesSingleRecordAction = () => { const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); const recordId = useSelectedRecordIdOrThrow(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + const { createFavorite } = useCreateFavorite(); + const { createNavigationMenuItem } = useCreateNavigationMenuItem(); const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId)); @@ -20,7 +28,11 @@ export const AddToFavoritesSingleRecordAction = () => { return; } - createFavorite(selectedRecord, objectMetadataItem.nameSingular); + if (isNavigationMenuItemEnabled) { + createNavigationMenuItem(selectedRecord, objectMetadataItem.nameSingular); + } else { + createFavorite(selectedRecord, objectMetadataItem.nameSingular); + } }; return ; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DeleteSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DeleteSingleRecordAction.tsx index 4564f16a3c..2c78c7d8b0 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DeleteSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DeleteSingleRecordAction.tsx @@ -2,11 +2,15 @@ import { Action } from '@/action-menu/actions/components/Action'; import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow'; import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData'; +import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; export const DeleteSingleRecordAction = () => { const { recordIndexId, objectMetadataItem } = @@ -25,6 +29,13 @@ export const DeleteSingleRecordAction = () => { const { sortedFavorites: favorites } = useFavorites(); const { deleteFavorite } = useDeleteFavorite(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + const { navigationMenuItems, workspaceNavigationMenuItems } = + usePrefetchedNavigationMenuItemsData(); + const { removeNavigationMenuItemsByTargetRecordIds } = + useRemoveNavigationMenuItemByTargetRecordId(); const handleDeleteClick = async () => { removeSelectedRecordsFromRecordBoard(); @@ -39,6 +50,17 @@ export const DeleteSingleRecordAction = () => { deleteFavorite(foundFavorite.id); } + if (isNavigationMenuItemEnabled) { + const foundNavigationMenuItem = [ + ...navigationMenuItems, + ...workspaceNavigationMenuItems, + ].find((item) => item.targetRecordId === recordId); + + if (isDefined(foundNavigationMenuItem)) { + removeNavigationMenuItemsByTargetRecordIds([recordId]); + } + } + await deleteOneRecord(recordId); }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/RemoveFromFavoritesSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/RemoveFromFavoritesSingleRecordAction.tsx index a41eeb366b..30ee938443 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/RemoveFromFavoritesSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/RemoveFromFavoritesSingleRecordAction.tsx @@ -1,21 +1,50 @@ import { Action } from '@/action-menu/actions/components/Action'; import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow'; +import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem'; +import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; export const RemoveFromFavoritesSingleRecordAction = () => { const recordId = useSelectedRecordIdOrThrow(); + const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); const { sortedFavorites: favorites } = useFavorites(); + const { navigationMenuItems, workspaceNavigationMenuItems } = + usePrefetchedNavigationMenuItemsData(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); const { deleteFavorite } = useDeleteFavorite(); + const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem(); const foundFavorite = favorites?.find( (favorite) => favorite.recordId === recordId, ); + const foundNavigationMenuItem = isNavigationMenuItemEnabled + ? [...navigationMenuItems, ...workspaceNavigationMenuItems].find( + (item) => + item.targetRecordId === recordId && + item.targetObjectMetadataId === objectMetadataItem.id, + ) + : undefined; + const handleClick = () => { + if (isNavigationMenuItemEnabled) { + if (!isDefined(foundNavigationMenuItem)) { + return; + } + + deleteNavigationMenuItem(foundNavigationMenuItem.id); + return; + } + if (!isDefined(foundFavorite)) { return; } diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts b/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts index 5178f3850b..319ad57faa 100644 --- a/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts @@ -8,14 +8,18 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType'; import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { useContext } from 'react'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { useContext, useMemo } from 'react'; import { useRecoilCallback, useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; export const useShouldActionBeRegisteredParams = ({ objectMetadataItem, @@ -23,6 +27,10 @@ export const useShouldActionBeRegisteredParams = ({ objectMetadataItem?: ObjectMetadataItem; }): ShouldBeRegisteredFunctionParams => { const { sortedFavorites: favorites } = useFavorites(); + const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); const contextStoreTargetedRecordsRule = useRecoilComponentValue( contextStoreTargetedRecordsRuleComponentState, @@ -33,11 +41,31 @@ export const useShouldActionBeRegisteredParams = ({ ? contextStoreTargetedRecordsRule.selectedRecordIds[0] : undefined; - const foundFavorite = favorites?.find( - (favorite) => favorite.recordId === recordId, - ); + const isFavorite = useMemo(() => { + if (!isDefined(recordId)) { + return false; + } - const isFavorite = !!foundFavorite; + if (isNavigationMenuItemEnabled && isDefined(objectMetadataItem)) { + const foundNavigationMenuItem = navigationMenuItems?.find( + (item) => + item.targetRecordId === recordId && + item.targetObjectMetadataId === objectMetadataItem.id, + ); + return !!foundNavigationMenuItem; + } + + const foundFavorite = favorites?.find( + (favorite) => favorite.recordId === recordId, + ); + return !!foundFavorite; + }, [ + recordId, + isNavigationMenuItemEnabled, + objectMetadataItem, + navigationMenuItems, + favorites, + ]); const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId ?? '')) || undefined; diff --git a/packages/twenty-front/src/modules/favorites/constants/FavoriteDroppableIds.ts b/packages/twenty-front/src/modules/favorites/constants/FavoriteDroppableIds.ts index fbb63fa666..b581b78aa0 100644 --- a/packages/twenty-front/src/modules/favorites/constants/FavoriteDroppableIds.ts +++ b/packages/twenty-front/src/modules/favorites/constants/FavoriteDroppableIds.ts @@ -1,5 +1,7 @@ +import { FOLDER_DROPPABLE_IDS } from '@/ui/layout/draggable-list/utils/folderDroppableIds'; + export const FAVORITE_DROPPABLE_IDS = { ORPHAN_FAVORITES: 'orphan-favorites', - FOLDER_PREFIX: 'folder-', - FOLDER_HEADER_PREFIX: 'folder-header-', -}; + FOLDER_PREFIX: FOLDER_DROPPABLE_IDS.FOLDER_PREFIX, + FOLDER_HEADER_PREFIX: FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX, +} as const; diff --git a/packages/twenty-front/src/modules/favorites/hooks/useHandleFavoriteDragAndDrop.ts b/packages/twenty-front/src/modules/favorites/hooks/useHandleFavoriteDragAndDrop.ts index 90c28f8f06..c51f2ce2ef 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/useHandleFavoriteDragAndDrop.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/useHandleFavoriteDragAndDrop.ts @@ -1,10 +1,10 @@ import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites'; import { openFavoriteFolderIdsState } from '@/favorites/states/openFavoriteFolderIdsState'; -import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition'; -import { validateAndExtractFolderId } from '@/favorites/utils/validateAndExtractFolderId'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition'; +import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId'; import { type OnDragEndResponder } from '@hello-pangea/dnd'; import { useSetRecoilState } from 'recoil'; import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData'; @@ -43,10 +43,14 @@ export const useHandleFavoriteDragAndDrop = () => { const draggedFavorite = favorites.find((f) => f.id === draggableId); if (!draggedFavorite) return; - const destinationFolderId = validateAndExtractFolderId( - destination.droppableId, - ); - const sourceFolderId = validateAndExtractFolderId(source.droppableId); + const destinationFolderId = validateAndExtractFolderId({ + droppableId: destination.droppableId, + orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }); + const sourceFolderId = validateAndExtractFolderId({ + droppableId: source.droppableId, + orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }); if ( destination.droppableId.startsWith( diff --git a/packages/twenty-front/src/modules/favorites/utils/__tests__/getObjectMetadataNamePluralFromViewId.test.ts b/packages/twenty-front/src/modules/favorites/utils/__tests__/getObjectMetadataNamePluralFromViewId.test.ts new file mode 100644 index 0000000000..c22871d676 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/utils/__tests__/getObjectMetadataNamePluralFromViewId.test.ts @@ -0,0 +1,37 @@ +import { getObjectMetadataNamePluralFromViewId } from '@/favorites/utils/getObjectMetadataNamePluralFromViewId'; +import { type View } from '@/views/types/View'; +import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems'; + +describe('getObjectMetadataNamePluralFromViewId', () => { + it('should return namePlural and view for matching objectMetadataId', () => { + const view: Pick = { + id: 'view-id', + name: 'All People', + objectMetadataId: + generatedMockObjectMetadataItems[0]?.id ?? 'metadata-id', + }; + + const result = getObjectMetadataNamePluralFromViewId( + view, + generatedMockObjectMetadataItems, + ); + + expect(result.namePlural).toBeDefined(); + expect(result.view).toEqual(view); + }); + + it('should throw error when objectMetadataItem is not found', () => { + const view: Pick = { + id: 'view-id', + name: 'All People', + objectMetadataId: 'non-existent-id', + }; + + expect(() => { + getObjectMetadataNamePluralFromViewId( + view, + generatedMockObjectMetadataItems, + ); + }).toThrow('Object metadata item not found for id non-existent-id'); + }); +}); diff --git a/packages/twenty-front/src/modules/favorites/utils/__tests__/sortFavorites.test.ts b/packages/twenty-front/src/modules/favorites/utils/__tests__/sortFavorites.test.ts new file mode 100644 index 0000000000..34863c7696 --- /dev/null +++ b/packages/twenty-front/src/modules/favorites/utils/__tests__/sortFavorites.test.ts @@ -0,0 +1,233 @@ +import { type Favorite } from '@/favorites/types/Favorite'; +import { sortFavorites } from '@/favorites/utils/sortFavorites'; +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; +import { type View } from '@/views/types/View'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +jest.mock('@/favorites/utils/getObjectMetadataNamePluralFromViewId', () => ({ + getObjectMetadataNamePluralFromViewId: jest.fn( + ( + view: Pick, + items: ObjectMetadataItem[], + ) => { + const item = items.find((item) => item.id === view.objectMetadataId); + return { namePlural: item?.namePlural ?? 'items' }; + }, + ), +})); + +jest.mock('twenty-shared/utils', () => { + const actual = jest.requireActual('twenty-shared/utils'); + return { + ...actual, + getAppPath: jest.fn((path, params, query) => { + const basePath = `/app/objects/${params.objectNamePlural}`; + const viewId = query?.viewId; + if (viewId !== undefined && viewId !== null) { + return `${basePath}?viewId=${viewId}`; + } + return basePath; + }), + }; +}); + +describe('sortFavorites', () => { + const mockView: Pick = { + id: 'view-id', + name: 'All People', + objectMetadataId: 'metadata-id', + icon: 'IconUser', + }; + + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'metadata-id', + nameSingular: 'person', + namePlural: 'people', + } as ObjectMetadataItem; + + const mockObjectRecord: ObjectRecord = { + __typename: 'ObjectRecord', + id: 'record-id', + name: 'John Doe', + } as ObjectRecord; + + const mockObjectRecordIdentifier: ObjectRecordIdentifier = { + id: 'record-id', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.jpg', + avatarType: 'rounded', + linkToShowPage: '/app/objects/people/record-id', + }; + + const mockRelationField: FieldMetadataItem = { + name: 'person', + type: FieldMetadataType.RELATION, + relation: { + targetObjectMetadata: { + nameSingular: 'person', + }, + }, + } as FieldMetadataItem; + + const getObjectRecordIdentifierByNameSingular = jest.fn( + ( + record: ObjectRecord, + objectNameSingular: string, + ): ObjectRecordIdentifier => { + if (objectNameSingular === 'person') { + return mockObjectRecordIdentifier; + } + return { + id: record.id, + name: 'Unknown', + }; + }, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should process favorite with viewId', () => { + const favorite = { + id: 'favorite-id', + viewId: 'view-id', + position: 1, + } as unknown as Favorite; + + const result = sortFavorites( + [favorite], + [], + getObjectRecordIdentifierByNameSingular, + true, + [mockView], + [mockObjectMetadataItem], + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 'favorite-id', + objectNameSingular: 'view', + Icon: 'IconUser', + }); + }); + + it('should handle favorite with viewId when view is not found', () => { + const favorite = { + id: 'favorite-id', + viewId: 'non-existent-view-id', + position: 1, + } as unknown as Favorite; + + const result = sortFavorites( + [favorite], + [], + getObjectRecordIdentifierByNameSingular, + true, + [], + [mockObjectMetadataItem], + ); + + expect(result).toHaveLength(1); + expect(result[0].objectNameSingular).toBe('view'); + }); + + it('should process favorite with relation field', () => { + const favorite = { + id: 'favorite-id', + person: mockObjectRecord, + position: 2, + } as unknown as Favorite; + + const result = sortFavorites( + [favorite], + [mockRelationField], + getObjectRecordIdentifierByNameSingular, + true, + [], + [], + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 'favorite-id', + objectNameSingular: 'person', + labelIdentifier: 'John Doe', + }); + }); + + it('should return empty link when hasLinkToShowPage is false', () => { + const favorite = { + id: 'favorite-id', + person: mockObjectRecord, + position: 2, + } as unknown as Favorite; + + const result = sortFavorites( + [favorite], + [mockRelationField], + getObjectRecordIdentifierByNameSingular, + false, + [], + [], + ); + + expect(result).toHaveLength(1); + expect(result[0].link).toBe(''); + }); + + it('should sort favorites by position', () => { + const favorites = [ + { + id: 'favorite-3', + viewId: 'view-id', + position: 3, + }, + { + id: 'favorite-1', + viewId: 'view-id', + position: 1, + }, + { + id: 'favorite-2', + viewId: 'view-id', + position: 2, + }, + ] as unknown as Favorite[]; + + const result = sortFavorites( + favorites, + [], + getObjectRecordIdentifierByNameSingular, + true, + [mockView], + [mockObjectMetadataItem], + ); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('favorite-1'); + expect(result[1].id).toBe('favorite-2'); + expect(result[2].id).toBe('favorite-3'); + }); + + it('should filter out favorites with no viewId and no relation fields', () => { + const favorite = { + id: 'favorite-id', + position: 1, + } as unknown as Favorite; + + const result = sortFavorites( + [favorite], + [], + getObjectRecordIdentifierByNameSingular, + true, + [], + [], + ); + + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/twenty-front/src/modules/favorites/utils/__tests__/validateAndExtractFolderId.test.ts b/packages/twenty-front/src/modules/favorites/utils/__tests__/validateAndExtractFolderId.test.ts index 6cf00ef4bd..964100b2eb 100644 --- a/packages/twenty-front/src/modules/favorites/utils/__tests__/validateAndExtractFolderId.test.ts +++ b/packages/twenty-front/src/modules/favorites/utils/__tests__/validateAndExtractFolderId.test.ts @@ -1,11 +1,12 @@ import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; -import { validateAndExtractFolderId } from '@/favorites/utils/validateAndExtractFolderId'; +import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId'; describe('validateAndExtractFolderId', () => { it('should return null for orphan favorites', () => { - const result = validateAndExtractFolderId( - FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, - ); + const result = validateAndExtractFolderId({ + droppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }); expect(result).toBeNull(); }); @@ -13,7 +14,10 @@ describe('validateAndExtractFolderId', () => { const folderId = '123-456'; const droppableId = `${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`; - const result = validateAndExtractFolderId(droppableId); + const result = validateAndExtractFolderId({ + droppableId, + orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }); expect(result).toBe(folderId); }); @@ -21,25 +25,37 @@ describe('validateAndExtractFolderId', () => { const folderId = '123-456'; const droppableId = `${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`; - const result = validateAndExtractFolderId(droppableId); + const result = validateAndExtractFolderId({ + droppableId, + orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }); expect(result).toBe(folderId); }); it('should throw error for invalid droppable id format', () => { expect(() => { - validateAndExtractFolderId('invalid-id'); + validateAndExtractFolderId({ + droppableId: 'invalid-id', + orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }); }).toThrow('Invalid droppable ID format: invalid-id'); }); it('should throw error for empty folder id in folder format', () => { expect(() => { - validateAndExtractFolderId(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX); + validateAndExtractFolderId({ + droppableId: FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX, + orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }); }).toThrow(`Invalid folder ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}`); }); it('should throw error for empty folder id in folder header format', () => { expect(() => { - validateAndExtractFolderId(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX); + validateAndExtractFolderId({ + droppableId: FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX, + orphanDroppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES, + }); }).toThrow( `Invalid folder header ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}`, ); diff --git a/packages/twenty-front/src/modules/favorites/utils/validateAndExtractFolderId.ts b/packages/twenty-front/src/modules/favorites/utils/validateAndExtractFolderId.ts deleted file mode 100644 index 62e774b413..0000000000 --- a/packages/twenty-front/src/modules/favorites/utils/validateAndExtractFolderId.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds'; -import { CustomError } from 'twenty-shared/utils'; - -export const validateAndExtractFolderId = ( - droppableId: string, -): string | null => { - if (droppableId === FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES) { - return null; - } - - if (droppableId.startsWith(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX)) { - const folderId = droppableId.replace( - FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX, - '', - ); - if (!folderId) - throw new CustomError( - `Invalid folder header ID: ${droppableId}`, - 'INVALID_FOLDER_HEADER_ID', - ); - return folderId; - } - - if (droppableId.startsWith(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX)) { - const folderId = droppableId.replace( - FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX, - '', - ); - if (!folderId) - throw new CustomError( - `Invalid folder ID: ${droppableId}`, - 'INVALID_FOLDER_ID', - ); - return folderId; - } - - throw new CustomError( - `Invalid droppable ID format: ${droppableId}`, - 'INVALID_DROPPABLE_ID_FORMAT', - ); -}; 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 new file mode 100644 index 0000000000..a3f2053aaa --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders.tsx @@ -0,0 +1,84 @@ +import { useLingui } from '@lingui/react/macro'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { IconFolderPlus } from 'twenty-ui/display'; +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'; +import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems'; +import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/states/isNavigationMenuItemFolderCreatingState'; +import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; +import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; +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'; + +export const CurrentWorkspaceMemberNavigationMenuItemFolders = () => { + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const { navigationMenuItemsSorted } = useSortedNavigationMenuItems(); + const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder(); + + const [ + isNavigationMenuItemFolderCreating, + setIsNavigationMenuItemFolderCreating, + ] = useRecoilState(isNavigationMenuItemFolderCreatingState); + + const loading = useIsPrefetchLoading(); + + const { t } = useLingui(); + + const { + toggleNavigationSection, + isNavigationSectionOpenState, + openNavigationSection, + } = useNavigationSection('Favorites'); + const isNavigationSectionOpen = useRecoilValue(isNavigationSectionOpenState); + + const toggleNewFolder = () => { + openNavigationSection(); + setIsNavigationMenuItemFolderCreating((current) => !current); + }; + + if (loading && isDefined(currentWorkspaceMember)) { + return ; + } + + if ( + (!navigationMenuItemsSorted || navigationMenuItemsSorted.length === 0) && + !isNavigationMenuItemFolderCreating && + (!navigationMenuItemsByFolder || navigationMenuItemsByFolder.length === 0) + ) { + return null; + } + + return ( + + + + } + /> + + {isNavigationSectionOpen && ( + + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher.tsx new file mode 100644 index 0000000000..40e9345566 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher.tsx @@ -0,0 +1,17 @@ +import { FeatureFlagKey } from '~/generated/graphql'; + +import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders'; +import { CurrentWorkspaceMemberNavigationMenuItemFolders } from '@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFolders'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +export const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = () => { + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + + if (isNavigationMenuItemEnabled) { + return ; + } + + return ; +}; 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 new file mode 100644 index 0000000000..22d2d52d8f --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems.tsx @@ -0,0 +1,289 @@ +import { Droppable } from '@hello-pangea/dnd'; +import { useLingui } from '@lingui/react/macro'; +import { useContext, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useLocation } from 'react-router-dom'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { IconFolder, IconFolderOpen, IconHeartOff } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; +import { AnimatedExpandableContainer } from 'twenty-ui/layout'; +import { useIsMobile } from 'twenty-ui/utilities'; + +import { NavigationMenuItemDroppable } from '@/navigation-menu-item/components/NavigationMenuItemDroppable'; +import { NavigationMenuItemFolderNavigationDrawerItemDropdown } from '@/navigation-menu-item/components/NavigationMenuItemFolderNavigationDrawerItemDropdown'; +import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon'; +import { NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID } from '@/navigation-menu-item/constants/NavigationMenuItemFolderDeleteModalId'; +import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; +import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem'; +import { useDeleteNavigationMenuItemFolder } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItemFolder'; +import { useRenameNavigationMenuItemFolder } from '@/navigation-menu-item/hooks/useRenameNavigationMenuItemFolder'; +import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState'; +import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel'; +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 { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; +import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput'; +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 { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +type CurrentWorkspaceMemberNavigationMenuItemsProps = { + folder: { + folderId: string; + folderName: string; + navigationMenuItems: ProcessedNavigationMenuItem[]; + }; + isGroup: boolean; +}; + +export const CurrentWorkspaceMemberNavigationMenuItems = ({ + folder, + isGroup, +}: CurrentWorkspaceMemberNavigationMenuItemsProps) => { + const { t } = useLingui(); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const currentPath = useLocation().pathname; + const currentViewPath = useLocation().pathname + useLocation().search; + const { isDragging } = useContext(NavigationMenuItemDragContext); + const [ + isNavigationMenuItemFolderRenaming, + setIsNavigationMenuItemFolderRenaming, + ] = useState(false); + const [navigationMenuItemFolderName, setNavigationMenuItemFolderName] = + useState(folder.folderName); + const { openModal } = useModal(); + + const isMobile = useIsMobile(); + + const [openNavigationMenuItemFolderIds, setOpenNavigationMenuItemFolderIds] = + useRecoilState(openNavigationMenuItemFolderIdsState); + + const setCurrentFolderId = useSetRecoilState( + currentNavigationMenuItemFolderIdState, + ); + + const isOpen = openNavigationMenuItemFolderIds.includes(folder.folderId); + + const handleToggle = () => { + if (isMobile) { + setCurrentFolderId((prev) => + prev === folder.folderId ? null : folder.folderId, + ); + } else { + setOpenNavigationMenuItemFolderIds((currentOpenFolders) => { + if (isOpen) { + return currentOpenFolders.filter((id) => id !== folder.folderId); + } else { + return [...currentOpenFolders, folder.folderId]; + } + }); + } + }; + + const { renameNavigationMenuItemFolder } = + useRenameNavigationMenuItemFolder(); + const { deleteNavigationMenuItemFolder } = + useDeleteNavigationMenuItemFolder(); + + const dropdownId = `navigation-menu-item-folder-edit-${folder.folderId}`; + + const isDropdownOpenComponent = useRecoilComponentValue( + isDropdownOpenComponentState, + dropdownId, + ); + + const { closeDropdown } = useCloseDropdown(); + + const selectedNavigationMenuItemIndex = folder.navigationMenuItems.findIndex( + (item) => + isLocationMatchingNavigationMenuItem(currentPath, currentViewPath, item), + ); + + const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem(); + + const navigationMenuItemFolderContentLength = + folder.navigationMenuItems.length; + + const handleSubmitRename = async (value: string) => { + if (value === '') return; + await renameNavigationMenuItemFolder(folder.folderId, value); + setIsNavigationMenuItemFolderRenaming(false); + return true; + }; + + const handleCancelRename = () => { + setNavigationMenuItemFolderName(folder.folderName); + setIsNavigationMenuItemFolderRenaming(false); + }; + + const handleClickOutside = async ( + _event: MouseEvent | TouchEvent, + value: string, + ) => { + if (!value) { + setIsNavigationMenuItemFolderRenaming(false); + return; + } + + await renameNavigationMenuItemFolder(folder.folderId, value); + setIsNavigationMenuItemFolderRenaming(false); + }; + + const modalId = `${NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID}-${folder.folderId}`; + + const handleNavigationMenuItemFolderDelete = async () => { + if (folder.navigationMenuItems.length > 0) { + openModal(modalId); + closeDropdown(dropdownId); + } else { + await deleteNavigationMenuItemFolder(folder.folderId); + closeDropdown(dropdownId); + } + }; + + const handleConfirmDelete = async () => { + await deleteNavigationMenuItemFolder(folder.folderId); + }; + + const rightOptions = ( + setIsNavigationMenuItemFolderRenaming(true)} + onDelete={handleNavigationMenuItemFolderDelete} + closeDropdown={() => { + closeDropdown(dropdownId); + }} + /> + ); + + const isModalOpened = useRecoilComponentValue( + isModalOpenedComponentState, + modalId, + ); + + const navigationMenuItemCount = folder.navigationMenuItems.length; + + return ( + <> + + {isNavigationMenuItemFolderRenaming ? ( + + ) : ( + + + + )} + + + + {(provided) => ( +
+ {folder.navigationMenuItems.map((navigationMenuItem, index) => ( + ( + + )} + to={isDragging ? undefined : navigationMenuItem.link} + active={index === selectedNavigationMenuItemIndex} + subItemState={getNavigationSubItemLeftAdornment({ + index, + arrayLength: navigationMenuItemFolderContentLength, + selectedIndex: selectedNavigationMenuItemIndex, + })} + rightOptions={ + + deleteNavigationMenuItem(navigationMenuItem.id) + } + accent="tertiary" + /> + } + isDragging={isDragging} + triggerEvent="CLICK" + /> + } + /> + ))} + {provided.placeholder} +
+ )} +
+
+
+ + {isModalOpened && + createPortal( + 1 + ? t`Remove ${navigationMenuItemCount} navigation menu items?` + : t`Remove ${navigationMenuItemCount} navigation menu item?` + } + subtitle={ + folder.navigationMenuItems.length > 1 + ? t`This action will delete this folder and all ${navigationMenuItemCount} navigation menu items inside. Do you want to continue?` + : t`This action will delete this folder and the navigation menu item inside. Do you want to continue?` + } + onConfirmClick={handleConfirmDelete} + confirmButtonText={t`Delete Folder`} + />, + document.body, + )} + + ); +}; 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 new file mode 100644 index 0000000000..4b3e6bb3c7 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/CurrentWorkspaceMemberOrphanNavigationMenuItems.tsx @@ -0,0 +1,92 @@ +import styled from '@emotion/styled'; +import { useContext } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; +import { IconHeartOff } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; + +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 { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; +import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem'; +import { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems'; +import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel'; +import { isLocationMatchingNavigationMenuItem } from '@/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem'; +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'; + +const StyledEmptyContainer = styled.div` + width: 100%; +`; + +const StyledOrphanNavigationMenuItemsContainer = styled.div` + margin-bottom: ${({ theme }) => theme.betweenSiblingsGap}; +`; + +export const CurrentWorkspaceMemberOrphanNavigationMenuItems = () => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const { navigationMenuItemsSorted } = useSortedNavigationMenuItems(); + const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem(); + const currentPath = useLocation().pathname; + const currentViewPath = useLocation().pathname + useLocation().search; + const { isDragging } = useContext(NavigationMenuItemDragContext); + + const orphanNavigationMenuItems = navigationMenuItemsSorted.filter( + (item) => !item.folderId, + ); + + return ( + + {orphanNavigationMenuItems.length > 0 ? ( + orphanNavigationMenuItems.map((navigationMenuItem, index) => ( + + ( + + )} + active={isLocationMatchingNavigationMenuItem( + currentPath, + currentViewPath, + navigationMenuItem, + )} + to={isDragging ? undefined : navigationMenuItem.link} + rightOptions={ + + deleteNavigationMenuItem(navigationMenuItem.id) + } + accent="tertiary" + /> + } + isDragging={isDragging} + triggerEvent="CLICK" + /> + + } + /> + )) + ) : ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemBackButton.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemBackButton.tsx new file mode 100644 index 0000000000..bea21386dd --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemBackButton.tsx @@ -0,0 +1,59 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useSetRecoilState } from 'recoil'; +import { IconX } from 'twenty-ui/display'; + +import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState'; + +const StyledBackButton = styled.button` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(2)}; + padding-left: ${({ theme }) => theme.spacing(1)}; + padding-right: ${({ theme }) => theme.spacing(0.5)}; + padding-top: ${({ theme }) => theme.spacing(1)}; + padding-bottom: ${({ theme }) => theme.spacing(1)}; + width: 100%; + background: transparent; + border: none; + color: ${({ theme }) => theme.font.color.secondary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + font-family: ${({ theme }) => theme.font.family}; + cursor: pointer; + text-align: left; + + &:hover { + background: ${({ theme }) => theme.background.transparent.light}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + } +`; + +type NavigationMenuItemBackButtonProps = { + folderName: string; +}; + +export const NavigationMenuItemBackButton = ({ + folderName, +}: NavigationMenuItemBackButtonProps) => { + const theme = useTheme(); + const setCurrentFolderId = useSetRecoilState( + currentNavigationMenuItemFolderIdState, + ); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setCurrentFolderId(null); + }; + + return ( + + + {folderName} + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemDragProvider.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemDragProvider.tsx new file mode 100644 index 0000000000..95d1298cd7 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemDragProvider.tsx @@ -0,0 +1,39 @@ +import { + DragDropContext, + type DragStart, + type DropResult, + type ResponderProvided, +} from '@hello-pangea/dnd'; +import { type ReactNode, useState } from 'react'; + +import { NavigationMenuItemDragContext } from '@/navigation-menu-item/contexts/NavigationMenuItemDragContext'; +import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop'; + +type NavigationMenuItemDragProviderProps = { + children: ReactNode; +}; + +export const NavigationMenuItemDragProvider = ({ + children, +}: NavigationMenuItemDragProviderProps) => { + const [isDragging, setIsDragging] = useState(false); + const { handleNavigationMenuItemDragAndDrop } = + useHandleNavigationMenuItemDragAndDrop(); + + const handleDragStart = (_: DragStart) => { + setIsDragging(true); + }; + + const handleDragEnd = (result: DropResult, provided: ResponderProvided) => { + setIsDragging(false); + handleNavigationMenuItemDragAndDrop(result, provided); + }; + + return ( + + + {children} + + + ); +}; 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 new file mode 100644 index 0000000000..8201e86121 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemDroppable.tsx @@ -0,0 +1,70 @@ +import styled from '@emotion/styled'; +import { Droppable } from '@hello-pangea/dnd'; + +type NavigationMenuItemDroppableProps = { + droppableId: string; + children: React.ReactNode; + isDragIndicatorVisible?: boolean; + showDropLine?: boolean; +}; + +const StyledDroppableWrapper = styled.div<{ + isDraggingOver: boolean; + isDragIndicatorVisible: boolean; + showDropLine: boolean; +}>` + position: relative; + transition: all 150ms ease-in-out; + width: 100%; + + ${({ isDraggingOver, isDragIndicatorVisible, showDropLine, theme }) => + isDraggingOver && + isDragIndicatorVisible && + ` + background-color: ${theme.background.transparent.blue}; + + ${ + showDropLine && + ` + &::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; + } + ` + } + `} +`; + +export const NavigationMenuItemDroppable = ({ + droppableId, + children, + isDragIndicatorVisible = true, + showDropLine = true, +}: NavigationMenuItemDroppableProps) => { + return ( + + {(provided, snapshot) => ( + +
+ {children} + {provided.placeholder} +
+
+ )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolderContent.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolderContent.tsx new file mode 100644 index 0000000000..478fbe2ef7 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolderContent.tsx @@ -0,0 +1,82 @@ +import { Droppable } from '@hello-pangea/dnd'; +import { useRecoilValue } from 'recoil'; +import { IconHeartOff } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; + +import { NavigationMenuItemBackButton } from '@/navigation-menu-item/components/NavigationMenuItemBackButton'; +import { NavigationMenuItemDragProvider } from '@/navigation-menu-item/components/NavigationMenuItemDragProvider'; +import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon'; +import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem'; +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 { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; + +type NavigationMenuItemFolderContentProps = { + folderId: string; + folderName: string; + navigationMenuItems: ProcessedNavigationMenuItem[]; +}; + +export const NavigationMenuItemFolderContent = ({ + folderName, + folderId, + navigationMenuItems, +}: NavigationMenuItemFolderContentProps) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem(); + + return ( + <> + + + + {(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/NavigationMenuItemFolderContentDispatcher.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolderContentDispatcher.tsx new file mode 100644 index 0000000000..2be2a8f947 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolderContentDispatcher.tsx @@ -0,0 +1,48 @@ +import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; + +import { FavoritesFolderContent } from '@/favorites/components/FavoritesFolderContent'; +import { type ProcessedFavorite } from '@/favorites/utils/sortFavorites'; +import { NavigationMenuItemFolderContent } from '@/navigation-menu-item/components/NavigationMenuItemFolderContent'; +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +type NavigationMenuItemFolderContentDispatcherEffectProps = { + folderName: string; + folderId: string; + favorites?: ProcessedFavorite[]; + navigationMenuItems?: ProcessedNavigationMenuItem[]; +}; + +export const NavigationMenuItemFolderContentDispatcherEffect = ({ + folderName, + folderId, + favorites, + navigationMenuItems, +}: NavigationMenuItemFolderContentDispatcherEffectProps) => { + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + + if (isNavigationMenuItemEnabled && isDefined(navigationMenuItems)) { + return ( + + ); + } + + if (isDefined(favorites)) { + return ( + + ); + } + + return null; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolderNavigationDrawerItemDropdown.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolderNavigationDrawerItemDropdown.tsx new file mode 100644 index 0000000000..28bdb45780 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolderNavigationDrawerItemDropdown.tsx @@ -0,0 +1,63 @@ +import { useLingui } from '@lingui/react/macro'; +import { IconDotsVertical, IconPencil, IconTrash } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; +import { MenuItem } from 'twenty-ui/navigation'; + +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; + +type NavigationMenuItemFolderNavigationDrawerItemDropdownProps = { + folderId: string; + onRename: () => void; + onDelete: () => void; + closeDropdown: () => void; +}; + +export const NavigationMenuItemFolderNavigationDrawerItemDropdown = ({ + folderId, + onRename, + onDelete, + closeDropdown, +}: NavigationMenuItemFolderNavigationDrawerItemDropdownProps) => { + const { t } = useLingui(); + const handleRename = () => { + closeDropdown(); + onRename(); + }; + + const handleDelete = () => { + closeDropdown(); + onDelete(); + }; + + return ( + + } + dropdownPlacement="bottom-start" + dropdownComponents={ + + + + + + + } + /> + ); +}; 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 new file mode 100644 index 0000000000..fc7f30faf1 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemFolders.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { useRecoilState } from 'recoil'; +import { IconFolder } from 'twenty-ui/display'; + +import { CurrentWorkspaceMemberNavigationMenuItems } from '@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItems'; +import { useCreateNavigationMenuItemFolder } from '@/navigation-menu-item/hooks/useCreateNavigationMenuItemFolder'; +import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder'; +import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/states/isNavigationMenuItemFolderCreatingState'; +import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; +import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput'; + +type NavigationMenuItemFoldersProps = { + isNavigationSectionOpen: boolean; +}; + +export const NavigationMenuItemFolders = ({ + isNavigationSectionOpen, +}: NavigationMenuItemFoldersProps) => { + const [newFolderName, setNewFolderName] = useState(''); + + const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder(); + const { createNewNavigationMenuItemFolder } = + useCreateNavigationMenuItemFolder(); + + const [ + isNavigationMenuItemFolderCreating, + setIsNavigationMenuItemFolderCreating, + ] = useRecoilState(isNavigationMenuItemFolderCreatingState); + + const handleNavigationMenuItemFolderNameChange = (value: string) => { + setNewFolderName(value); + }; + + const handleSubmitNavigationMenuItemFolderCreation = async ( + value: string, + ) => { + if (value === '') return; + + setIsNavigationMenuItemFolderCreating(false); + setNewFolderName(''); + await createNewNavigationMenuItemFolder(value); + return true; + }; + + const handleClickOutside = async ( + _event: MouseEvent | TouchEvent, + value: string, + ) => { + if (!value) { + setIsNavigationMenuItemFolderCreating(false); + return; + } + + setIsNavigationMenuItemFolderCreating(false); + setNewFolderName(''); + await createNewNavigationMenuItemFolder(value); + }; + + const handleCancelNavigationMenuItemFolderCreation = () => { + setNewFolderName(''); + setIsNavigationMenuItemFolderCreating(false); + }; + + if (!isNavigationSectionOpen) { + return null; + } + + return ( + <> + {isNavigationMenuItemFolderCreating && ( + + + + )} + {navigationMenuItemsByFolder.map((folder) => ( + 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 new file mode 100644 index 0000000000..9315c3c3a4 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemIcon.tsx @@ -0,0 +1,35 @@ +import { useTheme } from '@emotion/react'; +import { Avatar, useIcons } from 'twenty-ui/display'; + +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; +import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon'; + +export const NavigationMenuItemIcon = ({ + navigationMenuItem, +}: { + navigationMenuItem: ProcessedNavigationMenuItem; +}) => { + const theme = useTheme(); + const { getIcon } = useIcons(); + const { Icon: StandardIcon, IconColor } = useGetStandardObjectIcon( + navigationMenuItem.objectNameSingular || '', + ); + const IconToUse = + StandardIcon || + (navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined); + const iconColorToUse = StandardIcon ? IconColor : theme.font.color.secondary; + + const placeholderColorSeed = navigationMenuItem.targetRecordId ?? undefined; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemSkeletonLoader.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemSkeletonLoader.tsx new file mode 100644 index 0000000000..58e55445de --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/NavigationMenuItemSkeletonLoader.tsx @@ -0,0 +1,46 @@ +import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; + +const StyledSkeletonContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + height: 71px; + padding-left: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledSkeletonColumn = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const NavigationMenuItemSkeletonLoader = () => { + const theme = useTheme(); + 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 new file mode 100644 index 0000000000..6705ca20d8 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItems.tsx @@ -0,0 +1,26 @@ +import { useLingui } from '@lingui/react/macro'; + +import { useWorkspaceNavigationMenuItems } from '@/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems'; +import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems'; +import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader'; +import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; + +export const WorkspaceNavigationMenuItems = () => { + const { workspaceNavigationMenuItemsObjectMetadataItems } = + useWorkspaceNavigationMenuItems(); + + const loading = useIsPrefetchLoading(); + const { t } = useLingui(); + + if (loading) { + return ; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemsDispatcher.tsx b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemsDispatcher.tsx new file mode 100644 index 0000000000..d8b8e22e11 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/components/WorkspaceNavigationMenuItemsDispatcher.tsx @@ -0,0 +1,17 @@ +import { FeatureFlagKey } from '~/generated/graphql'; + +import { WorkspaceFavorites } from '@/favorites/components/WorkspaceFavorites'; +import { WorkspaceNavigationMenuItems } from '@/navigation-menu-item/components/WorkspaceNavigationMenuItems'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +export const WorkspaceNavigationMenuItemsDispatcher = () => { + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + + if (isNavigationMenuItemEnabled) { + return ; + } + + return ; +}; 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 new file mode 100644 index 0000000000..024fc34b23 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationMenuItemDroppableIds.ts @@ -0,0 +1,2 @@ +export const ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID = + 'orphan-navigation-menu-items'; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationMenuItemFolderDeleteModalId.ts b/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationMenuItemFolderDeleteModalId.ts new file mode 100644 index 0000000000..730e69485b --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/constants/NavigationMenuItemFolderDeleteModalId.ts @@ -0,0 +1,2 @@ +export const NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID = + 'navigation-menu-item-folder-delete-modal'; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationMenuItemDragContext.tsx b/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationMenuItemDragContext.tsx new file mode 100644 index 0000000000..8e0cf4e1da --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/contexts/NavigationMenuItemDragContext.tsx @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +type NavigationMenuItemDragContextType = { + isDragging: boolean; +}; + +export const NavigationMenuItemDragContext = + createContext({ + isDragging: false, + }); 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 new file mode 100644 index 0000000000..b3a7dcf7ff --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/graphql/fragments/navigationMenuItemFragment.ts @@ -0,0 +1,17 @@ +import { gql } from '@apollo/client'; + +export const NAVIGATION_MENU_ITEM_FRAGMENT = gql` + fragment NavigationMenuItemFields on NavigationMenuItem { + id + userWorkspaceId + targetRecordId + targetObjectMetadataId + viewId + folderId + name + position + applicationId + createdAt + updatedAt + } +`; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/graphql/fragments/navigationMenuItemQueryFragment.ts b/packages/twenty-front/src/modules/navigation-menu-item/graphql/fragments/navigationMenuItemQueryFragment.ts new file mode 100644 index 0000000000..edaabfe737 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/graphql/fragments/navigationMenuItemQueryFragment.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +import { NAVIGATION_MENU_ITEM_FRAGMENT } from './navigationMenuItemFragment'; + +export const NAVIGATION_MENU_ITEM_QUERY_FRAGMENT = gql` + ${NAVIGATION_MENU_ITEM_FRAGMENT} + fragment NavigationMenuItemQueryFields on NavigationMenuItem { + ...NavigationMenuItemFields + targetRecordIdentifier { + id + labelIdentifier + imageIdentifier + } + } +`; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/createNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/createNavigationMenuItem.ts new file mode 100644 index 0000000000..a26ff9ebf7 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/createNavigationMenuItem.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; +import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemFragment'; + +export const CREATE_NAVIGATION_MENU_ITEM = gql` + ${NAVIGATION_MENU_ITEM_FRAGMENT} + mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) { + createNavigationMenuItem(input: $input) { + ...NavigationMenuItemFields + } + } +`; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/deleteNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/deleteNavigationMenuItem.ts new file mode 100644 index 0000000000..86ead1d973 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/deleteNavigationMenuItem.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; +import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemFragment'; + +export const DELETE_NAVIGATION_MENU_ITEM = gql` + ${NAVIGATION_MENU_ITEM_FRAGMENT} + mutation DeleteNavigationMenuItem($id: UUID!) { + deleteNavigationMenuItem(id: $id) { + ...NavigationMenuItemFields + } + } +`; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/updateNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/updateNavigationMenuItem.ts new file mode 100644 index 0000000000..67a92551ae --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/graphql/mutations/updateNavigationMenuItem.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; +import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemFragment'; + +export const UPDATE_NAVIGATION_MENU_ITEM = gql` + ${NAVIGATION_MENU_ITEM_FRAGMENT} + mutation UpdateNavigationMenuItem($input: UpdateOneNavigationMenuItemInput!) { + updateNavigationMenuItem(input: $input) { + ...NavigationMenuItemFields + } + } +`; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/graphql/queries/findManyNavigationMenuItems.ts b/packages/twenty-front/src/modules/navigation-menu-item/graphql/queries/findManyNavigationMenuItems.ts new file mode 100644 index 0000000000..79502d0b9e --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/graphql/queries/findManyNavigationMenuItems.ts @@ -0,0 +1,11 @@ +import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemQueryFragment'; +import { gql } from '@apollo/client'; + +export const FIND_MANY_NAVIGATION_MENU_ITEMS = gql` + ${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT} + query FindManyNavigationMenuItems { + navigationMenuItems { + ...NavigationMenuItemQueryFields + } + } +`; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/graphql/queries/findOneNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/graphql/queries/findOneNavigationMenuItem.ts new file mode 100644 index 0000000000..1a9a603af6 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/graphql/queries/findOneNavigationMenuItem.ts @@ -0,0 +1,11 @@ +import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/graphql/fragments/navigationMenuItemQueryFragment'; +import { gql } from '@apollo/client'; + +export const FIND_ONE_NAVIGATION_MENU_ITEM = gql` + ${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT} + query FindOneNavigationMenuItem($id: UUID!) { + navigationMenuItem(id: $id) { + ...NavigationMenuItemQueryFields + } + } +`; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useCreateNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useCreateNavigationMenuItem.ts new file mode 100644 index 0000000000..2b7c2378d0 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useCreateNavigationMenuItem.ts @@ -0,0 +1,85 @@ +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { useCreateNavigationMenuItemMutation } from '~/generated-metadata/graphql'; + +import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export const useCreateNavigationMenuItem = () => { + const { navigationMenuItems, currentWorkspaceMemberId } = + usePrefetchedNavigationMenuItemsData(); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const [createNavigationMenuItemMutation] = + useCreateNavigationMenuItemMutation({ + refetchQueries: ['FindManyNavigationMenuItems'], + }); + + const createNavigationMenuItem = async ( + targetRecord: ObjectRecord, + targetObjectNameSingular: string, + folderId?: string, + ) => { + const isView = targetObjectNameSingular === 'view'; + + if (isView) { + const relevantItems = folderId + ? navigationMenuItems.filter((item) => item.folderId === folderId) + : navigationMenuItems.filter( + (item) => !item.folderId && item.userWorkspaceId, + ); + + const maxPosition = Math.max( + ...relevantItems.map((item) => item.position), + 0, + ); + + await createNavigationMenuItemMutation({ + variables: { + input: { + viewId: targetRecord.id, + userWorkspaceId: currentWorkspaceMemberId, + folderId, + position: maxPosition + 1, + }, + }, + }); + } else { + const objectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === targetObjectNameSingular, + ); + + if (!isDefined(objectMetadataItem)) { + throw new Error( + `Object metadata item not found for nameSingular: ${targetObjectNameSingular}`, + ); + } + + const relevantItems = folderId + ? navigationMenuItems.filter((item) => item.folderId === folderId) + : navigationMenuItems.filter( + (item) => !item.folderId && item.userWorkspaceId, + ); + + const maxPosition = Math.max( + ...relevantItems.map((item) => item.position), + 0, + ); + + await createNavigationMenuItemMutation({ + variables: { + input: { + targetRecordId: targetRecord.id, + targetObjectMetadataId: objectMetadataItem.id, + userWorkspaceId: currentWorkspaceMemberId, + folderId, + position: maxPosition + 1, + }, + }, + }); + } + }; + + return { createNavigationMenuItem }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useCreateNavigationMenuItemFolder.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useCreateNavigationMenuItemFolder.ts new file mode 100644 index 0000000000..38c18943bf --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useCreateNavigationMenuItemFolder.ts @@ -0,0 +1,52 @@ +import { isDefined } from 'twenty-shared/utils'; +import { useCreateNavigationMenuItemMutation } from '~/generated-metadata/graphql'; + +import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData'; + +export const useCreateNavigationMenuItemFolder = () => { + const { navigationMenuItems, currentWorkspaceMemberId } = + usePrefetchedNavigationMenuItemsData(); + + const [createNavigationMenuItemMutation] = + useCreateNavigationMenuItemMutation({ + refetchQueries: ['FindManyNavigationMenuItems'], + }); + + const createNewNavigationMenuItemFolder = async ( + name: string, + ): Promise => { + if (!name || !currentWorkspaceMemberId) { + return; + } + + const folderNavigationMenuItems = navigationMenuItems.filter( + (item) => + isDefined(item.name) && + !item.folderId && + !item.targetRecordId && + !item.targetObjectMetadataId && + !item.viewId && + item.userWorkspaceId === currentWorkspaceMemberId, + ); + + const maxPosition = Math.max( + ...folderNavigationMenuItems.map((item) => item.position), + 0, + ); + + await createNavigationMenuItemMutation({ + variables: { + input: { + name, + targetRecordId: null, + targetObjectMetadataId: null, + userWorkspaceId: currentWorkspaceMemberId, + folderId: null, + position: maxPosition + 1, + }, + }, + }); + }; + + return { createNewNavigationMenuItemFolder }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useDeleteNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useDeleteNavigationMenuItem.ts new file mode 100644 index 0000000000..b1b3250c4c --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useDeleteNavigationMenuItem.ts @@ -0,0 +1,16 @@ +import { useDeleteNavigationMenuItemMutation } from '~/generated-metadata/graphql'; + +export const useDeleteNavigationMenuItem = () => { + const [deleteNavigationMenuItemMutation] = + useDeleteNavigationMenuItemMutation({ + refetchQueries: ['FindManyNavigationMenuItems'], + }); + + const deleteNavigationMenuItem = async (id: string) => { + await deleteNavigationMenuItemMutation({ + variables: { id }, + }); + }; + + return { deleteNavigationMenuItem }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useDeleteNavigationMenuItemFolder.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useDeleteNavigationMenuItemFolder.ts new file mode 100644 index 0000000000..6246f80cfa --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useDeleteNavigationMenuItemFolder.ts @@ -0,0 +1,15 @@ +import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem'; + +export const useDeleteNavigationMenuItemFolder = () => { + const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem(); + + const deleteNavigationMenuItemFolder = async ( + folderId: string, + ): Promise => { + await deleteNavigationMenuItem(folderId); + }; + + return { + deleteNavigationMenuItemFolder, + }; +}; 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 new file mode 100644 index 0000000000..c87d2a99b8 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useHandleNavigationMenuItemDragAndDrop.ts @@ -0,0 +1,143 @@ +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 { useSortedNavigationMenuItems } from '@/navigation-menu-item/hooks/useSortedNavigationMenuItems'; +import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItem'; +import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/states/openNavigationMenuItemFolderIdsState'; +import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition'; +import { FOLDER_DROPPABLE_IDS } from '@/ui/layout/draggable-list/utils/folderDroppableIds'; +import { validateAndExtractFolderId } from '@/ui/layout/draggable-list/utils/validateAndExtractFolderId'; + +import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData'; + +export const useHandleNavigationMenuItemDragAndDrop = () => { + const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData(); + const { navigationMenuItemsSorted } = useSortedNavigationMenuItems(); + const { updateNavigationMenuItem } = useUpdateNavigationMenuItem(); + const setOpenNavigationMenuItemFolderIds = useSetRecoilState( + openNavigationMenuItemFolderIdsState, + ); + + const openDestinationFolder = (folderId: string | null) => { + if (!folderId) { + return; + } + + setOpenNavigationMenuItemFolderIds((current) => { + if (!current.includes(folderId)) { + return [...current, folderId]; + } + return current; + }); + }; + + const handleNavigationMenuItemDragAndDrop: OnDragEndResponder = async ( + result, + ) => { + const { destination, source, draggableId } = result; + + if (!destination) { + return; + } + + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) { + return; + } + + const draggedNavigationMenuItem = navigationMenuItems.find( + (item) => item.id === draggableId, + ); + if (!draggedNavigationMenuItem) { + return; + } + + const destinationFolderId = validateAndExtractFolderId({ + droppableId: destination.droppableId, + orphanDroppableId: ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID, + }); + const sourceFolderId = validateAndExtractFolderId({ + droppableId: source.droppableId, + orphanDroppableId: ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID, + }); + + if ( + destination.droppableId.startsWith( + FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX, + ) + ) { + if (destinationFolderId === null) + throw new Error('Invalid folder header ID'); + + const folderNavigationMenuItems = navigationMenuItemsSorted.filter( + (item) => item.folderId === destinationFolderId, + ); + + const newPosition = + folderNavigationMenuItems.length === 0 + ? 1 + : folderNavigationMenuItems[folderNavigationMenuItems.length - 1] + .position + 1; + + await updateNavigationMenuItem({ + id: draggableId, + folderId: destinationFolderId, + position: newPosition, + }); + + openDestinationFolder(destinationFolderId); + return; + } + + if (destination.droppableId !== source.droppableId) { + const destinationNavigationMenuItems = navigationMenuItemsSorted.filter( + (item) => item.folderId === destinationFolderId, + ); + + let newPosition; + if (destinationNavigationMenuItems.length === 0) { + newPosition = 1; + } else if (destination.index === 0) { + newPosition = destinationNavigationMenuItems[0].position - 1; + } else if (destination.index >= destinationNavigationMenuItems.length) { + newPosition = + destinationNavigationMenuItems[ + destinationNavigationMenuItems.length - 1 + ].position + 1; + } else { + newPosition = calculateNewPosition({ + destinationIndex: destination.index, + sourceIndex: -1, + items: destinationNavigationMenuItems, + }); + } + + await updateNavigationMenuItem({ + id: draggableId, + folderId: destinationFolderId ?? null, + position: newPosition, + }); + return; + } + + const navigationMenuItemsInSameList = navigationMenuItemsSorted + .filter((item) => item.folderId === sourceFolderId) + .filter((item) => item.id !== draggableId); + + const newPosition = calculateNewPosition({ + destinationIndex: destination.index, + sourceIndex: source.index, + items: navigationMenuItemsInSameList, + }); + + await updateNavigationMenuItem({ + id: draggableId, + position: newPosition, + }); + }; + + return { handleNavigationMenuItemDragAndDrop }; +}; 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 new file mode 100644 index 0000000000..9aee408ed3 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts @@ -0,0 +1,111 @@ +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier'; +import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; + +import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData'; + +type NavigationMenuItemFolder = { + folderId: string; + folderName: string; + navigationMenuItems: ReturnType[number][]; +}; + +export const useNavigationMenuItemsByFolder = () => { + const coreViews = useRecoilValue(coreViewsState); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData(); + + const views = coreViews.map(convertCoreViewToView); + + const { folders, itemsByFolderId } = navigationMenuItems.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]); + } + } + + 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; + } + + const targetRecordIdentifier = item.targetRecordIdentifier; + + if (!isDefined(targetRecordIdentifier)) { + return map; + } + + const itemObjectMetadata = objectMetadataItems.find( + (meta) => meta.id === item.targetObjectMetadataId, + ); + + if (isDefined(itemObjectMetadata)) { + const objectRecordIdentifier = recordIdentifierToObjectRecordIdentifier( + { + recordIdentifier: targetRecordIdentifier, + objectMetadataItem: itemObjectMetadata, + }, + ); + + map.set(itemTargetRecordId, objectRecordIdentifier); + } + + return map; + }, new Map()); + + const sortedItems = sortNavigationMenuItems( + itemsInFolder, + true, + views, + objectMetadataItems, + targetRecordIdentifiersMap, + ); + + acc.push({ + folderId: folder.id, + folderName: folder.name, + navigationMenuItems: sortedItems, + }); + + return acc; + }, []); + + return { navigationMenuItemsByFolder }; +}; 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 new file mode 100644 index 0000000000..4ce8dc91ed --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData.ts @@ -0,0 +1,34 @@ +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +type PrefetchedNavigationMenuItemsData = { + navigationMenuItems: NavigationMenuItem[]; + workspaceNavigationMenuItems: NavigationMenuItem[]; + currentWorkspaceMemberId: string | undefined; +}; + +export const usePrefetchedNavigationMenuItemsData = + (): PrefetchedNavigationMenuItemsData => { + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const currentWorkspaceMemberId = currentWorkspaceMember?.id; + const prefetchNavigationMenuItems = useRecoilValue( + prefetchNavigationMenuItemsState, + ); + + const navigationMenuItems = prefetchNavigationMenuItems.filter((item) => + isDefined(item.userWorkspaceId), + ); + + const workspaceNavigationMenuItems = prefetchNavigationMenuItems.filter( + (item) => !isDefined(item.userWorkspaceId), + ); + + return { + navigationMenuItems, + workspaceNavigationMenuItems, + currentWorkspaceMemberId, + }; + }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId.ts new file mode 100644 index 0000000000..fee4d5713f --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId.ts @@ -0,0 +1,48 @@ +import { useRecoilCallback } from 'recoil'; + +import { FIND_MANY_NAVIGATION_MENU_ITEMS } from '@/navigation-menu-item/graphql/queries/findManyNavigationMenuItems'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState'; +import { isDefined } from 'twenty-shared/utils'; + +export const useRemoveNavigationMenuItemByTargetRecordId = () => { + const apolloCoreClient = useApolloCoreClient(); + const cache = apolloCoreClient.cache; + + const removeNavigationMenuItemsByTargetRecordIds = useRecoilCallback( + ({ set, snapshot }) => + (targetRecordIds: string[]) => { + const targetRecordIdsSet = new Set(targetRecordIds); + const currentNavigationMenuItems = snapshot + .getLoadable(prefetchNavigationMenuItemsState) + .getValue(); + + const updatedNavigationMenuItems = currentNavigationMenuItems.filter( + (item) => + !isDefined(item.targetRecordId) || + !targetRecordIdsSet.has(item.targetRecordId), + ); + + set(prefetchNavigationMenuItemsState, updatedNavigationMenuItems); + + cache.updateQuery( + { query: FIND_MANY_NAVIGATION_MENU_ITEMS }, + (data) => { + if (!isDefined(data?.navigationMenuItems)) { + return data; + } + + return { + ...data, + navigationMenuItems: updatedNavigationMenuItems, + }; + }, + ); + }, + [cache], + ); + + return { + removeNavigationMenuItemsByTargetRecordIds, + }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useRenameNavigationMenuItemFolder.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useRenameNavigationMenuItemFolder.ts new file mode 100644 index 0000000000..a3f43f1c46 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useRenameNavigationMenuItemFolder.ts @@ -0,0 +1,23 @@ +import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/hooks/useUpdateNavigationMenuItem'; + +export const useRenameNavigationMenuItemFolder = () => { + const { updateNavigationMenuItem } = useUpdateNavigationMenuItem(); + + const renameNavigationMenuItemFolder = async ( + folderId: string, + newName: string, + ): Promise => { + if (!newName) { + return; + } + + await updateNavigationMenuItem({ + id: folderId, + name: newName, + }); + }; + + return { + renameNavigationMenuItemFolder, + }; +}; 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 new file mode 100644 index 0000000000..c2b53db25b --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useSortedNavigationMenuItems.ts @@ -0,0 +1,109 @@ +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier'; +import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; + +import { usePrefetchedNavigationMenuItemsData } from './usePrefetchedNavigationMenuItemsData'; + +export const useSortedNavigationMenuItems = () => { + const { navigationMenuItems, workspaceNavigationMenuItems } = + usePrefetchedNavigationMenuItemsData(); + const coreViews = useRecoilValue(coreViewsState).map(convertCoreViewToView); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const targetRecordIdentifiers = useMemo(() => { + const identifiersMap = new Map(); + + [...navigationMenuItems, ...workspaceNavigationMenuItems].forEach( + (navigationMenuItem) => { + if (isDefined(navigationMenuItem.viewId)) { + return; + } + + const itemTargetRecordId = navigationMenuItem.targetRecordId; + if (!isDefined(itemTargetRecordId)) { + return; + } + + const targetRecordIdentifier = + navigationMenuItem.targetRecordIdentifier; + if (!isDefined(targetRecordIdentifier)) { + return; + } + + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === navigationMenuItem.targetObjectMetadataId, + ); + + if (!isDefined(objectMetadataItem)) { + return; + } + + const objectRecordIdentifier = recordIdentifierToObjectRecordIdentifier( + { + recordIdentifier: targetRecordIdentifier, + objectMetadataItem, + }, + ); + + identifiersMap.set(itemTargetRecordId, objectRecordIdentifier); + }, + ); + + return identifiersMap; + }, [navigationMenuItems, workspaceNavigationMenuItems, objectMetadataItems]); + + const navigationMenuItemsSorted = useMemo(() => { + return sortNavigationMenuItems( + navigationMenuItems, + true, + coreViews, + objectMetadataItems, + targetRecordIdentifiers, + ); + }, [ + navigationMenuItems, + coreViews, + objectMetadataItems, + targetRecordIdentifiers, + ]); + + const workspaceNavigationMenuItemsSorted = useMemo(() => { + const filtered = workspaceNavigationMenuItems.filter((item) => { + if (isDefined(item.viewId)) { + return coreViews.some((view) => view.id === item.viewId); + } + + const itemTargetRecordId = item.targetRecordId; + if (!isDefined(itemTargetRecordId)) { + return false; + } + const matchesTargetRecord = + targetRecordIdentifiers.has(itemTargetRecordId); + return matchesTargetRecord; + }); + return sortNavigationMenuItems( + filtered, + false, + coreViews, + objectMetadataItems, + targetRecordIdentifiers, + ); + }, [ + workspaceNavigationMenuItems, + coreViews, + objectMetadataItems, + targetRecordIdentifiers, + ]); + + return { + navigationMenuItemsSorted, + workspaceNavigationMenuItemsSorted, + }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateNavigationMenuItem.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateNavigationMenuItem.ts new file mode 100644 index 0000000000..ac307bb538 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useUpdateNavigationMenuItem.ts @@ -0,0 +1,28 @@ +import { + useUpdateNavigationMenuItemMutation, + type UpdateNavigationMenuItemInput, + type UpdateOneNavigationMenuItemInput, +} from '~/generated-metadata/graphql'; + +export const useUpdateNavigationMenuItem = () => { + const [updateNavigationMenuItemMutation] = + useUpdateNavigationMenuItemMutation({ + refetchQueries: ['FindManyNavigationMenuItems'], + }); + + const updateNavigationMenuItem = async ( + input: UpdateNavigationMenuItemInput & { id: string }, + ) => { + const { id, ...update } = input; + const updateOneInput: UpdateOneNavigationMenuItemInput = { + id, + update, + }; + + await updateNavigationMenuItemMutation({ + variables: { input: updateOneInput }, + }); + }; + + return { updateNavigationMenuItem }; +}; 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 new file mode 100644 index 0000000000..5a494e83a8 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems.ts @@ -0,0 +1,60 @@ +import { useRecoilValue } from 'recoil'; + +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +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'; +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 workspaceNavigationMenuItemViewIds = new Set( + workspaceNavigationMenuItemsSorted + .map((item) => item.viewId) + .filter((viewId) => isDefined(viewId)), + ); + + const navigationMenuItemViewObjectMetadataIds = new Set( + views.reduce((acc, view) => { + if (workspaceNavigationMenuItemViewIds.has(view.id)) { + acc.push(view.objectMetadataId); + } + return acc; + }, []), + ); + + const navigationMenuItemRecordObjectMetadataIds = new Set( + rawWorkspaceNavigationMenuItems + .map((item) => item.targetObjectMetadataId) + .filter((objectMetadataId) => isDefined(objectMetadataId)), + ); + + const allNavigationMenuItemObjectMetadataIds = new Set([ + ...navigationMenuItemViewObjectMetadataIds, + ...navigationMenuItemRecordObjectMetadataIds, + ]); + + const { activeNonSystemObjectMetadataItems } = + useFilteredObjectMetadataItems(); + + const activeNonSystemObjectMetadataItemsInWorkspaceNavigationMenuItems: ObjectMetadataItem[] = + activeNonSystemObjectMetadataItems.filter((item: ObjectMetadataItem) => + allNavigationMenuItemObjectMetadataIds.has(item.id), + ); + + return { + workspaceNavigationMenuItemsObjectMetadataItems: + activeNonSystemObjectMetadataItemsInWorkspaceNavigationMenuItems, + }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/states/isNavigationMenuItemFolderCreatingState.ts b/packages/twenty-front/src/modules/navigation-menu-item/states/isNavigationMenuItemFolderCreatingState.ts new file mode 100644 index 0000000000..36e8c57b58 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/states/isNavigationMenuItemFolderCreatingState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isNavigationMenuItemFolderCreatingState = atom({ + key: 'isNavigationMenuItemFolderCreatingState', + default: false, +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/states/openNavigationMenuItemFolderIdsState.ts b/packages/twenty-front/src/modules/navigation-menu-item/states/openNavigationMenuItemFolderIdsState.ts new file mode 100644 index 0000000000..b90fb4d29f --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/states/openNavigationMenuItemFolderIdsState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const openNavigationMenuItemFolderIdsState = atom({ + key: 'openNavigationMenuItemFolderIdsState', + default: [], +}); 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 new file mode 100644 index 0000000000..9e0823a7b6 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/computeNavigationMenuItemDisplayFields.test.ts @@ -0,0 +1,154 @@ +import { computeNavigationMenuItemDisplayFields } from '@/navigation-menu-item/utils/computeNavigationMenuItemDisplayFields'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; + +describe('computeNavigationMenuItemDisplayFields', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'metadata-id', + nameSingular: 'person', + namePlural: 'people', + labelSingular: 'Person', + labelPlural: 'People', + } as ObjectMetadataItem; + + const mockObjectRecordIdentifier: ObjectRecordIdentifier = { + id: 'record-id', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.jpg', + avatarType: 'rounded', + 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 complete display fields when all parameters are provided', () => { + const result = computeNavigationMenuItemDisplayFields( + mockObjectMetadataItem, + mockObjectRecordIdentifier, + ); + + expect(result).toEqual({ + labelIdentifier: 'John Doe', + avatarUrl: 'https://example.com/avatar.jpg', + avatarType: 'rounded', + link: '/app/objects/people/record-id', + objectNameSingular: 'person', + }); + }); + + it('should handle objectRecordIdentifier with undefined optional fields', () => { + const identifierWithoutOptionalFields: 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, + ); + + 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'); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getNavigationMenuItemSecondaryLabel.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getNavigationMenuItemSecondaryLabel.test.ts new file mode 100644 index 0000000000..c8d9ad5c9c --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/getNavigationMenuItemSecondaryLabel.test.ts @@ -0,0 +1,31 @@ +import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems'; +import { getNavigationMenuItemSecondaryLabel } from '@/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel'; + +describe('getNavigationMenuItemSecondaryLabel', () => { + it('should return "View" for view object', () => { + const result = getNavigationMenuItemSecondaryLabel({ + objectMetadataItems: generatedMockObjectMetadataItems, + navigationMenuItemObjectNameSingular: 'view', + }); + + expect(result).toBe('View'); + }); + + it('should return labelSingular for matching object metadata item', () => { + const result = getNavigationMenuItemSecondaryLabel({ + objectMetadataItems: generatedMockObjectMetadataItems, + navigationMenuItemObjectNameSingular: 'person', + }); + + expect(result).toBe('Person'); + }); + + it('should return undefined when object metadata item is not found', () => { + const result = getNavigationMenuItemSecondaryLabel({ + objectMetadataItems: generatedMockObjectMetadataItems, + navigationMenuItemObjectNameSingular: 'nonexistent', + }); + + expect(result).toBeUndefined(); + }); +}); 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 new file mode 100644 index 0000000000..bf07694e14 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/isLocationMatchingNavigationMenuItem.test.ts @@ -0,0 +1,88 @@ +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', + }; + + expect( + isLocationMatchingNavigationMenuItem( + currentPath, + currentViewPath, + navigationMenuItem, + ), + ).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, + ), + ).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', + }; + + expect( + isLocationMatchingNavigationMenuItem( + currentPath, + currentViewPath, + navigationMenuItem, + ), + ).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, + ), + ).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__/sortNavigationMenuItems.test.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/sortNavigationMenuItems.test.ts new file mode 100644 index 0000000000..0f08a1fa3e --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/__tests__/sortNavigationMenuItems.test.ts @@ -0,0 +1,364 @@ +import { sortNavigationMenuItems } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; +import { type View } from '@/views/types/View'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +jest.mock('@/favorites/utils/getObjectMetadataNamePluralFromViewId', () => ({ + getObjectMetadataNamePluralFromViewId: jest.fn( + ( + view: Pick, + items: ObjectMetadataItem[], + ) => { + const item = items.find((item) => item.id === view.objectMetadataId); + return { namePlural: item?.namePlural ?? 'items' }; + }, + ), +})); + +jest.mock('twenty-shared/utils', () => { + const actual = jest.requireActual('twenty-shared/utils'); + return { + ...actual, + getAppPath: jest.fn((path, params, query) => { + const basePath = `/app/objects/${params.objectNamePlural}`; + const viewId = query?.viewId; + if (viewId !== undefined && viewId !== null) { + return `${basePath}?viewId=${viewId}`; + } + return basePath; + }), + }; +}); + +describe('sortNavigationMenuItems', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'metadata-id', + nameSingular: 'person', + namePlural: 'people', + } as ObjectMetadataItem; + + const mockView: Pick = { + id: 'view-id', + name: 'All People', + objectMetadataId: 'metadata-id', + icon: 'IconUser', + }; + + const mockObjectRecordIdentifier: ObjectRecordIdentifier = { + id: 'record-id', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.jpg', + avatarType: 'rounded', + linkToShowPage: '/app/objects/people/record-id', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty array when navigationMenuItems is empty', () => { + const result = sortNavigationMenuItems([], true, [], [], new Map()); + + expect(result).toEqual([]); + }); + + it('should process view link navigation menu items', () => { + const navigationMenuItem: NavigationMenuItem = { + id: 'item-id', + viewId: 'view-id', + position: 1, + } as NavigationMenuItem; + + const result = sortNavigationMenuItems( + [navigationMenuItem], + true, + [mockView], + [mockObjectMetadataItem], + new Map(), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 'item-id', + viewId: 'view-id', + position: 1, + labelIdentifier: 'All People', + objectNameSingular: 'view', + Icon: 'IconUser', + }); + expect(result[0].link).toContain('viewId=view-id'); + }); + + it('should return null for view link when view is not found', () => { + const navigationMenuItem: NavigationMenuItem = { + id: 'item-id', + viewId: 'non-existent-view-id', + position: 1, + } as NavigationMenuItem; + + const result = sortNavigationMenuItems( + [navigationMenuItem], + true, + [], + [mockObjectMetadataItem], + new Map(), + ); + + expect(result).toEqual([]); + }); + + it('should process record link navigation menu items', () => { + const navigationMenuItem: NavigationMenuItem = { + id: 'item-id', + targetRecordId: 'record-id', + targetObjectMetadataId: 'metadata-id', + position: 2, + } as NavigationMenuItem; + + const targetRecordIdentifiers = new Map([ + ['record-id', mockObjectRecordIdentifier], + ]); + + const result = sortNavigationMenuItems( + [navigationMenuItem], + true, + [], + [mockObjectMetadataItem], + targetRecordIdentifiers, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 'item-id', + targetRecordId: 'record-id', + targetObjectMetadataId: 'metadata-id', + position: 2, + labelIdentifier: 'John Doe', + avatarUrl: 'https://example.com/avatar.jpg', + avatarType: 'rounded', + link: '/app/objects/people/record-id', + objectNameSingular: 'person', + }); + }); + + it('should return null for record link when targetRecordId is not defined', () => { + const navigationMenuItem: NavigationMenuItem = { + id: 'item-id', + targetObjectMetadataId: 'metadata-id', + position: 1, + } as NavigationMenuItem; + + const result = sortNavigationMenuItems( + [navigationMenuItem], + true, + [], + [mockObjectMetadataItem], + new Map(), + ); + + expect(result).toEqual([]); + }); + + it('should return null for record link when objectMetadataItem is not found', () => { + const navigationMenuItem: NavigationMenuItem = { + id: 'item-id', + targetRecordId: 'record-id', + targetObjectMetadataId: 'non-existent-metadata-id', + position: 1, + } as NavigationMenuItem; + + const targetRecordIdentifiers = new Map([ + ['record-id', mockObjectRecordIdentifier], + ]); + + const result = sortNavigationMenuItems( + [navigationMenuItem], + true, + [], + [mockObjectMetadataItem], + targetRecordIdentifiers, + ); + + expect(result).toEqual([]); + }); + + it('should return null for record link when targetRecordIdentifier is not found', () => { + 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).toEqual([]); + }); + + it('should return empty link when hasLinkToShowPage is false', () => { + const navigationMenuItem: NavigationMenuItem = { + id: 'item-id', + targetRecordId: 'record-id', + targetObjectMetadataId: 'metadata-id', + position: 2, + } as NavigationMenuItem; + + const targetRecordIdentifiers = new Map([ + ['record-id', mockObjectRecordIdentifier], + ]); + + const result = sortNavigationMenuItems( + [navigationMenuItem], + false, + [], + [mockObjectMetadataItem], + targetRecordIdentifiers, + ); + + expect(result).toHaveLength(1); + expect(result[0].link).toBe(''); + }); + + it('should sort navigation menu items by position', () => { + const navigationMenuItems: NavigationMenuItem[] = [ + { + id: 'item-3', + targetRecordId: 'record-id-3', + targetObjectMetadataId: 'metadata-id', + position: 3, + }, + { + id: 'item-1', + targetRecordId: 'record-id-1', + targetObjectMetadataId: 'metadata-id', + position: 1, + }, + { + id: 'item-2', + targetRecordId: 'record-id-2', + targetObjectMetadataId: 'metadata-id', + position: 2, + }, + ] as NavigationMenuItem[]; + + const targetRecordIdentifiers = new Map([ + ['record-id-1', { ...mockObjectRecordIdentifier, id: 'record-id-1' }], + ['record-id-2', { ...mockObjectRecordIdentifier, id: 'record-id-2' }], + ['record-id-3', { ...mockObjectRecordIdentifier, id: 'record-id-3' }], + ]); + + const result = sortNavigationMenuItems( + navigationMenuItems, + true, + [], + [mockObjectMetadataItem], + targetRecordIdentifiers, + ); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('item-1'); + expect(result[1].id).toBe('item-2'); + expect(result[2].id).toBe('item-3'); + }); + + it('should handle mixed view and record link items', () => { + const navigationMenuItems: NavigationMenuItem[] = [ + { + id: 'view-item', + viewId: 'view-id', + position: 1, + }, + { + id: 'record-item', + targetRecordId: 'record-id', + targetObjectMetadataId: 'metadata-id', + position: 2, + }, + ] as NavigationMenuItem[]; + + const targetRecordIdentifiers = new Map([ + ['record-id', mockObjectRecordIdentifier], + ]); + + const result = sortNavigationMenuItems( + navigationMenuItems, + true, + [mockView], + [mockObjectMetadataItem], + targetRecordIdentifiers, + ); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('view-item'); + expect(result[0].objectNameSingular).toBe('view'); + 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', + viewId: 'view-id', + targetRecordId: 'record-id', + targetObjectMetadataId: 'metadata-id', + position: 1, + } as NavigationMenuItem; + + const result = sortNavigationMenuItems( + [navigationMenuItem], + true, + [mockView], + [mockObjectMetadataItem], + new Map(), + ); + + expect(result).toHaveLength(1); + expect(result[0].objectNameSingular).toBe('view'); + 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], + true, + [], + [mockObjectMetadataItem], + new Map(), + ); + + expect(result).toHaveLength(0); + }); +}); diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/computeNavigationMenuItemDisplayFields.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/computeNavigationMenuItemDisplayFields.ts new file mode 100644 index 0000000000..167589423a --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/computeNavigationMenuItemDisplayFields.ts @@ -0,0 +1,31 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; +import { isDefined } from 'twenty-shared/utils'; + +export type NavigationMenuItemDisplayFields = { + labelIdentifier: string; + avatarUrl: string; + avatarType: 'icon' | 'rounded' | 'squared'; + link: string; + objectNameSingular: string; + Icon?: string; +}; + +export const computeNavigationMenuItemDisplayFields = ( + objectMetadataItem: ObjectMetadataItem | null, + objectRecordIdentifier: ObjectRecordIdentifier | null, +): NavigationMenuItemDisplayFields | null => { + if (!isDefined(objectMetadataItem) || !isDefined(objectRecordIdentifier)) { + return null; + } + + const objectNameSingular = objectMetadataItem.nameSingular; + + return { + labelIdentifier: objectRecordIdentifier.name, + avatarUrl: objectRecordIdentifier.avatarUrl ?? '', + avatarType: objectRecordIdentifier.avatarType ?? 'icon', + link: objectRecordIdentifier.linkToShowPage ?? '', + objectNameSingular, + }; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel.ts new file mode 100644 index 0000000000..1908971852 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/getNavigationMenuItemSecondaryLabel.ts @@ -0,0 +1,23 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +type GetNavigationMenuItemSecondaryLabelProps = { + objectMetadataItems: Pick< + ObjectMetadataItem, + 'nameSingular' | 'labelSingular' + >[]; + navigationMenuItemObjectNameSingular: string; +}; + +export const getNavigationMenuItemSecondaryLabel = ({ + objectMetadataItems, + navigationMenuItemObjectNameSingular, +}: GetNavigationMenuItemSecondaryLabelProps) => { + if (navigationMenuItemObjectNameSingular === 'view') { + return 'View'; + } + + return objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === navigationMenuItemObjectNameSingular, + )?.labelSingular; +}; 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 new file mode 100644 index 0000000000..240843b5e7 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/isLocationMatchingNavigationMenuItem.ts @@ -0,0 +1,14 @@ +import { type ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems'; + +export const isLocationMatchingNavigationMenuItem = ( + currentPath: string, + currentViewPath: string, + navigationMenuItem: Pick< + ProcessedNavigationMenuItem, + 'objectNameSingular' | 'link' + >, +) => { + return navigationMenuItem.objectNameSingular === 'view' + ? navigationMenuItem.link === currentViewPath + : navigationMenuItem.link === currentPath; +}; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier.ts new file mode 100644 index 0000000000..542982dd59 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/recordIdentifierToObjectRecordIdentifier.ts @@ -0,0 +1,51 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getAvatarType } from '@/object-metadata/utils/getAvatarType'; +import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; +import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; +import { isDefined } from 'twenty-shared/utils'; + +type RecordIdentifierDTO = { + id: string; + labelIdentifier: string; + imageIdentifier?: string | null; +}; + +export const recordIdentifierToObjectRecordIdentifier = ({ + recordIdentifier, + objectMetadataItem, +}: { + recordIdentifier: RecordIdentifierDTO; + objectMetadataItem: ObjectMetadataItem; +}): ObjectRecordIdentifier => { + const avatarType = getAvatarType(objectMetadataItem.nameSingular); + + const basePathToShowPage = getBasePathToShowPage({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const isWorkspaceMemberObjectMetadata = + objectMetadataItem.nameSingular === CoreObjectNameSingular.WorkspaceMember; + + let linkToShowPage = ''; + + if ( + objectMetadataItem.nameSingular === CoreObjectNameSingular.NoteTarget || + objectMetadataItem.nameSingular === CoreObjectNameSingular.TaskTarget + ) { + linkToShowPage = ''; + } else if ( + !isWorkspaceMemberObjectMetadata && + isDefined(recordIdentifier.id) + ) { + linkToShowPage = `${basePathToShowPage}${recordIdentifier.id}`; + } + + return { + id: recordIdentifier.id, + name: recordIdentifier.labelIdentifier, + avatarUrl: recordIdentifier.imageIdentifier ?? undefined, + avatarType, + linkToShowPage, + }; +}; 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 new file mode 100644 index 0000000000..659f32cd8f --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/utils/sortNavigationMenuItems.ts @@ -0,0 +1,96 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; +import { type View } from '@/views/types/View'; +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 { + computeNavigationMenuItemDisplayFields, + type NavigationMenuItemDisplayFields, +} from './computeNavigationMenuItemDisplayFields'; + +export type ProcessedNavigationMenuItem = NavigationMenuItem & + NavigationMenuItemDisplayFields; + +export const sortNavigationMenuItems = ( + navigationMenuItems: NavigationMenuItem[], + hasLinkToShowPage: boolean, + views: Pick[], + objectMetadataItems: ObjectMetadataItem[], + targetRecordIdentifiers: Map, +): ProcessedNavigationMenuItem[] => { + return navigationMenuItems + .map((navigationMenuItem) => { + if (isDefined(navigationMenuItem.viewId)) { + const view = views.find( + (view) => view.id === navigationMenuItem.viewId, + ); + + if (isDefined(view)) { + const { namePlural } = getObjectMetadataNamePluralFromViewId( + view, + objectMetadataItems, + ); + + const displayFields: NavigationMenuItemDisplayFields = { + labelIdentifier: view.name, + avatarUrl: '', + avatarType: 'icon', + link: getAppPath( + AppPath.RecordIndexPage, + { objectNamePlural: namePlural }, + { viewId: navigationMenuItem.viewId }, + ), + objectNameSingular: 'view', + Icon: view.icon, + }; + + return { + ...navigationMenuItem, + ...displayFields, + }; + } + + return null; + } + + if (!isDefined(navigationMenuItem.targetRecordId)) { + return null; + } + + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === navigationMenuItem.targetObjectMetadataId, + ); + + if (!isDefined(objectMetadataItem)) { + return null; + } + + const objectRecordIdentifier = targetRecordIdentifiers.get( + navigationMenuItem.targetRecordId, + ); + + if (!isDefined(objectRecordIdentifier)) { + return null; + } + + const displayFields = computeNavigationMenuItemDisplayFields( + objectMetadataItem, + objectRecordIdentifier, + ); + + if (!isDefined(displayFields)) { + return null; + } + + return { + ...navigationMenuItem, + ...displayFields, + link: hasLinkToShowPage ? displayFields.link : '', + }; + }) + .filter(isDefined) + .sort((a, b) => a.position - b.position); +}; diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx index 4f35c8c63b..4c793ec582 100644 --- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawer.tsx @@ -1,23 +1,43 @@ import { useRecoilValue } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { FavoritesFolderContent } from '@/favorites/components/FavoritesFolderContent'; import { useFavoritesByFolder } from '@/favorites/hooks/useFavoritesByFolder'; +import { NavigationMenuItemFolderContentDispatcherEffect } from '@/navigation-menu-item/components/NavigationMenuItemFolderContentDispatcher'; +import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/hooks/useNavigationMenuItemsByFolder'; import { MainNavigationDrawerFixedItems } from '@/navigation/components/MainNavigationDrawerFixedItems'; import { MainNavigationDrawerScrollableItems } from '@/navigation/components/MainNavigationDrawerScrollableItems'; import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer'; import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent'; import { NavigationDrawerScrollableContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerScrollableContent'; import { currentFavoriteFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentFavoriteFolderIdState'; +import { currentNavigationMenuItemFolderIdState } from '@/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; export const MainNavigationDrawer = ({ className }: { className?: string }) => { const currentWorkspace = useRecoilValue(currentWorkspaceState); const currentFavoriteFolderId = useRecoilValue(currentFavoriteFolderIdState); + const currentNavigationMenuItemFolderId = useRecoilValue( + currentNavigationMenuItemFolderIdState, + ); const { favoritesByFolder } = useFavoritesByFolder(); - const openedFolder = favoritesByFolder.find( + const { navigationMenuItemsByFolder } = useNavigationMenuItemsByFolder(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + + const openedFavoriteFolder = favoritesByFolder.find( (f) => f.folderId === currentFavoriteFolderId, ); + const openedNavigationMenuItemFolder = navigationMenuItemsByFolder.find( + (f) => f.folderId === currentNavigationMenuItemFolderId, + ); + + const openedFolder = isNavigationMenuItemEnabled + ? openedNavigationMenuItemFolder + : openedFavoriteFolder; + return ( { - {currentFavoriteFolderId && openedFolder ? ( - ) : ( diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerScrollableItems.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerScrollableItems.tsx index 4bf7dce549..98084c3794 100644 --- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerScrollableItems.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerScrollableItems.tsx @@ -1,8 +1,23 @@ -import { CurrentWorkspaceMemberFavoritesFolders } from '@/favorites/components/CurrentWorkspaceMemberFavoritesFolders'; -import { WorkspaceFavorites } from '@/favorites/components/WorkspaceFavorites'; import { NavigationDrawerOpenedSection } from '@/object-metadata/components/NavigationDrawerOpenedSection'; import { RemoteNavigationDrawerSection } from '@/object-metadata/components/RemoteNavigationDrawerSection'; import styled from '@emotion/styled'; +import { lazy, Suspense } from 'react'; + +const CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher = lazy(() => + import( + '@/navigation-menu-item/components/CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher' + ).then((module) => ({ + default: module.CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher, + })), +); + +const WorkspaceNavigationMenuItemsDispatcher = lazy(() => + import( + '@/navigation-menu-item/components/WorkspaceNavigationMenuItemsDispatcher' + ).then((module) => ({ + default: module.WorkspaceNavigationMenuItemsDispatcher, + })), +); const StyledScrollableItemsContainer = styled.div` display: flex; @@ -14,8 +29,12 @@ export const MainNavigationDrawerScrollableItems = () => { return ( - - + + + + + + ); 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 aa94f97fcc..58b6df95b4 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerOpenedSection.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerOpenedSection.tsx @@ -1,11 +1,14 @@ import { useParams } from 'react-router-dom'; import { useWorkspaceFavorites } from '@/favorites/hooks/useWorkspaceFavorites'; +import { useWorkspaceNavigationMenuItems } from '@/navigation-menu-item/hooks/useWorkspaceNavigationMenuItems'; import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems'; import { NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useLingui } from '@lingui/react/macro'; +import { FeatureFlagKey } from '~/generated/graphql'; export const NavigationDrawerOpenedSection = () => { const { t } = useLingui(); @@ -17,6 +20,11 @@ export const NavigationDrawerOpenedSection = () => { const loading = useIsPrefetchLoading(); const { workspaceFavoritesObjectMetadataItems } = useWorkspaceFavorites(); + const { workspaceNavigationMenuItemsObjectMetadataItems } = + useWorkspaceNavigationMenuItems(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); const { objectNamePlural: currentObjectNamePlural, @@ -37,10 +45,13 @@ export const NavigationDrawerOpenedSection = () => { return; } - const shouldDisplayObjectInOpenedSection = - !workspaceFavoritesObjectMetadataItems - .map((item) => item.id) - .includes(objectMetadataItem.id); + const workspaceItemsToExclude = isNavigationMenuItemEnabled + ? workspaceNavigationMenuItemsObjectMetadataItems + : workspaceFavoritesObjectMetadataItems; + + const shouldDisplayObjectInOpenedSection = !workspaceItemsToExclude + .map((item) => item.id) + .includes(objectMetadataItem.id); if (loading) { return ; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index 00214bf02b..5c52c4188f 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -16,7 +16,10 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField'; +import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilValue } from 'recoil'; +import { FeatureFlagKey } from '~/generated/graphql'; import { isDefined } from 'twenty-shared/utils'; import { sleep } from '~/utils/sleep'; @@ -57,6 +60,11 @@ export const useDeleteManyRecords = ({ const { objectMetadataItems } = useObjectMetadataItems(); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); const { refetchAggregateQueries } = useRefetchAggregateQueries(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + const { removeNavigationMenuItemsByTargetRecordIds } = + useRemoveNavigationMenuItemByTargetRecordId(); const mutationResponseField = getDeleteManyRecordsMutationResponseField( objectMetadataItem.namePlural, @@ -223,6 +231,10 @@ export const useDeleteManyRecords = ({ objectMetadataNamePlural: objectMetadataItem.namePlural, }); + if (isNavigationMenuItemEnabled) { + removeNavigationMenuItemsByTargetRecordIds(recordIdsToDelete); + } + dispatchObjectRecordOperationBrowserEvent({ objectMetadataItem, operation: { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts index 8af4bd9d95..aa20e56e20 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts @@ -13,7 +13,10 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField'; +import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilValue } from 'recoil'; +import { FeatureFlagKey } from '~/generated/graphql'; import { capitalize, isDefined } from 'twenty-shared/utils'; import { sleep } from '~/utils/sleep'; @@ -52,6 +55,11 @@ export const useDestroyManyRecords = ({ const { objectMetadataItems } = useObjectMetadataItems(); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); const { refetchAggregateQueries } = useRefetchAggregateQueries(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + const { removeNavigationMenuItemsByTargetRecordIds } = + useRemoveNavigationMenuItemByTargetRecordId(); const mutationResponseField = getDestroyManyRecordsMutationResponseField( objectMetadataItem.namePlural, @@ -144,6 +152,10 @@ export const useDestroyManyRecords = ({ objectMetadataNamePlural: objectMetadataItem.namePlural, }); + if (isNavigationMenuItemEnabled) { + removeNavigationMenuItemsByTargetRecordIds(recordIdsToDestroy); + } + dispatchObjectRecordOperationBrowserEvent({ objectMetadataItem, operation: { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts index ad110efbdd..289fb9cf76 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useIncrementalDeleteManyRecords.ts @@ -16,7 +16,10 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent'; +import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useCallback } from 'react'; +import { FeatureFlagKey } from '~/generated/graphql'; import { isDefined } from 'twenty-shared/utils'; import { sleep } from '~/utils/sleep'; @@ -61,6 +64,11 @@ export const useIncrementalDeleteManyRecords = ({ const { objectMetadataItems } = useObjectMetadataItems(); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); const { refetchAggregateQueries } = useRefetchAggregateQueries(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + const { removeNavigationMenuItemsByTargetRecordIds } = + useRemoveNavigationMenuItemByTargetRecordId(); const { incrementalFetchAndMutate, progress, isProcessing, updateProgress } = useIncrementalFetchAndMutateRecords({ @@ -219,11 +227,13 @@ export const useIncrementalDeleteManyRecords = ({ const incrementalDeleteManyRecords = async () => { let totalDeletedCount = 0; + const allDeletedRecordIds: string[] = []; await incrementalFetchAndMutate( async ({ recordIds, totalCount, abortSignal }) => { await deleteManyRecordsBatch(recordIds, abortSignal); + allDeletedRecordIds.push(...recordIds); totalDeletedCount += recordIds.length; updateProgress(totalDeletedCount, totalCount); @@ -234,6 +244,10 @@ export const useIncrementalDeleteManyRecords = ({ objectMetadataNamePlural: objectMetadataItem.namePlural, }); + if (isNavigationMenuItemEnabled) { + removeNavigationMenuItemsByTargetRecordIds(allDeletedRecordIds); + } + dispatchObjectRecordOperationBrowserEvent({ objectMetadataItem, operation: { diff --git a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx index 14ed8547d2..519ed4dd7e 100644 --- a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutTabList.stories.tsx @@ -5,12 +5,12 @@ import { useMemo, useState } from 'react'; import { RecoilRoot } from 'recoil'; import { ComponentWithRouterDecorator } from 'twenty-ui/testing'; -import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition'; import { PageLayoutTabList } from '@/page-layout/components/PageLayoutTabList'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { PageLayoutTabListEffect } from '@/page-layout/components/PageLayoutTabListEffect'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition'; const StyledContainer = styled.div` border: 1px solid ${({ theme }) => theme.border.color.strong}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts index 3e9c79de1e..b1bf105486 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useDuplicatePageLayoutTab.ts @@ -1,7 +1,6 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; -import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; @@ -10,6 +9,7 @@ import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; import { generateDuplicatedTimestamps } from '@/page-layout/utils/generateDuplicatedTimestamps'; import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; +import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutTab.ts index 03a29f7b9f..58d5694625 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutTab.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useMovePageLayoutTab.ts @@ -1,7 +1,7 @@ -import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; +import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useRecoilCallback } from 'recoil'; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts index 09c308ca32..f1c79f90fa 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useReorderPageLayoutTabs.ts @@ -1,8 +1,8 @@ -import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition'; import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/PageLayoutTabListDroppableIds'; import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout'; import { usePageLayoutDraftState } from '@/page-layout/hooks/usePageLayoutDraftState'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { type DropResult } from '@hello-pangea/dnd'; import { useCallback } from 'react'; diff --git a/packages/twenty-front/src/modules/prefetch/components/PrefetchDataProvider.tsx b/packages/twenty-front/src/modules/prefetch/components/PrefetchDataProvider.tsx index 24e747faa6..c0363fab7b 100644 --- a/packages/twenty-front/src/modules/prefetch/components/PrefetchDataProvider.tsx +++ b/packages/twenty-front/src/modules/prefetch/components/PrefetchDataProvider.tsx @@ -1,10 +1,12 @@ import { PrefetchRunFavoriteQueriesEffect } from '@/prefetch/components/PrefetchRunFavoriteQueriesEffect'; +import { PrefetchRunNavigationMenuItemQueriesEffect } from '@/prefetch/components/PrefetchRunNavigationMenuItemQueriesEffect'; import React from 'react'; export const PrefetchDataProvider = ({ children }: React.PropsWithChildren) => { return ( <> + {children} ); diff --git a/packages/twenty-front/src/modules/prefetch/components/PrefetchRunFavoriteQueriesEffect.tsx b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunFavoriteQueriesEffect.tsx index dca51992af..9a0646ac27 100644 --- a/packages/twenty-front/src/modules/prefetch/components/PrefetchRunFavoriteQueriesEffect.tsx +++ b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunFavoriteQueriesEffect.tsx @@ -16,11 +16,16 @@ import { prefetchFavoritesState } from '@/prefetch/states/prefetchFavoritesState import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { FeatureFlagKey } from '~/generated/graphql'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const PrefetchRunFavoriteQueriesEffect = () => { + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); const showAuthModal = useShowAuthModal(); const isSettingsPage = useIsSettingsPage(); const currentWorkspace = useRecoilValue(currentWorkspaceState); @@ -55,14 +60,22 @@ export const PrefetchRunFavoriteQueriesEffect = () => { objectNameSingular: CoreObjectNameSingular.Favorite, filter: findAllFavoritesOperationSignature.variables.filter, recordGqlFields: findAllFavoritesOperationSignature.fields, - skip: showAuthModal || isSettingsPage || !isWorkspaceActive, + skip: + showAuthModal || + isSettingsPage || + !isWorkspaceActive || + isNavigationMenuItemEnabled, }); const { records: favoriteFolders } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.FavoriteFolder, filter: findAllFavoriteFoldersOperationSignature.variables.filter, recordGqlFields: findAllFavoriteFoldersOperationSignature.fields, - skip: showAuthModal || isSettingsPage || !isWorkspaceActive, + skip: + showAuthModal || + isSettingsPage || + !isWorkspaceActive || + isNavigationMenuItemEnabled, }); const setPrefetchFavoritesState = useRecoilCallback( diff --git a/packages/twenty-front/src/modules/prefetch/components/PrefetchRunNavigationMenuItemQueriesEffect.tsx b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunNavigationMenuItemQueriesEffect.tsx new file mode 100644 index 0000000000..4766161ad5 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunNavigationMenuItemQueriesEffect.tsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; +import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil'; + +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; +import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState'; +import { prefetchNavigationMenuItemsState } from '@/prefetch/states/prefetchNavigationMenuItemsState'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { isDefined } from 'twenty-shared/utils'; +import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; +import { + useFindManyNavigationMenuItemsQuery, + type NavigationMenuItem, +} from '~/generated-metadata/graphql'; +import { FeatureFlagKey } from '~/generated/graphql'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const PrefetchRunNavigationMenuItemQueriesEffect = () => { + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + + const showAuthModal = useShowAuthModal(); + const isSettingsPage = useIsSettingsPage(); + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const isWorkspaceActive = + currentWorkspace?.activationStatus === WorkspaceActivationStatus.ACTIVE; + + const setIsPrefetchNavigationMenuItemsLoaded = useSetRecoilState( + prefetchIsLoadedFamilyState(PrefetchKey.AllNavigationMenuItems), + ); + + const { data, loading } = useFindManyNavigationMenuItemsQuery({ + skip: + showAuthModal || + isSettingsPage || + !isWorkspaceActive || + !isNavigationMenuItemEnabled, + fetchPolicy: 'cache-and-network', + }); + + const setPrefetchNavigationMenuItemsState = useRecoilCallback( + ({ set, snapshot }) => + (navigationMenuItems: NavigationMenuItem[]) => { + const existingNavigationMenuItems = snapshot + .getLoadable(prefetchNavigationMenuItemsState) + .getValue(); + + if (!isDeeplyEqual(existingNavigationMenuItems, navigationMenuItems)) { + set(prefetchNavigationMenuItemsState, navigationMenuItems); + } + }, + [], + ); + + useEffect(() => { + if (!loading && isDefined(data?.navigationMenuItems)) { + setPrefetchNavigationMenuItemsState(data.navigationMenuItems); + setIsPrefetchNavigationMenuItemsLoaded(true); + } + }, [ + data, + loading, + setPrefetchNavigationMenuItemsState, + setIsPrefetchNavigationMenuItemsLoaded, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/prefetch/states/prefetchNavigationMenuItemsState.ts b/packages/twenty-front/src/modules/prefetch/states/prefetchNavigationMenuItemsState.ts new file mode 100644 index 0000000000..24fd64f6df --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/states/prefetchNavigationMenuItemsState.ts @@ -0,0 +1,9 @@ +import { createState } from 'twenty-ui/utilities'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +export const prefetchNavigationMenuItemsState = createState< + NavigationMenuItem[] +>({ + key: 'prefetchNavigationMenuItemsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/prefetch/types/PrefetchKey.ts b/packages/twenty-front/src/modules/prefetch/types/PrefetchKey.ts index 0d076ad0e6..b30eeb7d0d 100644 --- a/packages/twenty-front/src/modules/prefetch/types/PrefetchKey.ts +++ b/packages/twenty-front/src/modules/prefetch/types/PrefetchKey.ts @@ -1,4 +1,5 @@ export enum PrefetchKey { AllFavorites = 'ALL_FAVORITES', AllFavoritesFolders = 'ALL_FAVORITES_FOLDERS', + AllNavigationMenuItems = 'ALL_NAVIGATION_MENU_ITEMS', } diff --git a/packages/twenty-front/src/modules/favorites/utils/calculateNewPosition.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/calculateNewPosition.ts similarity index 100% rename from packages/twenty-front/src/modules/favorites/utils/calculateNewPosition.ts rename to packages/twenty-front/src/modules/ui/layout/draggable-list/utils/calculateNewPosition.ts diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/folderDroppableIds.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/folderDroppableIds.ts new file mode 100644 index 0000000000..f0fa04702f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/folderDroppableIds.ts @@ -0,0 +1,4 @@ +export const FOLDER_DROPPABLE_IDS = { + FOLDER_PREFIX: 'folder-', + FOLDER_HEADER_PREFIX: 'folder-header-', +} as const; diff --git a/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/validateAndExtractFolderId.ts b/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/validateAndExtractFolderId.ts new file mode 100644 index 0000000000..536c610401 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/draggable-list/utils/validateAndExtractFolderId.ts @@ -0,0 +1,48 @@ +import { CustomError } from 'twenty-shared/utils'; +import { FOLDER_DROPPABLE_IDS } from './folderDroppableIds'; + +type ValidateAndExtractFolderIdParams = { + droppableId: string; + // TODO: Remove orphanDroppableId prop when deleting all favorites code + orphanDroppableId: string; +}; + +export const validateAndExtractFolderId = ({ + droppableId, + orphanDroppableId, +}: ValidateAndExtractFolderIdParams): string | null => { + if (droppableId === orphanDroppableId) { + return null; + } + + if (droppableId.startsWith(FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX)) { + const folderId = droppableId.replace( + FOLDER_DROPPABLE_IDS.FOLDER_HEADER_PREFIX, + '', + ); + if (!folderId) + throw new CustomError( + `Invalid folder header ID: ${droppableId}`, + 'INVALID_FOLDER_HEADER_ID', + ); + return folderId; + } + + if (droppableId.startsWith(FOLDER_DROPPABLE_IDS.FOLDER_PREFIX)) { + const folderId = droppableId.replace( + FOLDER_DROPPABLE_IDS.FOLDER_PREFIX, + '', + ); + if (!folderId) + throw new CustomError( + `Invalid folder ID: ${droppableId}`, + 'INVALID_FOLDER_ID', + ); + return folderId; + } + + throw new CustomError( + `Invalid droppable ID format: ${droppableId}`, + 'INVALID_DROPPABLE_ID_FORMAT', + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState.ts new file mode 100644 index 0000000000..fd8e09aba0 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/states/currentNavigationMenuItemFolderIdState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const currentNavigationMenuItemFolderIdState = atom({ + key: 'currentNavigationMenuItemFolderIdState', + default: null, +}); diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerOptionDropdown.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerOptionDropdown.tsx index 1d7727063e..0b6c532739 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerOptionDropdown.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerOptionDropdown.tsx @@ -1,5 +1,7 @@ import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite'; import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { useCreateNavigationMenuItem } from '@/navigation-menu-item/hooks/useCreateNavigationMenuItem'; +import { usePrefetchedNavigationMenuItemsData } from '@/navigation-menu-item/hooks/usePrefetchedNavigationMenuItemsData'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; @@ -10,6 +12,7 @@ import { type View } from '@/views/types/View'; import { useDestroyViewFromCurrentState } from '@/views/view-picker/hooks/useDestroyViewFromCurrentState'; import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode'; import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useLingui } from '@lingui/react/macro'; import { IconHeart, @@ -19,7 +22,7 @@ import { useIcons, } from 'twenty-ui/display'; import { MenuItem } from 'twenty-ui/navigation'; -import { ViewVisibility } from '~/generated-metadata/graphql'; +import { FeatureFlagKey, ViewVisibility } from '~/generated-metadata/graphql'; import { PermissionFlagType } from '~/generated/graphql'; type ViewPickerOptionDropdownProps = { @@ -57,16 +60,28 @@ export const ViewPickerOptionDropdown = ({ const { sortedFavorites: favorites } = useFavorites(); const { createFavorite } = useCreateFavorite(); + const isNavigationMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + ); + const { createNavigationMenuItem } = useCreateNavigationMenuItem(); + const { navigationMenuItems, currentWorkspaceMemberId } = + usePrefetchedNavigationMenuItemsData(); // Users with VIEWS permission can edit all views // Users without VIEWS permission can only edit unlisted views (which are always their own, filtered by backend) const canEditView = hasViewsPermission || view.visibility === ViewVisibility.UNLISTED; - const isFavorite = favorites.some( - (favorite) => - favorite.recordId === view.id && favorite.forWorkspaceMemberId, - ); + const isFavorite = isNavigationMenuItemEnabled + ? navigationMenuItems.some( + (item) => + item.viewId === view.id && + item.userWorkspaceId === currentWorkspaceMemberId, + ) + : favorites.some( + (favorite) => + favorite.recordId === view.id && favorite.forWorkspaceMemberId, + ); const handleDelete = () => { setViewPickerReferenceViewId(view.id); @@ -76,7 +91,11 @@ export const ViewPickerOptionDropdown = ({ const handleAddToFavorites = () => { if (!isFavorite) { - createFavorite(view, 'view'); + if (isNavigationMenuItemEnabled) { + createNavigationMenuItem(view, 'view'); + } else { + createFavorite(view, 'view'); + } } else { setViewPickerReferenceViewId(view.id); setViewPickerMode('favorite-folders-picker'); diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1769196250679-addNavigationMenuItemViewForeignKey.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769196250679-addNavigationMenuItemViewForeignKey.ts new file mode 100644 index 0000000000..743c4ca8f8 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769196250679-addNavigationMenuItemViewForeignKey.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddNavigationMenuItemViewForeignKey1769196250679 + implements MigrationInterface +{ + name = 'AddNavigationMenuItemViewForeignKey1769196250679'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "FK_9ec9d8bc9bb4197be12d4efcaf3" FOREIGN KEY ("viewId") REFERENCES "core"."view"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "FK_9ec9d8bc9bb4197be12d4efcaf3"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 4710bdae20..6f6120d24d 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -14,5 +14,6 @@ export enum FeatureFlagKey { IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED', IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED', + IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED', IS_FILES_FIELD_ENABLED = 'IS_FILES_FIELD_ENABLED', } diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index 0b3037ff1e..fa5bb2dc2b 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -34,6 +34,7 @@ import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/cale import { CalendarModule } from 'src/modules/calendar/calendar.module'; import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module'; import { FavoriteModule } from 'src/modules/favorite/favorite.module'; +import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module'; import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module'; @@ -62,6 +63,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; WebhookJobModule, WorkflowModule, FavoriteModule, + NavigationMenuItemModule, WorkspaceCleanerModule, SubscriptionsModule, AuditJobModule, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts new file mode 100644 index 0000000000..14f55d51a2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-image-identifier.util.ts @@ -0,0 +1,67 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +type GetRecordImageIdentifierOptions = { + record: Record; + flatObjectMetadata: FlatObjectMetadata; + flatFieldMetadataMaps: FlatEntityMaps; + signUrl?: (url: string) => string | null; +}; + +export const getRecordImageIdentifier = ({ + record, + flatObjectMetadata, + flatFieldMetadataMaps, + signUrl, +}: GetRecordImageIdentifierOptions): string | null => { + if (flatObjectMetadata.nameSingular === 'company') { + const domainNameObj = record.domainName as + | { primaryLinkUrl?: string } + | undefined; + const domainNamePrimaryLinkUrl = domainNameObj?.primaryLinkUrl; + + return domainNamePrimaryLinkUrl + ? getLogoUrlFromDomainName(domainNamePrimaryLinkUrl) || null + : null; + } + + if (!isDefined(flatObjectMetadata.imageIdentifierFieldMetadataId)) { + return null; + } + + const imageIdentifierField = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: flatObjectMetadata.imageIdentifierFieldMetadataId, + }); + + if (!isDefined(imageIdentifierField)) { + return null; + } + + const imageValue = record[imageIdentifierField.name]; + + if (!isDefined(imageValue)) { + return null; + } + + const rawImageValue = String(imageValue); + + if (!isNonEmptyString(rawImageValue)) { + return null; + } + + if ( + signUrl && + (flatObjectMetadata.nameSingular === 'person' || + flatObjectMetadata.nameSingular === 'workspaceMember') + ) { + return signUrl(rawImageValue); + } + + return rawImageValue; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts index 08f8af965e..55758a0339 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts @@ -106,6 +106,11 @@ export const ALL_METADATA_RELATIONS = { flatEntityForeignKeyAggregator: null, foreignKey: 'folderId', }, + view: { + metadataName: 'view', + flatEntityForeignKeyAggregator: null, + foreignKey: 'viewId', + }, }, oneToMany: {}, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index 3826ccdc2f..5f6c7f2640 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -42,6 +42,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo [ "objectMetadata", "navigationMenuItem", + "view", ] `; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap index d48a072acb..e7b326fb43 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap @@ -3,8 +3,8 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with children first (most manyToOne relations first) 1`] = ` [ "rowLevelPermissionPredicate", - "viewFilter", "navigationMenuItem", + "viewFilter", "pageLayoutWidget", "viewField", "commandMenuItem", 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 a84d22b6eb..aceecd4173 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 @@ -10,6 +10,8 @@ import { import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { RecordIdentifierDTO } from './record-identifier.dto'; + @ObjectType('NavigationMenuItem') export class NavigationMenuItemDTO { @IsUUID() @@ -64,4 +66,7 @@ export class NavigationMenuItemDTO { @IsDateString() @Field() updatedAt: Date; + + @Field(() => RecordIdentifierDTO, { nullable: true }) + targetRecordIdentifier?: RecordIdentifierDTO | null; } diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto.ts new file mode 100644 index 0000000000..f5afe1fc3c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto.ts @@ -0,0 +1,21 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('RecordIdentifier') +export class RecordIdentifierDTO { + @IsUUID() + @Field(() => UUIDScalarType) + id: string; + + @IsString() + @Field(() => String) + labelIdentifier: string; + + @IsOptional() + @IsString() + @Field(() => String, { nullable: true }) + imageIdentifier?: string | null; +} 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 aad2e448f1..375676e8e4 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 @@ -13,6 +13,7 @@ import { import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; @Entity({ name: 'navigationMenuItem', schema: 'core' }) @@ -63,6 +64,13 @@ export class NavigationMenuItemEntity @Column({ nullable: true, type: 'uuid' }) viewId: string | null; + @ManyToOne(() => ViewEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'viewId' }) + view: Relation | null; + @ManyToOne(() => ObjectMetadataEntity, { onDelete: 'CASCADE', nullable: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/jobs/navigation-menu-item-deletion.job.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/jobs/navigation-menu-item-deletion.job.ts new file mode 100644 index 0000000000..0201648493 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/jobs/navigation-menu-item-deletion.job.ts @@ -0,0 +1,29 @@ +import { Scope } from '@nestjs/common'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { NavigationMenuItemDeletionService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service'; + +export type NavigationMenuItemDeletionJobData = { + workspaceId: string; + deletedRecordIds: string[]; +}; + +@Processor({ + queueName: MessageQueue.deleteCascadeQueue, + scope: Scope.REQUEST, +}) +export class NavigationMenuItemDeletionJob { + constructor( + private readonly navigationMenuItemDeletionService: NavigationMenuItemDeletionService, + ) {} + + @Process(NavigationMenuItemDeletionJob.name) + async handle(data: NavigationMenuItemDeletionJobData): Promise { + await this.navigationMenuItemDeletionService.deleteNavigationMenuItemsForDeletedRecords( + data.deletedRecordIds, + data.workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/listeners/navigation-menu-item-deletion.listener.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/listeners/navigation-menu-item-deletion.listener.ts new file mode 100644 index 0000000000..3b72c88e58 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/listeners/navigation-menu-item-deletion.listener.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; + +import { type ObjectRecordDeleteEvent } from 'twenty-shared/database-events'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { + NavigationMenuItemDeletionJob, + type NavigationMenuItemDeletionJobData, +} from 'src/engine/metadata-modules/navigation-menu-item/jobs/navigation-menu-item-deletion.job'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; + +@Injectable() +export class NavigationMenuItemDeletionListener { + constructor( + @InjectMessageQueue(MessageQueue.deleteCascadeQueue) + private readonly messageQueueService: MessageQueueService, + ) {} + + @OnDatabaseBatchEvent('*', DatabaseEventAction.DELETED) + async handleDeletedEvent( + payload: WorkspaceEventBatch, + ) { + const deletedRecordIds = payload.events.map(({ recordId }) => recordId); + + if (deletedRecordIds.length === 0) { + return; + } + + await this.messageQueueService.add( + NavigationMenuItemDeletionJob.name, + { + workspaceId: payload.workspaceId, + deletedRecordIds, + }, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module.ts index 358a77136a..4ca5f68aac 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module.ts @@ -1,13 +1,19 @@ import { Module } from '@nestjs/common'; +import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { FileModule } from 'src/engine/core-modules/file/file.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatNavigationMenuItemModule } from 'src/engine/metadata-modules/flat-navigation-menu-item/flat-navigation-menu-item.module'; import { NavigationMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor'; +import { NavigationMenuItemDeletionJob } from 'src/engine/metadata-modules/navigation-menu-item/jobs/navigation-menu-item-deletion.job'; +import { NavigationMenuItemDeletionListener } from 'src/engine/metadata-modules/navigation-menu-item/listeners/navigation-menu-item-deletion.listener'; import { NavigationMenuItemResolver } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver'; import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service'; import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service'; +import { NavigationMenuItemDeletionService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @@ -18,10 +24,16 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace ApplicationModule, FlatNavigationMenuItemModule, PermissionsModule, + FileModule, + UserRoleModule, + ApiKeyModule, ], providers: [ NavigationMenuItemService, NavigationMenuItemAccessService, + NavigationMenuItemDeletionService, + NavigationMenuItemDeletionListener, + NavigationMenuItemDeletionJob, NavigationMenuItemResolver, NavigationMenuItemGraphqlApiExceptionInterceptor, WorkspaceMigrationGraphqlApiExceptionInterceptor, diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver.ts index d8d101c825..f7bfd2098c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver.ts @@ -1,5 +1,17 @@ import { UseGuards, UseInterceptors } from '@nestjs/common'; -import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { + Args, + Context, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; + +import { isDefined } from 'twenty-shared/utils'; + +import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; @@ -12,6 +24,7 @@ import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input'; import { NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto'; +import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto'; import { UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input'; import { NavigationMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor'; import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service'; @@ -105,4 +118,25 @@ export class NavigationMenuItemResolver { authApplicationId: context.req.application?.id, }); } + + @ResolveField(() => RecordIdentifierDTO, { nullable: true }) + async targetRecordIdentifier( + @Parent() navigationMenuItem: NavigationMenuItemDTO, + @AuthWorkspace() workspace: WorkspaceEntity, + @Context() context: { req: WorkspaceAuthContext }, + ): Promise { + if ( + !isDefined(navigationMenuItem.targetRecordId) || + !isDefined(navigationMenuItem.targetObjectMetadataId) + ) { + return null; + } + + return await this.navigationMenuItemService.findTargetRecord({ + targetRecordId: navigationMenuItem.targetRecordId, + targetObjectMetadataId: navigationMenuItem.targetObjectMetadataId, + workspaceId: workspace.id, + authContext: context.req, + }); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service.ts index ba400935bc..d0f559b1a3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service.ts @@ -2,7 +2,13 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; +import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; + +import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { FileService } from 'src/engine/core-modules/file/services/file.service'; +import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; +import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; @@ -12,12 +18,19 @@ import { fromFlatNavigationMenuItemToNavigationMenuItemDto } from 'src/engine/me import { fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-update-navigation-menu-item-input-to-flat-navigation-menu-item-to-update-or-throw.util'; import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input'; import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto'; +import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto'; import { type UpdateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input'; import { NavigationMenuItemException, NavigationMenuItemExceptionCode, } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception'; import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service'; +import { getMinimalSelectForRecordIdentifier } from 'src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util'; +import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; +import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; @@ -28,6 +41,10 @@ export class NavigationMenuItemService { private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationService: ApplicationService, private readonly navigationMenuItemAccessService: NavigationMenuItemAccessService, + private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly fileService: FileService, + private readonly userRoleService: UserRoleService, + private readonly apiKeyRoleService: ApiKeyRoleService, ) {} async findAll({ @@ -334,4 +351,150 @@ export class NavigationMenuItemService { flatNavigationMenuItemToDelete, ); } + + private async getRoleId( + authContext: WorkspaceAuthContext, + workspaceId: string, + ): Promise { + if (isDefined(authContext.apiKey)) { + return this.apiKeyRoleService.getRoleIdForApiKeyId( + authContext.apiKey.id, + workspaceId, + ); + } + + if (isDefined(authContext.application?.defaultServerlessFunctionRoleId)) { + return authContext.application.defaultServerlessFunctionRoleId; + } + + if (isDefined(authContext.userWorkspaceId)) { + try { + return await this.userRoleService.getRoleIdForUserWorkspace({ + userWorkspaceId: authContext.userWorkspaceId, + workspaceId, + }); + } catch (error: unknown) { + if (error instanceof PermissionsException) { + return undefined; + } + throw error; + } + } + + return undefined; + } + + async findTargetRecord({ + targetRecordId, + targetObjectMetadataId, + workspaceId, + authContext, + }: { + targetRecordId: string; + targetObjectMetadataId: string; + workspaceId: string; + authContext: WorkspaceAuthContext; + }): Promise { + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const objectMetadata = flatObjectMetadataMaps.byId[targetObjectMetadataId]; + + if (!isDefined(objectMetadata)) { + return null; + } + + try { + const roleId = await this.getRoleId(authContext, workspaceId); + + if (!isDefined(roleId)) { + return null; + } + + const rolePermissionConfig: RolePermissionConfig = { + unionOf: [roleId], + }; + + const minimalSelectColumns = getMinimalSelectForRecordIdentifier({ + flatObjectMetadata: objectMetadata, + flatFieldMetadataMaps, + }); + + const record = + await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + authContext, + async () => { + const repository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + objectMetadata.nameSingular, + rolePermissionConfig, + ); + + const alias = objectMetadata.nameSingular; + const queryBuilder = repository.createQueryBuilder(alias); + + queryBuilder.select([]); + + for (const column of minimalSelectColumns) { + queryBuilder.addSelect(`"${alias}"."${column}"`, column); + } + + const rawResult = await queryBuilder + .where(`${alias}.id = :id`, { id: targetRecordId }) + .getRawOne(); + + if (!isDefined(rawResult)) { + return null; + } + + const formattedRecord = formatResult>( + rawResult, + objectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + ); + + return formattedRecord; + }, + ); + + if (!isDefined(record)) { + return null; + } + + const labelIdentifier = getRecordDisplayName( + record, + objectMetadata, + flatFieldMetadataMaps, + ); + + const imageIdentifier = getRecordImageIdentifier({ + record, + flatObjectMetadata: objectMetadata, + flatFieldMetadataMaps, + signUrl: (url: string) => + this.fileService.signFileUrl({ + url, + workspaceId, + }), + }); + + return { + id: record.id as string, + labelIdentifier, + imageIdentifier, + }; + } catch (error: unknown) { + if (error instanceof PermissionsException) { + return null; + } + throw error; + } + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service.ts new file mode 100644 index 0000000000..032b15c27b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +@Injectable() +export class NavigationMenuItemDeletionService { + constructor( + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + ) {} + + async deleteNavigationMenuItemsForDeletedRecords( + deletedRecordIds: string[], + workspaceId: string, + ): Promise { + const { flatNavigationMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatNavigationMenuItemMaps'], + }, + ); + + const deletedRecordIdsSet = new Set(deletedRecordIds); + + const navigationMenuItemsToDelete = Object.values( + flatNavigationMenuItemMaps.byId, + ).filter( + (item): item is NonNullable => + isDefined(item) && + isDefined(item.targetRecordId) && + !isDefined(item.viewId) && + deletedRecordIdsSet.has(item.targetRecordId), + ); + + if (navigationMenuItemsToDelete.length === 0) { + return; + } + + const flatNavigationMenuItemsToDelete = navigationMenuItemsToDelete.map( + (item) => + fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow({ + flatNavigationMenuItemMaps, + navigationMenuItemId: item.id, + }), + ); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + navigationMenuItem: { + flatEntityToCreate: [], + flatEntityToDelete: flatNavigationMenuItemsToDelete, + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: true, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while deleting navigation menu items for deleted records', + ); + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util.ts new file mode 100644 index 0000000000..fb640790ed --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util.ts @@ -0,0 +1,75 @@ +import { compositeTypeDefinitions } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +const ID_FIELD = 'id' as const; + +const COMPANY_AVATAR_COLUMN = 'domainNamePrimaryLinkUrl' as const; + +export const getMinimalSelectForRecordIdentifier = ({ + flatObjectMetadata, + flatFieldMetadataMaps, +}: { + flatObjectMetadata: FlatObjectMetadata; + flatFieldMetadataMaps: FlatEntityMaps; +}): string[] => { + const selectColumns: string[] = [ID_FIELD]; + + const labelField = isDefined( + flatObjectMetadata.labelIdentifierFieldMetadataId, + ) + ? findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: flatObjectMetadata.labelIdentifierFieldMetadataId, + }) + : undefined; + + if (isDefined(labelField)) { + const labelCompositeType = isCompositeFieldMetadataType(labelField.type) + ? compositeTypeDefinitions.get(labelField.type) + : undefined; + + if (isDefined(labelCompositeType)) { + for (const compositeProperty of labelCompositeType.properties) { + selectColumns.push( + computeCompositeColumnName(labelField.name, compositeProperty), + ); + } + } else { + selectColumns.push(labelField.name); + } + } + + if (flatObjectMetadata.nameSingular === 'company') { + selectColumns.push(COMPANY_AVATAR_COLUMN); + } else if (isDefined(flatObjectMetadata.imageIdentifierFieldMetadataId)) { + const imageField = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: flatObjectMetadata.imageIdentifierFieldMetadataId, + }); + + if (isDefined(imageField)) { + const imageCompositeType = isCompositeFieldMetadataType(imageField.type) + ? compositeTypeDefinitions.get(imageField.type) + : undefined; + + if (isDefined(imageCompositeType)) { + for (const compositeProperty of imageCompositeType.properties) { + selectColumns.push( + computeCompositeColumnName(imageField.name, compositeProperty), + ); + } + } else { + selectColumns.push(imageField.name); + } + } + } + + return selectColumns; +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 560b3b4143..cf71b44537 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -223,6 +223,7 @@ describe('WorkspaceEntityManager', () => { IS_JUNCTION_RELATIONS_ENABLED: false, IS_SSE_DB_EVENTS_ENABLED: false, IS_COMMAND_MENU_ITEM_ENABLED: false, + IS_NAVIGATION_MENU_ITEM_ENABLED: false, IS_FILES_FIELD_ENABLED: false, IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED: false, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 3c9b53d1ef..e73899c0e5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -81,6 +81,11 @@ export const seedFeatureFlags = async ({ workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + workspaceId: workspaceId, + value: true, + }, { key: FeatureFlagKey.IS_FILES_FIELD_ENABLED, workspaceId: workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant.ts new file mode 100644 index 0000000000..7005ced3d9 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant.ts @@ -0,0 +1,46 @@ +import { STANDARD_OBJECTS } from './standard-object.constant'; + +export const STANDARD_NAVIGATION_MENU_ITEMS = { + allCompanies: { + universalIdentifier: '20202020-b001-4b01-8b01-c0aba11c0001', + viewUniversalIdentifier: + STANDARD_OBJECTS.company.views.allCompanies.universalIdentifier, + position: 0, + }, + allDashboards: { + universalIdentifier: '20202020-b002-4b02-8b02-c0aba11c0002', + viewUniversalIdentifier: + STANDARD_OBJECTS.dashboard.views.allDashboards.universalIdentifier, + position: 1, + }, + allNotes: { + universalIdentifier: '20202020-b003-4b03-8b03-c0aba11c0003', + viewUniversalIdentifier: + STANDARD_OBJECTS.note.views.allNotes.universalIdentifier, + position: 2, + }, + allOpportunities: { + universalIdentifier: '20202020-b004-4b04-8b04-c0aba11c0004', + viewUniversalIdentifier: + STANDARD_OBJECTS.opportunity.views.allOpportunities.universalIdentifier, + position: 3, + }, + allPeople: { + universalIdentifier: '20202020-b005-4b05-8b05-c0aba11c0005', + viewUniversalIdentifier: + STANDARD_OBJECTS.person.views.allPeople.universalIdentifier, + position: 4, + }, + allTasks: { + universalIdentifier: '20202020-b006-4b06-8b06-c0aba11c0006', + viewUniversalIdentifier: + STANDARD_OBJECTS.task.views.allTasks.universalIdentifier, + position: 5, + }, + allWorkflows: { + universalIdentifier: '20202020-b007-4b07-8b07-c0aba11c0007', + viewUniversalIdentifier: + STANDARD_OBJECTS.workflow.views.allWorkflows.universalIdentifier, + position: 6, + }, +} as const; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-all-metadata-name.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-all-metadata-name.constant.ts index a475f501ec..57ecb2067b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-all-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-all-metadata-name.constant.ts @@ -8,6 +8,7 @@ export const TWENTY_STANDARD_ALL_METADATA_NAME = [ 'viewFilter', 'viewGroup', 'view', + 'navigationMenuItem', 'role', 'agent', 'skill', diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts index 877d40c66e..d8264fff1f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts @@ -3,10 +3,12 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; import { getSubFlatEntityMapsByApplicationIdOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util'; +import { fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util'; import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -26,6 +28,7 @@ export class TwentyStandardApplicationService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceCacheService: WorkspaceCacheService, private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, ) {} // Note: To remove and handle natively in validateBuildAndRun after favorite migration to metadata @@ -58,6 +61,80 @@ export class TwentyStandardApplicationService { ); } + private async createManyNavigationMenuItem({ + flatViews, + workspaceId, + }: { + flatViews: FlatView[]; + workspaceId: string; + }) { + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatNavigationMenuItemMaps'], + }, + ); + + const existingWorkspaceItems = Object.values( + existingFlatNavigationMenuItemMaps.byId, + ).filter( + (item) => + isDefined(item) && + item.workspaceId === workspaceId && + item.userWorkspaceId === null, + ); + + const maxPosition = existingWorkspaceItems.reduce( + (max, item) => Math.max(max, item?.position ?? 0), + 0, + ); + + const flatNavigationMenuItemsToCreate = flatViews.map((flatView, index) => { + return fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate({ + createNavigationMenuItemInput: { + viewId: flatView.id, + userWorkspaceId: undefined, + position: maxPosition + index + 1, + }, + workspaceId, + applicationId: workspaceCustomFlatApplication.id, + flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps, + }); + }); + + if (flatNavigationMenuItemsToCreate.length === 0) { + return; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + navigationMenuItem: { + flatEntityToCreate: flatNavigationMenuItemsToCreate, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: true, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Multiple validation errors occurred while creating workspace navigation menu items', + ); + } + } + async synchronizeTwentyStandardApplicationOrThrow({ workspaceId, }: { diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/twenty-standard-application.module.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/twenty-standard-application.module.ts index 2f6470931c..82128d848d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/twenty-standard-application.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/twenty-standard-application.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @@ -14,6 +15,7 @@ import { TwentyStandardApplicationService } from './services/twenty-standard-app WorkspaceCacheModule, WorkspaceMigrationModule, GlobalWorkspaceDataSourceModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, ], exports: [TwentyStandardApplicationService], }) diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/build-standard-flat-navigation-menu-item-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/build-standard-flat-navigation-menu-item-maps.util.ts new file mode 100644 index 0000000000..264c188ed6 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/build-standard-flat-navigation-menu-item-maps.util.ts @@ -0,0 +1,58 @@ +import { v4 } from 'uuid'; + +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type'; +import { addFlatNavigationMenuItemToMapsAndUpdateIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util'; +import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; +import { STANDARD_NAVIGATION_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant'; +import { createStandardNavigationMenuItemFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/create-standard-navigation-menu-item-flat-metadata.util'; + +export const buildStandardFlatNavigationMenuItemMaps = ({ + now, + workspaceId, + twentyStandardApplicationId, + dependencyFlatEntityMaps: { flatViewMaps }, +}: { + now: string; + workspaceId: string; + twentyStandardApplicationId: string; + dependencyFlatEntityMaps: { + flatViewMaps: FlatEntityMaps; + }; +}): FlatNavigationMenuItemMaps => { + const flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps = { + ...createEmptyFlatEntityMaps(), + byUserWorkspaceIdAndFolderId: {}, + }; + + for (const navigationMenuItemName of Object.keys( + STANDARD_NAVIGATION_MENU_ITEMS, + ) as Array) { + const navigationMenuItemDefinition = + STANDARD_NAVIGATION_MENU_ITEMS[navigationMenuItemName]; + + const flatNavigationMenuItem = createStandardNavigationMenuItemFlatMetadata( + { + workspaceId, + navigationMenuItemName, + viewUniversalIdentifier: + navigationMenuItemDefinition.viewUniversalIdentifier, + position: navigationMenuItemDefinition.position, + navigationMenuItemId: v4(), + dependencyFlatEntityMaps: { + flatViewMaps, + }, + twentyStandardApplicationId, + now, + }, + ); + + addFlatNavigationMenuItemToMapsAndUpdateIndex({ + flatNavigationMenuItem, + flatNavigationMenuItemMaps, + }); + } + + return flatNavigationMenuItemMaps; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/create-standard-navigation-menu-item-flat-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/create-standard-navigation-menu-item-flat-metadata.util.ts new file mode 100644 index 0000000000..56220714b3 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/create-standard-navigation-menu-item-flat-metadata.util.ts @@ -0,0 +1,65 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type'; +import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; +import { STANDARD_NAVIGATION_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant'; + +export const createStandardNavigationMenuItemFlatMetadata = ({ + workspaceId, + navigationMenuItemName, + viewUniversalIdentifier, + position, + navigationMenuItemId, + dependencyFlatEntityMaps: { flatViewMaps }, + twentyStandardApplicationId, + now, +}: { + workspaceId: string; + navigationMenuItemName: keyof typeof STANDARD_NAVIGATION_MENU_ITEMS; + viewUniversalIdentifier: string; + position: number; + navigationMenuItemId: string; + dependencyFlatEntityMaps: { + flatViewMaps: FlatEntityMaps; + }; + twentyStandardApplicationId: string; + now: string; +}): FlatNavigationMenuItem => { + const navigationMenuItemDefinition = + STANDARD_NAVIGATION_MENU_ITEMS[navigationMenuItemName]; + + if (!isDefined(navigationMenuItemDefinition)) { + throw new Error( + `Invalid navigation menu item configuration ${navigationMenuItemName}`, + ); + } + + const flatView = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatViewMaps, + universalIdentifier: viewUniversalIdentifier, + }); + + if (!isDefined(flatView)) { + throw new Error( + `View not found for universal identifier ${viewUniversalIdentifier}`, + ); + } + + return { + id: navigationMenuItemId, + universalIdentifier: navigationMenuItemDefinition.universalIdentifier, + applicationId: twentyStandardApplicationId, + workspaceId, + userWorkspaceId: null, + targetRecordId: null, + targetObjectMetadataId: null, + viewId: flatView.id, + folderId: null, + name: null, + position, + createdAt: now, + updatedAt: now, + }; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant.ts index 688d134330..48e37ce68d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant.ts @@ -5,6 +5,7 @@ import { buildStandardFlatFieldMetadataMaps } from 'src/engine/workspace-manager import { getStandardObjectMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-object-metadata-related-entity-ids.util'; import { getStandardPageLayoutMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-page-layout-metadata-related-entity-ids.util'; import { buildStandardFlatIndexMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/build-standard-flat-index-metadata-maps.util'; +import { buildStandardFlatNavigationMenuItemMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/navigation-menu-item/build-standard-flat-navigation-menu-item-maps.util'; import { buildStandardFlatObjectMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/build-standard-flat-object-metadata-maps.util'; import { buildStandardFlatPageLayoutTabMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/page-layout-tab/build-standard-flat-page-layout-tab-metadata-maps.util'; import { buildStandardFlatPageLayoutWidgetMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util'; @@ -161,6 +162,15 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({ standardPageLayoutMetadataRelatedEntityIds, }); + const flatNavigationMenuItemMaps = buildStandardFlatNavigationMenuItemMaps({ + now, + workspaceId, + twentyStandardApplicationId, + dependencyFlatEntityMaps: { + flatViewMaps, + }, + }); + return { flatViewFieldMaps, flatViewFilterMaps, @@ -169,6 +179,7 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({ flatIndexMaps, flatFieldMetadataMaps, flatObjectMetadataMaps, + flatNavigationMenuItemMaps, flatRoleMaps, flatAgentMaps, flatSkillMaps,