feat: implement useUrlState hook for managing URL-based state in admin pages

- Refactor multiple admin pages (BlogsPage, ClinicsPage, DoctorsPage, etc.) to utilize the new useUrlState hook for managing pagination, search, and filter states via URL.
- Ensure that the state persists in the URL, allowing users to return to the same state when navigating back from detail pages.
- Update relevant components to handle state changes appropriately and maintain clean URLs by removing default values.
- Add SlotPicker component for selecting appointment slots based on availability.
- Create tests for useUrlState to validate its functionality and ensure correct behavior when interacting with the URL.
- Update API documentation to reflect changes in appointment creation and slot selection processes.
This commit is contained in:
hamed
2026-07-29 21:04:40 +03:30
parent e0e8fbd1e4
commit 684cf1f783
20 changed files with 668 additions and 79 deletions
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { useUrlState, pageOf } from './useUrlState';
import BackButton from '../components/ui/BackButton';
/**
* قرارداد: وضعیت لیست در URL است، پس «بازگشت» (که همان URL قبلی را برمی‌گرداند)
* فیلتر و صفحه را هم برمی‌گرداند.
*/
function ListPage() {
const [state, setState] = useUrlState({ page: '1', search: '', status: '' });
const location = useLocation();
return (
<div>
<span data-testid="qs">{location.search}</span>
<span data-testid="state">{`${state.page}|${state.search}|${state.status}`}</span>
<button onClick={() => setState({ search: 'مریم', page: '1' })}>جستجو</button>
<button onClick={() => setState({ status: 'paid', page: '1' })}>فیلتر</button>
<button onClick={() => setState({ page: '3' })}>صفحه ۳</button>
<button onClick={() => { setState({ status: 'paid' }); setState({ page: '2' }); }}>دو تغییر پشتسرهم</button>
<a href="/detail" onClick={(e) => { e.preventDefault(); window.history.pushState({}, '', '/detail'); }}>x</a>
</div>
);
}
function DetailPage() {
return (
<>
<span>جزئیات</span>
<BackButton fallback="/list" />
</>
);
}
function renderApp(entries: string[] = ['/list']) {
return render(
<MemoryRouter initialEntries={entries}>
<Routes>
<Route path="/list" element={<ListPage />} />
<Route path="/detail" element={<DetailPage />} />
</Routes>
</MemoryRouter>,
);
}
describe('useUrlState', () => {
it('مقدار اولیه را از URL می‌خواند', () => {
renderApp(['/list?page=2&search=علی&status=paid']);
expect(screen.getByTestId('state')).toHaveTextContent('2|علی|paid');
});
it('تغییر فیلتر در query string می‌نشیند و صفحه را یک می‌کند', () => {
renderApp(['/list?page=4']);
fireEvent.click(screen.getByText('فیلتر'));
expect(screen.getByTestId('qs').textContent).toContain('status=paid');
expect(screen.getByTestId('state')).toHaveTextContent('1|');
});
it('مقدارِ برابرِ پیش‌فرض از URL حذف می‌شود', () => {
renderApp(['/list?page=3']);
fireEvent.click(screen.getByText('صفحه ۳'));
expect(screen.getByTestId('qs').textContent).toContain('page=3');
fireEvent.click(screen.getByText('فیلتر')); // page → '1' = پیش‌فرض
expect(screen.getByTestId('qs').textContent).not.toContain('page=');
});
it('دو تغییرِ پشت‌سرهم در یک handler همدیگر را پاک نمی‌کنند', () => {
renderApp(['/list']);
fireEvent.click(screen.getByText('دو تغییر پشت‌سرهم'));
// مودالِ فیلتر دقیقاً همین کار را می‌کند: setFilters(...) و بعد setPage(1).
expect(screen.getByTestId('state')).toHaveTextContent('2||paid');
});
it('بازگشت از صفحهٔ جزئیات، فیلتر و صفحهٔ لیست را دست‌نخورده برمی‌گرداند', () => {
render(
<MemoryRouter initialEntries={['/list?page=3&search=مریم&status=paid', '/detail']} initialIndex={1}>
<Routes>
<Route path="/list" element={<ListPage />} />
<Route path="/detail" element={<DetailPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByText('جزئیات')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /بازگشت/ }));
expect(screen.getByTestId('state')).toHaveTextContent('3|مریم|paid');
});
it('pageOf مقدار نامعتبر را به ۱ می‌برد', () => {
expect(pageOf('')).toBe(1);
expect(pageOf('0')).toBe(1);
expect(pageOf('abc')).toBe(1);
expect(pageOf('7')).toBe(7);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
/**
* وضعیت لیست (جستجو، فیلترها، صفحه، تب) در query string زندگی می‌کند، نه در state
* کامپوننت. دلیلش «بازگشت»‌ است: `navigate(-1)` همان URL قبلی را برمی‌گرداند، پس هر
* چیزی که در URL باشد سرِ جایش برمی‌گردد و هر چیزی که در state باشد با remount می‌پرد.
* سود جانبی: رفرش و اشتراک لینک هم همان نما را می‌دهد.
*
* مقدارِ برابرِ پیش‌فرض از URL حذف می‌شود تا آدرس تمیز بماند.
*/
export function useUrlState<T extends Record<string, string>>(defaults: T) {
const [params, setParams] = useSearchParams();
const state = useMemo(() => {
const out = { ...defaults };
(Object.keys(defaults) as (keyof T)[]).forEach((k) => {
const raw = params.get(String(k));
if (raw !== null) out[k] = raw as T[keyof T];
});
return out;
}, [params, defaults]);
// `setSearchParams` مثل setState صف نمی‌شود؛ دو فراخوانی در یک handler، اگر هر دو از
// `params` فعلی شروع کنند، همدیگر را پاک می‌کنند. این ref آخرین مقدارِ نوشته‌شده را
// نگه می‌دارد تا فراخوانی‌های پشت‌سرهم روی هم انباشته شوند.
const latest = useRef(params);
useEffect(() => { latest.current = params; }, [params]);
/**
* چند کلید را با هم می‌نویسد. `replace` پیش‌فرض است تا تایپ در فیلد جستجو
* تاریخچه را پر نکند و یک بار «بازگشت» کاربر را از صفحه بیرون ببرد.
*/
const setState = useCallback((patch: Partial<T>, opts?: { push?: boolean }) => {
const next = new URLSearchParams(latest.current);
Object.entries(patch).forEach(([k, v]) => {
const value = v === undefined ? '' : String(v);
if (value === '' || value === defaults[k as keyof T]) next.delete(k);
else next.set(k, value);
});
latest.current = next;
setParams(next, { replace: !opts?.push });
}, [setParams, defaults]);
return [state, setState] as const;
}
/** عدد صفحه از URL — همیشه ≥ ۱. */
export function pageOf(value: string): number {
return Math.max(1, Number(value) || 1);
}