mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
380 lines
8.4 KiB
Vue
380 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|
import type { IResourceLocatorResultExpanded } from '@/Interface';
|
|
import { N8nLoading } from 'n8n-design-system';
|
|
import type { EventBus } from 'n8n-design-system/utils';
|
|
import { createEventBus } from 'n8n-design-system/utils';
|
|
import type { NodeParameterValue } from 'n8n-workflow';
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
|
|
const SEARCH_BAR_HEIGHT_PX = 40;
|
|
const SCROLL_MARGIN_PX = 10;
|
|
|
|
type Props = {
|
|
modelValue?: NodeParameterValue;
|
|
resources?: IResourceLocatorResultExpanded[];
|
|
show?: boolean;
|
|
filterable?: boolean;
|
|
loading?: boolean;
|
|
filter?: string;
|
|
hasMore?: boolean;
|
|
errorView?: boolean;
|
|
filterRequired?: boolean;
|
|
width?: number;
|
|
eventBus?: EventBus;
|
|
};
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
modelValue: undefined,
|
|
resources: () => [],
|
|
show: false,
|
|
filterable: false,
|
|
loading: false,
|
|
filter: '',
|
|
hasMore: false,
|
|
errorView: false,
|
|
filterRequired: false,
|
|
width: undefined,
|
|
eventBus: () => createEventBus(),
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: NodeParameterValue];
|
|
loadMore: [];
|
|
filter: [filter: string];
|
|
}>();
|
|
|
|
const hoverIndex = ref(0);
|
|
const showHoverUrl = ref(false);
|
|
const searchRef = ref<HTMLInputElement>();
|
|
const resultsContainerRef = ref<HTMLDivElement>();
|
|
const itemsRef = ref<HTMLDivElement[]>([]);
|
|
|
|
const sortedResources = computed<IResourceLocatorResultExpanded[]>(() => {
|
|
const seen = new Set();
|
|
const { selected, notSelected } = props.resources.reduce(
|
|
(acc, item: IResourceLocatorResultExpanded) => {
|
|
if (seen.has(item.value)) {
|
|
return acc;
|
|
}
|
|
seen.add(item.value);
|
|
|
|
if (props.modelValue && item.value === props.modelValue) {
|
|
acc.selected = item;
|
|
} else {
|
|
acc.notSelected.push(item);
|
|
}
|
|
|
|
return acc;
|
|
},
|
|
{
|
|
selected: null as IResourceLocatorResultExpanded | null,
|
|
notSelected: [] as IResourceLocatorResultExpanded[],
|
|
},
|
|
);
|
|
|
|
if (selected) {
|
|
return [selected, ...notSelected];
|
|
}
|
|
|
|
return notSelected;
|
|
});
|
|
|
|
watch(
|
|
() => props.show,
|
|
(value) => {
|
|
if (value) {
|
|
hoverIndex.value = 0;
|
|
showHoverUrl.value = false;
|
|
|
|
setTimeout(() => {
|
|
if (value && props.filterable && searchRef.value) {
|
|
searchRef.value.focus();
|
|
}
|
|
}, 0);
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => props.loading,
|
|
() => {
|
|
setTimeout(() => onResultsEnd(), 0); // in case of filtering
|
|
},
|
|
);
|
|
onMounted(() => {
|
|
props.eventBus.on('keyDown', onKeyDown);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
props.eventBus.off('keyDown', onKeyDown);
|
|
});
|
|
|
|
function openUrl(event: MouseEvent, url: string) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
window.open(url, '_blank');
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'ArrowDown') {
|
|
if (hoverIndex.value < sortedResources.value.length - 1) {
|
|
hoverIndex.value++;
|
|
|
|
if (resultsContainerRef.value && itemsRef.value.length === 1) {
|
|
const item = itemsRef.value[0];
|
|
if (
|
|
item.offsetTop + item.clientHeight >
|
|
resultsContainerRef.value.scrollTop + resultsContainerRef.value.offsetHeight
|
|
) {
|
|
const top = item.offsetTop - resultsContainerRef.value.offsetHeight + item.clientHeight;
|
|
resultsContainerRef.value.scrollTo({ top });
|
|
}
|
|
}
|
|
}
|
|
} else if (e.key === 'ArrowUp') {
|
|
if (hoverIndex.value > 0) {
|
|
hoverIndex.value--;
|
|
|
|
const searchOffset = props.filterable ? SEARCH_BAR_HEIGHT_PX : 0;
|
|
if (resultsContainerRef.value && itemsRef.value.length === 1) {
|
|
const item = itemsRef.value[0];
|
|
if (item.offsetTop <= resultsContainerRef.value.scrollTop + searchOffset) {
|
|
resultsContainerRef.value.scrollTo({ top: item.offsetTop - searchOffset });
|
|
}
|
|
}
|
|
}
|
|
} else if (e.key === 'Enter') {
|
|
emit('update:modelValue', sortedResources.value[hoverIndex.value].value);
|
|
}
|
|
}
|
|
|
|
function onFilterInput(value: string) {
|
|
emit('filter', value);
|
|
}
|
|
|
|
function onItemClick(selected: string | number | boolean) {
|
|
emit('update:modelValue', selected);
|
|
}
|
|
|
|
function onItemHover(index: number) {
|
|
hoverIndex.value = index;
|
|
|
|
setTimeout(() => {
|
|
if (hoverIndex.value === index) {
|
|
showHoverUrl.value = true;
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function onItemHoverLeave() {
|
|
showHoverUrl.value = false;
|
|
}
|
|
|
|
function onResultsEnd() {
|
|
if (props.loading || !props.loading) {
|
|
return;
|
|
}
|
|
|
|
if (resultsContainerRef.value) {
|
|
const diff =
|
|
resultsContainerRef.value.offsetHeight -
|
|
(resultsContainerRef.value.scrollHeight - resultsContainerRef.value.scrollTop);
|
|
if (diff > -SCROLL_MARGIN_PX && diff < SCROLL_MARGIN_PX) {
|
|
emit('loadMore');
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<n8n-popover
|
|
placement="bottom"
|
|
:width="width"
|
|
:popper-class="$style.popover"
|
|
:visible="show"
|
|
:teleported="false"
|
|
data-test-id="resource-locator-dropdown"
|
|
>
|
|
<div v-if="errorView" :class="$style.messageContainer">
|
|
<slot name="error"></slot>
|
|
</div>
|
|
<div v-if="filterable && !errorView" :class="$style.searchInput" @keydown="onKeyDown">
|
|
<N8nInput
|
|
ref="searchRef"
|
|
:model-value="filter"
|
|
:clearable="true"
|
|
:placeholder="$locale.baseText('resourceLocator.search.placeholder')"
|
|
data-test-id="rlc-search"
|
|
@update:model-value="onFilterInput"
|
|
>
|
|
<template #prefix>
|
|
<font-awesome-icon :class="$style.searchIcon" icon="search" />
|
|
</template>
|
|
</N8nInput>
|
|
</div>
|
|
<div v-if="filterRequired && !filter && !errorView && !loading" :class="$style.searchRequired">
|
|
{{ $locale.baseText('resourceLocator.mode.list.searchRequired') }}
|
|
</div>
|
|
<div
|
|
v-else-if="!errorView && sortedResources.length === 0 && !loading"
|
|
:class="$style.messageContainer"
|
|
>
|
|
{{ $locale.baseText('resourceLocator.mode.list.noResults') }}
|
|
</div>
|
|
<div
|
|
v-else-if="!errorView"
|
|
ref="resultsContainerRef"
|
|
:class="$style.container"
|
|
@scroll="onResultsEnd"
|
|
>
|
|
<div
|
|
v-for="(result, i) in sortedResources"
|
|
:key="result.value.toString()"
|
|
ref="itemsRef"
|
|
:class="{
|
|
[$style.resourceItem]: true,
|
|
[$style.selected]: result.value === modelValue,
|
|
[$style.hovering]: hoverIndex === i,
|
|
}"
|
|
data-test-id="rlc-item"
|
|
@click="() => onItemClick(result.value)"
|
|
@mouseenter="() => onItemHover(i)"
|
|
@mouseleave="() => onItemHoverLeave()"
|
|
>
|
|
<div :class="$style.resourceNameContainer">
|
|
<span>{{ result.name }}</span>
|
|
</div>
|
|
<div :class="$style.urlLink">
|
|
<font-awesome-icon
|
|
v-if="showHoverUrl && result.url && hoverIndex === i"
|
|
icon="external-link-alt"
|
|
:title="result.linkAlt || $locale.baseText('resourceLocator.mode.list.openUrl')"
|
|
@click="openUrl($event, result.url)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div v-if="loading && !errorView">
|
|
<div v-for="i in 3" :key="i" :class="$style.loadingItem">
|
|
<N8nLoading :class="$style.loader" variant="p" :rows="1" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #reference>
|
|
<slot />
|
|
</template>
|
|
</n8n-popover>
|
|
</template>
|
|
|
|
<style lang="scss" module>
|
|
:root .popover {
|
|
--content-height: 236px;
|
|
padding: 0 !important;
|
|
border: var(--border-base);
|
|
display: flex;
|
|
max-height: calc(var(--content-height) + var(--spacing-xl));
|
|
flex-direction: column;
|
|
|
|
& ::-webkit-scrollbar {
|
|
width: 12px;
|
|
}
|
|
|
|
& ::-webkit-scrollbar-thumb {
|
|
border-radius: 12px;
|
|
background: var(--color-foreground-dark);
|
|
border: 3px solid white;
|
|
}
|
|
|
|
& ::-webkit-scrollbar-thumb:hover {
|
|
background: var(--color-foreground-xdark);
|
|
}
|
|
}
|
|
|
|
.container {
|
|
position: relative;
|
|
overflow: auto;
|
|
}
|
|
|
|
.messageContainer {
|
|
height: 236px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.searchInput {
|
|
border-bottom: var(--border-base);
|
|
--input-border-color: none;
|
|
--input-font-size: var(--font-size-2xs);
|
|
width: 100%;
|
|
z-index: 1;
|
|
}
|
|
|
|
.selected {
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.resourceItem {
|
|
display: flex;
|
|
padding: 0 var(--spacing-xs);
|
|
white-space: nowrap;
|
|
height: 32px;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
background-color: var(--color-background-base);
|
|
}
|
|
}
|
|
|
|
.loadingItem {
|
|
padding: 10px var(--spacing-xs);
|
|
}
|
|
|
|
.loader {
|
|
max-width: 120px;
|
|
|
|
* {
|
|
margin-top: 0 !important;
|
|
max-height: 12px;
|
|
}
|
|
}
|
|
|
|
.hovering {
|
|
background-color: var(--color-background-base);
|
|
}
|
|
|
|
.searchRequired {
|
|
height: 50px;
|
|
margin-top: 40px;
|
|
padding-left: var(--spacing-xs);
|
|
font-size: var(--font-size-xs);
|
|
color: var(--color-text-base);
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.urlLink {
|
|
display: flex;
|
|
align-items: center;
|
|
font-size: var(--font-size-3xs);
|
|
color: var(--color-text-base);
|
|
margin-left: var(--spacing-2xs);
|
|
|
|
&:hover {
|
|
color: var(--color-primary);
|
|
}
|
|
}
|
|
|
|
.resourceNameContainer {
|
|
font-size: var(--font-size-2xs);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
display: inline-block;
|
|
align-self: center;
|
|
}
|
|
|
|
.searchIcon {
|
|
color: var(--color-text-light);
|
|
}
|
|
</style>
|