diff --git a/packages/twenty-front/src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts b/packages/twenty-front/src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts index 219a8298a0..30794a0b07 100644 --- a/packages/twenty-front/src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts +++ b/packages/twenty-front/src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts @@ -2,41 +2,113 @@ import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; import { renderHook, waitFor } from '@testing-library/react'; import { Provider as JotaiProvider } from 'jotai'; import { createElement, useEffect, type ReactNode } from 'react'; -import { AppPath } from 'twenty-shared/types'; +import { AppPath, SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath } from 'twenty-shared/utils'; import { + type NavigationMenuItem, + NavigationMenuItemType, ViewOpenRecordIn, ViewType, ViewVisibility, } from '~/generated-metadata/graphql'; import { mockedUserData } from '~/testing/mock-data/users'; -import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock'; import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; -import { setTestViewsInMetadataStore } from '~/testing/utils/setTestViewsInMetadataStore'; +import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock'; import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore'; +import { setTestViewsInMetadataStore } from '~/testing/utils/setTestViewsInMetadataStore'; const Wrapper = ({ children }: { children: ReactNode }) => createElement(JotaiProvider, { store: jotaiStore }, children); +const buildObjectNavigationMenuItem = ( + objectNameSingular: string, + position: number, + folderId?: string, +): NavigationMenuItem => ({ + __typename: 'NavigationMenuItem', + id: `navigation-menu-item-${objectNameSingular}`, + type: NavigationMenuItemType.OBJECT, + targetObjectMetadataId: + getMockObjectMetadataItemOrThrow(objectNameSingular).id, + position, + folderId, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}); + +const buildFolderNavigationMenuItem = ( + id: string, + position: number, +): NavigationMenuItem => ({ + __typename: 'NavigationMenuItem', + id, + type: NavigationMenuItemType.FOLDER, + name: 'Folder', + position, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}); + +const buildPageLayoutNavigationMenuItem = ( + pageLayoutId: string, + position: number, +): NavigationMenuItem => ({ + __typename: 'NavigationMenuItem', + id: `navigation-menu-item-page-layout-${pageLayoutId}`, + type: NavigationMenuItemType.PAGE_LAYOUT, + pageLayoutId, + position, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}); + +const buildLinkNavigationMenuItem = (position: number): NavigationMenuItem => ({ + __typename: 'NavigationMenuItem', + id: `navigation-menu-item-link-${position}`, + type: NavigationMenuItemType.LINK, + link: 'https://example.com', + position, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}); + +const buildViewNavigationMenuItem = ( + viewId: string, + position: number, +): NavigationMenuItem => ({ + __typename: 'NavigationMenuItem', + id: `navigation-menu-item-view-${viewId}`, + type: NavigationMenuItemType.VIEW, + viewId, + position, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', +}); + const renderHooks = ({ withCurrentUser, withExistingView, withObjectMetadataLoaded = true, + objectMetadataItems = getTestEnrichedObjectMetadataItemsMock(), + navigationMenuItems = [], + withNavigationMenuItemsLoaded = true, }: { withCurrentUser: boolean; withExistingView: boolean; withObjectMetadataLoaded?: boolean; + objectMetadataItems?: EnrichedObjectMetadataItem[]; + navigationMenuItems?: NavigationMenuItem[]; + withNavigationMenuItemsLoaded?: boolean; }) => { if (withObjectMetadataLoaded) { - setTestObjectMetadataItemsInMetadataStore( - jotaiStore, - getTestEnrichedObjectMetadataItemsMock(), - ); + setTestObjectMetadataItemsInMetadataStore(jotaiStore, objectMetadataItems); } else { jotaiStore.set(metadataStoreState.atomFamily('objectMetadataItems'), { current: [], @@ -45,6 +117,12 @@ const renderHooks = ({ }); } + jotaiStore.set(metadataStoreState.atomFamily('navigationMenuItems'), { + current: navigationMenuItems, + draft: [], + status: withNavigationMenuItemsLoaded ? 'up-to-date' : 'empty', + }); + const { result } = renderHook( () => { const setCurrentUser = useSetAtomState(currentUserState); @@ -119,25 +197,98 @@ describe('useDefaultHomePagePath', () => { expect(result.current.defaultHomePagePath).toEqual(AppPath.SignInUp); }); }); - it('should return proper path when currentUser is defined', async () => { + it('should redirect to the first object of the navigation menu', async () => { const { result } = renderHooks({ withCurrentUser: true, withExistingView: false, + navigationMenuItems: [ + buildObjectNavigationMenuItem('person', 0), + buildObjectNavigationMenuItem('company', 1), + ], + }); + + await waitFor(() => { + expect(result.current.defaultHomePagePath).toEqual('/objects/people'); + }); + }); + it('should honor display order over a lower-positioned item nested in a folder', async () => { + const { result } = renderHooks({ + withCurrentUser: true, + withExistingView: false, + navigationMenuItems: [ + buildObjectNavigationMenuItem('company', 0, 'folder-1'), + buildObjectNavigationMenuItem('person', 1), + buildFolderNavigationMenuItem('folder-1', 2), + ], + }); + + await waitFor(() => { + expect(result.current.defaultHomePagePath).toEqual('/objects/people'); + }); + }); + it('should redirect to a PAGE_LAYOUT navigation menu item as homepage', async () => { + const { result } = renderHooks({ + withCurrentUser: true, + withExistingView: false, + navigationMenuItems: [ + buildPageLayoutNavigationMenuItem('page-layout-1', 0), + buildObjectNavigationMenuItem('person', 1), + ], + }); + + await waitFor(() => { + expect(result.current.defaultHomePagePath).toEqual('/page/page-layout-1'); + }); + }); + it('should skip a LINK navigation menu item and use the next valid item', async () => { + const { result } = renderHooks({ + withCurrentUser: true, + withExistingView: false, + navigationMenuItems: [ + buildLinkNavigationMenuItem(0), + buildObjectNavigationMenuItem('person', 1), + ], + }); + + await waitFor(() => { + expect(result.current.defaultHomePagePath).toEqual('/objects/people'); + }); + }); + it('should honor the view of a VIEW navigation menu item', async () => { + const { result } = renderHooks({ + withCurrentUser: true, + withExistingView: true, + navigationMenuItems: [buildViewNavigationMenuItem('viewId', 0)], + }); + + await waitFor(() => { + expect(result.current.defaultHomePagePath).toEqual( + '/objects/companies?viewId=viewId', + ); + }); + }); + it('should fall back to the first readable object when the menu has no object item', async () => { + const { result } = renderHooks({ + withCurrentUser: true, + withExistingView: false, + navigationMenuItems: [], }); await waitFor(() => { expect(result.current.defaultHomePagePath).toEqual('/objects/companies'); }); }); - it('should return proper path when currentUser is defined and view exists', async () => { + it('should redirect to profile settings when there is no readable object', async () => { const { result } = renderHooks({ withCurrentUser: true, - withExistingView: true, + withExistingView: false, + objectMetadataItems: [], + navigationMenuItems: [], }); await waitFor(() => { expect(result.current.defaultHomePagePath).toEqual( - '/objects/companies?viewId=viewId', + getSettingsPath(SettingsPath.ProfilePage), ); }); }); @@ -151,6 +302,18 @@ describe('useDefaultHomePagePath', () => { withObjectMetadataLoaded: false, }); + await waitFor(() => { + expect(result.current.defaultHomePagePath).toEqual(AppPath.Index); + }); + }); + it('should defer to AppPath.Index when navigation menu items are not loaded yet', async () => { + const { result } = renderHooks({ + withCurrentUser: true, + withExistingView: false, + navigationMenuItems: [buildObjectNavigationMenuItem('person', 0)], + withNavigationMenuItemsLoaded: false, + }); + await waitFor(() => { expect(result.current.defaultHomePagePath).toEqual(AppPath.Index); }); diff --git a/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts b/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts index 516d226c87..1dfc428d01 100644 --- a/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts +++ b/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts @@ -1,10 +1,14 @@ import { currentUserState } from '@/auth/states/currentUserState'; import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; -import { lastVisitedObjectMetadataItemIdState } from '@/navigation/states/lastVisitedObjectMetadataItemIdState'; +import { metadataStoreStatusFamilySelector } from '@/metadata-store/states/metadataStoreStatusFamilySelector'; +import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems'; import { type ObjectPathInfo } from '@/navigation/types/ObjectPathInfo'; +import { getFirstNavigationMenuItemLink } from '@/navigation/utils/getFirstNavigationMenuItemLink'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { viewsSelector } from '@/views/states/selectors/viewsSelector'; @@ -12,10 +16,8 @@ import isEmpty from 'lodash.isempty'; import { useCallback, useMemo } from 'react'; import { AppPath, SettingsPath } from 'twenty-shared/types'; import { getAppPath, getSettingsPath, isDefined } from 'twenty-shared/utils'; -import { useStore } from 'jotai'; export const useDefaultHomePagePath = () => { - const store = useStore(); const currentUser = useAtomStateValue(currentUserState); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); const metadataStore = useAtomFamilyStateValue( @@ -23,8 +25,17 @@ export const useDefaultHomePagePath = () => { 'objectMetadataItems', ); const areObjectMetadataItemsLoaded = metadataStore.status === 'up-to-date'; + const navigationMenuItemsStatus = useAtomFamilySelectorValue( + metadataStoreStatusFamilySelector, + 'navigationMenuItems', + ); + const areNavigationMenuItemsLoaded = + navigationMenuItemsStatus === 'up-to-date'; const { activeObjectMetadataItems } = useFilteredObjectMetadataItems(); + const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector); + const views = useAtomStateValue(viewsSelector); + const navigationMenuItemsInDisplayOrder = useNavigationMenuItemSectionItems(); const readableNonSystemObjectMetadataItems = useMemo( () => @@ -37,17 +48,6 @@ export const useDefaultHomePagePath = () => { [activeObjectMetadataItems, objectPermissionsByObjectMetadataId], ); - const getActiveObjectMetadataItemMatchingId = useCallback( - (objectMetadataId: string) => { - return readableNonSystemObjectMetadataItems.find( - (item) => item.id === objectMetadataId, - ); - }, - [readableNonSystemObjectMetadataItems], - ); - - const views = useAtomStateValue(viewsSelector); - const getFirstView = useCallback( (objectMetadataItemId: string | undefined | null) => { return views.find( @@ -57,6 +57,22 @@ export const useDefaultHomePagePath = () => { [views], ); + const firstNavigationMenuItemLink = useMemo( + () => + getFirstNavigationMenuItemLink({ + navigationMenuItemsInDisplayOrder, + objectMetadataItems, + views, + objectPermissionsByObjectMetadataId, + }), + [ + objectMetadataItems, + objectPermissionsByObjectMetadataId, + views, + navigationMenuItemsInDisplayOrder, + ], + ); + const firstObjectPathInfo = useMemo(() => { const [firstObjectMetadataItem] = readableNonSystemObjectMetadataItems; @@ -64,37 +80,11 @@ export const useDefaultHomePagePath = () => { return null; } - const view = getFirstView(firstObjectMetadataItem?.id); + const view = getFirstView(firstObjectMetadataItem.id); return { objectMetadataItem: firstObjectMetadataItem, view }; }, [getFirstView, readableNonSystemObjectMetadataItems]); - const getDefaultObjectPathInfo = useCallback(() => { - const lastVisitedObjectMetadataItemId = store.get( - lastVisitedObjectMetadataItemIdState.atom, - ); - - const lastVisitedObjectMetadataItem = isDefined( - lastVisitedObjectMetadataItemId, - ) - ? getActiveObjectMetadataItemMatchingId(lastVisitedObjectMetadataItemId) - : undefined; - - if (isDefined(lastVisitedObjectMetadataItem)) { - return { - view: getFirstView(lastVisitedObjectMetadataItemId), - objectMetadataItem: lastVisitedObjectMetadataItem, - }; - } - - return firstObjectPathInfo; - }, [ - firstObjectPathInfo, - getActiveObjectMetadataItemMatchingId, - getFirstView, - store, - ]); - const defaultHomePagePath = useMemo(() => { if (!isDefined(currentUser)) { return AppPath.SignInUp; @@ -113,25 +103,35 @@ export const useDefaultHomePagePath = () => { return getSettingsPath(SettingsPath.ProfilePage); } - const defaultObjectPathInfo = getDefaultObjectPathInfo(); + // The navigation menu drives the redirect and loads after the minimal- + // metadata fast path. Wait for it instead of falling back to the + // alphabetically-first object during the post-login window. + if (!areNavigationMenuItemsLoaded) { + return AppPath.Index; + } - if (!isDefined(defaultObjectPathInfo)) { + if (isDefined(firstNavigationMenuItemLink)) { + return firstNavigationMenuItemLink; + } + + if (!isDefined(firstObjectPathInfo)) { return AppPath.NotFound; } - const namePlural = defaultObjectPathInfo.objectMetadataItem?.namePlural; - const viewId = defaultObjectPathInfo.view?.id; - return getAppPath( AppPath.RecordIndexPage, - { objectNamePlural: namePlural }, - viewId ? { viewId } : undefined, + { objectNamePlural: firstObjectPathInfo.objectMetadataItem?.namePlural }, + firstObjectPathInfo.view?.id + ? { viewId: firstObjectPathInfo.view.id } + : undefined, ); }, [ currentUser, - getDefaultObjectPathInfo, readableNonSystemObjectMetadataItems, areObjectMetadataItemsLoaded, + areNavigationMenuItemsLoaded, + firstNavigationMenuItemLink, + firstObjectPathInfo, ]); return { defaultHomePagePath }; diff --git a/packages/twenty-front/src/modules/navigation/utils/getFirstNavigationMenuItemLink.ts b/packages/twenty-front/src/modules/navigation/utils/getFirstNavigationMenuItemLink.ts new file mode 100644 index 0000000000..14951c92eb --- /dev/null +++ b/packages/twenty-front/src/modules/navigation/utils/getFirstNavigationMenuItemLink.ts @@ -0,0 +1,71 @@ +import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/display/object/utils/getObjectMetadataForNavigationMenuItem'; +import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink'; +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; +import { type View } from '@/views/types/View'; +import { isNonEmptyString } from '@sniptt/guards'; +import { NavigationMenuItemType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; + +type GetFirstNavigationMenuItemLinkArgs = { + navigationMenuItemsInDisplayOrder: NavigationMenuItem[]; + objectMetadataItems: EnrichedObjectMetadataItem[]; + views: Pick[]; + objectPermissionsByObjectMetadataId: Parameters< + typeof getObjectPermissionsForObject + >[0]; +}; + +const OBJECT_BACKED_NAVIGATION_MENU_ITEM_TYPES = [ + NavigationMenuItemType.OBJECT, + NavigationMenuItemType.VIEW, + NavigationMenuItemType.RECORD, +]; + +export const getFirstNavigationMenuItemLink = ({ + navigationMenuItemsInDisplayOrder, + objectMetadataItems, + views, + objectPermissionsByObjectMetadataId, +}: GetFirstNavigationMenuItemLinkArgs): string | null => { + for (const item of navigationMenuItemsInDisplayOrder) { + if ( + item.type === NavigationMenuItemType.FOLDER || + item.type === NavigationMenuItemType.LINK + ) { + continue; + } + + if (OBJECT_BACKED_NAVIGATION_MENU_ITEM_TYPES.includes(item.type)) { + const objectMetadataItem = getObjectMetadataForNavigationMenuItem( + item, + objectMetadataItems, + views, + ); + + if ( + !isDefined(objectMetadataItem) || + objectMetadataItem.isSystem || + !getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + objectMetadataItem.id, + ).canReadObjectRecords + ) { + continue; + } + } + + const link = getNavigationMenuItemComputedLink( + item, + objectMetadataItems, + views, + ); + + if (isNonEmptyString(link)) { + return link; + } + } + + return null; +};