Add post-onboarding AI chat setup behind a feature flag (#23120)
https://github.com/user-attachments/assets/fec7076f-4e46-4c39-84d7-68e4340244ac After finishing onboarding, users now land in a full-screen AI chat that helps them set up their workspace, instead of going straight to their default view. The welcome overlay's title flies into the chat's first message so the handoff reads as one continuous motion: the slide plays alone, the title swaps in place pixel-exactly (a regular-weight clone of the target line is crossfaded in mid-flight to morph the font weight), then the rest of the text fades in. All of it sits behind `IS_ONBOARDING_AI_CHAT_ENABLED` (default off, not registered as a public flag). With the flag off, onboarding behaves exactly as it does today — the welcome overlay still plays and the user lands on their home view. Layout follows the Figma: the nav drawer stays visible and the chat renders in a panel-styled container with an "Onboarding" header, matching the expanded side panel. Also fixes two pre-existing bugs the feature surfaced: - On billing instances the completion redirect raced the lazy `PaymentSuccess` page, which silently skipped the welcome animation on the no-card trial path. The redirect now defers while a checkout is pending, and `PaymentSuccess` always confirms through `useLoadCurrentUser` so freshly served feature flags are respected. - `useDefaultHomePagePath` could conclude its `/settings/profile` empty-workspace fallback from a transiently empty metadata store and strand the user there; it now waits for both object metadata and navigation menu items before deciding. Reviewer notes: - `AgentChatRuntimeEffects` no longer keys off side-panel state, so `modules/ai` stops importing `modules/side-panel`. The two visibility-scoped effects moved into `AiChatTab`. - `/workspace-setup` is deliberately URL-addressable rather than onboarding-only: the collapse control in the header is a general expand/collapse toggle (paired with a new expand button in the side panel top bar), and gating the route would break refresh and browser-back. It is still authenticated-only. - The design's second, LLM-authored paragraph is not implemented — starting an assistant turn with no user message needs server-side work. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23120?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -1742,6 +1742,7 @@ enum FeatureFlagKey {
|
||||
IS_JUNCTION_RELATIONS_ENABLED
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED
|
||||
IS_WORKFLOW_VERSION_IN_CORE_ENABLED
|
||||
}
|
||||
|
||||
@@ -1397,7 +1397,7 @@ export interface FeatureFlag {
|
||||
__typename: 'FeatureFlag'
|
||||
}
|
||||
|
||||
export type FeatureFlagKey = 'IS_APP_CLAIMING_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_CALENDAR_WEEK_VIEW_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED'
|
||||
export type FeatureFlagKey = 'IS_APP_CLAIMING_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_CALENDAR_WEEK_VIEW_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_ONBOARDING_AI_CHAT_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED'
|
||||
|
||||
export interface WorkspaceUrls {
|
||||
customUrl?: Scalars['String']
|
||||
@@ -9277,6 +9277,7 @@ export const enumFeatureFlagKey = {
|
||||
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const,
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: 'IS_ONBOARDING_AI_CHAT_ENABLED' as const,
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const,
|
||||
IS_WORKFLOW_VERSION_IN_CORE_ENABLED: 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED' as const
|
||||
}
|
||||
|
||||
@@ -326,6 +326,7 @@ export enum FeatureFlagKey {
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
|
||||
@@ -1770,6 +1770,7 @@ export enum FeatureFlagKey {
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
|
||||
+37
-1
@@ -86,6 +86,8 @@ const setupMockState = (
|
||||
currentWorkspace: object | null = { id: 'mock-workspace-id' },
|
||||
isBillingEnabled: boolean = true,
|
||||
isMinimalMetadataReady: boolean = true,
|
||||
shouldOpenAiChatAfterOnboarding: boolean = false,
|
||||
isOnboardingCheckoutPending: boolean = false,
|
||||
) => {
|
||||
jest
|
||||
.mocked(useAtomStateValue)
|
||||
@@ -94,7 +96,9 @@ const setupMockState = (
|
||||
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }])
|
||||
.mockReturnValueOnce(isMinimalMetadataReady)
|
||||
.mockReturnValueOnce(verifyEmailRedirectPath)
|
||||
.mockReturnValueOnce(returnToPath ?? '');
|
||||
.mockReturnValueOnce(returnToPath ?? '')
|
||||
.mockReturnValueOnce(shouldOpenAiChatAfterOnboarding)
|
||||
.mockReturnValueOnce(isOnboardingCheckoutPending);
|
||||
};
|
||||
|
||||
// prettier-ignore
|
||||
@@ -113,7 +117,19 @@ const testCases: {
|
||||
useQueryResult?: { data?: unknown; loading?: boolean };
|
||||
isBillingEnabled?: boolean;
|
||||
isMinimalMetadataReady?: boolean;
|
||||
shouldOpenAiChatAfterOnboarding?: boolean;
|
||||
isOnboardingCheckoutPending?: boolean;
|
||||
}[] = [
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation },
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps },
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined },
|
||||
@@ -372,6 +388,14 @@ const testCases: {
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/objects/tasks', res: '/objects/tasks' },
|
||||
{ loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/settings/api-keys', res: '/settings/api-keys' },
|
||||
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, shouldOpenAiChatAfterOnboarding: true, res: AppPath.WorkspaceSetup },
|
||||
{ loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, shouldOpenAiChatAfterOnboarding: false, res: defaultHomePagePath },
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, shouldOpenAiChatAfterOnboarding: true, res: AppPath.WorkspaceSetup },
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, shouldOpenAiChatAfterOnboarding: true, returnToPath: '/objects/tasks', res: '/objects/tasks' },
|
||||
|
||||
{ loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnboardingCheckoutPending: true, res: undefined },
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnboardingCheckoutPending: true, res: defaultHomePagePath },
|
||||
|
||||
// isOnAWorkspace:false — on default domain, don't redirect to returnToPath or defaultHomePagePath from auth pages
|
||||
{ loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined },
|
||||
{ loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined },
|
||||
@@ -394,6 +418,8 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
||||
useQueryResult,
|
||||
isBillingEnabled,
|
||||
isMinimalMetadataReady,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
isOnboardingCheckoutPending,
|
||||
res,
|
||||
}) => {
|
||||
setupMockIsMatchingLocation(loc);
|
||||
@@ -410,6 +436,8 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
||||
undefined,
|
||||
isBillingEnabled ?? true,
|
||||
isMinimalMetadataReady ?? true,
|
||||
shouldOpenAiChatAfterOnboarding ?? false,
|
||||
isOnboardingCheckoutPending ?? false,
|
||||
);
|
||||
|
||||
expect(usePageChangeEffectNavigateLocation()).toEqual(res);
|
||||
@@ -439,6 +467,14 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
||||
[
|
||||
'billingDisabled:inviteTeamCompleted',
|
||||
'billingDisabled:planRequiredCompleted',
|
||||
].length +
|
||||
[
|
||||
'workspaceSetupPending:inviteTeamCompleted',
|
||||
'workspaceSetupNotPending:inviteTeamCompleted',
|
||||
'workspaceSetupPending:paymentSuccessCompleted',
|
||||
'workspaceSetupPending:returnToPathWins',
|
||||
'checkoutPending:paymentSuccessDefersRedirect',
|
||||
'checkoutPending:verifyStillRedirects',
|
||||
].length,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { isMinimalMetadataReadyState } from '@/metadata-store/states/isMinimalMe
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { isOnboardingCheckoutPendingState } from '@/onboarding/states/isOnboardingCheckoutPendingState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
|
||||
import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath';
|
||||
@@ -79,6 +81,17 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
? returnToPath
|
||||
: readReturnToPathFromUrlSearchParams();
|
||||
|
||||
const shouldOpenAiChatAfterOnboarding = useAtomStateValue(
|
||||
shouldOpenAiChatAfterOnboardingState,
|
||||
);
|
||||
const onboardingCompletedPath = shouldOpenAiChatAfterOnboarding
|
||||
? AppPath.WorkspaceSetup
|
||||
: defaultHomePagePath;
|
||||
|
||||
const isOnboardingCheckoutPending = useAtomStateValue(
|
||||
isOnboardingCheckoutPendingState,
|
||||
);
|
||||
|
||||
if (
|
||||
(!hasAccessTokenPair || !isOnAWorkspace || !isDefined(currentWorkspace)) &&
|
||||
!someMatchingLocationOf([
|
||||
@@ -170,7 +183,14 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
hasAccessTokenPair &&
|
||||
isOnAWorkspace
|
||||
) {
|
||||
return resolvedReturnToPath ?? defaultHomePagePath;
|
||||
if (
|
||||
isMatchingLocation(location, AppPath.PlanRequiredSuccess) &&
|
||||
isOnboardingCheckoutPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return resolvedReturnToPath ?? onboardingCompletedPath;
|
||||
}
|
||||
|
||||
if (isMatchingLocation(location, AppPath.Index) && hasAccessTokenPair) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { hasAgentChatBeenOpenedState } from '@/ai/states/hasAgentChatBeenOpenedState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
export const AgentChatHasBeenOpenedEffect = () => {
|
||||
const setHasAgentChatBeenOpened = useSetAtomState(
|
||||
hasAgentChatBeenOpenedState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setHasAgentChatBeenOpened(true);
|
||||
}, [setHasAgentChatBeenOpened]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -3,32 +3,11 @@ import { AgentChatPrepromptEffect } from '@/ai/components/AgentChatPrepromptEffe
|
||||
import { AgentChatSessionStartTimeEffect } from '@/ai/components/AgentChatSessionStartTimeEffect';
|
||||
import { AgentChatStreamKeepAliveEffect } from '@/ai/components/AgentChatStreamKeepAliveEffect';
|
||||
import { AgentChatStreamSubscriptionEffect } from '@/ai/components/AgentChatStreamSubscriptionEffect';
|
||||
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
|
||||
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
|
||||
import { hasAgentChatBeenOpenedState } from '@/ai/states/hasAgentChatBeenOpenedState';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useEffect } from 'react';
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
|
||||
export const AgentChatRuntimeEffects = () => {
|
||||
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
|
||||
const sidePanelPage = useAtomStateValue(sidePanelPageState);
|
||||
|
||||
const [hasAgentChatBeenOpened, setHasAgentChatBeenOpened] = useAtomState(
|
||||
hasAgentChatBeenOpenedState,
|
||||
);
|
||||
|
||||
const isAgentChatOpen =
|
||||
isSidePanelOpened && sidePanelPage === SidePanelPages.AskAI;
|
||||
|
||||
useEffect(() => {
|
||||
if (isAgentChatOpen && !hasAgentChatBeenOpened) {
|
||||
setHasAgentChatBeenOpened(true);
|
||||
}
|
||||
}, [isAgentChatOpen, hasAgentChatBeenOpened, setHasAgentChatBeenOpened]);
|
||||
const hasAgentChatBeenOpened = useAtomStateValue(hasAgentChatBeenOpenedState);
|
||||
|
||||
if (!hasAgentChatBeenOpened) {
|
||||
return null;
|
||||
@@ -41,12 +20,6 @@ export const AgentChatRuntimeEffects = () => {
|
||||
<AgentChatPrepromptEffect />
|
||||
<AgentChatStreamKeepAliveEffect />
|
||||
<AgentChatSessionStartTimeEffect />
|
||||
{isAgentChatOpen && (
|
||||
<>
|
||||
<AgentChatStreamingPartsDiffSyncEffect />
|
||||
<AgentChatStreamingAutoScrollEffect />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { IconX } from 'twenty-ui/icon';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
|
||||
import { useReturnFromExpandedAiChat } from '@/ai/hooks/useReturnFromExpandedAiChat';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const AiChatCloseButton = () => {
|
||||
const { t } = useLingui();
|
||||
const returnFromExpandedAiChat = useReturnFromExpandedAiChat({
|
||||
reopenSidePanel: false,
|
||||
});
|
||||
const isWelcomeAnimationVisible = useAtomStateValue(
|
||||
isWelcomeAnimationVisibleState,
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
Icon={IconX}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={isWelcomeAnimationVisible}
|
||||
onClick={returnFromExpandedAiChat}
|
||||
ariaLabel={t`Close`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { IconLayoutSidebarRightCollapse } from 'twenty-ui/icon';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
|
||||
import { useReturnFromExpandedAiChat } from '@/ai/hooks/useReturnFromExpandedAiChat';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const AiChatCollapseButton = () => {
|
||||
const { t } = useLingui();
|
||||
const returnFromExpandedAiChat = useReturnFromExpandedAiChat({
|
||||
reopenSidePanel: true,
|
||||
});
|
||||
const isWelcomeAnimationVisible = useAtomStateValue(
|
||||
isWelcomeAnimationVisibleState,
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
Icon={IconLayoutSidebarRightCollapse}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
disabled={isWelcomeAnimationVisible}
|
||||
onClick={returnFromExpandedAiChat}
|
||||
ariaLabel={t`Collapse to side panel`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,9 @@ import { useState } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { DropZone } from '@/activities/files/components/DropZone';
|
||||
import { AgentChatHasBeenOpenedEffect } from '@/ai/components/AgentChatHasBeenOpenedEffect';
|
||||
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
|
||||
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
|
||||
import { AiChatEditorSection } from '@/ai/components/AiChatEditorSection';
|
||||
import { useAiChatFileUpload } from '@/ai/hooks/useAiChatFileUpload';
|
||||
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
@@ -44,6 +47,9 @@ export const AiChatTab = () => {
|
||||
onDragEnter={() => setIsDraggingFile(true)}
|
||||
onDragLeave={() => setIsDraggingFile(false)}
|
||||
>
|
||||
<AgentChatHasBeenOpenedEffect />
|
||||
<AgentChatStreamingPartsDiffSyncEffect />
|
||||
<AgentChatStreamingAutoScrollEffect />
|
||||
{isDraggingFile && (
|
||||
<DropZone
|
||||
setIsDraggingFile={setIsDraggingFile}
|
||||
|
||||
@@ -8,10 +8,13 @@ import { AgentChatScrollToBottomOnMountLayoutEffect } from '@/ai/components/Agen
|
||||
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
|
||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
|
||||
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
|
||||
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledScrollWrapperContainer = styled.div`
|
||||
@@ -23,6 +26,14 @@ const StyledScrollWrapperContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledPreambleOutsideScrollContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
padding: ${themeCssVariables.spacing[4]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledMessageListContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -31,6 +42,7 @@ const StyledMessageListContent = styled.div`
|
||||
`;
|
||||
|
||||
export const AiChatTabMessageList = () => {
|
||||
const messageListPreamble = useContext(AiChatMessageListPreambleContext);
|
||||
const agentChatHasMessage = useAtomComponentSelectorValue(
|
||||
agentChatHasMessageComponentSelector,
|
||||
);
|
||||
@@ -40,7 +52,14 @@ export const AiChatTabMessageList = () => {
|
||||
);
|
||||
|
||||
if (!agentChatHasMessage) {
|
||||
return null;
|
||||
if (!isDefined(messageListPreamble)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<StyledPreambleOutsideScrollContainer>
|
||||
{messageListPreamble}
|
||||
</StyledPreambleOutsideScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -53,6 +72,7 @@ export const AiChatTabMessageList = () => {
|
||||
>
|
||||
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
|
||||
<StyledMessageListContent>
|
||||
{messageListPreamble}
|
||||
<AiChatNonLastMessageIdsList />
|
||||
<AiChatLastMessageWithStreamingState />
|
||||
<AiChatPendingResponseIndicator />
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { AgentChatRuntimeEffects } from '@/ai/components/AgentChatRuntimeEffects';
|
||||
import { hasAgentChatBeenOpenedState } from '@/ai/states/hasAgentChatBeenOpenedState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
jest.mock('@/ai/components/AgentChatMessagesFetchEffect', () => ({
|
||||
AgentChatMessagesFetchEffect: () => <div data-testid="messages-fetch" />,
|
||||
}));
|
||||
jest.mock('@/ai/components/AgentChatStreamSubscriptionEffect', () => ({
|
||||
AgentChatStreamSubscriptionEffect: () => (
|
||||
<div data-testid="stream-subscription" />
|
||||
),
|
||||
}));
|
||||
jest.mock('@/ai/components/AgentChatPrepromptEffect', () => ({
|
||||
AgentChatPrepromptEffect: () => <div data-testid="preprompt" />,
|
||||
}));
|
||||
jest.mock('@/ai/components/AgentChatStreamKeepAliveEffect', () => ({
|
||||
AgentChatStreamKeepAliveEffect: () => <div data-testid="keep-alive" />,
|
||||
}));
|
||||
jest.mock('@/ai/components/AgentChatSessionStartTimeEffect', () => ({
|
||||
AgentChatSessionStartTimeEffect: () => <div data-testid="session-start" />,
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('AgentChatRuntimeEffects', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
});
|
||||
|
||||
it('should render nothing until the chat has been opened once', () => {
|
||||
const { container } = render(<AgentChatRuntimeEffects />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should run the chat runtime once the chat has been opened, regardless of the side panel', () => {
|
||||
jotaiStore.set(hasAgentChatBeenOpenedState.atom, true);
|
||||
|
||||
const { getByTestId } = render(<AgentChatRuntimeEffects />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(getByTestId('messages-fetch')).toBeInTheDocument();
|
||||
expect(getByTestId('stream-subscription')).toBeInTheDocument();
|
||||
expect(getByTestId('preprompt')).toBeInTheDocument();
|
||||
expect(getByTestId('keep-alive')).toBeInTheDocument();
|
||||
expect(getByTestId('session-start')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { AiChatTabMessageList } from '@/ai/components/AiChatTabMessageList';
|
||||
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
|
||||
|
||||
const renderWithPreamble = (preamble: ReactNode) =>
|
||||
render(
|
||||
<AiChatMessageListPreambleContext.Provider value={preamble}>
|
||||
<AiChatTabMessageList />
|
||||
</AiChatMessageListPreambleContext.Provider>,
|
||||
);
|
||||
|
||||
const mockUseAtomComponentSelectorValue = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue',
|
||||
() => ({
|
||||
useAtomComponentSelectorValue: () => mockUseAtomComponentSelectorValue(),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
|
||||
useAtomStateValue: () => false,
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/utilities/scroll/components/ScrollWrapper', () => ({
|
||||
ScrollWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="scroll-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/ai/components/AiChatNonLastMessageIdsList', () => ({
|
||||
AiChatNonLastMessageIdsList: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatLastMessageWithStreamingState', () => ({
|
||||
AiChatLastMessageWithStreamingState: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatPendingResponseIndicator', () => ({
|
||||
AiChatPendingResponseIndicator: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatErrorUnderMessageList', () => ({
|
||||
AiChatErrorUnderMessageList: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatScrollToBottomButton', () => ({
|
||||
AiChatScrollToBottomButton: () => null,
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect',
|
||||
() => ({
|
||||
AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect: () => null,
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ai/components/AgentChatScrollToBottomOnMountLayoutEffect', () => ({
|
||||
AgentChatScrollToBottomOnMountLayoutEffect: () => null,
|
||||
}));
|
||||
|
||||
describe('AiChatTabMessageList', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render nothing with no messages and no preamble', () => {
|
||||
mockUseAtomComponentSelectorValue.mockReturnValue(false);
|
||||
|
||||
const { container } = render(<AiChatTabMessageList />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should render the preamble outside the scroll container with no messages', () => {
|
||||
mockUseAtomComponentSelectorValue.mockReturnValue(false);
|
||||
|
||||
const { getByTestId, queryByTestId } = renderWithPreamble(
|
||||
<div data-testid="preamble" />,
|
||||
);
|
||||
|
||||
expect(getByTestId('preamble')).toBeInTheDocument();
|
||||
expect(queryByTestId('scroll-wrapper')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the preamble inside the message list once messages exist', () => {
|
||||
mockUseAtomComponentSelectorValue.mockReturnValue(true);
|
||||
|
||||
const { getByTestId } = renderWithPreamble(<div data-testid="preamble" />);
|
||||
|
||||
expect(getByTestId('scroll-wrapper')).toContainElement(
|
||||
getByTestId('preamble'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createContext, type ReactNode } from 'react';
|
||||
|
||||
export const AiChatMessageListPreambleContext = createContext<ReactNode>(null);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
|
||||
type UseReturnFromExpandedAiChatParams = {
|
||||
reopenSidePanel: boolean;
|
||||
};
|
||||
|
||||
export const useReturnFromExpandedAiChat = ({
|
||||
reopenSidePanel,
|
||||
}: UseReturnFromExpandedAiChatParams) => {
|
||||
const store = useStore();
|
||||
const navigate = useNavigate();
|
||||
const { defaultHomePagePath } = useDefaultHomePagePath();
|
||||
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
|
||||
const { closeSidePanelMenu } = useSidePanelMenu();
|
||||
|
||||
return useCallback(() => {
|
||||
if (reopenSidePanel) {
|
||||
openAskAiPage({ resetNavigationStack: true });
|
||||
} else {
|
||||
void closeSidePanelMenu();
|
||||
}
|
||||
|
||||
const returnLocation = store.get(aiChatExpandedReturnLocationState.atom);
|
||||
navigate(returnLocation ?? defaultHomePagePath);
|
||||
|
||||
store.set(aiChatExpandedReturnLocationState.atom, null);
|
||||
store.set(shouldOpenAiChatAfterOnboardingState.atom, false);
|
||||
}, [
|
||||
reopenSidePanel,
|
||||
openAskAiPage,
|
||||
closeSidePanelMenu,
|
||||
store,
|
||||
navigate,
|
||||
defaultHomePagePath,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const aiChatExpandedReturnLocationState = createAtomState<string | null>(
|
||||
{
|
||||
key: 'aiChatExpandedReturnLocationState',
|
||||
defaultValue: null,
|
||||
},
|
||||
);
|
||||
@@ -10,6 +10,7 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/
|
||||
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
|
||||
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
|
||||
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
|
||||
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
|
||||
import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection';
|
||||
@@ -157,6 +158,13 @@ export const PageChangeEffect = () => {
|
||||
if (consumedReturnToPath) {
|
||||
clearReturnToPath();
|
||||
}
|
||||
|
||||
if (
|
||||
store.get(shouldOpenAiChatAfterOnboardingState.atom) &&
|
||||
pageChangeEffectNavigateLocation !== AppPath.WorkspaceSetup
|
||||
) {
|
||||
store.set(shouldOpenAiChatAfterOnboardingState.atom, false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
navigate,
|
||||
@@ -166,6 +174,7 @@ export const PageChangeEffect = () => {
|
||||
saveReturnToPath,
|
||||
getReturnToPath,
|
||||
clearReturnToPath,
|
||||
store,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -111,6 +111,12 @@ const StandalonePageLayoutPage = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const WorkspaceSetup = lazyWithPreload(() =>
|
||||
import('~/pages/onboarding/WorkspaceSetup').then((module) => ({
|
||||
default: module.WorkspaceSetup,
|
||||
})),
|
||||
);
|
||||
|
||||
const NotFound = lazy(() =>
|
||||
import('~/pages/not-found/NotFound').then((module) => ({
|
||||
default: module.NotFound,
|
||||
@@ -124,6 +130,7 @@ const preloadOnboardingPages = () => {
|
||||
void InstallApps.preload();
|
||||
void InviteTeam.preload();
|
||||
void ChooseYourPlan.preload();
|
||||
void WorkspaceSetup.preload();
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -140,6 +147,14 @@ const createWorkspaceAppRouter = (
|
||||
>
|
||||
<Route element={<MinimalMetadataGate />}>
|
||||
<Route element={<DefaultLayout />}>
|
||||
<Route
|
||||
path={AppPath.WorkspaceSetup}
|
||||
element={
|
||||
<LazyRoute fallback={null}>
|
||||
<WorkspaceSetup />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route element={<MainAppLayoutWithSidePanel />}>
|
||||
<Route
|
||||
path={indexAppPath.getIndexAppPath()}
|
||||
|
||||
@@ -37,6 +37,10 @@ describe('isValidReturnToPath', () => {
|
||||
expect(isValidReturnToPath('/reset-password')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for the workspace setup path', () => {
|
||||
expect(isValidReturnToPath('/workspace-setup')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for valid application paths', () => {
|
||||
expect(isValidReturnToPath('/objects/people')).toBe(true);
|
||||
expect(isValidReturnToPath('/settings/accounts')).toBe(true);
|
||||
|
||||
+13
@@ -314,6 +314,19 @@ describe('useDefaultHomePagePath', () => {
|
||||
withNavigationMenuItemsLoaded: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.defaultHomePagePath).toEqual(AppPath.Index);
|
||||
});
|
||||
});
|
||||
it('should defer to AppPath.Index when object metadata is loaded but empty while navigation menu items are not loaded yet', async () => {
|
||||
const { result } = renderHooks({
|
||||
withCurrentUser: true,
|
||||
withExistingView: false,
|
||||
objectMetadataItems: [],
|
||||
navigationMenuItems: [],
|
||||
withNavigationMenuItemsLoaded: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.defaultHomePagePath).toEqual(AppPath.Index);
|
||||
});
|
||||
|
||||
@@ -90,24 +90,15 @@ export const useDefaultHomePagePath = () => {
|
||||
return AppPath.SignInUp;
|
||||
}
|
||||
|
||||
if (isEmpty(readableNonSystemObjectMetadataItems)) {
|
||||
// Object metadata may legitimately be empty for a user with no readable
|
||||
// objects, in which case /settings/profile is the intended fallback.
|
||||
// It can also be transiently empty during the post-login window before
|
||||
// workspace metadata has finished loading. Defer to AppPath.Index in
|
||||
// that case so the user isn't stranded on /settings/profile once
|
||||
// metadata becomes available.
|
||||
if (!areObjectMetadataItemsLoaded) {
|
||||
return AppPath.Index;
|
||||
}
|
||||
return getSettingsPath(SettingsPath.ProfilePage);
|
||||
// Both stores are transiently empty during the post-login window;
|
||||
// deciding the redirect before they are loaded could strand users on a
|
||||
// wrong fallback (/settings/profile or the alphabetically-first object).
|
||||
if (!areObjectMetadataItemsLoaded || !areNavigationMenuItemsLoaded) {
|
||||
return AppPath.Index;
|
||||
}
|
||||
|
||||
// The navigation menu drives the redirect and loads after the minimal-
|
||||
// metadata fast path. Wait for it instead of falling back to the
|
||||
// alphabetically-first object during the post-login window.
|
||||
if (!areNavigationMenuItemsLoaded) {
|
||||
return AppPath.Index;
|
||||
if (isEmpty(readableNonSystemObjectMetadataItems)) {
|
||||
return getSettingsPath(SettingsPath.ProfilePage);
|
||||
}
|
||||
|
||||
if (isDefined(firstNavigationMenuItemLink)) {
|
||||
|
||||
+45
-13
@@ -1,24 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const WELCOME_HOLD_DURATION_MS = 2900;
|
||||
import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId';
|
||||
import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState';
|
||||
import { welcomeTitleFlightState } from '@/onboarding/states/welcomeTitleFlightState';
|
||||
import { measureWelcomeTitleFlight } from '@/onboarding/utils/measureWelcomeTitleFlight';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
type WelcomeAnimationAutoLeaveEffectProps = {
|
||||
onAutoLeave: () => void;
|
||||
};
|
||||
const WELCOME_HOLD_MIN_DURATION_MS = 2900;
|
||||
const WELCOME_HOLD_MAX_DURATION_MS = 5000;
|
||||
|
||||
export const WelcomeAnimationAutoLeaveEffect = () => {
|
||||
const setIsWelcomeAnimationLeaving = useSetAtomState(
|
||||
isWelcomeAnimationLeavingState,
|
||||
);
|
||||
const setWelcomeTitleFlight = useSetAtomState(welcomeTitleFlightState);
|
||||
|
||||
export const WelcomeAnimationAutoLeaveEffect = ({
|
||||
onAutoLeave,
|
||||
}: WelcomeAnimationAutoLeaveEffectProps) => {
|
||||
useEffect(() => {
|
||||
const autoLeaveTimeoutId = setTimeout(
|
||||
onAutoLeave,
|
||||
WELCOME_HOLD_DURATION_MS,
|
||||
);
|
||||
let hasFired = false;
|
||||
|
||||
const leave = () => {
|
||||
if (hasFired) {
|
||||
return;
|
||||
}
|
||||
hasFired = true;
|
||||
|
||||
setWelcomeTitleFlight(measureWelcomeTitleFlight());
|
||||
setIsWelcomeAnimationLeaving(true);
|
||||
};
|
||||
|
||||
const capTimeoutId = setTimeout(leave, WELCOME_HOLD_MAX_DURATION_MS);
|
||||
const minHoldTimeoutId = setTimeout(() => {
|
||||
const hasHandoffTarget = isDefined(
|
||||
document.getElementById(WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID),
|
||||
);
|
||||
if (!hasHandoffTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(document.fonts)) {
|
||||
void document.fonts.ready.then(leave);
|
||||
} else {
|
||||
leave();
|
||||
}
|
||||
}, WELCOME_HOLD_MIN_DURATION_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(autoLeaveTimeoutId);
|
||||
clearTimeout(capTimeoutId);
|
||||
clearTimeout(minHoldTimeoutId);
|
||||
hasFired = true;
|
||||
};
|
||||
}, [onAutoLeave]);
|
||||
}, [setIsWelcomeAnimationLeaving, setWelcomeTitleFlight]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { welcomeTitleFlightState } from '@/onboarding/states/welcomeTitleFlightState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
const FORCED_TEARDOWN_DELAY_IN_MS = 1100;
|
||||
|
||||
export const WelcomeAnimationForcedTeardownEffect = () => {
|
||||
const setIsWelcomeAnimationVisible = useSetAtomState(
|
||||
isWelcomeAnimationVisibleState,
|
||||
);
|
||||
const setIsWelcomeAnimationLeaving = useSetAtomState(
|
||||
isWelcomeAnimationLeavingState,
|
||||
);
|
||||
const setWelcomeTitleFlight = useSetAtomState(welcomeTitleFlightState);
|
||||
|
||||
useEffect(() => {
|
||||
const forcedTeardownTimeoutId = setTimeout(() => {
|
||||
setIsWelcomeAnimationVisible(false);
|
||||
setIsWelcomeAnimationLeaving(false);
|
||||
setWelcomeTitleFlight(null);
|
||||
}, FORCED_TEARDOWN_DELAY_IN_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(forcedTeardownTimeoutId);
|
||||
};
|
||||
}, [
|
||||
setIsWelcomeAnimationVisible,
|
||||
setIsWelcomeAnimationLeaving,
|
||||
setWelcomeTitleFlight,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+169
-46
@@ -1,22 +1,22 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
type AnimationEvent,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { WelcomeAnimationAutoLeaveEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect';
|
||||
import { WelcomeAnimationForcedTeardownEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeAnimationForcedTeardownEffect';
|
||||
import { WelcomeHalftoneCanvas } from '@/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvas';
|
||||
import { WelcomePersonChip } from '@/onboarding/components/WelcomeOverlay/WelcomePersonChip';
|
||||
import { WELCOME_TITLE_MESSAGE } from '@/onboarding/constants/WelcomeTitleMessage';
|
||||
import { WELCOME_TITLE_SOURCE_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleSourceElementId';
|
||||
import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { welcomeTitleFlightState } from '@/onboarding/states/welcomeTitleFlightState';
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
const WELCOME_TITLE_WORDS = ['Welcome', 'to', 'your', 'workspace'];
|
||||
|
||||
const StyledOverlay = styled.div`
|
||||
align-items: center;
|
||||
@@ -25,6 +25,7 @@ const StyledOverlay = styled.div`
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
@@ -66,8 +67,6 @@ const StyledCanvasLayer = styled.div`
|
||||
const StyledTitle = styled.div`
|
||||
align-items: center;
|
||||
animation: welcomeTitleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) 0.8s both;
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border-radius: ${themeCssVariables.border.radius.pill};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -106,11 +105,32 @@ const StyledTitle = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
&.is-flying {
|
||||
animation:
|
||||
welcomeTitleFlight 0.62s cubic-bezier(0.16, 1, 0.3, 1) forwards,
|
||||
welcomeTitleFlightOut 0.14s ease-out 0.66s forwards;
|
||||
transform-origin: var(--welcome-flight-origin-x) center;
|
||||
}
|
||||
|
||||
@keyframes welcomeTitleFlight {
|
||||
to {
|
||||
transform: translate(var(--welcome-flight-x), var(--welcome-flight-y))
|
||||
scale(var(--welcome-flight-scale));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes welcomeTitleFlightOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
|
||||
&.is-leaving {
|
||||
animation-name: welcomeTitleFadeOut;
|
||||
&.is-leaving,
|
||||
&.is-flying {
|
||||
animation: welcomeTitleFadeOut 0.34s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes welcomeTitleFadeOut {
|
||||
@@ -121,10 +141,89 @@ const StyledTitle = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTitleSurface = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border-radius: ${themeCssVariables.border.radius.pill};
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
|
||||
.is-flying & {
|
||||
animation: welcomeTitleSurfaceOut 0.24s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes welcomeTitleSurfaceOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTitleBoldRun = styled.span`
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
white-space: nowrap;
|
||||
|
||||
.is-flying & {
|
||||
animation: welcomeTitleBoldRunOut 0.38s ease 0.12s forwards;
|
||||
}
|
||||
|
||||
@keyframes welcomeTitleBoldRunOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.is-flying & {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTitleRegularRun = styled.span`
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
font-weight: ${themeCssVariables.font.weight.regular};
|
||||
gap: calc(${themeCssVariables.spacing[2]} * 2);
|
||||
left: ${themeCssVariables.spacing[8]};
|
||||
line-height: 1.4em;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
white-space: nowrap;
|
||||
|
||||
.is-flying & {
|
||||
animation: welcomeTitleRegularRunIn 0.38s ease 0.12s forwards;
|
||||
}
|
||||
|
||||
@keyframes welcomeTitleRegularRunIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.is-flying & {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTargetScaleChip = styled.span`
|
||||
display: inline-flex;
|
||||
font-size: 0.5em;
|
||||
line-height: 1.4em;
|
||||
transform: scale(2);
|
||||
transform-origin: left center;
|
||||
`;
|
||||
|
||||
const StyledWord = styled.span`
|
||||
animation: welcomeWordIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: calc(1.1s + var(--word-index) * 0.07s);
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
|
||||
@keyframes welcomeWordIn {
|
||||
from {
|
||||
@@ -153,57 +252,81 @@ const StyledWord = styled.span`
|
||||
`;
|
||||
|
||||
export const WelcomeOverlay = () => {
|
||||
const { t } = useLingui();
|
||||
const isWelcomeAnimationVisible = useAtomStateValue(
|
||||
isWelcomeAnimationVisibleState,
|
||||
);
|
||||
const setIsWelcomeAnimationVisible = useSetAtomState(
|
||||
isWelcomeAnimationVisibleState,
|
||||
const isWelcomeAnimationLeaving = useAtomStateValue(
|
||||
isWelcomeAnimationLeavingState,
|
||||
);
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
|
||||
const startLeaving = useCallback(() => setIsLeaving(true), []);
|
||||
const welcomeTitleFlight = useAtomStateValue(welcomeTitleFlightState);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!isWelcomeAnimationVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropAnimationEnd = (
|
||||
event: AnimationEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.target === event.currentTarget && isLeaving) {
|
||||
setIsWelcomeAnimationVisible(false);
|
||||
setIsLeaving(false);
|
||||
}
|
||||
};
|
||||
const activeWelcomeTitleFlight = isMobile ? null : welcomeTitleFlight;
|
||||
|
||||
const leavingClassName = isLeaving ? 'is-leaving' : undefined;
|
||||
const welcomeTitle = t(WELCOME_TITLE_MESSAGE);
|
||||
const welcomeTitleWords = welcomeTitle.split(' ');
|
||||
|
||||
const leavingClassName = isWelcomeAnimationLeaving ? 'is-leaving' : undefined;
|
||||
const titleClassName = isWelcomeAnimationLeaving
|
||||
? isDefined(activeWelcomeTitleFlight)
|
||||
? 'is-flying'
|
||||
: 'is-leaving'
|
||||
: undefined;
|
||||
|
||||
const titleStyle = isDefined(activeWelcomeTitleFlight)
|
||||
? ({
|
||||
'--welcome-flight-x': `${activeWelcomeTitleFlight.translateXInPx}px`,
|
||||
'--welcome-flight-y': `${activeWelcomeTitleFlight.translateYInPx}px`,
|
||||
'--welcome-flight-scale': activeWelcomeTitleFlight.scale,
|
||||
'--welcome-flight-origin-x': `${activeWelcomeTitleFlight.transformOriginXInPx}px`,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return createPortal(
|
||||
<StyledOverlay>
|
||||
<WelcomeAnimationAutoLeaveEffect onAutoLeave={startLeaving} />
|
||||
<StyledBackdrop
|
||||
className={leavingClassName}
|
||||
onAnimationEnd={handleBackdropAnimationEnd}
|
||||
/>
|
||||
{isWelcomeAnimationLeaving ? (
|
||||
<WelcomeAnimationForcedTeardownEffect />
|
||||
) : (
|
||||
<WelcomeAnimationAutoLeaveEffect />
|
||||
)}
|
||||
<StyledBackdrop className={leavingClassName} />
|
||||
<StyledCanvasLayer>
|
||||
<WelcomeHalftoneCanvas isLeaving={isLeaving} />
|
||||
<WelcomeHalftoneCanvas isLeaving={isWelcomeAnimationLeaving} />
|
||||
</StyledCanvasLayer>
|
||||
<StyledTitle className={leavingClassName}>
|
||||
{WELCOME_TITLE_WORDS.map((word, index) => (
|
||||
<StyledTitle
|
||||
id={WELCOME_TITLE_SOURCE_ELEMENT_ID}
|
||||
className={titleClassName}
|
||||
style={titleStyle}
|
||||
>
|
||||
<StyledTitleSurface />
|
||||
<StyledTitleBoldRun>
|
||||
{welcomeTitleWords.map((word, index) => (
|
||||
<StyledWord
|
||||
key={`${word}-${index}`}
|
||||
style={{ '--word-index': index } as CSSProperties}
|
||||
>
|
||||
{word}
|
||||
</StyledWord>
|
||||
))}
|
||||
<StyledWord
|
||||
key={word}
|
||||
style={{ '--word-index': index } as CSSProperties}
|
||||
style={
|
||||
{ '--word-index': welcomeTitleWords.length } as CSSProperties
|
||||
}
|
||||
>
|
||||
{word}
|
||||
<WelcomePersonChip />
|
||||
</StyledWord>
|
||||
))}
|
||||
<StyledWord
|
||||
style={
|
||||
{ '--word-index': WELCOME_TITLE_WORDS.length } as CSSProperties
|
||||
}
|
||||
>
|
||||
<WelcomePersonChip />
|
||||
</StyledWord>
|
||||
</StyledTitleBoldRun>
|
||||
<StyledTitleRegularRun aria-hidden>
|
||||
{welcomeTitle}
|
||||
<StyledTargetScaleChip>
|
||||
<WelcomePersonChip avatarSize="xs" sizeVariant="compact" />
|
||||
</StyledTargetScaleChip>
|
||||
</StyledTitleRegularRun>
|
||||
</StyledTitle>
|
||||
</StyledOverlay>,
|
||||
document.body,
|
||||
|
||||
+27
-8
@@ -1,18 +1,29 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { Avatar } from 'twenty-ui/data-display';
|
||||
import { Avatar, type AvatarSize } from 'twenty-ui/data-display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
|
||||
|
||||
const StyledChip = styled.div`
|
||||
type WelcomePersonChipSizeVariant = 'default' | 'compact';
|
||||
|
||||
const StyledChip = styled.div<{ sizeVariant: WelcomePersonChipSizeVariant }>`
|
||||
align-items: center;
|
||||
background: ${themeCssVariables.background.transparent.light};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
border-radius: ${({ sizeVariant }) =>
|
||||
sizeVariant === 'compact'
|
||||
? themeCssVariables.border.radius.sm
|
||||
: themeCssVariables.border.radius.md};
|
||||
display: inline-flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
|
||||
gap: ${({ sizeVariant }) =>
|
||||
sizeVariant === 'compact'
|
||||
? themeCssVariables.spacing[1]
|
||||
: themeCssVariables.spacing[2]};
|
||||
padding: ${({ sizeVariant }) =>
|
||||
sizeVariant === 'compact'
|
||||
? `${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}`
|
||||
: `${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}`};
|
||||
`;
|
||||
|
||||
const StyledPersonName = styled.span`
|
||||
@@ -24,17 +35,25 @@ const StyledPersonName = styled.span`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const WelcomePersonChip = () => {
|
||||
type WelcomePersonChipProps = {
|
||||
avatarSize?: AvatarSize;
|
||||
sizeVariant?: WelcomePersonChipSizeVariant;
|
||||
};
|
||||
|
||||
export const WelcomePersonChip = ({
|
||||
avatarSize = 'lg',
|
||||
sizeVariant = 'default',
|
||||
}: WelcomePersonChipProps) => {
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const firstName = currentWorkspaceMember?.name?.firstName ?? '';
|
||||
const lastName = currentWorkspaceMember?.name?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
return (
|
||||
<StyledChip>
|
||||
<StyledChip sizeVariant={sizeVariant}>
|
||||
<Avatar
|
||||
type="rounded"
|
||||
size="lg"
|
||||
size={avatarSize}
|
||||
placeholder={fullName}
|
||||
placeholderColorSeed={currentWorkspaceMember?.id}
|
||||
avatarUrl={getAbsoluteImageUrl(currentWorkspaceMember?.avatarUrl)}
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { WelcomeAnimationAutoLeaveEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect';
|
||||
import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId';
|
||||
import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
|
||||
);
|
||||
|
||||
const addHandoffTarget = () => {
|
||||
const target = document.createElement('div');
|
||||
target.id = WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID;
|
||||
document.body.appendChild(target);
|
||||
};
|
||||
|
||||
const isLeaving = () => jotaiStore.get(isWelcomeAnimationLeavingState.atom);
|
||||
|
||||
describe('WelcomeAnimationAutoLeaveEffect', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
jest.useFakeTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should leave at the minimum hold once the handoff target exists', () => {
|
||||
addHandoffTarget();
|
||||
render(<WelcomeAnimationAutoLeaveEffect />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2899);
|
||||
});
|
||||
expect(isLeaving()).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
expect(isLeaving()).toBe(true);
|
||||
});
|
||||
|
||||
it('should hold for the minimum until the target appears', () => {
|
||||
render(<WelcomeAnimationAutoLeaveEffect />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2900);
|
||||
});
|
||||
expect(isLeaving()).toBe(false);
|
||||
});
|
||||
|
||||
it('should leave at the cap when the target never appears', () => {
|
||||
render(<WelcomeAnimationAutoLeaveEffect />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4999);
|
||||
});
|
||||
expect(isLeaving()).toBe(false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
expect(isLeaving()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not fire again after the cap once it has already left', () => {
|
||||
addHandoffTarget();
|
||||
render(<WelcomeAnimationAutoLeaveEffect />, { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2900);
|
||||
});
|
||||
jotaiStore.set(isWelcomeAnimationLeavingState.atom, false);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(isLeaving()).toBe(false);
|
||||
});
|
||||
});
|
||||
+13
-2
@@ -1,6 +1,9 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { createElement } from 'react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { WelcomeOverlay } from '@/onboarding/components/WelcomeOverlay/WelcomeOverlay';
|
||||
@@ -10,10 +13,18 @@ import {
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
import { messages } from '~/locales/generated/en';
|
||||
import { mockedWorkspaceMemberData } from '~/testing/mock-data/users';
|
||||
|
||||
i18n.load({ [SOURCE_LOCALE]: messages });
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(JotaiProvider, { store: jotaiStore }, children);
|
||||
createElement(
|
||||
JotaiProvider,
|
||||
{ store: jotaiStore },
|
||||
createElement(I18nProvider, { i18n }, children),
|
||||
);
|
||||
|
||||
describe('WelcomeOverlay', () => {
|
||||
beforeEach(() => {
|
||||
@@ -37,6 +48,6 @@ describe('WelcomeOverlay', () => {
|
||||
|
||||
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
||||
expect(screen.getByText('workspace')).toBeInTheDocument();
|
||||
expect(screen.getByText('Marie Curie')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Marie Curie').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { WelcomePersonChip } from '@/onboarding/components/WelcomeOverlay/WelcomePersonChip';
|
||||
import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId';
|
||||
import { WELCOME_TITLE_MESSAGE } from '@/onboarding/constants/WelcomeTitleMessage';
|
||||
import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
const StyledPreamble = styled.div<{
|
||||
isHiddenBehindOverlay: boolean;
|
||||
}>`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-weight: ${themeCssVariables.font.weight.regular};
|
||||
line-height: 1.4em;
|
||||
opacity: ${({ isHiddenBehindOverlay }) => (isHiddenBehindOverlay ? 0 : 1)};
|
||||
overflow-wrap: break-word;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSingleLineHandoffRun = styled.span`
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-revealing-after-flight {
|
||||
animation: workspaceSetupHandoffRunIn 0.12s ease-out 0.62s both;
|
||||
}
|
||||
|
||||
@keyframes workspaceSetupHandoffRunIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&.is-revealing-after-flight {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledContinuation = styled.span`
|
||||
&.is-revealing-after-flight {
|
||||
animation: workspaceSetupContinuationIn 0.2s ease-out 0.8s both;
|
||||
}
|
||||
|
||||
@keyframes workspaceSetupContinuationIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&.is-revealing-after-flight {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const WorkspaceSetupChatPreamble = () => {
|
||||
const { t } = useLingui();
|
||||
const isWelcomeAnimationVisible = useAtomStateValue(
|
||||
isWelcomeAnimationVisibleState,
|
||||
);
|
||||
const isWelcomeAnimationLeaving = useAtomStateValue(
|
||||
isWelcomeAnimationLeavingState,
|
||||
);
|
||||
|
||||
const revealClassName = isWelcomeAnimationLeaving
|
||||
? 'is-revealing-after-flight'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StyledPreamble
|
||||
isHiddenBehindOverlay={
|
||||
isWelcomeAnimationVisible && !isWelcomeAnimationLeaving
|
||||
}
|
||||
>
|
||||
<StyledSingleLineHandoffRun
|
||||
id={WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID}
|
||||
className={revealClassName}
|
||||
>
|
||||
{t(WELCOME_TITLE_MESSAGE)}
|
||||
<WelcomePersonChip avatarSize="xs" sizeVariant="compact" />
|
||||
</StyledSingleLineHandoffRun>{' '}
|
||||
<StyledContinuation className={revealClassName}>
|
||||
{t`It natively comes with 7 standard objects.`}
|
||||
</StyledContinuation>
|
||||
</StyledPreamble>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { AiChatCloseButton } from '@/ai/components/AiChatCloseButton';
|
||||
import { AiChatCollapseButton } from '@/ai/components/AiChatCollapseButton';
|
||||
import { SIDE_PANEL_TOP_BAR_HEIGHT } from '@/side-panel/constants/SidePanelTopBarHeight';
|
||||
|
||||
const StyledHeader = styled.header`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: ${themeCssVariables.spacing['0.5']};
|
||||
height: ${SIDE_PANEL_TOP_BAR_HEIGHT}px;
|
||||
padding: 0 ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledHeaderTitle = styled.div`
|
||||
align-items: center;
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
height: ${themeCssVariables.spacing[6]};
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 ${themeCssVariables.spacing[1]};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
type WorkspaceSetupHeaderProps = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const WorkspaceSetupHeader = ({ title }: WorkspaceSetupHeaderProps) => (
|
||||
<StyledHeader>
|
||||
<StyledHeaderTitle>{title}</StyledHeaderTitle>
|
||||
<AiChatCollapseButton />
|
||||
<AiChatCloseButton />
|
||||
</StyledHeader>
|
||||
);
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble';
|
||||
import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { messages } from '~/locales/generated/en';
|
||||
|
||||
jest.mock('@/onboarding/components/WelcomeOverlay/WelcomePersonChip', () => ({
|
||||
WelcomePersonChip: () => <span data-testid="person-chip" />,
|
||||
}));
|
||||
|
||||
i18n.load({ [SOURCE_LOCALE]: messages });
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<I18nProvider i18n={i18n}>{children}</I18nProvider>
|
||||
</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('WorkspaceSetupChatPreamble', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
});
|
||||
|
||||
it('should carry the handoff target id on a single non-wrapping run', () => {
|
||||
const { container } = render(<WorkspaceSetupChatPreamble />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const handoffRun = container.querySelector(
|
||||
`#${WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID}`,
|
||||
);
|
||||
|
||||
expect(handoffRun).toBeInTheDocument();
|
||||
expect(handoffRun?.textContent).toContain('Welcome to your workspace');
|
||||
});
|
||||
});
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID =
|
||||
'welcome-title-handoff-target';
|
||||
@@ -0,0 +1,3 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const WELCOME_TITLE_MESSAGE = msg`Welcome to your workspace`;
|
||||
@@ -0,0 +1 @@
|
||||
export const WELCOME_TITLE_SOURCE_ELEMENT_ID = 'welcome-title-source';
|
||||
+113
-49
@@ -8,6 +8,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
mockCurrentWorkspace,
|
||||
mockedUserData,
|
||||
@@ -29,6 +30,7 @@ type RenderHooksOptions = {
|
||||
withSubscription?: boolean;
|
||||
isBillingEnabled?: boolean;
|
||||
withOneWorkspaceMember?: boolean;
|
||||
isOnboardingAiChatEnabled?: boolean;
|
||||
};
|
||||
|
||||
const renderHooks = (
|
||||
@@ -37,6 +39,7 @@ const renderHooks = (
|
||||
withSubscription = false,
|
||||
isBillingEnabled = false,
|
||||
withOneWorkspaceMember = true,
|
||||
isOnboardingAiChatEnabled = false,
|
||||
}: RenderHooksOptions = {},
|
||||
) => {
|
||||
const { result } = renderHook(
|
||||
@@ -51,6 +54,9 @@ const renderHooks = (
|
||||
const isWelcomeAnimationVisible = useAtomStateValue(
|
||||
isWelcomeAnimationVisibleState,
|
||||
);
|
||||
const shouldOpenAiChatAfterOnboarding = useAtomStateValue(
|
||||
shouldOpenAiChatAfterOnboardingState,
|
||||
);
|
||||
return {
|
||||
currentUser,
|
||||
setCurrentUser,
|
||||
@@ -59,6 +65,7 @@ const renderHooks = (
|
||||
setBilling,
|
||||
setNextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -74,6 +81,12 @@ const renderHooks = (
|
||||
? mockCurrentWorkspace.billingSubscriptions
|
||||
: [],
|
||||
workspaceMembersCount: withOneWorkspaceMember ? 1 : 2,
|
||||
featureFlags: [
|
||||
{
|
||||
key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
value: isOnboardingAiChatEnabled,
|
||||
},
|
||||
],
|
||||
});
|
||||
result.current.setBilling({
|
||||
__typename: 'Billing',
|
||||
@@ -87,114 +100,165 @@ const renderHooks = (
|
||||
return {
|
||||
nextOnboardingStatus: result.current.currentUser?.onboardingStatus,
|
||||
isWelcomeAnimationVisible: result.current.isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding:
|
||||
result.current.shouldOpenAiChatAfterOnboarding,
|
||||
};
|
||||
};
|
||||
|
||||
describe('useSetNextOnboardingStatus', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resetJotaiStore();
|
||||
});
|
||||
|
||||
it('should sync emails right after workspace activation', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.WORKSPACE_ACTIVATION,
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.WORKSPACE_ACTIVATION);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should install apps after syncing emails', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.SYNC_EMAIL,
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.SYNC_EMAIL);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.APPS_INSTALLATION);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should create profile after installing apps', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.APPS_INSTALLATION,
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.APPS_INSTALLATION);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should invite the team right after profile creation', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.PROFILE_CREATION);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.INVITE_TEAM);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should complete after profile creation when more than 1 workspaceMember exist', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
{
|
||||
withOneWorkspaceMember: false,
|
||||
},
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.PROFILE_CREATION, {
|
||||
withOneWorkspaceMember: false,
|
||||
});
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
expect(isWelcomeAnimationVisible).toBe(true);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should require a plan after profile creation when billing is enabled and the workspace has no subscription', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.PROFILE_CREATION,
|
||||
{
|
||||
withOneWorkspaceMember: false,
|
||||
isBillingEnabled: true,
|
||||
withSubscription: false,
|
||||
},
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.PROFILE_CREATION, {
|
||||
withOneWorkspaceMember: false,
|
||||
isBillingEnabled: true,
|
||||
withSubscription: false,
|
||||
});
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should complete after inviting the team when billing is disabled', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.INVITE_TEAM,
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.INVITE_TEAM);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
expect(isWelcomeAnimationVisible).toBe(true);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should complete after inviting the team when the workspace already has a subscription', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.INVITE_TEAM,
|
||||
{
|
||||
isBillingEnabled: true,
|
||||
withSubscription: true,
|
||||
},
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.INVITE_TEAM, {
|
||||
isBillingEnabled: true,
|
||||
withSubscription: true,
|
||||
});
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
expect(isWelcomeAnimationVisible).toBe(true);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should require a plan after inviting the team when billing is enabled and the workspace has no subscription', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.INVITE_TEAM,
|
||||
{
|
||||
isBillingEnabled: true,
|
||||
withSubscription: false,
|
||||
},
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.INVITE_TEAM, {
|
||||
isBillingEnabled: true,
|
||||
withSubscription: false,
|
||||
});
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should not show the welcome animation when the onboarding was already completed', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks(
|
||||
OnboardingStatus.COMPLETED,
|
||||
);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(OnboardingStatus.COMPLETED);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should not show the welcome animation when the onboarding status is unknown', () => {
|
||||
const { nextOnboardingStatus, isWelcomeAnimationVisible } =
|
||||
renderHooks(null);
|
||||
const {
|
||||
nextOnboardingStatus,
|
||||
isWelcomeAnimationVisible,
|
||||
shouldOpenAiChatAfterOnboarding,
|
||||
} = renderHooks(null);
|
||||
expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED);
|
||||
expect(isWelcomeAnimationVisible).toBe(false);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
it('should open the ai chat after onboarding when the feature flag is enabled', () => {
|
||||
const { isWelcomeAnimationVisible, shouldOpenAiChatAfterOnboarding } =
|
||||
renderHooks(OnboardingStatus.INVITE_TEAM, {
|
||||
isOnboardingAiChatEnabled: true,
|
||||
});
|
||||
expect(isWelcomeAnimationVisible).toBe(true);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
it('should still show the welcome animation when the ai chat feature flag is disabled', () => {
|
||||
const { isWelcomeAnimationVisible, shouldOpenAiChatAfterOnboarding } =
|
||||
renderHooks(OnboardingStatus.INVITE_TEAM, {
|
||||
isOnboardingAiChatEnabled: false,
|
||||
});
|
||||
expect(isWelcomeAnimationVisible).toBe(true);
|
||||
expect(shouldOpenAiChatAfterOnboarding).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
} from '@/auth/states/currentWorkspaceState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { getHasJustCompletedOnboarding } from '@/onboarding/utils/getHasJustCompletedOnboarding';
|
||||
import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
type GetNextOnboardingStatusArgs = {
|
||||
@@ -68,6 +70,9 @@ export const useSetNextOnboardingStatus = () => {
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const isBillingEnabled = billing?.isBillingEnabled ?? false;
|
||||
const isOnboardingAiChatEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
);
|
||||
|
||||
return useCallback(() => {
|
||||
const nextOnboardingStatus = getNextOnboardingStatus({
|
||||
@@ -92,6 +97,16 @@ export const useSetNextOnboardingStatus = () => {
|
||||
})
|
||||
) {
|
||||
store.set(isWelcomeAnimationVisibleState.atom, true);
|
||||
store.set(
|
||||
shouldOpenAiChatAfterOnboardingState.atom,
|
||||
isOnboardingAiChatEnabled,
|
||||
);
|
||||
}
|
||||
}, [currentUser, currentWorkspace, isBillingEnabled, store]);
|
||||
}, [
|
||||
currentUser,
|
||||
currentWorkspace,
|
||||
isBillingEnabled,
|
||||
isOnboardingAiChatEnabled,
|
||||
store,
|
||||
]);
|
||||
};
|
||||
|
||||
+13
-1
@@ -1,8 +1,11 @@
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { checkIfFeatureFlagIsEnabledOnWorkspace } from '@/workspace/utils/checkIfFeatureFlagIsEnabledOnWorkspace';
|
||||
import { isOnboardingCheckoutPendingState } from '@/onboarding/states/isOnboardingCheckoutPendingState';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useStore } from 'jotai';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useShowWelcomeAnimationAfterOnboardingCheckout = () => {
|
||||
const store = useStore();
|
||||
@@ -18,7 +21,16 @@ export const useShowWelcomeAnimationAfterOnboardingCheckout = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOnboardingAiChatEnabled = checkIfFeatureFlagIsEnabledOnWorkspace(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
store.get(currentWorkspaceState.atom),
|
||||
);
|
||||
|
||||
store.set(isOnboardingCheckoutPendingState.atom, false);
|
||||
store.set(isWelcomeAnimationVisibleState.atom, true);
|
||||
store.set(
|
||||
shouldOpenAiChatAfterOnboardingState.atom,
|
||||
isOnboardingAiChatEnabled,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isWelcomeAnimationLeavingState = createAtomState<boolean>({
|
||||
key: 'isWelcomeAnimationLeavingState',
|
||||
defaultValue: false,
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const shouldOpenAiChatAfterOnboardingState = createAtomState<boolean>({
|
||||
key: 'shouldOpenAiChatAfterOnboardingState',
|
||||
defaultValue: false,
|
||||
useSessionStorage: true,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type WelcomeTitleFlight } from '@/onboarding/types/WelcomeTitleFlight';
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const welcomeTitleFlightState =
|
||||
createAtomState<WelcomeTitleFlight | null>({
|
||||
key: 'welcomeTitleFlightState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export type WelcomeTitleFlight = {
|
||||
translateXInPx: number;
|
||||
translateYInPx: number;
|
||||
scale: number;
|
||||
transformOriginXInPx: number;
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { getWelcomeTitleFlight } from '@/onboarding/utils/getWelcomeTitleFlight';
|
||||
|
||||
const buildRect = (
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): DOMRect =>
|
||||
({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
x: left,
|
||||
y: top,
|
||||
}) as DOMRect;
|
||||
|
||||
describe('getWelcomeTitleFlight', () => {
|
||||
it('should translate from where the text starts, not from the pill edge', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(100, 200, 400, 80),
|
||||
sourcePaddingLeftInPx: 32,
|
||||
sourceFontSizeInPx: 26,
|
||||
targetRect: buildRect(24, 40, 300, 24),
|
||||
targetFontSizeInPx: 26,
|
||||
});
|
||||
|
||||
expect(flight.translateXInPx).toBe(24 - (100 + 32));
|
||||
});
|
||||
|
||||
it('should align the vertical centers of source and target', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(0, 200, 400, 80),
|
||||
sourcePaddingLeftInPx: 0,
|
||||
sourceFontSizeInPx: 26,
|
||||
targetRect: buildRect(0, 40, 300, 24),
|
||||
targetFontSizeInPx: 26,
|
||||
});
|
||||
|
||||
expect(flight.translateYInPx).toBe(52 - 240);
|
||||
});
|
||||
|
||||
it('should scale by the font size ratio', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(0, 0, 400, 80),
|
||||
sourcePaddingLeftInPx: 0,
|
||||
sourceFontSizeInPx: 26,
|
||||
targetRect: buildRect(0, 0, 300, 24),
|
||||
targetFontSizeInPx: 13,
|
||||
});
|
||||
|
||||
expect(flight.scale).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should anchor the transform origin on the text start', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(0, 0, 400, 80),
|
||||
sourcePaddingLeftInPx: 32,
|
||||
sourceFontSizeInPx: 26,
|
||||
targetRect: buildRect(0, 0, 300, 24),
|
||||
targetFontSizeInPx: 26,
|
||||
});
|
||||
|
||||
expect(flight.transformOriginXInPx).toBe(32);
|
||||
});
|
||||
|
||||
it('should fall back to a neutral scale when the source font size is unreadable', () => {
|
||||
const flight = getWelcomeTitleFlight({
|
||||
sourceRect: buildRect(0, 0, 400, 80),
|
||||
sourcePaddingLeftInPx: 0,
|
||||
sourceFontSizeInPx: 0,
|
||||
targetRect: buildRect(0, 0, 300, 24),
|
||||
targetFontSizeInPx: 16,
|
||||
});
|
||||
|
||||
expect(flight.scale).toBe(1);
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId';
|
||||
import { WELCOME_TITLE_SOURCE_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleSourceElementId';
|
||||
import { measureWelcomeTitleFlight } from '@/onboarding/utils/measureWelcomeTitleFlight';
|
||||
|
||||
const buildSourceElement = () => {
|
||||
const source = document.createElement('div');
|
||||
source.id = WELCOME_TITLE_SOURCE_ELEMENT_ID;
|
||||
source.getBoundingClientRect = () =>
|
||||
({ left: 100, top: 200, width: 400, height: 80 }) as DOMRect;
|
||||
document.body.appendChild(source);
|
||||
return source;
|
||||
};
|
||||
|
||||
const buildTargetElement = (rect: Partial<DOMRect>, visibility = 'visible') => {
|
||||
const target = document.createElement('span');
|
||||
target.id = WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID;
|
||||
target.style.visibility = visibility;
|
||||
target.getBoundingClientRect = () =>
|
||||
({ left: 24, top: 40, width: 200, height: 20, ...rect }) as DOMRect;
|
||||
document.body.appendChild(target);
|
||||
return target;
|
||||
};
|
||||
|
||||
describe('measureWelcomeTitleFlight', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('should return null when the source element is missing', () => {
|
||||
buildTargetElement({});
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the target element is missing', () => {
|
||||
buildSourceElement();
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the target has no width', () => {
|
||||
buildSourceElement();
|
||||
buildTargetElement({ width: 0 });
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the target has no height', () => {
|
||||
buildSourceElement();
|
||||
buildTargetElement({ height: 0 });
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the target is not visible', () => {
|
||||
buildSourceElement();
|
||||
buildTargetElement({}, 'hidden');
|
||||
|
||||
expect(measureWelcomeTitleFlight()).toBeNull();
|
||||
});
|
||||
|
||||
it('should measure a flight when both ends are laid out and visible', () => {
|
||||
buildSourceElement();
|
||||
buildTargetElement({});
|
||||
|
||||
expect(measureWelcomeTitleFlight()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type WelcomeTitleFlight } from '@/onboarding/types/WelcomeTitleFlight';
|
||||
|
||||
type GetWelcomeTitleFlightArgs = {
|
||||
sourceRect: DOMRect;
|
||||
sourcePaddingLeftInPx: number;
|
||||
sourceFontSizeInPx: number;
|
||||
targetRect: DOMRect;
|
||||
targetFontSizeInPx: number;
|
||||
};
|
||||
|
||||
export const getWelcomeTitleFlight = ({
|
||||
sourceRect,
|
||||
sourcePaddingLeftInPx,
|
||||
sourceFontSizeInPx,
|
||||
targetRect,
|
||||
targetFontSizeInPx,
|
||||
}: GetWelcomeTitleFlightArgs): WelcomeTitleFlight => {
|
||||
const sourceTextLeft = sourceRect.left + sourcePaddingLeftInPx;
|
||||
const sourceCenterY = sourceRect.top + sourceRect.height / 2;
|
||||
const targetCenterY = targetRect.top + targetRect.height / 2;
|
||||
|
||||
return {
|
||||
translateXInPx: targetRect.left - sourceTextLeft,
|
||||
translateYInPx: targetCenterY - sourceCenterY,
|
||||
scale: sourceFontSizeInPx > 0 ? targetFontSizeInPx / sourceFontSizeInPx : 1,
|
||||
transformOriginXInPx: sourcePaddingLeftInPx,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId';
|
||||
import { WELCOME_TITLE_SOURCE_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleSourceElementId';
|
||||
import { type WelcomeTitleFlight } from '@/onboarding/types/WelcomeTitleFlight';
|
||||
import { getWelcomeTitleFlight } from '@/onboarding/utils/getWelcomeTitleFlight';
|
||||
|
||||
const isElementLaidOutAndVisible = (
|
||||
rect: DOMRect,
|
||||
style: CSSStyleDeclaration,
|
||||
) => rect.width > 0 && rect.height > 0 && style.visibility === 'visible';
|
||||
|
||||
export const measureWelcomeTitleFlight = (): WelcomeTitleFlight | null => {
|
||||
const sourceElement = document.getElementById(
|
||||
WELCOME_TITLE_SOURCE_ELEMENT_ID,
|
||||
);
|
||||
const targetElement = document.getElementById(
|
||||
WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID,
|
||||
);
|
||||
|
||||
if (!isDefined(sourceElement) || !isDefined(targetElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceStyle = window.getComputedStyle(sourceElement);
|
||||
const targetStyle = window.getComputedStyle(targetElement);
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
const targetRect = targetElement.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
!isElementLaidOutAndVisible(sourceRect, sourceStyle) ||
|
||||
!isElementLaidOutAndVisible(targetRect, targetStyle)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getWelcomeTitleFlight({
|
||||
sourceRect,
|
||||
sourcePaddingLeftInPx: parseFloat(sourceStyle.paddingLeft),
|
||||
sourceFontSizeInPx: parseFloat(sourceStyle.fontSize),
|
||||
targetRect,
|
||||
targetFontSizeInPx: parseFloat(targetStyle.fontSize),
|
||||
});
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { AppPath, SidePanelPages } from 'twenty-shared/types';
|
||||
import { IconLayoutSidebarRightExpand } from 'twenty-ui/icon';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
|
||||
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
export const SidePanelExpandAiChatButton = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const sidePanelPage = useAtomStateValue(sidePanelPageState);
|
||||
const isOnboardingAiChatEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
);
|
||||
const setAiChatExpandedReturnLocation = useSetAtomState(
|
||||
aiChatExpandedReturnLocationState,
|
||||
);
|
||||
|
||||
const isOnAskAiPage = sidePanelPage === SidePanelPages.AskAI;
|
||||
|
||||
if (!isOnboardingAiChatEnabled || isMobile || !isOnAskAiPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setAiChatExpandedReturnLocation(
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
);
|
||||
navigate(AppPath.WorkspaceSetup);
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
Icon={IconLayoutSidebarRightExpand}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={handleClick}
|
||||
ariaLabel={t`Expand chat`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SidePanelBackButton } from '@/side-panel/components/SidePanelBackButton';
|
||||
import { SidePanelPageInfo } from '@/side-panel/components/SidePanelPageInfo';
|
||||
import { SidePanelTopBarInputFocusEffect } from '@/side-panel/components/SidePanelTopBarInputFocusEffect';
|
||||
import { SidePanelExpandAiChatButton } from '@/side-panel/components/SidePanelExpandAiChatButton';
|
||||
import { SidePanelTopBarRightCornerIcon } from '@/side-panel/components/SidePanelTopBarRightCornerIcon';
|
||||
import { COMMAND_MENU_SIDE_PANEL_PAGES } from '@/side-panel/constants/CommandMenuSidePanelPages';
|
||||
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
|
||||
@@ -211,6 +212,7 @@ export const SidePanelTopBar = () => {
|
||||
</StyledContentContainer>
|
||||
<StyledRightControlsContainer>
|
||||
<SidePanelTopBarRightCornerIcon />
|
||||
<SidePanelExpandAiChatButton />
|
||||
{!shouldHideCloseButton && (
|
||||
<IconButton
|
||||
Icon={IconX}
|
||||
|
||||
+4
@@ -28,6 +28,10 @@ jest.mock('@/side-panel/components/SidePanelTopBarRightCornerIcon', () => ({
|
||||
SidePanelTopBarRightCornerIcon: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@/side-panel/components/SidePanelExpandAiChatButton', () => ({
|
||||
SidePanelExpandAiChatButton: () => null,
|
||||
}));
|
||||
|
||||
const mockCloseSidePanelMenu = jest.fn();
|
||||
|
||||
jest.mock('@/side-panel/hooks/useSidePanelContextChips', () => ({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { hasAgentChatBeenOpenedState } from '@/ai/states/hasAgentChatBeenOpenedState';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useCallback } from 'react';
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
@@ -10,6 +12,9 @@ import { v4 } from 'uuid';
|
||||
export const useOpenAskAiPageInSidePanel = () => {
|
||||
const { navigateSidePanelMenu } = useSidePanelMenu();
|
||||
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
|
||||
const setHasAgentChatBeenOpened = useSetAtomState(
|
||||
hasAgentChatBeenOpenedState,
|
||||
);
|
||||
|
||||
const openAskAiPage = useCallback(
|
||||
({
|
||||
@@ -22,6 +27,8 @@ export const useOpenAskAiPageInSidePanel = () => {
|
||||
? resetNavigationStack
|
||||
: isSidePanelOpened;
|
||||
|
||||
setHasAgentChatBeenOpened(true);
|
||||
|
||||
navigateSidePanelMenu({
|
||||
page: SidePanelPages.AskAI,
|
||||
pageTitle: t`Ask AI`,
|
||||
@@ -30,7 +37,7 @@ export const useOpenAskAiPageInSidePanel = () => {
|
||||
resetNavigationStack: shouldReset,
|
||||
});
|
||||
},
|
||||
[navigateSidePanelMenu, isSidePanelOpened],
|
||||
[navigateSidePanelMenu, isSidePanelOpened, setHasAgentChatBeenOpened],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { SubTitle } from '@/auth/components/SubTitle';
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { OnboardingAnimatedReveal } from '@/onboarding/components/OnboardingAnimatedReveal';
|
||||
import { OnboardingVerifyLayout } from '@/onboarding/components/OnboardingVerifyLayout';
|
||||
import { useOnboardingMotionTransition } from '@/onboarding/hooks/useOnboardingMotionTransition';
|
||||
import { useShowWelcomeAnimationAfterOnboardingCheckout } from '@/onboarding/hooks/useShowWelcomeAnimationAfterOnboardingCheckout';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { useLazyQuery } from '@apollo/client/react';
|
||||
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
@@ -15,7 +12,6 @@ import { useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { MainButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { GetCurrentUserDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
const SUBSCRIPTION_CONFIRMATION_POLL_INTERVAL_MS = 2000;
|
||||
const SUBSCRIPTION_CONFIRMATION_MAX_ATTEMPTS = 30;
|
||||
@@ -26,11 +22,7 @@ const StyledRetryButtonContainer = styled.div`
|
||||
`;
|
||||
|
||||
export const PaymentSuccess = () => {
|
||||
const subscriptionStatus = useSubscriptionStatus();
|
||||
const [getCurrentUser] = useLazyQuery(GetCurrentUserDocument, {
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
const setCurrentUser = useSetAtomState(currentUserState);
|
||||
const { loadCurrentUser } = useLoadCurrentUser();
|
||||
const showWelcomeAnimationAfterOnboardingCheckout =
|
||||
useShowWelcomeAnimationAfterOnboardingCheckout();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
@@ -47,23 +39,18 @@ export const PaymentSuccess = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(subscriptionStatus)) {
|
||||
showWelcomeAnimationAfterOnboardingCheckout();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getCurrentUser();
|
||||
const refreshedWorkspace = await loadCurrentUser()
|
||||
.then(({ workspace }) => workspace)
|
||||
.catch(() => null);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = result.data?.currentUser;
|
||||
const refreshedSubscriptionStatus =
|
||||
currentUser?.currentWorkspace?.currentBillingSubscription?.status;
|
||||
refreshedWorkspace?.currentBillingSubscription?.status;
|
||||
|
||||
if (isDefined(currentUser) && isDefined(refreshedSubscriptionStatus)) {
|
||||
setCurrentUser(currentUser);
|
||||
if (isDefined(refreshedSubscriptionStatus)) {
|
||||
showWelcomeAnimationAfterOnboardingCheckout();
|
||||
return;
|
||||
}
|
||||
@@ -93,7 +80,7 @@ export const PaymentSuccess = () => {
|
||||
}
|
||||
};
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [confirmationRunIndex, subscriptionStatus]);
|
||||
}, [confirmationRunIndex]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setHasTimedOut(false);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
|
||||
import { AiChatTab } from '@/ai/components/AiChatTab';
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble';
|
||||
import { WorkspaceSetupHeader } from '@/onboarding/components/WorkspaceSetupHeader';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
const PANEL_CORNER_RADIUS_DERIVED_FROM_THEME_SCALE = `calc(${themeCssVariables.border.radius.md} + ${themeCssVariables.spacing[1]})`;
|
||||
|
||||
const StyledPanel = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border-left: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${PANEL_CORNER_RADIUS_DERIVED_FROM_THEME_SCALE} 0 0
|
||||
${PANEL_CORNER_RADIUS_DERIVED_FROM_THEME_SCALE};
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledContent = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-x: clip;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const WorkspaceSetup = () => {
|
||||
const { t } = useLingui();
|
||||
const { defaultHomePagePath } = useDefaultHomePagePath();
|
||||
const isOnboardingAiChatEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
);
|
||||
const shouldOpenAiChatAfterOnboarding = useAtomStateValue(
|
||||
shouldOpenAiChatAfterOnboardingState,
|
||||
);
|
||||
|
||||
if (!isOnboardingAiChatEnabled) {
|
||||
return <Navigate to={defaultHomePagePath} replace />;
|
||||
}
|
||||
|
||||
const title = shouldOpenAiChatAfterOnboarding ? t`Onboarding` : t`Ask AI`;
|
||||
const preamble = shouldOpenAiChatAfterOnboarding ? (
|
||||
<WorkspaceSetupChatPreamble />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<StyledPanel>
|
||||
<WorkspaceSetupHeader title={title} />
|
||||
<StyledContent>
|
||||
<AiChatMessageListPreambleContext.Provider value={preamble}>
|
||||
<AiChatTab />
|
||||
</AiChatMessageListPreambleContext.Provider>
|
||||
</StyledContent>
|
||||
</StyledPanel>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
import { messages } from '~/locales/generated/en';
|
||||
import { WorkspaceSetup } from '~/pages/onboarding/WorkspaceSetup';
|
||||
import { mockCurrentWorkspace } from '~/testing/mock-data/users';
|
||||
|
||||
i18n.load({ [SOURCE_LOCALE]: messages });
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
|
||||
const defaultHomePagePath = '/objects/companies';
|
||||
|
||||
jest.mock('@/navigation/hooks/useDefaultHomePagePath', () => ({
|
||||
useDefaultHomePagePath: () => ({ defaultHomePagePath }),
|
||||
}));
|
||||
|
||||
jest.mock('@/ai/components/AiChatTab', () => {
|
||||
const { useContext } = jest.requireActual('react');
|
||||
const { AiChatMessageListPreambleContext } = jest.requireActual(
|
||||
'@/ai/contexts/AiChatMessageListPreambleContext',
|
||||
);
|
||||
return {
|
||||
AiChatTab: () => (
|
||||
<div data-testid="ai-chat-tab">
|
||||
{useContext(AiChatMessageListPreambleContext)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@/onboarding/components/WorkspaceSetupHeader', () => ({
|
||||
WorkspaceSetupHeader: ({ title }: { title: string }) => (
|
||||
<div data-testid="header-title">{title}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/onboarding/components/WorkspaceSetupChatPreamble', () => ({
|
||||
WorkspaceSetupChatPreamble: () => <div data-testid="preamble" />,
|
||||
}));
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
Navigate: (props: { to: string }) => {
|
||||
mockNavigate(props.to);
|
||||
return <div data-testid="navigate" />;
|
||||
},
|
||||
}));
|
||||
|
||||
const setOnboardingAiChatFeatureFlag = (value: boolean) => {
|
||||
jotaiStore.set(currentWorkspaceState.atom, {
|
||||
...mockCurrentWorkspace,
|
||||
featureFlags: [
|
||||
{ key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, value },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<I18nProvider i18n={i18n}>{children}</I18nProvider>
|
||||
</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('WorkspaceSetup', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
resetJotaiStore();
|
||||
mockNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('should dress the chat for onboarding when the post-onboarding hint is set', () => {
|
||||
setOnboardingAiChatFeatureFlag(true);
|
||||
jotaiStore.set(shouldOpenAiChatAfterOnboardingState.atom, true);
|
||||
|
||||
const { getByTestId } = render(<WorkspaceSetup />, { wrapper: Wrapper });
|
||||
|
||||
expect(getByTestId('ai-chat-tab')).toBeInTheDocument();
|
||||
expect(getByTestId('preamble')).toBeInTheDocument();
|
||||
expect(getByTestId('header-title')).toHaveTextContent('Onboarding');
|
||||
});
|
||||
|
||||
it('should render a plain chat when the post-onboarding hint is not set', () => {
|
||||
setOnboardingAiChatFeatureFlag(true);
|
||||
|
||||
const { getByTestId, queryByTestId } = render(<WorkspaceSetup />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(getByTestId('ai-chat-tab')).toBeInTheDocument();
|
||||
expect(queryByTestId('preamble')).not.toBeInTheDocument();
|
||||
expect(getByTestId('header-title')).toHaveTextContent('Ask AI');
|
||||
});
|
||||
|
||||
it('should redirect home when the onboarding ai chat feature flag is disabled', () => {
|
||||
setOnboardingAiChatFeatureFlag(false);
|
||||
|
||||
const { queryByTestId } = render(<WorkspaceSetup />, { wrapper: Wrapper });
|
||||
|
||||
expect(queryByTestId('ai-chat-tab')).not.toBeInTheDocument();
|
||||
expect(mockNavigate).toHaveBeenCalledWith(defaultHomePagePath);
|
||||
});
|
||||
});
|
||||
+1
@@ -247,6 +247,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: false,
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED: false,
|
||||
IS_WORKFLOW_VERSION_IN_CORE_ENABLED: false,
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED: false,
|
||||
},
|
||||
userWorkspaceRoleMap: {},
|
||||
apiKeyRoleMap: {},
|
||||
|
||||
+5
@@ -50,6 +50,11 @@ export const seedFeatureFlags = async ({
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: false,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum AppPath {
|
||||
BookCall = '/book-call',
|
||||
|
||||
// Onboarded
|
||||
WorkspaceSetup = '/workspace-setup',
|
||||
Index = '/',
|
||||
TasksPage = '/objects/tasks',
|
||||
OpportunitiesPage = '/objects/opportunities',
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum FeatureFlagKey {
|
||||
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
|
||||
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
|
||||
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
|
||||
IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED',
|
||||
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
|
||||
IS_WORKFLOW_VERSION_IN_CORE_ENABLED = 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user