mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(editor): Add credentials resolvers list UI (#23082)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M23.0877 0H13.9944L16.8048 8.75784H25.8982L18.5411 13.9805L21.3523 22.788C26.0879 19.3383 27.6352 14.1173 25.899 8.75784L23.0877 0ZM2.09058 8.75784H11.1839L13.9944 0H4.90182L2.09058 8.75784C0.353512 14.1173 1.90165 19.3383 6.63727 22.788L9.44769 13.9805L2.09058 8.75784ZM6.63727 22.788L13.9944 28L21.3515 22.788L13.9944 17.4896L6.63727 22.788Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<string, unknown>>({});
|
||||
const availableTypes = ref<CredentialResolverType[]>([]);
|
||||
const hasUnsavedChanges = ref(false);
|
||||
const errorMessage = ref<string>('');
|
||||
const mainContentRef = ref<HTMLElement>();
|
||||
|
||||
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<IMenuItem[]>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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<Array<{ key: string; value: string }>>([]);
|
||||
@@ -79,8 +75,14 @@ const executionOrderOptions = ref<Array<{ key: string; value: string }>>([
|
||||
const timezones = ref<Array<{ key: string; value: string }>>([]);
|
||||
const workflowSettings = ref<IWorkflowSettings>({} as IWorkflowSettings);
|
||||
const workflows = ref<IWorkflowShortResponse[]>([]);
|
||||
const credentialResolvers = ref<CredentialResolver[]>([]);
|
||||
const credentialResolverSelectRef = ref<InstanceType<typeof N8nSelect> | null>(null);
|
||||
|
||||
const {
|
||||
resolvers: credentialResolvers,
|
||||
fetchResolvers: loadCredentialResolvers,
|
||||
openCreateModal,
|
||||
openEditModal,
|
||||
} = useCredentialResolvers();
|
||||
const executionTimeout = ref(0);
|
||||
const maxExecutionTimeout = ref(0);
|
||||
const timeoutHMS = ref<ITimeoutHMS>({ 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof restApiClient>();
|
||||
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<typeof createTestingPinia>;
|
||||
let uiStore: MockedStore<typeof useUIStore>;
|
||||
let rootStore: MockedStore<typeof useRootStore>;
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
import type { CredentialResolver } from '@n8n/api-types';
|
||||
import {
|
||||
N8nActionBox,
|
||||
N8nActionToggle,
|
||||
N8nButton,
|
||||
N8nCard,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nLink,
|
||||
N8nLoading2,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import dateformat from 'dateformat';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useCredentialResolvers } from './composables/useCredentialResolvers';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const {
|
||||
resolvers,
|
||||
resolverTypes,
|
||||
isLoading,
|
||||
fetchResolvers,
|
||||
fetchResolverTypes,
|
||||
deleteResolver,
|
||||
openCreateModal,
|
||||
openEditModal,
|
||||
} = useCredentialResolvers();
|
||||
|
||||
// TODO: use actual docs link when available
|
||||
const docsUrl = 'https://docs.n8n.io/';
|
||||
|
||||
const RESOLVER_LIST_ITEM_ACTIONS = {
|
||||
EDIT: 'edit',
|
||||
DELETE: 'delete',
|
||||
} as const;
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchResolvers(), fetchResolverTypes()]);
|
||||
});
|
||||
|
||||
const currentYear = new Date().getFullYear().toString();
|
||||
|
||||
function getDateFormat(date: Date): string {
|
||||
return `d mmmm${String(date).startsWith(currentYear) ? '' : ', yyyy'}`;
|
||||
}
|
||||
|
||||
const actions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: i18n.baseText('credentialResolver.action.edit'),
|
||||
value: RESOLVER_LIST_ITEM_ACTIONS.EDIT,
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('credentialResolver.action.delete'),
|
||||
value: RESOLVER_LIST_ITEM_ACTIONS.DELETE,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function createResolver() {
|
||||
openCreateModal();
|
||||
}
|
||||
|
||||
function editResolver(resolver: CredentialResolver) {
|
||||
openEditModal(resolver.id);
|
||||
}
|
||||
|
||||
async function handleDeleteResolver(resolver: CredentialResolver) {
|
||||
const deleted = await deleteResolver(resolver);
|
||||
if (deleted) {
|
||||
void fetchResolvers();
|
||||
}
|
||||
}
|
||||
|
||||
async function onAction(action: string, resolver: CredentialResolver) {
|
||||
switch (action) {
|
||||
case RESOLVER_LIST_ITEM_ACTIONS.EDIT:
|
||||
editResolver(resolver);
|
||||
break;
|
||||
case RESOLVER_LIST_ITEM_ACTIONS.DELETE:
|
||||
await handleDeleteResolver(resolver);
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div class="mb-xl" :class="$style.headerContainer">
|
||||
<div :class="$style.headerTitle">
|
||||
<N8nHeading tag="h1" size="2xlarge">
|
||||
{{ i18n.baseText('credentialResolver.view.title') }}
|
||||
</N8nHeading>
|
||||
<N8nText v-if="resolvers.length" color="text-base" size="medium">
|
||||
{{ i18n.baseText('credentialResolver.view.description') }}
|
||||
{{ i18n.baseText('credentialResolver.view.learnMore') }}
|
||||
<N8nLink theme="text" :href="docsUrl" size="medium" new-window>
|
||||
<span :class="$style.link">
|
||||
{{ i18n.baseText('generic.documentation') }}
|
||||
<N8nIcon icon="arrow-up-right" />
|
||||
</span>
|
||||
</N8nLink>
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<N8nLoading2 v-if="isLoading && resolvers.length === 0" :rows="5" :shrink-last="false" />
|
||||
<div v-else-if="resolvers.length === 0">
|
||||
<N8nActionBox class="mt-2xl mb-l" description="yes">
|
||||
<template #description>
|
||||
<div :class="$style.iconCardContainer">
|
||||
<div :class="$style.iconCard"><N8nIcon icon="key-round" /></div>
|
||||
<div :class="$style.iconCard"><N8nIcon icon="split" /></div>
|
||||
<div :class="$style.iconCard"><N8nIcon icon="user" /></div>
|
||||
</div>
|
||||
<N8nHeading tag="h2" size="medium" align="center" class="mb-2xs">
|
||||
Resolve dynamic credentials from user identity
|
||||
</N8nHeading>
|
||||
<div>
|
||||
{{ i18n.baseText('credentialResolver.view.description') }}
|
||||
</div>
|
||||
</template>
|
||||
<template #additionalContent>
|
||||
<N8nButton type="highlight" class="mr-2xs" element="a" :href="docsUrl" target="_blank">
|
||||
Learn more <N8nIcon icon="arrow-up-right" />
|
||||
</N8nButton>
|
||||
<N8nButton type="primary" @click="createResolver">
|
||||
{{ i18n.baseText('credentialResolver.addNew') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</N8nActionBox>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div :class="$style.actionBar">
|
||||
<N8nButton class="ml-auto" type="primary" icon="plus" @click="createResolver">
|
||||
{{ i18n.baseText('credentialResolver.addNew') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<N8nCard
|
||||
v-for="resolver in resolvers"
|
||||
:key="resolver.id"
|
||||
class="mb-2xs"
|
||||
hoverable
|
||||
@click.stop="editResolver(resolver)"
|
||||
>
|
||||
<template #prepend>
|
||||
<N8nIcon icon="resolver" color="text-dark" :size="28" />
|
||||
</template>
|
||||
<template #header>
|
||||
<N8nText tag="h2" bold> {{ resolver.name }} </N8nText>
|
||||
</template>
|
||||
<div :class="$style.cardDescription">
|
||||
<N8nText color="text-light" size="small">
|
||||
{{
|
||||
resolverTypes.find(({ name }) => name === resolver.type)?.displayName || resolver.type
|
||||
}}
|
||||
|
|
||||
</N8nText>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('credentialResolver.item.updated') }}
|
||||
<TimeAgo :date="resolver.updatedAt.toString()" /> |
|
||||
</N8nText>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('credentialResolver.item.created') }}
|
||||
{{ dateformat(resolver.createdAt, getDateFormat(resolver.createdAt)) }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<template #append>
|
||||
<N8nActionToggle :actions="actions" @action="onAction($event, resolver)" />
|
||||
</template>
|
||||
</N8nCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" module>
|
||||
.container {
|
||||
padding-bottom: var(--spacing--xl);
|
||||
max-width: 702px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.headerContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.iconCardContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.iconCard {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color--neutral-100);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--color--background--light-2);
|
||||
box-shadow: var(--shadow--dark);
|
||||
border-radius: var(--radius);
|
||||
|
||||
&:nth-child(1) {
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
z-index: 1;
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
transform: rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
text-transform: lowercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
+257
@@ -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<typeof restApiClient>();
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+122
@@ -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<void>;
|
||||
onDelete?: (resolverId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function useCredentialResolvers() {
|
||||
const rootStore = useRootStore();
|
||||
const uiStore = useUIStore();
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
|
||||
const resolvers = ref<CredentialResolver[]>([]);
|
||||
const resolverTypes = ref<CredentialResolverType[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const fetchResolvers = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
try {
|
||||
resolverTypes.value = await getCredentialResolverTypes(rootStore.restApiContext);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('credentialResolverEdit.error.loadTypes'));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAndDeleteResolver = async (resolver: CredentialResolver): Promise<boolean> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user