feat(editor): Add workflow filters to querystring (#7456)

fixes:
https://linear.app/n8n/issue/ADO-1222/feature-save-filters-in-workflows
This commit is contained in:
Ricardo Espinoza 2023-11-08 08:42:53 -05:00 committed by GitHub
parent 8171ad4fa8
commit afd637b5ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 228 additions and 4 deletions

View file

@ -0,0 +1,118 @@
import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { MainSidebar } from '../pages';
import { INSTANCE_OWNER } from '../constants';
const WorkflowsPage = new WorkflowsPageClass();
const WorkflowPages = new WorkflowPageClass();
const mainSidebar = new MainSidebar();
describe('Workflow filters', () => {
before(() => {
cy.enableFeature('sharing', true);
});
beforeEach(() => {
cy.visit(WorkflowsPage.url);
});
it('Should filter by tags', () => {
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.newWorkflowButtonCard().click();
cy.createFixtureWorkflow('Test_workflow_1.json', `Workflow 1`);
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.createWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_2.json', `Workflow 2`);
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.workflowFilterButton().click();
WorkflowsPage.getters.workflowTagsDropdown().click();
WorkflowsPage.getters.workflowTagItem('other-tag-1').click();
cy.get('body').click(0, 0);
WorkflowsPage.getters.workflowCards().should('have.length', 1);
WorkflowsPage.getters.workflowCard('Workflow 2').should('contain.text', 'Workflow 2');
mainSidebar.actions.goToSettings();
cy.go('back');
WorkflowsPage.getters.workflowCards().should('have.length', 1);
WorkflowsPage.getters.workflowCard('Workflow 2').should('contain.text', 'Workflow 2');
WorkflowsPage.getters.workflowResetFilters().click();
WorkflowsPage.getters.workflowCards().each(($el) => {
const workflowName = $el.find('[data-test-id="workflow-card-name"]').text();
WorkflowsPage.getters.workflowCardActions(workflowName).click();
WorkflowsPage.getters.workflowDeleteButton().click();
cy.get('button').contains('delete').click();
});
});
it('Should filter by status', () => {
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.newWorkflowButtonCard().click();
cy.createFixtureWorkflow('Test_workflow_1.json', `Workflow 1`);
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.createWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_3.json', `Workflow 3`);
WorkflowPages.getters.activatorSwitch().click();
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.workflowFilterButton().click();
WorkflowsPage.getters.workflowStatusDropdown().click();
WorkflowsPage.getters.workflowStatusItem('Active').click();
cy.get('body').click(0, 0);
WorkflowsPage.getters.workflowCards().should('have.length', 1);
WorkflowsPage.getters.workflowCard('Workflow 3').should('contain.text', 'Workflow 3');
mainSidebar.actions.goToSettings();
cy.go('back');
WorkflowsPage.getters.workflowCards().should('have.length', 1);
WorkflowsPage.getters.workflowCard('Workflow 3').should('contain.text', 'Workflow 3');
WorkflowsPage.getters.workflowResetFilters().click();
WorkflowsPage.getters.workflowCards().each(($el) => {
const workflowName = $el.find('[data-test-id="workflow-card-name"]').text();
WorkflowsPage.getters.workflowCardActions(workflowName).click();
WorkflowsPage.getters.workflowDeleteButton().click();
cy.get('button').contains('delete').click();
});
});
it('Should filter by owned by', () => {
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.newWorkflowButtonCard().click();
cy.createFixtureWorkflow('Test_workflow_1.json', `Workflow 1`);
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.createWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_3.json', `Workflow 3`);
WorkflowPages.getters.activatorSwitch().click();
cy.visit(WorkflowsPage.url);
WorkflowsPage.getters.workflowFilterButton().click();
WorkflowsPage.getters.workflowOwnershipDropdown().realClick();
WorkflowsPage.getters.workflowOwner(INSTANCE_OWNER.email).click();
cy.get('body').click(0, 0);
WorkflowsPage.getters.workflowCards().should('have.length', 2);
mainSidebar.actions.goToSettings();
cy.go('back');
WorkflowsPage.getters.workflowResetFilters().click();
WorkflowsPage.getters.workflowCards().each(($el) => {
const workflowName = $el.find('[data-test-id="workflow-card-name"]').text();
WorkflowsPage.getters.workflowCardActions(workflowName).click();
WorkflowsPage.getters.workflowDeleteButton().click();
cy.get('button').contains('delete').click();
});
});
});

View file

@ -23,6 +23,14 @@ export class WorkflowsPage extends BasePage {
this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'),
workflowDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
workflowFilterButton: () => cy.getByTestId('resources-list-filters-trigger').filter(':visible'),
workflowTagsDropdown: () => cy.getByTestId('tags-dropdown'),
workflowTagItem: (tag: string) => cy.getByTestId('tag').contains(tag),
workflowStatusDropdown: () => cy.getByTestId('status-dropdown'),
workflowStatusItem: (status: string) => cy.getByTestId('status').contains(status),
workflowOwnershipDropdown: () => cy.getByTestId('user-select-trigger'),
workflowOwner: (email: string) => cy.getByTestId('user-email').contains(email),
workflowResetFilters: () => cy.getByTestId('workflows-filter-reset'),
// Not yet implemented
// myWorkflows: () => cy.getByTestId('my-workflows'),
// allWorkflows: () => cy.getByTestId('all-workflows'),

View file

@ -19,7 +19,7 @@
</span>
</div>
<div>
<n8n-text size="small" color="text-light">{{ email }}</n8n-text>
<n8n-text data-test-id="user-email" size="small" color="text-light">{{ email }}</n8n-text>
</div>
</div>
</div>

View file

@ -1,5 +1,6 @@
<template>
<n8n-select
data-test-id="user-select-trigger"
v-bind="$attrs"
:modelValue="modelValue"
:filterable="true"

View file

@ -17,6 +17,7 @@
ref="selectRef"
loading-text="..."
popper-class="tags-dropdown"
data-test-id="tags-dropdown"
@update:modelValue="onTagsUpdated"
@visible-change="onVisibleChange"
@remove-tag="onRemoveTag"
@ -48,6 +49,7 @@
:key="tag.id + '_' + i"
:label="tag.name"
class="tag"
data-test-id="tag"
ref="tagRefs"
/>

View file

@ -107,7 +107,7 @@
<div v-if="showFiltersDropdown" v-show="hasFilters" class="mt-xs">
<n8n-info-tip :bold="false">
{{ i18n.baseText(`${resourceKey}.filters.active`) }}
<n8n-link @click="resetFilters" size="small">
<n8n-link data-test-id="workflows-filter-reset" @click="resetFilters" size="small">
{{ i18n.baseText(`${resourceKey}.filters.active.reset`) }}
</n8n-link>
</n8n-info-tip>
@ -392,6 +392,19 @@ export default defineComponent({
this.loading = false;
await this.$nextTick();
this.focusSearchInput();
if (this.hasAppliedFilters()) {
this.hasFilters = true;
}
},
hasAppliedFilters(): boolean {
return !!this.filterKeys.find(
(key) =>
key !== 'search' &&
(Array.isArray(this.filters[key])
? this.filters[key].length > 0
: this.filters[key] !== ''),
);
},
setCurrentPage(page: number) {
this.currentPage = page;

View file

@ -101,12 +101,17 @@
color="text-base"
class="mb-3xs"
/>
<n8n-select :modelValue="filters.status" @update:modelValue="setKeyValue('status', $event)">
<n8n-select
data-test-id="status-dropdown"
:modelValue="filters.status"
@update:modelValue="setKeyValue('status', $event)"
>
<n8n-option
v-for="option in statusFilterOptions"
:key="option.label"
:label="option.label"
:value="option.value"
data-test-id="status"
>
</n8n-option>
</n8n-select>
@ -130,6 +135,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { genericHelpers } from '@/mixins/genericHelpers';
import { useTagsStore } from '@/stores';
type IResourcesListLayoutInstance = InstanceType<typeof ResourcesListLayout>;
@ -153,7 +159,7 @@ const WorkflowsView = defineComponent({
search: '',
ownedBy: '',
sharedWith: '',
status: StatusFilter.ALL,
status: StatusFilter.ALL as string | boolean,
tags: [] as string[],
},
sourceControlStoreUnsubscribe: () => {},
@ -167,6 +173,7 @@ const WorkflowsView = defineComponent({
useWorkflowsStore,
useCredentialsStore,
useSourceControlStore,
useTagsStore,
),
currentUser(): IUser {
return this.usersStore.currentUser || ({} as IUser);
@ -221,6 +228,8 @@ const WorkflowsView = defineComponent({
filters: { tags: string[]; search: string; status: string | boolean },
matches: boolean,
): boolean {
this.saveFiltersOnQueryString();
if (this.settingsStore.areTagsEnabled && filters.tags.length > 0) {
matches =
matches &&
@ -243,6 +252,77 @@ const WorkflowsView = defineComponent({
sendFiltersTelemetry(source: string) {
(this.$refs.layout as IResourcesListLayoutInstance).sendFiltersTelemetry(source);
},
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.ownedBy) {
query.ownedBy = this.filters.ownedBy;
}
if (this.filters.sharedWith) {
query.sharedWith = this.filters.sharedWith;
}
void this.$router.replace({
name: VIEWS.WORKFLOWS,
query,
});
},
isValidUserId(userId: string) {
return Object.keys(this.usersStore.users).includes(userId);
},
setFiltersFromQueryString() {
const { tags, status, search, ownedBy, sharedWith } = this.$route.query;
const filtersToApply: { [key: string]: string | string[] | boolean } = {};
if (ownedBy && typeof ownedBy === 'string' && this.isValidUserId(ownedBy)) {
filtersToApply.ownedBy = ownedBy;
}
if (sharedWith && typeof sharedWith === 'string' && this.isValidUserId(sharedWith)) {
filtersToApply.sharedWith = sharedWith;
}
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';
}
if (Object.keys(filtersToApply).length) {
this.filters = {
...this.filters,
...filtersToApply,
};
}
},
},
watch: {
'filters.tags'() {
@ -250,6 +330,8 @@ const WorkflowsView = defineComponent({
},
},
mounted() {
this.setFiltersFromQueryString();
void this.usersStore.showPersonalizationSurvey();
this.sourceControlStoreUnsubscribe = this.sourceControlStore.$onAction(({ name, after }) => {