[Page Layouts] Focus specific tabs on mobile and side panel (#15984)

## On mobile


https://github.com/user-attachments/assets/4a8017b2-56a9-4759-bc18-8d99fed9f80a

## In side panel


https://github.com/user-attachments/assets/f36f0827-699a-4f76-8e84-a8ceaeb71396

## Dashboards

Untouched, but keep working.


https://github.com/user-attachments/assets/fe583f80-ea8d-453e-95ea-6b99175d1899

## Other Record Page Layouts

Untouched, but keep working.


https://github.com/user-attachments/assets/c64f731f-5d28-4585-8cb5-c3940fb1ab6f
This commit is contained in:
Baptiste Devessier
2025-11-21 13:39:17 +01:00
committed by GitHub
parent 0a2d42e79f
commit 3e187b4ce3
7 changed files with 288 additions and 2 deletions
@@ -116,6 +116,9 @@ export const PageLayoutRendererContent = () => {
<PageLayoutTabListEffect
tabs={sortedTabs}
componentInstanceId={tabListInstanceId}
defaultTabIdToFocusOnMobileAndSidePanel={
currentPageLayout.defaultTabIdToFocusOnMobileAndSidePanel
}
/>
{(sortedTabs.length > 1 || isPageLayoutInEditMode) && (
<StyledPageLayoutTabList
@@ -1,6 +1,9 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { getPageLayoutTabListInitialActiveTabId } from '@/page-layout/utils/getPageLayoutTabListInitialActiveTabId';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { type TabListProps } from '@/ui/layout/tab-list/types/TabListProps';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useEffect } from 'react';
@@ -9,20 +12,30 @@ type PageLayoutTabListEffectProps = Pick<
'componentInstanceId' | 'onChangeTab'
> & {
tabs: PageLayoutTab[];
defaultTabIdToFocusOnMobileAndSidePanel?: string;
};
export const PageLayoutTabListEffect = ({
tabs,
onChangeTab,
componentInstanceId,
defaultTabIdToFocusOnMobileAndSidePanel,
}: PageLayoutTabListEffectProps) => {
const [activeTabId, setActiveTabId] = useRecoilComponentState(
activeTabIdComponentState,
componentInstanceId,
);
const activeTabExists = tabs.some((tab) => tab.id === activeTabId);
const initialActiveTabId = activeTabExists ? activeTabId : tabs[0]?.id;
const isMobile = useIsMobile();
const { isInRightDrawer } = useLayoutRenderingContext();
const initialActiveTabId = getPageLayoutTabListInitialActiveTabId({
activeTabId,
tabs,
defaultTabIdToFocusOnMobileAndSidePanel,
isMobile,
isInRightDrawer,
});
useEffect(() => {
setActiveTabId(initialActiveTabId);
@@ -15,6 +15,7 @@ export const DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT: PageLayout = {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
defaultTabIdToFocusOnMobileAndSidePanel: 'workflow-run-tab-flow',
tabs: [
// Fields tab (position 100)
{
@@ -15,6 +15,7 @@ export const DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT: PageLayout = {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
defaultTabIdToFocusOnMobileAndSidePanel: 'workflow-version-tab-flow',
tabs: [
// Fields tab (position 100)
{
@@ -3,4 +3,5 @@ import { type PageLayout as PageLayoutGenerated } from '~/generated/graphql';
export type PageLayout = Omit<PageLayoutGenerated, 'tabs'> & {
tabs: PageLayoutTab[];
defaultTabIdToFocusOnMobileAndSidePanel?: string;
};
@@ -0,0 +1,226 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { getPageLayoutTabListInitialActiveTabId } from '../getPageLayoutTabListInitialActiveTabId';
describe('getPageLayoutTabListInitialActiveTabId', () => {
const createMockTab = (id: string): 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',
});
const mockTabs: PageLayoutTab[] = [
createMockTab('tab-1'),
createMockTab('tab-2'),
createMockTab('tab-3'),
];
describe('when activeTabId exists in tabs', () => {
it('should return activeTabId regardless of context', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: 'tab-2',
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-3',
isMobile: true,
isInRightDrawer: true,
});
expect(result).toBe('tab-2');
});
it('should return activeTabId even when not on mobile or in drawer', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: 'tab-1',
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-3',
isMobile: false,
isInRightDrawer: false,
});
expect(result).toBe('tab-1');
});
});
describe('when activeTabId does not exist in tabs', () => {
describe('on mobile or in right drawer', () => {
it('should return defaultTabIdToFocusOnMobileAndSidePanel when on mobile and default exists', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: 'non-existent-tab',
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-3',
isMobile: true,
isInRightDrawer: false,
});
expect(result).toBe('tab-3');
});
it('should return defaultTabIdToFocusOnMobileAndSidePanel when in right drawer and default exists', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-2',
isMobile: false,
isInRightDrawer: true,
});
expect(result).toBe('tab-2');
});
it('should return defaultTabIdToFocusOnMobileAndSidePanel when on mobile and in right drawer', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-1',
isMobile: true,
isInRightDrawer: true,
});
expect(result).toBe('tab-1');
});
it('should fallback to first tab when defaultTabIdToFocusOnMobileAndSidePanel does not exist', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'non-existent-tab',
isMobile: true,
isInRightDrawer: false,
});
expect(result).toBe('tab-1');
});
it('should fallback to first tab when defaultTabIdToFocusOnMobileAndSidePanel is undefined', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: undefined,
isMobile: true,
isInRightDrawer: false,
});
expect(result).toBe('tab-1');
});
});
describe('not on mobile and not in right drawer', () => {
it('should return first tab when default is provided', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-3',
isMobile: false,
isInRightDrawer: false,
});
expect(result).toBe('tab-1');
});
it('should return first tab when default is not provided', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: undefined,
isMobile: false,
isInRightDrawer: false,
});
expect(result).toBe('tab-1');
});
});
});
describe('edge cases', () => {
it('should return null when tabs array is empty', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: [],
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-1',
isMobile: false,
isInRightDrawer: false,
});
expect(result).toBe(null);
});
it('should return null when tabs array is empty even with default on mobile', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: [],
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-1',
isMobile: true,
isInRightDrawer: false,
});
expect(result).toBe(null);
});
it('should handle activeTabId being null', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: undefined,
isMobile: false,
isInRightDrawer: false,
});
expect(result).toBe('tab-1');
});
it('should handle single tab array', () => {
const singleTab = [createMockTab('only-tab')];
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: null,
tabs: singleTab,
defaultTabIdToFocusOnMobileAndSidePanel: undefined,
isMobile: false,
isInRightDrawer: false,
});
expect(result).toBe('only-tab');
});
});
describe('priority order', () => {
it('should prioritize activeTabId over default and context', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: 'tab-1',
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-2',
isMobile: true,
isInRightDrawer: true,
});
expect(result).toBe('tab-1');
});
it('should prioritize valid default over first tab when on mobile', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: 'non-existent',
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'tab-3',
isMobile: true,
isInRightDrawer: false,
});
expect(result).toBe('tab-3');
});
it('should fallback to first tab when default is invalid on mobile', () => {
const result = getPageLayoutTabListInitialActiveTabId({
activeTabId: 'non-existent',
tabs: mockTabs,
defaultTabIdToFocusOnMobileAndSidePanel: 'invalid-tab',
isMobile: true,
isInRightDrawer: false,
});
expect(result).toBe('tab-1');
});
});
});
@@ -0,0 +1,41 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { isDefined } from 'twenty-shared/utils';
type GetPageLayoutTabListInitialActiveTabIdParams = {
activeTabId: string | null;
tabs: PageLayoutTab[];
defaultTabIdToFocusOnMobileAndSidePanel?: string;
isMobile: boolean;
isInRightDrawer: boolean;
};
export const getPageLayoutTabListInitialActiveTabId = ({
activeTabId,
tabs,
defaultTabIdToFocusOnMobileAndSidePanel,
isMobile,
isInRightDrawer,
}: GetPageLayoutTabListInitialActiveTabIdParams): string | null => {
const activeTabExists = tabs.some((tab) => tab.id === activeTabId);
if (activeTabExists) {
return activeTabId;
}
const isOnMobileOrSidePanel = isMobile || isInRightDrawer;
if (
isOnMobileOrSidePanel &&
isDefined(defaultTabIdToFocusOnMobileAndSidePanel)
) {
const defaultTabExists = tabs.some(
(tab) => tab.id === defaultTabIdToFocusOnMobileAndSidePanel,
);
if (defaultTabExists) {
return defaultTabIdToFocusOnMobileAndSidePanel;
}
}
return tabs[0]?.id ?? null;
};