feat: add TourProgressController and related entities for user tour progress tracking
- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users. - Created UserTourProgress entity to store the highest version of tours seen by each user. - Developed UserTourProgressRepository for database interactions related to user tour progress. - Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps. - Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { renderHookWithClient } from '@/test/utils';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
const drive = vi.fn();
|
||||
const destroyHandlers: Array<() => void> = [];
|
||||
|
||||
vi.mock('driver.js', () => ({
|
||||
driver: vi.fn((config: { onDestroyed?: () => void }) => {
|
||||
if (config.onDestroyed) destroyHandlers.push(config.onDestroyed);
|
||||
return { drive };
|
||||
}),
|
||||
}));
|
||||
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||
|
||||
import { driver } from 'driver.js';
|
||||
import { api } from '@/lib/api';
|
||||
import { useTour } from '@/hooks/useTour';
|
||||
import { useTourProgress } from '@/hooks/useTourProgress';
|
||||
import { appointmentsTour } from '@/lib/tour/tours/appointments';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
const driverMock = driver as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function mountAppointmentsAnchors() {
|
||||
document.body.innerHTML = appointmentsTour.steps
|
||||
.map((s) => `<div data-tour="${s.anchor}"></div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
document.body.innerHTML = '';
|
||||
destroyHandlers.length = 0;
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
drive.mockReset();
|
||||
driverMock.mockClear();
|
||||
});
|
||||
|
||||
describe('useTourProgress', () => {
|
||||
it('نقشهٔ دیدهشدهها را از سرور میخواند', async () => {
|
||||
get.mockResolvedValue({ data: { seen: { appointments: 2 } } });
|
||||
|
||||
const { result } = renderHookWithClient(() => useTourProgress());
|
||||
|
||||
await waitFor(() => expect(result.current.isReady).toBe(true));
|
||||
expect(result.current.isSeen('appointments', 2)).toBe(true);
|
||||
expect(result.current.isSeen('appointments', 3)).toBe(false);
|
||||
expect(result.current.isSeen('patients', 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('ثبت دیدهشدن، کش را بدون درخواست دوباره بهروز میکند', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
post.mockResolvedValue({ data: { tourId: 'appointments', version: 1 } });
|
||||
|
||||
const { result } = renderHookWithClient(() => useTourProgress());
|
||||
await waitFor(() => expect(result.current.isReady).toBe(true));
|
||||
|
||||
result.current.markSeen('appointments', 1);
|
||||
|
||||
await waitFor(() => expect(result.current.isSeen('appointments', 1)).toBe(true));
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', { version: 1 });
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTour', () => {
|
||||
it('تور دیدهنشده را بعد از آماده شدن صفحه خودکار اجرا میکند', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
mountAppointmentsAnchors();
|
||||
|
||||
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||
|
||||
await waitFor(() => expect(driverMock).toHaveBeenCalled());
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
|
||||
expect(drive).toHaveBeenCalledTimes(1);
|
||||
expect(driverMock.mock.calls[0][0].steps).toHaveLength(appointmentsTour.steps.length);
|
||||
});
|
||||
|
||||
it('شمارندهٔ استپها با ارقام فارسی است', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
mountAppointmentsAnchors();
|
||||
|
||||
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
await waitFor(() => expect(drive).toHaveBeenCalled());
|
||||
|
||||
const steps = driverMock.mock.calls[0][0].steps;
|
||||
expect(steps[0].popover.progressText).toBe('۱ از ۸');
|
||||
expect(steps[7].popover.progressText).toBe('۸ از ۸');
|
||||
});
|
||||
|
||||
it('رندرهای پیاپی قبل از شلیک تایمر، اجرای خودکار را لغو نمیکنند', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
mountAppointmentsAnchors();
|
||||
|
||||
const { rerender } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||
|
||||
// هر رندر تابع start تازهای میسازد؛ اگر وابستگیِ effect باشد، cleanup
|
||||
// تایمر را پاک میکند و تور هرگز اجرا نمیشود.
|
||||
for (let i = 0; i < 5; i++) rerender();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
await waitFor(() => expect(drive).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('استپِ بدون المان را حذف میکند', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
document.body.innerHTML = '<div data-tour="appointments-stats"></div>';
|
||||
|
||||
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
await waitFor(() => expect(drive).toHaveBeenCalled());
|
||||
|
||||
expect(driverMock.mock.calls[0][0].steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('تور دیدهشده خودکار اجرا نمیشود', async () => {
|
||||
get.mockResolvedValue({ data: { seen: { appointments: appointmentsTour.version } } });
|
||||
mountAppointmentsAnchors();
|
||||
|
||||
const { result } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(drive).not.toHaveBeenCalled();
|
||||
|
||||
// ولی دکمهٔ راهنما همچنان کار میکند
|
||||
result.current.start();
|
||||
expect(drive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('تا وقتی وضعیت از سرور نیامده اجرا نمیشود', async () => {
|
||||
get.mockRejectedValue(new Error('network down'));
|
||||
mountAppointmentsAnchors();
|
||||
|
||||
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(drive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('صفحهای که هنوز آماده نیست تور نمیگیرد', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
mountAppointmentsAnchors();
|
||||
|
||||
renderHookWithClient(() => useTour('appointments', { ready: false }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(drive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('شناسهٔ ناشناس نه دکمه دارد نه اجرا', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
|
||||
const { result } = renderHookWithClient(() => useTour('nope', { ready: true }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(result.current.available).toBe(false);
|
||||
|
||||
result.current.start();
|
||||
expect(drive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('وقتی هیچ المانی روی صفحه نیست، اجرا نمیشود', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
|
||||
const { result } = renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||
await waitFor(() => expect(result.current.available).toBe(true));
|
||||
|
||||
result.current.start();
|
||||
expect(drive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('بستن تور، دیدهشدن را ثبت میکند', async () => {
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
post.mockResolvedValue({ data: { tourId: 'appointments', version: appointmentsTour.version } });
|
||||
mountAppointmentsAnchors();
|
||||
|
||||
renderHookWithClient(() => useTour('appointments', { ready: true }));
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
await waitFor(() => expect(destroyHandlers).toHaveLength(1));
|
||||
|
||||
destroyHandlers[0]();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/my/tours/appointments/seen', {
|
||||
version: appointmentsTour.version,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { driver } from 'driver.js';
|
||||
import 'driver.js/dist/driver.css';
|
||||
import { getTour } from '../lib/tour/registry';
|
||||
import { anchorSelector, resolveSteps } from '../lib/tour/resolveSteps';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import { useTourProgress } from './useTourProgress';
|
||||
|
||||
interface Options {
|
||||
/** وقتی true شد یعنی دادهٔ صفحه آمده و المانهای هدف رندر شدهاند */
|
||||
ready?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* راهنمای قدمبهقدم یک صفحه. بار اول خودکار اجرا میشود و بعد از آن فقط با
|
||||
* صدا زدن start — یعنی دکمهٔ «؟» صفحه.
|
||||
*/
|
||||
export function useTour(tourId?: string, { ready = false }: Options = {}) {
|
||||
const tour = getTour(tourId);
|
||||
const { isReady, isSeen, markSeen } = useTourProgress();
|
||||
const autoStarted = useRef(false);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!tour) return;
|
||||
|
||||
const steps = resolveSteps(tour.steps);
|
||||
if (steps.length === 0) return;
|
||||
|
||||
driver({
|
||||
showProgress: true,
|
||||
allowClose: true,
|
||||
overlayOpacity: 0.55,
|
||||
popoverClass: 'cp-tour',
|
||||
nextBtnText: 'بعدی',
|
||||
prevBtnText: 'قبلی',
|
||||
doneBtnText: 'باشه، فهمیدم',
|
||||
steps: steps.map((s, i) => ({
|
||||
element: anchorSelector(s.anchor),
|
||||
popover: {
|
||||
title: s.title,
|
||||
description: s.body,
|
||||
side: s.side ?? 'bottom',
|
||||
align: 'start',
|
||||
// قالبِ سراسری driver فقط {{current}} میدهد و آن ارقام لاتین است؛
|
||||
// شمارنده باید مثل بقیهٔ پنل فارسی باشد.
|
||||
progressText: `${formatNumber(i + 1)} از ${formatNumber(steps.length)}`,
|
||||
},
|
||||
})),
|
||||
// بستن وسط تور هم «دیده شده» است؛ تکرارش برای کسی که ردش کرده آزار است.
|
||||
onDestroyed: () => markSeen(tour.id, tour.version),
|
||||
}).drive();
|
||||
}, [tour, markSeen]);
|
||||
|
||||
// `start` با هر رندر بازساخته میشود؛ اگر وابستگیِ effect باشد، cleanup تایمرِ
|
||||
// سیصد میلیثانیهای را قبل از شلیک پاک میکند و تور هرگز اجرا نمیشود.
|
||||
const startRef = useRef(start);
|
||||
startRef.current = start;
|
||||
|
||||
// مقدار boolean وابستگیِ پایداری است، برخلاف خودِ تابع isSeen.
|
||||
const alreadySeen = tour ? isSeen(tour.id, tour.version) : true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!tour || !ready || autoStarted.current) return;
|
||||
// تا پاسخ سرور نیامده هیچ چیز اجرا نمیشود؛ وگرنه در خطای شبکه کاربر قدیمی
|
||||
// هر بار رفرش یک تور میبیند.
|
||||
if (!isReady || alreadySeen) return;
|
||||
|
||||
autoStarted.current = true;
|
||||
// یک لحظه صبر تا چیدمان نهایی بنشیند و highlight سرِ جای درست بیفتد.
|
||||
const timer = window.setTimeout(() => startRef.current(), 300);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [tour, ready, isReady, alreadySeen]);
|
||||
|
||||
return { available: tour !== null, start };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
|
||||
/** tour id => بالاترین نسخهای که کاربر دیده است */
|
||||
type SeenMap = Record<string, number>;
|
||||
|
||||
export const TOURS_QUERY_KEY = ['my-tours'] as const;
|
||||
|
||||
/**
|
||||
* وضعیت «دیدهشده» روی حساب کاربر ذخیره میشود نه روی مرورگر، تا با عوض کردن
|
||||
* دستگاه یا مرورگر تورها دوباره از سر اجرا نشوند.
|
||||
*/
|
||||
export function useTourProgress() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: TOURS_QUERY_KEY,
|
||||
queryFn: () => api.get<ApiResponse<{ seen: SeenMap }>>('/api/v1/my/tours'),
|
||||
// در طول یک نشست عوض نمیشود مگر با همین mutation، پس refetch بیفایده است.
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const seen: SeenMap = query.data?.data?.seen ?? {};
|
||||
|
||||
const markSeen = useMutation({
|
||||
mutationFn: ({ tourId, version }: { tourId: string; version: number }) =>
|
||||
api.post<ApiResponse<{ tourId: string; version: number }>>(`/api/v1/my/tours/${tourId}/seen`, { version }),
|
||||
// کش را بدون رفتوبرگشت اضافه بهروز میکند؛ سرور همین مقدار را برمیگرداند.
|
||||
onSuccess: (_data, { tourId, version }) => {
|
||||
queryClient.setQueryData<ApiResponse<{ seen: SeenMap }>>(TOURS_QUERY_KEY, (prev) => {
|
||||
const previous = prev?.data?.seen ?? {};
|
||||
return {
|
||||
...(prev ?? { success: true, errors: [] as never[] }),
|
||||
data: { seen: { ...previous, [tourId]: Math.max(previous[tourId] ?? 0, version) } },
|
||||
} as ApiResponse<{ seen: SeenMap }>;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
seen,
|
||||
/** تا وقتی پاسخ سرور نیامده، هیچ توری خودکار اجرا نمیشود */
|
||||
isReady: query.isSuccess,
|
||||
isSeen: (tourId: string, version: number) => (seen[tourId] ?? 0) >= version,
|
||||
markSeen: (tourId: string, version: number) => markSeen.mutate({ tourId, version }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user