🐛 fix(onboarding): prevent step overflow and misc improvements (#11322)

This commit is contained in:
YuTengjing
2026-01-08 14:48:07 +08:00
committed by GitHub
parent 3d6b39962a
commit 8586fd4b06
18 changed files with 882 additions and 79 deletions

View File

@@ -330,7 +330,7 @@
"switchToYearly.desc": "切换后,支付差价后立即生效年付计费,起始日期继承原计划。",
"switchToYearly.title": "切换为年付",
"tab.billing": "账单管理",
"tab.funds": "点数管理",
"tab.funds": "积分管理",
"tab.plans": "订阅计划",
"tab.referral": "推荐奖励",
"tab.spend": "点数明细",

View File

@@ -9,8 +9,10 @@ export interface UserOnboarding {
version: number;
}
export const MAX_ONBOARDING_STEPS = 5;
export const UserOnboardingSchema = z.object({
currentStep: z.number().optional(),
currentStep: z.number().min(1).max(MAX_ONBOARDING_STEPS).optional(),
finishedAt: z.string().optional(),
version: z.number(),
});

View File

@@ -4,11 +4,8 @@ import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { modal } from '@/components/AntdStaticMethods';
import { useServerConfigStore } from '@/store/serverConfig';
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
import { useToolStore } from '@/store/tool';
import { type KlavisServer, KlavisServerStatus } from '@/store/tool/slices/klavisStore';
import { type ToolStore } from '@/store/tool/store';
import { type KlavisServer } from '@/store/tool/slices/klavisStore';
interface KlavisAuthItemProps {
server: KlavisServer;
@@ -54,11 +51,6 @@ const KlavisAuthItem = memo<KlavisAuthItemProps>(({ server }) => {
return <IconComponent size={14} />;
};
// 只显示已连接的服务器
if (server.status !== KlavisServerStatus.CONNECTED) {
return null;
}
return (
<Tag closable onClose={handleRevoke}>
<Flexbox align="center" gap={4} horizontal style={{ opacity: isRevoking ? 0.5 : 1 }}>
@@ -69,24 +61,14 @@ const KlavisAuthItem = memo<KlavisAuthItemProps>(({ server }) => {
);
});
export const KlavisAuthorizationList = memo(() => {
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
const useFetchUserKlavisServers = useToolStore((s: ToolStore) => s.useFetchUserKlavisServers);
const servers = useToolStore((s: ToolStore) => s.servers);
// 获取已授权的服务器列表
useFetchUserKlavisServers(enableKlavis);
// 只显示已连接的服务器
const connectedServers = servers.filter((s) => s.status === KlavisServerStatus.CONNECTED);
if (!enableKlavis || connectedServers.length === 0) {
return null;
}
interface KlavisAuthorizationListProps {
servers: KlavisServer[];
}
export const KlavisAuthorizationList = memo<KlavisAuthorizationListProps>(({ servers }) => {
return (
<Flexbox gap={8} horizontal wrap="wrap">
{connectedServers.map((server) => (
{servers.map((server) => (
<KlavisAuthItem key={server.identifier} server={server} />
))}
</Flexbox>

View File

@@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
import SettingHeader from '@/app/[variants]/(main)/settings/features/SettingHeader';
import { useServerConfigStore } from '@/store/serverConfig';
import { serverConfigSelectors } from '@/store/serverConfig/selectors';
import { useToolStore } from '@/store/tool';
import { KlavisServerStatus } from '@/store/tool/slices/klavisStore';
import { useUserStore } from '@/store/user';
import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
@@ -37,9 +39,21 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
const isLoadedAuthProviders = useUserStore(authSelectors.isLoadedAuthProviders);
const fetchAuthProviders = useUserStore((s) => s.fetchAuthProviders);
const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
const [servers, isServersInit, useFetchUserKlavisServers] = useToolStore((s) => [
s.servers,
s.isServersInit,
s.useFetchUserKlavisServers,
]);
const connectedServers = servers.filter((s) => s.status === KlavisServerStatus.CONNECTED);
// Fetch Klavis servers
useFetchUserKlavisServers(enableKlavis);
const isLoginWithAuth = isLoginWithNextAuth || isLoginWithBetterAuth;
const isLoading = !isUserLoaded || (isLoginWithAuth && !isLoadedAuthProviders);
const isLoading =
!isUserLoaded ||
(isLoginWithAuth && !isLoadedAuthProviders) ||
(enableKlavis && !isServersInit);
useEffect(() => {
if (isLoginWithAuth) {
@@ -110,11 +124,11 @@ const ProfileSetting = ({ mobile }: ProfileSettingProps) => {
)}
{/* Klavis Authorizations Row */}
{enableKlavis && (
{enableKlavis && connectedServers.length > 0 && (
<>
<Divider style={{ margin: 0 }} />
<ProfileRow label={t('profile.authorizations.title')} mobile={mobile}>
<KlavisAuthorizationList />
<KlavisAuthorizationList servers={connectedServers} />
</ProfileRow>
</>
)}

View File

@@ -4,7 +4,7 @@ import { SendButton } from '@lobehub/editor/react';
import { Button, Flexbox, Icon, Input } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { SignatureIcon, Undo2Icon } from 'lucide-react';
import { memo, useState } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
@@ -23,13 +23,25 @@ const FullNameStep = memo<FullNameStepProps>(({ onBack, onNext }) => {
const updateFullName = useUserStore((s) => s.updateFullName);
const [value, setValue] = useState(existingFullName || '');
const [isNavigating, setIsNavigating] = useState(false);
const isNavigatingRef = useRef(false);
const handleNext = () => {
const handleNext = useCallback(() => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
if (value.trim()) {
updateFullName(value.trim());
}
onNext();
};
}, [value, updateFullName, onNext]);
const handleBack = useCallback(() => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
onBack();
}, [onBack]);
return (
<Flexbox gap={16}>
@@ -59,7 +71,7 @@ const FullNameStep = memo<FullNameStepProps>(({ onBack, onNext }) => {
}}
suffix={
<SendButton
disabled={!value?.trim()}
disabled={!value?.trim() || isNavigating}
onClick={handleNext}
style={{
zoom: 1.5,
@@ -73,8 +85,9 @@ const FullNameStep = memo<FullNameStepProps>(({ onBack, onNext }) => {
</Flexbox>
<Flexbox horizontal justify={'flex-start'} style={{ marginTop: 32 }}>
<Button
disabled={isNavigating}
icon={Undo2Icon}
onClick={onBack}
onClick={handleBack}
style={{
color: cssVar.colorTextDescription,
}}

View File

@@ -3,7 +3,7 @@
import { Block, Button, Flexbox, Icon, Input, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { BriefcaseIcon, Undo2Icon } from 'lucide-react';
import { memo, useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
@@ -25,6 +25,8 @@ const InterestsStep = memo<InterestsStepProps>(({ onBack, onNext }) => {
const [selectedInterests, setSelectedInterests] = useState<string[]>(existingInterests);
const [customInput, setCustomInput] = useState('');
const [showCustomInput, setShowCustomInput] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
const isNavigatingRef = useRef(false);
const areas = useMemo(
() =>
@@ -49,7 +51,11 @@ const InterestsStep = memo<InterestsStepProps>(({ onBack, onNext }) => {
}
}, [customInput, selectedInterests]);
const handleNext = useCallback(async () => {
const handleNext = useCallback(() => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
// Include custom input value if "other" is active and has content
const finalInterests = [...selectedInterests];
const trimmedCustom = customInput.trim();
@@ -60,12 +66,17 @@ const InterestsStep = memo<InterestsStepProps>(({ onBack, onNext }) => {
// Deduplicate
const uniqueInterests = [...new Set(finalInterests)];
if (uniqueInterests.length > 0) {
await updateInterests(uniqueInterests);
}
updateInterests(uniqueInterests);
onNext();
}, [selectedInterests, customInput, showCustomInput, updateInterests, onNext]);
const handleBack = useCallback(() => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
onBack();
}, [onBack]);
return (
<Flexbox gap={16}>
<LobeMessage
@@ -138,14 +149,15 @@ const InterestsStep = memo<InterestsStepProps>(({ onBack, onNext }) => {
)}
<Flexbox horizontal justify={'space-between'} style={{ marginTop: 32 }}>
<Button
disabled={isNavigating}
icon={Undo2Icon}
onClick={onBack}
onClick={handleBack}
style={{ color: cssVar.colorTextDescription }}
type={'text'}
>
{t('back')}
</Button>
<Button onClick={handleNext} type={'primary'}>
<Button disabled={isNavigating} onClick={handleNext} type={'primary'}>
{t('next')}
</Button>
</Flexbox>

View File

@@ -3,7 +3,7 @@
import { Button, Flexbox, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Undo2Icon } from 'lucide-react';
import { memo } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -34,14 +34,30 @@ const ProSettingsStep = memo<ProSettingsStepProps>(({ onBack }) => {
(s) => settingsSelectors.currentSettings(s).defaultAgent?.config,
);
const handleFinish = () => {
const [isNavigating, setIsNavigating] = useState(false);
const isNavigatingRef = useRef(false);
const handleFinish = useCallback(() => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
finishOnboarding();
navigate('/');
};
}, [finishOnboarding, navigate]);
const handleModelChange = ({ model, provider }: { model: string; provider: string }) => {
updateDefaultModel(model, provider);
};
const handleBack = useCallback(() => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
onBack();
}, [onBack]);
const handleModelChange = useCallback(
({ model, provider }: { model: string; provider: string }) => {
updateDefaultModel(model, provider);
},
[updateDefaultModel],
);
return (
<Flexbox gap={16}>
@@ -70,8 +86,9 @@ const ProSettingsStep = memo<ProSettingsStepProps>(({ onBack }) => {
<Flexbox align={'center'} horizontal justify={'space-between'} style={{ marginTop: 16 }}>
<Button
disabled={isNavigating}
icon={Undo2Icon}
onClick={onBack}
onClick={handleBack}
style={{
color: cssVar.colorTextDescription,
}}
@@ -79,7 +96,12 @@ const ProSettingsStep = memo<ProSettingsStepProps>(({ onBack }) => {
>
{t('back')}
</Button>
<Button onClick={handleFinish} style={{ minWidth: 120 }} type="primary">
<Button
disabled={isNavigating}
onClick={handleFinish}
style={{ minWidth: 120 }}
type="primary"
>
{t('finish')}
</Button>
</Flexbox>

View File

@@ -4,7 +4,7 @@ import { SendButton } from '@lobehub/editor/react';
import { Button, Flexbox, Select, Text } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Undo2Icon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type Locales, localeOptions, normalizeLocale } from '@/locales/resources';
@@ -24,11 +24,23 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
const setSettings = useUserStore((s) => s.setSettings);
const [value, setValue] = useState<Locales | ''>(normalizeLocale(navigator.language));
const [isNavigating, setIsNavigating] = useState(false);
const isNavigatingRef = useRef(false);
const handleNext = () => {
const handleNext = useCallback(() => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
setSettings({ general: { responseLanguage: value || '' } });
onNext();
};
}, [value, setSettings, onNext]);
const handleBack = useCallback(() => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
onBack();
}, [onBack]);
const Message = useCallback(
() => (
@@ -73,6 +85,7 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
value={value}
/>
<SendButton
disabled={isNavigating}
onClick={handleNext}
style={{
zoom: 1.5,
@@ -85,8 +98,9 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
</Text>
<Flexbox horizontal justify={'flex-start'} style={{ marginTop: 32 }}>
<Button
disabled={isNavigating}
icon={Undo2Icon}
onClick={onBack}
onClick={handleBack}
style={{
color: cssVar.colorTextDescription,
}}

View File

@@ -7,7 +7,7 @@ import { LoadingDots } from '@lobehub/ui/chat';
import { Steps, Switch } from 'antd';
import { cssVar } from 'antd-style';
import { BrainIcon, HeartHandshakeIcon, PencilRulerIcon, ShieldCheck } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { ProductLogo } from '@/components/Branding';
@@ -21,12 +21,20 @@ interface TelemetryStepProps {
const TelemetryStep = memo<TelemetryStepProps>(({ onNext }) => {
const { t } = useTranslation('onboarding');
const [check, setCheck] = useState(true);
const [isNavigating, setIsNavigating] = useState(false);
const isNavigatingRef = useRef(false);
const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
const handleChoice = (enabled: boolean) => {
updateGeneralConfig({ telemetry: enabled });
onNext();
};
const handleChoice = useCallback(
(enabled: boolean) => {
if (isNavigatingRef.current) return;
isNavigatingRef.current = true;
setIsNavigating(true);
updateGeneralConfig({ telemetry: enabled });
onNext();
},
[updateGeneralConfig, onNext],
);
const IconAvatar = useCallback(({ icon }: { icon: IconProps['icon'] }) => {
return (
@@ -123,6 +131,7 @@ const TelemetryStep = memo<TelemetryStepProps>(({ onNext }) => {
</Flexbox>
</Flexbox>
<Button
disabled={isNavigating}
onClick={() => handleChoice(check)}
size={'large'}
style={{

View File

@@ -1,5 +1,6 @@
'use client';
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
@@ -40,7 +41,7 @@ const OnboardingPage = memo(() => {
case 4: {
return <ResponseLanguageStep onBack={goToPreviousStep} onNext={goToNextStep} />;
}
case 5: {
case MAX_ONBOARDING_STEPS: {
return <ProSettingsStep onBack={goToPreviousStep} />;
}
default: {

View File

@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { lambdaClient, toolsClient } from '@/libs/trpc/client';
@@ -8,6 +8,10 @@ import { KlavisServerStatus } from './types';
vi.mock('zustand/traditional');
beforeEach(() => {
vi.clearAllMocks();
});
vi.mock('@/libs/trpc/client', () => ({
lambdaClient: {
klavis: {
@@ -509,4 +513,165 @@ describe('klavisStore actions', () => {
expect(lambdaClient.klavis.getServerInstance.query).toHaveBeenCalled();
});
});
describe('useFetchUserKlavisServers', () => {
it('should set isServersInit to true on success with empty data', async () => {
act(() => {
useToolStore.setState({
servers: [],
loadingServerIds: new Set(),
executingToolIds: new Set(),
isServersInit: false,
});
});
vi.mocked(lambdaClient.klavis.getKlavisPlugins.query).mockResolvedValue([]);
renderHook(() => useToolStore.getState().useFetchUserKlavisServers(true));
await waitFor(() => {
expect(useToolStore.getState().isServersInit).toBe(true);
});
});
it('should not fetch when disabled', () => {
act(() => {
useToolStore.setState({
servers: [],
loadingServerIds: new Set(),
executingToolIds: new Set(),
isServersInit: false,
});
});
vi.mocked(lambdaClient.klavis.getKlavisPlugins.query).mockClear();
renderHook(() => useToolStore.getState().useFetchUserKlavisServers(false));
expect(lambdaClient.klavis.getKlavisPlugins.query).not.toHaveBeenCalled();
expect(useToolStore.getState().isServersInit).toBe(false);
});
});
describe('server deduplication logic', () => {
it('should deduplicate servers by identifier when adding new servers', () => {
// This tests the deduplication logic used in useFetchUserKlavisServers onSuccess
act(() => {
useToolStore.setState({
servers: [
{
identifier: 'gmail',
serverName: 'Gmail',
instanceId: 'existing-inst',
serverUrl: 'https://klavis.ai/gmail',
status: KlavisServerStatus.CONNECTED,
isAuthenticated: true,
createdAt: Date.now(),
},
],
loadingServerIds: new Set(),
executingToolIds: new Set(),
isServersInit: false,
});
});
// Simulate what onSuccess does
const incomingServers = [
{
identifier: 'gmail',
serverName: 'Gmail',
instanceId: 'new-inst',
serverUrl: 'https://klavis.ai/gmail',
status: KlavisServerStatus.CONNECTED,
isAuthenticated: true,
createdAt: Date.now(),
},
{
identifier: 'github',
serverName: 'GitHub',
instanceId: 'github-inst',
serverUrl: 'https://klavis.ai/github',
status: KlavisServerStatus.CONNECTED,
isAuthenticated: true,
createdAt: Date.now(),
},
];
act(() => {
const existingServers = useToolStore.getState().servers;
const existingIdentifiers = new Set(existingServers.map((s) => s.identifier));
const newServers = incomingServers.filter((s) => !existingIdentifiers.has(s.identifier));
useToolStore.setState({
servers: [...existingServers, ...newServers],
isServersInit: true,
});
});
const finalServers = useToolStore.getState().servers;
expect(finalServers).toHaveLength(2);
// Existing gmail should keep its original instanceId
expect(finalServers.find((s) => s.identifier === 'gmail')?.instanceId).toBe('existing-inst');
// New github should be added
expect(finalServers.find((s) => s.identifier === 'github')?.instanceId).toBe('github-inst');
expect(useToolStore.getState().isServersInit).toBe(true);
});
it('should add all servers when none exist', () => {
act(() => {
useToolStore.setState({
servers: [],
loadingServerIds: new Set(),
executingToolIds: new Set(),
isServersInit: false,
});
});
const incomingServers = [
{
identifier: 'gmail',
serverName: 'Gmail',
instanceId: 'inst-1',
serverUrl: 'https://klavis.ai/gmail',
status: KlavisServerStatus.CONNECTED,
isAuthenticated: true,
createdAt: Date.now(),
},
];
act(() => {
const existingServers = useToolStore.getState().servers;
const existingIdentifiers = new Set(existingServers.map((s) => s.identifier));
const newServers = incomingServers.filter((s) => !existingIdentifiers.has(s.identifier));
useToolStore.setState({
servers: [...existingServers, ...newServers],
isServersInit: true,
});
});
expect(useToolStore.getState().servers).toHaveLength(1);
expect(useToolStore.getState().isServersInit).toBe(true);
});
it('should set isServersInit even when no servers are added', () => {
act(() => {
useToolStore.setState({
servers: [],
loadingServerIds: new Set(),
executingToolIds: new Set(),
isServersInit: false,
});
});
// Simulate empty data case
act(() => {
useToolStore.setState({
isServersInit: true,
});
});
expect(useToolStore.getState().isServersInit).toBe(true);
});
});
});

View File

@@ -356,18 +356,19 @@ export const createKlavisStoreSlice: StateCreator<
{
fallbackData: [],
onSuccess: (data) => {
if (data.length > 0) {
set(
produce((draft: KlavisStoreState) => {
set(
produce((draft: KlavisStoreState) => {
if (data.length > 0) {
// 使用 identifier 检查是否已存在
const existingIdentifiers = new Set(draft.servers.map((s) => s.identifier));
const newServers = data.filter((s) => !existingIdentifiers.has(s.identifier));
draft.servers = [...draft.servers, ...newServers];
}),
false,
n('useFetchUserKlavisServers'),
);
}
}
draft.isServersInit = true;
}),
false,
n('useFetchUserKlavisServers'),
);
},
revalidateOnFocus: false,
},

View File

@@ -9,6 +9,8 @@ import { type KlavisServer } from './types';
export interface KlavisStoreState {
/** 正在执行的工具调用 ID 集合 */
executingToolIds: Set<string>;
/** 是否已完成初始化加载 */
isServersInit: boolean;
/** 正在加载的服务器 ID 集合 */
loadingServerIds: Set<string>;
/** 已创建的 Klavis Server 列表 */
@@ -20,6 +22,7 @@ export interface KlavisStoreState {
*/
export const initialKlavisStoreState: KlavisStoreState = {
executingToolIds: new Set(),
isServersInit: false,
loadingServerIds: new Set(),
servers: [],
};

View File

@@ -41,6 +41,7 @@ const fetchAuthProvidersData = async (): Promise<AuthProvidersData> => {
accounts
.filter((account) => account.providerId !== 'credential')
.map(async (account) => {
// In theory, the id_token could be decrypted from the accounts table, but I found that better-auth on GitHub does not save the id_token
const info = await accountInfo({
query: { accountId: account.accountId },
});

View File

@@ -0,0 +1,342 @@
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { userService } from '@/services/user';
import { useUserStore } from '@/store/user';
import { initialOnboardingState } from './initialState';
vi.mock('zustand/traditional');
vi.mock('@/services/user', () => ({
userService: {
updateOnboarding: vi.fn(),
},
}));
describe('onboarding actions', () => {
beforeEach(() => {
vi.clearAllMocks();
act(() => {
useUserStore.setState({
...initialOnboardingState,
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
refreshUserState: vi.fn(),
});
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('goToNextStep', () => {
it('should increment step and set localOnboardingStep', () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
});
});
act(() => {
result.current.goToNextStep();
});
expect(result.current.localOnboardingStep).toBe(2);
});
it('should not increment step when already at MAX_ONBOARDING_STEPS', () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
localOnboardingStep: MAX_ONBOARDING_STEPS,
onboarding: { currentStep: MAX_ONBOARDING_STEPS, version: CURRENT_ONBOARDING_VERSION },
});
});
act(() => {
result.current.goToNextStep();
});
// localOnboardingStep should remain at MAX_ONBOARDING_STEPS
expect(result.current.localOnboardingStep).toBe(MAX_ONBOARDING_STEPS);
});
it('should queue step update when incrementing', () => {
const { result } = renderHook(() => useUserStore());
const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
act(() => {
useUserStore.setState({
...initialOnboardingState,
onboarding: { currentStep: 2, version: CURRENT_ONBOARDING_VERSION },
});
});
act(() => {
result.current.goToNextStep();
});
expect(queueStepUpdateSpy).toHaveBeenCalledWith(3);
});
it('should not queue step update when at MAX_ONBOARDING_STEPS', () => {
const { result } = renderHook(() => useUserStore());
const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
act(() => {
useUserStore.setState({
...initialOnboardingState,
localOnboardingStep: MAX_ONBOARDING_STEPS,
onboarding: { currentStep: MAX_ONBOARDING_STEPS, version: CURRENT_ONBOARDING_VERSION },
});
});
act(() => {
result.current.goToNextStep();
});
expect(queueStepUpdateSpy).not.toHaveBeenCalled();
});
});
describe('goToPreviousStep', () => {
it('should decrement step and set localOnboardingStep', () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
localOnboardingStep: 3,
onboarding: { currentStep: 3, version: CURRENT_ONBOARDING_VERSION },
});
});
act(() => {
result.current.goToPreviousStep();
});
expect(result.current.localOnboardingStep).toBe(2);
});
it('should not decrement step when already at step 1', () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
localOnboardingStep: 1,
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
});
});
act(() => {
result.current.goToPreviousStep();
});
// localOnboardingStep should remain at 1
expect(result.current.localOnboardingStep).toBe(1);
});
it('should queue step update when decrementing', () => {
const { result } = renderHook(() => useUserStore());
const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
act(() => {
useUserStore.setState({
...initialOnboardingState,
localOnboardingStep: 3,
onboarding: { currentStep: 3, version: CURRENT_ONBOARDING_VERSION },
});
});
act(() => {
result.current.goToPreviousStep();
});
expect(queueStepUpdateSpy).toHaveBeenCalledWith(2);
});
it('should not queue step update when at step 1', () => {
const { result } = renderHook(() => useUserStore());
const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
act(() => {
useUserStore.setState({
...initialOnboardingState,
localOnboardingStep: 1,
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
});
});
act(() => {
result.current.goToPreviousStep();
});
expect(queueStepUpdateSpy).not.toHaveBeenCalled();
});
});
describe('internal_queueStepUpdate', () => {
it('should add task to empty queue and start processing', () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
stepUpdateQueue: [],
});
});
const processSpy = vi.spyOn(result.current, 'internal_processStepUpdateQueue');
act(() => {
result.current.internal_queueStepUpdate(2);
});
expect(result.current.stepUpdateQueue).toContain(2);
expect(processSpy).toHaveBeenCalled();
});
it('should add pending task when one task is executing', () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
stepUpdateQueue: [2],
isProcessingStepQueue: true,
});
});
act(() => {
result.current.internal_queueStepUpdate(3);
});
expect(result.current.stepUpdateQueue).toEqual([2, 3]);
});
it('should replace pending task when queue has two tasks', () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
stepUpdateQueue: [2, 3],
isProcessingStepQueue: true,
});
});
act(() => {
result.current.internal_queueStepUpdate(4);
});
expect(result.current.stepUpdateQueue).toEqual([2, 4]);
});
});
describe('internal_processStepUpdateQueue', () => {
it('should not process when already processing', async () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
stepUpdateQueue: [2],
isProcessingStepQueue: true,
});
});
await act(async () => {
await result.current.internal_processStepUpdateQueue();
});
// userService.updateOnboarding should not be called
expect(userService.updateOnboarding).not.toHaveBeenCalled();
});
it('should not process when queue is empty', async () => {
const { result } = renderHook(() => useUserStore());
act(() => {
useUserStore.setState({
...initialOnboardingState,
stepUpdateQueue: [],
isProcessingStepQueue: false,
});
});
await act(async () => {
await result.current.internal_processStepUpdateQueue();
});
expect(userService.updateOnboarding).not.toHaveBeenCalled();
});
it('should process queue and call userService.updateOnboarding', async () => {
const { result } = renderHook(() => useUserStore());
vi.mocked(userService.updateOnboarding).mockResolvedValue({} as any);
act(() => {
useUserStore.setState({
...initialOnboardingState,
stepUpdateQueue: [2],
isProcessingStepQueue: false,
onboarding: { version: CURRENT_ONBOARDING_VERSION },
refreshUserState: vi.fn(),
});
});
await act(async () => {
await result.current.internal_processStepUpdateQueue();
});
expect(userService.updateOnboarding).toHaveBeenCalledWith({
currentStep: 2,
finishedAt: undefined,
version: CURRENT_ONBOARDING_VERSION,
});
});
it('should handle errors gracefully and continue processing', async () => {
const { result } = renderHook(() => useUserStore());
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(userService.updateOnboarding).mockRejectedValueOnce(new Error('Update failed'));
act(() => {
useUserStore.setState({
...initialOnboardingState,
stepUpdateQueue: [2],
isProcessingStepQueue: false,
onboarding: { version: CURRENT_ONBOARDING_VERSION },
refreshUserState: vi.fn(),
});
});
await act(async () => {
await result.current.internal_processStepUpdateQueue();
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to update onboarding step:',
expect.any(Error),
);
expect(result.current.isProcessingStepQueue).toBe(false);
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -1,4 +1,5 @@
import { CURRENT_ONBOARDING_VERSION, INBOX_SESSION_ID } from '@lobechat/const';
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
import type { StateCreator } from 'zustand/vanilla';
import { userService } from '@/services/user';
@@ -51,25 +52,19 @@ export const createOnboardingSlice: StateCreator<
goToNextStep: () => {
const currentStep = onboardingSelectors.currentStep(get());
if (currentStep === MAX_ONBOARDING_STEPS) return;
const nextStep = currentStep + 1;
// Optimistic update: immediately update local state
set({ localOnboardingStep: nextStep }, false, 'goToNextStep/optimistic');
// Queue the server update
get().internal_queueStepUpdate(nextStep);
},
goToPreviousStep: () => {
const currentStep = onboardingSelectors.currentStep(get());
if (currentStep <= 1) return;
if (currentStep === 1) return;
const prevStep = currentStep - 1;
// Optimistic update: immediately update local state
set({ localOnboardingStep: prevStep }, false, 'goToPreviousStep/optimistic');
// Queue the server update
get().internal_queueStepUpdate(prevStep);
},

View File

@@ -0,0 +1,222 @@
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import type { UserStore } from '@/store/user';
import { initialOnboardingState } from './initialState';
import { onboardingSelectors } from './selectors';
describe('onboardingSelectors', () => {
describe('currentStep', () => {
it('should return localOnboardingStep when set', () => {
const store = {
...initialOnboardingState,
localOnboardingStep: 3,
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
} as unknown as UserStore;
expect(onboardingSelectors.currentStep(store)).toBe(3);
});
it('should return onboarding.currentStep when localOnboardingStep is undefined', () => {
const store = {
...initialOnboardingState,
localOnboardingStep: undefined,
onboarding: { currentStep: 4, version: CURRENT_ONBOARDING_VERSION },
} as unknown as UserStore;
expect(onboardingSelectors.currentStep(store)).toBe(4);
});
it('should return 1 when both localOnboardingStep and onboarding.currentStep are undefined', () => {
const store = {
...initialOnboardingState,
localOnboardingStep: undefined,
onboarding: undefined,
} as unknown as UserStore;
expect(onboardingSelectors.currentStep(store)).toBe(1);
});
it('should clamp step to minimum of 1 when step is less than 1', () => {
const store = {
...initialOnboardingState,
localOnboardingStep: 0,
onboarding: undefined,
} as unknown as UserStore;
expect(onboardingSelectors.currentStep(store)).toBe(1);
});
it('should clamp step to minimum of 1 when step is negative', () => {
const store = {
...initialOnboardingState,
localOnboardingStep: -5,
onboarding: undefined,
} as unknown as UserStore;
expect(onboardingSelectors.currentStep(store)).toBe(1);
});
it('should clamp step to MAX_ONBOARDING_STEPS when step exceeds maximum', () => {
const store = {
...initialOnboardingState,
localOnboardingStep: 10,
onboarding: undefined,
} as unknown as UserStore;
expect(onboardingSelectors.currentStep(store)).toBe(MAX_ONBOARDING_STEPS);
});
it('should clamp server state step to MAX_ONBOARDING_STEPS when it exceeds maximum', () => {
const store = {
...initialOnboardingState,
localOnboardingStep: undefined,
onboarding: { currentStep: 29, version: CURRENT_ONBOARDING_VERSION },
} as unknown as UserStore;
expect(onboardingSelectors.currentStep(store)).toBe(MAX_ONBOARDING_STEPS);
});
it('should return exact step when within valid range', () => {
for (let step = 1; step <= MAX_ONBOARDING_STEPS; step++) {
const store = {
...initialOnboardingState,
localOnboardingStep: step,
onboarding: undefined,
} as unknown as UserStore;
expect(onboardingSelectors.currentStep(store)).toBe(step);
}
});
});
describe('version', () => {
it('should return onboarding version', () => {
const store = {
...initialOnboardingState,
onboarding: { version: 2 },
} as unknown as UserStore;
expect(onboardingSelectors.version(store)).toBe(2);
});
it('should return CURRENT_ONBOARDING_VERSION when onboarding is undefined', () => {
const store = {
...initialOnboardingState,
onboarding: undefined,
} as unknown as UserStore;
expect(onboardingSelectors.version(store)).toBe(CURRENT_ONBOARDING_VERSION);
});
});
describe('finishedAt', () => {
it('should return finishedAt when set', () => {
const finishedAt = '2024-01-01T00:00:00Z';
const store = {
...initialOnboardingState,
onboarding: { finishedAt, version: CURRENT_ONBOARDING_VERSION },
} as unknown as UserStore;
expect(onboardingSelectors.finishedAt(store)).toBe(finishedAt);
});
it('should return undefined when onboarding is undefined', () => {
const store = {
...initialOnboardingState,
onboarding: undefined,
} as unknown as UserStore;
expect(onboardingSelectors.finishedAt(store)).toBeUndefined();
});
});
describe('isFinished', () => {
it('should return true when finishedAt is set', () => {
const store = {
...initialOnboardingState,
onboarding: { finishedAt: '2024-01-01T00:00:00Z', version: CURRENT_ONBOARDING_VERSION },
} as unknown as UserStore;
expect(onboardingSelectors.isFinished(store)).toBe(true);
});
it('should return false when finishedAt is undefined', () => {
const store = {
...initialOnboardingState,
onboarding: { version: CURRENT_ONBOARDING_VERSION },
} as unknown as UserStore;
expect(onboardingSelectors.isFinished(store)).toBe(false);
});
it('should return false when onboarding is undefined', () => {
const store = {
...initialOnboardingState,
onboarding: undefined,
} as unknown as UserStore;
expect(onboardingSelectors.isFinished(store)).toBe(false);
});
});
describe('needsOnboarding', () => {
it('should return true when finishedAt is not set', () => {
const store = {
onboarding: { version: CURRENT_ONBOARDING_VERSION },
} as Pick<UserStore, 'onboarding'>;
expect(onboardingSelectors.needsOnboarding(store)).toBe(true);
});
it('should return true when version is older than current', () => {
// If CURRENT_ONBOARDING_VERSION > 1, test with version 1
// Otherwise, this test is not applicable since there's no valid older version
if (CURRENT_ONBOARDING_VERSION > 1) {
const store = {
onboarding: {
finishedAt: '2024-01-01T00:00:00Z',
version: 1,
},
} as Pick<UserStore, 'onboarding'>;
expect(onboardingSelectors.needsOnboarding(store)).toBe(true);
} else {
// When CURRENT_ONBOARDING_VERSION is 1, there's no valid older version (0 is falsy)
// Test that version 0 is treated as NOT needing onboarding due to falsy check
const store = {
onboarding: {
finishedAt: '2024-01-01T00:00:00Z',
version: 0,
},
} as Pick<UserStore, 'onboarding'>;
// version 0 is falsy, so the condition (version && version < CURRENT) short-circuits to 0 (falsy)
// finishedAt is set, so the first condition is false
// The result is falsy (0), not strictly false
expect(onboardingSelectors.needsOnboarding(store)).toBeFalsy();
}
});
it('should return false when finishedAt is set and version is current', () => {
const store = {
onboarding: {
finishedAt: '2024-01-01T00:00:00Z',
version: CURRENT_ONBOARDING_VERSION,
},
} as Pick<UserStore, 'onboarding'>;
expect(onboardingSelectors.needsOnboarding(store)).toBe(false);
});
it('should return true when onboarding is undefined', () => {
const store = {
onboarding: undefined,
} as Pick<UserStore, 'onboarding'>;
expect(onboardingSelectors.needsOnboarding(store)).toBe(true);
});
});
});

View File

@@ -1,12 +1,17 @@
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
import type { UserStore } from '../../store';
/**
* Returns the current step for UI display.
* Prioritizes local optimistic state over server state for immediate feedback.
* Clamps the value to valid range [1, MAX_ONBOARDING_STEPS].
*/
const currentStep = (s: UserStore) => s.localOnboardingStep ?? s.onboarding?.currentStep ?? 1;
const currentStep = (s: UserStore) => {
const step = s.localOnboardingStep ?? s.onboarding?.currentStep ?? 1;
return Math.max(1, Math.min(step, MAX_ONBOARDING_STEPS));
};
const version = (s: UserStore) => s.onboarding?.version ?? CURRENT_ONBOARDING_VERSION;