import { isEqual } from 'lodash' import { computed, defineComponent, onMounted, ref, watch, type PropType, type Ref, } from 'vue' import useClickOutside from '../../composables/use-click-outside' import useEndlessScrollApi, { type paramsSerializationOptions } from '../../composables/use-endless-scroll-api' import useScrollPagination from '../../composables/use-scroll-pagination' import { DebounceRejectError } from '../../helpers' import type ApiInterface from '../../types/api-interface' import type DataProviderInterface from '../../types/data-provider-interface' import defineTemplate, { DEFAULT_SHOW_ITEMS_COUNT } from './template' import type { RelationSelectInterface, SelectSlotParams } from './types' import './styles.scss' /** * Компонент представляет собой селект, получающий данные из API. * Обладает различными настраиваемыми свойствами: параметры запроса, сериализации, добавление пользовательских элементов, поиск элемента, настройка пагинации и пр. * *

* Дизайн *

*/ export function defineRelationSelect() { return defineComponent({ props: { /** * Опция копирования значения */ isWithCopy: { type: Boolean, default: false, }, /** * Интерфейс API */ api: { type: Object as PropType, default: undefined, }, /** * Пользовательская установка значения **подсветки** \ */ customHighlightSearch: { type: Function as PropType<(value: string) => string>, default: undefined, }, /** * Интерфейс dataProvider. * * Обращение к бэку без использования axios */ dataProvider: { type: Object as PropType, default: undefined, }, /** * Вариант отображения селекта (filter, form) */ appearance: { type: String as PropType<'filter'|'form'>, default: 'filter', }, /** * Вкл/Выкл возможность очищать выбранное значение */ resetable: { type: Boolean, default: false, }, /** * Флаг, сброса значений при одинаковых параметрах */ resetOnQueryParamsChange: { type: Boolean, default: false, }, /** * Флаг, указывающий, отключен ли селект */ disabled: { type: Boolean, default: false, }, /** * Эндпоинд, на который будут отправляться запросы на получение массива элементов / поиска */ endpoint: { type: String, required: true, }, /** * Массив ошибок */ errors: { type: Array as PropType, default: () => [], }, /** * Функция в которой можно привести массив элементов к нужному формату */ itemConverter: { type: Function as PropType<(item: any) => T>, }, /** * Функция со слотом кастомных элементов списка */ itemSlot: { type: Function as PropType<(item: T, params: SelectSlotParams) => JSX.Element>, }, /** * Слот слева перед установленным значением */ leftSlot: { type: Function as PropType<(item: T) => JSX.Element>, default: undefined, }, /** * Значение селекта */ modelValue: { type: Object as PropType, default: undefined, }, /** * Функция смены значения */ onValueChange: { type: Function as PropType<(v: T|undefined) => void>, default: () => () => { // do nothing }, }, /** * Тип пагинации скроллом или постраничная */ paginationType: { type: String, }, /** * Объект с запрашиваемыми параметрами в URL'e */ params: { type: Object as PropType<{ [code: string] : string|string[] }>, default: () => ({}), }, /** * Количество элементов запрашиваемое в запросе к бэку на одной странице пагинации */ perPage: { type: Number, default: 10, }, /** * Текстовая подсказка в окне селекта при значении undefined в modelValue */ placeholder: { type: String, default: '', }, /** * Описание селекта */ description: { type: String, default: '', }, /** * Флаг, отвечающий за предвыбранный элемент из списка */ preselected: { type: Boolean, default: false, }, /** * Ключ пагинации текущей страницы */ requestPageKey: { type: String, }, /** * Ключ пагинации куда указывать количество элементов запрашиваемое в запросе к бэку на одной странице пагинации */ requestPerPageKey: { type: String, }, /** * Ключ массива из которого брать элементы списка */ responseItemsKey: { type: String, }, /** * Ключ количества всех элементов */ responseTotalKey: { type: String, }, /** * Ключ параметра отвечающий за поиск элемента на бэке */ searchKey: { type: String, default: 'name', }, /** * Название параметра сортировки */ sortFieldKey: { type: String, default: undefined, }, /** * Размер селекта: 'S' или 'M'. * Если не указать, то по умолчанию 'M'. */ size: { type: String as PropType<'M'|'S'>, }, /** * Название селекта */ title: { type: String, }, /** * Отметить как обязательный */ isRequired: { type: Boolean, default: false, }, /** * Тип сериализации квери параметров: 'default' | 'custom' | 'comma' */ paramsSerialization: { type: String as PropType, default: 'default', }, /** * Массив элементов добавляемый юзером */ additionalItems: { type: Array as PropType, }, /** * Функция проверки наличия элементов в массиве */ onInitialLoad: { type: Function as PropType<(isEmpty: boolean) => void>, }, /** * Число отображаемых элементов в раскрытом списке */ showItemsCount: { type: Number, default: DEFAULT_SHOW_ITEMS_COUNT, }, /** * Флаг, в значении true отображает id каждого элемента */ showDescriptionId: { type: Boolean, default: false, }, /** * Использовать для пажинации флаги наличия следующей и предыдущей страницы вместо флага count */ usePrevNextFlags: { type: Boolean, default: false, }, /** * Ключ флага наличия следующей страницы в ответе бекенда */ nextPageFlagKey: { type: String, }, /** * Ключ флага наличия предыдущей страницы в ответе бекенда */ prevPageFlagKey: { type: String, }, hideSearch: { type: Boolean, default: false, }, }, setup(props, { expose }) { const isOpened = ref(false) const triggerDown = ref(null) as unknown as Ref const triggerUp = ref(null) as unknown as Ref const canBeLoaded = ref(false) const root = ref(null) as unknown as Ref const select = ref(null) as unknown as Ref const search = ref('') const isLoadingPrev = ref(false) const isLoadingNext = ref(false) const value = computed(() => props.modelValue as T) const unset = () => { if (props.onValueChange) props.onValueChange(undefined) } const queryParams = computed((): typeof props.params => { const params = { ...props.params } // Де-структуризация ломает реактивность if (search.value !== '') params[props.searchKey] = search.value return params }) const { items, loadNext, loadPrev, initialLoad, reload, offset, clear, count, } = useEndlessScrollApi({ endpoint: props.endpoint, params: queryParams, itemConverter: props.itemConverter, paginationType: props.paginationType, requestPageKey: props.requestPageKey, requestPerPageKey: props.requestPerPageKey, api: props.api, dataProvider: props.dataProvider, paramsSerialization: props.paramsSerialization, autoReload: false, requestSize: ref(props.showItemsCount + 5), usePrevNextFlags: props.usePrevNextFlags, prevPageFlagKey: props.prevPageFlagKey, nextPageFlagKey: props.nextPageFlagKey, sortFieldKey: props.sortFieldKey, }) if (props.preselected) { onMounted(async () => { await initialLoad() canBeLoaded.value = true if (props.onValueChange) props.onValueChange(items.value[0]) if (props.onInitialLoad) { props.onInitialLoad(Boolean(items.value.length)) } }) } useScrollPagination({ callback: async () => { if (canBeLoaded.value && !isLoadingNext.value && !isLoadingPrev.value) { const lastChild = root.value.children[root.value.children.length - 2] as HTMLElement isLoadingNext.value = true const flag = await loadNext() if (flag) { root.value.scrollTo({ top: lastChild.offsetTop + lastChild.clientHeight - root.value.clientHeight }) setTimeout(() => { isLoadingNext.value = false }, 100) } else isLoadingNext.value = false } }, trigger: triggerDown, root, }) useScrollPagination({ callback: async () => { if (canBeLoaded.value && !isLoadingNext.value && !isLoadingPrev.value) { const firstChildIndex = 1 + (props.additionalItems?.length || 0) const firstChild = root.value.children[firstChildIndex] as HTMLElement isLoadingPrev.value = true const flag = await loadPrev() if (flag) { root.value.scrollTo({ top: firstChild.offsetTop }) setTimeout(() => { isLoadingPrev.value = false }, 100) } else isLoadingPrev.value = false } }, trigger: triggerUp, root, }) watch(() => props.params, (after, before) => { if (props.resetOnQueryParamsChange && !isEqual(after, before)) { unset() } }) const deferredReload = async () => { // мгновенная перезагрузка происходит только в том случае, если селект раскрыт, иначе она будет выполена отложенно при раскрытии if (isOpened.value) { isLoadingNext.value = true isLoadingPrev.value = true try { await reload() isLoadingNext.value = false isLoadingPrev.value = false } catch (error) { if (error instanceof DebounceRejectError) return isLoadingNext.value = false isLoadingPrev.value = false } return } clear() } watch(() => props.endpoint, async () => { deferredReload() }) watch(queryParams, async (after, before) => { if (isEqual(after, before)) return deferredReload() }) watch(isOpened, async () => { if (isOpened.value && items.value.length === 0) { isLoadingNext.value = true await initialLoad() isLoadingNext.value = false canBeLoaded.value = true if (props.onInitialLoad) { props.onInitialLoad(Boolean(items.value.length)) } } }) useClickOutside(select, () => { isOpened.value = false }) const toggleOpened = () => { isOpened.value = !isOpened.value } const toggleItem = (item: T) => { if (props.onValueChange) props.onValueChange(item) toggleOpened() } const isSelected = (item: T) => value.value?.id === item.id const exposeParms: RelationSelectInterface = { items, isOpened, clear, unset, } expose(exposeParms) const SelectTemplate = defineTemplate() return (): JSX.Element => { const templateProps = { isWithCopy: props.isWithCopy, clear: unset, disabled: props.disabled, errors: props.errors, isLoadingNext: isLoadingNext.value, isLoadingPrev: isLoadingPrev.value, isOpened: isOpened.value, isSelected, items: items.value, itemSlot: props.itemSlot, leftSlot: props.leftSlot ? ((value: T[]) => props.leftSlot!(value[0])) : undefined, modelValue: value.value ? [value.value] : [], placeholder: props.placeholder, description: props.description, isRequired: props.isRequired, resetable: props.resetable, root, search: search.value, setSearch: (s: string) => { search.value = s }, select, title: props.title, toggleItem, toggleOpened, topItems: [], triggerUp, triggerDown, isSingle: true, appearance: props.appearance, additionalItems: props.additionalItems, size: props.size, showItemsCount: props.showItemsCount, hideSearch: props.hideSearch, showDescriptionId: props.showDescriptionId, customHighlightSearch: props.customHighlightSearch?.(search.value), } return } }, }) } export default defineRelationSelect<{ id: string, name: string }>()