mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
feat(editor): Preserve workflow list sort & page size preferences (#15101)
This commit is contained in:
committed by
GitHub
parent
c76245519c
commit
cf03a28774
@@ -99,7 +99,7 @@ describe('Workflows', () => {
|
|||||||
WorkflowsPage.getters.workflowCards().should('have.length', 1);
|
WorkflowsPage.getters.workflowCards().should('have.length', 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should preserve filters and pagination in URL', () => {
|
it('should preserve filters in URL', () => {
|
||||||
// Add a search query
|
// Add a search query
|
||||||
WorkflowsPage.getters.searchBar().type('My');
|
WorkflowsPage.getters.searchBar().type('My');
|
||||||
// Add a tag filter
|
// Add a tag filter
|
||||||
@@ -118,8 +118,6 @@ describe('Workflows', () => {
|
|||||||
cy.url().should('include', 'search=My');
|
cy.url().should('include', 'search=My');
|
||||||
// Cannot really know tag id, so just check if it contains 'tags='
|
// Cannot really know tag id, so just check if it contains 'tags='
|
||||||
cy.url().should('include', 'tags=');
|
cy.url().should('include', 'tags=');
|
||||||
cy.url().should('include', 'sort=lastCreated');
|
|
||||||
cy.url().should('include', 'pageSize=25');
|
|
||||||
|
|
||||||
// Reload the page
|
// Reload the page
|
||||||
cy.reload();
|
cy.reload();
|
||||||
@@ -136,8 +134,6 @@ describe('Workflows', () => {
|
|||||||
// Aso, check if the URL is preserved
|
// Aso, check if the URL is preserved
|
||||||
cy.url().should('include', 'search=My');
|
cy.url().should('include', 'search=My');
|
||||||
cy.url().should('include', 'tags=');
|
cy.url().should('include', 'tags=');
|
||||||
cy.url().should('include', 'sort=lastCreated');
|
|
||||||
cy.url().should('include', 'pageSize=25');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to share workflows from workflows list', () => {
|
it('should be able to share workflows from workflows list', () => {
|
||||||
|
|||||||
@@ -240,10 +240,10 @@ onClickOutside(
|
|||||||
</N8nOption>
|
</N8nOption>
|
||||||
<N8nOption v-else-if="options.length === 0" value="message" disabled>
|
<N8nOption v-else-if="options.length === 0" value="message" disabled>
|
||||||
<span v-if="createEnabled">{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
|
<span v-if="createEnabled">{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
|
||||||
<span v-if="allTags.length > 0">{{
|
<span v-else-if="allTags.length > 0">{{
|
||||||
i18n.baseText('tagsDropdown.noMatchingTagsExist')
|
i18n.baseText('tagsDropdown.noMatchingTagsExist')
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else-if="filter">{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
|
<span v-else>{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
|
||||||
</N8nOption>
|
</N8nOption>
|
||||||
|
|
||||||
<N8nOption
|
<N8nOption
|
||||||
|
|||||||
@@ -1,8 +1,50 @@
|
|||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
import ResourcesListLayout, { type Resource } from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import type router from 'vue-router';
|
import type router from 'vue-router';
|
||||||
|
import type { ProjectSharingData } from 'n8n-workflow';
|
||||||
|
import { waitAllPromises } from '@/__tests__/utils';
|
||||||
|
|
||||||
|
const TEST_HOME_PROJECT: ProjectSharingData = vi.hoisted(() => ({
|
||||||
|
id: '1',
|
||||||
|
type: 'team',
|
||||||
|
name: 'Test Project 1',
|
||||||
|
icon: {
|
||||||
|
type: 'emoji',
|
||||||
|
value: '🗂️',
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TEST_LOCAL_STORAGE_VALUES = vi.hoisted(() => ({
|
||||||
|
sort: 'name',
|
||||||
|
pageSize: 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TEST_WORKFLOWS: Resource[] = vi.hoisted(() => [
|
||||||
|
{
|
||||||
|
resourceType: 'workflow',
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Workflow 1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
active: true,
|
||||||
|
readOnly: false,
|
||||||
|
homeProject: TEST_HOME_PROJECT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceType: 'workflow',
|
||||||
|
id: '2',
|
||||||
|
name: 'Test Workflow 2',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
active: true,
|
||||||
|
readOnly: false,
|
||||||
|
homeProject: TEST_HOME_PROJECT,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
||||||
useGlobalEntityCreation: () => ({
|
useGlobalEntityCreation: () => ({
|
||||||
@@ -10,18 +52,42 @@ vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/composables/useN8nLocalStorage', () => {
|
||||||
|
const loadProjectPreferencesFromLocalStorage = vi.fn().mockReturnValue(TEST_LOCAL_STORAGE_VALUES);
|
||||||
|
const getProjectKey = vi.fn(() => TEST_HOME_PROJECT.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
useN8nLocalStorage: () => ({
|
||||||
|
loadProjectPreferencesFromLocalStorage,
|
||||||
|
getProjectKey,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('vue-router', async (importOriginal) => {
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
const { RouterLink } = await importOriginal<typeof router>();
|
const { RouterLink } = await importOriginal<typeof router>();
|
||||||
return {
|
return {
|
||||||
RouterLink,
|
RouterLink,
|
||||||
useRoute: () => ({
|
useRoute: () => ({
|
||||||
params: {},
|
params: {
|
||||||
|
projectId: TEST_HOME_PROJECT.id,
|
||||||
|
},
|
||||||
|
path: `/projects/${TEST_HOME_PROJECT.id}/workflows`,
|
||||||
|
query: {},
|
||||||
}),
|
}),
|
||||||
useRouter: vi.fn(),
|
useRouter: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(ResourcesListLayout);
|
const renderComponent = createComponentRenderer(ResourcesListLayout, {
|
||||||
|
props: {
|
||||||
|
resourceKey: 'workflows',
|
||||||
|
resources: [],
|
||||||
|
disabled: false,
|
||||||
|
typeProps: { itemSize: 80 },
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('ResourcesListLayout', () => {
|
describe('ResourcesListLayout', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,4 +104,49 @@ describe('ResourcesListLayout', () => {
|
|||||||
|
|
||||||
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
|
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render scrollable list based on `type` prop', () => {
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
resources: TEST_WORKFLOWS,
|
||||||
|
type: 'list-full',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getByTestId('resources-list-wrapper')).toBeTruthy();
|
||||||
|
expect(getByTestId('resources-list')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render paginated list based on `type` prop', () => {
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
resources: TEST_WORKFLOWS,
|
||||||
|
type: 'list-paginated',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getByTestId('resources-list-wrapper')).toBeTruthy();
|
||||||
|
expect(getByTestId('paginated-list')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render paginated table list based on `type` prop', () => {
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
resources: TEST_WORKFLOWS,
|
||||||
|
type: 'datatable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getByTestId('resources-list-wrapper')).toBeTruthy();
|
||||||
|
expect(getByTestId('resources-table')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load sort & page size from local storage', async () => {
|
||||||
|
const { emitted } = renderComponent({
|
||||||
|
props: {
|
||||||
|
resources: TEST_WORKFLOWS,
|
||||||
|
type: 'list-paginated',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitAllPromises();
|
||||||
|
expect(emitted()['update:pagination-and-sort']).toBeTruthy();
|
||||||
|
expect(emitted()['update:pagination-and-sort'].pop()).toEqual([TEST_LOCAL_STORAGE_VALUES]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { BaseTextKey } from '@/plugins/i18n';
|
|||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
import type { BaseFolderItem, BaseResource, ITag, ResourceParentFolder } from '@/Interface';
|
import type { BaseFolderItem, BaseResource, ITag, ResourceParentFolder } from '@/Interface';
|
||||||
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
|
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
|
||||||
|
import { useN8nLocalStorage } from '@/composables/useN8nLocalStorage';
|
||||||
|
|
||||||
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
|
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
|
||||||
|
|
||||||
@@ -63,12 +64,19 @@ export type BaseFilters = {
|
|||||||
[key: string]: boolean | string | string[];
|
[key: string]: boolean | string | string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SortingAndPaginationUpdates = {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
const n8nLocalStorage = useN8nLocalStorage();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -126,12 +134,15 @@ const rowsPerPage = ref<number>(props.customPageSize);
|
|||||||
const resettingFilters = ref(false);
|
const resettingFilters = ref(false);
|
||||||
const search = ref<HTMLElement | null>(null);
|
const search = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Preferred sorting and page size
|
||||||
|
// These refs store the values that are set by the user and preserved in local storage
|
||||||
|
const preferredPageSize = ref<number>(props.customPageSize);
|
||||||
|
const preferredSort = ref<string>(props.sortOptions[0]);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:filters': [value: BaseFilters];
|
'update:filters': [value: BaseFilters];
|
||||||
'click:add': [event: Event];
|
'click:add': [event: Event];
|
||||||
'update:current-page': [page: number];
|
'update:pagination-and-sort': [value: SortingAndPaginationUpdates];
|
||||||
'update:page-size': [pageSize: number];
|
|
||||||
sort: [value: string];
|
|
||||||
'update:search': [value: string];
|
'update:search': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -286,24 +297,18 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
|
||||||
() => sortBy.value,
|
|
||||||
(newValue) => {
|
|
||||||
emit('sort', newValue);
|
|
||||||
sendSortingTelemetry();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route?.params?.projectId,
|
() => route?.params?.projectId,
|
||||||
async () => {
|
async () => {
|
||||||
await resetFilters();
|
await resetFilters();
|
||||||
|
await loadPaginationPreferences();
|
||||||
|
await props.initialize();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lifecycle hooks
|
// Lifecycle hooks
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadPaginationFromQueryString();
|
await loadPaginationPreferences();
|
||||||
await props.initialize();
|
await props.initialize();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
@@ -350,21 +355,33 @@ const hasAppliedFilters = (): boolean => {
|
|||||||
|
|
||||||
const setRowsPerPage = async (numberOfRowsPerPage: number) => {
|
const setRowsPerPage = async (numberOfRowsPerPage: number) => {
|
||||||
rowsPerPage.value = numberOfRowsPerPage;
|
rowsPerPage.value = numberOfRowsPerPage;
|
||||||
await savePaginationToQueryString();
|
await savePaginationPreferences();
|
||||||
emit('update:page-size', numberOfRowsPerPage);
|
emit('update:pagination-and-sort', {
|
||||||
|
pageSize: numberOfRowsPerPage,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCurrentPage = async (page: number) => {
|
const setSorting = async (sort: string, persistUpdate = true) => {
|
||||||
|
sortBy.value = sort;
|
||||||
|
if (persistUpdate) {
|
||||||
|
await savePaginationPreferences();
|
||||||
|
}
|
||||||
|
emit('update:pagination-and-sort', {
|
||||||
|
sort,
|
||||||
|
});
|
||||||
|
sendSortingTelemetry();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCurrentPage = async (page: number, persistUpdate = true) => {
|
||||||
currentPage.value = page;
|
currentPage.value = page;
|
||||||
await savePaginationToQueryString();
|
if (persistUpdate) {
|
||||||
emit('update:current-page', page);
|
await savePaginationPreferences();
|
||||||
|
}
|
||||||
|
emit('update:pagination-and-sort', {
|
||||||
|
page,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
currentPage,
|
|
||||||
setCurrentPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendFiltersTelemetry = (source: string) => {
|
const sendFiltersTelemetry = (source: string) => {
|
||||||
// Prevent sending multiple telemetry events when resetting filters
|
// Prevent sending multiple telemetry events when resetting filters
|
||||||
// Timeout is required to wait for search debounce to be over
|
// Timeout is required to wait for search debounce to be over
|
||||||
@@ -409,7 +426,7 @@ const resetFilters = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reset the current page
|
// Reset the current page
|
||||||
await setCurrentPage(1);
|
await setCurrentPage(1, false);
|
||||||
|
|
||||||
resettingFilters.value = true;
|
resettingFilters.value = true;
|
||||||
hasFilters.value = false;
|
hasFilters.value = false;
|
||||||
@@ -452,7 +469,10 @@ const findNearestPageSize = (size: number): number => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const savePaginationToQueryString = async () => {
|
/**
|
||||||
|
* Saves the current pagination preferences to local storage and updates the URL query parameters.
|
||||||
|
*/
|
||||||
|
const savePaginationPreferences = async () => {
|
||||||
// For now, only available for paginated lists
|
// For now, only available for paginated lists
|
||||||
if (props.type !== 'list-paginated') {
|
if (props.type !== 'list-paginated') {
|
||||||
return;
|
return;
|
||||||
@@ -466,38 +486,95 @@ const savePaginationToQueryString = async () => {
|
|||||||
delete currentQuery.page;
|
delete currentQuery.page;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rowsPerPage.value !== props.customPageSize) {
|
// Only update sort & page size if they are different from the default values
|
||||||
|
// otherwise, remove them from the query
|
||||||
|
if (rowsPerPage.value !== preferredPageSize.value) {
|
||||||
currentQuery.pageSize = rowsPerPage.value.toString();
|
currentQuery.pageSize = rowsPerPage.value.toString();
|
||||||
|
preferredPageSize.value = rowsPerPage.value;
|
||||||
} else {
|
} else {
|
||||||
delete currentQuery.pageSize;
|
delete currentQuery.pageSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortBy.value !== preferredSort.value) {
|
||||||
|
currentQuery.sort = sortBy.value;
|
||||||
|
preferredSort.value = sortBy.value;
|
||||||
|
} else {
|
||||||
|
delete currentQuery.sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
n8nLocalStorage.saveProjectPreferencesToLocalStorage(
|
||||||
|
(route.params.projectId as string) ?? '',
|
||||||
|
'workflows',
|
||||||
|
{
|
||||||
|
sort: sortBy.value,
|
||||||
|
pageSize: rowsPerPage.value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await router.replace({
|
await router.replace({
|
||||||
query: Object.keys(currentQuery).length ? currentQuery : undefined,
|
query: Object.keys(currentQuery).length ? currentQuery : undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPaginationFromQueryString = async () => {
|
/**
|
||||||
|
* Loads the pagination preferences from local storage or URL query parameter
|
||||||
|
* Current page is only saved in the URL query parameters
|
||||||
|
* Page size and sort are saved both in local storage and URL query parameters, with query parameters taking precedence
|
||||||
|
*/
|
||||||
|
const loadPaginationPreferences = async () => {
|
||||||
// For now, only available for paginated lists
|
// For now, only available for paginated lists
|
||||||
if (props.type !== 'list-paginated') {
|
if (props.type !== 'list-paginated') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const query = router.currentRoute.value.query;
|
const query = route.query;
|
||||||
|
// For now, only load workflow list preferences from local storage
|
||||||
|
const localStorageValues = n8nLocalStorage.loadProjectPreferencesFromLocalStorage(
|
||||||
|
(route.params.projectId as string) ?? '',
|
||||||
|
'workflows',
|
||||||
|
);
|
||||||
|
|
||||||
|
const emitPayload: SortingAndPaginationUpdates = {};
|
||||||
|
|
||||||
if (query.page) {
|
if (query.page) {
|
||||||
await setCurrentPage(parseInt(query.page as string, 10));
|
const newPage = parseInt(query.page as string, 10);
|
||||||
|
if (newPage > 1) {
|
||||||
|
currentPage.value = newPage;
|
||||||
|
emitPayload.page = newPage;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.pageSize) {
|
if (query.pageSize ?? localStorageValues.pageSize) {
|
||||||
const parsedSize = parseInt(query.pageSize as string, 10);
|
const parsedSize = parseInt(
|
||||||
|
(query.pageSize as string) || String(localStorageValues.pageSize),
|
||||||
|
10,
|
||||||
|
);
|
||||||
// Round to the nearest available page size, this will prevent users from passing arbitrary values
|
// Round to the nearest available page size, this will prevent users from passing arbitrary values
|
||||||
await setRowsPerPage(findNearestPageSize(parsedSize));
|
const newPageSize = findNearestPageSize(parsedSize);
|
||||||
|
rowsPerPage.value = newPageSize;
|
||||||
|
emitPayload.pageSize = newPageSize;
|
||||||
|
preferredPageSize.value = newPageSize;
|
||||||
|
} else {
|
||||||
|
rowsPerPage.value = props.customPageSize;
|
||||||
|
emitPayload.pageSize = props.customPageSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.sort) {
|
if (query.sort) {
|
||||||
sortBy.value = query.sort as string;
|
// Update the sortBy value and emit the event based on the query parameter
|
||||||
|
sortBy.value = emitPayload.sort = preferredSort.value = query.sort as string;
|
||||||
|
} else if (localStorageValues.sort) {
|
||||||
|
await setSorting(localStorageValues.sort, false);
|
||||||
|
emitPayload.sort = localStorageValues.sort;
|
||||||
|
preferredSort.value = localStorageValues.sort;
|
||||||
|
} else {
|
||||||
|
sortBy.value = props.sortOptions[0];
|
||||||
}
|
}
|
||||||
|
emit('update:pagination-and-sort', emitPayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
currentPage,
|
||||||
|
setCurrentPage,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -556,7 +633,12 @@ const loadPaginationFromQueryString = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</n8n-input>
|
</n8n-input>
|
||||||
<div :class="$style['sort-and-filter']">
|
<div :class="$style['sort-and-filter']">
|
||||||
<n8n-select v-model="sortBy" size="small" data-test-id="resources-list-sort">
|
<n8n-select
|
||||||
|
v-model="sortBy"
|
||||||
|
size="small"
|
||||||
|
data-test-id="resources-list-sort"
|
||||||
|
@change="setSorting(sortBy)"
|
||||||
|
>
|
||||||
<n8n-option
|
<n8n-option
|
||||||
v-for="sortOption in sortOptions"
|
v-for="sortOption in sortOptions"
|
||||||
:key="sortOption"
|
:key="sortOption"
|
||||||
@@ -624,7 +706,11 @@ const loadPaginationFromQueryString = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</n8n-recycle-scroller>
|
</n8n-recycle-scroller>
|
||||||
<!-- PAGINATED LIST -->
|
<!-- PAGINATED LIST -->
|
||||||
<div v-else-if="type === 'list-paginated'" :class="$style.paginatedListWrapper">
|
<div
|
||||||
|
v-else-if="type === 'list-paginated'"
|
||||||
|
:class="$style.paginatedListWrapper"
|
||||||
|
data-test-id="paginated-list"
|
||||||
|
>
|
||||||
<div :class="$style.listItems">
|
<div :class="$style.listItems">
|
||||||
<div v-for="(item, index) in resources" :key="index" :class="$style.listItem">
|
<div v-for="(item, index) in resources" :key="index" :class="$style.listItem">
|
||||||
<slot name="item" :item="item" :index="index">
|
<slot name="item" :item="item" :index="index">
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { useN8nLocalStorage } from './useN8nLocalStorage';
|
||||||
|
|
||||||
|
const mockOverview = {
|
||||||
|
isOverviewSubPage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a shared storage object that persists between calls
|
||||||
|
let mockLocalStorageValue: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
vi.mock('@/composables/useOverview', () => ({
|
||||||
|
useOverview: vi.fn(() => mockOverview),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@vueuse/core', () => ({
|
||||||
|
useLocalStorage: vi.fn((_key, defaultValue) => {
|
||||||
|
// Only initialize with default value if the mock is empty
|
||||||
|
if (Object.keys(mockLocalStorageValue).length === 0) {
|
||||||
|
Object.assign(mockLocalStorageValue, structuredClone(defaultValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: mockLocalStorageValue,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useN8nLocalStorage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockOverview.isOverviewSubPage = false;
|
||||||
|
mockLocalStorageValue = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProjectKey', () => {
|
||||||
|
it('returns the projectId when not on overview page', () => {
|
||||||
|
const { getProjectKey } = useN8nLocalStorage();
|
||||||
|
const projectId = 'test-project';
|
||||||
|
|
||||||
|
expect(getProjectKey(projectId)).toBe(projectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "home" when on overview page', () => {
|
||||||
|
// Override the mock for this specific test
|
||||||
|
mockOverview.isOverviewSubPage = true;
|
||||||
|
|
||||||
|
const { getProjectKey } = useN8nLocalStorage();
|
||||||
|
const projectId = 'test-project';
|
||||||
|
|
||||||
|
expect(getProjectKey(projectId)).toBe('home');
|
||||||
|
|
||||||
|
// Reset for subsequent tests
|
||||||
|
mockOverview.isOverviewSubPage = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the provided projectId when it is an empty string', () => {
|
||||||
|
const { getProjectKey } = useN8nLocalStorage();
|
||||||
|
const projectId = '';
|
||||||
|
|
||||||
|
expect(getProjectKey(projectId)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when projectId is undefined', () => {
|
||||||
|
const { getProjectKey } = useN8nLocalStorage();
|
||||||
|
|
||||||
|
expect(getProjectKey(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveProjectPreferencesToLocalStorage', () => {
|
||||||
|
it('saves new preferences to localStorage', () => {
|
||||||
|
const { saveProjectPreferencesToLocalStorage } = useN8nLocalStorage();
|
||||||
|
const projectId = 'test-project';
|
||||||
|
const tabKey = 'workflows';
|
||||||
|
const preferences = { sort: 'name', pageSize: 25 };
|
||||||
|
|
||||||
|
saveProjectPreferencesToLocalStorage(projectId, tabKey, preferences);
|
||||||
|
|
||||||
|
expect(mockLocalStorageValue).toEqual({
|
||||||
|
'test-project': {
|
||||||
|
workflows: { sort: 'name', pageSize: 25 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges new preferences with existing ones', () => {
|
||||||
|
const { saveProjectPreferencesToLocalStorage } = useN8nLocalStorage();
|
||||||
|
const projectId = 'test-project';
|
||||||
|
const tabKey = 'workflows';
|
||||||
|
|
||||||
|
// First save
|
||||||
|
saveProjectPreferencesToLocalStorage(projectId, tabKey, { sort: 'name' });
|
||||||
|
// Second save with different preference
|
||||||
|
saveProjectPreferencesToLocalStorage(projectId, tabKey, { pageSize: 25 });
|
||||||
|
|
||||||
|
expect(mockLocalStorageValue).toEqual({
|
||||||
|
'test-project': {
|
||||||
|
workflows: { sort: 'name', pageSize: 25 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when projectKey is falsy', () => {
|
||||||
|
mockOverview.isOverviewSubPage = false;
|
||||||
|
|
||||||
|
const { saveProjectPreferencesToLocalStorage } = useN8nLocalStorage();
|
||||||
|
const projectId = ''; // This will result in a falsy projectKey
|
||||||
|
const tabKey = 'workflows';
|
||||||
|
const preferences = { sort: 'name', pageSize: 25 };
|
||||||
|
|
||||||
|
saveProjectPreferencesToLocalStorage(projectId, tabKey, preferences);
|
||||||
|
|
||||||
|
expect(mockLocalStorageValue).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports saving credentials tab preferences', () => {
|
||||||
|
const { saveProjectPreferencesToLocalStorage } = useN8nLocalStorage();
|
||||||
|
const projectId = 'test-project';
|
||||||
|
const tabKey = 'credentials';
|
||||||
|
const preferences = { sort: 'type', pageSize: 50 };
|
||||||
|
|
||||||
|
saveProjectPreferencesToLocalStorage(projectId, tabKey, preferences);
|
||||||
|
|
||||||
|
expect(mockLocalStorageValue).toEqual({
|
||||||
|
'test-project': {
|
||||||
|
credentials: { sort: 'type', pageSize: 50 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadProjectPreferencesFromLocalStorage', () => {
|
||||||
|
it('loads preferences from localStorage', () => {
|
||||||
|
mockLocalStorageValue['test-project'] = {
|
||||||
|
workflows: { sort: 'name', pageSize: 25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadProjectPreferencesFromLocalStorage } = useN8nLocalStorage();
|
||||||
|
const projectId = 'test-project';
|
||||||
|
const tabKey = 'workflows';
|
||||||
|
|
||||||
|
const result = loadProjectPreferencesFromLocalStorage(projectId, tabKey);
|
||||||
|
|
||||||
|
expect(result).toEqual({ sort: 'name', pageSize: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when preferences do not exist', () => {
|
||||||
|
const { loadProjectPreferencesFromLocalStorage } = useN8nLocalStorage();
|
||||||
|
const projectId = 'non-existent-project';
|
||||||
|
const tabKey = 'workflows';
|
||||||
|
|
||||||
|
const result = loadProjectPreferencesFromLocalStorage(projectId, tabKey);
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when projectKey is falsy', () => {
|
||||||
|
const { loadProjectPreferencesFromLocalStorage } = useN8nLocalStorage();
|
||||||
|
const projectId = ''; // This will result in a falsy projectKey
|
||||||
|
const tabKey = 'workflows';
|
||||||
|
|
||||||
|
const result = loadProjectPreferencesFromLocalStorage(projectId, tabKey);
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when tab does not exist', () => {
|
||||||
|
mockLocalStorageValue['test-project'] = {
|
||||||
|
workflows: { sort: 'name', pageSize: 25 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { loadProjectPreferencesFromLocalStorage } = useN8nLocalStorage();
|
||||||
|
const projectId = 'test-project';
|
||||||
|
const tabKey = 'credentials';
|
||||||
|
|
||||||
|
const result = loadProjectPreferencesFromLocalStorage(projectId, tabKey);
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useOverview } from '@/composables/useOverview';
|
||||||
|
import { LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY } from '@/constants';
|
||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
|
||||||
|
// Workflow list user preferences
|
||||||
|
type TabSettings = {
|
||||||
|
sort?: string;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// We are currently only saving workflow tab settings
|
||||||
|
// but we are keeping the credentials tab settings here for future use
|
||||||
|
export type WorkflowListPreferences = {
|
||||||
|
[projectId: string]: {
|
||||||
|
[tabName in 'workflows' | 'credentials']: TabSettings;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple n8n wrapper around vueuse's useLocalStorage.
|
||||||
|
* Provides util functions to read and write n8n values to local storage.
|
||||||
|
* Currently only used for workflow list user preferences.
|
||||||
|
*/
|
||||||
|
export function useN8nLocalStorage() {
|
||||||
|
const overview = useOverview();
|
||||||
|
|
||||||
|
const getProjectKey = (projectId?: string) => {
|
||||||
|
return overview.isOverviewSubPage ? 'home' : projectId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveProjectPreferencesToLocalStorage = (
|
||||||
|
projectId: string,
|
||||||
|
tabKey: 'workflows' | 'credentials',
|
||||||
|
preferences: TabSettings,
|
||||||
|
) => {
|
||||||
|
const projectKey = getProjectKey(projectId);
|
||||||
|
if (!projectKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localStorage = useLocalStorage<Record<string, WorkflowListPreferences>>(
|
||||||
|
LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!localStorage.value[projectKey]) {
|
||||||
|
localStorage.value[projectKey] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.value[projectKey][tabKey] = {
|
||||||
|
...localStorage.value[projectKey][tabKey],
|
||||||
|
...preferences,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProjectPreferencesFromLocalStorage = (
|
||||||
|
projectId: string,
|
||||||
|
tabKey: 'workflows' | 'credentials',
|
||||||
|
) => {
|
||||||
|
const projectKey = getProjectKey(projectId);
|
||||||
|
if (!projectKey) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const localStorage = useLocalStorage<Record<string, WorkflowListPreferences>>(
|
||||||
|
LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const projectPreferences: TabSettings =
|
||||||
|
(localStorage.value[projectKey]?.[tabKey] as TabSettings) || {};
|
||||||
|
return projectPreferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
saveProjectPreferencesToLocalStorage,
|
||||||
|
loadProjectPreferencesFromLocalStorage,
|
||||||
|
getProjectKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -480,6 +480,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
|
|||||||
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
||||||
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
||||||
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
|
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
|
||||||
|
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
|
||||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||||
export const COMMUNITY_PLUS_DOCS_URL =
|
export const COMMUNITY_PLUS_DOCS_URL =
|
||||||
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
BaseFilters,
|
BaseFilters,
|
||||||
FolderResource,
|
FolderResource,
|
||||||
Resource,
|
Resource,
|
||||||
|
SortingAndPaginationUpdates,
|
||||||
WorkflowResource,
|
WorkflowResource,
|
||||||
} from '@/components/layouts/ResourcesListLayout.vue';
|
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
@@ -112,7 +113,9 @@ const documentTitle = useDocumentTitle();
|
|||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
const overview = useOverview();
|
const overview = useOverview();
|
||||||
|
|
||||||
const loading = ref(false);
|
// We render component in a loading state until initialization is done
|
||||||
|
// This will prevent any additional workflow fetches while initializing
|
||||||
|
const loading = ref(true);
|
||||||
const breadcrumbsLoading = ref(false);
|
const breadcrumbsLoading = ref(false);
|
||||||
const filters = ref<Filters>({
|
const filters = ref<Filters>({
|
||||||
search: '',
|
search: '',
|
||||||
@@ -363,7 +366,7 @@ const showRegisteredCommunityCTA = computed(
|
|||||||
watch(
|
watch(
|
||||||
() => route.params?.projectId,
|
() => route.params?.projectId,
|
||||||
async () => {
|
async () => {
|
||||||
await initialize();
|
loading.value = true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -542,18 +545,6 @@ const fetchWorkflows = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Filter and sort methods
|
// Filter and sort methods
|
||||||
|
|
||||||
const onSortUpdated = async (sort: string) => {
|
|
||||||
currentSort.value =
|
|
||||||
WORKFLOWS_SORT_MAP[sort as keyof typeof WORKFLOWS_SORT_MAP] ?? 'updatedAt:desc';
|
|
||||||
if (currentSort.value !== 'updatedAt:desc') {
|
|
||||||
void router.replace({ query: { ...route.query, sort } });
|
|
||||||
} else {
|
|
||||||
void router.replace({ query: { ...route.query, sort: undefined } });
|
|
||||||
}
|
|
||||||
await fetchWorkflows();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFiltersUpdated = async () => {
|
const onFiltersUpdated = async () => {
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
saveFiltersOnQueryString();
|
saveFiltersOnQueryString();
|
||||||
@@ -571,14 +562,23 @@ const onSearchUpdated = async (search: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCurrentPage = async (page: number) => {
|
const setPaginationAndSort = async (payload: SortingAndPaginationUpdates) => {
|
||||||
currentPage.value = page;
|
if (payload.page) {
|
||||||
await callDebounced(fetchWorkflows, { debounceTime: FILTERS_DEBOUNCE_TIME, trailing: true });
|
currentPage.value = payload.page;
|
||||||
};
|
}
|
||||||
|
if (payload.pageSize) {
|
||||||
const setPageSize = async (size: number) => {
|
pageSize.value = payload.pageSize;
|
||||||
pageSize.value = size;
|
}
|
||||||
|
if (payload.sort) {
|
||||||
|
currentSort.value =
|
||||||
|
WORKFLOWS_SORT_MAP[payload.sort as keyof typeof WORKFLOWS_SORT_MAP] ?? 'updatedAt:desc';
|
||||||
|
}
|
||||||
|
// Don't fetch workflows if we are loading
|
||||||
|
// This will prevent unnecessary API calls when changing sort and pagination from url/local storage
|
||||||
|
// when switching between projects
|
||||||
|
if (!loading.value) {
|
||||||
await callDebounced(fetchWorkflows, { debounceTime: FILTERS_DEBOUNCE_TIME, trailing: true });
|
await callDebounced(fetchWorkflows, { debounceTime: FILTERS_DEBOUNCE_TIME, trailing: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickTag = async (tagId: string) => {
|
const onClickTag = async (tagId: string) => {
|
||||||
@@ -1340,10 +1340,8 @@ const onNameSubmit = async ({
|
|||||||
:has-empty-state="foldersStore.totalWorkflowCount === 0 && !currentFolderId"
|
:has-empty-state="foldersStore.totalWorkflowCount === 0 && !currentFolderId"
|
||||||
@click:add="addWorkflow"
|
@click:add="addWorkflow"
|
||||||
@update:search="onSearchUpdated"
|
@update:search="onSearchUpdated"
|
||||||
@update:current-page="setCurrentPage"
|
|
||||||
@update:page-size="setPageSize"
|
|
||||||
@update:filters="onFiltersUpdated"
|
@update:filters="onFiltersUpdated"
|
||||||
@sort="onSortUpdated"
|
@update:pagination-and-sort="setPaginationAndSort"
|
||||||
@mouseleave="folderHelpers.resetDropTarget"
|
@mouseleave="folderHelpers.resetDropTarget"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
|
|||||||
Reference in New Issue
Block a user