mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-13 16:14:07 -08:00
refactor(editor): WorkflowsView to script setup + tests (#11078)
This commit is contained in:
parent
e081fd1f0b
commit
ce69218f1b
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue