feat: searchable unit picker in the work form
Units dictionary has around a hundred entries; typing now filters the list and Enter picks the first match. Only dictionary values can be chosen, as before. Generic SearchableSelect lives in shared until the ui-kit gets one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,13 +89,15 @@
|
|||||||
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
||||||
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
|
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
|
||||||
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
|
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
|
||||||
- [ ] Drag & drop (только `canEdit`): до / внутрь / после, запрет на себя и потомков, ошибка «Не удалось переместить: …»
|
- [-] ~~Drag & drop~~ — **убрано по решению 2026-10-16** (неудобно, зона «внутрь» не работала, лишние случайные переносы). Родителя Раздела меняют через «Редактировать».
|
||||||
- [ ] «Работы» → раздел Работы с `objectId`
|
- [ ] «Работы» → раздел Работы с `objectId`
|
||||||
- [ ] После любой операции дерево перезагружается
|
- [ ] После любой операции дерево перезагружается
|
||||||
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
||||||
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
|
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
|
||||||
|
|
||||||
🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. 🐞 Зона сброса «внутрь» нулевой высоты — на практике узел можно бросить только «до» / «после» (перенесено как есть).
|
🐞 Удаление узла не удаляет детей.
|
||||||
|
|
||||||
|
ℹ️ Экспорт падает с 500 на стороне `api.tnsu.kz`: «Failed to acquire Graph token … SPO_TENANT_ID, SPO_CLIENT_ID, SPO_CERT_PATH, SPO_CERT_PASS» — у API не настроен доступ к Graph (проверка 2026-10-16). Наш запрос доходит до API корректно; чинится на сервере.
|
||||||
|
|
||||||
**Сознательные отличия от objects:**
|
**Сознательные отличия от objects:**
|
||||||
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
|
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
|
||||||
@@ -119,7 +121,7 @@
|
|||||||
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
||||||
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
||||||
|
|
||||||
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
||||||
|
|
||||||
**Сознательные отличия от works:**
|
**Сознательные отличия от works:**
|
||||||
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
|
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
|
||||||
@@ -127,6 +129,8 @@
|
|||||||
- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же.
|
- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же.
|
||||||
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`).
|
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`).
|
||||||
- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`.
|
- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`.
|
||||||
|
- 🔧 **Исправлено при проверке T-WRK (2026-10-19):** дату окончания нельзя поставить раньше даты начала — календарь не даёт, а при сохранении ошибка «Дата окончания не может быть раньше даты начала» (в старом коде проверки не было).
|
||||||
|
- ✨ **Улучшено по просьбе (2026-10-19):** «Ед. изм.» — список с поиском: можно начать печатать, Enter выбирает первое найденное. Выбрать можно только значение из справочника, как и раньше.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -268,4 +272,4 @@
|
|||||||
|
|
||||||
- ✅ Схема списка **«Доступы»** проверена на сайте (2026-10-16): `project` — Lookup на «Проекты», `user` — Person, права `read` / `edit0` / `aproove`. SharePoint отдаёт к ним `projectId` / `userId` — поэтому оба способа из старых репозиториев (`project/Id` + `$expand` и `projectId` / `userId`) обращаются к одним и тем же полям. Расхождения нет.
|
- ✅ Схема списка **«Доступы»** проверена на сайте (2026-10-16): `project` — Lookup на «Проекты», `user` — Person, права `read` / `edit0` / `aproove`. SharePoint отдаёт к ним `projectId` / `userId` — поэтому оба способа из старых репозиториев (`project/Id` + `$expand` и `projectId` / `userId`) обращаются к одним и тем же полям. Расхождения нет.
|
||||||
- ✅ Схема списка **«Объекты»** проверена на сайте (2026-10-16): `parent` — Lookup («Объект»), SharePoint отдаёт к нему `parentId` — чтение через `parent/Id` и запись через `parentId` обращаются к одному полю. `project` — Text (название проекта), как и ожидает код.
|
- ✅ Схема списка **«Объекты»** проверена на сайте (2026-10-16): `parent` — Lookup («Объект»), SharePoint отдаёт к нему `parentId` — чтение через `parent/Id` и запись через `parentId` обращаются к одному полю. `project` — Text (название проекта), как и ожидает код.
|
||||||
- ℹ️ В «Объектах» есть поля `level` (Number) и `skipLevelUpdate` (Boolean), которые не писал ни один старый репозиторий — вероятно, уровень проставляет поток Power Automate. Новое приложение их тоже не трогает. При проверке T-OBJ посмотреть, что у созданных объектов `level` проставился.
|
- ✅ В «Объектах» есть поля `level` (Number) и `skipLevelUpdate` (Boolean), которые не писал ни один старый репозиторий. Проверено 2026-10-16: `level` пустой и у старых настоящих объектов — поле никто не заполняет и ни на что не влияет. Новое приложение его тоже не трогает.
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@
|
|||||||
- [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект».
|
- [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект».
|
||||||
- [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет.
|
- [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет.
|
||||||
- [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»).
|
- [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»).
|
||||||
- [ ] ✍️ **T-OBJ-08** Перетащить объект на другой → родитель сменился; на своего потомка — запрещено.
|
- [-] **T-OBJ-08** Перетаскивание — **убрано** (решение 2026-10-16). Проверить, что строку дерева мышкой больше не перетащить.
|
||||||
- [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было).
|
- [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было).
|
||||||
- [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы.
|
- [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы.
|
||||||
- [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
|
- [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { Alert, Button, Dialog, FormField, Select, TextInput } from '@tnsu/ui-kit';
|
import { Alert, Button, Dialog, FormField, Select, TextInput } from '@tnsu/ui-kit';
|
||||||
import { ITree } from '../_tree/treeRules';
|
import { ITree } from '../_tree/treeRules';
|
||||||
import { FormInput } from '../_tree/FormInput';
|
import { FormInput } from '../_tree/FormInput';
|
||||||
|
import { SearchableSelect } from '../../shared/components/SearchableSelect';
|
||||||
import { IWorkEditor } from './useWorkEditor';
|
import { IWorkEditor } from './useWorkEditor';
|
||||||
import { parentOptions, unitOptions } from './worksRules';
|
import { parentOptions, unitOptions } from './worksRules';
|
||||||
import { IWork } from './worksTypes';
|
import { IWork } from './worksTypes';
|
||||||
@@ -60,12 +61,15 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={TEXTS.unit} htmlFor="work-unit" full>
|
<FormField label={TEXTS.unit} htmlFor="work-unit" full>
|
||||||
<Select
|
<SearchableSelect
|
||||||
id="work-unit"
|
id="work-unit"
|
||||||
value={form.unit}
|
value={form.unit}
|
||||||
onChange={(value) => setField('unit', value)}
|
onChange={(value) => setField('unit', value)}
|
||||||
options={unitOptions(units, form.unit)}
|
options={unitOptions(units, form.unit)}
|
||||||
placeholder={TEXTS.unitPlaceholder}
|
placeholder={TEXTS.unitPlaceholder}
|
||||||
|
searchPlaceholder={TEXTS.unitSearch}
|
||||||
|
notFoundText={TEXTS.unitNotFound}
|
||||||
|
clearLabel={TEXTS.cancel}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={TEXTS.volume} htmlFor="work-volume" full>
|
<FormField label={TEXTS.volume} htmlFor="work-volume" full>
|
||||||
@@ -93,7 +97,14 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
|
|||||||
<TextInput id="work-start" type="date" value={form.start} onChange={(value) => setField('start', value)} />
|
<TextInput id="work-start" type="date" value={form.start} onChange={(value) => setField('start', value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={TEXTS.finish} htmlFor="work-finish" full>
|
<FormField label={TEXTS.finish} htmlFor="work-finish" full>
|
||||||
<TextInput id="work-finish" type="date" value={form.finish} onChange={(value) => setField('finish', value)} />
|
{/* min — календарь не даёт выбрать окончание раньше начала. */}
|
||||||
|
<TextInput
|
||||||
|
id="work-finish"
|
||||||
|
type="date"
|
||||||
|
value={form.finish}
|
||||||
|
min={form.start || undefined}
|
||||||
|
onChange={(value) => setField('finish', value)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{editor.saveError && <Alert variant="err">{editor.saveError}</Alert>}
|
{editor.saveError && <Alert variant="err">{editor.saveError}</Alert>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export const WORKS_TEXTS = {
|
|||||||
add: 'Добавить',
|
add: 'Добавить',
|
||||||
save: 'Сохранить',
|
save: 'Сохранить',
|
||||||
saving: 'Сохранение...',
|
saving: 'Сохранение...',
|
||||||
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.'
|
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.',
|
||||||
|
dateRangeError: 'Дата окончания не может быть раньше даты начала',
|
||||||
|
unitSearch: 'Начните вводить ед. изм.',
|
||||||
|
unitNotFound: 'Ничего не найдено'
|
||||||
},
|
},
|
||||||
confirmDelete: {
|
confirmDelete: {
|
||||||
title: 'Подтвердите удаление',
|
title: 'Подтвердите удаление',
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Длинный справочник прокручивается внутри списка, а не растягивает модалку.
|
||||||
|
$dropdown-max-height: 260px;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-md;
|
||||||
|
padding: 0 $sp-3;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldOpen {
|
||||||
|
border-color: $gold-500;
|
||||||
|
box-shadow: 0 0 0 3px rgba(212, 160, 30, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: $sp-2 0;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $sp-1;
|
||||||
|
margin-right: $sp-1;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
color: $gray-500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + #{$sp-1});
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
max-height: $dropdown-max-height;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-3;
|
||||||
|
padding: $sp-1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
font-size: $fs-body;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionSelected {
|
||||||
|
font-weight: 600;
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: $sp-3;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon, ISelectOption } from '@tnsu/ui-kit';
|
||||||
|
import { useOutsideClose } from '../hooks/useOutsideClose';
|
||||||
|
import { filterOptions } from '../utils/filterOptions';
|
||||||
|
import styles from './SearchableSelect.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
options: ISelectOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Текст в пустом поле, когда ничего не выбрано. */
|
||||||
|
placeholder: string;
|
||||||
|
/** Подсказка в поле, пока пользователь ищет. */
|
||||||
|
searchPlaceholder: string;
|
||||||
|
notFoundText: string;
|
||||||
|
clearLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выпадающий список с поиском: можно прокрутить или начать печатать — список отфильтруется.
|
||||||
|
* Выбрать можно только значение из списка (свободный текст не сохраняется). В ui-kit такого
|
||||||
|
* компонента пока нет — живёт здесь, позже переедет в кит.
|
||||||
|
*/
|
||||||
|
export const SearchableSelect: React.FC<Props> = (props) => {
|
||||||
|
const { id, value, options, onChange, placeholder, searchPlaceholder, notFoundText, clearLabel } = props;
|
||||||
|
const [isOpen, setIsOpen] = React.useState<boolean>(false);
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const selectedLabel = options.filter((option) => option.value === value)[0]?.label ?? value;
|
||||||
|
const filtered = filterOptions(options, query);
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
};
|
||||||
|
useOutsideClose(wrapperRef, isOpen, close);
|
||||||
|
|
||||||
|
const pick = (option: ISelectOption): void => {
|
||||||
|
onChange(option.value);
|
||||||
|
close();
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
// Enter выбирает первое найденное — быстрее, чем тянуться к мышке.
|
||||||
|
if (event.key === 'Enter' && isOpen && filtered.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
pick(filtered[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
|
||||||
|
const onClear = (event: React.MouseEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
onChange('');
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper} ref={wrapperRef}>
|
||||||
|
<div className={isOpen ? `${styles.field} ${styles.fieldOpen}` : styles.field}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={isOpen ? query : selectedLabel}
|
||||||
|
placeholder={isOpen ? searchPlaceholder : placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
{value && !isOpen && (
|
||||||
|
<button type="button" className={styles.clearButton} title={clearLabel} aria-label={clearLabel} onMouseDown={onClear}>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={styles.chevron} aria-hidden="true">
|
||||||
|
<Icon name="chevron-down" size="sm" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.dropdown} role="listbox">
|
||||||
|
{filtered.length === 0 && <div className={styles.message}>{notFoundText}</div>}
|
||||||
|
{filtered.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
className={option.value === value ? `${styles.option} ${styles.optionSelected}` : styles.option}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
pick(option);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { filterOptions } from './filterOptions';
|
||||||
|
|
||||||
|
const units = ['м', 'м2', 'м3', 'кг', 'Комплект'].map((label) => ({ value: label, label }));
|
||||||
|
|
||||||
|
describe('filterOptions', () => {
|
||||||
|
it('пустой запрос — весь список', () => {
|
||||||
|
expect(filterOptions(units, ' ')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('подстрока без учёта регистра', () => {
|
||||||
|
expect(filterOptions(units, 'м3').map((o) => o.value)).toEqual(['м3']);
|
||||||
|
expect(filterOptions(units, 'КОМ').map((o) => o.value)).toEqual(['Комплект']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ничего не найдено — пустой список', () => {
|
||||||
|
expect(filterOptions(units, 'литр')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ISelectOption } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
|
/** Поиск по подписи: подстрока без учёта регистра. Пустой запрос — весь список. */
|
||||||
|
export function filterOptions(options: ISelectOption[], query: string): ISelectOption[] {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) return options;
|
||||||
|
return options.filter((option) => option.label.toLowerCase().indexOf(normalized) !== -1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user