From 865fc21276727e8d88ccee0355147904b81c4421 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= <raul00gm@gmail.com>
Date: Mon, 13 Jan 2025 10:24:51 +0100
Subject: [PATCH] fix(editor): Update filter and feedback for source control
 (#12504)

---
 packages/editor-ui/src/api/credentials.ts     |   1 +
 packages/editor-ui/src/api/sourceControl.ts   |   2 +-
 .../src/components/CredentialCard.vue         |   8 +-
 .../MainSidebarSourceControl.test.ts          | 129 ++++++++++++-
 .../components/MainSidebarSourceControl.vue   | 123 +++++++++---
 .../forms/ResourceFiltersDropdown.vue         |  13 +-
 .../layouts/ResourcesListLayout.vue           |  20 +-
 .../src/plugins/i18n/locales/en.json          |   7 +
 .../src/views/CredentialsView.test.ts         | 177 ++++++++++++++++--
 .../editor-ui/src/views/CredentialsView.vue   |  72 +++++--
 10 files changed, 474 insertions(+), 78 deletions(-)

diff --git a/packages/editor-ui/src/api/credentials.ts b/packages/editor-ui/src/api/credentials.ts
index 6ddb37c16a..18bb7b3f3d 100644
--- a/packages/editor-ui/src/api/credentials.ts
+++ b/packages/editor-ui/src/api/credentials.ts
@@ -32,6 +32,7 @@ export async function getAllCredentials(
 ): Promise<ICredentialsResponse[]> {
 	return await makeRestApiRequest(context, 'GET', '/credentials', {
 		...(includeScopes ? { includeScopes } : {}),
+		includeData: true,
 		...(filter ? { filter } : {}),
 	});
 }
diff --git a/packages/editor-ui/src/api/sourceControl.ts b/packages/editor-ui/src/api/sourceControl.ts
index 3aa929b6af..4553856928 100644
--- a/packages/editor-ui/src/api/sourceControl.ts
+++ b/packages/editor-ui/src/api/sourceControl.ts
@@ -33,7 +33,7 @@ export const pushWorkfolder = async (
 export const pullWorkfolder = async (
 	context: IRestApiContext,
 	data: PullWorkFolderRequestDto,
-): Promise<void> => {
+): Promise<SourceControlledFile[]> => {
 	return await makeRestApiRequest(context, 'POST', `${sourceControlApiRoot}/pull-workfolder`, data);
 };
 
diff --git a/packages/editor-ui/src/components/CredentialCard.vue b/packages/editor-ui/src/components/CredentialCard.vue
index db40f0d274..4d4dd131f6 100644
--- a/packages/editor-ui/src/components/CredentialCard.vue
+++ b/packages/editor-ui/src/components/CredentialCard.vue
@@ -29,6 +29,7 @@ const props = withDefaults(
 	defineProps<{
 		data: ICredentialsResponse;
 		readOnly?: boolean;
+		needsSetup?: boolean;
 	}>(),
 	{
 		data: () => ({
@@ -146,6 +147,9 @@ function moveResource() {
 				<N8nBadge v-if="readOnly" class="ml-3xs" theme="tertiary" bold>
 					{{ locale.baseText('credentials.item.readonly') }}
 				</N8nBadge>
+				<N8nBadge v-if="needsSetup" class="ml-3xs" theme="warning">
+					{{ locale.baseText('credentials.item.needsSetup') }}
+				</N8nBadge>
 			</n8n-heading>
 		</template>
 		<div :class="$style.cardDescription">
@@ -195,10 +199,6 @@ function moveResource() {
 .cardHeading {
 	font-size: var(--font-size-s);
 	padding: var(--spacing-s) 0 0;
-
-	span {
-		color: var(--color-text-light);
-	}
 }
 
 .cardDescription {
diff --git a/packages/editor-ui/src/components/MainSidebarSourceControl.test.ts b/packages/editor-ui/src/components/MainSidebarSourceControl.test.ts
index 4c192ffba6..dc3e805b05 100644
--- a/packages/editor-ui/src/components/MainSidebarSourceControl.test.ts
+++ b/packages/editor-ui/src/components/MainSidebarSourceControl.test.ts
@@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/vue';
 import userEvent from '@testing-library/user-event';
 import { createTestingPinia } from '@pinia/testing';
 import { merge } from 'lodash-es';
-import { SOURCE_CONTROL_PULL_MODAL_KEY, STORES } from '@/constants';
+import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, STORES } from '@/constants';
 import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
 import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
 import { useSourceControlStore } from '@/stores/sourceControl.store';
@@ -18,8 +18,9 @@ let rbacStore: ReturnType<typeof useRBACStore>;
 
 const showMessage = vi.fn();
 const showError = vi.fn();
+const showToast = vi.fn();
 vi.mock('@/composables/useToast', () => ({
-	useToast: () => ({ showMessage, showError }),
+	useToast: () => ({ showMessage, showError, showToast }),
 }));
 
 const renderComponent = createComponentRenderer(MainSidebarSourceControl);
@@ -131,5 +132,129 @@ describe('MainSidebarSourceControl', () => {
 				),
 			);
 		});
+
+		it('should open push modal when there are changes', async () => {
+			const status = [
+				{
+					id: '014da93897f146d2b880-baa374b9d02d',
+					name: 'vuelfow2',
+					type: 'workflow' as const,
+					status: 'created' as const,
+					location: 'local' as const,
+					conflict: false,
+					file: '/014da93897f146d2b880-baa374b9d02d.json',
+					updatedAt: '2025-01-09T13:12:24.580Z',
+				},
+			];
+			vi.spyOn(sourceControlStore, 'getAggregatedStatus').mockResolvedValueOnce(status);
+			const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
+
+			const { getAllByRole } = renderComponent({
+				pinia,
+				props: { isCollapsed: false },
+			});
+
+			await userEvent.click(getAllByRole('button')[1]);
+			await waitFor(() =>
+				expect(openModalSpy).toHaveBeenCalledWith(
+					expect.objectContaining({
+						name: SOURCE_CONTROL_PUSH_MODAL_KEY,
+						data: expect.objectContaining({
+							status,
+						}),
+					}),
+				),
+			);
+		});
+
+		it("should show user's feedback when pulling", async () => {
+			vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([
+				{
+					id: '014da93897f146d2b880-baa374b9d02d',
+					name: 'vuelfow2',
+					type: 'workflow',
+					status: 'created',
+					location: 'remote',
+					conflict: false,
+					file: '/014da93897f146d2b880-baa374b9d02d.json',
+					updatedAt: '2025-01-09T13:12:24.580Z',
+				},
+				{
+					id: 'a102c0b9-28ac-43cb-950e-195723a56d54',
+					name: 'Gmail account',
+					type: 'credential',
+					status: 'created',
+					location: 'remote',
+					conflict: false,
+					file: '/a102c0b9-28ac-43cb-950e-195723a56d54.json',
+					updatedAt: '2025-01-09T13:12:24.586Z',
+				},
+				{
+					id: 'variables',
+					name: 'variables',
+					type: 'variables',
+					status: 'modified',
+					location: 'remote',
+					conflict: false,
+					file: '/variable_stubs.json',
+					updatedAt: '2025-01-09T13:12:24.588Z',
+				},
+				{
+					id: 'mappings',
+					name: 'tags',
+					type: 'tags',
+					status: 'modified',
+					location: 'remote',
+					conflict: false,
+					file: '/tags.json',
+					updatedAt: '2024-12-16T12:53:12.155Z',
+				},
+			]);
+
+			const { getAllByRole } = renderComponent({
+				pinia,
+				props: { isCollapsed: false },
+			});
+
+			await userEvent.click(getAllByRole('button')[0]);
+			await waitFor(() => {
+				expect(showToast).toHaveBeenNthCalledWith(
+					1,
+					expect.objectContaining({
+						title: 'Finish setting up your new variables to use in workflows',
+					}),
+				);
+				expect(showToast).toHaveBeenNthCalledWith(
+					2,
+					expect.objectContaining({
+						title: 'Finish setting up your new credentials to use in workflows',
+					}),
+				);
+				expect(showToast).toHaveBeenNthCalledWith(
+					3,
+					expect.objectContaining({
+						message: '1 Workflow, 1 Credential, Variables, and Tags were pulled',
+					}),
+				);
+			});
+		});
+
+		it('should show feedback where there are no change to pull', async () => {
+			vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([]);
+
+			const { getAllByRole } = renderComponent({
+				pinia,
+				props: { isCollapsed: false },
+			});
+
+			await userEvent.click(getAllByRole('button')[0]);
+			await waitFor(() => {
+				expect(showMessage).toHaveBeenCalledWith(
+					expect.objectContaining({
+						title: 'Up to date',
+					}),
+				);
+			});
+		});
 	});
 });
diff --git a/packages/editor-ui/src/components/MainSidebarSourceControl.vue b/packages/editor-ui/src/components/MainSidebarSourceControl.vue
index 1fa8338a3f..af861e93c9 100644
--- a/packages/editor-ui/src/components/MainSidebarSourceControl.vue
+++ b/packages/editor-ui/src/components/MainSidebarSourceControl.vue
@@ -1,5 +1,5 @@
 <script lang="ts" setup>
-import { computed, nextTick, ref } from 'vue';
+import { computed, h, nextTick, ref } from 'vue';
 import { createEventBus } from 'n8n-design-system/utils';
 import { useI18n } from '@/composables/useI18n';
 import { hasPermission } from '@/utils/rbac/permissions';
@@ -9,6 +9,9 @@ import { useUIStore } from '@/stores/ui.store';
 import { useSourceControlStore } from '@/stores/sourceControl.store';
 import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
 import { sourceControlEventBus } from '@/event-bus/source-control';
+import { groupBy } from 'lodash-es';
+import { RouterLink } from 'vue-router';
+import { VIEWS } from '@/constants';
 import type { SourceControlledFile } from '@n8n/api-types';
 
 defineProps<{
@@ -64,48 +67,106 @@ async function pushWorkfolder() {
 	}
 }
 
+const variablesToast = {
+	title: i18n.baseText('settings.sourceControl.pull.upToDate.variables.title'),
+	message: h(RouterLink, { to: { name: VIEWS.VARIABLES } }, () =>
+		i18n.baseText('settings.sourceControl.pull.upToDate.variables.description'),
+	),
+	type: 'info' as const,
+	closeOnClick: true,
+	duration: 0,
+};
+
+const credentialsToast = {
+	title: i18n.baseText('settings.sourceControl.pull.upToDate.credentials.title'),
+	message: h(RouterLink, { to: { name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } } }, () =>
+		i18n.baseText('settings.sourceControl.pull.upToDate.credentials.description'),
+	),
+	type: 'info' as const,
+	closeOnClick: true,
+	duration: 0,
+};
+
+const pullMessage = ({
+	credential,
+	tags,
+	variables,
+	workflow,
+}: Partial<Record<SourceControlledFile['type'], SourceControlledFile[]>>) => {
+	const messages: string[] = [];
+
+	if (workflow?.length) {
+		messages.push(
+			i18n.baseText('generic.workflow', {
+				adjustToNumber: workflow.length,
+				interpolate: { count: workflow.length },
+			}),
+		);
+	}
+
+	if (credential?.length) {
+		messages.push(
+			i18n.baseText('generic.credential', {
+				adjustToNumber: credential.length,
+				interpolate: { count: credential.length },
+			}),
+		);
+	}
+
+	if (variables?.length) {
+		messages.push(i18n.baseText('generic.variable_plural'));
+	}
+
+	if (tags?.length) {
+		messages.push(i18n.baseText('generic.tag_plural'));
+	}
+
+	return [
+		new Intl.ListFormat(i18n.locale, { style: 'long', type: 'conjunction' }).format(messages),
+		'were pulled',
+	].join(' ');
+};
+
 async function pullWorkfolder() {
 	loadingService.startLoading();
 	loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
 
 	try {
-		const status: SourceControlledFile[] =
-			((await sourceControlStore.pullWorkfolder(false)) as unknown as SourceControlledFile[]) || [];
+		const status = await sourceControlStore.pullWorkfolder(false);
 
-		const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
-			return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
-		});
-
-		if (statusWithoutLocallyCreatedWorkflows.length === 0) {
+		if (!status.length) {
 			toast.showMessage({
 				title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
 				message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
 				type: 'success',
 			});
-		} else {
-			toast.showMessage({
-				title: i18n.baseText('settings.sourceControl.pull.success.title'),
-				type: 'success',
-			});
-
-			const incompleteFileTypes = ['variables', 'credential'];
-			const hasVariablesOrCredentials = (status || []).some((file) => {
-				return incompleteFileTypes.includes(file.type);
-			});
-
-			if (hasVariablesOrCredentials) {
-				void nextTick(() => {
-					toast.showMessage({
-						message: i18n.baseText('settings.sourceControl.pull.oneLastStep.description'),
-						title: i18n.baseText('settings.sourceControl.pull.oneLastStep.title'),
-						type: 'info',
-						duration: 0,
-						showClose: true,
-						offset: 0,
-					});
-				});
-			}
+			return;
 		}
+
+		const { credential, tags, variables, workflow } = groupBy(status, 'type');
+
+		const toastMessages = [
+			...(variables?.length ? [variablesToast] : []),
+			...(credential?.length ? [credentialsToast] : []),
+			{
+				title: i18n.baseText('settings.sourceControl.pull.success.title'),
+				message: pullMessage({ credential, tags, variables, workflow }),
+				type: 'success' as const,
+			},
+		];
+
+		for (const message of toastMessages) {
+			/**
+			 * the toasts stack in a reversed way, resulting in
+			 * Success
+			 * Credentials
+			 * Variables
+			 */
+			//
+			toast.showToast(message);
+			await nextTick();
+		}
+
 		sourceControlEventBus.emit('pull');
 	} catch (error) {
 		const errorResponse = error.response;
diff --git a/packages/editor-ui/src/components/forms/ResourceFiltersDropdown.vue b/packages/editor-ui/src/components/forms/ResourceFiltersDropdown.vue
index 8bc6af756e..af0329cbe2 100644
--- a/packages/editor-ui/src/components/forms/ResourceFiltersDropdown.vue
+++ b/packages/editor-ui/src/components/forms/ResourceFiltersDropdown.vue
@@ -44,7 +44,18 @@ const filtersLength = computed(() => {
 		}
 
 		const value = props.modelValue[key];
-		length += (Array.isArray(value) ? value.length > 0 : value !== '') ? 1 : 0;
+
+		if (value === true) {
+			length += 1;
+		}
+
+		if (Array.isArray(value) && value.length) {
+			length += 1;
+		}
+
+		if (typeof value === 'string' && value !== '') {
+			length += 1;
+		}
 	});
 
 	return length;
diff --git a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue
index 33e165a650..221b2127d6 100644
--- a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue
+++ b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue
@@ -168,13 +168,19 @@ const focusSearchInput = () => {
 };
 
 const hasAppliedFilters = (): boolean => {
-	return !!filterKeys.value.find(
-		(key) =>
-			key !== 'search' &&
-			(Array.isArray(props.filters[key])
-				? props.filters[key].length > 0
-				: props.filters[key] !== ''),
-	);
+	return !!filterKeys.value.find((key) => {
+		if (key === 'search') return false;
+
+		if (typeof props.filters[key] === 'boolean') {
+			return props.filters[key];
+		}
+
+		if (Array.isArray(props.filters[key])) {
+			return props.filters[key].length > 0;
+		}
+
+		return props.filters[key] !== '';
+	});
 };
 
 const setRowsPerPage = (numberOfRowsPerPage: number) => {
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index a42f2de61a..e84c490939 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -624,8 +624,11 @@
 	"credentials.item.created": "Created",
 	"credentials.item.owner": "Owner",
 	"credentials.item.readonly": "Read only",
+	"credentials.item.needsSetup": "Needs first setup",
 	"credentials.search.placeholder": "Search credentials...",
 	"credentials.filters.type": "Type",
+	"credentials.filters.setup": "Needs first setup",
+	"credentials.filters.status": "Status",
 	"credentials.filters.active": "Some credentials may be hidden since filters are applied.",
 	"credentials.filters.active.reset": "Remove filters",
 	"credentials.sort.lastUpdated": "Sort by last updated",
@@ -1967,6 +1970,10 @@
 	"settings.sourceControl.pull.success.title": "Pulled successfully",
 	"settings.sourceControl.pull.upToDate.title": "Up to date",
 	"settings.sourceControl.pull.upToDate.description": "No workflow changes to pull from Git",
+	"settings.sourceControl.pull.upToDate.variables.title": "Finish setting up your new variables to use in workflows",
+	"settings.sourceControl.pull.upToDate.variables.description": "Review Variables",
+	"settings.sourceControl.pull.upToDate.credentials.title": "Finish setting up your new credentials to use in workflows",
+	"settings.sourceControl.pull.upToDate.credentials.description": "Review Credentials",
 	"settings.sourceControl.modals.pull.title": "Pull changes",
 	"settings.sourceControl.modals.pull.description": "These workflows will be updated, and any local changes to them will be overridden. To keep the local version, push it before pulling.",
 	"settings.sourceControl.modals.pull.description.learnMore": "More info",
diff --git a/packages/editor-ui/src/views/CredentialsView.test.ts b/packages/editor-ui/src/views/CredentialsView.test.ts
index 482cf6e767..878cc108b0 100644
--- a/packages/editor-ui/src/views/CredentialsView.test.ts
+++ b/packages/editor-ui/src/views/CredentialsView.test.ts
@@ -5,28 +5,27 @@ import CredentialsView from '@/views/CredentialsView.vue';
 import { useUIStore } from '@/stores/ui.store';
 import { mockedStore } from '@/__tests__/utils';
 import { waitFor, within, fireEvent } from '@testing-library/vue';
-import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants';
+import { CREDENTIAL_SELECT_MODAL_KEY, STORES, VIEWS } from '@/constants';
 import { useProjectsStore } from '@/stores/projects.store';
 import type { Project } from '@/types/projects.types';
-import { useRouter } from 'vue-router';
+import { createRouter, createWebHistory } from 'vue-router';
+import { flushPromises } from '@vue/test-utils';
+import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
 vi.mock('@/composables/useGlobalEntityCreation', () => ({
 	useGlobalEntityCreation: () => ({
 		menu: [],
 	}),
 }));
 
-vi.mock('vue-router', async () => {
-	const actual = await vi.importActual('vue-router');
-	const push = vi.fn();
-	const replace = vi.fn();
-	return {
-		...actual,
-		// your mocked methods
-		useRouter: () => ({
-			push,
-			replace,
-		}),
-	};
+const router = createRouter({
+	history: createWebHistory(),
+	routes: [
+		{
+			path: '/:credentialId?',
+			name: VIEWS.CREDENTIALS,
+			component: { template: '<div></div>' },
+		},
+	],
 });
 
 const initialState = {
@@ -36,14 +35,14 @@ const initialState = {
 };
 
 const renderComponent = createComponentRenderer(CredentialsView, {
-	global: { stubs: { ProjectHeader: true } },
+	global: { stubs: { ProjectHeader: true }, plugins: [router] },
 });
-let router: ReturnType<typeof useRouter>;
 
 describe('CredentialsView', () => {
-	beforeEach(() => {
+	beforeEach(async () => {
 		createTestingPinia({ initialState });
-		router = useRouter();
+		await router.push('/');
+		await router.isReady();
 	});
 
 	afterEach(() => {
@@ -115,6 +114,7 @@ describe('CredentialsView', () => {
 		});
 
 		it('should update credentialId route param when opened', async () => {
+			const replaceSpy = vi.spyOn(router, 'replace');
 			const projectsStore = mockedStore(useProjectsStore);
 			projectsStore.isProjectHome = false;
 			projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
@@ -137,8 +137,147 @@ describe('CredentialsView', () => {
 			 */
 			await fireEvent.click(getByTestId('resources-list-item'));
 			await waitFor(() =>
-				expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: '1' } }),
+				expect(replaceSpy).toHaveBeenCalledWith(
+					expect.objectContaining({ params: { credentialId: '1' } }),
+				),
 			);
 		});
 	});
+
+	describe('filters', () => {
+		it('should filter by type', async () => {
+			await router.push({ name: VIEWS.CREDENTIALS, query: { type: ['test'] } });
+			const credentialsStore = mockedStore(useCredentialsStore);
+			credentialsStore.allCredentialTypes = [
+				{
+					name: 'test',
+					displayName: 'test',
+					properties: [],
+				},
+			];
+			credentialsStore.allCredentials = [
+				{
+					id: '1',
+					name: 'test',
+					type: 'test',
+					createdAt: '2021-05-05T00:00:00Z',
+					updatedAt: '2021-05-05T00:00:00Z',
+					scopes: ['credential:update'],
+					isManaged: false,
+				},
+				{
+					id: '1',
+					name: 'test',
+					type: 'another',
+					createdAt: '2021-05-05T00:00:00Z',
+					updatedAt: '2021-05-05T00:00:00Z',
+					scopes: ['credential:update'],
+					isManaged: false,
+				},
+			];
+			const { getAllByTestId } = renderComponent();
+			expect(getAllByTestId('resources-list-item').length).toBe(1);
+		});
+
+		it('should filter by setupNeeded', async () => {
+			await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
+			const credentialsStore = mockedStore(useCredentialsStore);
+			credentialsStore.allCredentials = [
+				{
+					id: '1',
+					name: 'test',
+					type: 'test',
+					createdAt: '2021-05-05T00:00:00Z',
+					updatedAt: '2021-05-05T00:00:00Z',
+					scopes: ['credential:update'],
+					isManaged: false,
+					data: {} as unknown as string,
+				},
+				{
+					id: '1',
+					name: 'test',
+					type: 'another',
+					createdAt: '2021-05-05T00:00:00Z',
+					updatedAt: '2021-05-05T00:00:00Z',
+					scopes: ['credential:update'],
+					isManaged: false,
+					data: { anyKey: 'any' } as unknown as string,
+				},
+			];
+			const { getAllByTestId, getByTestId } = renderComponent();
+			await flushPromises();
+			expect(getAllByTestId('resources-list-item').length).toBe(1);
+
+			await fireEvent.click(getByTestId('credential-filter-setup-needed'));
+			await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
+		});
+
+		it('should filter by setupNeeded when object keys are empty', async () => {
+			await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
+			const credentialsStore = mockedStore(useCredentialsStore);
+			credentialsStore.allCredentials = [
+				{
+					id: '1',
+					name: 'credential needs setup',
+					type: 'test',
+					createdAt: '2021-05-05T00:00:00Z',
+					updatedAt: '2021-05-05T00:00:00Z',
+					scopes: ['credential:update'],
+					isManaged: false,
+					data: { anyKey: '' } as unknown as string,
+				},
+				{
+					id: '2',
+					name: 'random',
+					type: 'test',
+					createdAt: '2021-05-05T00:00:00Z',
+					updatedAt: '2021-05-05T00:00:00Z',
+					scopes: ['credential:update'],
+					isManaged: false,
+					data: { anyKey: 'any value' } as unknown as string,
+				},
+			];
+			const { getAllByTestId, getByTestId } = renderComponent();
+			await flushPromises();
+			expect(getAllByTestId('resources-list-item').length).toBe(1);
+			expect(getByTestId('resources-list-item').textContent).toContain('credential needs setup');
+
+			await fireEvent.click(getByTestId('credential-filter-setup-needed'));
+			await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
+		});
+
+		it('should filter by setupNeeded when object keys are "CREDENTIAL_EMPTY_VALUE"', async () => {
+			await router.push({ name: VIEWS.CREDENTIALS, query: { setupNeeded: 'true' } });
+			const credentialsStore = mockedStore(useCredentialsStore);
+			credentialsStore.allCredentials = [
+				{
+					id: '1',
+					name: 'credential needs setup',
+					type: 'test',
+					createdAt: '2021-05-05T00:00:00Z',
+					updatedAt: '2021-05-05T00:00:00Z',
+					scopes: ['credential:update'],
+					isManaged: false,
+					data: { anyKey: CREDENTIAL_EMPTY_VALUE } as unknown as string,
+				},
+				{
+					id: '2',
+					name: 'random',
+					type: 'test',
+					createdAt: '2021-05-05T00:00:00Z',
+					updatedAt: '2021-05-05T00:00:00Z',
+					scopes: ['credential:update'],
+					isManaged: false,
+					data: { anyKey: 'any value' } as unknown as string,
+				},
+			];
+			const { getAllByTestId, getByTestId } = renderComponent();
+			await flushPromises();
+			expect(getAllByTestId('resources-list-item').length).toBe(1);
+			expect(getByTestId('resources-list-item').textContent).toContain('credential needs setup');
+
+			await fireEvent.click(getByTestId('credential-filter-setup-needed'));
+			await waitFor(() => expect(getAllByTestId('resources-list-item').length).toBe(2));
+		});
+	});
 });
diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue
index 14d9feb1e2..3585a3fc24 100644
--- a/packages/editor-ui/src/views/CredentialsView.vue
+++ b/packages/editor-ui/src/views/CredentialsView.vue
@@ -1,13 +1,13 @@
 <script setup lang="ts">
 import { ref, computed, onMounted, watch } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
+import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
 import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
+import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
 import ResourcesListLayout, {
 	type IResource,
 	type IFilters,
 } from '@/components/layouts/ResourcesListLayout.vue';
 import CredentialCard from '@/components/CredentialCard.vue';
-import type { ICredentialType } from 'n8n-workflow';
 import {
 	CREDENTIAL_SELECT_MODAL_KEY,
 	CREDENTIAL_EDIT_MODAL_KEY,
@@ -27,6 +27,9 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
 import { useTelemetry } from '@/composables/useTelemetry';
 import { useI18n } from '@/composables/useI18n';
 import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
+import { N8nCheckbox } from 'n8n-design-system';
+import { pickBy } from 'lodash-es';
+import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
 
 const props = defineProps<{
 	credentialId?: string;
@@ -46,14 +49,26 @@ const router = useRouter();
 const telemetry = useTelemetry();
 const i18n = useI18n();
 
-const filters = ref<IFilters>({
-	search: '',
-	homeProject: '',
-	type: [],
-});
+type Filters = IFilters & { type?: string[]; setupNeeded?: boolean };
+const updateFilter = (state: Filters) => {
+	void router.replace({ query: pickBy(state) as LocationQueryRaw });
+};
 
+const filters = computed<Filters>(
+	() =>
+		({ ...route.query, setupNeeded: route.query.setupNeeded?.toString() === 'true' }) as Filters,
+);
 const loading = ref(false);
 
+const needsSetup = (data: string | undefined): boolean => {
+	const dataObject = data as unknown as ICredentialsDecrypted['data'];
+	if (!dataObject) return false;
+
+	if (Object.keys(dataObject).length === 0) return true;
+
+	return Object.values(dataObject).every((value) => !value || value === CREDENTIAL_EMPTY_VALUE);
+};
+
 const allCredentials = computed<IResource[]>(() =>
 	credentialsStore.allCredentials.map((credential) => ({
 		id: credential.id,
@@ -66,6 +81,7 @@ const allCredentials = computed<IResource[]>(() =>
 		type: credential.type,
 		sharedWithProjects: credential.sharedWithProjects,
 		readOnly: !getResourcePermissions(credential.scopes).credential.update,
+		needsSetup: needsSetup(credential.data),
 	})),
 );
 
@@ -84,7 +100,7 @@ const projectPermissions = computed(() =>
 );
 
 const setRouteCredentialId = (credentialId?: string) => {
-	void router.replace({ params: { credentialId } });
+	void router.replace({ params: { credentialId }, query: route.query });
 };
 
 const addCredential = () => {
@@ -98,7 +114,7 @@ listenForModalChanges({
 	store: uiStore,
 	onModalClosed(modalName) {
 		if ([CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY].includes(modalName as string)) {
-			void router.replace({ params: { credentialId: '' } });
+			void router.replace({ params: { credentialId: '' }, query: route.query });
 		}
 	},
 });
@@ -121,9 +137,9 @@ watch(
 );
 
 const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => {
-	const iResource = resource as ICredentialsResponse;
-	const filtersToApply = newFilters as IFilters & { type: string[] };
-	if (filtersToApply.type.length > 0) {
+	const iResource = resource as ICredentialsResponse & { needsSetup: boolean };
+	const filtersToApply = newFilters as Filters;
+	if (filtersToApply.type && filtersToApply.type.length > 0) {
 		matches = matches && filtersToApply.type.includes(iResource.type);
 	}
 
@@ -136,6 +152,10 @@ const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean):
 				credentialTypesById.value[iResource.type].displayName.toLowerCase().includes(searchString));
 	}
 
+	if (filtersToApply.setupNeeded) {
+		matches = matches && iResource.needsSetup;
+	}
+
 	return matches;
 };
 
@@ -156,6 +176,14 @@ const initialize = async () => {
 	loading.value = false;
 };
 
+credentialsStore.$onAction(({ name, after }) => {
+	if (name === 'createNewCredential') {
+		after(() => {
+			void credentialsStore.fetchAllCredentials(route?.params?.projectId as string | undefined);
+		});
+	}
+});
+
 sourceControlStore.$onAction(({ name, after }) => {
 	if (name !== 'pullWorkfolder') return;
 	after(() => {
@@ -181,7 +209,7 @@ onMounted(() => {
 		:type-props="{ itemSize: 77 }"
 		:loading="loading"
 		:disabled="readOnlyEnv || !projectPermissions.credential.create"
-		@update:filters="filters = $event"
+		@update:filters="updateFilter"
 	>
 		<template #header>
 			<ProjectHeader />
@@ -192,6 +220,7 @@ onMounted(() => {
 				class="mb-2xs"
 				:data="data"
 				:read-only="data.readOnly"
+				:needs-setup="data.needsSetup"
 				@click="setRouteCredentialId"
 			/>
 		</template>
@@ -221,6 +250,23 @@ onMounted(() => {
 					/>
 				</N8nSelect>
 			</div>
+			<div class="mb-s">
+				<N8nInputLabel
+					:label="i18n.baseText('credentials.filters.status')"
+					:bold="false"
+					size="small"
+					color="text-base"
+					class="mb-3xs"
+				/>
+
+				<N8nCheckbox
+					:label="i18n.baseText('credentials.filters.setup')"
+					data-test-id="credential-filter-setup-needed"
+					:model-value="filters.setupNeeded"
+					@update:model-value="setKeyValue('setupNeeded', $event)"
+				>
+				</N8nCheckbox>
+			</div>
 		</template>
 		<template #empty>
 			<n8n-action-box