From 05c25da010751de8476bc4e303ac7dd80759fa5a Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Mon, 18 May 2026 17:02:32 +0300 Subject: [PATCH] fix(editor): Automatically close credential modal after claiming free OpenAI credits (#30610) --- .../components/FreeAiCreditsCallout.test.ts | 41 +++++++++++++++++++ .../app/components/FreeAiCreditsCallout.vue | 41 +++++++++++-------- .../src/app/composables/useFreeAiCredits.ts | 6 ++- .../WorkflowSetupSectionBody.test.ts | 4 ++ .../WorkflowSetupSectionBody.test.ts | 4 ++ .../components/WorkflowSetupSectionBody.vue | 7 ++++ .../CredentialEdit/CredentialConfig.vue | 6 ++- .../CredentialEdit/CredentialEdit.vue | 1 + 8 files changed, 92 insertions(+), 18 deletions(-) diff --git a/packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.test.ts b/packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.test.ts index 8c4ee9f85c8..396f4c84557 100644 --- a/packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.test.ts +++ b/packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.test.ts @@ -11,6 +11,7 @@ import { useToast } from '@/app/composables/useToast'; import { renderComponent } from '@/__tests__/render'; import { mockedStore } from '@/__tests__/utils'; import { useTelemetry } from '@/app/composables/useTelemetry'; +import { useFreeAiCredits } from '@/app/composables/useFreeAiCredits'; vi.mock('@/app/composables/useToast', () => ({ useToast: vi.fn(), @@ -111,6 +112,8 @@ describe('FreeAiCreditsCallout', () => { (useTelemetry as any).mockReturnValue({ track: vi.fn(), }); + + useFreeAiCredits().showSuccessCallout.value = false; }); it('should shows the claim callout when the user can claim credits', () => { @@ -191,6 +194,44 @@ describe('FreeAiCreditsCallout', () => { assertUserCannotClaimCredits(); }); + it('should emit "claimed" after a successful claim', async () => { + const { emitted } = renderComponent(FreeAiCreditsCallout); + + await fireEvent.click(screen.getByRole('button', { name: 'Claim credits' })); + + await waitFor(() => { + expect(emitted()).toHaveProperty('claimed'); + }); + }); + + it('should not emit "claimed" when the claim fails', async () => { + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.claimFreeAiCredits.mockRejectedValueOnce(new Error('boom')); + + const { emitted } = renderComponent(FreeAiCreditsCallout); + + await fireEvent.click(screen.getByRole('button', { name: 'Claim credits' })); + + await waitFor(() => { + expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalled(); + }); + expect(emitted()).not.toHaveProperty('claimed'); + }); + + it('should use the telemetrySource prop value when tracking the claim', async () => { + renderComponent(FreeAiCreditsCallout, { + props: { telemetrySource: 'instanceAiWorkflowSetup' }, + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Claim credits' })); + + await waitFor(() => { + expect(useTelemetry().track).toHaveBeenCalledWith('User claimed OpenAI credits', { + source: 'instanceAiWorkflowSetup', + }); + }); + }); + it('should not be able to claim credits if active node it is not a valid node', async () => { (useNDVStore as any).mockReturnValue({ activeNode: { type: '@n8n/n8n-nodes.jira' }, diff --git a/packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.vue b/packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.vue index 5b1a6cd0aba..d0758976ca4 100644 --- a/packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.vue +++ b/packages/frontend/editor-ui/src/app/components/FreeAiCreditsCallout.vue @@ -2,15 +2,20 @@ import { useI18n } from '@n8n/i18n'; import { useNDVStore } from '@/features/ndv/shared/ndv.store'; import { useFreeAiCredits } from '@/app/composables/useFreeAiCredits'; -import { computed, ref } from 'vue'; +import { computed } from 'vue'; import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow'; import { N8nButton, N8nCallout, N8nText } from '@n8n/design-system'; type Props = { credentialTypeName?: string; + telemetrySource?: 'freeAiCreditsCallout' | 'instanceAiWorkflowSetup'; }; -const props = defineProps(); +const props = withDefaults(defineProps(), { + telemetrySource: 'freeAiCreditsCallout', +}); + +const emit = defineEmits<{ claimed: [] }>(); const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.'; const N8N_NODES_PREFIX = '@n8n/n8n-nodes.'; @@ -22,13 +27,16 @@ const NODES_WITH_OPEN_AI_API_CREDENTIAL = [ `${N8N_NODES_PREFIX}openAi`, ]; -const showSuccessCallout = ref(false); - const ndvStore = useNDVStore(); const i18n = useI18n(); -const { aiCreditsQuota, userCanClaimOpenAiCredits, claimingCredits, claimCredits } = - useFreeAiCredits(); +const { + aiCreditsQuota, + userCanClaimOpenAiCredits, + claimingCredits, + showSuccessCallout, + claimCredits, +} = useFreeAiCredits(); const isEditingOpenAiCredential = computed( () => props.credentialTypeName && props.credentialTypeName === OPEN_AI_API_CREDENTIAL_TYPE, @@ -40,23 +48,24 @@ const activeNodeHasOpenAiApiCredential = computed( NODES_WITH_OPEN_AI_API_CREDENTIAL.includes(ndvStore.activeNode.type), ); -const showCallout = computed(() => { - return ( - userCanClaimOpenAiCredits.value && - (activeNodeHasOpenAiApiCredential.value || isEditingOpenAiCredential.value) - ); -}); +const isRelevantContext = computed( + () => activeNodeHasOpenAiApiCredential.value || isEditingOpenAiCredential.value, +); + +const showCallout = computed(() => userCanClaimOpenAiCredits.value && isRelevantContext.value); + +const showSuccess = computed(() => showSuccessCallout.value && isRelevantContext.value); const onClaimCreditsClicked = async () => { - const success = await claimCredits('freeAiCreditsCallout'); + const success = await claimCredits(props.telemetrySource); if (success) { - showSuccessCallout.value = true; + emit('claimed'); } }; - + {{ i18n.baseText('freeAi.credits.callout.success.title.part1', { diff --git a/packages/frontend/editor-ui/src/app/composables/useFreeAiCredits.ts b/packages/frontend/editor-ui/src/app/composables/useFreeAiCredits.ts index 3c44ed5eb68..2449b78eb2e 100644 --- a/packages/frontend/editor-ui/src/app/composables/useFreeAiCredits.ts +++ b/packages/frontend/editor-ui/src/app/composables/useFreeAiCredits.ts @@ -8,6 +8,8 @@ import { useTelemetry } from '@/app/composables/useTelemetry'; import { useToast } from '@/app/composables/useToast'; import { useI18n } from '@n8n/i18n'; +const showSuccessCallout = ref(false); + export function useFreeAiCredits() { const credentialsStore = useCredentialsStore(); const projectsStore = useProjectsStore(); @@ -42,7 +44,7 @@ export function useFreeAiCredits() { ); async function claimCredits( - source: 'chatHubAutoClaim' | 'freeAiCreditsCallout', + source: 'chatHubAutoClaim' | 'freeAiCreditsCallout' | 'instanceAiWorkflowSetup', ): Promise { if (!userCanClaimOpenAiCredits.value) { return false; @@ -57,6 +59,7 @@ export function useFreeAiCredits() { usersStore.currentUser.settings.userClaimedAiCredits = true; } + showSuccessCallout.value = true; telemetry.track('User claimed OpenAI credits', { source }); return true; @@ -75,6 +78,7 @@ export function useFreeAiCredits() { aiCreditsQuota, userCanClaimOpenAiCredits, claimingCredits, + showSuccessCallout, claimCredits, }; } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/WorkflowSetupSectionBody.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/WorkflowSetupSectionBody.test.ts index 08abcc45c97..484d7b63a2f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/WorkflowSetupSectionBody.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/WorkflowSetupSectionBody.test.ts @@ -23,6 +23,10 @@ vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', () => ({ default: { template: '
' }, })); +vi.mock('@/app/components/FreeAiCreditsCallout.vue', () => ({ + default: { template: '
' }, +})); + vi.mock('@n8n/i18n', async (importOriginal) => ({ ...(await importOriginal()), useI18n: () => ({ diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts index ad10e8bf0fc..bba39a4f445 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts @@ -47,6 +47,10 @@ vi.mock('@/features/credentials/components/NodeCredentials.vue', () => ({ }, })); +vi.mock('@/app/components/FreeAiCreditsCallout.vue', () => ({ + default: { template: '
' }, +})); + vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', async () => { const { defineComponent, h } = await import('vue'); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.vue index 5f2b04fc535..cc7c24ebfcc 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.vue @@ -3,6 +3,7 @@ import { computed, provide, ref, watch } from 'vue'; import { N8nText, N8nTooltip } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import NodeCredentials from '@/features/credentials/components/NodeCredentials.vue'; +import FreeAiCreditsCallout from '@/app/components/FreeAiCreditsCallout.vue'; import ParameterInputList from '@/features/ndv/parameters/components/ParameterInputList.vue'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; @@ -136,6 +137,12 @@ function onParameterValueChanged(update: IUpdateInformation) {