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'), this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'),
workflowDeleteButton: () => workflowDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), 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 // Not yet implemented
// myWorkflows: () => cy.getByTestId('my-workflows'), // myWorkflows: () => cy.getByTestId('my-workflows'),
// allWorkflows: () => cy.getByTestId('all-workflows'), // allWorkflows: () => cy.getByTestId('all-workflows'),

View file

@ -19,7 +19,7 @@
</span> </span>
</div> </div>
<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> </div>
</div> </div>

View file

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

View file

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

View file

@ -107,7 +107,7 @@
<div v-if="showFiltersDropdown" v-show="hasFilters" class="mt-xs"> <div v-if="showFiltersDropdown" v-show="hasFilters" class="mt-xs">
<n8n-info-tip :bold="false"> <n8n-info-tip :bold="false">
{{ i18n.baseText(`${resourceKey}.filters.active`) }} {{ 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`) }} {{ i18n.baseText(`${resourceKey}.filters.active.reset`) }}
</n8n-link> </n8n-link>
</n8n-info-tip> </n8n-info-tip>
@ -392,6 +392,19 @@ export default defineComponent({
this.loading = false; this.loading = false;
await this.$nextTick(); await this.$nextTick();
this.focusSearchInput(); 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) { setCurrentPage(page: number) {
this.currentPage = page; this.currentPage = page;

View file

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