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