n8n/packages/editor-ui/src/views/WorkflowsView.vue
Mutasem Aldmour 87bee7123e
fix types
2024-10-30 14:53:14 +01:00

692 lines
18 KiB
Vue

<script lang="ts" setup>
import { computed, onMounted, watch, ref, onBeforeMount } from 'vue';
import ResourcesListLayout, { type IResource } from '@/components/layouts/ResourcesListLayout.vue';
import WorkflowCard from '@/components/WorkflowCard.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
import type { IUser, WorkflowsFetchOptions } from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useTagsStore } from '@/stores/tags.store';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { useTemplatesStore } from '@/stores/templates.store';
import { getResourcePermissions } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useI18n } from '@/composables/useI18n';
import { useRoute, useRouter } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry';
import {
N8nButton,
N8nCard,
N8nHeading,
N8nIcon,
N8nInputLabel,
N8nOption,
N8nSelect,
N8nText,
N8nTooltip,
} from 'n8n-design-system';
import { pickBy } from 'lodash-es';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ProjectSharingData } from 'n8n-workflow';
import { useDebounce } from '@/composables/useDebounce';
const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const sourceControlStore = useSourceControlStore();
const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const posthogStore = usePostHog();
const projectsStore = useProjectsStore();
const templatesStore = useTemplatesStore();
const telemetry = useTelemetry();
const uiStore = useUIStore();
const tagsStore = useTagsStore();
const documentTitle = useDocumentTitle();
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const { callDebounced } = useDebounce();
interface Filters {
search: string;
homeProject: string;
status: string | boolean;
tags: string[];
credentials: string[];
nodeTypes: string[];
nodeName: string;
webhookUrl: string;
httpNodeUrl: string;
}
const StatusFilter = {
ACTIVE: true,
DEACTIVATED: false,
ALL: '',
};
const loading = ref(false);
const filters = ref<Filters>({
search: '',
homeProject: '',
status: StatusFilter.ALL,
tags: [],
credentials: [],
nodeTypes: [],
nodeName: '',
webhookUrl: '',
httpNodeUrl: '',
});
const nodeTypesFilter = ref<string>('');
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
const allWorkflows = computed(() => workflowsStore.allWorkflows as IResource[]);
const isShareable = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
);
const nodeTypesFiltered = computed(() =>
nodeTypesStore.allLatestNodeTypes.filter((nodeType) =>
nodeType.displayName.toLowerCase().includes(nodeTypesFilter.value),
),
);
const statusFilterOptions = computed(() => [
{
label: i18n.baseText('workflows.filters.status.all'),
value: StatusFilter.ALL,
},
{
label: i18n.baseText('workflows.filters.status.active'),
value: StatusFilter.ACTIVE,
},
{
label: i18n.baseText('workflows.filters.status.deactivated'),
value: StatusFilter.DEACTIVATED,
},
]);
const userRole = computed(() => {
const role = usersStore.currentUserCloudInfo?.role;
if (role) return role;
const answers = usersStore.currentUser?.personalizationAnswers;
if (answers && 'role' in answers) {
return answers.role;
}
return undefined;
});
const isOnboardingExperimentEnabled = computed(() => {
return (
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant
);
});
const isSalesUser = computed(() => {
return ['Sales', 'sales-and-marketing'].includes(userRole.value || '');
});
const addWorkflowButtonText = computed(() => {
return projectsStore.currentProject
? i18n.baseText('workflows.project.add')
: i18n.baseText('workflows.add');
});
const projectPermissions = computed(() => {
return getResourcePermissions(
projectsStore.currentProject?.scopes ?? projectsStore.personalProject?.scopes,
);
});
const emptyListDescription = computed(() => {
if (readOnlyEnv.value) {
return i18n.baseText('workflows.empty.description.readOnlyEnv');
} else if (!projectPermissions.value.workflow.create) {
return i18n.baseText('workflows.empty.description.noPermission');
} else {
return i18n.baseText('workflows.empty.description');
}
});
// Methods
const onFiltersUpdated = async (newFilters: Filters) => {
Object.assign(filters.value, newFilters);
void callDebounced(fetchWorkflowsWithFilters, { debounceTime: 1000, trailing: true });
};
const addWorkflow = () => {
uiStore.nodeViewInitialized = false;
void router.push({
name: VIEWS.NEW_WORKFLOW,
query: { projectId: route.params?.projectId },
});
telemetry.track('User clicked add workflow button', {
source: 'Workflows list',
});
trackEmptyCardClick('blank');
};
const getTemplateRepositoryURL = () => templatesStore.websiteTemplateRepositoryURL;
const trackEmptyCardClick = (option: 'blank' | 'templates' | 'courses') => {
telemetry.track('User clicked empty page option', {
option,
});
if (option === 'templates' && isSalesUser.value) {
trackCategoryLinkClick('Sales');
}
};
const trackCategoryLinkClick = (category: string) => {
telemetry.track(`User clicked Browse ${category} Templates`, {
role: usersStore.currentUserCloudInfo?.role,
active_workflow_count: workflowsStore.activeWorkflows.length,
});
};
const fetchWorkflowsWithFilters = async () => {
const { homeProject, status, credentials, nodeTypes, nodeName, webhookUrl, httpNodeUrl, tags } =
filters.value;
const options: WorkflowsFetchOptions = {
filter: {
projectId: homeProject ? homeProject : undefined,
active: status === StatusFilter.ACTIVE ? true : undefined,
tags: tags.map((tagId) => tagsStore.tagsById[tagId]?.name),
},
credentialIds: credentials.length ? credentials : undefined,
nodeTypes: nodeTypes.length ? nodeTypes : undefined,
nodeName: nodeName.length ? nodeName : undefined,
httpNodeURL: httpNodeUrl.length ? httpNodeUrl : undefined,
webhookURL: webhookUrl.length ? webhookUrl : undefined,
};
await workflowsStore.fetchAllWorkflows(options);
};
const initialize = async () => {
loading.value = true;
await setFiltersFromQueryString();
await Promise.all([
nodeTypesStore.loadNodeTypesIfNotLoaded(),
credentialsStore.fetchAllCredentials(route?.params?.projectId as string | undefined),
usersStore.fetchUsers(),
fetchWorkflowsWithFilters(),
workflowsStore.fetchActiveWorkflows(),
]);
loading.value = false;
};
const onClickTag = (tagId: string) => {
if (!filters.value.tags.includes(tagId)) {
filters.value.tags.push(tagId);
}
};
const saveFiltersOnQueryString = () => {
const query: { [key: string]: string } = {};
if (filters.value.search) {
query.search = filters.value.search;
}
if (typeof filters.value.status !== 'string') {
query.status = filters.value.status.toString();
}
if (filters.value.tags.length) {
query.tags = filters.value.tags.join(',');
}
if (filters.value.homeProject) {
query.homeProject = filters.value.homeProject;
}
if (filters.value.credentials) {
query.credentials = filters.value.credentials.join(',');
}
if (filters.value.nodeTypes) {
query.nodeTypes = filters.value.nodeTypes.join(',');
}
if (filters.value.nodeName) {
query.nodeName = filters.value.nodeName;
}
if (filters.value.httpNodeUrl) {
query.httpNodeUrl = filters.value.httpNodeUrl;
}
if (filters.value.webhookUrl) {
query.webhookUrl = filters.value.webhookUrl;
}
void router.replace({
query: Object.keys(query).length ? query : undefined,
});
};
function isValidProjectId(availableProjects: ProjectSharingData[], projectId: string) {
return availableProjects.some((project) => project.id === projectId);
}
const setFiltersFromQueryString = async () => {
const {
tags,
status,
search,
homeProject,
credentials,
nodeTypes,
nodeName,
httpNodeUrl,
webhookUrl,
} = route.query ?? {};
const filtersToApply: { [key: string]: string | string[] | boolean } = {};
if (homeProject && typeof homeProject === 'string') {
const available = await projectsStore.getAvailableProjects();
if (isValidProjectId(available, homeProject)) {
filtersToApply.homeProject = homeProject;
}
}
if (credentials && typeof credentials === 'string') {
// todo validation?
filters.value.credentials = credentials.split(',');
}
if (nodeTypes && typeof nodeTypes === 'string') {
filters.value.nodeTypes = nodeTypes.split(',');
}
if (nodeName && typeof nodeName === 'string') {
filters.value.nodeName = nodeName;
}
if (httpNodeUrl && typeof httpNodeUrl === 'string') {
filters.value.httpNodeUrl = httpNodeUrl;
}
if (webhookUrl && typeof webhookUrl === 'string') {
filters.value.webhookUrl = webhookUrl;
}
if (search && typeof search === 'string') {
filtersToApply.search = search;
}
if (tags && typeof tags === 'string') {
await tagsStore.fetchAll();
const currentTags = tagsStore.allTags.map((tag) => tag.id);
filtersToApply.tags = tags.split(',').filter((tag) => currentTags.includes(tag));
}
if (
status &&
typeof status === 'string' &&
[StatusFilter.ACTIVE.toString(), StatusFilter.DEACTIVATED.toString()].includes(status)
) {
filtersToApply.status = status === 'true';
}
if (Object.keys(filtersToApply).length) {
Object.assign(filters.value, filtersToApply);
}
void router.replace({ query: pickBy(route.query) });
};
function updateNodeTypesFilter(filter: string) {
nodeTypesFilter.value = filter;
}
sourceControlStore.$onAction(({ name, after }) => {
if (name !== 'pullWorkfolder') return;
after(async () => await initialize());
});
watch(filters, () => saveFiltersOnQueryString(), { deep: true });
watch(
() => route.params?.projectId,
async () => await initialize(),
);
onBeforeMount(async () => {});
onMounted(async () => {
documentTitle.set(i18n.baseText('workflows.heading'));
void usersStore.showPersonalizationSurvey();
});
</script>
<template>
<ResourcesListLayout
resource-key="workflows"
:resources="allWorkflows"
:filters="filters"
:type-props="{ itemSize: 80 }"
:shareable="isShareable"
:initialize="initialize"
:disabled="readOnlyEnv || !projectPermissions.workflow.create"
:loading="loading"
@click:add="addWorkflow"
@update:filters="onFiltersUpdated"
>
<template #header>
<ProjectTabs />
</template>
<template #add-button="{ disabled }">
<N8nTooltip :disabled="!readOnlyEnv">
<div>
<N8nButton
size="large"
block
:disabled="disabled"
data-test-id="resources-list-add"
@click="addWorkflow"
>
{{ addWorkflowButtonText }}
</N8nButton>
</div>
<template #content>
<i18n-t tag="span" keypath="mainSidebar.workflows.readOnlyEnv.tooltip">
<template #link>
<a target="_blank" href="https://docs.n8n.io/source-control-environments/">
{{ i18n.baseText('mainSidebar.workflows.readOnlyEnv.tooltip.link') }}
</a>
</template>
</i18n-t>
</template>
</N8nTooltip>
</template>
<template #default="{ data, updateItemSize }">
<WorkflowCard
data-test-id="resources-list-item"
class="mb-2xs"
:data="data"
:read-only="readOnlyEnv"
@expand:tags="updateItemSize(data)"
@click:tag="onClickTag"
/>
</template>
<template #empty>
<div class="text-center mt-s">
<N8nHeading tag="h2" size="xlarge" class="mb-2xs">
{{
currentUser.firstName
? i18n.baseText('workflows.empty.heading', {
interpolate: { name: currentUser.firstName },
})
: i18n.baseText('workflows.empty.heading.userNotSetup')
}}
</N8nHeading>
<N8nText v-if="!isOnboardingExperimentEnabled" size="large" color="text-base">
{{ emptyListDescription }}
</N8nText>
</div>
<div
v-if="!readOnlyEnv && projectPermissions.workflow.create"
:class="['text-center', 'mt-2xl', $style.actionsContainer]"
>
<N8nCard
:class="$style.emptyStateCard"
hoverable
data-test-id="new-workflow-card"
@click="addWorkflow"
>
<N8nIcon :class="$style.emptyStateCardIcon" icon="file" />
<N8nText size="large" class="mt-xs" color="text-dark">
{{ i18n.baseText('workflows.empty.startFromScratch') }}
</N8nText>
</N8nCard>
<a
v-if="isSalesUser || isOnboardingExperimentEnabled"
href="https://docs.n8n.io/courses/#available-courses"
:class="$style.emptyStateCard"
target="_blank"
>
<N8nCard
hoverable
data-test-id="browse-sales-templates-card"
@click="trackEmptyCardClick('courses')"
>
<N8nIcon :class="$style.emptyStateCardIcon" icon="graduation-cap" />
<N8nText size="large" class="mt-xs" color="text-dark">
{{ i18n.baseText('workflows.empty.learnN8n') }}
</N8nText>
</N8nCard>
</a>
<a
v-if="isSalesUser || isOnboardingExperimentEnabled"
:href="getTemplateRepositoryURL()"
:class="$style.emptyStateCard"
target="_blank"
>
<N8nCard
hoverable
data-test-id="browse-sales-templates-card"
@click="trackEmptyCardClick('templates')"
>
<N8nIcon :class="$style.emptyStateCardIcon" icon="box-open" />
<N8nText size="large" class="mt-xs" color="text-dark">
{{ i18n.baseText('workflows.empty.browseTemplates') }}
</N8nText>
</N8nCard>
</a>
</div>
</template>
<template #filters="{ setKeyValue }">
<div v-if="settingsStore.areTagsEnabled" class="mb-s">
<N8nInputLabel
:label="i18n.baseText('workflows.filters.tags')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
>
<WorkflowTagsDropdown
:placeholder="i18n.baseText('workflowOpen.filterWorkflows')"
:model-value="filters.tags"
:create-enabled="false"
@update:model-value="setKeyValue('tags', $event)"
/>
</N8nInputLabel>
</div>
<div class="mb-s">
<N8nInputLabel
:label="i18n.baseText('workflows.filters.status')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
>
<N8nSelect
data-test-id="status-dropdown"
:model-value="filters.status"
@update:model-value="setKeyValue('status', $event)"
>
<N8nOption
v-for="option in statusFilterOptions"
:key="option.label"
:label="option.label"
:value="option.value"
data-test-id="status"
>
</N8nOption>
</N8nSelect>
</N8nInputLabel>
</div>
<div :class="['mb-xs', $style.sectionTitle]">
{{ i18n.baseText('workflows.filter.credentialsAndNodes.sectionTitle') }}
</div>
<div class="mb-s">
<N8nInputLabel
:label="i18n.baseText('workflows.filters.credentials')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
>
<N8nSelect
data-test-id="credentials-filter"
:model-value="filters.credentials"
:multiple="true"
:filterable="true"
:placeholder="i18n.baseText('workflows.filters.credentials.placeholder')"
class="tags-container"
@update:model-value="setKeyValue('credentials', $event)"
>
<N8nOption
v-for="cred in credentialsStore.allCredentials"
:key="cred.id"
:label="cred.name"
:value="cred.id"
size="mini"
>
</N8nOption>
</N8nSelect>
</N8nInputLabel>
</div>
<div class="mb-s">
<N8nInputLabel
:label="i18n.baseText('workflows.filters.nodeTypes')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
>
<N8nSelect
data-test-id="node-types-filter"
:model-value="filters.nodeTypes"
:multiple="true"
:placeholder="i18n.baseText('workflows.filters.nodeTypes.placeholder')"
:filterable="true"
:filter-method="updateNodeTypesFilter"
class="tags-container"
@update:model-value="setKeyValue('nodeTypes', $event)"
>
<N8nOption
v-for="nodeType in nodeTypesFiltered"
:key="nodeType.name"
:label="nodeType.displayName"
:value="nodeType.name"
>
</N8nOption>
</N8nSelect>
</N8nInputLabel>
</div>
<div class="mb-s">
<N8nInputLabel
:label="i18n.baseText('workflows.filters.nodeName')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
>
<N8nInput
data-test-id="node-name-filter"
:model-value="filters.nodeName"
@update:model-value="setKeyValue('nodeName', $event)"
/>
</N8nInputLabel>
</div>
<div :class="['mb-xs', $style.sectionTitle]">
{{ i18n.baseText('workflows.filter.urls.sectionTitle') }}
</div>
<div class="mb-s">
<N8nInputLabel
:label="i18n.baseText('workflows.filters.urlWebhook')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
>
<N8nInput
data-test-id="url-webhook-filter"
:model-value="filters.webhookUrl"
@update:model-value="setKeyValue('webhookUrl', $event)"
/>
</N8nInputLabel>
</div>
<div class="mb-s">
<N8nInputLabel
:label="i18n.baseText('workflows.filters.urlHTTPRequest')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
>
<N8nInput
data-test-id="url-webhook-filter"
:model-value="filters.httpNodeUrl"
@update:model-value="setKeyValue('httpNodeUrl', $event)"
/>
</N8nInputLabel>
</div>
</template>
</ResourcesListLayout>
</template>
<style lang="scss" module>
.actionsContainer {
display: flex;
justify-content: center;
}
.emptyStateCard {
width: 192px;
text-align: center;
display: inline-flex;
height: 230px;
& + & {
margin-left: var(--spacing-s);
}
&:hover {
svg {
color: var(--color-primary);
}
}
}
.sectionTitle {
text-transform: uppercase;
color: var(--color-text-dark);
font-size: var(--font-size-3xs);
font-weight: var(--font-weight-bold);
border-bottom: var(--border-base);
line-height: 2;
}
.emptyStateCardIcon {
font-size: 48px;
svg {
width: 48px !important;
color: var(--color-foreground-dark);
transition: color 0.3s ease;
}
}
</style>