584 Refactor Tabs (#11008)

Closes https://github.com/twentyhq/core-team-issues/issues/584

This PR:
- Migrates the component state `activeTabIdComponentState` from the
deprecated V1 version to V2.
- Allows the active tab state to be preserved during navigation inside
the side panel and reset when the side panel is closed.
- Allows the active tab state to be preserved when we open a record in
full page from the side panel


https://github.com/user-attachments/assets/f2329d7a-ea15-4bd8-81dc-e98ce11edbd0


https://github.com/user-attachments/assets/474bffd5-29e0-40ba-97f4-fa5e9be34dc2
This commit is contained in:
Raphaël Bosi
2025-03-19 16:53:22 +01:00
committed by GitHub
parent 0d40126a29
commit cfdb3f5778
37 changed files with 299 additions and 609 deletions

View File

@ -1,8 +1,9 @@
import { TabListFromUrlOptionalEffect } from '@/ui/layout/tab/components/TabListFromUrlOptionalEffect';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { TabListScope } from '@/ui/layout/tab/scopes/TabListScope';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab/states/contexts/TabListComponentInstanceContext';
import { LayoutCard } from '@/ui/layout/tab/types/LayoutCard';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import styled from '@emotion/styled';
import * as React from 'react';
import { useEffect } from 'react';
@ -21,12 +22,12 @@ export type SingleTabProps<T extends string = string> = {
};
type TabListProps = {
tabListInstanceId: string;
tabs: SingleTabProps[];
loading?: boolean;
behaveAsLinks?: boolean;
className?: string;
isInRightDrawer?: boolean;
componentInstanceId: string;
};
const StyledContainer = styled.div`
@ -44,15 +45,18 @@ const StyledOuterContainer = styled.div`
export const TabList = ({
tabs,
tabListInstanceId,
loading,
behaveAsLinks = true,
isInRightDrawer,
className,
componentInstanceId,
}: TabListProps) => {
const visibleTabs = tabs.filter((tab) => !tab.hide);
const { activeTabId, setActiveTabId } = useTabList(tabListInstanceId);
const [activeTabId, setActiveTabId] = useRecoilComponentStateV2(
activeTabIdComponentState,
componentInstanceId,
);
const initialActiveTabId = activeTabId || visibleTabs[0]?.id || '';
@ -65,17 +69,18 @@ export const TabList = ({
}
return (
<StyledOuterContainer>
<TabListScope tabListScopeId={tabListInstanceId}>
<TabListComponentInstanceContext.Provider
value={{ instanceId: componentInstanceId }}
>
<StyledOuterContainer>
<TabListFromUrlOptionalEffect
isInRightDrawer={!!isInRightDrawer}
componentInstanceId={tabListInstanceId}
tabListIds={tabs.map((tab) => tab.id)}
/>
<ScrollWrapper
defaultEnableYScroll={false}
contextProviderName="tabList"
componentInstanceId={`scroll-wrapper-tab-list-${tabListInstanceId}`}
componentInstanceId={`scroll-wrapper-tab-list-${componentInstanceId}`}
>
<StyledContainer className={className}>
{visibleTabs.map((tab) => (
@ -98,7 +103,7 @@ export const TabList = ({
))}
</StyledContainer>
</ScrollWrapper>
</TabListScope>
</StyledOuterContainer>
</StyledOuterContainer>
</TabListComponentInstanceContext.Provider>
);
};

View File

@ -1,20 +1,23 @@
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
type TabListFromUrlOptionalEffectProps = {
componentInstanceId: string;
tabListIds: string[];
isInRightDrawer: boolean;
};
export const TabListFromUrlOptionalEffect = ({
componentInstanceId,
tabListIds,
isInRightDrawer,
}: TabListFromUrlOptionalEffectProps) => {
const location = useLocation();
const { activeTabId, setActiveTabId } = useTabList(componentInstanceId);
const activeTabId = useRecoilComponentValueV2(activeTabIdComponentState);
const setActiveTabId = useSetRecoilComponentStateV2(
activeTabIdComponentState,
);
const hash = location.hash.replace('#', '');

View File

@ -2,8 +2,6 @@ import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { ComponentWithRouterDecorator, IconCheckbox } from 'twenty-ui';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { TabList } from '../TabList';
const tabs = [
@ -39,17 +37,10 @@ const meta: Meta<typeof TabList> = {
title: 'UI/Layout/Tab/TabList',
component: TabList,
args: {
tabListInstanceId: 'tab-list-id',
tabs: tabs,
componentInstanceId: 'tab-list',
},
decorators: [
(Story) => (
<RecoilScope>
<Story />
</RecoilScope>
),
ComponentWithRouterDecorator,
],
decorators: [ComponentWithRouterDecorator],
};
export default meta;

View File

@ -1,31 +0,0 @@
import { act } from 'react-dom/test-utils';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useTabList } from '../useTabList';
describe('useTabList', () => {
it('Should update the activeTabId state', async () => {
const { result } = renderHook(
() => {
const { activeTabId, setActiveTabId } = useTabList('TEST_TAB_LIST_ID');
return {
activeTabId,
setActiveTabId: setActiveTabId,
};
},
{
wrapper: RecoilRoot,
},
);
expect(result.current.setActiveTabId).toBeInstanceOf(Function);
expect(result.current.activeTabId).toBeNull();
act(() => {
result.current.setActiveTabId('test-value');
});
expect(result.current.activeTabId).toBe('test-value');
});
});

View File

@ -1,20 +0,0 @@
import { TabListScopeInternalContext } from '@/ui/layout/tab/scopes/scope-internal-context/TabListScopeInternalContext';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
type useTabListStatesProps = {
tabListScopeId?: string;
};
export const useTabListStates = ({ tabListScopeId }: useTabListStatesProps) => {
const scopeId = useAvailableScopeIdOrThrow(
TabListScopeInternalContext,
tabListScopeId,
);
return {
scopeId,
activeTabIdState: extractComponentState(activeTabIdComponentState, scopeId),
};
};

View File

@ -1,18 +0,0 @@
import { RecoilState, useRecoilState } from 'recoil';
import { useTabListStates } from '@/ui/layout/tab/hooks/internal/useTabListStates';
export const useTabList = <T extends string>(tabListId?: string) => {
const { activeTabIdState } = useTabListStates({
tabListScopeId: tabListId,
});
const [activeTabId, setActiveTabId] = useRecoilState(
activeTabIdState as RecoilState<T | null>,
);
return {
activeTabId,
setActiveTabId,
};
};

View File

@ -1,6 +1,8 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab/states/contexts/TabListComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const activeTabIdComponentState = createComponentState<string | null>({
export const activeTabIdComponentState = createComponentStateV2<string | null>({
key: 'activeTabIdComponentState',
defaultValue: null,
componentInstanceContext: TabListComponentInstanceContext,
});

View File

@ -0,0 +1,3 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const TabListComponentInstanceContext = createComponentInstanceContext();