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 }>()