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:
@@ -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;
|
||||
|
||||
+13
-1
@@ -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} />;
|
||||
|
||||
+22
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
+29
@@ -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;
|
||||
}
|
||||
|
||||
+33
-5
@@ -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(
|
||||
|
||||
+37
@@ -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);
|
||||
});
|
||||
});
|
||||
+25
-9
@@ -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',
|
||||
);
|
||||
};
|
||||
+84
@@ -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>
|
||||
);
|
||||
};
|
||||
+17
@@ -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 />;
|
||||
};
|
||||
+289
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+92
@@ -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>
|
||||
);
|
||||
};
|
||||
+59
@@ -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>
|
||||
);
|
||||
};
|
||||
+39
@@ -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>
|
||||
);
|
||||
};
|
||||
+70
@@ -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>
|
||||
);
|
||||
};
|
||||
+82
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+48
@@ -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;
|
||||
};
|
||||
+63
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+91
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+35
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+46
@@ -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>
|
||||
);
|
||||
};
|
||||
+26
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+17
@@ -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 />;
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const ORPHAN_NAVIGATION_MENU_ITEMS_DROPPABLE_ID =
|
||||
'orphan-navigation-menu-items';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const NAVIGATION_MENU_ITEM_FOLDER_DELETE_MODAL_ID =
|
||||
'navigation-menu-item-folder-delete-modal';
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type NavigationMenuItemDragContextType = {
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export const NavigationMenuItemDragContext =
|
||||
createContext<NavigationMenuItemDragContextType>({
|
||||
isDragging: false,
|
||||
});
|
||||
+17
@@ -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
|
||||
}
|
||||
`;
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+85
@@ -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 };
|
||||
};
|
||||
+52
@@ -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 };
|
||||
};
|
||||
+16
@@ -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 };
|
||||
};
|
||||
+15
@@ -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,
|
||||
};
|
||||
};
|
||||
+143
@@ -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 };
|
||||
};
|
||||
+111
@@ -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 };
|
||||
};
|
||||
+34
@@ -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,
|
||||
};
|
||||
};
|
||||
+48
@@ -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,
|
||||
};
|
||||
};
|
||||
+23
@@ -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,
|
||||
};
|
||||
};
|
||||
+109
@@ -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,
|
||||
};
|
||||
};
|
||||
+28
@@ -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 };
|
||||
};
|
||||
+60
@@ -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,
|
||||
};
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const isNavigationMenuItemFolderCreatingState = atom<boolean>({
|
||||
key: 'isNavigationMenuItemFolderCreatingState',
|
||||
default: false,
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const openNavigationMenuItemFolderIdsState = atom<string[]>({
|
||||
key: 'openNavigationMenuItemFolderIdsState',
|
||||
default: [],
|
||||
});
|
||||
+154
@@ -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');
|
||||
});
|
||||
});
|
||||
+31
@@ -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();
|
||||
});
|
||||
});
|
||||
+88
@@ -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);
|
||||
});
|
||||
});
|
||||
+364
@@ -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);
|
||||
});
|
||||
});
|
||||
+31
@@ -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,
|
||||
};
|
||||
};
|
||||
+23
@@ -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;
|
||||
};
|
||||
+14
@@ -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;
|
||||
};
|
||||
+51
@@ -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,
|
||||
};
|
||||
};
|
||||
+96
@@ -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 />
|
||||
|
||||
+23
-4
@@ -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>
|
||||
);
|
||||
|
||||
+15
-4
@@ -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: {
|
||||
|
||||
+14
@@ -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: {
|
||||
|
||||
+1
-1
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
+15
-2
@@ -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(
|
||||
|
||||
+71
@@ -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;
|
||||
+48
@@ -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',
|
||||
);
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const currentNavigationMenuItemFolderIdState = atom<string | null>({
|
||||
key: 'currentNavigationMenuItemFolderIdState',
|
||||
default: null,
|
||||
});
|
||||
+25
-6
@@ -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');
|
||||
|
||||
+19
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -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,
|
||||
|
||||
+67
@@ -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;
|
||||
};
|
||||
+5
@@ -106,6 +106,11 @@ export const ALL_METADATA_RELATIONS = {
|
||||
flatEntityForeignKeyAggregator: null,
|
||||
foreignKey: 'folderId',
|
||||
},
|
||||
view: {
|
||||
metadataName: 'view',
|
||||
flatEntityForeignKeyAggregator: null,
|
||||
foreignKey: 'viewId',
|
||||
},
|
||||
},
|
||||
oneToMany: {},
|
||||
},
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
|
||||
[
|
||||
"objectMetadata",
|
||||
"navigationMenuItem",
|
||||
"view",
|
||||
]
|
||||
`;
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+5
@@ -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;
|
||||
}
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
+8
@@ -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,
|
||||
|
||||
+29
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -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,
|
||||
|
||||
+35
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+163
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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;
|
||||
};
|
||||
+1
@@ -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,
|
||||
},
|
||||
|
||||
+5
@@ -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
Reference in New Issue
Block a user