9.4 KiB
جایگزینی همهی <select> بومی پنل ادمین با SearchableSelect
پروژه
clinicpro (React 19 admin SPA — assets/admin/)
زمینه
در سراسر پنل ادمین از <select> بومی HTML استفاده شده (استایل درونخطی تکراری،
بدون جستوجو، ظاهر ناهماهنگ با طراحیسیستم، بدون RTL/dark درست). طراحیسیستم یک
کامپوننت مشترک دارد: assets/admin/components/ui/SearchableSelect.tsx (روی react-select،
قابل جستوجو، RTL، هماهنگ با توکنهای CSS و dark mode، منو portal با zIndex 9999).
هدف: همهی <select>های بومی پنل با SearchableSelect جایگزین شوند تا ظاهر و رفتار
یکدست شود (مثل دستهبندی «افزودن کالا» در AddItemModal.tsx که از قبل SearchableSelect است).
API کامپوننت SearchableSelect (مرجع — تغییرش نده)
interface SelectOption { value: string | number; label: string }
interface Props {
options: SelectOption[];
value?: string | number | null;
onChange?: (value: string | number | null) => void; // مقدار خام، نه event
placeholder?: string; // پیشفرض «انتخاب کنید...»
isLoading?: boolean;
isDisabled?: boolean;
isClearable?: boolean;
noOptionsMessage?: string; // پیشفرض «موردی یافت نشد»
inputId?: string;
height?: number; // پیشفرض 42؛ برای همارتفاعی با فیلدهای 38px مقدار بده
}
نکات مهمِ تفاوت با <select>:
onChangeمقدار خام میدهد (string|number|null) نهe.target.value.optionsباید{ value, label }باشد — نه<option>.- گزینهی خالی (
<option value="">انتخاب...</option>) حذف و بهplaceholderمنتقل شود؛ اگر خالیکردن مجاز استisClearableبده. disabled→isDisabled.
فایلهای دارای <select> بومی (۱۶ فایل، ~۳۵ مورد)
| فایل | تعداد | نکته |
|---|---|---|
pages/MyPatientsPage.tsx |
4 | فیلترها |
pages/AppointmentEditPage.tsx |
4 | |
pages/AppointmentCreatePage.tsx |
4 | |
components/NewAppointmentDrawer.tsx |
4 | بخش/زیربخش/سرویس (وابسته) |
components/AppointmentActions.tsx |
4 | خطوط 850,869,961,976 |
pages/DoctorDetailPage.tsx |
3 | |
pages/PatientRecordFormPage.tsx |
2 | RHF register (gender:119، referral_source:135) |
components/AppointmentFiltersModal.tsx |
2 | sel استایل مشترک؛ سرویس وابسته به بخش (disabled) |
pages/ReserveAppointmentsPage.tsx |
1 | |
pages/RepresentationSettlementPage.tsx |
1 | |
pages/MyPaymentsPage.tsx |
1 | |
pages/InventoryPage.tsx |
1 | فیلتر دسته |
pages/AppointmentsPage.tsx |
1 | سرویس (خط 653) |
components/inventory/AddPackageModal.tsx |
1 | |
components/dashboard/TauriDashboardView.tsx |
1 | |
components/PatientsFilterModal.tsx |
1 |
وضعیت فعلی — سه الگوی رایج
الگوی A — controlled با value + onChange (بیشترین)
// AppointmentsPage.tsx:653
<select aria-label="سرویس" value={value} onChange={e => onChange(e.target.value)} style={sel}>
<option value="">سرویس مورد نظر را انتخاب کنید...</option>
{options.map(s => <option key={s.uuid} value={s.uuid}>{s.name}</option>)}
</select>
الگوی B — گزینههای وابسته / disabled
// AppointmentFiltersModal.tsx:100
<select aria-label="سرویس" style={{ ...sel, margin: '6px 0 14px' }} value={f.itemUuid} disabled={!f.sectionUuid}
onChange={...}>
<option value="">انتخاب سرویس</option>
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
الگوی C — React Hook Form با register (خاص — نیازمند Controller)
// PatientRecordFormPage.tsx:119
<div className="field"><select {...form.register('gender')} style={{...}}>
<option value="">انتخاب...</option>
<option value="female">زن</option>
<option value="male">مرد</option>
</select></div>
وظایف
یک فایل در هر مرحله. بعد از هر فایل:
tscسبز شود، بعد فایل بعدی. ترتیب: از فایلهای کممورد به پرمورد، یا هر ترتیبی، ولی هر فایل مستقل تست/تایپچک شود.
۱. تبدیل الگوی A (controlled)
هر <select value onChange> را با این جایگزین کن:
<SearchableSelect
options={options.map(s => ({ value: s.uuid, label: s.name }))}
value={value || null}
onChange={v => onChange(v ? String(v) : '')} // اگر state رشته است String() بزن
placeholder="سرویس مورد نظر را انتخاب کنید..." // همان متن option خالی
isClearable // اگر خالیکردن مجاز بود
height={38} // برای همارتفاعی با فیلدهای فعلی
/>
- import در بالای فایل:
import SearchableSelect from '../ui/SearchableSelect';(عمق مسیر را بر اساس محل فایل تنظیم کن: ازpages/→'../components/ui/SearchableSelect'). - استایل درونخطی
sel/styleروی select حذف شود (کامپوننت خودش استایل دارد). اگرmarginبیرونی لازم بود، در یک<div style={{ margin }}>دور کامپوننت بگذار. aria-labelرا حفظ کن: چون react-select خودش input دارد، برای دسترسپذیری ازinputId+ یک<label htmlFor>مخفی یاaria-labelروی wrapper استفاده کن (اختیاری ولی بهتر).
۲. تبدیل الگوی B (وابسته/disabled)
مثل A ولی isDisabled را از شرط قبلی بده و در صورت لودشدن آسنکرون isLoading را از
query.isLoading بده:
<SearchableSelect
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name }))}
value={f.itemUuid || null}
onChange={v => setF({ ...f, itemUuid: v ? String(v) : '' })}
placeholder="انتخاب سرویس"
isDisabled={!f.sectionUuid}
isLoading={itemsQ.isLoading}
isClearable
height={38}
/>
۳. تبدیل الگوی C (React Hook Form)
register روی SearchableSelect کار نمیکند (input بومی نیست). دو راه — راه ساده watch+setValue:
<SearchableSelect
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
value={form.watch('gender') || null}
onChange={v => form.setValue('gender', v ? String(v) : '', { shouldValidate: true, shouldDirty: true })}
placeholder="انتخاب..."
isClearable
height={38}
/>
- برای فیلدهای الزامیِ Zod،
shouldValidate: trueرا نگه دار تا خطاها بهروز شوند. - اگر فیلد قبلاً از
REFERRAL_OPTIONSمیساخت:REFERRAL_OPTIONS.map(o => ({ value: o, label: o })).
۴. حذف کد مرده
بعد از تبدیل، هر متغیر استایل مشترکِ بلااستفاده (const sel = {...}) و importهای بیاستفاده
را حذف کن (tsc/eslint نشان میدهد).
نکات مهم
- رفتار داده تغییر نکند: مقداری که به state/RHF/API میرود همان
uuid/کلید قبلی بماند (فقط''↔nullرا مدیریت کن —SearchableSelectهنگام خالیnullمیدهد، اگر state انتظار رشته دارد به''تبدیل کن). - ارتفاع: فیلدهای فعلی معمولاً
height: 38pxاند؛height={38}بده تا ردیفها جابهجا نشوند. پیشفرض کامپوننت 42 است. - گزینههای وابسته (بخش→زیربخش→سرویس در NewAppointmentDrawer / AppointmentActions): با تغییر والد، مقدار فرزند ریست شود (همان منطق فعلی onChange والد را حفظ کن).
- منوی داخل Modal/Drawer:
SearchableSelectمنو را باmenuPortal+zIndex 9999بهbodyمیبرد؛ مشکل بریدگی/overflow نخواهد داشت — نیازی به تنظیم اضافه نیست. - تست: بعد از هر فایل
ddev exec npx tsc --noEmit --project tsconfig.json؛ در پایانnpx vitest run(روی host؛ داخل ddev esbuild پلتفرم mismatch دارد). تستهای موجودِ فایلهایی که select داشتند (مثلAppointmentFiltersModal.test.tsx،PatientRecordInfoForm.test.tsx) را اجرا کن؛ اگر باgetByRole('combobox')/selectOptionsبودند، به تعامل react-select (کلیک + انتخاب گزینه با متن) بهروز کن. - بدون کتابخانه جدید:
react-selectاز قبل نصب است. RTL و dark از خود کامپوننت میآید. - تغییر فقط-UI است؛ backend و
docs/api/*تغییری ندارد. - این یک قاعدهی دائمی است: از این پس هیچ
<select>بومیِ جدیدی در پنل ادمین نساز؛ همیشهSearchableSelect.