feat: implement OptionsInput component for handling select field options
This commit is contained in:
@@ -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'] })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||||
import Field from './ui/Field';
|
import Field from './ui/Field';
|
||||||
import Input from './ui/Input';
|
import Input from './ui/Input';
|
||||||
@@ -13,6 +14,50 @@ const TYPE_OPTIONS = [
|
|||||||
|
|
||||||
const MAX_FIELDS = 20;
|
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.type === 'select' && (
|
||||||
<Field label="گزینهها (با کاما جدا کنید)" htmlFor={`fs-options-${index}`}>
|
<Field label="گزینهها (با کاما جدا کنید)" htmlFor={`fs-options-${index}`}>
|
||||||
<Input
|
<OptionsInput
|
||||||
id={`fs-options-${index}`}
|
id={`fs-options-${index}`}
|
||||||
value={(field.options ?? []).join('، ')}
|
options={field.options}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
dir="ltr"
|
onChange={(options) => patch(index, { options })}
|
||||||
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 !== ''),
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user