@lobehub/lobehub 2.0.0-next.236 → 2.0.0-next.238
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/changelog/v1.json +17 -0
- package/locales/en-US/oauth.json +1 -0
- package/locales/zh-CN/oauth.json +1 -0
- package/locales/zh-CN/subscription.json +1 -1
- package/package.json +1 -1
- package/packages/const/src/klavis.ts +1 -7
- package/packages/types/src/user/onboarding.ts +3 -1
- package/src/app/[variants]/(auth)/oauth/callback/success/page.tsx +44 -2
- package/src/app/[variants]/(main)/chat/_layout/Sidebar/Header/Nav.tsx +19 -1
- package/src/app/[variants]/(main)/chat/_layout/Sidebar/Header/index.tsx +1 -2
- package/src/app/[variants]/(main)/chat/_layout/Sidebar/Topic/List/Item/Editing.tsx +18 -9
- package/src/app/[variants]/(main)/settings/profile/features/KlavisAuthorizationList/index.tsx +6 -24
- package/src/app/[variants]/(main)/settings/profile/index.tsx +17 -3
- package/src/app/[variants]/onboarding/features/FullNameStep.tsx +18 -5
- package/src/app/[variants]/onboarding/features/InterestsStep.tsx +19 -7
- package/src/app/[variants]/onboarding/features/ProSettingsStep.tsx +30 -8
- package/src/app/[variants]/onboarding/features/ResponseLanguageStep.tsx +18 -4
- package/src/app/[variants]/onboarding/features/TelemetryStep.tsx +14 -5
- package/src/app/[variants]/onboarding/index.tsx +2 -1
- package/src/features/ChatInput/ActionBar/Tools/LobehubSkillServerItem.tsx +42 -2
- package/src/server/services/search/impls/exa/index.ts +1 -1
- package/src/server/services/search/impls/search1api/index.ts +1 -1
- package/src/server/services/search/impls/tavily/index.ts +1 -1
- package/src/store/tool/slices/klavisStore/action.test.ts +167 -2
- package/src/store/tool/slices/klavisStore/action.ts +9 -8
- package/src/store/tool/slices/klavisStore/initialState.ts +3 -0
- package/src/store/user/slices/auth/action.ts +1 -0
- package/src/store/user/slices/onboarding/action.test.ts +342 -0
- package/src/store/user/slices/onboarding/action.ts +4 -9
- package/src/store/user/slices/onboarding/selectors.test.ts +222 -0
- package/src/store/user/slices/onboarding/selectors.ts +6 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
|
|
2
|
+
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
|
|
3
|
+
import { act, renderHook } from '@testing-library/react';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { userService } from '@/services/user';
|
|
7
|
+
import { useUserStore } from '@/store/user';
|
|
8
|
+
|
|
9
|
+
import { initialOnboardingState } from './initialState';
|
|
10
|
+
|
|
11
|
+
vi.mock('zustand/traditional');
|
|
12
|
+
|
|
13
|
+
vi.mock('@/services/user', () => ({
|
|
14
|
+
userService: {
|
|
15
|
+
updateOnboarding: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe('onboarding actions', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
act(() => {
|
|
23
|
+
useUserStore.setState({
|
|
24
|
+
...initialOnboardingState,
|
|
25
|
+
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
|
|
26
|
+
refreshUserState: vi.fn(),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('goToNextStep', () => {
|
|
36
|
+
it('should increment step and set localOnboardingStep', () => {
|
|
37
|
+
const { result } = renderHook(() => useUserStore());
|
|
38
|
+
|
|
39
|
+
act(() => {
|
|
40
|
+
useUserStore.setState({
|
|
41
|
+
...initialOnboardingState,
|
|
42
|
+
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
act(() => {
|
|
47
|
+
result.current.goToNextStep();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.current.localOnboardingStep).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should not increment step when already at MAX_ONBOARDING_STEPS', () => {
|
|
54
|
+
const { result } = renderHook(() => useUserStore());
|
|
55
|
+
|
|
56
|
+
act(() => {
|
|
57
|
+
useUserStore.setState({
|
|
58
|
+
...initialOnboardingState,
|
|
59
|
+
localOnboardingStep: MAX_ONBOARDING_STEPS,
|
|
60
|
+
onboarding: { currentStep: MAX_ONBOARDING_STEPS, version: CURRENT_ONBOARDING_VERSION },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current.goToNextStep();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// localOnboardingStep should remain at MAX_ONBOARDING_STEPS
|
|
69
|
+
expect(result.current.localOnboardingStep).toBe(MAX_ONBOARDING_STEPS);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should queue step update when incrementing', () => {
|
|
73
|
+
const { result } = renderHook(() => useUserStore());
|
|
74
|
+
|
|
75
|
+
const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
|
|
76
|
+
|
|
77
|
+
act(() => {
|
|
78
|
+
useUserStore.setState({
|
|
79
|
+
...initialOnboardingState,
|
|
80
|
+
onboarding: { currentStep: 2, version: CURRENT_ONBOARDING_VERSION },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
act(() => {
|
|
85
|
+
result.current.goToNextStep();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(queueStepUpdateSpy).toHaveBeenCalledWith(3);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should not queue step update when at MAX_ONBOARDING_STEPS', () => {
|
|
92
|
+
const { result } = renderHook(() => useUserStore());
|
|
93
|
+
|
|
94
|
+
const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
|
|
95
|
+
|
|
96
|
+
act(() => {
|
|
97
|
+
useUserStore.setState({
|
|
98
|
+
...initialOnboardingState,
|
|
99
|
+
localOnboardingStep: MAX_ONBOARDING_STEPS,
|
|
100
|
+
onboarding: { currentStep: MAX_ONBOARDING_STEPS, version: CURRENT_ONBOARDING_VERSION },
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
act(() => {
|
|
105
|
+
result.current.goToNextStep();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(queueStepUpdateSpy).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('goToPreviousStep', () => {
|
|
113
|
+
it('should decrement step and set localOnboardingStep', () => {
|
|
114
|
+
const { result } = renderHook(() => useUserStore());
|
|
115
|
+
|
|
116
|
+
act(() => {
|
|
117
|
+
useUserStore.setState({
|
|
118
|
+
...initialOnboardingState,
|
|
119
|
+
localOnboardingStep: 3,
|
|
120
|
+
onboarding: { currentStep: 3, version: CURRENT_ONBOARDING_VERSION },
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
act(() => {
|
|
125
|
+
result.current.goToPreviousStep();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.current.localOnboardingStep).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should not decrement step when already at step 1', () => {
|
|
132
|
+
const { result } = renderHook(() => useUserStore());
|
|
133
|
+
|
|
134
|
+
act(() => {
|
|
135
|
+
useUserStore.setState({
|
|
136
|
+
...initialOnboardingState,
|
|
137
|
+
localOnboardingStep: 1,
|
|
138
|
+
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
result.current.goToPreviousStep();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// localOnboardingStep should remain at 1
|
|
147
|
+
expect(result.current.localOnboardingStep).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should queue step update when decrementing', () => {
|
|
151
|
+
const { result } = renderHook(() => useUserStore());
|
|
152
|
+
|
|
153
|
+
const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
|
|
154
|
+
|
|
155
|
+
act(() => {
|
|
156
|
+
useUserStore.setState({
|
|
157
|
+
...initialOnboardingState,
|
|
158
|
+
localOnboardingStep: 3,
|
|
159
|
+
onboarding: { currentStep: 3, version: CURRENT_ONBOARDING_VERSION },
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
result.current.goToPreviousStep();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(queueStepUpdateSpy).toHaveBeenCalledWith(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should not queue step update when at step 1', () => {
|
|
171
|
+
const { result } = renderHook(() => useUserStore());
|
|
172
|
+
|
|
173
|
+
const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
|
|
174
|
+
|
|
175
|
+
act(() => {
|
|
176
|
+
useUserStore.setState({
|
|
177
|
+
...initialOnboardingState,
|
|
178
|
+
localOnboardingStep: 1,
|
|
179
|
+
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
act(() => {
|
|
184
|
+
result.current.goToPreviousStep();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(queueStepUpdateSpy).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('internal_queueStepUpdate', () => {
|
|
192
|
+
it('should add task to empty queue and start processing', () => {
|
|
193
|
+
const { result } = renderHook(() => useUserStore());
|
|
194
|
+
|
|
195
|
+
act(() => {
|
|
196
|
+
useUserStore.setState({
|
|
197
|
+
...initialOnboardingState,
|
|
198
|
+
stepUpdateQueue: [],
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const processSpy = vi.spyOn(result.current, 'internal_processStepUpdateQueue');
|
|
203
|
+
|
|
204
|
+
act(() => {
|
|
205
|
+
result.current.internal_queueStepUpdate(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result.current.stepUpdateQueue).toContain(2);
|
|
209
|
+
expect(processSpy).toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should add pending task when one task is executing', () => {
|
|
213
|
+
const { result } = renderHook(() => useUserStore());
|
|
214
|
+
|
|
215
|
+
act(() => {
|
|
216
|
+
useUserStore.setState({
|
|
217
|
+
...initialOnboardingState,
|
|
218
|
+
stepUpdateQueue: [2],
|
|
219
|
+
isProcessingStepQueue: true,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
act(() => {
|
|
224
|
+
result.current.internal_queueStepUpdate(3);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(result.current.stepUpdateQueue).toEqual([2, 3]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should replace pending task when queue has two tasks', () => {
|
|
231
|
+
const { result } = renderHook(() => useUserStore());
|
|
232
|
+
|
|
233
|
+
act(() => {
|
|
234
|
+
useUserStore.setState({
|
|
235
|
+
...initialOnboardingState,
|
|
236
|
+
stepUpdateQueue: [2, 3],
|
|
237
|
+
isProcessingStepQueue: true,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
act(() => {
|
|
242
|
+
result.current.internal_queueStepUpdate(4);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(result.current.stepUpdateQueue).toEqual([2, 4]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('internal_processStepUpdateQueue', () => {
|
|
250
|
+
it('should not process when already processing', async () => {
|
|
251
|
+
const { result } = renderHook(() => useUserStore());
|
|
252
|
+
|
|
253
|
+
act(() => {
|
|
254
|
+
useUserStore.setState({
|
|
255
|
+
...initialOnboardingState,
|
|
256
|
+
stepUpdateQueue: [2],
|
|
257
|
+
isProcessingStepQueue: true,
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await act(async () => {
|
|
262
|
+
await result.current.internal_processStepUpdateQueue();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// userService.updateOnboarding should not be called
|
|
266
|
+
expect(userService.updateOnboarding).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should not process when queue is empty', async () => {
|
|
270
|
+
const { result } = renderHook(() => useUserStore());
|
|
271
|
+
|
|
272
|
+
act(() => {
|
|
273
|
+
useUserStore.setState({
|
|
274
|
+
...initialOnboardingState,
|
|
275
|
+
stepUpdateQueue: [],
|
|
276
|
+
isProcessingStepQueue: false,
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await act(async () => {
|
|
281
|
+
await result.current.internal_processStepUpdateQueue();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(userService.updateOnboarding).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should process queue and call userService.updateOnboarding', async () => {
|
|
288
|
+
const { result } = renderHook(() => useUserStore());
|
|
289
|
+
|
|
290
|
+
vi.mocked(userService.updateOnboarding).mockResolvedValue({} as any);
|
|
291
|
+
|
|
292
|
+
act(() => {
|
|
293
|
+
useUserStore.setState({
|
|
294
|
+
...initialOnboardingState,
|
|
295
|
+
stepUpdateQueue: [2],
|
|
296
|
+
isProcessingStepQueue: false,
|
|
297
|
+
onboarding: { version: CURRENT_ONBOARDING_VERSION },
|
|
298
|
+
refreshUserState: vi.fn(),
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await act(async () => {
|
|
303
|
+
await result.current.internal_processStepUpdateQueue();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(userService.updateOnboarding).toHaveBeenCalledWith({
|
|
307
|
+
currentStep: 2,
|
|
308
|
+
finishedAt: undefined,
|
|
309
|
+
version: CURRENT_ONBOARDING_VERSION,
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should handle errors gracefully and continue processing', async () => {
|
|
314
|
+
const { result } = renderHook(() => useUserStore());
|
|
315
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
316
|
+
|
|
317
|
+
vi.mocked(userService.updateOnboarding).mockRejectedValueOnce(new Error('Update failed'));
|
|
318
|
+
|
|
319
|
+
act(() => {
|
|
320
|
+
useUserStore.setState({
|
|
321
|
+
...initialOnboardingState,
|
|
322
|
+
stepUpdateQueue: [2],
|
|
323
|
+
isProcessingStepQueue: false,
|
|
324
|
+
onboarding: { version: CURRENT_ONBOARDING_VERSION },
|
|
325
|
+
refreshUserState: vi.fn(),
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await act(async () => {
|
|
330
|
+
await result.current.internal_processStepUpdateQueue();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
334
|
+
'Failed to update onboarding step:',
|
|
335
|
+
expect.any(Error),
|
|
336
|
+
);
|
|
337
|
+
expect(result.current.isProcessingStepQueue).toBe(false);
|
|
338
|
+
|
|
339
|
+
consoleErrorSpy.mockRestore();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CURRENT_ONBOARDING_VERSION, INBOX_SESSION_ID } from '@lobechat/const';
|
|
2
|
+
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
|
|
2
3
|
import type { StateCreator } from 'zustand/vanilla';
|
|
3
4
|
|
|
4
5
|
import { userService } from '@/services/user';
|
|
@@ -51,25 +52,19 @@ export const createOnboardingSlice: StateCreator<
|
|
|
51
52
|
|
|
52
53
|
goToNextStep: () => {
|
|
53
54
|
const currentStep = onboardingSelectors.currentStep(get());
|
|
54
|
-
|
|
55
|
+
if (currentStep === MAX_ONBOARDING_STEPS) return;
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
const nextStep = currentStep + 1;
|
|
57
58
|
set({ localOnboardingStep: nextStep }, false, 'goToNextStep/optimistic');
|
|
58
|
-
|
|
59
|
-
// Queue the server update
|
|
60
59
|
get().internal_queueStepUpdate(nextStep);
|
|
61
60
|
},
|
|
62
61
|
|
|
63
62
|
goToPreviousStep: () => {
|
|
64
63
|
const currentStep = onboardingSelectors.currentStep(get());
|
|
65
|
-
if (currentStep
|
|
64
|
+
if (currentStep === 1) return;
|
|
66
65
|
|
|
67
66
|
const prevStep = currentStep - 1;
|
|
68
|
-
|
|
69
|
-
// Optimistic update: immediately update local state
|
|
70
67
|
set({ localOnboardingStep: prevStep }, false, 'goToPreviousStep/optimistic');
|
|
71
|
-
|
|
72
|
-
// Queue the server update
|
|
73
68
|
get().internal_queueStepUpdate(prevStep);
|
|
74
69
|
},
|
|
75
70
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
|
|
2
|
+
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import type { UserStore } from '@/store/user';
|
|
6
|
+
|
|
7
|
+
import { initialOnboardingState } from './initialState';
|
|
8
|
+
import { onboardingSelectors } from './selectors';
|
|
9
|
+
|
|
10
|
+
describe('onboardingSelectors', () => {
|
|
11
|
+
describe('currentStep', () => {
|
|
12
|
+
it('should return localOnboardingStep when set', () => {
|
|
13
|
+
const store = {
|
|
14
|
+
...initialOnboardingState,
|
|
15
|
+
localOnboardingStep: 3,
|
|
16
|
+
onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
|
|
17
|
+
} as unknown as UserStore;
|
|
18
|
+
|
|
19
|
+
expect(onboardingSelectors.currentStep(store)).toBe(3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return onboarding.currentStep when localOnboardingStep is undefined', () => {
|
|
23
|
+
const store = {
|
|
24
|
+
...initialOnboardingState,
|
|
25
|
+
localOnboardingStep: undefined,
|
|
26
|
+
onboarding: { currentStep: 4, version: CURRENT_ONBOARDING_VERSION },
|
|
27
|
+
} as unknown as UserStore;
|
|
28
|
+
|
|
29
|
+
expect(onboardingSelectors.currentStep(store)).toBe(4);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return 1 when both localOnboardingStep and onboarding.currentStep are undefined', () => {
|
|
33
|
+
const store = {
|
|
34
|
+
...initialOnboardingState,
|
|
35
|
+
localOnboardingStep: undefined,
|
|
36
|
+
onboarding: undefined,
|
|
37
|
+
} as unknown as UserStore;
|
|
38
|
+
|
|
39
|
+
expect(onboardingSelectors.currentStep(store)).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should clamp step to minimum of 1 when step is less than 1', () => {
|
|
43
|
+
const store = {
|
|
44
|
+
...initialOnboardingState,
|
|
45
|
+
localOnboardingStep: 0,
|
|
46
|
+
onboarding: undefined,
|
|
47
|
+
} as unknown as UserStore;
|
|
48
|
+
|
|
49
|
+
expect(onboardingSelectors.currentStep(store)).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should clamp step to minimum of 1 when step is negative', () => {
|
|
53
|
+
const store = {
|
|
54
|
+
...initialOnboardingState,
|
|
55
|
+
localOnboardingStep: -5,
|
|
56
|
+
onboarding: undefined,
|
|
57
|
+
} as unknown as UserStore;
|
|
58
|
+
|
|
59
|
+
expect(onboardingSelectors.currentStep(store)).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should clamp step to MAX_ONBOARDING_STEPS when step exceeds maximum', () => {
|
|
63
|
+
const store = {
|
|
64
|
+
...initialOnboardingState,
|
|
65
|
+
localOnboardingStep: 10,
|
|
66
|
+
onboarding: undefined,
|
|
67
|
+
} as unknown as UserStore;
|
|
68
|
+
|
|
69
|
+
expect(onboardingSelectors.currentStep(store)).toBe(MAX_ONBOARDING_STEPS);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should clamp server state step to MAX_ONBOARDING_STEPS when it exceeds maximum', () => {
|
|
73
|
+
const store = {
|
|
74
|
+
...initialOnboardingState,
|
|
75
|
+
localOnboardingStep: undefined,
|
|
76
|
+
onboarding: { currentStep: 29, version: CURRENT_ONBOARDING_VERSION },
|
|
77
|
+
} as unknown as UserStore;
|
|
78
|
+
|
|
79
|
+
expect(onboardingSelectors.currentStep(store)).toBe(MAX_ONBOARDING_STEPS);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should return exact step when within valid range', () => {
|
|
83
|
+
for (let step = 1; step <= MAX_ONBOARDING_STEPS; step++) {
|
|
84
|
+
const store = {
|
|
85
|
+
...initialOnboardingState,
|
|
86
|
+
localOnboardingStep: step,
|
|
87
|
+
onboarding: undefined,
|
|
88
|
+
} as unknown as UserStore;
|
|
89
|
+
|
|
90
|
+
expect(onboardingSelectors.currentStep(store)).toBe(step);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('version', () => {
|
|
96
|
+
it('should return onboarding version', () => {
|
|
97
|
+
const store = {
|
|
98
|
+
...initialOnboardingState,
|
|
99
|
+
onboarding: { version: 2 },
|
|
100
|
+
} as unknown as UserStore;
|
|
101
|
+
|
|
102
|
+
expect(onboardingSelectors.version(store)).toBe(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should return CURRENT_ONBOARDING_VERSION when onboarding is undefined', () => {
|
|
106
|
+
const store = {
|
|
107
|
+
...initialOnboardingState,
|
|
108
|
+
onboarding: undefined,
|
|
109
|
+
} as unknown as UserStore;
|
|
110
|
+
|
|
111
|
+
expect(onboardingSelectors.version(store)).toBe(CURRENT_ONBOARDING_VERSION);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('finishedAt', () => {
|
|
116
|
+
it('should return finishedAt when set', () => {
|
|
117
|
+
const finishedAt = '2024-01-01T00:00:00Z';
|
|
118
|
+
const store = {
|
|
119
|
+
...initialOnboardingState,
|
|
120
|
+
onboarding: { finishedAt, version: CURRENT_ONBOARDING_VERSION },
|
|
121
|
+
} as unknown as UserStore;
|
|
122
|
+
|
|
123
|
+
expect(onboardingSelectors.finishedAt(store)).toBe(finishedAt);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return undefined when onboarding is undefined', () => {
|
|
127
|
+
const store = {
|
|
128
|
+
...initialOnboardingState,
|
|
129
|
+
onboarding: undefined,
|
|
130
|
+
} as unknown as UserStore;
|
|
131
|
+
|
|
132
|
+
expect(onboardingSelectors.finishedAt(store)).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('isFinished', () => {
|
|
137
|
+
it('should return true when finishedAt is set', () => {
|
|
138
|
+
const store = {
|
|
139
|
+
...initialOnboardingState,
|
|
140
|
+
onboarding: { finishedAt: '2024-01-01T00:00:00Z', version: CURRENT_ONBOARDING_VERSION },
|
|
141
|
+
} as unknown as UserStore;
|
|
142
|
+
|
|
143
|
+
expect(onboardingSelectors.isFinished(store)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should return false when finishedAt is undefined', () => {
|
|
147
|
+
const store = {
|
|
148
|
+
...initialOnboardingState,
|
|
149
|
+
onboarding: { version: CURRENT_ONBOARDING_VERSION },
|
|
150
|
+
} as unknown as UserStore;
|
|
151
|
+
|
|
152
|
+
expect(onboardingSelectors.isFinished(store)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should return false when onboarding is undefined', () => {
|
|
156
|
+
const store = {
|
|
157
|
+
...initialOnboardingState,
|
|
158
|
+
onboarding: undefined,
|
|
159
|
+
} as unknown as UserStore;
|
|
160
|
+
|
|
161
|
+
expect(onboardingSelectors.isFinished(store)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('needsOnboarding', () => {
|
|
166
|
+
it('should return true when finishedAt is not set', () => {
|
|
167
|
+
const store = {
|
|
168
|
+
onboarding: { version: CURRENT_ONBOARDING_VERSION },
|
|
169
|
+
} as Pick<UserStore, 'onboarding'>;
|
|
170
|
+
|
|
171
|
+
expect(onboardingSelectors.needsOnboarding(store)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should return true when version is older than current', () => {
|
|
175
|
+
// If CURRENT_ONBOARDING_VERSION > 1, test with version 1
|
|
176
|
+
// Otherwise, this test is not applicable since there's no valid older version
|
|
177
|
+
if (CURRENT_ONBOARDING_VERSION > 1) {
|
|
178
|
+
const store = {
|
|
179
|
+
onboarding: {
|
|
180
|
+
finishedAt: '2024-01-01T00:00:00Z',
|
|
181
|
+
version: 1,
|
|
182
|
+
},
|
|
183
|
+
} as Pick<UserStore, 'onboarding'>;
|
|
184
|
+
|
|
185
|
+
expect(onboardingSelectors.needsOnboarding(store)).toBe(true);
|
|
186
|
+
} else {
|
|
187
|
+
// When CURRENT_ONBOARDING_VERSION is 1, there's no valid older version (0 is falsy)
|
|
188
|
+
// Test that version 0 is treated as NOT needing onboarding due to falsy check
|
|
189
|
+
const store = {
|
|
190
|
+
onboarding: {
|
|
191
|
+
finishedAt: '2024-01-01T00:00:00Z',
|
|
192
|
+
version: 0,
|
|
193
|
+
},
|
|
194
|
+
} as Pick<UserStore, 'onboarding'>;
|
|
195
|
+
|
|
196
|
+
// version 0 is falsy, so the condition (version && version < CURRENT) short-circuits to 0 (falsy)
|
|
197
|
+
// finishedAt is set, so the first condition is false
|
|
198
|
+
// The result is falsy (0), not strictly false
|
|
199
|
+
expect(onboardingSelectors.needsOnboarding(store)).toBeFalsy();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should return false when finishedAt is set and version is current', () => {
|
|
204
|
+
const store = {
|
|
205
|
+
onboarding: {
|
|
206
|
+
finishedAt: '2024-01-01T00:00:00Z',
|
|
207
|
+
version: CURRENT_ONBOARDING_VERSION,
|
|
208
|
+
},
|
|
209
|
+
} as Pick<UserStore, 'onboarding'>;
|
|
210
|
+
|
|
211
|
+
expect(onboardingSelectors.needsOnboarding(store)).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should return true when onboarding is undefined', () => {
|
|
215
|
+
const store = {
|
|
216
|
+
onboarding: undefined,
|
|
217
|
+
} as Pick<UserStore, 'onboarding'>;
|
|
218
|
+
|
|
219
|
+
expect(onboardingSelectors.needsOnboarding(store)).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
|
|
2
|
+
import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
|
|
2
3
|
|
|
3
4
|
import type { UserStore } from '../../store';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Returns the current step for UI display.
|
|
7
8
|
* Prioritizes local optimistic state over server state for immediate feedback.
|
|
9
|
+
* Clamps the value to valid range [1, MAX_ONBOARDING_STEPS].
|
|
8
10
|
*/
|
|
9
|
-
const currentStep = (s: UserStore) =>
|
|
11
|
+
const currentStep = (s: UserStore) => {
|
|
12
|
+
const step = s.localOnboardingStep ?? s.onboarding?.currentStep ?? 1;
|
|
13
|
+
return Math.max(1, Math.min(step, MAX_ONBOARDING_STEPS));
|
|
14
|
+
};
|
|
10
15
|
|
|
11
16
|
const version = (s: UserStore) => s.onboarding?.version ?? CURRENT_ONBOARDING_VERSION;
|
|
12
17
|
|