Integrate NavigationMenuItem with feature flag support (#17268)

## Implement Navigation Menu Items Frontend

Implements the frontend for navigation menu items, the new system
replacing favorites.

### Changes
- Added GraphQL fragments and queries for navigation menu items
- Added hooks for managing navigation menu items (create, update,
delete, sorting, filtering)
- Updated components to use navigation menu items instead of favorites
- Added test coverage for utility functions

### Migration Note
The favorites and navigation menu item modules currently exist in
parallel. The favorites code will be removed once all data has been
migrated to navigation menu items.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Replaces Favorites with feature-flagged `NavigationMenuItem` across
frontend and backend, while keeping Favorites as fallback until
migration completes.
> 
> - UI: new `navigation-menu-item` components (folders, orphan items,
drag provider/droppable, icons, skeleton), dispatcher components to
switch from Favorites, and updated “Add to favorites” action to create
`NavigationMenuItem` when `IS_NAVIGATION_MENU_ITEM_ENABLED`
> - DnD: shared `validateAndExtractFolderId` and droppable id utils
moved to `ui/layout/draggable-list`; favorites DnD updated to use shared
utils
> - GraphQL (client): add fragments, queries, mutations, hooks
(create/update/delete/find), and generated types; added
`RecordIdentifier` and `targetRecordIdentifier` on `NavigationMenuItem`
> - Prefetch: new prefetch state/effect for navigation menu items; skip
favorites prefetch when flag enabled
> - Backend: add DTOs (`NavigationMenuItem`, `RecordIdentifier`),
resolver `targetRecordIdentifier` field, service logic to fetch record
identifiers with permission-aware access and image signing,
`getRecordImageIdentifier` util, entity relation to `view`, and
migration adding FK on `viewId`
> - Feature flags & seeding: add `IS_NAVIGATION_MENU_ITEM_ENABLED` to
enums, dev seeder enables it; standard app seeds workspace navigation
menu items instead of favorites when flag on
> - Tests: add unit tests for sorting/labels/folder id and related utils
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c99746f08b9f84fc8cec4fcc3a7d7afb8ea92db7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Aman Raj <92664006+araj00@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
Abdul Rahman
2026-01-27 16:12:27 +05:30
committed by GitHub
parent c79f633f17
commit 2a8f834377
107 changed files with 4518 additions and 92 deletions
@@ -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}',
@@ -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<Scalars['UUID']>;
targetRecordId?: Maybe<Scalars['UUID']>;
targetRecordIdentifier?: Maybe<RecordIdentifier>;
updatedAt: Scalars['DateTime'];
userWorkspaceId?: Maybe<Scalars['UUID']>;
viewId?: Maybe<Scalars['UUID']>;
@@ -4086,6 +4088,13 @@ export type RatioAggregateConfig = {
optionValue: Scalars['String'];
};
export type RecordIdentifier = {
__typename?: 'RecordIdentifier';
id: Scalars['UUID'];
imageIdentifier?: Maybe<Scalars['String']>;
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<Array<string>> | 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<D
export type DeleteFileMutationHookResult = ReturnType<typeof useDeleteFileMutation>;
export type DeleteFileMutationResult = Apollo.MutationResult<DeleteFileMutation>;
export type DeleteFileMutationOptions = Apollo.BaseMutationOptions<DeleteFileMutation, DeleteFileMutationVariables>;
export const CreateNavigationMenuItemDocument = gql`
mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) {
createNavigationMenuItem(input: $input) {
...NavigationMenuItemFields
}
}
${NavigationMenuItemFieldsFragmentDoc}`;
export type CreateNavigationMenuItemMutationFn = Apollo.MutationFunction<CreateNavigationMenuItemMutation, CreateNavigationMenuItemMutationVariables>;
/**
* __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<CreateNavigationMenuItemMutation, CreateNavigationMenuItemMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateNavigationMenuItemMutation, CreateNavigationMenuItemMutationVariables>(CreateNavigationMenuItemDocument, options);
}
export type CreateNavigationMenuItemMutationHookResult = ReturnType<typeof useCreateNavigationMenuItemMutation>;
export type CreateNavigationMenuItemMutationResult = Apollo.MutationResult<CreateNavigationMenuItemMutation>;
export type CreateNavigationMenuItemMutationOptions = Apollo.BaseMutationOptions<CreateNavigationMenuItemMutation, CreateNavigationMenuItemMutationVariables>;
export const DeleteNavigationMenuItemDocument = gql`
mutation DeleteNavigationMenuItem($id: UUID!) {
deleteNavigationMenuItem(id: $id) {
...NavigationMenuItemFields
}
}
${NavigationMenuItemFieldsFragmentDoc}`;
export type DeleteNavigationMenuItemMutationFn = Apollo.MutationFunction<DeleteNavigationMenuItemMutation, DeleteNavigationMenuItemMutationVariables>;
/**
* __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<DeleteNavigationMenuItemMutation, DeleteNavigationMenuItemMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteNavigationMenuItemMutation, DeleteNavigationMenuItemMutationVariables>(DeleteNavigationMenuItemDocument, options);
}
export type DeleteNavigationMenuItemMutationHookResult = ReturnType<typeof useDeleteNavigationMenuItemMutation>;
export type DeleteNavigationMenuItemMutationResult = Apollo.MutationResult<DeleteNavigationMenuItemMutation>;
export type DeleteNavigationMenuItemMutationOptions = Apollo.BaseMutationOptions<DeleteNavigationMenuItemMutation, DeleteNavigationMenuItemMutationVariables>;
export const UpdateNavigationMenuItemDocument = gql`
mutation UpdateNavigationMenuItem($input: UpdateOneNavigationMenuItemInput!) {
updateNavigationMenuItem(input: $input) {
...NavigationMenuItemFields
}
}
${NavigationMenuItemFieldsFragmentDoc}`;
export type UpdateNavigationMenuItemMutationFn = Apollo.MutationFunction<UpdateNavigationMenuItemMutation, UpdateNavigationMenuItemMutationVariables>;
/**
* __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<UpdateNavigationMenuItemMutation, UpdateNavigationMenuItemMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateNavigationMenuItemMutation, UpdateNavigationMenuItemMutationVariables>(UpdateNavigationMenuItemDocument, options);
}
export type UpdateNavigationMenuItemMutationHookResult = ReturnType<typeof useUpdateNavigationMenuItemMutation>;
export type UpdateNavigationMenuItemMutationResult = Apollo.MutationResult<UpdateNavigationMenuItemMutation>;
export type UpdateNavigationMenuItemMutationOptions = Apollo.BaseMutationOptions<UpdateNavigationMenuItemMutation, UpdateNavigationMenuItemMutationVariables>;
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<FindManyNavigationMenuItemsQuery, FindManyNavigationMenuItemsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindManyNavigationMenuItemsQuery, FindManyNavigationMenuItemsQueryVariables>(FindManyNavigationMenuItemsDocument, options);
}
export function useFindManyNavigationMenuItemsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindManyNavigationMenuItemsQuery, FindManyNavigationMenuItemsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindManyNavigationMenuItemsQuery, FindManyNavigationMenuItemsQueryVariables>(FindManyNavigationMenuItemsDocument, options);
}
export type FindManyNavigationMenuItemsQueryHookResult = ReturnType<typeof useFindManyNavigationMenuItemsQuery>;
export type FindManyNavigationMenuItemsLazyQueryHookResult = ReturnType<typeof useFindManyNavigationMenuItemsLazyQuery>;
export type FindManyNavigationMenuItemsQueryResult = Apollo.QueryResult<FindManyNavigationMenuItemsQuery, FindManyNavigationMenuItemsQueryVariables>;
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<FindOneNavigationMenuItemQuery, FindOneNavigationMenuItemQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindOneNavigationMenuItemQuery, FindOneNavigationMenuItemQueryVariables>(FindOneNavigationMenuItemDocument, options);
}
export function useFindOneNavigationMenuItemLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindOneNavigationMenuItemQuery, FindOneNavigationMenuItemQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindOneNavigationMenuItemQuery, FindOneNavigationMenuItemQueryVariables>(FindOneNavigationMenuItemDocument, options);
}
export type FindOneNavigationMenuItemQueryHookResult = ReturnType<typeof useFindOneNavigationMenuItemQuery>;
export type FindOneNavigationMenuItemLazyQueryHookResult = ReturnType<typeof useFindOneNavigationMenuItemLazyQuery>;
export type FindOneNavigationMenuItemQueryResult = Apollo.QueryResult<FindOneNavigationMenuItemQuery, FindOneNavigationMenuItemQueryVariables>;
export const CreateOneObjectMetadataItemDocument = gql`
mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) {
createOneObject(input: $input) {
@@ -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<Scalars['UUID']>;
targetRecordId?: Maybe<Scalars['UUID']>;
targetRecordIdentifier?: Maybe<RecordIdentifier>;
updatedAt: Scalars['DateTime'];
userWorkspaceId?: Maybe<Scalars['UUID']>;
viewId?: Maybe<Scalars['UUID']>;
@@ -3889,6 +3891,13 @@ export type RatioAggregateConfig = {
optionValue: Scalars['String'];
};
export type RecordIdentifier = {
__typename?: 'RecordIdentifier';
id: Scalars['UUID'];
imageIdentifier?: Maybe<Scalars['String']>;
labelIdentifier: Scalars['String'];
};
export type Relation = {
__typename?: 'Relation';
sourceFieldMetadata: Field;
@@ -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 <Action onClick={handleClick} />;
@@ -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);
};
@@ -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;
}
@@ -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;
@@ -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;
@@ -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(
@@ -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<View, 'id' | 'name' | 'objectMetadataId'> = {
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<View, 'id' | 'name' | 'objectMetadataId'> = {
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');
});
});
@@ -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<View, 'id' | 'name' | 'objectMetadataId'>,
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<View, 'id' | 'name' | 'objectMetadataId' | 'icon'> = {
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);
});
});
@@ -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}`,
);
@@ -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',
);
};
@@ -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 <NavigationMenuItemSkeletonLoader />;
}
if (
(!navigationMenuItemsSorted || navigationMenuItemsSorted.length === 0) &&
!isNavigationMenuItemFolderCreating &&
(!navigationMenuItemsByFolder || navigationMenuItemsByFolder.length === 0)
) {
return null;
}
return (
<NavigationDrawerSection>
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerSectionTitle
label={t`Favorites`}
onClick={toggleNavigationSection}
rightIcon={
<LightIconButton
Icon={IconFolderPlus}
onClick={toggleNewFolder}
accent="tertiary"
/>
}
/>
</NavigationDrawerAnimatedCollapseWrapper>
{isNavigationSectionOpen && (
<NavigationMenuItemDragProvider>
<NavigationMenuItemFolders
isNavigationSectionOpen={isNavigationSectionOpen}
/>
<CurrentWorkspaceMemberOrphanNavigationMenuItems />
</NavigationMenuItemDragProvider>
)}
</NavigationDrawerSection>
);
};
@@ -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 <CurrentWorkspaceMemberNavigationMenuItemFolders />;
}
return <CurrentWorkspaceMemberFavoritesFolders />;
};
@@ -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 = (
<NavigationMenuItemFolderNavigationDrawerItemDropdown
folderId={folder.folderId}
onRename={() => setIsNavigationMenuItemFolderRenaming(true)}
onDelete={handleNavigationMenuItemFolderDelete}
closeDropdown={() => {
closeDropdown(dropdownId);
}}
/>
);
const isModalOpened = useRecoilComponentValue(
isModalOpenedComponentState,
modalId,
);
const navigationMenuItemCount = folder.navigationMenuItems.length;
return (
<>
<NavigationDrawerItemsCollapsableContainer
key={folder.folderId}
isGroup={isGroup}
>
{isNavigationMenuItemFolderRenaming ? (
<NavigationDrawerInput
Icon={IconFolder}
value={navigationMenuItemFolderName}
onChange={setNavigationMenuItemFolderName}
onSubmit={handleSubmitRename}
onCancel={handleCancelRename}
onClickOutside={handleClickOutside}
/>
) : (
<NavigationMenuItemDroppable
droppableId={`folder-header-${folder.folderId}`}
>
<NavigationDrawerItem
label={folder.folderName}
Icon={isOpen ? IconFolderOpen : IconFolder}
onClick={handleToggle}
rightOptions={rightOptions}
className="navigation-drawer-item"
isRightOptionsDropdownOpen={isDropdownOpenComponent}
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
/>
</NavigationMenuItemDroppable>
)}
<AnimatedExpandableContainer
isExpanded={isOpen}
dimension="height"
mode="fit-content"
containAnimation
>
<Droppable droppableId={`folder-${folder.folderId}`}>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{folder.navigationMenuItems.map((navigationMenuItem, index) => (
<DraggableItem
key={navigationMenuItem.id}
draggableId={navigationMenuItem.id}
index={index}
isInsideScrollableContainer
itemComponent={
<NavigationDrawerSubItem
secondaryLabel={getNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
navigationMenuItem.objectNameSingular,
})}
label={navigationMenuItem.labelIdentifier}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
to={isDragging ? undefined : navigationMenuItem.link}
active={index === selectedNavigationMenuItemIndex}
subItemState={getNavigationSubItemLeftAdornment({
index,
arrayLength: navigationMenuItemFolderContentLength,
selectedIndex: selectedNavigationMenuItemIndex,
})}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
accent="tertiary"
/>
}
isDragging={isDragging}
triggerEvent="CLICK"
/>
}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</AnimatedExpandableContainer>
</NavigationDrawerItemsCollapsableContainer>
{isModalOpened &&
createPortal(
<ConfirmationModal
modalId={modalId}
title={
folder.navigationMenuItems.length > 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,
)}
</>
);
};
@@ -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 (
<NavigationMenuItemDroppable
droppableId={ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID}
>
{orphanNavigationMenuItems.length > 0 ? (
orphanNavigationMenuItems.map((navigationMenuItem, index) => (
<DraggableItem
key={navigationMenuItem.id}
draggableId={navigationMenuItem.id}
index={index}
isInsideScrollableContainer={true}
itemComponent={
<StyledOrphanNavigationMenuItemsContainer>
<NavigationDrawerItem
secondaryLabel={getNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
navigationMenuItem.objectNameSingular,
})}
label={navigationMenuItem.labelIdentifier}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
active={isLocationMatchingNavigationMenuItem(
currentPath,
currentViewPath,
navigationMenuItem,
)}
to={isDragging ? undefined : navigationMenuItem.link}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
accent="tertiary"
/>
}
isDragging={isDragging}
triggerEvent="CLICK"
/>
</StyledOrphanNavigationMenuItemsContainer>
}
/>
))
) : (
<StyledEmptyContainer style={{ height: isDragging ? '24px' : '1px' }} />
)}
</NavigationMenuItemDroppable>
);
};
@@ -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 (
<StyledBackButton onClick={handleClick}>
<IconX
size={theme.icon.size.md}
stroke={theme.icon.stroke.lg}
color={theme.font.color.tertiary}
/>
<span>{folderName}</span>
</StyledBackButton>
);
};
@@ -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 (
<NavigationMenuItemDragContext.Provider value={{ isDragging }}>
<DragDropContext onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
{children}
</DragDropContext>
</NavigationMenuItemDragContext.Provider>
);
};
@@ -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 (
<Droppable droppableId={droppableId}>
{(provided, snapshot) => (
<StyledDroppableWrapper
isDraggingOver={snapshot.isDraggingOver}
isDragIndicatorVisible={isDragIndicatorVisible}
showDropLine={showDropLine}
>
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{children}
{provided.placeholder}
</div>
</StyledDroppableWrapper>
)}
</Droppable>
);
};
@@ -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 (
<>
<NavigationMenuItemBackButton folderName={folderName} />
<NavigationMenuItemDragProvider>
<Droppable droppableId={`folder-${folderId}`}>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{navigationMenuItems.map((navigationMenuItem, index) => (
<DraggableItem
key={navigationMenuItem.id}
draggableId={navigationMenuItem.id}
index={index}
isInsideScrollableContainer
itemComponent={
<NavigationDrawerItem
secondaryLabel={getNavigationMenuItemSecondaryLabel({
objectMetadataItems,
navigationMenuItemObjectNameSingular:
navigationMenuItem.objectNameSingular,
})}
label={navigationMenuItem.labelIdentifier}
Icon={() => (
<NavigationMenuItemIcon
navigationMenuItem={navigationMenuItem}
/>
)}
rightOptions={
<LightIconButton
Icon={IconHeartOff}
onClick={() =>
deleteNavigationMenuItem(navigationMenuItem.id)
}
accent="tertiary"
/>
}
triggerEvent="CLICK"
to={navigationMenuItem.link}
/>
}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</NavigationMenuItemDragProvider>
</>
);
};
@@ -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 (
<NavigationMenuItemFolderContent
folderId={folderId}
folderName={folderName}
navigationMenuItems={navigationMenuItems}
/>
);
}
if (isDefined(favorites)) {
return (
<FavoritesFolderContent
folderName={folderName}
folderId={folderId}
favorites={favorites}
/>
);
}
return null;
};
@@ -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 (
<Dropdown
dropdownId={`navigation-menu-item-folder-edit-${folderId}`}
data-select-disable
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownPlacement="bottom-start"
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPencil}
onClick={handleRename}
accent="default"
text={t`Rename`}
/>
<MenuItem
LeftIcon={IconTrash}
onClick={handleDelete}
accent="danger"
text={t`Delete`}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -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 && (
<NavigationDrawerAnimatedCollapseWrapper>
<NavigationDrawerInput
Icon={IconFolder}
value={newFolderName}
onChange={handleNavigationMenuItemFolderNameChange}
onSubmit={handleSubmitNavigationMenuItemFolderCreation}
onCancel={handleCancelNavigationMenuItemFolderCreation}
onClickOutside={handleClickOutside}
/>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{navigationMenuItemsByFolder.map((folder) => (
<CurrentWorkspaceMemberNavigationMenuItems
key={folder.folderId}
folder={folder}
isGroup={navigationMenuItemsByFolder.length > 1}
/>
))}
</>
);
};
@@ -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 (
<Avatar
size="md"
type={navigationMenuItem.avatarType}
Icon={IconToUse}
iconColor={iconColorToUse}
avatarUrl={navigationMenuItem.avatarUrl}
placeholder={navigationMenuItem.labelIdentifier}
placeholderColorSeed={placeholderColorSeed}
/>
);
};
@@ -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 (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonContainer>
<Skeleton
width={56}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.xs}
/>
<StyledSkeletonColumn>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<Skeleton
width={196}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</StyledSkeletonColumn>
</StyledSkeletonContainer>
</SkeletonTheme>
);
};
@@ -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 <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
}
return (
<NavigationDrawerSectionForObjectMetadataItems
sectionTitle={t`Workspace`}
objectMetadataItems={workspaceNavigationMenuItemsObjectMetadataItems}
isRemote={false}
/>
);
};
@@ -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 <WorkspaceNavigationMenuItems />;
}
return <WorkspaceFavorites />;
};
@@ -0,0 +1,2 @@
export const ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID =
'orphan-navigation-menu-items';
@@ -0,0 +1,2 @@
export const NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID =
'navigation-menu-item-folder-delete-modal';
@@ -0,0 +1,10 @@
import { createContext } from 'react';
type NavigationMenuItemDragContextType = {
isDragging: boolean;
};
export const NavigationMenuItemDragContext =
createContext<NavigationMenuItemDragContextType>({
isDragging: false,
});
@@ -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
}
`;
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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 };
};
@@ -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<void> => {
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 };
};
@@ -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 };
};
@@ -0,0 +1,15 @@
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/hooks/useDeleteNavigationMenuItem';
export const useDeleteNavigationMenuItemFolder = () => {
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const deleteNavigationMenuItemFolder = async (
folderId: string,
): Promise<void> => {
await deleteNavigationMenuItem(folderId);
};
return {
deleteNavigationMenuItemFolder,
};
};
@@ -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 };
};
@@ -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<typeof sortNavigationMenuItems>[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<string, NavigationMenuItem[]>;
}>(
(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<string, ObjectRecordIdentifier>
>((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 };
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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<void> => {
if (!newName) {
return;
}
await updateNavigationMenuItem({
id: folderId,
name: newName,
});
};
return {
renameNavigationMenuItemFolder,
};
};
@@ -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<string, ObjectRecordIdentifier>();
[...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,
};
};
@@ -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 };
};
@@ -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<string[]>((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,
};
};
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isNavigationMenuItemFolderCreatingState = atom<boolean>({
key: 'isNavigationMenuItemFolderCreatingState',
default: false,
});
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const openNavigationMenuItemFolderIdsState = atom<string[]>({
key: 'openNavigationMenuItemFolderIdsState',
default: [],
});
@@ -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');
});
});
@@ -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();
});
});
@@ -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);
});
});
@@ -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<View, 'id' | 'name' | 'objectMetadataId'>,
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<View, 'id' | 'name' | 'objectMetadataId' | 'icon'> = {
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);
});
});
@@ -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,
};
};
@@ -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;
};
@@ -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;
};
@@ -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,
};
};
@@ -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<View, 'id' | 'name' | 'objectMetadataId' | 'icon'>[],
objectMetadataItems: ObjectMetadataItem[],
targetRecordIdentifiers: Map<string, ObjectRecordIdentifier>,
): 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);
};
@@ -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 (
<NavigationDrawer
className={className}
@@ -28,11 +48,14 @@ export const MainNavigationDrawer = ({ className }: { className?: string }) => {
</NavigationDrawerFixedContent>
<NavigationDrawerScrollableContent>
{currentFavoriteFolderId && openedFolder ? (
<FavoritesFolderContent
{openedFolder ? (
<NavigationMenuItemFolderContentDispatcherEffect
folderName={openedFolder.folderName}
folderId={openedFolder.folderId}
favorites={openedFolder.favorites}
favorites={openedFavoriteFolder?.favorites}
navigationMenuItems={
openedNavigationMenuItemFolder?.navigationMenuItems
}
/>
) : (
<MainNavigationDrawerScrollableItems />
@@ -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 (
<StyledScrollableItemsContainer>
<NavigationDrawerOpenedSection />
<CurrentWorkspaceMemberFavoritesFolders />
<WorkspaceFavorites />
<Suspense fallback={null}>
<CurrentWorkspaceMemberNavigationMenuItemFoldersDispatcher />
</Suspense>
<Suspense fallback={null}>
<WorkspaceNavigationMenuItemsDispatcher />
</Suspense>
<RemoteNavigationDrawerSection />
</StyledScrollableItemsContainer>
);
@@ -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 <NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader />;
@@ -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: {
@@ -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: {
@@ -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 = <T>({
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<T>({
@@ -219,11 +227,13 @@ export const useIncrementalDeleteManyRecords = <T>({
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 = <T>({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
if (isNavigationMenuItemEnabled) {
removeNavigationMenuItemsByTargetRecordIds(allDeletedRecordIds);
}
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
@@ -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};
@@ -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';
@@ -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';
@@ -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';
@@ -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 (
<>
<PrefetchRunFavoriteQueriesEffect />
<PrefetchRunNavigationMenuItemQueriesEffect />
{children}
</>
);
@@ -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(
@@ -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 <></>;
};
@@ -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: [],
});
@@ -1,4 +1,5 @@
export enum PrefetchKey {
AllFavorites = 'ALL_FAVORITES',
AllFavoritesFolders = 'ALL_FAVORITES_FOLDERS',
AllNavigationMenuItems = 'ALL_NAVIGATION_MENU_ITEMS',
}
@@ -0,0 +1,4 @@
export const FOLDER_DROPPABLE_IDS = {
FOLDER_PREFIX: 'folder-',
FOLDER_HEADER_PREFIX: 'folder-header-',
} as const;
@@ -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',
);
};
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const currentNavigationMenuItemFolderIdState = atom<string | null>({
key: 'currentNavigationMenuItemFolderIdState',
default: null,
});
@@ -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');
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddNavigationMenuItemViewForeignKey1769196250679
implements MigrationInterface
{
name = 'AddNavigationMenuItemViewForeignKey1769196250679';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "FK_9ec9d8bc9bb4197be12d4efcaf3"`,
);
}
}
@@ -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',
}
@@ -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,
@@ -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<string, unknown>;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
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;
};
@@ -106,6 +106,11 @@ export const ALL_METADATA_RELATIONS = {
flatEntityForeignKeyAggregator: null,
foreignKey: 'folderId',
},
view: {
metadataName: 'view',
flatEntityForeignKeyAggregator: null,
foreignKey: 'viewId',
},
},
oneToMany: {},
},
@@ -42,6 +42,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
[
"objectMetadata",
"navigationMenuItem",
"view",
]
`;
@@ -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",
@@ -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;
}
@@ -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;
}
@@ -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<ViewEntity> | null;
@ManyToOne(() => ObjectMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
@@ -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<void> {
await this.navigationMenuItemDeletionService.deleteNavigationMenuItemsForDeletedRecords(
data.deletedRecordIds,
data.workspaceId,
);
}
}
@@ -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<ObjectRecordDeleteEvent>,
) {
const deletedRecordIds = payload.events.map(({ recordId }) => recordId);
if (deletedRecordIds.length === 0) {
return;
}
await this.messageQueueService.add<NavigationMenuItemDeletionJobData>(
NavigationMenuItemDeletionJob.name,
{
workspaceId: payload.workspaceId,
deletedRecordIds,
},
);
}
}
@@ -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,
@@ -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<RecordIdentifierDTO | null> {
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,
});
}
}
@@ -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<string | undefined> {
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<RecordIdentifierDTO | null> {
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<Record<string, unknown>>(
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;
}
}
}
@@ -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<void> {
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<typeof item> =>
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',
);
}
}
}
@@ -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<FlatFieldMetadata>;
}): 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;
};
@@ -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,
},
@@ -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,

Some files were not shown because too many files have changed in this diff Show More