diff --git a/assets/admin/components/FieldSchemaEditor.test.tsx b/assets/admin/components/FieldSchemaEditor.test.tsx new file mode 100644 index 00000000..eafa04ef --- /dev/null +++ b/assets/admin/components/FieldSchemaEditor.test.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; +import FieldSchemaEditor from './FieldSchemaEditor'; +import type { TreatmentFormField } from '../types'; + +const selectField = (options?: TreatmentFormField['options']): TreatmentFormField => ({ + key: 'spot', + label: 'اسپات', + type: 'select', + required: false, + sort_order: 0, + options, +}); + +/** والدِ کنترل‌شده — همان قراردادی که مودال نوع منبع دارد. */ +function Harness({ initial, onEmit }: { + initial: TreatmentFormField[]; + onEmit?: (fields: TreatmentFormField[]) => void; +}) { + const [value, setValue] = useState(initial); + return ( + { setValue(next); onEmit?.(next); }} + /> + ); +} + +const optionsInput = () => + screen.getByPlaceholderText('7, 8, 9, 10, 12, 14, 16, 18') as HTMLInputElement; + +describe('FieldSchemaEditor — ورودی گزینه‌های فیلد select', () => { + it('کاما در فیلد باقی می‌ماند و پاک نمی‌شود', () => { + renderWithProviders(); + + fireEvent.change(optionsInput(), { target: { value: '7,' } }); + + expect(optionsInput().value).toBe('7,'); + }); + + it('فاصلهٔ بعد از کاما حفظ می‌شود و گزینهٔ بعدی تایپ‌شدنی است', () => { + const onEmit = vi.fn(); + renderWithProviders(); + + fireEvent.change(optionsInput(), { target: { value: '7, ' } }); + expect(optionsInput().value).toBe('7, '); + + fireEvent.change(optionsInput(), { target: { value: '7, 8' } }); + expect(optionsInput().value).toBe('7, 8'); + expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['7', '8'] })]); + }); + + it('کامای فارسی هم جداکننده است', () => { + const onEmit = vi.fn(); + renderWithProviders(); + + fireEvent.change(optionsInput(), { target: { value: 'کم، زیاد' } }); + + expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['کم', 'زیاد'] })]); + }); + + it('مقدار اولیه از آرایه ساخته می‌شود', () => { + renderWithProviders(); + + expect(optionsInput().value).toBe('7, 8, 9'); + }); + + it('گزینهٔ خالی به بک‌اند فرستاده نمی‌شود', () => { + const onEmit = vi.fn(); + renderWithProviders(); + + fireEvent.change(optionsInput(), { target: { value: '7,, ,8,' } }); + + expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['7', '8'] })]); + }); +}); diff --git a/assets/admin/components/FieldSchemaEditor.tsx b/assets/admin/components/FieldSchemaEditor.tsx index 0a867d2e..6242c7fd 100644 --- a/assets/admin/components/FieldSchemaEditor.tsx +++ b/assets/admin/components/FieldSchemaEditor.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from 'react'; import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; import Field from './ui/Field'; import Input from './ui/Input'; @@ -13,6 +14,50 @@ const TYPE_OPTIONS = [ const MAX_FIELDS = 20; +const parseOptions = (raw: string) => + raw.split(/[,،]/).map((o) => o.trim()).filter((o) => o !== ''); + +/** + * ورودی متنی گزینه‌های فیلد `select`. + * + * متن خام را در state خودش نگه می‌دارد و آرایه را فقط موقع emit می‌سازد. اگر مقدار + * input مستقیم از آرایه ساخته می‌شد، کاما و فاصلهٔ انتهایی در همان keystroke حذف + * می‌شد (چون عنصر خالی filter می‌شود) و کاربر اصلاً نمی‌توانست کاما تایپ کند. + */ +function OptionsInput({ id, options, disabled, onChange }: { + id: string; + options: TreatmentFormField['options']; + disabled?: boolean; + onChange: (options: string[]) => void; +}) { + const [draft, setDraft] = useState(() => (options ?? []).join(', ')); + const emitted = useRef(options); + + // فقط وقتی آرایه از بیرون عوض شود (نه با تایپ خودِ کاربر) متن را همگام کن. + useEffect(() => { + if (options !== emitted.current) { + emitted.current = options; + setDraft((options ?? []).join(', ')); + } + }, [options]); + + return ( + { + setDraft(e.target.value); + const next = parseOptions(e.target.value); + emitted.current = next; + onChange(next); + }} + /> + ); +} + /** * فرمی که اپراتور بعد از درمانِ هر ناحیه با این نوع منبع پر می‌کند. * @@ -77,18 +122,11 @@ export default function FieldSchemaEditor({ value, onChange, disabled }: { {field.type === 'select' && ( - patch(index, { - options: e.target.value - .split(/[,،]/) - .map((o) => o.trim()) - .filter((o) => o !== ''), - })} + onChange={(options) => patch(index, { options })} /> )}