refactor(editor): WorkflowsView to script setup + tests (#11078)

This commit is contained in:
Raúl Gómez Morales 2024-10-03 16:43:08 +02:00 committed by GitHub
parent e081fd1f0b
commit ce69218f1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 578 additions and 418 deletions

View file

@ -1,111 +1,299 @@
import { afterAll, beforeAll } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { setupServer } from '@/__tests__/server';
import WorkflowsView from '@/views/WorkflowsView.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { createComponentRenderer } from '@/__tests__/render';
import { useProjectsStore } from '@/stores/projects.store';
import { createTestingPinia } from '@pinia/testing';
import { STORES, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
import { mockedStore } from '@/__tests__/utils';
import { usePostHog } from '@/stores/posthog.store';
import type { Cloud, IUser, IWorkflowDb } from '@/Interface';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { Project } from '@/types/projects.types';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useTagsStore } from '@/stores/tags.store';
import { createRouter, createWebHistory } from 'vue-router';
import * as usersApi from '@/api/users';
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight',
) as PropertyDescriptor;
vi.mock('@/api/projects.api');
vi.mock('@/api/users');
vi.mock('@/api/sourceControl');
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/:projectId?',
component: { template: '<div></div>' },
},
{
path: '/workflow',
name: VIEWS.NEW_WORKFLOW,
component: { template: '<div></div>' },
},
],
});
const renderComponent = createComponentRenderer(WorkflowsView, {
global: {
plugins: [router],
},
});
const initialState = {
[STORES.SETTINGS]: { settings: { enterprise: { sharing: false } } },
};
describe('WorkflowsView', () => {
let server: ReturnType<typeof setupServer>;
let pinia: ReturnType<typeof createPinia>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let projectsStore: ReturnType<typeof useProjectsStore>;
const routerReplaceMock = vi.fn();
const routerPushMock = vi.fn();
const renderComponent = createComponentRenderer(WorkflowsView, {
global: {
mocks: {
$route: {
query: {},
params: {},
},
$router: {
replace: routerReplaceMock,
push: routerPushMock,
},
},
},
});
beforeAll(() => {
Object.defineProperties(HTMLElement.prototype, {
offsetHeight: {
get() {
return this.getAttribute('data-test-id') === 'resources-list' ? 1000 : 100;
},
},
});
server = setupServer();
});
afterAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight);
});
beforeEach(async () => {
pinia = createPinia();
setActivePinia(pinia);
settingsStore = useSettingsStore();
usersStore = useUsersStore();
projectsStore = useProjectsStore();
vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {});
await settingsStore.getSettings();
await usersStore.fetchUsers();
await usersStore.loginWithCookie();
await router.push('/');
await router.isReady();
});
afterAll(() => {
server.shutdown();
describe('should show empty state', () => {
it('for non setup user', () => {
const { getByRole } = renderComponent({ pinia: createTestingPinia({ initialState }) });
expect(getByRole('heading').textContent).toBe('👋 Welcome!');
});
it('for currentUser user', () => {
const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore);
userStore.currentUser = { firstName: 'John' } as IUser;
const { getByRole } = renderComponent({ pinia });
expect(getByRole('heading').textContent).toBe('👋 Welcome John!');
});
describe('when onboardingExperiment -> False', () => {
const pinia = createTestingPinia({ initialState });
const posthog = mockedStore(usePostHog);
const sourceControl = mockedStore(useSourceControlStore);
posthog.getVariant.mockReturnValue(MORE_ONBOARDING_OPTIONS_EXPERIMENT.control);
const projectsStore = mockedStore(useProjectsStore);
it('for readOnlyEnvironment', () => {
sourceControl.preferences.branchReadOnly = true;
const { getByText } = renderComponent({ pinia });
expect(getByText('No workflows here yet')).toBeInTheDocument();
sourceControl.preferences.branchReadOnly = false;
});
it('for noPermission', () => {
const { getByText } = renderComponent({ pinia });
expect(getByText('There are currently no workflows to view')).toBeInTheDocument();
});
it('for user with create scope', () => {
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getByText } = renderComponent({ pinia });
expect(getByText('Create your first workflow')).toBeInTheDocument();
});
});
it('should allow workflow creation', async () => {
const pinia = createTestingPinia({ initialState });
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getByTestId } = renderComponent({ pinia });
expect(getByTestId('new-workflow-card')).toBeInTheDocument();
await userEvent.click(getByTestId('new-workflow-card'));
expect(router.currentRoute.value.name).toBe(VIEWS.NEW_WORKFLOW);
});
describe('should show courses and templates link for sales users', () => {
it('for cloudUser', () => {
const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore);
userStore.currentUserCloudInfo = { role: 'Sales' } as Cloud.UserAccount;
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
it('for personalizationAnswers', () => {
const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore);
userStore.currentUser = { personalizationAnswers: { role: 'Sales' } } as IUser;
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
});
it('should show courses and templates link for onboardingExperiment', () => {
const pinia = createTestingPinia({ initialState });
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const posthog = mockedStore(usePostHog);
posthog.getVariant.mockReturnValue(MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant);
const { getAllByTestId } = renderComponent({ pinia });
expect(getAllByTestId('browse-sales-templates-card').length).toBe(2);
});
});
it('should filter workflows by tags', async () => {
const { getByTestId, getAllByTestId, queryByTestId } = renderComponent({
pinia,
describe('workflow creation button', () => {
it('should create global workflow', async () => {
const pushSpy = vi.spyOn(router, 'push');
const pinia = createTestingPinia({ initialState });
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.allWorkflows = [{ id: '1' } as IWorkflowDb];
const projectsStore = mockedStore(useProjectsStore);
projectsStore.fetchProject.mockResolvedValue({} as Project);
projectsStore.personalProject = { scopes: ['workflow:create'] } as Project;
const { getByTestId } = renderComponent({ pinia });
expect(getByTestId('resources-list-add')).toBeInTheDocument();
expect(getByTestId('resources-list-add').textContent).toBe('Add workflow');
await userEvent.click(getByTestId('resources-list-add'));
expect(pushSpy).toHaveBeenCalledWith({ name: VIEWS.NEW_WORKFLOW, query: { projectId: '' } });
});
expect(queryByTestId('resources-list')).not.toBeInTheDocument();
it('should create a project specific workflow', async () => {
await router.replace({ path: '/project-id' });
const pushSpy = vi.spyOn(router, 'push');
await waitFor(() => {
// There are 5 workflows defined in server fixtures
expect(getAllByTestId('resources-list-item')).toHaveLength(5);
const pinia = createTestingPinia({ initialState });
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.allWorkflows = [{ id: '1' } as IWorkflowDb];
const projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
const { getByTestId } = renderComponent({ pinia });
expect(router.currentRoute.value.params.projectId).toBe('project-id');
expect(getByTestId('resources-list-add')).toBeInTheDocument();
expect(getByTestId('resources-list-add').textContent).toBe('Add workflow to project');
await userEvent.click(getByTestId('resources-list-add'));
expect(pushSpy).toHaveBeenCalledWith({
name: VIEWS.NEW_WORKFLOW,
query: { projectId: 'project-id' },
});
});
});
describe('filters', () => {
it('should set tag filter based on query parameters', async () => {
await router.replace({ query: { tags: 'test-tag' } });
const pinia = createTestingPinia({ initialState });
const tagStore = mockedStore(useTagsStore);
tagStore.allTags = [{ id: 'test-tag', name: 'tag' }];
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.allWorkflows = [
{ id: '1' },
{ id: '2', tags: [{ id: 'test-tag', name: 'tag' }] },
] as IWorkflowDb[];
const { getAllByTestId } = renderComponent({ pinia });
expect(tagStore.fetchAll).toHaveBeenCalled();
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(1));
});
await userEvent.click(
getAllByTestId('resources-list-item')[0].querySelector('.n8n-tag') as HTMLElement,
);
await waitFor(() => {
expect(getAllByTestId('resources-list-item').length).toBeLessThan(5);
});
expect(routerReplaceMock).toHaveBeenLastCalledWith({ query: { tags: '1' } });
it('should set search filter based on query parameters', async () => {
await router.replace({ query: { search: 'one' } });
await userEvent.click(getByTestId('workflows-filter-reset'));
await waitFor(() => {
expect(getAllByTestId('resources-list-item')).toHaveLength(5);
});
expect(routerReplaceMock).toHaveBeenLastCalledWith({ query: undefined });
const pinia = createTestingPinia({ initialState });
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.allWorkflows = [
{ id: '1', name: 'one' },
{ id: '2', name: 'two' },
] as IWorkflowDb[];
await userEvent.click(
getAllByTestId('resources-list-item')[3].querySelector('.n8n-tag') as HTMLElement,
);
await waitFor(() => {
expect(getAllByTestId('resources-list-item').length).toBeLessThan(5);
const { getAllByTestId } = renderComponent({ pinia });
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(1));
});
expect(routerReplaceMock).toHaveBeenLastCalledWith({ query: { tags: '1' } });
it('should set status filter based on query parameters', async () => {
await router.replace({ query: { status: 'true' } });
const pinia = createTestingPinia({ initialState });
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.allWorkflows = [
{ id: '1', active: true },
{ id: '2', active: false },
] as IWorkflowDb[];
const { getAllByTestId } = renderComponent({ pinia });
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(1));
});
it('should reset filters', async () => {
await router.replace({ query: { status: 'true' } });
const pinia = createTestingPinia({ initialState });
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.allWorkflows = [
{ id: '1', active: true },
{ id: '2', active: false },
] as IWorkflowDb[];
const { getAllByTestId, getByTestId } = renderComponent({ pinia });
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(1));
await waitFor(() => expect(getByTestId('workflows-filter-reset')).toBeInTheDocument());
await userEvent.click(getByTestId('workflows-filter-reset'));
await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
});
it('should remove incomplete properties', async () => {
await router.replace({ query: { tags: '' } });
const pinia = createTestingPinia({ initialState });
renderComponent({ pinia });
await waitFor(() => expect(router.currentRoute.value.query).toStrictEqual({}));
});
it('should remove invalid tabs', async () => {
await router.replace({ query: { tags: 'non-existing-tag' } });
const tagStore = mockedStore(useTagsStore);
tagStore.allTags = [{ id: 'test-tag', name: 'tag' }];
const pinia = createTestingPinia({ initialState });
renderComponent({ pinia });
await waitFor(() => expect(router.currentRoute.value.query).toStrictEqual({}));
});
});
it('should reinitialize on source control pullWorkfolder', async () => {
vi.spyOn(usersApi, 'getUsers').mockResolvedValue([]);
const pinia = createTestingPinia({ initialState, stubActions: false });
const userStore = mockedStore(useUsersStore);
const workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.fetchAllWorkflows.mockResolvedValue([]);
workflowsStore.fetchActiveWorkflows.mockResolvedValue([]);
const sourceControl = useSourceControlStore();
renderComponent({ pinia });
expect(userStore.fetchUsers).toHaveBeenCalledTimes(1);
await sourceControl.pullWorkfolder(true);
expect(userStore.fetchUsers).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,11 +1,10 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed, onMounted, watch, ref } 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 { ITag, IUser, IWorkflowDb } from '@/Interface';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
@ -18,6 +17,37 @@ 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';
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();
interface Filters {
search: string;
@ -26,308 +56,250 @@ interface Filters {
tags: string[];
}
type QueryFilters = Partial<Filters>;
const StatusFilter = {
ACTIVE: true,
DEACTIVATED: false,
ALL: '',
};
const WorkflowsView = defineComponent({
name: 'WorkflowsView',
components: {
ResourcesListLayout,
WorkflowCard,
WorkflowTagsDropdown,
ProjectTabs,
},
data() {
return {
filters: {
search: '',
homeProject: '',
status: StatusFilter.ALL,
tags: [],
} as Filters,
sourceControlStoreUnsubscribe: () => {},
loading: false,
documentTitle: useDocumentTitle(),
};
},
computed: {
...mapStores(
useSettingsStore,
useUIStore,
useUsersStore,
useWorkflowsStore,
useSourceControlStore,
useTagsStore,
useProjectsStore,
useTemplatesStore,
usePostHog,
),
readOnlyEnv(): boolean {
return this.sourceControlStore.preferences.branchReadOnly;
},
currentUser(): IUser {
return this.usersStore.currentUser || ({} as IUser);
},
allWorkflows(): IResource[] {
return this.workflowsStore.allWorkflows as IResource[];
},
isShareable(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing];
},
statusFilterOptions(): Array<{ label: string; value: string | boolean }> {
return [
{
label: this.$locale.baseText('workflows.filters.status.all'),
value: StatusFilter.ALL,
},
{
label: this.$locale.baseText('workflows.filters.status.active'),
value: StatusFilter.ACTIVE,
},
{
label: this.$locale.baseText('workflows.filters.status.deactivated'),
value: StatusFilter.DEACTIVATED,
},
];
},
userRole() {
const role = this.usersStore.currentUserCloudInfo?.role;
if (role) {
return role;
}
const answers = this.usersStore.currentUser?.personalizationAnswers;
if (answers && 'role' in answers) {
return answers.role;
}
return undefined;
},
isOnboardingExperimentEnabled() {
return (
this.posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
MORE_ONBOARDING_OPTIONS_EXPERIMENT.variant
);
},
isSalesUser() {
if (!this.userRole) {
return false;
}
return ['Sales', 'sales-and-marketing'].includes(this.userRole);
},
addWorkflowButtonText() {
return this.projectsStore.currentProject
? this.$locale.baseText('workflows.project.add')
: this.$locale.baseText('workflows.add');
},
projectPermissions() {
return getResourcePermissions(
this.projectsStore.currentProject?.scopes ?? this.projectsStore.personalProject?.scopes,
);
},
emptyListDescription() {
if (this.readOnlyEnv) {
return this.$locale.baseText('workflows.empty.description.readOnlyEnv');
} else if (!this.projectPermissions.workflow.create) {
return this.$locale.baseText('workflows.empty.description.noPermission');
} else {
return this.$locale.baseText('workflows.empty.description');
}
},
},
watch: {
filters: {
deep: true,
handler() {
this.saveFiltersOnQueryString();
},
},
'$route.params.projectId'() {
void this.initialize();
},
},
async mounted() {
this.documentTitle.set(this.$locale.baseText('workflows.heading'));
await this.tagsStore.fetchAll();
await this.setFiltersFromQueryString();
void this.usersStore.showPersonalizationSurvey();
this.sourceControlStoreUnsubscribe = this.sourceControlStore.$onAction(({ name, after }) => {
if (name === 'pullWorkfolder' && after) {
after(() => {
void this.initialize();
});
}
});
},
beforeUnmount() {
this.sourceControlStoreUnsubscribe();
},
methods: {
onFiltersUpdated(filters: Filters) {
this.filters = filters;
},
addWorkflow() {
this.uiStore.nodeViewInitialized = false;
void this.$router.push({
name: VIEWS.NEW_WORKFLOW,
query: { projectId: this.$route?.params?.projectId },
});
this.$telemetry.track('User clicked add workflow button', {
source: 'Workflows list',
});
this.trackEmptyCardClick('blank');
},
getTemplateRepositoryURL() {
return this.templatesStore.websiteTemplateRepositoryURL;
},
trackEmptyCardClick(option: 'blank' | 'templates' | 'courses') {
this.$telemetry.track('User clicked empty page option', {
option,
});
if (option === 'templates' && this.isSalesUser) {
this.trackCategoryLinkClick('Sales');
}
},
trackCategoryLinkClick(category: string) {
this.$telemetry.track(`User clicked Browse ${category} Templates`, {
role: this.usersStore.currentUserCloudInfo?.role,
active_workflow_count: this.workflowsStore.activeWorkflows.length,
});
},
async initialize() {
this.loading = true;
await Promise.all([
this.usersStore.fetchUsers(),
this.workflowsStore.fetchAllWorkflows(this.$route?.params?.projectId as string | undefined),
this.workflowsStore.fetchActiveWorkflows(),
]);
this.loading = false;
},
onClickTag(tagId: string) {
if (!this.filters.tags.includes(tagId)) {
this.filters.tags.push(tagId);
}
},
onFilter(
resource: IWorkflowDb,
filters: { tags: string[]; search: string; status: string | boolean },
matches: boolean,
): boolean {
if (this.settingsStore.areTagsEnabled && filters.tags.length > 0) {
matches =
matches &&
filters.tags.every((tag) =>
(resource.tags as ITag[])?.find((resourceTag) =>
typeof resourceTag === 'object'
? `${resourceTag.id}` === `${tag}`
: `${resourceTag}` === `${tag}`,
),
);
}
if (filters.status !== '') {
matches = matches && resource.active === filters.status;
}
return matches;
},
saveFiltersOnQueryString() {
const query: { [key: string]: string } = {};
if (this.filters.search) {
query.search = this.filters.search;
}
if (typeof this.filters.status !== 'string') {
query.status = this.filters.status.toString();
}
if (this.filters.tags.length) {
query.tags = this.filters.tags.join(',');
}
if (this.filters.homeProject) {
query.homeProject = this.filters.homeProject;
}
void this.$router.replace({
query: Object.keys(query).length ? query : undefined,
});
},
isValidProjectId(projectId: string) {
return this.projectsStore.availableProjects.some((project) => project.id === projectId);
},
async removeInvalidQueryFiltersFromUrl(filtersToApply: QueryFilters) {
await this.$router.push({
query: {
...(filtersToApply.tags && { tags: filtersToApply.tags?.join(',') }),
...(filtersToApply.status && { status: filtersToApply.status?.toString() }),
...(filtersToApply.search && { search: filtersToApply.search }),
...(filtersToApply.homeProject && { homeProject: filtersToApply.homeProject }),
},
});
},
async setFiltersFromQueryString() {
const { tags, status, search, homeProject } = this.$route.query;
const filtersToApply: QueryFilters = {};
if (homeProject && typeof homeProject === 'string') {
await this.projectsStore.getAvailableProjects();
if (this.isValidProjectId(homeProject)) {
filtersToApply.homeProject = homeProject;
}
}
if (search && typeof search === 'string') {
filtersToApply.search = search;
}
if (tags && typeof tags === 'string') {
const currentTags = this.tagsStore.allTags.map((tag) => tag.id);
const savedTags = tags.split(',').filter((tag) => currentTags.includes(tag));
if (savedTags.length) {
filtersToApply.tags = savedTags;
}
}
if (
status &&
typeof status === 'string' &&
[StatusFilter.ACTIVE.toString(), StatusFilter.DEACTIVATED.toString()].includes(status)
) {
filtersToApply.status = status === 'true';
}
await this.removeInvalidQueryFiltersFromUrl(filtersToApply);
if (Object.keys(filtersToApply).length) {
this.filters = {
...this.filters,
...filtersToApply,
};
}
},
},
const loading = ref(false);
const filters = ref<Filters>({
search: '',
homeProject: '',
status: StatusFilter.ALL,
tags: [],
});
export default WorkflowsView;
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 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');
}
});
const onFilter = (
resource: IWorkflowDb,
newFilters: { tags: string[]; search: string; status: string | boolean },
matches: boolean,
): boolean => {
if (settingsStore.areTagsEnabled && newFilters.tags.length > 0) {
matches =
matches &&
newFilters.tags.every((tag) =>
(resource.tags as ITag[])?.find((resourceTag) =>
typeof resourceTag === 'object'
? `${resourceTag.id}` === `${tag}`
: `${resourceTag}` === `${tag}`,
),
);
}
if (newFilters.status !== '') {
matches = matches && resource.active === newFilters.status;
}
return matches;
};
// Methods
const onFiltersUpdated = (newFilters: Filters) => {
Object.assign(filters.value, newFilters);
};
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 initialize = async () => {
loading.value = true;
await Promise.all([
usersStore.fetchUsers(),
workflowsStore.fetchAllWorkflows(route.params?.projectId as string | undefined),
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;
}
void router.replace({
query: Object.keys(query).length ? query : undefined,
});
};
function isValidProjectId(projectId: string) {
return projectsStore.availableProjects.some((project) => project.id === projectId);
}
const setFiltersFromQueryString = async () => {
const { tags, status, search, homeProject } = route.query ?? {};
const filtersToApply: { [key: string]: string | string[] | boolean } = {};
if (homeProject && typeof homeProject === 'string') {
await projectsStore.getAvailableProjects();
if (isValidProjectId(homeProject)) {
filtersToApply.homeProject = homeProject;
}
}
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) });
};
sourceControlStore.$onAction(({ name, after }) => {
if (name !== 'pullWorkfolder') return;
after(async () => await initialize());
});
watch(filters, () => saveFiltersOnQueryString(), { deep: true });
watch(
() => route.params?.projectId,
async () => await initialize(),
);
onMounted(async () => {
documentTitle.set(i18n.baseText('workflows.heading'));
await setFiltersFromQueryString();
void usersStore.showPersonalizationSurvey();
});
</script>
<template>
<ResourcesListLayout
ref="layout"
resource-key="workflows"
:resources="allWorkflows"
:filters="filters"
@ -344,9 +316,9 @@ export default WorkflowsView;
<ProjectTabs />
</template>
<template #add-button="{ disabled }">
<n8n-tooltip :disabled="!readOnlyEnv">
<N8nTooltip :disabled="!readOnlyEnv">
<div>
<n8n-button
<N8nButton
size="large"
block
:disabled="disabled"
@ -354,18 +326,18 @@ export default WorkflowsView;
@click="addWorkflow"
>
{{ addWorkflowButtonText }}
</n8n-button>
</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/">
{{ $locale.baseText('mainSidebar.workflows.readOnlyEnv.tooltip.link') }}
{{ i18n.baseText('mainSidebar.workflows.readOnlyEnv.tooltip.link') }}
</a>
</template>
</i18n-t>
</template>
</n8n-tooltip>
</N8nTooltip>
</template>
<template #default="{ data, updateItemSize }">
<WorkflowCard
@ -379,50 +351,50 @@ export default WorkflowsView;
</template>
<template #empty>
<div class="text-center mt-s">
<n8n-heading tag="h2" size="xlarge" class="mb-2xs">
<N8nHeading tag="h2" size="xlarge" class="mb-2xs">
{{
currentUser.firstName
? $locale.baseText('workflows.empty.heading', {
? i18n.baseText('workflows.empty.heading', {
interpolate: { name: currentUser.firstName },
})
: $locale.baseText('workflows.empty.heading.userNotSetup')
: i18n.baseText('workflows.empty.heading.userNotSetup')
}}
</n8n-heading>
<n8n-text v-if="!isOnboardingExperimentEnabled" size="large" color="text-base">
</N8nHeading>
<N8nText v-if="!isOnboardingExperimentEnabled" size="large" color="text-base">
{{ emptyListDescription }}
</n8n-text>
</N8nText>
</div>
<div
v-if="!readOnlyEnv && projectPermissions.workflow.create"
:class="['text-center', 'mt-2xl', $style.actionsContainer]"
>
<n8n-card
<N8nCard
:class="$style.emptyStateCard"
hoverable
data-test-id="new-workflow-card"
@click="addWorkflow"
>
<n8n-icon :class="$style.emptyStateCardIcon" icon="file" />
<n8n-text size="large" class="mt-xs" color="text-dark">
{{ $locale.baseText('workflows.empty.startFromScratch') }}
</n8n-text>
</n8n-card>
<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"
>
<n8n-card
<N8nCard
hoverable
data-test-id="browse-sales-templates-card"
@click="trackEmptyCardClick('courses')"
>
<n8n-icon :class="$style.emptyStateCardIcon" icon="graduation-cap" />
<n8n-text size="large" class="mt-xs" color="text-dark">
{{ $locale.baseText('workflows.empty.learnN8n') }}
</n8n-text>
</n8n-card>
<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"
@ -430,57 +402,57 @@ export default WorkflowsView;
:class="$style.emptyStateCard"
target="_blank"
>
<n8n-card
<N8nCard
hoverable
data-test-id="browse-sales-templates-card"
@click="trackEmptyCardClick('templates')"
>
<n8n-icon :class="$style.emptyStateCardIcon" icon="box-open" />
<n8n-text size="large" class="mt-xs" color="text-dark">
{{ $locale.baseText('workflows.empty.browseTemplates') }}
</n8n-text>
</n8n-card>
<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">
<n8n-input-label
:label="$locale.baseText('workflows.filters.tags')"
<N8nInputLabel
:label="i18n.baseText('workflows.filters.tags')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<WorkflowTagsDropdown
:placeholder="$locale.baseText('workflowOpen.filterWorkflows')"
:placeholder="i18n.baseText('workflowOpen.filterWorkflows')"
:model-value="filters.tags"
:create-enabled="false"
@update:model-value="setKeyValue('tags', $event)"
/>
</div>
<div class="mb-s">
<n8n-input-label
:label="$locale.baseText('workflows.filters.status')"
<N8nInputLabel
:label="i18n.baseText('workflows.filters.status')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<n8n-select
<N8nSelect
data-test-id="status-dropdown"
:model-value="filters.status"
@update:model-value="setKeyValue('status', $event)"
>
<n8n-option
<N8nOption
v-for="option in statusFilterOptions"
:key="option.label"
:label="option.label"
:value="option.value"
data-test-id="status"
>
</n8n-option>
</n8n-select>
</N8nOption>
</N8nSelect>
</div>
</template>
</ResourcesListLayout>