fix: заявки рекрутинга не давали отправить без выьбранного проекта, но пропускали пустую матрицу согласования
Tests / frontend-test (push) Successful in 1m23s
Tests / frontend-test (push) Successful in 1m23s
This commit is contained in:
@@ -196,10 +196,20 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
// При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная.
|
// При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная.
|
||||||
const mainCompanyMissing = data.Companies.length >= 2 && !data.MainCompanyId;
|
const mainCompanyMissing = data.Companies.length >= 2 && !data.MainCompanyId;
|
||||||
const isValid = data.HireCandidateName.trim().length > 0 && data.Position.trim().length > 0 && !mainCompanyMissing;
|
// ProjectId обязателен (а не просто текст Project): маршрут резолвится ПО
|
||||||
|
// ПРОЕКТУ (ByProject, см. projectId выше), и без выбранного из списка
|
||||||
|
// варианта (id) approval-route вернёт 404 — форма должна не пускать на
|
||||||
|
// отправку раньше, чем выяснится, что маршрут вообще некому строить (баг:
|
||||||
|
// раньше это не проверялось, и заявка молча уходила на согласование с
|
||||||
|
// пустой матрицей).
|
||||||
|
const isValid =
|
||||||
|
data.HireCandidateName.trim().length > 0 &&
|
||||||
|
data.Position.trim().length > 0 &&
|
||||||
|
!!data.ProjectId &&
|
||||||
|
!mainCompanyMissing;
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (mainCompanyMissing) return 'Выбрано несколько организаций — укажите, какая из них основное место работы.';
|
if (mainCompanyMissing) return 'Выбрано несколько организаций — укажите, какая из них основное место работы.';
|
||||||
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
|
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность, Проект (выберите вариант из списка).';
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -296,6 +306,20 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
const validateSubmit = (): string | undefined => {
|
const validateSubmit = (): string | undefined => {
|
||||||
const formError = validate();
|
const formError = validate();
|
||||||
if (formError) return formError;
|
if (formError) return formError;
|
||||||
|
if (matrix.error) {
|
||||||
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
|
}
|
||||||
|
if (matrix.loading) {
|
||||||
|
return 'Маршрут согласования ещё формируется — подождите немного и повторите.';
|
||||||
|
}
|
||||||
|
// Матрица пуста (не только «есть шаг без человека») — раньше это молча
|
||||||
|
// пропускалось (findUnassignedStep на пустом массиве ошибки не даёт), и
|
||||||
|
// заявка уходила на согласование без единого согласующего. У фазы
|
||||||
|
// исполнения (executors) пустой список — нормальное состояние (не у
|
||||||
|
// всех маршрутов есть шаги исполнения), там эта проверка не нужна.
|
||||||
|
if (matrix.rows.length === 0) {
|
||||||
|
return 'Маршрут согласования не найден для выбранного проекта — обратитесь к администратору.';
|
||||||
|
}
|
||||||
const unassignedApproverError = unassignedStepMessage(
|
const unassignedApproverError = unassignedStepMessage(
|
||||||
matrix.rows,
|
matrix.rows,
|
||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
shares={data.CompanyShares}
|
shares={data.CompanyShares}
|
||||||
onSharesChange={(v) => set('CompanyShares', v)}
|
onSharesChange={(v) => set('CompanyShares', v)}
|
||||||
/>
|
/>
|
||||||
<FormField label="1.2 Проект" htmlFor="Project" full>
|
<FormField label="1.2 Проект" htmlFor="Project" required full>
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
context={context}
|
context={context}
|
||||||
id="Project"
|
id="Project"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
|
||||||
|
import { PositionPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
export const CandidateSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
<CollapseSection title="2. Кандидат" icon="people">
|
<CollapseSection title="2. Кандидат" icon="people">
|
||||||
<FormField label="2.1 Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
|
<FormField label="2.1 Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
|
||||||
<TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
|
<TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
|
||||||
@@ -11,7 +12,13 @@ export const CandidateSection: React.FC<ISectionProps> = ({ data, set }) => (
|
|||||||
<TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
|
<TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="2.3 Должность" htmlFor="Position" required>
|
<FormField label="2.3 Должность" htmlFor="Position" required>
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<PositionPicker
|
||||||
|
context={context}
|
||||||
|
id="Position"
|
||||||
|
value={data.Position}
|
||||||
|
onChange={(v) => set('Position', v)}
|
||||||
|
placeholder="Начните вводить название должности…"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="2.4 Вид работ" htmlFor="WorkKind">
|
<FormField label="2.4 Вид работ" htmlFor="WorkKind">
|
||||||
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
|
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
|
||||||
|
|||||||
@@ -207,9 +207,23 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim() && data.Residence);
|
// ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут
|
||||||
|
// согласования резолвится ПО ПРОЕКТУ (ByProject, см. projectId выше), и без
|
||||||
|
// выбранного из списка варианта (id) approval-route вернёт 404 — форма
|
||||||
|
// должна не пускать на отправку раньше, чем выяснится, что маршрут вообще
|
||||||
|
// некому строить (баг: раньше это не проверялось, и заявка молча уходила
|
||||||
|
// на согласование с пустой матрицей).
|
||||||
|
const isValid = !!(
|
||||||
|
data.Responsible &&
|
||||||
|
data.DepartmentName.trim() &&
|
||||||
|
data.Position.trim() &&
|
||||||
|
data.Residence &&
|
||||||
|
data.ObjectProjectId
|
||||||
|
);
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (!isValid) return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность, проживание.';
|
if (!isValid) {
|
||||||
|
return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность, проживание, объект/проект (выберите вариант из списка).';
|
||||||
|
}
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -295,6 +309,15 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
if (matrix.error) {
|
if (matrix.error) {
|
||||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
}
|
}
|
||||||
|
if (matrix.loading) {
|
||||||
|
return 'Маршрут согласования ещё формируется — подождите немного и повторите.';
|
||||||
|
}
|
||||||
|
// Матрица пуста (не только «есть шаг без человека») — раньше это молча
|
||||||
|
// пропускалось (findUnassignedStep на пустом массиве ошибки не даёт), и
|
||||||
|
// заявка уходила на согласование без единого согласующего.
|
||||||
|
if (matrix.rows.length === 0) {
|
||||||
|
return 'Маршрут согласования не найден для выбранного объекта/проекта — обратитесь к администратору.';
|
||||||
|
}
|
||||||
const unassignedError = unassignedStepMessage(
|
const unassignedError = unassignedStepMessage(
|
||||||
matrix.rows,
|
matrix.rows,
|
||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
placeholder="Начните вводить название организации…"
|
placeholder="Начните вводить название организации…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.7 Объект/Проект" htmlFor="ObjectProject" full>
|
<FormField label="1.7 Объект/Проект" htmlFor="ObjectProject" required full>
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
context={context}
|
context={context}
|
||||||
id="ObjectProject"
|
id="ObjectProject"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
|
||||||
|
import { PositionPicker } from '../../../shared/dictionary';
|
||||||
import { LanguagesField } from './LanguagesField';
|
import { LanguagesField } from './LanguagesField';
|
||||||
import { MultiChoiceField } from './MultiChoiceField';
|
import { MultiChoiceField } from './MultiChoiceField';
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +13,16 @@ import {
|
|||||||
OTHER_VALUE
|
OTHER_VALUE
|
||||||
} from '../model';
|
} from '../model';
|
||||||
|
|
||||||
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const VacancySection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
<CollapseSection title="3. Вакансия" icon="document">
|
<CollapseSection title="3. Вакансия" icon="document">
|
||||||
<FormField label="3.1 Должность" htmlFor="Position" required full>
|
<FormField label="3.1 Должность" htmlFor="Position" required full>
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<PositionPicker
|
||||||
|
context={context}
|
||||||
|
id="Position"
|
||||||
|
value={data.Position}
|
||||||
|
onChange={(v) => set('Position', v)}
|
||||||
|
placeholder="Начните вводить название должности…"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType">
|
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export { getEmployeeOrganizations, formatEmployeeOrganizations } from './employe
|
|||||||
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||||
export { getNomenclature } from './nomenclature.service';
|
export { getNomenclature } from './nomenclature.service';
|
||||||
export type { INomenclatureRow } from './models/INomenclatureRow';
|
export type { INomenclatureRow } from './models/INomenclatureRow';
|
||||||
|
export { searchPositions } from './positions.service';
|
||||||
|
export type { IPosition } from './models/IPosition';
|
||||||
|
export { PositionPicker } from './ui/PositionPicker';
|
||||||
|
export type { IPositionPickerProps } from './ui/PositionPicker';
|
||||||
export {
|
export {
|
||||||
directorPositionFor,
|
directorPositionFor,
|
||||||
getEmployeeEmailByPosition,
|
getEmployeeEmailByPosition,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// GET https://api.tnsu.kz/Dictionary/Positions?search=&page=&pageSize= →
|
||||||
|
// { total, page, pageSize, items: IPosition[] }. Справочник большой (878+
|
||||||
|
// записей), поэтому ищем на сервере через search, а не тянем всё разом.
|
||||||
|
export interface IPosition {
|
||||||
|
id: string;
|
||||||
|
position: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPositionListResponse {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
items: IPosition[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||||
|
import { IPosition, IPositionListResponse } from './models/IPosition';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /Dictionary/Positions?search=...&page=1&pageSize=... — справочник
|
||||||
|
* должностей (878+ записей). В отличие от /Project (см. projects.service),
|
||||||
|
* весь список разом не тянем и не кэшируем — ищем на сервере по мере ввода
|
||||||
|
* (см. PositionPicker), иначе каждое открытие формы качало бы сотни записей.
|
||||||
|
*/
|
||||||
|
export async function searchPositions(
|
||||||
|
context: WebPartContext,
|
||||||
|
search: string,
|
||||||
|
pageSize: number
|
||||||
|
): Promise<IPosition[]> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Dictionary/Positions?search=${encodeURIComponent(search)}&page=1&pageSize=${pageSize}`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Dictionary/Positions: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const body: IPositionListResponse = await response.json();
|
||||||
|
return body.items ?? [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
// справа место под крестик-очистку
|
||||||
|
padding: 11px 36px 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
@include readonly-field(':read-only');
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './PositionPicker.module.scss';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { searchPositions } from '../positions.service';
|
||||||
|
import { IPosition } from '../models/IPosition';
|
||||||
|
|
||||||
|
export interface IPositionPickerProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
id?: string;
|
||||||
|
/** Текущее название должности (то, что видно в поле). Ручной ввод — тоже валидное значение. */
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле «Должность» со справочником (GET /Dictionary/Positions): ручной ввод
|
||||||
|
* не блокируется, а по мере набора (с задержкой) подтягиваются подсказки из
|
||||||
|
* справочника — выбор варианта просто подставляет его название в поле.
|
||||||
|
* Поиск — на сервере (?search=), т.к. справочник большой (878+ записей).
|
||||||
|
*/
|
||||||
|
export const PositionPicker: React.FC<IPositionPickerProps> = ({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
|
||||||
|
const [suggestions, setSuggestions] = React.useState<IPosition[]>([]);
|
||||||
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = React.useState<boolean>(false);
|
||||||
|
// Счётчик запросов — чтобы ответ на устаревший запрос (после ввода новых
|
||||||
|
// символов) не перезаписал подсказки более свежего.
|
||||||
|
const requestIdRef = React.useRef(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const query = value.trim();
|
||||||
|
if (!query) {
|
||||||
|
setSuggestions([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
searchPositions(context, query, PAGE_SIZE)
|
||||||
|
.then((list) => {
|
||||||
|
if (requestIdRef.current === requestId) {
|
||||||
|
setSuggestions(list);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (requestIdRef.current === requestId) {
|
||||||
|
setSuggestions([]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [context, value]);
|
||||||
|
|
||||||
|
const handlePick = (position: IPosition): void => {
|
||||||
|
onChange(position.position);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
|
// setOpen(true) и при вводе, а не только по фокусу: после выбора список
|
||||||
|
// закрывается (handlePick), но фокус остаётся на поле — без этого
|
||||||
|
// повторный ввод в том же фокусе не открывал бы выпадашку заново.
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{/* Быстрая очистка. preventDefault на mousedown — чтобы клик не «съел»
|
||||||
|
фокус до onClick (как в списке ниже). */}
|
||||||
|
{!effectiveReadOnly && value.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.clear}
|
||||||
|
aria-label="Очистить"
|
||||||
|
title="Очистить"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
onChange('');
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!effectiveReadOnly && open && loading && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
<div className={styles.status}>Поиск…</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!effectiveReadOnly && open && !loading && suggestions.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{suggestions.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick, и список закрывается до выбора.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handlePick(p)}
|
||||||
|
>
|
||||||
|
{p.position}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user