mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-15 00:54:06 -08:00
22bdb0568e
Co-authored-by: Alex Grozav <alex@grozav.com>
459 lines
12 KiB
Vue
459 lines
12 KiB
Vue
<template>
|
|
<TemplatesView>
|
|
<template #header>
|
|
<div :class="$style.wrapper">
|
|
<div :class="$style.title">
|
|
<n8n-heading tag="h1" size="2xlarge">
|
|
{{ $locale.baseText('templates.heading') }}
|
|
</n8n-heading>
|
|
</div>
|
|
<div :class="$style.button">
|
|
<n8n-button
|
|
size="large"
|
|
type="secondary"
|
|
element="a"
|
|
:href="creatorHubUrl"
|
|
:label="$locale.baseText('templates.shareWorkflow')"
|
|
target="_blank"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<div :class="$style.contentWrapper">
|
|
<div :class="$style.filters">
|
|
<TemplateFilters
|
|
:categories="templatesStore.allCategories"
|
|
:sort-on-populate="areCategoriesPrepopulated"
|
|
:selected="categories"
|
|
:loading="loadingCategories"
|
|
@clear="onCategoryUnselected"
|
|
@clear-all="onCategoriesCleared"
|
|
@select="onCategorySelected"
|
|
/>
|
|
</div>
|
|
<div :class="$style.search">
|
|
<n8n-input
|
|
:model-value="search"
|
|
:placeholder="$locale.baseText('templates.searchPlaceholder')"
|
|
clearable
|
|
data-test-id="template-search-input"
|
|
@update:model-value="onSearchInput"
|
|
@blur="trackSearch"
|
|
>
|
|
<template #prefix>
|
|
<font-awesome-icon icon="search" />
|
|
</template>
|
|
</n8n-input>
|
|
<div v-show="collections.length || loadingCollections" :class="$style.carouselContainer">
|
|
<div :class="$style.header">
|
|
<n8n-heading :bold="true" size="medium" color="text-light">
|
|
{{ $locale.baseText('templates.collections') }}
|
|
<span
|
|
v-if="!loadingCollections"
|
|
data-test-id="collection-count-label"
|
|
v-text="`(${collections.length})`"
|
|
/>
|
|
</n8n-heading>
|
|
</div>
|
|
<TemplatesInfoCarousel
|
|
:collections="collections"
|
|
:loading="loadingCollections"
|
|
@open-collection="onOpenCollection"
|
|
/>
|
|
</div>
|
|
<TemplateList
|
|
:infinite-scroll-enabled="true"
|
|
:loading="loadingWorkflows"
|
|
:workflows="workflows"
|
|
:total-count="totalWorkflows"
|
|
@load-more="onLoadMore"
|
|
@open-template="onOpenTemplate"
|
|
/>
|
|
<div v-if="endOfSearchMessage" :class="$style.endText">
|
|
<n8n-text size="medium" color="text-base">
|
|
<span v-html="endOfSearchMessage" />
|
|
</n8n-text>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</TemplatesView>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent } from 'vue';
|
|
import { mapStores } from 'pinia';
|
|
import TemplatesInfoCarousel from '@/components/TemplatesInfoCarousel.vue';
|
|
import TemplateFilters from '@/components/TemplateFilters.vue';
|
|
import TemplateList from '@/components/TemplateList.vue';
|
|
import TemplatesView from '@/views/TemplatesView.vue';
|
|
|
|
import type {
|
|
ITemplatesCollection,
|
|
ITemplatesWorkflow,
|
|
ITemplatesQuery,
|
|
ITemplatesCategory,
|
|
} from '@/Interface';
|
|
import type { IDataObject } from 'n8n-workflow';
|
|
import { setPageTitle } from '@/utils/htmlUtils';
|
|
import { CREATOR_HUB_URL, VIEWS } from '@/constants';
|
|
import { useSettingsStore } from '@/stores/settings.store';
|
|
import { useUsersStore } from '@/stores/users.store';
|
|
import { useTemplatesStore } from '@/stores/templates.store';
|
|
import { useUIStore } from '@/stores/ui.store';
|
|
import { useToast } from '@/composables/useToast';
|
|
import { usePostHog } from '@/stores/posthog.store';
|
|
import { useDebounce } from '@/composables/useDebounce';
|
|
|
|
interface ISearchEvent {
|
|
search_string: string;
|
|
workflow_results_count: number;
|
|
collection_results_count: number;
|
|
categories_applied: ITemplatesCategory[];
|
|
wf_template_repo_session_id: string;
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: 'TemplatesSearchView',
|
|
components: {
|
|
TemplatesInfoCarousel,
|
|
TemplateFilters,
|
|
TemplateList,
|
|
TemplatesView,
|
|
},
|
|
beforeRouteLeave(_to, _from, next) {
|
|
const contentArea = document.getElementById('content');
|
|
if (contentArea) {
|
|
// When leaving this page, store current scroll position in route data
|
|
this.$route.meta?.setScrollPosition?.(contentArea.scrollTop);
|
|
}
|
|
|
|
this.trackSearch();
|
|
next();
|
|
},
|
|
setup() {
|
|
const { callDebounced } = useDebounce();
|
|
|
|
return {
|
|
callDebounced,
|
|
...useToast(),
|
|
};
|
|
},
|
|
data() {
|
|
return {
|
|
areCategoriesPrepopulated: false,
|
|
categories: [] as ITemplatesCategory[],
|
|
loading: true,
|
|
loadingCategories: true,
|
|
loadingCollections: true,
|
|
loadingWorkflows: true,
|
|
search: '',
|
|
searchEventToTrack: null as null | ISearchEvent,
|
|
errorLoadingWorkflows: false,
|
|
creatorHubUrl: CREATOR_HUB_URL as string,
|
|
};
|
|
},
|
|
computed: {
|
|
...mapStores(useSettingsStore, useTemplatesStore, useUIStore, useUsersStore, usePostHog),
|
|
totalWorkflows(): number {
|
|
return this.templatesStore.getSearchedWorkflowsTotal(this.createQueryObject('name'));
|
|
},
|
|
workflows(): ITemplatesWorkflow[] {
|
|
return this.templatesStore.getSearchedWorkflows(this.createQueryObject('name')) ?? [];
|
|
},
|
|
collections(): ITemplatesCollection[] {
|
|
return this.templatesStore.getSearchedCollections(this.createQueryObject('id')) ?? [];
|
|
},
|
|
endOfSearchMessage(): string | null {
|
|
if (this.loadingWorkflows) {
|
|
return null;
|
|
}
|
|
if (
|
|
!this.loadingCollections &&
|
|
this.workflows.length === 0 &&
|
|
this.collections.length === 0
|
|
) {
|
|
if (!this.settingsStore.isTemplatesEndpointReachable && this.errorLoadingWorkflows) {
|
|
return this.$locale.baseText('templates.connectionWarning');
|
|
}
|
|
return this.$locale.baseText('templates.noSearchResults');
|
|
}
|
|
|
|
return null;
|
|
},
|
|
nothingFound(): boolean {
|
|
return (
|
|
!this.loadingWorkflows &&
|
|
!this.loadingCollections &&
|
|
this.workflows.length === 0 &&
|
|
this.collections.length === 0
|
|
);
|
|
},
|
|
},
|
|
watch: {
|
|
workflows(newWorkflows) {
|
|
if (newWorkflows.length === 0) {
|
|
this.scrollTo(0);
|
|
}
|
|
},
|
|
},
|
|
async mounted() {
|
|
setPageTitle('n8n - Templates');
|
|
await this.loadCategories();
|
|
void this.loadWorkflowsAndCollections(true);
|
|
void this.usersStore.showPersonalizationSurvey();
|
|
|
|
this.restoreSearchFromRoute();
|
|
|
|
setTimeout(() => {
|
|
// Check if there is scroll position saved in route and scroll to it
|
|
const scrollOffset = this.$route.meta?.scrollOffset;
|
|
if (typeof scrollOffset === 'number' && scrollOffset > 0) {
|
|
this.scrollTo(scrollOffset, 'auto');
|
|
}
|
|
}, 100);
|
|
},
|
|
methods: {
|
|
createQueryObject(categoryId: 'name' | 'id'): ITemplatesQuery {
|
|
// We are using category names for template search and ids for collection search
|
|
return {
|
|
categories: this.categories.map((category) =>
|
|
categoryId === 'name' ? category.name : String(category.id),
|
|
),
|
|
search: this.search,
|
|
};
|
|
},
|
|
restoreSearchFromRoute() {
|
|
let updateSearch = false;
|
|
if (this.$route.query.search && typeof this.$route.query.search === 'string') {
|
|
this.search = this.$route.query.search;
|
|
updateSearch = true;
|
|
}
|
|
if (typeof this.$route.query.categories === 'string' && this.$route.query.categories.length) {
|
|
const categoriesFromURL = this.$route.query.categories.split(',');
|
|
this.categories = this.templatesStore.allCategories.filter((category) =>
|
|
categoriesFromURL.includes(category.id.toString()),
|
|
);
|
|
updateSearch = true;
|
|
}
|
|
if (updateSearch) {
|
|
this.updateSearch();
|
|
this.trackCategories();
|
|
this.areCategoriesPrepopulated = true;
|
|
}
|
|
},
|
|
onOpenCollection({ event, id }: { event: MouseEvent; id: string }) {
|
|
this.navigateTo(event, VIEWS.COLLECTION, id);
|
|
},
|
|
onOpenTemplate({ event, id }: { event: MouseEvent; id: string }) {
|
|
this.navigateTo(event, VIEWS.TEMPLATE, id);
|
|
},
|
|
navigateTo(e: MouseEvent, page: string, id: string) {
|
|
if (e.metaKey || e.ctrlKey) {
|
|
const route = this.$router.resolve({ name: page, params: { id } });
|
|
window.open(route.href, '_blank');
|
|
return;
|
|
} else {
|
|
void this.$router.push({ name: page, params: { id } });
|
|
}
|
|
},
|
|
updateSearch() {
|
|
this.updateQueryParam(this.search, this.categories.map((category) => category.id).join(','));
|
|
void this.loadWorkflowsAndCollections(false);
|
|
},
|
|
updateSearchTracking(search: string, categories: number[]) {
|
|
if (!search) {
|
|
return;
|
|
}
|
|
if (this.searchEventToTrack && this.searchEventToTrack.search_string.length > search.length) {
|
|
return;
|
|
}
|
|
|
|
this.searchEventToTrack = {
|
|
search_string: search,
|
|
workflow_results_count: this.workflows.length,
|
|
collection_results_count: this.collections.length,
|
|
categories_applied: categories.map((categoryId) =>
|
|
this.templatesStore.getCategoryById(categoryId.toString()),
|
|
) as ITemplatesCategory[],
|
|
wf_template_repo_session_id: this.templatesStore.currentSessionId,
|
|
};
|
|
},
|
|
trackSearch() {
|
|
if (this.searchEventToTrack) {
|
|
this.$telemetry.track(
|
|
'User searched workflow templates',
|
|
this.searchEventToTrack as unknown as IDataObject,
|
|
);
|
|
this.searchEventToTrack = null;
|
|
}
|
|
},
|
|
onSearchInput(search: string) {
|
|
this.loadingWorkflows = true;
|
|
this.loadingCollections = true;
|
|
this.search = search;
|
|
void this.callDebounced(this.updateSearch, {
|
|
debounceTime: 500,
|
|
trailing: true,
|
|
});
|
|
|
|
if (search.length === 0) {
|
|
this.trackSearch();
|
|
}
|
|
},
|
|
onCategorySelected(selected: ITemplatesCategory) {
|
|
this.categories = this.categories.concat(selected);
|
|
this.updateSearch();
|
|
this.trackCategories();
|
|
},
|
|
onCategoryUnselected(selected: ITemplatesCategory) {
|
|
this.categories = this.categories.filter((category) => category.id !== selected.id);
|
|
this.updateSearch();
|
|
this.trackCategories();
|
|
},
|
|
onCategoriesCleared() {
|
|
this.categories = [];
|
|
this.updateSearch();
|
|
},
|
|
trackCategories() {
|
|
if (this.categories.length) {
|
|
this.$telemetry.track('User changed template filters', {
|
|
search_string: this.search,
|
|
categories_applied: this.categories,
|
|
wf_template_repo_session_id: this.templatesStore.currentSessionId,
|
|
});
|
|
}
|
|
},
|
|
updateQueryParam(search: string, category: string) {
|
|
const query = Object.assign({}, this.$route.query);
|
|
|
|
if (category.length) {
|
|
query.categories = category;
|
|
} else {
|
|
delete query.categories;
|
|
}
|
|
|
|
if (search.length) {
|
|
query.search = search;
|
|
} else {
|
|
delete query.search;
|
|
}
|
|
|
|
void this.$router.replace({ query });
|
|
},
|
|
async onLoadMore() {
|
|
if (this.workflows.length >= this.totalWorkflows) {
|
|
return;
|
|
}
|
|
try {
|
|
this.loadingWorkflows = true;
|
|
await this.templatesStore.getMoreWorkflows({
|
|
categories: this.categories.map((category) => category.name),
|
|
search: this.search,
|
|
});
|
|
} catch (e) {
|
|
this.showMessage({
|
|
title: 'Error',
|
|
message: 'Could not load more workflows',
|
|
type: 'error',
|
|
});
|
|
} finally {
|
|
this.loadingWorkflows = false;
|
|
}
|
|
},
|
|
async loadCategories() {
|
|
try {
|
|
await this.templatesStore.getCategories();
|
|
} catch (e) {}
|
|
this.loadingCategories = false;
|
|
},
|
|
async loadCollections() {
|
|
try {
|
|
this.loadingCollections = true;
|
|
await this.templatesStore.getCollections({
|
|
categories: this.categories.map((category) => String(category.id)),
|
|
search: this.search,
|
|
});
|
|
} catch (e) {}
|
|
|
|
this.loadingCollections = false;
|
|
},
|
|
async loadWorkflows() {
|
|
try {
|
|
this.loadingWorkflows = true;
|
|
await this.templatesStore.getWorkflows({
|
|
search: this.search,
|
|
categories: this.categories.map((category) => category.name),
|
|
});
|
|
this.errorLoadingWorkflows = false;
|
|
} catch (e) {
|
|
this.errorLoadingWorkflows = true;
|
|
}
|
|
|
|
this.loadingWorkflows = false;
|
|
},
|
|
async loadWorkflowsAndCollections(initialLoad: boolean) {
|
|
const search = this.search;
|
|
const categories = [...this.categories];
|
|
await Promise.all([this.loadWorkflows(), this.loadCollections()]);
|
|
if (!initialLoad) {
|
|
this.updateSearchTracking(
|
|
search,
|
|
categories.map((category) => category.id),
|
|
);
|
|
}
|
|
},
|
|
scrollTo(position: number, behavior: ScrollBehavior = 'smooth') {
|
|
setTimeout(() => {
|
|
const contentArea = document.getElementById('content');
|
|
if (contentArea) {
|
|
contentArea.scrollTo({
|
|
top: position,
|
|
behavior,
|
|
});
|
|
}
|
|
}, 0);
|
|
},
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss" module>
|
|
.wrapper {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.contentWrapper {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
|
|
@media (max-width: $breakpoint-xs) {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
|
|
.filters {
|
|
width: 200px;
|
|
margin-bottom: var(--spacing-xl);
|
|
margin-right: var(--spacing-2xl);
|
|
}
|
|
|
|
.search {
|
|
width: 100%;
|
|
|
|
> * {
|
|
margin-bottom: var(--spacing-l);
|
|
}
|
|
|
|
@media (max-width: $breakpoint-xs) {
|
|
padding-left: 0;
|
|
}
|
|
}
|
|
|
|
.header {
|
|
margin-bottom: var(--spacing-2xs);
|
|
}
|
|
</style>
|