From e0d1f74648c245ab9b395d03a995328bd3f0e359 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Tue, 18 Nov 2025 10:35:38 +0100 Subject: [PATCH] Page layout conditional display (#15802) - Allow widgets to be hidden based on device's type conditions: desktop or mobile - Create two widgets for rich text fields on tasks and notes. One widget is displayed below fields on mobile (and in the right drawer); the other is displayed on a separate tab on desktop - In read mode, hide tabs if they contain no visible widgets. If there is no tab left to display, display at least the first one with no widgets. - In edit mode, display all tabs and all widgets. ## Demo https://github.com/user-attachments/assets/65ef1261-3902-4432-a420-48983b763b2c Closes https://github.com/twentyhq/core-team-issues/issues/1811 --- packages/twenty-front/package.json | 2 + .../page-layout/PageLayoutMainContent.tsx | 3 +- .../components/PageLayoutContent.tsx | 10 +- .../components/PageLayoutGridLayout.tsx | 12 +- .../components/PageLayoutLeftPanel.tsx | 6 +- .../components/PageLayoutRendererContent.tsx | 11 +- .../constants/DefaultNoteRecordPageLayout.ts | 46 +++ .../constants/DefaultTaskRecordPageLayout.ts | 46 +++ ...ePageLayoutTabWithVisibleWidgetsOrThrow.ts | 41 +++ .../page-layout/types/PageLayoutTab.ts | 6 +- .../types/WidgetVisibilityContext.ts | 3 + .../evaluateWidgetVisibility.test.ts | 137 ++++++++ .../__tests__/filterVisibleWidgets.test.ts | 112 +++++++ .../__tests__/getTabsByDisplayMode.test.ts | 87 +++-- .../getTabsWithVisibleWidgets.test.ts | 312 ++++++++++++++++++ .../utils/buildWidgetVisibilityContext.ts | 15 + .../utils/evaluateWidgetVisibility.ts | 21 ++ .../page-layout/utils/filterVisibleWidgets.ts | 20 ++ .../page-layout/utils/getTabsByDisplayMode.ts | 21 +- .../utils/getTabsWithVisibleWidgets.ts | 38 +++ .../widgets/hooks/useIsInPinnedTab.ts | 17 +- yarn.lock | 16 + 22 files changed, 918 insertions(+), 64 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow.ts create mode 100644 packages/twenty-front/src/modules/page-layout/types/WidgetVisibilityContext.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/evaluateWidgetVisibility.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/filterVisibleWidgets.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsWithVisibleWidgets.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/buildWidgetVisibilityContext.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/evaluateWidgetVisibility.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/filterVisibleWidgets.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/getTabsWithVisibleWidgets.ts diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index f26e0aaba9..c823c4f10e 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -85,6 +85,7 @@ "input-otp": "^1.4.2", "js-cookie": "^3.0.5", "json-2-csv": "^5.4.0", + "json-logic-js": "^2.0.5", "jwt-decode": "^4.0.0", "qs": "^6.11.2", "react-data-grid": "7.0.0-beta.13", @@ -120,6 +121,7 @@ "@types/apollo-upload-client": "^17.0.2", "@types/file-saver": "^2.0.7", "@types/js-cookie": "^3.0.3", + "@types/json-logic-js": "^2", "@types/react-grid-layout": "^1", "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.39.0", diff --git a/packages/twenty-front/src/modules/page-layout/PageLayoutMainContent.tsx b/packages/twenty-front/src/modules/page-layout/PageLayoutMainContent.tsx index 9bea1d4e26..dae820fded 100644 --- a/packages/twenty-front/src/modules/page-layout/PageLayoutMainContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/PageLayoutMainContent.tsx @@ -1,6 +1,7 @@ import { PageLayoutContent } from '@/page-layout/components/PageLayoutContent'; import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext'; import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow'; +import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow'; import { getTabLayoutMode } from '@/page-layout/utils/getTabLayoutMode'; type PageLayoutMainContentProps = { @@ -11,8 +12,8 @@ export const PageLayoutMainContent = ({ tabId, }: PageLayoutMainContentProps) => { const { currentPageLayout } = useCurrentPageLayoutOrThrow(); + const activeTab = usePageLayoutTabWithVisibleWidgetsOrThrow(tabId); - const activeTab = currentPageLayout.tabs.find((tab) => tab.id === tabId); const layoutMode = getTabLayoutMode({ tab: activeTab, pageLayoutType: currentPageLayout.type, diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutContent.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutContent.tsx index 32c3e8876d..a5c3b1154a 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutContent.tsx @@ -3,14 +3,13 @@ import { PageLayoutGridLayout } from '@/page-layout/components/PageLayoutGridLay import { PageLayoutVerticalListEditor } from '@/page-layout/components/PageLayoutVerticalListEditor'; import { PageLayoutVerticalListViewer } from '@/page-layout/components/PageLayoutVerticalListViewer'; import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext'; -import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout'; +import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow'; import { useReorderPageLayoutWidgets } from '@/page-layout/hooks/useReorderPageLayoutWidgets'; import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import styled from '@emotion/styled'; -import { isDefined } from 'twenty-shared/utils'; import { FeatureFlagKey } from '~/generated/graphql'; const StyledContainer = styled.div<{ isInPinnedTab: boolean }>` @@ -33,20 +32,15 @@ export const PageLayoutContent = () => { isPageLayoutInEditModeComponentState, ); - const { currentPageLayout } = useCurrentPageLayout(); const { tabId } = usePageLayoutContentContext(); const { reorderWidgets } = useReorderPageLayoutWidgets(tabId); - const activeTab = currentPageLayout?.tabs.find((tab) => tab.id === tabId); + const activeTab = usePageLayoutTabWithVisibleWidgetsOrThrow(tabId); const { layoutMode } = usePageLayoutContentContext(); const { isInPinnedTab } = useIsInPinnedTab(); - if (!isDefined(currentPageLayout) || !isDefined(activeTab)) { - return null; - } - const isCanvasLayout = isRecordPageEnabled && layoutMode === 'canvas'; const isVerticalList = isRecordPageEnabled && layoutMode === 'vertical-list'; diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx index bcbcf5f379..fc542cbf6f 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx @@ -7,8 +7,8 @@ import { PAGE_LAYOUT_CONFIG, type PageLayoutBreakpoint, } from '@/page-layout/constants/PageLayoutBreakpoints'; -import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout'; import { usePageLayoutHandleLayoutChange } from '@/page-layout/hooks/usePageLayoutHandleLayoutChange'; +import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow'; import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; import { pageLayoutCurrentBreakpointComponentState } from '@/page-layout/states/pageLayoutCurrentBreakpointComponentState'; import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; @@ -110,11 +110,9 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { pageLayoutDraggedAreaComponentState, ); - const { currentPageLayout } = useCurrentPageLayout(); + const activeTab = usePageLayoutTabWithVisibleWidgetsOrThrow(tabId); - const activeTab = currentPageLayout?.tabs.find((tab) => tab.id === tabId); - - const activeTabWidgets = activeTab?.widgets; + const activeTabWidgets = activeTab.widgets; const isLayoutEmpty = !isDefined(activeTabWidgets) || activeTabWidgets.length === 0; @@ -138,10 +136,6 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => { [activeTabWidgets, hasPendingPlaceholder], ); - if (!isDefined(currentPageLayout) || !isDefined(activeTab)) { - return null; - } - return ( <> diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutLeftPanel.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutLeftPanel.tsx index db2df23925..dacc75537a 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutLeftPanel.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutLeftPanel.tsx @@ -2,6 +2,7 @@ import { SummaryCard } from '@/object-record/record-show/components/SummaryCard' import { PageLayoutContent } from '@/page-layout/components/PageLayoutContent'; import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext'; import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout'; +import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow'; import { getTabLayoutMode } from '@/page-layout/utils/getTabLayoutMode'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; @@ -18,15 +19,12 @@ export const PageLayoutLeftPanel = ({ const { currentPageLayout } = useCurrentPageLayout(); const targetRecordIdentifier = useTargetRecord(); const { isInRightDrawer } = useLayoutRenderingContext(); + const pinnedTab = usePageLayoutTabWithVisibleWidgetsOrThrow(pinnedLeftTabId); if (currentPageLayout?.type !== PageLayoutType.RECORD_PAGE) { return null; } - const pinnedTab = currentPageLayout.tabs.find( - (tab) => tab.id === pinnedLeftTabId, - ); - const layoutMode = getTabLayoutMode({ tab: pinnedTab, pageLayoutType: currentPageLayout.type, diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx index 75a6e18977..9088d7aa1b 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRendererContent.tsx @@ -12,6 +12,7 @@ import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPag import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode'; +import { getTabsWithVisibleWidgets } from '@/page-layout/utils/getTabsWithVisibleWidgets'; import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer'; @@ -83,8 +84,16 @@ export const PageLayoutRendererContent = () => { return null; } + const tabsWithVisibleWidgets = getTabsWithVisibleWidgets({ + tabs: currentPageLayout.tabs, + isMobile, + isInRightDrawer, + isEditMode: isPageLayoutInEditMode, + }); + const { tabsToRenderInTabList, pinnedLeftTab } = getTabsByDisplayMode({ - pageLayout: currentPageLayout, + tabs: tabsWithVisibleWidgets, + pageLayoutType: currentPageLayout.type, isMobile, isInRightDrawer, }); diff --git a/packages/twenty-front/src/modules/page-layout/constants/DefaultNoteRecordPageLayout.ts b/packages/twenty-front/src/modules/page-layout/constants/DefaultNoteRecordPageLayout.ts index 424b0da839..5334619f7a 100644 --- a/packages/twenty-front/src/modules/page-layout/constants/DefaultNoteRecordPageLayout.ts +++ b/packages/twenty-front/src/modules/page-layout/constants/DefaultNoteRecordPageLayout.ts @@ -60,6 +60,52 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, + conditionalDisplay: { + and: [ + { + '===': [{ var: 'device' }, 'MOBILE'], + }, + ], + }, + }, + ], + }, + { + __typename: 'PageLayoutTab', + id: 'note-tab-note', + title: 'Note', + position: 150, + layoutMode: 'vertical-list', + pageLayoutId: DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + widgets: [ + { + __typename: 'PageLayoutWidget', + id: 'note-widget-note', + pageLayoutTabId: 'note-tab-note', + title: 'Note', + type: WidgetType.RICH_TEXT, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 12, + column: 0, + rowSpan: 6, + columnSpan: 12, + }, + configuration: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + conditionalDisplay: { + and: [ + { + '===': [{ var: 'device' }, 'DESKTOP'], + }, + ], + }, }, ], }, diff --git a/packages/twenty-front/src/modules/page-layout/constants/DefaultTaskRecordPageLayout.ts b/packages/twenty-front/src/modules/page-layout/constants/DefaultTaskRecordPageLayout.ts index 1bbf7b5045..61a5b8acdf 100644 --- a/packages/twenty-front/src/modules/page-layout/constants/DefaultTaskRecordPageLayout.ts +++ b/packages/twenty-front/src/modules/page-layout/constants/DefaultTaskRecordPageLayout.ts @@ -60,6 +60,52 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, + conditionalDisplay: { + and: [ + { + '===': [{ var: 'device' }, 'MOBILE'], + }, + ], + }, + }, + ], + }, + { + __typename: 'PageLayoutTab', + id: 'task-tab-note', + title: 'Note', + position: 150, + layoutMode: 'vertical-list', + pageLayoutId: DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + widgets: [ + { + __typename: 'PageLayoutWidget', + id: 'task-widget-note', + pageLayoutTabId: 'task-tab-note', + title: 'Note', + type: WidgetType.RICH_TEXT, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 12, + column: 0, + rowSpan: 6, + columnSpan: 12, + }, + configuration: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + conditionalDisplay: { + and: [ + { + '===': [{ var: 'device' }, 'DESKTOP'], + }, + ], + }, }, ], }, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow.ts b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow.ts new file mode 100644 index 0000000000..d5ee94a9db --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow.ts @@ -0,0 +1,41 @@ +import { useCurrentPageLayout } from '@/page-layout/hooks/useCurrentPageLayout'; +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { buildWidgetVisibilityContext } from '@/page-layout/utils/buildWidgetVisibilityContext'; +import { filterVisibleWidgets } from '@/page-layout/utils/filterVisibleWidgets'; +import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isDefined } from 'twenty-shared/utils'; + +export const usePageLayoutTabWithVisibleWidgetsOrThrow = ( + tabId: string, +): PageLayoutTab => { + const { currentPageLayout } = useCurrentPageLayout(); + const isMobile = useIsMobile(); + const { isInRightDrawer } = useLayoutRenderingContext(); + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + ); + + if (!isDefined(currentPageLayout)) { + throw new Error('currentPageLayout is not defined'); + } + + const tab = currentPageLayout.tabs.find((t) => t.id === tabId); + + if (!isDefined(tab)) { + throw new Error('Tab not found'); + } + + if (isPageLayoutInEditMode) { + return tab; + } + + const context = buildWidgetVisibilityContext({ isMobile, isInRightDrawer }); + + return { + ...tab, + widgets: filterVisibleWidgets({ widgets: tab.widgets, context }), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTab.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTab.ts index 04d38b601f..2ee241f3f2 100644 --- a/packages/twenty-front/src/modules/page-layout/types/PageLayoutTab.ts +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutTab.ts @@ -1,4 +1,5 @@ import { type PageLayoutTabLayoutMode } from '@/page-layout/types/PageLayoutTabLayoutMode'; +import { type RulesLogic } from 'json-logic-js'; import { type ModifiedProperties, type Nullable } from 'twenty-shared/types'; import { type PageLayoutTab as PageLayoutTabGenerated, @@ -8,7 +9,10 @@ import { export type PageLayoutTab = Omit & { widgets: ModifiedProperties< PageLayoutWidget, - { objectMetadataId?: Nullable } + { + objectMetadataId?: Nullable; + conditionalDisplay?: RulesLogic; + } >[]; /** * Only available behind IS_RECORD_PAGE_LAYOUT_ENABLED for now. diff --git a/packages/twenty-front/src/modules/page-layout/types/WidgetVisibilityContext.ts b/packages/twenty-front/src/modules/page-layout/types/WidgetVisibilityContext.ts new file mode 100644 index 0000000000..7137222eab --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/WidgetVisibilityContext.ts @@ -0,0 +1,3 @@ +export type WidgetVisibilityContext = { + device: 'MOBILE' | 'DESKTOP'; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/evaluateWidgetVisibility.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/evaluateWidgetVisibility.test.ts new file mode 100644 index 0000000000..76a2348e79 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/evaluateWidgetVisibility.test.ts @@ -0,0 +1,137 @@ +import { type RulesLogic } from 'json-logic-js'; + +import { evaluateWidgetVisibility } from '../evaluateWidgetVisibility'; + +describe('evaluateWidgetVisibility', () => { + it('should return true (visible) when no conditionalDisplay is provided', () => { + const result = evaluateWidgetVisibility({ + conditionalDisplay: undefined, + context: { + device: 'DESKTOP', + }, + }); + + expect(result).toBe(true); + }); + + it('should return true (visible) when condition evaluates to true for MOBILE device', () => { + const conditionalDisplay: RulesLogic = { + and: [ + { + '===': [{ var: 'device' }, 'MOBILE'], + }, + ], + }; + + const result = evaluateWidgetVisibility({ + conditionalDisplay, + context: { + device: 'MOBILE', + }, + }); + + expect(result).toBe(true); + }); + + it('should return false (hidden) when condition evaluates to false for DESKTOP device', () => { + const conditionalDisplay: RulesLogic = { + and: [ + { + '===': [{ var: 'device' }, 'MOBILE'], + }, + ], + }; + + const result = evaluateWidgetVisibility({ + conditionalDisplay, + context: { + device: 'DESKTOP', + }, + }); + + expect(result).toBe(false); + }); + + it('should return true (visible) when condition evaluates to true for DESKTOP device', () => { + const conditionalDisplay: RulesLogic = { + and: [ + { + '===': [{ var: 'device' }, 'DESKTOP'], + }, + ], + }; + + const result = evaluateWidgetVisibility({ + conditionalDisplay, + context: { + device: 'DESKTOP', + }, + }); + + expect(result).toBe(true); + }); + + it('should return false (hidden) when condition evaluates to false for MOBILE device', () => { + const conditionalDisplay: RulesLogic = { + and: [ + { + '===': [{ var: 'device' }, 'DESKTOP'], + }, + ], + }; + + const result = evaluateWidgetVisibility({ + conditionalDisplay, + context: { + device: 'MOBILE', + }, + }); + + expect(result).toBe(false); + }); + + it('should handle OR conditions', () => { + const conditionalDisplay: RulesLogic = { + or: [ + { + '===': [{ var: 'device' }, 'MOBILE'], + }, + { + '===': [{ var: 'device' }, 'DESKTOP'], + }, + ], + }; + + const resultMobile = evaluateWidgetVisibility({ + conditionalDisplay, + context: { + device: 'MOBILE', + }, + }); + + const resultDesktop = evaluateWidgetVisibility({ + conditionalDisplay, + context: { + device: 'DESKTOP', + }, + }); + + expect(resultMobile).toBe(true); + expect(resultDesktop).toBe(true); + }); + + it('should throw error for invalid operator', () => { + const invalidConditionalDisplay = { + invalidOperator: 'test', + } as unknown as RulesLogic; + + expect(() => { + evaluateWidgetVisibility({ + conditionalDisplay: invalidConditionalDisplay, + context: { + device: 'DESKTOP', + }, + }); + }).toThrow(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/filterVisibleWidgets.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/filterVisibleWidgets.test.ts new file mode 100644 index 0000000000..e666c85cbb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/filterVisibleWidgets.test.ts @@ -0,0 +1,112 @@ +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { WidgetType } from '~/generated/graphql'; +import { filterVisibleWidgets } from '../filterVisibleWidgets'; + +describe('filterVisibleWidgets', () => { + const createMockWidget = ( + id: string, + conditionalDisplay?: any, + ): PageLayoutTab['widgets'][0] => ({ + __typename: 'PageLayoutWidget', + id, + pageLayoutTabId: 'tab-1', + title: `Widget ${id}`, + type: WidgetType.FIELDS, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 1, + }, + configuration: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: null, + conditionalDisplay, + }); + + it('should return all widgets when no conditionalDisplay is set', () => { + const widgets = [ + createMockWidget('widget-1'), + createMockWidget('widget-2'), + createMockWidget('widget-3'), + ]; + + const result = filterVisibleWidgets({ + widgets, + context: { device: 'DESKTOP' }, + }); + + expect(result).toHaveLength(3); + expect(result).toEqual(widgets); + }); + + it('should filter out widgets that should be hidden on MOBILE', () => { + const widgets = [ + createMockWidget('widget-1'), + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + createMockWidget('widget-3', { + and: [{ '===': [{ var: 'device' }, 'DESKTOP'] }], + }), + ]; + + const result = filterVisibleWidgets({ + widgets, + context: { device: 'MOBILE' }, + }); + + expect(result).toHaveLength(2); + expect(result.map((w) => w.id)).toEqual(['widget-1', 'widget-2']); + }); + + it('should filter out widgets that should be hidden on DESKTOP', () => { + const widgets = [ + createMockWidget('widget-1'), + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + createMockWidget('widget-3', { + and: [{ '===': [{ var: 'device' }, 'DESKTOP'] }], + }), + ]; + + const result = filterVisibleWidgets({ + widgets, + context: { device: 'DESKTOP' }, + }); + + expect(result).toHaveLength(2); + expect(result.map((w) => w.id)).toEqual(['widget-1', 'widget-3']); + }); + + it('should handle empty widgets array', () => { + const result = filterVisibleWidgets({ + widgets: [], + context: { device: 'DESKTOP' }, + }); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('should not mutate the original widgets array', () => { + const widgets = [ + createMockWidget('widget-1'), + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]; + const originalLength = widgets.length; + + filterVisibleWidgets({ + widgets, + context: { device: 'DESKTOP' }, + }); + + expect(widgets).toHaveLength(originalLength); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsByDisplayMode.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsByDisplayMode.test.ts index e5786f98e4..4382ff1fd9 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsByDisplayMode.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsByDisplayMode.test.ts @@ -32,7 +32,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); @@ -46,7 +47,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); @@ -58,7 +60,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout([]); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); @@ -72,7 +75,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); @@ -92,7 +96,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: true, }); @@ -106,7 +111,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: true, }); @@ -118,7 +124,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout([]); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: true, }); @@ -132,7 +139,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: true, }); @@ -152,7 +160,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -172,7 +181,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -185,7 +195,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout([]); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -199,7 +210,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -213,7 +225,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout([]); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -227,7 +240,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -244,12 +258,14 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const resultMobile = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); const resultDesktop = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -266,12 +282,14 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const resultMobile = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); const resultDesktop = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -289,7 +307,8 @@ describe('getTabsByDisplayMode', () => { const originalTabsLength = pageLayout.tabs.length; getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -306,7 +325,8 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout([tabWithExtraProps]); const result = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -322,12 +342,14 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const result1 = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); const result2 = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -340,12 +362,14 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const mobileResult = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); const desktopResult = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -362,12 +386,14 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const mobileResult = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); const desktopResult = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: false, }); @@ -386,17 +412,20 @@ describe('getTabsByDisplayMode', () => { const pageLayout = createMockPageLayout(tabs); const resultBothTrue = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: true, }); const resultOnlyMobile = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: true, isInRightDrawer: false, }); const resultOnlyRightDrawer = getTabsByDisplayMode({ - pageLayout, + tabs: pageLayout.tabs, + pageLayoutType: pageLayout.type, isMobile: false, isInRightDrawer: true, }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsWithVisibleWidgets.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsWithVisibleWidgets.test.ts new file mode 100644 index 0000000000..d16efd0157 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/getTabsWithVisibleWidgets.test.ts @@ -0,0 +1,312 @@ +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { WidgetType } from '~/generated/graphql'; +import { getTabsWithVisibleWidgets } from '../getTabsWithVisibleWidgets'; + +describe('getTabsWithVisibleWidgets', () => { + const createMockWidget = ( + id: string, + conditionalDisplay?: any, + ): PageLayoutTab['widgets'][0] => ({ + __typename: 'PageLayoutWidget', + id, + pageLayoutTabId: 'tab-1', + title: `Widget ${id}`, + type: WidgetType.FIELDS, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 1, + }, + configuration: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: null, + conditionalDisplay, + }); + + const createMockTab = ( + id: string, + widgets: PageLayoutTab['widgets'], + ): PageLayoutTab => ({ + __typename: 'PageLayoutTab', + id, + pageLayoutId: 'page-layout-1', + title: `Tab ${id}`, + position: 0, + widgets, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: null, + }); + + describe('in read mode', () => { + it('should filter out tabs with no visible widgets', () => { + const tabs = [ + createMockTab('tab-1', [createMockWidget('widget-1')]), + createMockTab('tab-2', [ + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + createMockTab('tab-3', [createMockWidget('widget-3')]), + ]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: false, + }); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('tab-1'); + expect(result[0].widgets).toHaveLength(1); + expect(result[1].id).toBe('tab-3'); + expect(result[1].widgets).toHaveLength(1); + }); + + it('should keep tabs with at least one visible widget', () => { + const tabs = [ + createMockTab('tab-1', [ + createMockWidget('widget-1'), + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + ]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: false, + }); + + expect(result).toHaveLength(1); + expect(result[0].widgets).toHaveLength(1); + expect(result[0].widgets[0].id).toBe('widget-1'); + }); + + it('should return first tab when all tabs have no visible widgets', () => { + const tabs = [ + createMockTab('tab-1', [ + createMockWidget('widget-1', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + createMockTab('tab-2', [ + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + ]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: false, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('tab-1'); + expect(result[0].widgets).toHaveLength(0); + }); + + it('should filter out tabs with no widgets when other tabs have widgets', () => { + const tabs = [ + createMockTab('tab-1', []), + createMockTab('tab-2', [createMockWidget('widget-2')]), + ]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: false, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('tab-2'); + expect(result[0].widgets).toHaveLength(1); + }); + + it('should return first tab when all tabs have no widgets', () => { + const tabs = [createMockTab('tab-1', []), createMockTab('tab-2', [])]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: false, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('tab-1'); + expect(result[0].widgets).toHaveLength(0); + }); + }); + + describe('in edit mode', () => { + it('should keep all tabs even if they have no visible widgets', () => { + const tabs = [ + createMockTab('tab-1', [createMockWidget('widget-1')]), + createMockTab('tab-2', [ + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + createMockTab('tab-3', [createMockWidget('widget-3')]), + ]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: true, + }); + + expect(result).toHaveLength(3); + expect(result.map((t) => t.id)).toEqual(['tab-1', 'tab-2', 'tab-3']); + }); + + it('should not filter widgets in edit mode', () => { + const tabs = [ + createMockTab('tab-1', [ + createMockWidget('widget-1'), + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + ]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: true, + }); + + expect(result).toHaveLength(1); + expect(result[0].widgets).toHaveLength(2); // All widgets kept in edit mode + expect(result[0].widgets[0].id).toBe('widget-1'); + expect(result[0].widgets[1].id).toBe('widget-2'); + }); + + it('should keep tabs with no widgets', () => { + const tabs = [createMockTab('tab-1', []), createMockTab('tab-2', [])]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: true, + }); + + expect(result).toHaveLength(2); + }); + + it('should keep all widgets even when they would be hidden in read mode', () => { + const tabs = [ + createMockTab('tab-1', [ + createMockWidget('widget-1', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + createMockTab('tab-2', [ + createMockWidget('widget-2', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + ]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: true, + }); + + expect(result).toHaveLength(2); + expect(result[0].widgets).toHaveLength(1); // Kept in edit mode + expect(result[1].widgets).toHaveLength(1); // Kept in edit mode + }); + }); + + describe('edge cases', () => { + it('should handle empty tabs array', () => { + const result = getTabsWithVisibleWidgets({ + tabs: [], + isMobile: false, + isInRightDrawer: false, + isEditMode: false, + }); + + expect(result).toHaveLength(0); + }); + + it('should not mutate the original tabs array', () => { + const tabs = [ + createMockTab('tab-1', [ + createMockWidget('widget-1', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + ]; + const originalLength = tabs.length; + const originalWidgetsLength = tabs[0].widgets.length; + + getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: false, + }); + + expect(tabs).toHaveLength(originalLength); + expect(tabs[0].widgets).toHaveLength(originalWidgetsLength); + }); + + it('should handle mixed scenarios with multiple widgets per tab', () => { + const tabs = [ + createMockTab('tab-1', [ + createMockWidget('widget-1'), + createMockWidget('widget-2'), + createMockWidget('widget-3', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + createMockTab('tab-2', [ + createMockWidget('widget-4', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + createMockWidget('widget-5', { + and: [{ '===': [{ var: 'device' }, 'MOBILE'] }], + }), + ]), + createMockTab('tab-3', [ + createMockWidget('widget-6'), + createMockWidget('widget-7', { + and: [{ '===': [{ var: 'device' }, 'DESKTOP'] }], + }), + ]), + ]; + + const result = getTabsWithVisibleWidgets({ + tabs, + isMobile: false, + isInRightDrawer: false, + isEditMode: false, + }); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('tab-1'); + expect(result[0].widgets).toHaveLength(2); + expect(result[1].id).toBe('tab-3'); + expect(result[1].widgets).toHaveLength(2); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/buildWidgetVisibilityContext.ts b/packages/twenty-front/src/modules/page-layout/utils/buildWidgetVisibilityContext.ts new file mode 100644 index 0000000000..d9e412fde9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/buildWidgetVisibilityContext.ts @@ -0,0 +1,15 @@ +import { type WidgetVisibilityContext } from '@/page-layout/types/WidgetVisibilityContext'; + +type BuildWidgetVisibilityContextParams = { + isMobile: boolean; + isInRightDrawer: boolean; +}; + +export const buildWidgetVisibilityContext = ({ + isMobile, + isInRightDrawer, +}: BuildWidgetVisibilityContextParams): WidgetVisibilityContext => { + return { + device: isMobile || isInRightDrawer ? 'MOBILE' : 'DESKTOP', + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/evaluateWidgetVisibility.ts b/packages/twenty-front/src/modules/page-layout/utils/evaluateWidgetVisibility.ts new file mode 100644 index 0000000000..03f024af02 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/evaluateWidgetVisibility.ts @@ -0,0 +1,21 @@ +import jsonLogic, { type RulesLogic } from 'json-logic-js'; + +import { type WidgetVisibilityContext } from '@/page-layout/types/WidgetVisibilityContext'; + +type EvaluateWidgetVisibilityParams = { + conditionalDisplay: RulesLogic | undefined; + context: WidgetVisibilityContext; +}; + +export const evaluateWidgetVisibility = ({ + conditionalDisplay, + context, +}: EvaluateWidgetVisibilityParams): boolean => { + if (!conditionalDisplay) { + return true; + } + + const isVisible = jsonLogic.apply(conditionalDisplay, context) === true; + + return isVisible; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/filterVisibleWidgets.ts b/packages/twenty-front/src/modules/page-layout/utils/filterVisibleWidgets.ts new file mode 100644 index 0000000000..97ed0daec5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/filterVisibleWidgets.ts @@ -0,0 +1,20 @@ +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { type WidgetVisibilityContext } from '@/page-layout/types/WidgetVisibilityContext'; +import { evaluateWidgetVisibility } from '@/page-layout/utils/evaluateWidgetVisibility'; + +type FilterVisibleWidgetsParams = { + widgets: PageLayoutTab['widgets']; + context: WidgetVisibilityContext; +}; + +export const filterVisibleWidgets = ({ + widgets, + context, +}: FilterVisibleWidgetsParams): PageLayoutTab['widgets'] => { + return widgets.filter((widget) => { + return evaluateWidgetVisibility({ + conditionalDisplay: widget.conditionalDisplay, + context, + }); + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/getTabsByDisplayMode.ts b/packages/twenty-front/src/modules/page-layout/utils/getTabsByDisplayMode.ts index 123ed6cf35..97a8b94a3a 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/getTabsByDisplayMode.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/getTabsByDisplayMode.ts @@ -1,38 +1,39 @@ -import { type DraftPageLayout } from '@/page-layout/types/draft-page-layout'; -import { type PageLayout } from '@/page-layout/types/PageLayout'; +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; import { PageLayoutType } from '~/generated/graphql'; type GetTabsByDisplayModeParams = { - pageLayout: PageLayout | DraftPageLayout; + tabs: PageLayoutTab[]; + pageLayoutType: PageLayoutType; isMobile: boolean; isInRightDrawer: boolean; }; export const getTabsByDisplayMode = ({ - pageLayout, + tabs, + pageLayoutType, isMobile, isInRightDrawer, }: GetTabsByDisplayModeParams) => { if ( isMobile || isInRightDrawer || - pageLayout.type !== PageLayoutType.RECORD_PAGE + pageLayoutType !== PageLayoutType.RECORD_PAGE ) { return { - tabsToRenderInTabList: pageLayout.tabs, + tabsToRenderInTabList: tabs, pinnedLeftTab: undefined, }; } - if (pageLayout.tabs.length === 1) { + if (tabs.length === 1) { return { - tabsToRenderInTabList: pageLayout.tabs, + tabsToRenderInTabList: tabs, pinnedLeftTab: undefined, }; } - const tabsToRenderInTabList = pageLayout.tabs.slice(1); - const pinnedLeftTab = pageLayout.tabs[0]; + const tabsToRenderInTabList = tabs.slice(1); + const pinnedLeftTab = tabs[0]; return { tabsToRenderInTabList, diff --git a/packages/twenty-front/src/modules/page-layout/utils/getTabsWithVisibleWidgets.ts b/packages/twenty-front/src/modules/page-layout/utils/getTabsWithVisibleWidgets.ts new file mode 100644 index 0000000000..907b112773 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/getTabsWithVisibleWidgets.ts @@ -0,0 +1,38 @@ +import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab'; +import { buildWidgetVisibilityContext } from '@/page-layout/utils/buildWidgetVisibilityContext'; +import { filterVisibleWidgets } from '@/page-layout/utils/filterVisibleWidgets'; + +type GetTabsWithVisibleWidgetsParams = { + tabs: PageLayoutTab[]; + isMobile: boolean; + isInRightDrawer: boolean; + isEditMode: boolean; +}; + +export const getTabsWithVisibleWidgets = ({ + tabs, + isMobile, + isInRightDrawer, + isEditMode, +}: GetTabsWithVisibleWidgetsParams): PageLayoutTab[] => { + if (isEditMode) { + return tabs; + } + + const context = buildWidgetVisibilityContext({ isMobile, isInRightDrawer }); + + const tabsWithFilteredWidgets = tabs.map((tab) => ({ + ...tab, + widgets: filterVisibleWidgets({ widgets: tab.widgets, context }), + })); + + const tabsWithVisibleWidgets = tabsWithFilteredWidgets.filter( + (tab) => tab.widgets.length > 0, + ); + + if (tabsWithVisibleWidgets.length === 0 && tabs.length > 0) { + return tabsWithFilteredWidgets.slice(0, 1); + } + + return tabsWithVisibleWidgets; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useIsInPinnedTab.ts b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useIsInPinnedTab.ts index d8bccf0404..e6182d1312 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/hooks/useIsInPinnedTab.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/hooks/useIsInPinnedTab.ts @@ -1,7 +1,10 @@ import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext'; import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow'; +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; import { getTabsByDisplayMode } from '@/page-layout/utils/getTabsByDisplayMode'; +import { getTabsWithVisibleWidgets } from '@/page-layout/utils/getTabsWithVisibleWidgets'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { isDefined } from 'twenty-shared/utils'; import { useIsMobile } from 'twenty-ui/utilities'; @@ -12,8 +15,20 @@ export const useIsInPinnedTab = () => { const { isInRightDrawer } = useLayoutRenderingContext(); const { currentPageLayout } = useCurrentPageLayoutOrThrow(); + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + ); + + const tabsWithVisibleWidgets = getTabsWithVisibleWidgets({ + tabs: currentPageLayout.tabs, + isMobile, + isInRightDrawer, + isEditMode: isPageLayoutInEditMode, + }); + const { pinnedLeftTab } = getTabsByDisplayMode({ - pageLayout: currentPageLayout, + tabs: tabsWithVisibleWidgets, + pageLayoutType: currentPageLayout.type, isMobile, isInRightDrawer, }); diff --git a/yarn.lock b/yarn.lock index e426e101af..652d221171 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24283,6 +24283,13 @@ __metadata: languageName: node linkType: hard +"@types/json-logic-js@npm:^2": + version: 2.0.8 + resolution: "@types/json-logic-js@npm:2.0.8" + checksum: 10c0/b1fc0b39242b8f1f939c86f47bfda213e1eb57686ee4dfbe11057df88fad0dbe0abc3582b72429975e8fb07b0267959d98341e5079ba945345931973538d2f43 + languageName: node + linkType: hard + "@types/json-schema@npm:*, @types/json-schema@npm:^7.0.11, @types/json-schema@npm:^7.0.12, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.4, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.7, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -41345,6 +41352,13 @@ __metadata: languageName: node linkType: hard +"json-logic-js@npm:^2.0.5": + version: 2.0.5 + resolution: "json-logic-js@npm:2.0.5" + checksum: 10c0/c80d96a9f704dac2f4488c7119d9c94cbf67adbd93cb47eaa5b7eadc4947f18ed6d62fd5555f245cc2f87525f9e8d7bd5f70bd9e7fba2c70366e63bd68010f23 + languageName: node + linkType: hard + "json-parse-even-better-errors@npm:^2.3.0, json-parse-even-better-errors@npm:^2.3.1": version: 2.3.1 resolution: "json-parse-even-better-errors@npm:2.3.1" @@ -55176,6 +55190,7 @@ __metadata: "@types/apollo-upload-client": "npm:^17.0.2" "@types/file-saver": "npm:^2.0.7" "@types/js-cookie": "npm:^3.0.3" + "@types/json-logic-js": "npm:^2" "@types/react-grid-layout": "npm:^1" "@typescript-eslint/eslint-plugin": "npm:^8.39.0" "@typescript-eslint/parser": "npm:^8.39.0" @@ -55210,6 +55225,7 @@ __metadata: input-otp: "npm:^1.4.2" js-cookie: "npm:^3.0.5" json-2-csv: "npm:^5.4.0" + json-logic-js: "npm:^2.0.5" jwt-decode: "npm:^4.0.0" monaco-editor: "npm:^0.51.0" monaco-editor-auto-typings: "npm:^0.4.5"