mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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);
|
||||
});
|
||||
|
||||
it('should preserve filters and pagination in URL', () => {
|
||||
it('should preserve filters in URL', () => {
|
||||
// Add a search query
|
||||
WorkflowsPage.getters.searchBar().type('My');
|
||||
// Add a tag filter
|
||||
@@ -118,8 +118,6 @@ describe('Workflows', () => {
|
||||
cy.url().should('include', 'search=My');
|
||||
// Cannot really know tag id, so just check if it contains 'tags='
|
||||
cy.url().should('include', 'tags=');
|
||||
cy.url().should('include', 'sort=lastCreated');
|
||||
cy.url().should('include', 'pageSize=25');
|
||||
|
||||
// Reload the page
|
||||
cy.reload();
|
||||
@@ -136,8 +134,6 @@ describe('Workflows', () => {
|
||||
// Aso, check if the URL is preserved
|
||||
cy.url().should('include', 'search=My');
|
||||
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', () => {
|
||||
|
||||
@@ -240,10 +240,10 @@ onClickOutside(
|
||||
</N8nOption>
|
||||
<N8nOption v-else-if="options.length === 0" value="message" disabled>
|
||||
<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')
|
||||
}}</span>
|
||||
<span v-else-if="filter">{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
|
||||
<span v-else>{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
|
||||
</N8nOption>
|
||||
|
||||
<N8nOption
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
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 { 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', () => ({
|
||||
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) => {
|
||||
const { RouterLink } = await importOriginal<typeof router>();
|
||||
return {
|
||||
RouterLink,
|
||||
useRoute: () => ({
|
||||
params: {},
|
||||
params: {
|
||||
projectId: TEST_HOME_PROJECT.id,
|
||||
},
|
||||
path: `/projects/${TEST_HOME_PROJECT.id}/workflows`,
|
||||
query: {},
|
||||
}),
|
||||
useRouter: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(ResourcesListLayout);
|
||||
const renderComponent = createComponentRenderer(ResourcesListLayout, {
|
||||
props: {
|
||||
resourceKey: 'workflows',
|
||||
resources: [],
|
||||
disabled: false,
|
||||
typeProps: { itemSize: 80 },
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('ResourcesListLayout', () => {
|
||||
beforeEach(() => {
|
||||
@@ -38,4 +104,49 @@ describe('ResourcesListLayout', () => {
|
||||
|
||||
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 { BaseFolderItem, BaseResource, ITag, ResourceParentFolder } from '@/Interface';
|
||||
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
|
||||
import { useN8nLocalStorage } from '@/composables/useN8nLocalStorage';
|
||||
|
||||
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
|
||||
|
||||
@@ -63,12 +64,19 @@ export type BaseFilters = {
|
||||
[key: string]: boolean | string | string[];
|
||||
};
|
||||
|
||||
export type SortingAndPaginationUpdates = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const { callDebounced } = useDebounce();
|
||||
const usersStore = useUsersStore();
|
||||
const telemetry = useTelemetry();
|
||||
const n8nLocalStorage = useN8nLocalStorage();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -126,12 +134,15 @@ const rowsPerPage = ref<number>(props.customPageSize);
|
||||
const resettingFilters = ref(false);
|
||||
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<{
|
||||
'update:filters': [value: BaseFilters];
|
||||
'click:add': [event: Event];
|
||||
'update:current-page': [page: number];
|
||||
'update:page-size': [pageSize: number];
|
||||
sort: [value: string];
|
||||
'update:pagination-and-sort': [value: SortingAndPaginationUpdates];
|
||||
'update:search': [value: string];
|
||||
}>();
|
||||
|
||||
@@ -286,24 +297,18 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => sortBy.value,
|
||||
(newValue) => {
|
||||
emit('sort', newValue);
|
||||
sendSortingTelemetry();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route?.params?.projectId,
|
||||
async () => {
|
||||
await resetFilters();
|
||||
await loadPaginationPreferences();
|
||||
await props.initialize();
|
||||
},
|
||||
);
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await loadPaginationFromQueryString();
|
||||
await loadPaginationPreferences();
|
||||
await props.initialize();
|
||||
await nextTick();
|
||||
|
||||
@@ -350,21 +355,33 @@ const hasAppliedFilters = (): boolean => {
|
||||
|
||||
const setRowsPerPage = async (numberOfRowsPerPage: number) => {
|
||||
rowsPerPage.value = numberOfRowsPerPage;
|
||||
await savePaginationToQueryString();
|
||||
emit('update:page-size', numberOfRowsPerPage);
|
||||
await savePaginationPreferences();
|
||||
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;
|
||||
await savePaginationToQueryString();
|
||||
emit('update:current-page', page);
|
||||
if (persistUpdate) {
|
||||
await savePaginationPreferences();
|
||||
}
|
||||
emit('update:pagination-and-sort', {
|
||||
page,
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
});
|
||||
|
||||
const sendFiltersTelemetry = (source: string) => {
|
||||
// Prevent sending multiple telemetry events when resetting filters
|
||||
// Timeout is required to wait for search debounce to be over
|
||||
@@ -409,7 +426,7 @@ const resetFilters = async () => {
|
||||
});
|
||||
|
||||
// Reset the current page
|
||||
await setCurrentPage(1);
|
||||
await setCurrentPage(1, false);
|
||||
|
||||
resettingFilters.value = true;
|
||||
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
|
||||
if (props.type !== 'list-paginated') {
|
||||
return;
|
||||
@@ -466,38 +486,95 @@ const savePaginationToQueryString = async () => {
|
||||
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();
|
||||
preferredPageSize.value = rowsPerPage.value;
|
||||
} else {
|
||||
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({
|
||||
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
|
||||
if (props.type !== 'list-paginated') {
|
||||
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) {
|
||||
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) {
|
||||
const parsedSize = parseInt(query.pageSize as string, 10);
|
||||
if (query.pageSize ?? localStorageValues.pageSize) {
|
||||
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
|
||||
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) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -556,7 +633,12 @@ const loadPaginationFromQueryString = async () => {
|
||||
</template>
|
||||
</n8n-input>
|
||||
<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
|
||||
v-for="sortOption in sortOptions"
|
||||
:key="sortOption"
|
||||
@@ -624,7 +706,11 @@ const loadPaginationFromQueryString = async () => {
|
||||
</template>
|
||||
</n8n-recycle-scroller>
|
||||
<!-- 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 v-for="(item, index) in resources" :key="index" :class="$style.listItem">
|
||||
<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_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_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
|
||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||
export const COMMUNITY_PLUS_DOCS_URL =
|
||||
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
BaseFilters,
|
||||
FolderResource,
|
||||
Resource,
|
||||
SortingAndPaginationUpdates,
|
||||
WorkflowResource,
|
||||
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||
@@ -112,7 +113,9 @@ const documentTitle = useDocumentTitle();
|
||||
const { callDebounced } = useDebounce();
|
||||
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 filters = ref<Filters>({
|
||||
search: '',
|
||||
@@ -363,7 +366,7 @@ const showRegisteredCommunityCTA = computed(
|
||||
watch(
|
||||
() => route.params?.projectId,
|
||||
async () => {
|
||||
await initialize();
|
||||
loading.value = true;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -542,18 +545,6 @@ const fetchWorkflows = async () => {
|
||||
};
|
||||
|
||||
// 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 () => {
|
||||
currentPage.value = 1;
|
||||
saveFiltersOnQueryString();
|
||||
@@ -571,14 +562,23 @@ const onSearchUpdated = async (search: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const setCurrentPage = async (page: number) => {
|
||||
currentPage.value = page;
|
||||
await callDebounced(fetchWorkflows, { debounceTime: FILTERS_DEBOUNCE_TIME, trailing: true });
|
||||
};
|
||||
|
||||
const setPageSize = async (size: number) => {
|
||||
pageSize.value = size;
|
||||
await callDebounced(fetchWorkflows, { debounceTime: FILTERS_DEBOUNCE_TIME, trailing: true });
|
||||
const setPaginationAndSort = async (payload: SortingAndPaginationUpdates) => {
|
||||
if (payload.page) {
|
||||
currentPage.value = payload.page;
|
||||
}
|
||||
if (payload.pageSize) {
|
||||
pageSize.value = payload.pageSize;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
const onClickTag = async (tagId: string) => {
|
||||
@@ -1340,10 +1340,8 @@ const onNameSubmit = async ({
|
||||
:has-empty-state="foldersStore.totalWorkflowCount === 0 && !currentFolderId"
|
||||
@click:add="addWorkflow"
|
||||
@update:search="onSearchUpdated"
|
||||
@update:current-page="setCurrentPage"
|
||||
@update:page-size="setPageSize"
|
||||
@update:filters="onFiltersUpdated"
|
||||
@sort="onSortUpdated"
|
||||
@update:pagination-and-sort="setPaginationAndSort"
|
||||
@mouseleave="folderHelpers.resetDropTarget"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
Reference in New Issue
Block a user