diff --git a/packages/twenty-front/src/modules/layout-customization/hooks/__tests__/useEnterLayoutCustomizationMode.test.tsx b/packages/twenty-front/src/modules/layout-customization/hooks/__tests__/useEnterLayoutCustomizationMode.test.tsx new file mode 100644 index 0000000000..183cd75d4b --- /dev/null +++ b/packages/twenty-front/src/modules/layout-customization/hooks/__tests__/useEnterLayoutCustomizationMode.test.tsx @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react'; +import { createStore, Provider as JotaiProvider } from 'jotai'; +import { type ReactNode } from 'react'; + +import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode'; +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; +import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; +import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; + +jest.mock('@/settings/roles/hooks/useHasPermissionFlag'); +jest.mock('@/side-panel/hooks/useNavigateSidePanel', () => ({ + useNavigateSidePanel: () => ({ + navigateSidePanel: jest.fn(), + }), +})); +jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({ + useSnackBar: () => ({ + enqueueWarningSnackBar: jest.fn(), + }), +})); + +const mockUseHasPermissionFlag = useHasPermissionFlag as jest.Mock; + +const getWrapper = + (store = createStore()) => + ({ children }: { children: ReactNode }) => ( + {children} + ); + +describe('useEnterLayoutCustomizationMode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return false and not enable customization mode when user lacks LAYOUTS permission', () => { + mockUseHasPermissionFlag.mockReturnValue(false); + const store = createStore(); + const wrapper = getWrapper(store); + + const { result } = renderHook(() => useEnterLayoutCustomizationMode(), { + wrapper, + }); + + const success = result.current.enterLayoutCustomizationMode(); + + expect(success).toBe(false); + expect(store.get(isLayoutCustomizationModeEnabledState.atom)).toBe(false); + }); + + it('should return true and enable customization mode when user has LAYOUTS permission', () => { + mockUseHasPermissionFlag.mockReturnValue(true); + const store = createStore(); + const wrapper = getWrapper(store); + + store.set(metadataStoreState.atomFamily('navigationMenuItems'), { + current: [], + draft: [], + status: 'up-to-date', + }); + + const { result } = renderHook(() => useEnterLayoutCustomizationMode(), { + wrapper, + }); + + const success = result.current.enterLayoutCustomizationMode(); + + expect(success).toBe(true); + expect(store.get(isLayoutCustomizationModeEnabledState.atom)).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts index 57e992c867..8764fdd52d 100644 --- a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts +++ b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts @@ -15,17 +15,25 @@ import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/state import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems'; import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState'; import { isDashboardInEditModeComponentState } from '@/page-layout/states/isDashboardInEditModeComponentState'; +import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { PermissionFlagType } from '~/generated-metadata/graphql'; + export const useEnterLayoutCustomizationMode = () => { const store = useStore(); const { navigateSidePanel } = useNavigateSidePanel(); const { enqueueWarningSnackBar } = useSnackBar(); + const hasLayoutsPermission = useHasPermissionFlag(PermissionFlagType.LAYOUTS); const enterLayoutCustomizationMode = useCallback((): boolean => { + if (!hasLayoutsPermission) { + return false; + } + const isLayoutCustomizationModeAlreadyEnabled = store.get( isLayoutCustomizationModeEnabledState.atom, ); @@ -86,7 +94,7 @@ export const useEnterLayoutCustomizationMode = () => { } return true; - }, [enqueueWarningSnackBar, navigateSidePanel, store]); + }, [enqueueWarningSnackBar, hasLayoutsPermission, navigateSidePanel, store]); return { enterLayoutCustomizationMode }; }; diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx b/packages/twenty-front/src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx index ef885a3617..da87542a5a 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/display/sections/workspace/components/WorkspaceSection.tsx @@ -2,6 +2,7 @@ import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { useNavigate } from 'react-router-dom'; +import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types'; import { IconColumnInsertRight, IconLink, @@ -27,15 +28,17 @@ import { WorkspaceSectionContainer } from '@/navigation-menu-item/display/sectio import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink'; import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel'; import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel'; +import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState'; import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { viewsSelector } from '@/views/states/selectors/viewsSelector'; -import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types'; -import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState'; + +import { PermissionFlagType } from '~/generated-metadata/graphql'; const StyledRightIconsContainer = styled.div` align-items: center; @@ -52,6 +55,7 @@ export const WorkspaceSection = () => { lastVisitedViewPerObjectMetadataItemState, ); const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode(); + const hasLayoutsPermission = useHasPermissionFlag(PermissionFlagType.LAYOUTS); const isLayoutCustomizationModeEnabled = useAtomStateValue( isLayoutCustomizationModeEnabledState, ); @@ -169,7 +173,9 @@ export const WorkspaceSection = () => { objectMetadataItem: EnrichedObjectMetadataItem, navigationMenuItemId: string, ) => { - enterLayoutCustomizationMode(); + if (!enterLayoutCustomizationMode()) { + return; + } setSelectedNavigationMenuItemIdInEditMode(navigationMenuItemId); openNavigationMenuItemInSidePanel({ pageTitle: objectMetadataItem.labelSingular, @@ -201,14 +207,16 @@ export const WorkspaceSection = () => { onClick={handleAddMenuItem} /> ) : ( -
- -
+ hasLayoutsPermission && ( +
+ +
+ ) )} } diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx index c9bcc72819..01146d2391 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/tabs/ObjectLayout.tsx @@ -5,10 +5,10 @@ import { useLingui } from '@lingui/react/macro'; import { AppPath } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { IconLayoutDashboard, IconReload } from 'twenty-ui/icon'; -import { H2Title } from 'twenty-ui/typography'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; +import { H2Title } from 'twenty-ui/typography'; import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; @@ -16,9 +16,12 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useResetPageLayoutToDefault } from '@/page-layout/hooks/useResetPageLayoutToDefault'; import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector'; import { SettingsCard } from '@/settings/components/SettingsCard'; +import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; + +import { PermissionFlagType } from '~/generated-metadata/graphql'; import { useNavigateApp } from '~/hooks/useNavigateApp'; const StyledContentContainer = styled.div` @@ -38,6 +41,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => { const { t } = useLingui(); const navigateApp = useNavigateApp(); const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode(); + const hasLayoutsPermission = useHasPermissionFlag(PermissionFlagType.LAYOUTS); const { openModal } = useModal(); const { resetPageLayoutToDefault } = useResetPageLayoutToDefault(); @@ -55,7 +59,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => { const firstRecord = records[0]; const handleCustomizeRecordPage = () => { - if (!isDefined(firstRecord)) { + if (!hasLayoutsPermission || !isDefined(firstRecord)) { return; } @@ -74,7 +78,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => { }; const handleConfirmReset = async () => { - if (!isDefined(pageLayout)) { + if (!hasLayoutsPermission || !isDefined(pageLayout)) { return; } @@ -94,7 +98,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => { title={t`Customize record page`} Icon={} onClick={handleCustomizeRecordPage} - disabled={!isDefined(firstRecord)} + disabled={!hasLayoutsPermission || !isDefined(firstRecord)} />
@@ -108,7 +112,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => { size="small" Icon={IconReload} onClick={handleResetPageLayout} - disabled={!isDefined(pageLayout)} + disabled={!hasLayoutsPermission || !isDefined(pageLayout)} />