fix(editor): Automatically close credential modal after claiming free OpenAI credits (#30610)

This commit is contained in:
Jaakko Husso 2026-05-18 17:02:32 +03:00 committed by GitHub
parent f5cc969c7c
commit 05c25da010
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 92 additions and 18 deletions

View File

@ -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' },

View File

@ -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<Props>();
const props = withDefaults(defineProps<Props>(), {
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');
}
};
</script>
<template>
<N8nCallout
v-if="showCallout && !showSuccessCallout"
v-if="showCallout && !showSuccess"
theme="secondary"
icon="circle-alert"
class="mt-xs"
@ -76,7 +85,7 @@ const onClaimCreditsClicked = async () => {
/>
</template>
</N8nCallout>
<N8nCallout v-else-if="showSuccessCallout" theme="success" icon="circle-check" class="mt-xs">
<N8nCallout v-else-if="showSuccess" theme="success" icon="circle-check" class="mt-xs">
<N8nText size="small">
{{
i18n.baseText('freeAi.credits.callout.success.title.part1', {

View File

@ -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<boolean> {
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,
};
}

View File

@ -23,6 +23,10 @@ vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', () => ({
default: { template: '<div />' },
}));
vi.mock('@/app/components/FreeAiCreditsCallout.vue', () => ({
default: { template: '<div />' },
}));
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({

View File

@ -47,6 +47,10 @@ vi.mock('@/features/credentials/components/NodeCredentials.vue', () => ({
},
}));
vi.mock('@/app/components/FreeAiCreditsCallout.vue', () => ({
default: { template: '<div />' },
}));
vi.mock('@/features/ndv/parameters/components/ParameterInputList.vue', async () => {
const { defineComponent, h } = await import('vue');

View File

@ -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) {
<template>
<div :class="$style.body">
<FreeAiCreditsCallout
v-if="credentialType"
:credential-type-name="credentialType"
telemetry-source="instanceAiWorkflowSetup"
/>
<NodeCredentials
v-if="credentialType"
:node="displayNode"

View File

@ -89,6 +89,7 @@ const emit = defineEmits<{
retest: [];
oauth: [];
quickConnect: [];
claimed: [];
'update:isResolvable': [value: boolean];
}>();
@ -281,7 +282,10 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
</N8nCallout>
<div v-else>
<div :class="$style.config" data-test-id="node-credentials-config-container">
<FreeAiCreditsCallout :credential-type-name="credentialType?.name" />
<FreeAiCreditsCallout
:credential-type-name="credentialType?.name"
@claimed="$emit('claimed')"
/>
<CredentialModeSelector
v-if="canWrite"

View File

@ -1508,6 +1508,7 @@ const { width } = useElementSize(credNameRef);
@retest="retestCredential"
@scroll-to-top="scrollToTop"
@auth-type-changed="onAuthTypeChanged"
@claimed="closeDialog"
@update:is-resolvable="onResolvableChange"
/>
</div>