mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 fix(onboarding): prevent step overflow and misc improvements (#11322)
This commit is contained in:
@@ -330,7 +330,7 @@
|
||||
"switchToYearly.desc": "切换后,支付差价后立即生效年付计费,起始日期继承原计划。",
|
||||
"switchToYearly.title": "切换为年付",
|
||||
"tab.billing": "账单管理",
|
||||
"tab.funds": "点数管理",
|
||||
"tab.funds": "积分管理",
|
||||
"tab.plans": "订阅计划",
|
||||
"tab.referral": "推荐奖励",
|
||||
"tab.spend": "点数明细",
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
342
src/store/user/slices/onboarding/action.test.ts
Normal file
342
src/store/user/slices/onboarding/action.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
222
src/store/user/slices/onboarding/selectors.test.ts
Normal file
222
src/store/user/slices/onboarding/selectors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user