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,15 +1,22 @@
import { CommandMenuActionMenuDropdownHotkeyScope } from '@/action-menu/types/CommandMenuActionMenuDropdownHotkeyScope';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { AppPath } from '@/types/AppPath';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Button, IconBrowserMaximize, getOsControlSymbol } from 'twenty-ui';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const StyledLink = styled(Link)`
text-decoration: none;
`;
@ -25,17 +32,58 @@ export const RecordShowRightDrawerOpenRecordButton = ({
}: RecordShowRightDrawerOpenRecordButtonProps) => {
const { closeCommandMenu } = useCommandMenu();
const commandMenuPageComponentInstance = useComponentInstanceStateContext(
CommandMenuPageComponentInstanceContext,
);
const tabListComponentId = getShowPageTabListComponentId({
pageId: commandMenuPageComponentInstance?.instanceId,
targetObjectId: record.id,
});
const activeTabIdInRightDrawer = useRecoilComponentValueV2(
activeTabIdComponentState,
tabListComponentId,
);
const tabListComponentIdInRecordPage = getShowPageTabListComponentId({
targetObjectId: record.id,
});
const setActiveTabIdInRecordPage = useSetRecoilComponentStateV2(
activeTabIdComponentState,
tabListComponentIdInRecordPage,
);
const to = getLinkToShowPage(objectNameSingular, record);
const navigate = useNavigateApp();
const handleOpenRecord = () => {
const handleOpenRecord = useCallback(() => {
const tabIdToOpen =
activeTabIdInRightDrawer === 'home'
? objectNameSingular === CoreObjectNameSingular.Note ||
objectNameSingular === CoreObjectNameSingular.Task
? 'richText'
: 'timeline'
: activeTabIdInRightDrawer;
setActiveTabIdInRecordPage(tabIdToOpen);
navigate(AppPath.RecordShowPage, {
objectNameSingular,
objectRecordId: record.id,
});
closeCommandMenu();
};
}, [
activeTabIdInRightDrawer,
closeCommandMenu,
navigate,
objectNameSingular,
record.id,
setActiveTabIdInRecordPage,
]);
useScopedHotkeys(
['ctrl+Enter,meta+Enter'],

View File

@ -12,13 +12,13 @@ import {
import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { TASKS_TAB_LIST_COMPONENT_ID } from '@/activities/tasks/constants/TasksTabListComponentId';
import { useTasks } from '@/activities/tasks/hooks/useTasks';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
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 groupBy from 'lodash.groupby';
import { AddTaskButton } from './AddTaskButton';
import { TaskList } from './TaskList';
@ -45,7 +45,7 @@ export const TaskGroups = ({ targetableObjects }: TaskGroupsProps) => {
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
const { activeTabId } = useTabList(TASKS_TAB_LIST_COMPONENT_ID);
const activeTabId = useRecoilComponentValueV2(activeTabIdComponentState);
const isLoading =
(activeTabId !== 'done' && tasksLoading) ||

View File

@ -16,7 +16,13 @@ import { viewableRecordIdState } from '@/object-record/record-right-drawer/state
import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2';
import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import { WorkflowRunTabId } from '@/workflow/workflow-steps/types/WorkflowRunTabId';
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
import { WorkflowServerlessFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/types/WorkflowServerlessFunctionTabId';
import { useRecoilCallback } from 'recoil';
export const useCommandMenuCloseAnimationCompleteCleanup = () => {
@ -29,7 +35,7 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => {
const { closeDropdown } = useDropdownV2();
const commandMenuCloseAnimationCompleteCleanup = useRecoilCallback(
({ set }) =>
({ snapshot, set }) =>
() => {
closeDropdown(COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID);
@ -54,6 +60,32 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => {
emitRightDrawerCloseEvent();
set(isCommandMenuClosingState, false);
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
WorkflowRunTabId.NODE,
);
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
}),
WorkflowServerlessFunctionTabId.CODE,
);
for (const [pageId, morphItem] of snapshot
.getLoadable(commandMenuNavigationMorphItemByPageState)
.getValue()) {
set(
activeTabIdComponentState.atomFamily({
instanceId: getShowPageTabListComponentId({
pageId,
targetObjectId: morphItem.recordId,
}),
}),
null,
);
}
},
[
closeDropdown,

View File

@ -6,6 +6,8 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { isDefined } from 'twenty-shared';
export const useCommandMenuHistory = () => {
@ -47,6 +49,19 @@ export const useCommandMenuHistory = () => {
const newMorphItems = new Map(currentMorphItems);
newMorphItems.delete(removedItem.pageId);
set(commandMenuNavigationMorphItemByPageState, newMorphItems);
const morphItem = currentMorphItems.get(removedItem.pageId);
if (isDefined(morphItem)) {
set(
activeTabIdComponentState.atomFamily({
instanceId: getShowPageTabListComponentId({
pageId: removedItem.pageId,
targetObjectId: morphItem.recordId,
}),
}),
null,
);
}
}
}
@ -84,6 +99,20 @@ export const useCommandMenuHistory = () => {
.getLoadable(commandMenuNavigationMorphItemByPageState)
.getValue();
for (const [pageId, morphItem] of currentMorphItems.entries()) {
if (!newNavigationStack.some((item) => item.pageId === pageId)) {
set(
activeTabIdComponentState.atomFamily({
instanceId: getShowPageTabListComponentId({
pageId,
targetObjectId: morphItem.recordId,
}),
}),
null,
);
}
}
const newMorphItems = new Map(
Array.from(currentMorphItems.entries()).filter(([pageId]) =>
newNavigationStack.some((item) => item.pageId === pageId),

View File

@ -1,5 +1,6 @@
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
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 { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
@ -9,6 +10,10 @@ import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components
import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import {
WorkflowRunTabId,
WorkflowRunTabIdType,
} from '@/workflow/workflow-steps/types/WorkflowRunTabId';
import { getWorkflowRunStepExecutionStatus } from '@/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import styled from '@emotion/styled';
@ -20,7 +25,7 @@ const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
type TabId = 'node' | 'input' | 'output';
type TabId = WorkflowRunTabIdType;
export const CommandMenuWorkflowRunViewStep = () => {
const flow = useFlowOrThrow();
@ -29,7 +34,8 @@ export const CommandMenuWorkflowRunViewStep = () => {
const workflowRun = useWorkflowRun({ workflowRunId });
const { activeTabId } = useTabList<TabId>(
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
);
@ -46,15 +52,15 @@ export const CommandMenuWorkflowRunViewStep = () => {
stepExecutionStatus === 'not-executed';
const tabs: SingleTabProps<TabId>[] = [
{ id: 'node', title: 'Node', Icon: IconStepInto },
{ id: WorkflowRunTabId.NODE, title: 'Node', Icon: IconStepInto },
{
id: 'input',
id: WorkflowRunTabId.INPUT,
title: 'Input',
Icon: IconLogin2,
disabled: areInputAndOutputTabsDisabled,
},
{
id: 'output',
id: WorkflowRunTabId.OUTPUT,
title: 'Output',
Icon: IconLogout,
disabled: areInputAndOutputTabsDisabled,
@ -70,12 +76,12 @@ export const CommandMenuWorkflowRunViewStep = () => {
value={{ workflowVersionId: workflowRun.workflowVersionId }}
>
<StyledTabList
tabListInstanceId={WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID}
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID}
/>
{activeTabId === 'node' ? (
{activeTabId === WorkflowRunTabId.NODE ? (
<WorkflowStepDetail
readonly
stepId={workflowSelectedNode}
@ -84,14 +90,14 @@ export const CommandMenuWorkflowRunViewStep = () => {
/>
) : null}
{activeTabId === 'input' ? (
{activeTabId === WorkflowRunTabId.INPUT ? (
<WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
{activeTabId === 'output' ? (
{activeTabId === WorkflowRunTabId.OUTPUT ? (
<WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}

View File

@ -11,7 +11,8 @@ import { SettingsAccountsCalendarChannelsGeneral } from '@/settings/accounts/com
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountCalendarChannelsTabListComponentId';
import { TabList } from '@/ui/layout/tab/components/TabList';
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 React from 'react';
const StyledCalenderContainer = styled.div`
@ -19,7 +20,8 @@ const StyledCalenderContainer = styled.div`
`;
export const SettingsAccountsCalendarChannelsContainer = () => {
const { activeTabId } = useTabList(
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
@ -63,10 +65,10 @@ export const SettingsAccountsCalendarChannelsContainer = () => {
{tabs.length > 1 && (
<StyledCalenderContainer>
<TabList
tabListInstanceId={
tabs={tabs}
componentInstanceId={
SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID
}
tabs={tabs}
/>
</StyledCalenderContainer>
)}

View File

@ -10,7 +10,8 @@ import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/compo
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountMessageChannelsTabListComponentId';
import { TabList } from '@/ui/layout/tab/components/TabList';
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 React from 'react';
const StyledMessageContainer = styled.div`
@ -18,7 +19,8 @@ const StyledMessageContainer = styled.div`
`;
export const SettingsAccountsMessageChannelsContainer = () => {
const { activeTabId } = useTabList(
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
@ -62,10 +64,10 @@ export const SettingsAccountsMessageChannelsContainer = () => {
{tabs.length > 1 && (
<StyledMessageContainer>
<TabList
tabListInstanceId={
tabs={tabs}
componentInstanceId={
SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID
}
tabs={tabs}
/>
</StyledMessageContainer>
)}

View File

@ -37,8 +37,8 @@ export const SettingsAdminContent = () => {
<>
<TabList
tabs={tabs}
tabListInstanceId={SETTINGS_ADMIN_TABS_ID}
behaveAsLinks={true}
componentInstanceId={SETTINGS_ADMIN_TABS_ID}
/>
<SettingsAdminTabContent />
</>

View File

@ -1,12 +1,10 @@
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { SettingsAdminWorkspaceContent } from '@/settings/admin-panel/components/SettingsAdminWorkspaceContent';
import { SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminUserLookupWorkspaceTabsId';
import { userLookupResultState } from '@/settings/admin-panel/states/userLookupResultState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@ -29,6 +27,9 @@ import { useUserLookupAdminPanelMutation } from '~/generated/graphql';
import { currentUserState } from '@/auth/states/currentUserState';
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
import { SETTINGS_ADMIN_GENERAL_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminGeneralTabsId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
const StyledContainer = styled.div`
align-items: center;
@ -49,8 +50,9 @@ export const SettingsAdminGeneral = () => {
const [userIdentifier, setUserIdentifier] = useState('');
const { enqueueSnackBar } = useSnackBar();
const { activeTabId, setActiveTabId } = useTabList(
SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID,
const [activeTabId, setActiveTabId] = useRecoilComponentStateV2(
activeTabIdComponentState,
'settings-admin-general',
);
const [userLookupResult, setUserLookupResult] = useRecoilState(
userLookupResultState,
@ -200,8 +202,8 @@ export const SettingsAdminGeneral = () => {
<StyledTabListContainer>
<TabList
tabs={tabs}
tabListInstanceId={SETTINGS_ADMIN_USER_LOOKUP_WORKSPACE_TABS_ID}
behaveAsLinks={false}
componentInstanceId={SETTINGS_ADMIN_GENERAL_TABS_ID}
/>
</StyledTabListContainer>

View File

@ -3,10 +3,14 @@ import { SettingsAdminGeneral } from '@/settings/admin-panel/components/Settings
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus';
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';
export const SettingsAdminTabContent = () => {
const { activeTabId } = useTabList(SETTINGS_ADMIN_TABS_ID);
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
SETTINGS_ADMIN_TABS_ID,
);
switch (activeTabId) {
case SETTINGS_ADMIN_TABS.GENERAL:

View File

@ -0,0 +1 @@
export const SETTINGS_ADMIN_GENERAL_TABS_ID = 'settings-admin-general-tabs-id';

View File

@ -6,8 +6,9 @@ import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/s
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { SettingsPath } from '@/types/SettingsPath';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import {
@ -45,7 +46,8 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
onChange: (filePath: string, value: string) => void;
setIsCodeValid: (isCodeValid: boolean) => void;
}) => {
const { activeTabId } = useTabList(
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
);
const TestButton = (
@ -81,12 +83,12 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
const HeaderTabList = (
<StyledTabList
tabListInstanceId={SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID}
tabs={files
.filter((file) => file.path !== '.env')
.map((file) => {
return { id: file.path, title: file.path.split('/').at(-1) || '' };
})}
componentInstanceId={SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID}
/>
);

View File

@ -1,6 +1,7 @@
import { RecordShowRightDrawerActionMenu } from '@/action-menu/components/RecordShowRightDrawerActionMenu';
import { RecordShowRightDrawerOpenRecordButton } from '@/action-menu/components/RecordShowRightDrawerOpenRecordButton';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { CardComponents } from '@/object-record/record-show/components/CardComponents';
import { FieldsCard } from '@/object-record/record-show/components/FieldsCard';
import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
@ -9,9 +10,13 @@ import { recordStoreFamilyState } from '@/object-record/record-store/states/reco
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { TabListComponentInstanceContext } from '@/ui/layout/tab/states/contexts/TabListComponentInstanceContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
@ -41,8 +46,6 @@ const StyledContentContainer = styled.div<{ isInRightDrawer: boolean }>`
isInRightDrawer ? theme.spacing(16) : 0};
`;
export const TAB_LIST_COMPONENT_ID = 'show-page-right-tab-list';
type ShowPageSubContainerProps = {
layout?: RecordLayout;
tabs: SingleTabProps[];
@ -52,7 +55,6 @@ type ShowPageSubContainerProps = {
>;
isInRightDrawer?: boolean;
loading: boolean;
isNewRightDrawerItemLoading?: boolean;
};
export const ShowPageSubContainer = ({
@ -62,9 +64,18 @@ export const ShowPageSubContainer = ({
loading,
isInRightDrawer = false,
}: ShowPageSubContainerProps) => {
const tabListComponentId = `${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}-${targetableObject.id}`;
const commandMenuPageComponentInstance = useComponentInstanceStateContext(
CommandMenuPageComponentInstanceContext,
);
const { activeTabId } = useTabList(tabListComponentId);
const tabListComponentId = getShowPageTabListComponentId({
pageId: commandMenuPageComponentInstance?.instanceId,
targetObjectId: targetableObject.id,
});
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
tabListComponentId,
);
const isMobile = useIsMobile();
@ -109,7 +120,9 @@ export const ShowPageSubContainer = ({
layout && !layout.hideSummaryAndFields && !isMobile && !isInRightDrawer;
return (
<>
<TabListComponentInstanceContext.Provider
value={{ instanceId: tabListComponentId }}
>
{displaySummaryAndFields && (
<ShowPageLeftContainer forceMobile={isMobile}>
{summaryCard}
@ -121,9 +134,9 @@ export const ShowPageSubContainer = ({
<StyledTabList
behaveAsLinks={!isInRightDrawer}
loading={loading}
tabListInstanceId={tabListComponentId}
tabs={tabs}
isInRightDrawer={isInRightDrawer}
componentInstanceId={tabListComponentId}
/>
</StyledTabListContainer>
{(isMobile || isInRightDrawer) && summaryCard}
@ -142,6 +155,6 @@ export const ShowPageSubContainer = ({
/>
)}
</StyledShowPageRightContainer>
</>
</TabListComponentInstanceContext.Provider>
);
};

View File

@ -0,0 +1 @@
export const SHOW_PAGE_RIGHT_TAB_LIST = 'show-page-right-tab-list';

View File

@ -0,0 +1,12 @@
import { SHOW_PAGE_RIGHT_TAB_LIST } from '@/ui/layout/show-page/constants/ShowPageTabListComponentId';
export const getShowPageTabListComponentId = ({
pageId,
targetObjectId,
}: {
pageId?: string;
targetObjectId: string;
}): string => {
const id = pageId || targetObjectId;
return `${SHOW_PAGE_RIGHT_TAB_LIST}-${id}`;
};

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();

View File

@ -1,5 +1,5 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { useTabListStates } from '@/ui/layout/tab/hooks/internal/useTabListStates';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
@ -24,27 +24,29 @@ export const WorkflowRunDiagramCanvasEffect = () => {
const workflowId = useRecoilValue(workflowIdState);
const { activeTabIdState: workflowRunRightDrawerListActiveTabIdState } =
useTabListStates({
tabListScopeId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
});
const goBackToFirstWorkflowRunRightDrawerTabIfNeeded = useRecoilCallback(
({ snapshot, set }) =>
() => {
const activeWorkflowRunRightDrawerTab = getSnapshotValue(
snapshot,
workflowRunRightDrawerListActiveTabIdState,
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
) as WorkflowRunTabId | null;
if (
activeWorkflowRunRightDrawerTab === 'input' ||
activeWorkflowRunRightDrawerTab === 'output'
) {
set(workflowRunRightDrawerListActiveTabIdState, 'node');
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
'node',
);
}
},
[workflowRunRightDrawerListActiveTabIdState],
[],
);
const handleSelectionChange = useCallback(

View File

@ -1,23 +0,0 @@
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { WorkflowStepContextProvider } from '@/workflow/states/context/WorkflowStepContext';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { RightDrawerWorkflowEditStepContent } from '@/workflow/workflow-steps/components/RightDrawerWorkflowEditStepContent';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const RightDrawerWorkflowEditStep = () => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return (
<WorkflowStepContextProvider
value={{ workflowVersionId: workflow.currentVersion.id }}
>
<RightDrawerWorkflowEditStepContent workflow={workflow} />
</WorkflowStepContextProvider>
);
};

View File

@ -1,30 +0,0 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hooks/useWorkflowSelectedNodeOrThrow';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
export const RightDrawerWorkflowEditStepContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const flow = useFlowOrThrow();
const workflowSelectedNode = useWorkflowSelectedNodeOrThrow();
const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow });
const { updateStep } = useUpdateStep({
workflow,
});
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
onActionUpdate={updateStep}
onTriggerUpdate={updateTrigger}
/>
);
};

View File

@ -1,102 +0,0 @@
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { WorkflowStepContextProvider } from '@/workflow/states/context/WorkflowStepContext';
import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hooks/useWorkflowSelectedNodeOrThrow';
import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail';
import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import { getWorkflowRunStepExecutionStatus } from '@/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui';
const StyledTabList = styled(TabList)`
background-color: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
type TabId = 'node' | 'input' | 'output';
export const RightDrawerWorkflowRunViewStep = () => {
const flow = useFlowOrThrow();
const workflowSelectedNode = useWorkflowSelectedNodeOrThrow();
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
const { activeTabId } = useTabList<TabId>(
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
);
const stepExecutionStatus = isDefined(workflowRun)
? getWorkflowRunStepExecutionStatus({
workflowRunOutput: workflowRun.output,
stepId: workflowSelectedNode,
})
: undefined;
const areInputAndOutputTabsDisabled =
workflowSelectedNode === TRIGGER_STEP_ID ||
stepExecutionStatus === 'running' ||
stepExecutionStatus === 'not-executed';
const tabs: SingleTabProps<TabId>[] = [
{ id: 'node', title: 'Node', Icon: IconStepInto },
{
id: 'input',
title: 'Input',
Icon: IconLogin2,
disabled: areInputAndOutputTabsDisabled,
},
{
id: 'output',
title: 'Output',
Icon: IconLogout,
disabled: areInputAndOutputTabsDisabled,
},
];
if (!isDefined(workflowRun)) {
return null;
}
return (
<WorkflowStepContextProvider
value={{ workflowVersionId: flow.workflowVersionId }}
>
<StyledTabList
tabListInstanceId={WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID}
tabs={tabs}
behaveAsLinks={false}
/>
{activeTabId === 'node' ? (
<WorkflowStepDetail
readonly
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
/>
) : null}
{activeTabId === 'input' ? (
<WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
{activeTabId === 'output' ? (
<WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
</WorkflowStepContextProvider>
);
};

View File

@ -1,213 +0,0 @@
import { flowState } from '@/workflow/states/flowState';
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { HttpResponse, graphql } from 'msw';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { oneFailedWorkflowRunQueryResult } from '~/testing/mock-data/workflow-run';
import { RightDrawerWorkflowRunViewStep } from '../RightDrawerWorkflowRunViewStep';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 500px;
`;
const meta: Meta<typeof RightDrawerWorkflowRunViewStep> = {
title: 'Modules/Workflow/RightDrawerWorkflowRunViewStep',
component: RightDrawerWorkflowRunViewStep,
decorators: [
(Story) => (
<StyledWrapper>
<Story />
</StyledWrapper>
),
I18nFrontDecorator,
ComponentDecorator,
(Story) => {
const setFlow = useSetRecoilState(flowState);
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
const setWorkflowRunId = useSetRecoilState(workflowRunIdState);
setFlow({
workflowVersionId:
oneFailedWorkflowRunQueryResult.workflowRun.workflowVersionId,
trigger:
oneFailedWorkflowRunQueryResult.workflowRun.output.flow.trigger,
steps: oneFailedWorkflowRunQueryResult.workflowRun.output.flow.steps,
});
setWorkflowSelectedNode(
oneFailedWorkflowRunQueryResult.workflowRun.output.flow.steps[0].id,
);
setWorkflowRunId(oneFailedWorkflowRunQueryResult.workflowRun.id);
return <Story />;
},
RouterDecorator,
ObjectMetadataItemsDecorator,
WorkspaceDecorator,
WorkflowStepDecorator,
],
parameters: {
msw: {
handlers: [
graphql.query('FindOneWorkflowRun', () => {
const workflowRunContext =
oneFailedWorkflowRunQueryResult.workflowRun.context;
// Rendering the whole objectMetadata information in the JSON viewer is too long for storybook
// so we remove it for the story
return HttpResponse.json({
data: {
...oneFailedWorkflowRunQueryResult,
workflowRun: {
...oneFailedWorkflowRunQueryResult.workflowRun,
context: {
...workflowRunContext,
trigger: {
...workflowRunContext.trigger,
objectMetadata: undefined,
},
},
},
},
});
}),
...graphqlMocks.handlers,
],
},
},
};
export default meta;
type Story = StoryObj<typeof RightDrawerWorkflowRunViewStep>;
export const NodeTab: Story = {};
export const InputTab: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Input' }));
expect(await canvas.findByText('Trigger')).toBeVisible();
},
};
export const InputTabDisabledForTrigger: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inputTab = await canvas.findByRole('button', { name: 'Input' });
expect(inputTab).toBeDisabled();
},
};
export const InputTabNotExecutedStep: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(
oneFailedWorkflowRunQueryResult.workflowRun.output.flow.steps.at(-1)!
.id,
);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inputTab = await canvas.findByRole('button', { name: 'Input' });
expect(inputTab).toBeDisabled();
},
};
export const OutputTab: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole('button', { name: 'Output' }),
);
await waitFor(() => {
expect(canvas.queryByText('Create Record')).not.toBeInTheDocument();
});
expect(await canvas.findByText('result')).toBeVisible();
},
};
export const OutputTabDisabledForTrigger: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outputTab = await canvas.findByRole('button', { name: 'Output' });
expect(outputTab).toBeDisabled();
},
};
export const OutputTabNotExecutedStep: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(
oneFailedWorkflowRunQueryResult.workflowRun.output.flow.steps.at(-1)!
.id,
);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outputTab = await canvas.findByRole('button', { name: 'Output' });
expect(outputTab).toBeDisabled();
},
};

View File

@ -1 +1,7 @@
export type WorkflowRunTabId = 'node' | 'input' | 'output';
export type WorkflowRunTabIdType = 'node' | 'input' | 'output';
export enum WorkflowRunTabId {
NODE = 'node',
INPUT = 'input',
OUTPUT = 'output',
}

View File

@ -1,16 +0,0 @@
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { RightDrawerWorkflowSelectActionContent } from '@/workflow/workflow-steps/workflow-actions/components/RightDrawerWorkflowSelectActionContent';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const RightDrawerWorkflowSelectAction = () => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
return null;
}
return <RightDrawerWorkflowSelectActionContent workflow={workflow} />;
};

View File

@ -1,45 +0,0 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { OTHER_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/OtherActions';
import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions';
import { MenuItemCommand, useIcons } from 'twenty-ui';
import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepContainer';
import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle';
export const RightDrawerWorkflowSelectActionContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { getIcon } = useIcons();
const { createStep } = useCreateStep({
workflow,
});
return (
<RightDrawerStepListContainer>
<RightDrawerWorkflowSelectStepTitle>
Records
</RightDrawerWorkflowSelectStepTitle>
{RECORD_ACTIONS.map((action) => (
<MenuItemCommand
key={action.type}
LeftIcon={getIcon(action.icon)}
text={action.label}
onClick={() => createStep(action.type)}
/>
))}
<RightDrawerWorkflowSelectStepTitle>
Other
</RightDrawerWorkflowSelectStepTitle>
{OTHER_ACTIONS.map((action) => (
<MenuItemCommand
key={action.type}
LeftIcon={getIcon(action.icon)}
text={action.label}
onClick={() => createStep(action.type)}
/>
))}
</RightDrawerStepListContainer>
);
};

View File

@ -19,11 +19,13 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
import { TextArea } from '@/ui/input/components/TextArea';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { TabList } from '@/ui/layout/tab/components/TabList';
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 { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowEditActionServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionServerlessFunctionFields';
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
import { WorkflowServerlessFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/types/WorkflowServerlessFunctionTabId';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getWrongExportedFunctionMarkers } from '@/workflow/workflow-steps/workflow-actions/utils/getWrongExportedFunctionMarkers';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
@ -77,8 +79,10 @@ export const WorkflowEditActionServerlessFunction = ({
const theme = useTheme();
const { getIcon } = useIcons();
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
const tabListId = `${WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID}_${serverlessFunctionId}`;
const { activeTabId } = useTabList(tabListId);
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
);
const { updateOneServerlessFunction } =
useUpdateOneServerlessFunction(serverlessFunctionId);
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
@ -267,8 +271,12 @@ export const WorkflowEditActionServerlessFunction = ({
};
const tabs = [
{ id: 'code', title: 'Code', Icon: IconCode },
{ id: 'test', title: 'Test', Icon: IconPlayerPlay },
{ id: WorkflowServerlessFunctionTabId.CODE, title: 'Code', Icon: IconCode },
{
id: WorkflowServerlessFunctionTabId.TEST,
title: 'Test',
Icon: IconPlayerPlay,
},
];
useEffect(() => {
@ -284,9 +292,11 @@ export const WorkflowEditActionServerlessFunction = ({
!loading && (
<StyledContainer>
<StyledTabList
tabListInstanceId={tabListId}
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID
}
/>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
@ -299,7 +309,7 @@ export const WorkflowEditActionServerlessFunction = ({
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
{activeTabId === 'code' && (
{activeTabId === WorkflowServerlessFunctionTabId.CODE && (
<>
<WorkflowEditActionServerlessFunctionFields
functionInput={functionInput}
@ -323,7 +333,7 @@ export const WorkflowEditActionServerlessFunction = ({
</StyledCodeEditorContainer>
</>
)}
{activeTabId === 'test' && (
{activeTabId === WorkflowServerlessFunctionTabId.TEST && (
<>
<WorkflowEditActionServerlessFunctionFields
functionInput={serverlessFunctionTestData.input}
@ -352,7 +362,7 @@ export const WorkflowEditActionServerlessFunction = ({
</>
)}
</WorkflowStepBody>
{activeTabId === 'test' && (
{activeTabId === WorkflowServerlessFunctionTabId.TEST && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton

View File

@ -0,0 +1,6 @@
export type WorkflowServerlessFunctionTabIdType = 'code' | 'test';
export enum WorkflowServerlessFunctionTabId {
CODE = 'code',
TEST = 'test',
}

View File

@ -10,10 +10,11 @@ import {
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/workflow-diagram/states/workflowDiagramTriggerNodeSelectionState';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
import { getCurrentSubStepFromPath } from '@/workflow/workflow-variables/utils/getCurrentSubStepFromPath';
import { getStepHeaderLabel } from '@/workflow/workflow-variables/utils/getStepHeaderLabel';
import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOutputSchema';
@ -26,7 +27,6 @@ import {
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
type WorkflowVariablesDropdownFieldItemsProps = {
step: StepOutputSchema;
@ -43,8 +43,8 @@ export const WorkflowVariablesDropdownFieldItems = ({
const [searchInputValue, setSearchInputValue] = useState('');
const { getIcon } = useIcons();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const { setActiveTabId } = useTabList(
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
const setActiveTabId = useSetRecoilComponentStateV2(
activeTabIdComponentState,
);
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
workflowDiagramTriggerNodeSelectionState,

View File

@ -12,12 +12,13 @@ import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared';
import {
@ -68,7 +69,8 @@ export const SettingsObjectDetailPage = () => {
findActiveObjectMetadataItemByNamePlural(objectNamePlural) ??
findActiveObjectMetadataItemByNamePlural(updatedObjectNamePlural);
const { activeTabId } = useTabList(
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
SETTINGS_OBJECT_DETAIL_TABS.COMPONENT_INSTANCE_ID,
);
@ -169,11 +171,10 @@ export const SettingsObjectDetailPage = () => {
>
<SettingsPageContainer>
<TabList
tabListInstanceId={
tabs={tabs}
componentInstanceId={
SETTINGS_OBJECT_DETAIL_TABS.COMPONENT_INSTANCE_ID
}
tabs={tabs}
className="tab-list"
/>
<StyledContentContainer>
{renderActiveTabContent()}

View File

@ -10,7 +10,8 @@ import { RoleSettings } from '@/settings/roles/role-settings/components/RoleSett
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
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 { useGetRolesQuery } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
@ -33,9 +34,7 @@ export const SettingsRoleEdit = () => {
const role = rolesData?.getRoles.find((r) => r.id === roleId);
const { activeTabId } = useTabList(
SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID,
);
const activeTabId = useRecoilComponentValueV2(activeTabIdComponentState);
useEffect(() => {
if (!rolesLoading && !role) {
@ -101,9 +100,11 @@ export const SettingsRoleEdit = () => {
{!rolesLoading && role ? (
<SettingsPageContainer>
<TabList
tabListInstanceId={SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID}
tabs={tabs}
className="tab-list"
componentInstanceId={
SETTINGS_ROLE_DETAIL_TABS.COMPONENT_INSTANCE_ID
}
/>
{renderActiveTabContent()}
</SettingsPageContainer>

View File

@ -14,7 +14,8 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
@ -26,12 +27,15 @@ import { FeatureFlagKey } from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const TAB_LIST_COMPONENT_ID = 'serverless-function-detail';
const SERVERLESS_FUNCTION_DETAIL_ID = 'serverless-function-detail';
export const SettingsServerlessFunctionDetail = () => {
const { serverlessFunctionId = '' } = useParams();
const { enqueueSnackBar } = useSnackBar();
const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
const [activeTabId, setActiveTabId] = useRecoilComponentStateV2(
activeTabIdComponentState,
SERVERLESS_FUNCTION_DETAIL_ID,
);
const [isCodeValid, setIsCodeValid] = useState(true);
const { updateOneServerlessFunction } =
useUpdateOneServerlessFunction(serverlessFunctionId);
@ -209,9 +213,9 @@ export const SettingsServerlessFunctionDetail = () => {
>
<SettingsPageContainer>
<TabList
tabListInstanceId={TAB_LIST_COMPONENT_ID}
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={SERVERLESS_FUNCTION_DETAIL_ID}
/>
{renderActiveTabContent()}
</SettingsPageContainer>