From 08754cbeb258f55ccbaebf19c0b6839338dc0785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Fri, 12 Dec 2025 10:05:28 +0100 Subject: [PATCH] feat(editor): Add credentials resolvers list UI (#23082) --- .../components/N8nIcon/custom/resolver.svg | 3 + .../src/components/N8nIcon/icons.ts | 6 + .../frontend/@n8n/i18n/src/locales/en.json | 9 + .../CredentialResolverEditModal.vue | 70 ++-- .../src/app/components/WorkflowSettings.vue | 61 +--- .../src/app/composables/useSettingsItems.ts | 8 + .../editor-ui/src/app/constants/navigation.ts | 1 + packages/frontend/editor-ui/src/app/router.ts | 26 ++ .../features/resolvers/ResolversView.test.ts | 341 ++++++++++++++++++ .../src/features/resolvers/ResolversView.vue | 240 ++++++++++++ .../useCredentialResolvers.test.ts | 257 +++++++++++++ .../composables/useCredentialResolvers.ts | 122 +++++++ 12 files changed, 1054 insertions(+), 90 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/resolver.svg create mode 100644 packages/frontend/editor-ui/src/features/resolvers/ResolversView.test.ts create mode 100644 packages/frontend/editor-ui/src/features/resolvers/ResolversView.vue create mode 100644 packages/frontend/editor-ui/src/features/resolvers/composables/useCredentialResolvers.test.ts create mode 100644 packages/frontend/editor-ui/src/features/resolvers/composables/useCredentialResolvers.ts diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/resolver.svg b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/resolver.svg new file mode 100644 index 00000000000..9c24a2b6f74 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/custom/resolver.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 80ab23e0076..9ca08f5aa0e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -17,6 +17,7 @@ import NodeSuccess from './custom/node-success.svg'; import NodeTrash from './custom/node-trash.svg'; import NodeValidationError from './custom/node-validation-error.svg'; import PopOut from './custom/pop-out.svg'; +import Resolver from './custom/resolver.svg'; import Retry from './custom/retry.svg'; import RunOnce from './custom/run-once.svg'; import Schema from './custom/schema.svg'; @@ -43,6 +44,7 @@ import IconLucideArrowRight from '~icons/lucide/arrow-right'; import IconLucideArrowRightFromLine from '~icons/lucide/arrow-right-from-line'; import IconLucideArrowRightToLine from '~icons/lucide/arrow-right-to-line'; import IconLucideArrowUp from '~icons/lucide/arrow-up'; +import IconLucideArrowUpRight from '~icons/lucide/arrow-up-right'; import IconLucideAtSign from '~icons/lucide/at-sign'; import IconLucideBan from '~icons/lucide/ban'; import IconLucideBell from '~icons/lucide/bell'; @@ -187,6 +189,7 @@ import IconLucideShare from '~icons/lucide/share'; import IconLucideSlidersHorizontal from '~icons/lucide/sliders-horizontal'; import IconLucideSmile from '~icons/lucide/smile'; import IconLucideSparkles from '~icons/lucide/sparkles'; +import IconLucideSplit from '~icons/lucide/split'; import IconLucideSquare from '~icons/lucide/square'; import IconLucideSquareCheck from '~icons/lucide/square-check'; import IconLucideSquareMinus from '~icons/lucide/square-minus'; @@ -474,6 +477,7 @@ export const updatedIconSet = { 'node-trash': NodeTrash, mcp: Mcp, lovable: Lovable, + resolver: Resolver, // lucide 'align-right': IconLucideAlignRight, @@ -486,6 +490,7 @@ export const updatedIconSet = { 'arrow-right-from-line': IconLucideArrowRightFromLine, 'arrow-right-to-line': IconLucideArrowRightToLine, 'arrow-up': IconLucideArrowUp, + 'arrow-up-right': IconLucideArrowUpRight, 'at-sign': IconLucideAtSign, ban: IconLucideBan, bell: IconLucideBell, @@ -629,6 +634,7 @@ export const updatedIconSet = { 'sliders-horizontal': IconLucideSlidersHorizontal, smile: IconLucideSmile, sparkles: IconLucideSparkles, + split: IconLucideSplit, square: IconLucideSquare, 'square-check': IconLucideSquareCheck, 'square-minus': IconLucideSquareMinus, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 875882bc3fa..e7b5e3c36e6 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -43,6 +43,7 @@ "generic.allow": "Allow", "generic.deny": "Deny", "generic.docs": "Docs", + "generic.documentation": "Documentation", "generic.back": "Back", "generic.cancel": "Cancel", "generic.connect": "Connect", @@ -3190,6 +3191,14 @@ "workflowSettings.credentialResolver.edit": "Edit resolver", "workflowSettings.credentialResolver.none": "Default - None", "workflowSettings.helpTexts.credentialResolver": "The resolver uses the identity of the user triggering the workflow to pick the right account for all dynamic credentials in this workflow.", + "credentialResolver.addNew": "Add Resolver", + "credentialResolver.view.title": "Credential resolvers", + "credentialResolver.view.description": "Resolvers take an incoming user identity and pick the account used by dynamic credentials when the workflow runs. Select a resolver in Workflow settings.", + "credentialResolver.view.learnMore": "You can learn more in the ", + "credentialResolver.action.edit": "Edit", + "credentialResolver.action.delete": "Delete", + "credentialResolver.item.updated": "Last updated", + "credentialResolver.item.created": "Created", "credentialResolverEdit.title.create": "Create Credential Resolver", "credentialResolverEdit.title.edit": "Edit Credential Resolver", "credentialResolverEdit.saveSuccess.title": "Credential Resolver was saved successfully", diff --git a/packages/frontend/editor-ui/src/app/components/CredentialResolverEditModal.vue b/packages/frontend/editor-ui/src/app/components/CredentialResolverEditModal.vue index 3699bdad050..917066b73e6 100644 --- a/packages/frontend/editor-ui/src/app/components/CredentialResolverEditModal.vue +++ b/packages/frontend/editor-ui/src/app/components/CredentialResolverEditModal.vue @@ -12,20 +12,18 @@ import { } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import { useToast } from '@/app/composables/useToast'; -import { useMessage } from '@/app/composables/useMessage'; -import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, MODAL_CONFIRM } from '../constants'; +import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY } from '../constants'; import { createEventBus } from '@n8n/utils/event-bus'; import Modal from './Modal.vue'; import SaveButton from './SaveButton.vue'; import { useRootStore } from '@n8n/stores/useRootStore'; -import type { CredentialResolverType, CredentialResolver } from '@n8n/api-types'; +import type { CredentialResolver } from '@n8n/api-types'; import { - getCredentialResolverTypes, getCredentialResolver, createCredentialResolver, updateCredentialResolver, - deleteCredentialResolver, } from '@n8n/rest-api-client'; +import { useCredentialResolvers } from '@/features/resolvers/composables/useCredentialResolvers'; import type { INodeProperties, ICredentialDataDecryptedObject, @@ -46,21 +44,25 @@ const props = defineProps<{ const modalBus = createEventBus(); const i18n = useI18n(); const toast = useToast(); -const message = useMessage(); const rootStore = useRootStore(); const activeTab = ref('configuration'); const isLoading = ref(false); const isSaving = ref(false); -const isDeleting = ref(false); const resolverName = ref(''); const resolverType = ref(''); const resolverConfig = ref>({}); -const availableTypes = ref([]); const hasUnsavedChanges = ref(false); const errorMessage = ref(''); const mainContentRef = ref(); +const { + resolverTypes: availableTypes, + fetchResolverTypes: loadResolverTypes, + deleteResolver: deleteResolverFromComposable, + isDeleting, +} = useCredentialResolvers(); + const isEditMode = computed(() => !!props.data?.resolverId); // Type guard to validate and convert resolver config to credential data @@ -174,14 +176,6 @@ const sidebarItems = computed(() => [ }, ]); -const loadResolverTypes = async () => { - try { - availableTypes.value = await getCredentialResolverTypes(rootStore.restApiContext); - } catch (error) { - toast.showError(error, i18n.baseText('credentialResolverEdit.error.loadTypes')); - } -}; - const loadResolver = async () => { if (!props.data?.resolverId) return; @@ -275,45 +269,25 @@ const onTabSelect = (tabId: string) => { const deleteResolver = async () => { if (!props.data?.resolverId) return; - const savedResolverName = resolverName.value; + // Create a resolver object for the composable + const resolver: CredentialResolver = { + id: props.data.resolverId, + name: resolverName.value, + type: resolverType.value, + config: JSON.stringify(resolverConfig.value), + createdAt: new Date(), + updatedAt: new Date(), + }; - const deleteConfirmed = await message.confirm( - i18n.baseText('credentialResolverEdit.confirmMessage.deleteResolver.message', { - interpolate: { savedResolverName }, - }), - i18n.baseText('credentialResolverEdit.confirmMessage.deleteResolver.headline'), - { - confirmButtonText: i18n.baseText( - 'credentialResolverEdit.confirmMessage.deleteResolver.confirmButtonText', - ), - }, - ); + const deleted = await deleteResolverFromComposable(resolver); - if (deleteConfirmed !== MODAL_CONFIRM) { - return; - } - - try { - isDeleting.value = true; - await deleteCredentialResolver(rootStore.restApiContext, props.data.resolverId); + if (deleted) { hasUnsavedChanges.value = false; - if (props.data?.onDelete) { props.data.onDelete(props.data.resolverId); } - } catch (error) { - toast.showError(error, i18n.baseText('credentialResolverEdit.error.delete')); - isDeleting.value = false; - return; + modalBus.emit('close'); } - - isDeleting.value = false; - modalBus.emit('close'); - - toast.showMessage({ - title: i18n.baseText('credentialResolverEdit.deleteSuccess.title'), - type: 'success', - }); }; onMounted(async () => { diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index 2af80f8d9a8..6fb68bbcce0 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -40,10 +40,7 @@ import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp'; import { useGlobalLinkActions } from '@/app/composables/useGlobalLinkActions'; import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store'; import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag'; -import { getCredentialResolvers } from '@n8n/rest-api-client'; -import type { CredentialResolver } from '@n8n/api-types'; -import { useUIStore } from '@/app/stores/ui.store'; -import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY } from '../constants'; +import { useCredentialResolvers } from '@/features/resolvers/composables/useCredentialResolvers'; import { ElCol, ElRow, ElSwitch } from 'element-plus'; @@ -64,7 +61,6 @@ const workflowsStore = useWorkflowsStore(); const workflowState = injectWorkflowState(); const workflowsEEStore = useWorkflowsEEStore(); const nodeCreatorStore = useNodeCreatorStore(); -const uiStore = useUIStore(); const isLoading = ref(true); const workflowCallerPolicyOptions = ref>([]); @@ -79,8 +75,14 @@ const executionOrderOptions = ref>([ const timezones = ref>([]); const workflowSettings = ref({} as IWorkflowSettings); const workflows = ref([]); -const credentialResolvers = ref([]); const credentialResolverSelectRef = ref | null>(null); + +const { + resolvers: credentialResolvers, + fetchResolvers: loadCredentialResolvers, + openCreateModal, + openEditModal, +} = useCredentialResolvers(); const executionTimeout = ref(0); const maxExecutionTimeout = ref(0); const timeoutHMS = ref({ hours: 0, minutes: 0, seconds: 0 }); @@ -368,30 +370,15 @@ const loadWorkflows = async (searchTerm?: string) => { workflows.value = workflowsData; }; -const loadCredentialResolvers = async () => { - try { - const resolvers = await getCredentialResolvers(rootStore.restApiContext); - credentialResolvers.value = resolvers; - } catch (error) { - toast.showError(error, i18n.baseText('workflowSettings.showError.fetchSettings.title')); - } -}; - const handleCreateNewResolver = async () => { // Close the dropdown first credentialResolverSelectRef.value?.blur(); await nextTick(); - // Open modal with callback - uiStore.openModalWithData({ - name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, - data: { - onSave: async (resolverId: string) => { - // Reload resolvers list - await loadCredentialResolvers(); - // Set the newly created resolver - workflowSettings.value.credentialResolverId = resolverId; - }, + openCreateModal({ + onSave: async (resolverId: string) => { + await loadCredentialResolvers(); + workflowSettings.value.credentialResolverId = resolverId; }, }); }; @@ -399,23 +386,13 @@ const handleCreateNewResolver = async () => { const handleEditResolver = async () => { if (!workflowSettings.value.credentialResolverId) return; - // Open modal in edit mode - uiStore.openModalWithData({ - name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, - data: { - resolverId: workflowSettings.value.credentialResolverId, - onSave: async () => { - // Reload resolvers list after editing - await loadCredentialResolvers(); - }, - onDelete: async (deletedResolverId: string) => { - // Reload resolvers list after deletion - await loadCredentialResolvers(); - // If the deleted resolver was selected, reset to None - if (workflowSettings.value.credentialResolverId === deletedResolverId) { - workflowSettings.value.credentialResolverId = undefined; - } - }, + openEditModal(workflowSettings.value.credentialResolverId, { + onSave: loadCredentialResolvers, + onDelete: async (deletedResolverId: string) => { + await loadCredentialResolvers(); + if (workflowSettings.value.credentialResolverId === deletedResolverId) { + workflowSettings.value.credentialResolverId = undefined; + } }, }); }; diff --git a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts index 7b90a926e1a..9222619ad1f 100644 --- a/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts +++ b/packages/frontend/editor-ui/src/app/composables/useSettingsItems.ts @@ -65,6 +65,14 @@ export function useSettingsItems() { available: canUserAccessRouteByName(VIEWS.EXTERNAL_SECRETS_SETTINGS), route: { to: { name: VIEWS.EXTERNAL_SECRETS_SETTINGS } }, }, + { + id: 'settings-credential-resolvers', + icon: 'key-round', + label: i18n.baseText('credentialResolver.view.title'), + position: 'top', + available: canUserAccessRouteByName(VIEWS.RESOLVERS), + route: { to: { name: VIEWS.RESOLVERS } }, + }, { id: 'settings-source-control', icon: 'git-branch', diff --git a/packages/frontend/editor-ui/src/app/constants/navigation.ts b/packages/frontend/editor-ui/src/app/constants/navigation.ts index 415c3da9e3e..2cbba0f410d 100644 --- a/packages/frontend/editor-ui/src/app/constants/navigation.ts +++ b/packages/frontend/editor-ui/src/app/constants/navigation.ts @@ -64,6 +64,7 @@ export const enum VIEWS { OAUTH_CONSENT = 'OAuthConsent', MIGRATION_REPORT = 'MigrationReport', MIGRATION_RULE_REPORT = 'MigrationRuleReport', + RESOLVERS = 'Resolvers', } export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG]; diff --git a/packages/frontend/editor-ui/src/app/router.ts b/packages/frontend/editor-ui/src/app/router.ts index 3ee612ec785..4b4004e2a6a 100644 --- a/packages/frontend/editor-ui/src/app/router.ts +++ b/packages/frontend/editor-ui/src/app/router.ts @@ -23,6 +23,7 @@ import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers'; import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources'; import { usePostHog } from '@/app/stores/posthog.store'; import { TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants/experiments'; +import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag'; const ChangePasswordView = async () => await import('@/features/core/auth/views/ChangePasswordView.vue'); @@ -52,6 +53,7 @@ const SettingsPersonalView = async () => await import('@/features/core/auth/views/SettingsPersonalView.vue'); const SettingsUsersView = async () => await import('@/features/settings/users/views/SettingsUsersView.vue'); +const SettingsResolversView = async () => await import('@/features/resolvers/ResolversView.vue'); const SettingsCommunityNodesView = async () => await import('@/features/settings/communityNodes/views/SettingsCommunityNodesView.vue'); const SettingsApiView = async () => @@ -640,6 +642,30 @@ export const routes: RouteRecordRaw[] = [ }, }, }, + { + path: 'resolvers', + name: VIEWS.RESOLVERS, + components: { + settingsView: SettingsResolversView, + }, + meta: { + middleware: ['authenticated', 'custom'], + middlewareOptions: { + custom: () => { + const { check } = useEnvFeatureFlag(); + return check.value('DYNAMIC_CREDENTIALS'); + }, + }, + telemetry: { + pageCategory: 'settings', + getProperties() { + return { + feature: 'resolvers', + }; + }, + }, + }, + }, { path: 'project-roles', components: { diff --git a/packages/frontend/editor-ui/src/features/resolvers/ResolversView.test.ts b/packages/frontend/editor-ui/src/features/resolvers/ResolversView.test.ts new file mode 100644 index 00000000000..a705857505b --- /dev/null +++ b/packages/frontend/editor-ui/src/features/resolvers/ResolversView.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; +import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore, type MockedStore } from '@/__tests__/utils'; +import { waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; + +import ResolversView from './ResolversView.vue'; +import { useUIStore } from '@/app/stores/ui.store'; +import { useRootStore } from '@n8n/stores/useRootStore'; +import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, MODAL_CONFIRM, MODAL_CANCEL } from '@/app/constants'; +import * as restApiClient from '@n8n/rest-api-client'; +import type { CredentialResolver, CredentialResolverType } from '@n8n/api-types'; + +const mockConfirm = vi.fn(); +const mockShowError = vi.fn(); +const mockShowMessage = vi.fn(); + +vi.mock('@/app/composables/useMessage', () => ({ + useMessage: () => ({ + confirm: mockConfirm, + }), +})); + +vi.mock('@/app/composables/useToast', () => ({ + useToast: () => ({ + showError: mockShowError, + showMessage: mockShowMessage, + }), +})); + +vi.mock('@n8n/rest-api-client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCredentialResolvers: vi.fn(), + getCredentialResolverTypes: vi.fn(), + deleteCredentialResolver: vi.fn(), + }; +}); + +const mockResolvers: CredentialResolver[] = [ + { + id: 'resolver-1', + name: 'Test Resolver', + type: 'test-type', + config: '{}', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-06-20'), + }, + { + id: 'resolver-2', + name: 'Another Resolver', + type: 'another-type', + config: '{}', + createdAt: new Date('2024-03-10'), + updatedAt: new Date('2024-07-15'), + }, +]; + +const mockResolverTypes: CredentialResolverType[] = [ + { + name: 'test-type', + displayName: 'Test Type Display Name', + description: 'A test resolver type', + }, + { + name: 'another-type', + displayName: 'Another Type Display', + description: 'Another resolver type', + }, +]; + +const renderComponent = createComponentRenderer(ResolversView); + +describe('ResolversView', () => { + let pinia: ReturnType; + let uiStore: MockedStore; + let rootStore: MockedStore; + + beforeEach(() => { + pinia = createTestingPinia(); + uiStore = mockedStore(useUIStore); + rootStore = mockedStore(useRootStore); + + rootStore.restApiContext = { + baseUrl: 'http://localhost', + pushRef: 'test-ref', + }; + + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([]); + vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Loading state', () => { + it('should show loading skeleton when data is being fetched', async () => { + // Create a promise that never resolves to simulate loading state + vi.mocked(restApiClient.getCredentialResolvers).mockImplementation( + async () => await new Promise(() => {}), + ); + + const { container } = renderComponent({ pinia }); + + // The loading component should be visible + await waitFor(() => { + expect(container.querySelector('.n8n-loading')).toBeInTheDocument(); + }); + }); + }); + + describe('Empty state', () => { + it('should display empty state when no resolvers exist', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([]); + + const { getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Resolve dynamic credentials from user identity')).toBeInTheDocument(); + }); + }); + + it('should open create modal when clicking "Add new" button in empty state', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([]); + + const { getByText } = renderComponent({ pinia }); + + // Wait for empty state to be displayed (after loading completes) + await waitFor(() => { + expect(getByText('Resolve dynamic credentials from user identity')).toBeInTheDocument(); + }); + + // Now the Add Resolver button should be available + await waitFor(() => { + expect(getByText('Add Resolver')).toBeInTheDocument(); + }); + + const addButton = getByText('Add Resolver'); + await userEvent.click(addButton); + + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + onSave: expect.any(Function), + }, + }); + }); + }); + + describe('List view', () => { + it('should display resolver cards when resolvers exist', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers); + + const { getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Test Resolver')).toBeInTheDocument(); + expect(getByText('Another Resolver')).toBeInTheDocument(); + }); + }); + + it('should display resolver type display name from types list', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers); + vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes); + + const { getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText(/Test Type Display Name/)).toBeInTheDocument(); + }); + }); + + it('should display add button in list view header', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers); + + const { getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Add Resolver')).toBeInTheDocument(); + }); + }); + + it('should open create modal when clicking add button in list view', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers); + + const { getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Test Resolver')).toBeInTheDocument(); + }); + + const addButton = getByText('Add Resolver'); + await userEvent.click(addButton); + + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + onSave: expect.any(Function), + }, + }); + }); + }); + + describe('Actions', () => { + it('should open edit modal when clicking on a resolver card', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers); + + const { getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Test Resolver')).toBeInTheDocument(); + }); + + await userEvent.click(getByText('Test Resolver')); + + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + resolverId: 'resolver-1', + onSave: expect.any(Function), + onDelete: expect.any(Function), + }, + }); + }); + + it('should show confirmation dialog when deleting a resolver', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([mockResolvers[0]]); + mockConfirm.mockResolvedValue(MODAL_CANCEL); + + const { container, getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Test Resolver')).toBeInTheDocument(); + }); + + // Find and click the action toggle button + const actionToggle = container.querySelector('[data-test-id="action-toggle"]'); + expect(actionToggle).toBeInTheDocument(); + await userEvent.click(actionToggle!); + + // Wait for dropdown to appear and click delete + await waitFor(() => { + const deleteOption = getByText('Delete'); + expect(deleteOption).toBeInTheDocument(); + }); + await userEvent.click(getByText('Delete')); + + expect(mockConfirm).toHaveBeenCalled(); + }); + + it('should delete resolver when confirmed', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([mockResolvers[0]]); + vi.mocked(restApiClient.deleteCredentialResolver).mockResolvedValue(undefined); + mockConfirm.mockResolvedValue(MODAL_CONFIRM); + + const { container, getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Test Resolver')).toBeInTheDocument(); + }); + + // Find and click the action toggle button + const actionToggle = container.querySelector('[data-test-id="action-toggle"]'); + await userEvent.click(actionToggle!); + + await waitFor(() => { + expect(getByText('Delete')).toBeInTheDocument(); + }); + await userEvent.click(getByText('Delete')); + + await waitFor(() => { + expect(restApiClient.deleteCredentialResolver).toHaveBeenCalledWith( + rootStore.restApiContext, + 'resolver-1', + ); + }); + + expect(mockShowMessage).toHaveBeenCalledWith({ + title: expect.any(String), + type: 'success', + }); + }); + + it('should not delete resolver when cancelled', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([mockResolvers[0]]); + mockConfirm.mockResolvedValue(MODAL_CANCEL); + + const { container, getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Test Resolver')).toBeInTheDocument(); + }); + + // Find and click the action toggle button + const actionToggle = container.querySelector('[data-test-id="action-toggle"]'); + await userEvent.click(actionToggle!); + + await waitFor(() => { + expect(getByText('Delete')).toBeInTheDocument(); + }); + await userEvent.click(getByText('Delete')); + + await waitFor(() => { + expect(mockConfirm).toHaveBeenCalled(); + }); + + expect(restApiClient.deleteCredentialResolver).not.toHaveBeenCalled(); + }); + }); + + describe('Error handling', () => { + it('should show error toast when delete fails', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([mockResolvers[0]]); + const error = new Error('Delete failed'); + vi.mocked(restApiClient.deleteCredentialResolver).mockRejectedValue(error); + mockConfirm.mockResolvedValue(MODAL_CONFIRM); + + const { container, getByText } = renderComponent({ pinia }); + + await waitFor(() => { + expect(getByText('Test Resolver')).toBeInTheDocument(); + }); + + // Find and click the action toggle button + const actionToggle = container.querySelector('[data-test-id="action-toggle"]'); + await userEvent.click(actionToggle!); + + await waitFor(() => { + expect(getByText('Delete')).toBeInTheDocument(); + }); + await userEvent.click(getByText('Delete')); + + await waitFor(() => { + expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String)); + }); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/resolvers/ResolversView.vue b/packages/frontend/editor-ui/src/features/resolvers/ResolversView.vue new file mode 100644 index 00000000000..d31d0168768 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/resolvers/ResolversView.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/resolvers/composables/useCredentialResolvers.test.ts b/packages/frontend/editor-ui/src/features/resolvers/composables/useCredentialResolvers.test.ts new file mode 100644 index 00000000000..f5c4ac136a1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/resolvers/composables/useCredentialResolvers.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; +import { useCredentialResolvers } from './useCredentialResolvers'; +import { useUIStore } from '@/app/stores/ui.store'; +import { useRootStore } from '@n8n/stores/useRootStore'; +import { mockedStore } from '@/__tests__/utils'; +import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, MODAL_CONFIRM, MODAL_CANCEL } from '@/app/constants'; +import * as restApiClient from '@n8n/rest-api-client'; +import type { CredentialResolver, CredentialResolverType } from '@n8n/api-types'; + +const mockConfirm = vi.fn(); +const mockShowError = vi.fn(); +const mockShowMessage = vi.fn(); + +vi.mock('@/app/composables/useMessage', () => ({ + useMessage: () => ({ + confirm: mockConfirm, + }), +})); + +vi.mock('@/app/composables/useToast', () => ({ + useToast: () => ({ + showError: mockShowError, + showMessage: mockShowMessage, + }), +})); + +vi.mock('@n8n/rest-api-client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCredentialResolvers: vi.fn(), + getCredentialResolverTypes: vi.fn(), + deleteCredentialResolver: vi.fn(), + }; +}); + +const mockResolvers: CredentialResolver[] = [ + { + id: 'resolver-1', + name: 'Test Resolver', + type: 'test-type', + config: '{}', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-06-20'), + }, +]; + +const mockResolverTypes: CredentialResolverType[] = [ + { + name: 'test-type', + displayName: 'Test Type Display Name', + description: 'A test resolver type', + }, +]; + +describe('useCredentialResolvers', () => { + beforeEach(() => { + setActivePinia(createTestingPinia()); + + const rootStore = mockedStore(useRootStore); + rootStore.restApiContext = { + baseUrl: 'http://localhost', + pushRef: 'test-ref', + }; + + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([]); + vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue([]); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('fetchResolvers', () => { + it('should fetch resolvers and update state', async () => { + vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers); + + const { resolvers, isLoading, fetchResolvers } = useCredentialResolvers(); + + expect(resolvers.value).toEqual([]); + expect(isLoading.value).toBe(false); + + const fetchPromise = fetchResolvers(); + expect(isLoading.value).toBe(true); + + await fetchPromise; + + expect(resolvers.value).toEqual(mockResolvers); + expect(isLoading.value).toBe(false); + }); + + it('should show error toast when fetch fails', async () => { + const error = new Error('Fetch failed'); + vi.mocked(restApiClient.getCredentialResolvers).mockRejectedValue(error); + + const { fetchResolvers } = useCredentialResolvers(); + + await fetchResolvers(); + + expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String)); + }); + }); + + describe('fetchResolverTypes', () => { + it('should fetch resolver types and update state', async () => { + vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes); + + const { resolverTypes, fetchResolverTypes } = useCredentialResolvers(); + + expect(resolverTypes.value).toEqual([]); + + await fetchResolverTypes(); + + expect(resolverTypes.value).toEqual(mockResolverTypes); + }); + + it('should show error toast when fetch fails', async () => { + const error = new Error('Fetch types failed'); + vi.mocked(restApiClient.getCredentialResolverTypes).mockRejectedValue(error); + + const { fetchResolverTypes } = useCredentialResolvers(); + + await fetchResolverTypes(); + + expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String)); + }); + }); + + describe('deleteResolver', () => { + it('should show confirmation dialog before deleting', async () => { + mockConfirm.mockResolvedValue(MODAL_CANCEL); + + const { deleteResolver } = useCredentialResolvers(); + + await deleteResolver(mockResolvers[0]); + + expect(mockConfirm).toHaveBeenCalled(); + expect(restApiClient.deleteCredentialResolver).not.toHaveBeenCalled(); + }); + + it('should delete resolver when confirmed', async () => { + mockConfirm.mockResolvedValue(MODAL_CONFIRM); + vi.mocked(restApiClient.deleteCredentialResolver).mockResolvedValue(undefined); + + const { deleteResolver, isDeleting } = useCredentialResolvers(); + + const result = await deleteResolver(mockResolvers[0]); + + expect(result).toBe(true); + expect(restApiClient.deleteCredentialResolver).toHaveBeenCalledWith( + expect.any(Object), + 'resolver-1', + ); + expect(mockShowMessage).toHaveBeenCalledWith({ + title: expect.any(String), + type: 'success', + }); + expect(isDeleting.value).toBe(false); + }); + + it('should return false when delete is cancelled', async () => { + mockConfirm.mockResolvedValue(MODAL_CANCEL); + + const { deleteResolver } = useCredentialResolvers(); + + const result = await deleteResolver(mockResolvers[0]); + + expect(result).toBe(false); + }); + + it('should show error toast when delete fails', async () => { + mockConfirm.mockResolvedValue(MODAL_CONFIRM); + const error = new Error('Delete failed'); + vi.mocked(restApiClient.deleteCredentialResolver).mockRejectedValue(error); + + const { deleteResolver } = useCredentialResolvers(); + + const result = await deleteResolver(mockResolvers[0]); + + expect(result).toBe(false); + expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String)); + }); + }); + + describe('openCreateModal', () => { + it('should open modal with default callback', () => { + const uiStore = useUIStore(); + + const { openCreateModal } = useCredentialResolvers(); + + openCreateModal(); + + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + onSave: expect.any(Function), + }, + }); + }); + + it('should open modal with custom callback', () => { + const uiStore = useUIStore(); + const customOnSave = vi.fn(); + + const { openCreateModal } = useCredentialResolvers(); + + openCreateModal({ onSave: customOnSave }); + + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + onSave: customOnSave, + }, + }); + }); + }); + + describe('openEditModal', () => { + it('should open modal with resolver id and default callbacks', () => { + const uiStore = useUIStore(); + + const { openEditModal } = useCredentialResolvers(); + + openEditModal('resolver-1'); + + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + resolverId: 'resolver-1', + onSave: expect.any(Function), + onDelete: expect.any(Function), + }, + }); + }); + + it('should open modal with custom callbacks', () => { + const uiStore = useUIStore(); + const customOnSave = vi.fn(); + const customOnDelete = vi.fn(); + + const { openEditModal } = useCredentialResolvers(); + + openEditModal('resolver-1', { onSave: customOnSave, onDelete: customOnDelete }); + + expect(uiStore.openModalWithData).toHaveBeenCalledWith({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + resolverId: 'resolver-1', + onSave: customOnSave, + onDelete: customOnDelete, + }, + }); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/resolvers/composables/useCredentialResolvers.ts b/packages/frontend/editor-ui/src/features/resolvers/composables/useCredentialResolvers.ts new file mode 100644 index 00000000000..3faebaf486d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/resolvers/composables/useCredentialResolvers.ts @@ -0,0 +1,122 @@ +import { ref } from 'vue'; +import type { CredentialResolver, CredentialResolverType } from '@n8n/api-types'; +import { + getCredentialResolvers, + getCredentialResolverTypes, + deleteCredentialResolver, +} from '@n8n/rest-api-client'; +import { useRootStore } from '@n8n/stores/useRootStore'; +import { useUIStore } from '@/app/stores/ui.store'; +import { useMessage } from '@/app/composables/useMessage'; +import { useToast } from '@/app/composables/useToast'; +import { useI18n } from '@n8n/i18n'; +import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, MODAL_CONFIRM } from '@/app/constants'; + +export interface ModalCallbacks { + onSave?: (resolverId: string) => void | Promise; + onDelete?: (resolverId: string) => void | Promise; +} + +export function useCredentialResolvers() { + const rootStore = useRootStore(); + const uiStore = useUIStore(); + const message = useMessage(); + const toast = useToast(); + const i18n = useI18n(); + + const resolvers = ref([]); + const resolverTypes = ref([]); + const isLoading = ref(false); + const isDeleting = ref(false); + + const fetchResolvers = async (): Promise => { + try { + isLoading.value = true; + resolvers.value = await getCredentialResolvers(rootStore.restApiContext); + } catch (error) { + toast.showError(error, i18n.baseText('workflowSettings.showError.fetchSettings.title')); + } finally { + isLoading.value = false; + } + }; + + const fetchResolverTypes = async (): Promise => { + try { + resolverTypes.value = await getCredentialResolverTypes(rootStore.restApiContext); + } catch (error) { + toast.showError(error, i18n.baseText('credentialResolverEdit.error.loadTypes')); + } + }; + + const confirmAndDeleteResolver = async (resolver: CredentialResolver): Promise => { + const deleteConfirmed = await message.confirm( + i18n.baseText('credentialResolverEdit.confirmMessage.deleteResolver.message', { + interpolate: { savedResolverName: resolver.name }, + }), + i18n.baseText('credentialResolverEdit.confirmMessage.deleteResolver.headline'), + { + confirmButtonText: i18n.baseText( + 'credentialResolverEdit.confirmMessage.deleteResolver.confirmButtonText', + ), + }, + ); + + if (deleteConfirmed !== MODAL_CONFIRM) { + return false; + } + + try { + isDeleting.value = true; + await deleteCredentialResolver(rootStore.restApiContext, resolver.id); + + toast.showMessage({ + title: i18n.baseText('credentialResolverEdit.deleteSuccess.title'), + type: 'success', + }); + + return true; + } catch (error) { + toast.showError(error, i18n.baseText('credentialResolverEdit.error.delete')); + return false; + } finally { + isDeleting.value = false; + } + }; + + const openCreateModal = (callbacks?: ModalCallbacks): void => { + uiStore.openModalWithData({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + onSave: callbacks?.onSave ?? fetchResolvers, + }, + }); + }; + + const openEditModal = (resolverId: string, callbacks?: ModalCallbacks): void => { + uiStore.openModalWithData({ + name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, + data: { + resolverId, + onSave: callbacks?.onSave ?? fetchResolvers, + onDelete: callbacks?.onDelete ?? fetchResolvers, + }, + }); + }; + + return { + // State + resolvers, + resolverTypes, + isLoading, + isDeleting, + + // Actions + fetchResolvers, + fetchResolverTypes, + deleteResolver: confirmAndDeleteResolver, + + // Modal helpers + openCreateModal, + openEditModal, + }; +}