feat: implement OptionsInput component for handling select field options

This commit is contained in:
hamed
2026-08-08 14:33:49 +03:30
parent 3862a91fd1
commit 35181cd715
2 changed files with 126 additions and 10 deletions
@@ -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 (
<FieldSchemaEditor
value={value}
onChange={(next) => { setValue(next); onEmit?.(next); }}
/>
);
}
const optionsInput = () =>
screen.getByPlaceholderText('7, 8, 9, 10, 12, 14, 16, 18') as HTMLInputElement;
describe('FieldSchemaEditor — ورودی گزینه‌های فیلد select', () => {
it('کاما در فیلد باقی می‌ماند و پاک نمی‌شود', () => {
renderWithProviders(<Harness initial={[selectField(['7'])]} />);
fireEvent.change(optionsInput(), { target: { value: '7,' } });
expect(optionsInput().value).toBe('7,');
});
it('فاصلهٔ بعد از کاما حفظ می‌شود و گزینهٔ بعدی تایپ‌شدنی است', () => {
const onEmit = vi.fn();
renderWithProviders(<Harness initial={[selectField(['7'])]} onEmit={onEmit} />);
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(<Harness initial={[selectField([])]} onEmit={onEmit} />);
fireEvent.change(optionsInput(), { target: { value: 'کم، زیاد' } });
expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['کم', 'زیاد'] })]);
});
it('مقدار اولیه از آرایه ساخته می‌شود', () => {
renderWithProviders(<Harness initial={[selectField(['7', '8', '9'])]} />);
expect(optionsInput().value).toBe('7, 8, 9');
});
it('گزینهٔ خالی به بک‌اند فرستاده نمی‌شود', () => {
const onEmit = vi.fn();
renderWithProviders(<Harness initial={[selectField([])]} onEmit={onEmit} />);
fireEvent.change(optionsInput(), { target: { value: '7,, ,8,' } });
expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['7', '8'] })]);
});
});
+48 -10
View File
@@ -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 (
<Input
id={id}
value={draft}
disabled={disabled}
dir="ltr"
placeholder="7, 8, 9, 10, 12, 14, 16, 18"
onChange={(e) => {
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' && (
<Field label="گزینه‌ها (با کاما جدا کنید)" htmlFor={`fs-options-${index}`}>
<Input
<OptionsInput
id={`fs-options-${index}`}
value={(field.options ?? []).join('، ')}
options={field.options}
disabled={disabled}
dir="ltr"
placeholder="7, 8, 9, 10, 12, 14, 16, 18"
onChange={(e) => patch(index, {
options: e.target.value
.split(/[,،]/)
.map((o) => o.trim())
.filter((o) => o !== ''),
})}
onChange={(options) => patch(index, { options })}
/>
</Field>
)}