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 { AllCheckState, RelationSelectInterface, SelectSlotParams } from './types' import './styles.scss' export interface MultipleRelationSelectInterface { isOpened: boolean } export function defineMultipleRelationSelect() { return defineComponent({ props: { api: { type: Object as PropType, default: undefined, }, /** * Пользовательская установка значения **подсветки** \ */ customHighlightSearch: { type: Function as PropType<(value: string) => string>, default: undefined, }, dataProvider: { type: Object as PropType, default: undefined, }, 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>, }, modelValue: { type: Object as PropType, required: true, }, onValueChange: { type: Function as PropType<(v: T[]) => void>, required: true, }, paginationType: { type: String, }, params: { type: Object as PropType<{ [code: string] : string|string[] }>, default: () => ({}), }, perPage: { type: Number, default: 10, }, placeholder: { 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, }, size: { type: String as PropType<'M'|'S'>, }, title: { type: String, }, paramsSerialization: { type: String as PropType, default: 'default', }, additionalItems: { type: Array as PropType, }, showItemsCount: { type: Number, default: DEFAULT_SHOW_ITEMS_COUNT, }, hideSearch: { type: Boolean, default: false, }, root: { type: Object as PropType>, default: document, }, showDescriptionId: { type: Boolean, default: false, }, /** * Использовать для пажинации флаги наличия следующей и предыдущей страницы вместо флага count */ usePrevNextFlags: { type: Boolean, default: false, }, /** * Ключ флага наличия следующей страницы в ответе бекенда */ nextPageFlagKey: { type: String, }, /** * Ключ флага наличия предыдущей страницы в ответе бекенда */ prevPageFlagKey: { type: String, }, requestSize: { type: Number, }, canCheckAll: { type: Boolean, default: false, }, }, setup(props, context) { 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 topItems: Ref = ref([]) topItems.value = [...props.modelValue] const requestSize = computed(() => props.requestSize || Math.max(topItems.value.length, props.showItemsCount) + 10) const queryParams = computed((): typeof props.params => { const params = { ...props.params } // Де-структуризация ломает реактивность if (search.value !== '') params[props.searchKey] = search.value return params }) const unset = () => { if (props.onValueChange) props.onValueChange([]) } const { items, loadNext, loadPrev, initialLoad, reload, offset, clear, hasNextPage, hasPrevPage, } = useEndlessScrollApi({ endpoint: props.endpoint, params: queryParams, itemConverter: props.itemConverter, requestSize, paginationType: props.paginationType, requestPageKey: props.requestPageKey, requestPerPageKey: props.requestPerPageKey, api: props.api, dataProvider: props.dataProvider, paramsSerialization: props.paramsSerialization, autoReload: false, usePrevNextFlags: props.usePrevNextFlags, prevPageFlagKey: props.prevPageFlagKey, nextPageFlagKey: props.nextPageFlagKey, debug: props.placeholder === 'Код (дубль названия)', sortFieldKey: props.sortFieldKey, }) if (props.preselected) { onMounted(async () => { await initialLoad() canBeLoaded.value = true if (props.onValueChange) props.onValueChange([items.value[0]]) }) } 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) } }, trigger: triggerDown, root, }) useScrollPagination({ callback: async () => { if (canBeLoaded.value && !isLoadingNext.value && !isLoadingPrev.value) { const firstChildIndex = 1 + topItems.value.length + (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) } }, 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 } }) useClickOutside(select, () => { isOpened.value = false topItems.value = [...props.modelValue] }, props.root) const setSearch = (s: string) => { search.value = s topItems.value = [...props.modelValue] } const isSelected = (item: T) => Boolean(props.modelValue.find((i) => i.id === item.id)) const toggleItem = (item: T) => { if (isSelected(item)) { const i = props.modelValue.find((i) => i.id === item.id) if (!i) return const newValue = [...props.modelValue] newValue.splice(newValue.indexOf(i), 1) if (props.onValueChange) props.onValueChange(newValue) } else if (props.onValueChange) props.onValueChange([...props.modelValue, item]) } const toggleOpened = () => { isOpened.value = !isOpened.value topItems.value = [...props.modelValue] } const allCheckState = computed(() => { if (!props.canCheckAll || items.value.length === 0 || hasNextPage.value || (!hasNextPage.value && hasPrevPage.value)) return undefined if (!props.modelValue.length) return false const idsToExclude = new Set([ ...items.value.map((item) => item.id), ...(props.additionalItems || []).map((item) => item.id), ]) const filteredTopItems = topItems.value.filter((item) => !idsToExclude.has(item.id)) return props.modelValue.length === items.value.length + (props.additionalItems?.length || 0) + filteredTopItems.length ? true : 'multiple' }) const toggleAll = () => { if (allCheckState.value === true) { props.onValueChange([]) } else { const idsToExclude = new Set([ ...items.value.map((item) => item.id), ...(props.additionalItems || []).map((item) => item.id), ]) const filteredTopItems = topItems.value.filter((item) => !idsToExclude.has(item.id)) props.onValueChange([...filteredTopItems, ...items.value, ...props.additionalItems || []]) } } const exposeParms: RelationSelectInterface = { items, isOpened, clear, unset, } context.expose(exposeParms) const SelectTemplate = defineTemplate() return (): JSX.Element => { const templateProps = { clear: unset, disabled: props.disabled, errors: props.errors, isLoadingNext: isLoadingNext.value, isLoadingPrev: isLoadingPrev.value, isOpened: isOpened.value, isSelected, items: items.value, itemSlot: props.itemSlot, modelValue: props.modelValue, placeholder: props.placeholder, resetable: props.resetable, root, search: search.value, setSearch, select, title: props.title, toggleItem, toggleOpened, topItems: topItems.value, triggerUp, triggerDown, isSingle: false, additionalItems: props.additionalItems, size: props.size, showItemsCount: props.showItemsCount, hideSearch: props.hideSearch, showDescriptionId: props.showDescriptionId, allCheckState: allCheckState.value, toggleAll, customHighlightSearch: props.customHighlightSearch?.(search.value), } return } }, }) } export default defineMultipleRelationSelect<{ id: string, name: string }>()