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:
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { ChevronLeftIcon } from '@heroicons/react/24/outline';
|
||||
import BackButton from './BackButton';
|
||||
import TourButton from './TourButton';
|
||||
|
||||
interface Crumb {
|
||||
label: string;
|
||||
@@ -18,9 +19,11 @@ interface Props {
|
||||
* مقدار، مقصدِ fallback است وقتی تاریخچهای برای برگشتن نیست.
|
||||
*/
|
||||
backTo?: string;
|
||||
/** شناسهٔ تور راهنمای این صفحه؛ اگر در registry ثبت نشده باشد دکمهای نمیآید. */
|
||||
tourId?: string;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, breadcrumbs, action, description, backTo }: Props) {
|
||||
export default function PageHeader({ title, breadcrumbs, action, description, backTo, tourId }: Props) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
@@ -48,7 +51,10 @@ export default function PageHeader({ title, breadcrumbs, action, description, ba
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<h1 className="section-title">{title}</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<h1 className="section-title">{title}</h1>
|
||||
<TourButton tourId={tourId} />
|
||||
</div>
|
||||
{description && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', marginTop: 4 }}>{description}</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } 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();
|
||||
vi.mock('driver.js', () => ({ driver: vi.fn(() => ({ drive })) }));
|
||||
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||
|
||||
import { api } from '@/lib/api';
|
||||
import TourButton from './TourButton';
|
||||
import PageHeader from './PageHeader';
|
||||
import { appointmentsTour } from '@/lib/tour/tours/appointments';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ data: { seen: {} } });
|
||||
drive.mockReset();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('TourButton', () => {
|
||||
it('برای تور ثبتشده دکمهٔ راهنما میآورد', () => {
|
||||
renderWithProviders(<TourButton tourId="appointments" />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('برای شناسهٔ ناشناس یا بدون شناسه چیزی رندر نمیکند', () => {
|
||||
const { container } = renderWithProviders(<TourButton tourId="does-not-exist" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
const { container: bare } = renderWithProviders(<TourButton />);
|
||||
expect(bare).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('کلیک، تور را روی المانهای موجود اجرا میکند', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<>
|
||||
<TourButton tourId="appointments" />
|
||||
{appointmentsTour.steps.map((s) => (
|
||||
<div key={s.anchor} data-tour={s.anchor} />
|
||||
))}
|
||||
</>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'راهنمای این صفحه' }));
|
||||
|
||||
expect(drive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageHeader', () => {
|
||||
it('با tourId دکمهٔ راهنما را کنار عنوان میگذارد', () => {
|
||||
renderWithProviders(<PageHeader title="نوبتها" tourId="appointments" />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'نوبتها' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('بدون tourId هیچ دکمهٔ راهنمایی ندارد', () => {
|
||||
renderWithProviders(<PageHeader title="نوبتها" />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'راهنمای این صفحه' })).toBeNull();
|
||||
});
|
||||
|
||||
it('صفحهٔ بدون تور هیچ درخواستی برای وضعیت تورها نمیفرستد', () => {
|
||||
renderWithProviders(<PageHeader title="نوبتها" />);
|
||||
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useTour } from '../../hooks/useTour';
|
||||
import { getTour } from '../../lib/tour/registry';
|
||||
|
||||
/**
|
||||
* دکمهٔ «راهنمای این صفحه».
|
||||
*
|
||||
* وجود تور قبل از هر هوکی بررسی میشود تا صفحهای که راهنما ندارد — یعنی بیشتر
|
||||
* صفحات پنل — هیچ درخواستی برای وضعیت تورها نفرستد.
|
||||
*/
|
||||
export default function TourButton({ tourId }: { tourId?: string }) {
|
||||
const tour = getTour(tourId);
|
||||
|
||||
if (!tour) return null;
|
||||
|
||||
return <TourLauncher tourId={tour.id} />;
|
||||
}
|
||||
|
||||
/** اجرای خودکار وظیفهٔ خود صفحه است؛ این دکمه فقط دستی اجرا میکند. */
|
||||
function TourLauncher({ tourId }: { tourId: string }) {
|
||||
const { start } = useTour(tourId);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="راهنمای این صفحه"
|
||||
title="راهنمای این صفحه"
|
||||
onClick={start}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 'var(--r-pill)',
|
||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-3)', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--primary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-3)')}
|
||||
>
|
||||
<QuestionMarkCircleIcon style={{ width: 20, height: 20 }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { TourDefinition } from './types';
|
||||
import { appointmentsTour } from './tours/appointments';
|
||||
|
||||
/** هر صفحه تور خودش را در tours/ دارد و فقط اینجا ثبت میشود. */
|
||||
export const TOURS: Record<string, TourDefinition> = {
|
||||
[appointmentsTour.id]: appointmentsTour,
|
||||
};
|
||||
|
||||
export function getTour(id?: string): TourDefinition | null {
|
||||
return id ? TOURS[id] ?? null : null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { TourStep } from './types';
|
||||
|
||||
export function anchorSelector(anchor: string): string {
|
||||
return `[data-tour="${anchor}"]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* فقط استپهایی میمانند که المانشان همین حالا در DOM هست.
|
||||
* صفحات پنل نقشمحورند: «افزودن نوبت» برای منشیِ بدون مجوز اصلاً رندر نمیشود و
|
||||
* تور نباید روی المان غایب گیر کند یا شمارندهٔ اشتباه نشان بدهد.
|
||||
*/
|
||||
export function resolveSteps(steps: TourStep[], root: ParentNode = document): TourStep[] {
|
||||
return steps.filter((s) => root.querySelector(anchorSelector(s.anchor)) !== null);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { anchorSelector, resolveSteps } from './resolveSteps';
|
||||
import { getTour, TOURS } from './registry';
|
||||
import type { TourStep } from './types';
|
||||
|
||||
const STEPS: TourStep[] = [
|
||||
{ anchor: 'one', title: 'یک', body: '…' },
|
||||
{ anchor: 'two', title: 'دو', body: '…' },
|
||||
{ anchor: 'three', title: 'سه', body: '…' },
|
||||
];
|
||||
|
||||
function mount(...anchors: string[]) {
|
||||
document.body.innerHTML = anchors.map((a) => `<div data-tour="${a}"></div>`).join('');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('resolveSteps', () => {
|
||||
it('استپهای موجود را با حفظ ترتیب نگه میدارد', () => {
|
||||
mount('three', 'one');
|
||||
|
||||
expect(resolveSteps(STEPS).map((s) => s.anchor)).toEqual(['one', 'three']);
|
||||
});
|
||||
|
||||
it('وقتی هیچ المانی نیست، خروجی خالی است', () => {
|
||||
expect(resolveSteps(STEPS)).toEqual([]);
|
||||
});
|
||||
|
||||
it('آرایهٔ خالی خروجی خالی میدهد', () => {
|
||||
mount('one');
|
||||
|
||||
expect(resolveSteps([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('سلکتور از روی anchor ساخته میشود', () => {
|
||||
expect(anchorSelector('appointments-stats')).toBe('[data-tour="appointments-stats"]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry', () => {
|
||||
it('برای شناسهٔ ناشناس یا خالی null میدهد', () => {
|
||||
expect(getTour('does-not-exist')).toBeNull();
|
||||
expect(getTour(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('تور نوبتها ثبت شده است', () => {
|
||||
expect(getTour('appointments')?.id).toBe('appointments');
|
||||
});
|
||||
|
||||
it('هر تور نسخهٔ معتبر و anchorهای بدون تکرار دارد', () => {
|
||||
for (const [id, tour] of Object.entries(TOURS)) {
|
||||
expect(tour.id).toBe(id);
|
||||
expect(tour.version).toBeGreaterThanOrEqual(1);
|
||||
expect(tour.steps.length).toBeGreaterThan(0);
|
||||
|
||||
const anchors = tour.steps.map((s) => s.anchor);
|
||||
expect(new Set(anchors).size).toBe(anchors.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { TourDefinition } from '../types';
|
||||
|
||||
export const appointmentsTour: TourDefinition = {
|
||||
id: 'appointments',
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
anchor: 'appointments-stats',
|
||||
title: 'آمار همین روز',
|
||||
body: 'تعداد کل نوبتها، انجامشده، در انتظار و لغوشده — همه برای روزی که پایین انتخاب کردهاید.',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
anchor: 'appointments-date',
|
||||
title: 'انتخاب روز',
|
||||
body: 'با فلشها یک روز جلو و عقب بروید، یا از آیکون تقویم یک تاریخ را مستقیم انتخاب کنید.',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
anchor: 'appointments-service',
|
||||
title: 'فیلتر خدمت',
|
||||
body: 'فقط نوبتهای یک خدمت مشخص را ببینید. برای روزهای شلوغ مفید است.',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
anchor: 'appointments-view',
|
||||
title: 'تایملاین یا جدول',
|
||||
body: 'تایملاین ساعتهای روز را کنار هم نشان میدهد. جدول همان نوبتها را فهرستوار میآورد.',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
anchor: 'appointments-filters',
|
||||
title: 'فیلترهای بیشتر',
|
||||
body: 'فیلتر بر اساس وضعیت نوبت، بیمه و بیمار. وقتی فیلتری فعال باشد، رنگ این دکمه عوض میشود.',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
anchor: 'appointments-new',
|
||||
title: 'ثبت نوبت جدید',
|
||||
body: 'نوبت را برای همان روز و همان پزشکِ انتخابشده باز میکند.',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
anchor: 'appointments-doctors',
|
||||
title: 'تب پزشکان',
|
||||
body: 'در کلینیک چندپزشکه، برنامهٔ هر پزشک را جدا ببینید.',
|
||||
side: 'bottom',
|
||||
},
|
||||
{
|
||||
anchor: 'appointments-list',
|
||||
title: 'خودِ نوبتها',
|
||||
body: 'با کلیک روی هر نوبت وارد جزئیات آن میشوید و میتوانید وضعیتش را عوض کنید.',
|
||||
side: 'top',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface TourStep {
|
||||
/** مقدار اتریبیوت data-tour روی المان هدف */
|
||||
anchor: string;
|
||||
title: string;
|
||||
body: string;
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface TourDefinition {
|
||||
/** شناسهٔ یکتا؛ همنام مسیر صفحه. سرور همین را ذخیره میکند، پس تغییرش یعنی تور از نو دیده میشود. */
|
||||
id: string;
|
||||
/** با هر بازنویسی متن تور یکی زیاد شود تا کاربر قدیمی هم یکبار دیگر آن را ببیند */
|
||||
version: number;
|
||||
steps: TourStep[];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } 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();
|
||||
vi.mock('driver.js', () => ({ driver: vi.fn(() => ({ drive })) }));
|
||||
vi.mock('driver.js/dist/driver.css', () => ({}));
|
||||
|
||||
import { driver } from 'driver.js';
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import AppointmentsPage from './AppointmentsPage';
|
||||
import { appointmentsTour } from '../lib/tour/tours/appointments';
|
||||
import { resolveSteps } from '../lib/tour/resolveSteps';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const driverMock = driver as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
/** پاسخهای ثابت صفحه؛ فقط وضعیت تور بین تستها فرق میکند. */
|
||||
function mockApi(seen: Record<string, number>) {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('/my/tours')) return Promise.resolve({ success: true, data: { seen } });
|
||||
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 7, completed: 3, waiting: 2, cancelled: 1 } });
|
||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
drive.mockReset();
|
||||
driverMock.mockClear();
|
||||
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1' } as any);
|
||||
});
|
||||
|
||||
describe('AppointmentsPage — راهنمای صفحه', () => {
|
||||
it('دکمهٔ راهنما کنار عنوان است', async () => {
|
||||
mockApi({});
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'راهنمای این صفحه' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('برای کاربری که تور را ندیده، خودکار اجرا میشود', async () => {
|
||||
mockApi({});
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
|
||||
await waitFor(() => expect(drive).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('برای کاربری که تور را دیده، خودکار اجرا نمیشود', async () => {
|
||||
mockApi({ appointments: appointmentsTour.version });
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
|
||||
await screen.findByText('این روز تعطیل است');
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
expect(drive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('پزشک مستقل تب پزشکان ندارد، پس آن استپ از تور حذف میشود', async () => {
|
||||
mockApi({});
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
|
||||
await waitFor(() => expect(drive).toHaveBeenCalled(), { timeout: 3000 });
|
||||
|
||||
const anchors = driverMock.mock.calls[0][0].steps.map((s: { element: string }) => s.element);
|
||||
expect(anchors).not.toContain('[data-tour="appointments-doctors"]');
|
||||
expect(anchors).toContain('[data-tour="appointments-stats"]');
|
||||
// شمارندهٔ تور همان تعداد استپِ واقعاً موجود است، نه کل تعریف
|
||||
expect(anchors).toHaveLength(resolveSteps(appointmentsTour.steps).length);
|
||||
expect(anchors.length).toBeLessThan(appointmentsTour.steps.length);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ import { useUrlState } from '../hooks/useUrlState';
|
||||
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
||||
import TurnsTable from '../components/appointments/TurnsTable';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useTour } from '../hooks/useTour';
|
||||
import TourButton from '../components/ui/TourButton';
|
||||
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
|
||||
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
|
||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||
@@ -68,7 +70,7 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
||||
<div ref={ref} data-tour="appointments-date" style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
||||
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
|
||||
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
@@ -417,14 +419,23 @@ export default function AppointmentsPage() {
|
||||
navigate(`/admin/appointments/${a.uuid}`);
|
||||
}
|
||||
|
||||
// تور بار اول فقط وقتی راه میافتد که نوبتها آمده باشند؛ قبل از آن نیمی از
|
||||
// المانهای هدف هنوز روی صفحه نیستند.
|
||||
useTour('appointments', { ready: !apptQuery.isLoading });
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<div style={{ maxWidth: 1050, margin: '0 auto' }}>
|
||||
{/* عنوان */}
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 16 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)' }}>نوبت ها</h1>
|
||||
<TourButton tourId="appointments" />
|
||||
</div>
|
||||
|
||||
{/* نوار آمار */}
|
||||
<TurnsStatInfo stats={stats} />
|
||||
<div data-tour="appointments-stats">
|
||||
<TurnsStatInfo stats={stats} />
|
||||
</div>
|
||||
|
||||
{/* نوار ابزار (بیرونِ کارت، مطابق طرح) */}
|
||||
<div style={{
|
||||
@@ -454,13 +465,16 @@ export default function AppointmentsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||
<div data-tour="appointments-view" style={{ display: 'flex' }}>
|
||||
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* سمت چپ: فیلتر + افزودن نوبت */}
|
||||
<button
|
||||
aria-label="فیلترها"
|
||||
data-tour="appointments-filters"
|
||||
className="btn sm"
|
||||
onClick={() => setFiltersOpen(true)}
|
||||
style={{
|
||||
@@ -476,6 +490,7 @@ export default function AppointmentsPage() {
|
||||
{!isRepresentation && canCreateAppt && (
|
||||
<button
|
||||
className="btn primary sm"
|
||||
data-tour="appointments-new"
|
||||
onClick={() => {
|
||||
// تب منبع فعال است ⇒ نوبت برای همان منبع، با سرویسهای خودش.
|
||||
if (activeResource) { setBookingResource(activeResource); return; }
|
||||
@@ -491,21 +506,23 @@ export default function AppointmentsPage() {
|
||||
</div>
|
||||
|
||||
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
||||
<div style={{
|
||||
<div data-tour="appointments-list" style={{
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', overflow: 'hidden',
|
||||
}}>
|
||||
{showDoctorTabs && (
|
||||
<DoctorTabs
|
||||
doctors={doctors.map(d => ({
|
||||
uuid: d.uuid,
|
||||
name: d.name,
|
||||
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
|
||||
}))}
|
||||
selected={selectedDoctorUuid}
|
||||
onSelect={selectDoctor}
|
||||
showAll={isAdmin}
|
||||
/>
|
||||
<div data-tour="appointments-doctors">
|
||||
<DoctorTabs
|
||||
doctors={doctors.map(d => ({
|
||||
uuid: d.uuid,
|
||||
name: d.name,
|
||||
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
|
||||
}))}
|
||||
selected={selectedDoctorUuid}
|
||||
onSelect={selectDoctor}
|
||||
showAll={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
|
||||
@@ -735,7 +752,7 @@ function ServiceFilterSelect({ value, options, onChange }: {
|
||||
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ minWidth: 280 }}>
|
||||
<div data-tour="appointments-service" style={{ minWidth: 280 }}>
|
||||
<SearchableSelect
|
||||
options={options.map(s => ({ value: s.uuid, label: s.name }))}
|
||||
value={value || null}
|
||||
|
||||
@@ -1096,3 +1096,50 @@ html, body { max-width: 100%; overflow-x: hidden; }
|
||||
[data-theme="dark"] .ck.ck-icon,
|
||||
[data-theme="dark"] .ck.ck-icon :is(path, polygon, rect, circle, ellipse) { fill: currentColor; }
|
||||
[data-theme="dark"] .ck.ck-button.ck-on .ck-icon :is(path, polygon, rect, circle, ellipse) { fill: var(--primary); }
|
||||
|
||||
/* ── تور راهنما ───────────────────────────────────────────────────────────
|
||||
driver.js استایل خودش را دارد؛ اینجا با توکنهای پنل بازنویسی میشود تا در
|
||||
تم روشن و تیره یکسان و خوانا بماند. */
|
||||
.driver-popover.cp-tour {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-family: inherit;
|
||||
max-width: 320px;
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-title {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-description {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-progress-text {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-close-btn { color: var(--text-3); }
|
||||
.driver-popover.cp-tour .driver-popover-navigation-btns button {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
text-shadow: none;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-navigation-btns button:last-child {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.driver-popover.cp-tour .driver-popover-arrow-side-top { border-top-color: var(--surface); }
|
||||
.driver-popover.cp-tour .driver-popover-arrow-side-bottom { border-bottom-color: var(--surface); }
|
||||
.driver-popover.cp-tour .driver-popover-arrow-side-left { border-left-color: var(--surface); }
|
||||
.driver-popover.cp-tour .driver-popover-arrow-side-right { border-right-color: var(--surface); }
|
||||
|
||||
Reference in New Issue
Block a user