diff --git a/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.test.ts b/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.test.ts
new file mode 100644
index 0000000000..ca410c0181
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.test.ts
@@ -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 = {};
+
+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({});
+ });
+ });
+});
diff --git a/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.ts b/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.ts
new file mode 100644
index 0000000000..1759003af0
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/useN8nLocalStorage.ts
@@ -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>(
+ 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>(
+ LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY,
+ {},
+ );
+ const projectPreferences: TabSettings =
+ (localStorage.value[projectKey]?.[tabKey] as TabSettings) || {};
+ return projectPreferences;
+ };
+
+ return {
+ saveProjectPreferencesToLocalStorage,
+ loadProjectPreferencesFromLocalStorage,
+ getProjectKey,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts
index 6f6d294ecf..48d8c9f28d 100644
--- a/packages/frontend/editor-ui/src/constants.ts
+++ b/packages/frontend/editor-ui/src/constants.ts
@@ -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';
diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/views/WorkflowsView.vue
index 06d24ab6f7..da8f7e664c 100644
--- a/packages/frontend/editor-ui/src/views/WorkflowsView.vue
+++ b/packages/frontend/editor-ui/src/views/WorkflowsView.vue
@@ -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({
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"
>