feat(editor): Add free AI credits CTA (#12365)

This commit is contained in:
Ricardo Espinoza 2024-12-30 07:35:49 -05:00 committed by GitHub
parent e26b406665
commit f8731963f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 411 additions and 17 deletions

View file

@ -364,6 +364,7 @@ export interface ICredentialsResponse extends ICredentialsEncrypted {
currentUserHasAccess?: boolean;
scopes?: Scope[];
ownedBy?: Pick<IUserResponse, 'id' | 'firstName' | 'lastName' | 'email'>;
isManaged: boolean;
}
export interface ICredentialsBase {

View file

@ -18,4 +18,7 @@ export const credentialFactory = Factory.extend<ICredentialsResponse>({
updatedAt() {
return '';
},
isManaged() {
return false;
},
});

View file

@ -17,6 +17,7 @@ const createCredential = (overrides = {}): ICredentialsResponse => ({
type: '',
name: '',
sharedWithProjects: [],
isManaged: false,
homeProject: {} as ProjectSharingData,
...overrides,
});

View file

@ -39,6 +39,7 @@ const props = withDefaults(
name: '',
sharedWithProjects: [],
homeProject: {} as ProjectSharingData,
isManaged: false,
}),
readOnly: false,
},

View file

@ -0,0 +1,56 @@
import CredentialConfig from './CredentialEdit/CredentialConfig.vue';
import { screen } from '@testing-library/vue';
import type { ICredentialDataDecryptedObject, ICredentialType } from 'n8n-workflow';
import { createTestingPinia } from '@pinia/testing';
import type { RenderOptions } from '@/__tests__/render';
import { createComponentRenderer } from '@/__tests__/render';
import { STORES } from '@/constants';
const defaultRenderOptions: RenderOptions = {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: {
enterprise: {
sharing: false,
externalSecrets: false,
},
},
},
},
}),
props: {
isManaged: true,
mode: 'edit',
credentialType: {} as ICredentialType,
credentialProperties: [],
credentialData: {} as ICredentialDataDecryptedObject,
credentialPermissions: {
share: false,
move: false,
create: false,
read: false,
update: false,
delete: false,
list: false,
},
},
};
const renderComponent = createComponentRenderer(CredentialConfig, defaultRenderOptions);
describe('CredentialConfig', () => {
it('should display a warning when isManaged is true', async () => {
renderComponent();
expect(
screen.queryByText('This is a managed credential and cannot be edited.'),
).toBeInTheDocument();
});
it('should not display a warning when isManaged is false', async () => {
renderComponent({ props: { isManaged: false } }, { merge: true });
expect(
screen.queryByText('This is a managed credential and cannot be edited.'),
).not.toBeInTheDocument();
});
});

View file

@ -55,6 +55,7 @@ type Props = {
isRetesting?: boolean;
requiredPropertiesFilled?: boolean;
showAuthTypeSelector?: boolean;
isManaged?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
@ -235,7 +236,10 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
</script>
<template>
<div>
<n8n-callout v-if="isManaged" theme="warning" icon="exclamation-triangle">
{{ i18n.baseText('freeAi.credits.credentials.edit') }}
</n8n-callout>
<div v-else>
<div :class="$style.config" data-test-id="node-credentials-config-container">
<Banner
v-show="showValidationWarning"

View file

@ -160,6 +160,11 @@ const credentialTypeName = computed(() => {
return `${props.activeId}`;
});
const isEditingManagedCredential = computed(() => {
if (!props.activeId) return false;
return credentialsStore.getCredentialById(props.activeId)?.isManaged ?? false;
});
const isCredentialTestable = computed(() => {
if (isOAuthType.value || !requiredPropertiesFilled.value) {
return false;
@ -597,6 +602,10 @@ function scrollToBottom() {
}
async function retestCredential() {
if (isEditingManagedCredential.value) {
return;
}
if (!isCredentialTestable.value || !credentialTypeName.value) {
authError.value = '';
testedSuccessfully.value = false;
@ -1061,7 +1070,9 @@ function resetCredentialData(): void {
<InlineNameEdit
:model-value="credentialName"
:subtitle="credentialType ? credentialType.displayName : ''"
:readonly="!credentialPermissions.update || !credentialType"
:readonly="
!credentialPermissions.update || !credentialType || isEditingManagedCredential
"
type="Credential"
data-test-id="credential-name"
@update:model-value="onNameEdit"
@ -1113,6 +1124,7 @@ function resetCredentialData(): void {
:credential-properties="credentialProperties"
:credential-data="credentialData"
:credential-id="credentialId"
:is-managed="isEditingManagedCredential"
:show-validation-warning="showValidationWarning"
:auth-error="authError"
:tested-successfully="testedSuccessfully"

View file

@ -0,0 +1,185 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fireEvent, screen } from '@testing-library/vue';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useNDVStore } from '@/stores/ndv.store';
import { usePostHog } from '@/stores/posthog.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useRootStore } from '@/stores/root.store';
import { useToast } from '@/composables/useToast';
import { renderComponent } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
vi.mock('@/composables/useToast', () => ({
useToast: vi.fn(),
}));
vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(),
}));
vi.mock('@/stores/credentials.store', () => ({
useCredentialsStore: vi.fn(),
}));
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
vi.mock('@/stores/ndv.store', () => ({
useNDVStore: vi.fn(),
}));
vi.mock('@/stores/posthog.store', () => ({
usePostHog: vi.fn(),
}));
vi.mock('@/stores/projects.store', () => ({
useProjectsStore: vi.fn(),
}));
vi.mock('@/stores/root.store', () => ({
useRootStore: vi.fn(),
}));
const assertUserCannotClaimCredits = () => {
expect(screen.queryByText('Get 100 free OpenAI API credits')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Claim credits' })).not.toBeInTheDocument();
};
const assertUserCanClaimCredits = () => {
expect(screen.getByText('Get 100 free OpenAI API credits')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Claim credits' })).toBeInTheDocument();
};
const assertUserClaimedCredits = () => {
expect(screen.getByText('Claimed 100 free OpenAI API credits')).toBeInTheDocument();
};
describe('FreeAiCreditsCallout', () => {
beforeEach(() => {
vi.clearAllMocks();
(useSettingsStore as any).mockReturnValue({
isAiCreditsEnabled: true,
aiCreditsQuota: 100,
});
(useCredentialsStore as any).mockReturnValue({
allCredentials: [],
upsertCredential: vi.fn(),
claimFreeAiCredits: vi.fn(),
});
(useUsersStore as any).mockReturnValue({
currentUser: {
settings: {
userClaimedAiCredits: false,
},
},
});
(useNDVStore as any).mockReturnValue({
activeNode: { type: '@n8n/n8n-nodes-langchain.openAi' },
});
(usePostHog as any).mockReturnValue({
isFeatureEnabled: vi.fn().mockReturnValue(true),
});
(useProjectsStore as any).mockReturnValue({
currentProject: { id: 'test-project-id' },
});
(useRootStore as any).mockReturnValue({
restApiContext: {},
});
(useToast as any).mockReturnValue({
showError: vi.fn(),
});
});
it('should shows the claim callout when the user can claim credits', () => {
renderComponent(FreeAiCreditsCallout);
assertUserCanClaimCredits();
});
it('should show success callout when credit are claimed', async () => {
const credentialsStore = mockedStore(useCredentialsStore);
renderComponent(FreeAiCreditsCallout);
const claimButton = screen.getByRole('button', {
name: 'Claim credits',
});
await fireEvent.click(claimButton);
expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id');
assertUserClaimedCredits();
});
it('should not be able to claim credits is user already claimed credits', async () => {
(useUsersStore as any).mockReturnValue({
currentUser: {
settings: {
userClaimedAiCredits: true,
},
},
});
renderComponent(FreeAiCreditsCallout);
assertUserCannotClaimCredits();
});
it('should not be able to claim credits is user does not have ai credits enabled', async () => {
(useSettingsStore as any).mockReturnValue({
isAiCreditsEnabled: false,
aiCreditsQuota: 0,
});
renderComponent(FreeAiCreditsCallout);
assertUserCannotClaimCredits();
});
it('should not be able to claim credits if user it is not in experiment', async () => {
(usePostHog as any).mockReturnValue({
isFeatureEnabled: vi.fn().mockReturnValue(false),
});
renderComponent(FreeAiCreditsCallout);
assertUserCannotClaimCredits();
});
it('should not be able to claim credits if user already has OpenAiApi credential', async () => {
(useCredentialsStore as any).mockReturnValue({
allCredentials: [
{
type: 'openAiApi',
},
],
upsertCredential: vi.fn(),
});
renderComponent(FreeAiCreditsCallout);
assertUserCannotClaimCredits();
});
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' },
});
renderComponent(FreeAiCreditsCallout);
assertUserCannotClaimCredits();
});
});

View file

@ -0,0 +1,118 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { AI_CREDITS_EXPERIMENT } from '@/constants';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useNDVStore } from '@/stores/ndv.store';
import { usePostHog } from '@/stores/posthog.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, ref } from 'vue';
const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.';
const N8N_NODES_PREFIX = '@n8n/n8n-nodes.';
const NODES_WITH_OPEN_AI_API_CREDENTIAL = [
`${LANGCHAIN_NODES_PREFIX}openAi`,
`${LANGCHAIN_NODES_PREFIX}embeddingsOpenAi`,
`${LANGCHAIN_NODES_PREFIX}lmChatOpenAi`,
`${N8N_NODES_PREFIX}openAi`,
];
const showSuccessCallout = ref(false);
const claimingCredits = ref(false);
const settingsStore = useSettingsStore();
const postHogStore = usePostHog();
const credentialsStore = useCredentialsStore();
const usersStore = useUsersStore();
const ndvStore = useNDVStore();
const projectsStore = useProjectsStore();
const i18n = useI18n();
const toast = useToast();
const userHasOpenAiCredentialAlready = computed(
() =>
!!credentialsStore.allCredentials.filter(
(credential) => credential.type === OPEN_AI_API_CREDENTIAL_TYPE,
).length,
);
const userHasClaimedAiCreditsAlready = computed(
() => !!usersStore.currentUser?.settings?.userClaimedAiCredits,
);
const activeNodeHasOpenAiApiCredential = computed(
() =>
ndvStore.activeNode?.type &&
NODES_WITH_OPEN_AI_API_CREDENTIAL.includes(ndvStore.activeNode.type),
);
const userCanClaimOpenAiCredits = computed(() => {
return (
settingsStore.isAiCreditsEnabled &&
activeNodeHasOpenAiApiCredential.value &&
postHogStore.isFeatureEnabled(AI_CREDITS_EXPERIMENT.name) &&
!userHasOpenAiCredentialAlready.value &&
!userHasClaimedAiCreditsAlready.value
);
});
const onClaimCreditsClicked = async () => {
claimingCredits.value = true;
try {
await credentialsStore.claimFreeAiCredits(projectsStore.currentProject?.id);
if (usersStore?.currentUser?.settings) {
usersStore.currentUser.settings.userClaimedAiCredits = true;
}
showSuccessCallout.value = true;
} catch (e) {
toast.showError(
e,
i18n.baseText('freeAi.credits.showError.claim.title'),
i18n.baseText('freeAi.credits.showError.claim.message'),
);
} finally {
claimingCredits.value = false;
}
};
</script>
<template>
<div class="mt-xs">
<n8n-callout
v-if="userCanClaimOpenAiCredits && !showSuccessCallout"
theme="secondary"
icon="exclamation-circle"
>
{{
i18n.baseText('freeAi.credits.callout.claim.title', {
interpolate: { credits: settingsStore.aiCreditsQuota },
})
}}
<template #trailingContent>
<n8n-button
type="tertiary"
size="small"
:label="i18n.baseText('freeAi.credits.callout.claim.button.label')"
:loading="claimingCredits"
@click="onClaimCreditsClicked"
/>
</template>
</n8n-callout>
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle">
{{
i18n.baseText('freeAi.credits.callout.success.title', {
interpolate: { credits: settingsStore.aiCreditsQuota },
})
}}
</n8n-callout>
</div>
</template>

View file

@ -48,6 +48,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
import { ProjectTypes } from '@/types/projects.types';
import { updateDynamicConnections } from '@/utils/nodeSettingsUtils';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
const props = withDefaults(
defineProps<{
@ -1026,6 +1027,7 @@ onBeforeUnmount(() => {
})
"
/>
<FreeAiCreditsCallout />
<div v-show="openPanel === 'params'">
<NodeWebhooks :node="node" :node-type-description="nodeType" />

View file

@ -2805,5 +2805,11 @@
"testDefinition.notImplemented": "This feature is not implemented yet!",
"testDefinition.viewDetails": "View Details",
"testDefinition.editTest": "Edit Test",
"testDefinition.deleteTest": "Delete Test"
"testDefinition.deleteTest": "Delete Test",
"freeAi.credits.callout.claim.title": "Get {credits} free OpenAI API credits",
"freeAi.credits.callout.claim.button.label": "Claim credits",
"freeAi.credits.callout.success.title": "Claimed {credits} free OpenAI API credits",
"freeAi.credits.credentials.edit": "This is a managed credential and cannot be edited.",
"freeAi.credits.showError.claim.title": "Free AI credits",
"freeAi.credits.showError.claim.message": "Enable to claim credits"
}

View file

@ -163,6 +163,7 @@ import {
faStream,
faPowerOff,
faPaperPlane,
faExclamationCircle,
} from '@fortawesome/free-solid-svg-icons';
import { faVariable, faXmark, faVault, faRefresh } from './custom';
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
@ -228,6 +229,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faEquals);
addIcon(faEye);
addIcon(faExclamationTriangle);
addIcon(faExclamationCircle);
addIcon(faExpand);
addIcon(faExpandAlt);
addIcon(faExternalLinkAlt);

View file

@ -26,6 +26,7 @@ import { computed, ref } from 'vue';
import { useNodeTypesStore } from './nodeTypes.store';
import { useRootStore } from './root.store';
import { useSettingsStore } from './settings.store';
import * as aiApi from '@/api/ai';
const DEFAULT_CREDENTIAL_NAME = 'Unnamed credential';
const DEFAULT_CREDENTIAL_POSTFIX = 'account';
@ -36,6 +37,8 @@ export type CredentialsStore = ReturnType<typeof useCredentialsStore>;
export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
const state = ref<ICredentialsState>({ credentialTypes: {}, credentials: {} });
const rootStore = useRootStore();
// ---------------------------------------------------------------------------
// #region Computed
// ---------------------------------------------------------------------------
@ -252,7 +255,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
if (allCredentialTypes.value.length > 0 && !forceFetch) {
return;
}
const rootStore = useRootStore();
const credentialTypes = await credentialsApi.getCredentialTypes(rootStore.baseUrl);
setCredentialTypes(credentialTypes);
};
@ -261,8 +263,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
projectId?: string,
includeScopes = true,
): Promise<ICredentialsResponse[]> => {
const rootStore = useRootStore();
const filter = {
projectId,
};
@ -279,8 +279,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
const fetchAllCredentialsForWorkflow = async (
options: { workflowId: string } | { projectId: string },
): Promise<ICredentialsResponse[]> => {
const rootStore = useRootStore();
const credentials = await credentialsApi.getAllCredentialsForWorkflow(
rootStore.restApiContext,
options,
@ -294,7 +292,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
}: {
id: string;
}): Promise<ICredentialsResponse | ICredentialsDecryptedResponse | undefined> => {
const rootStore = useRootStore();
return await credentialsApi.getCredentialData(rootStore.restApiContext, id);
};
@ -302,7 +299,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
data: ICredentialsDecrypted,
projectId?: string,
): Promise<ICredentialsResponse> => {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const credential = await credentialsApi.createNewCredential(
rootStore.restApiContext,
@ -333,7 +329,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
id: string;
}): Promise<ICredentialsResponse> => {
const { id, data } = params;
const rootStore = useRootStore();
const credential = await credentialsApi.updateCredential(rootStore.restApiContext, id, data);
upsertCredential(credential);
@ -342,7 +337,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
};
const deleteCredential = async ({ id }: { id: string }) => {
const rootStore = useRootStore();
const deleted = await credentialsApi.deleteCredential(rootStore.restApiContext, id);
if (deleted) {
const { [id]: deletedCredential, ...rest } = state.value.credentials;
@ -351,19 +345,16 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
};
const oAuth2Authorize = async (data: ICredentialsResponse): Promise<string> => {
const rootStore = useRootStore();
return await credentialsApi.oAuth2CredentialAuthorize(rootStore.restApiContext, data);
};
const oAuth1Authorize = async (data: ICredentialsResponse): Promise<string> => {
const rootStore = useRootStore();
return await credentialsApi.oAuth1CredentialAuthorize(rootStore.restApiContext, data);
};
const testCredential = async (
data: ICredentialsDecrypted,
): Promise<INodeCredentialTestResult> => {
const rootStore = useRootStore();
return await credentialsApi.testCredential(rootStore.restApiContext, { credentials: data });
};
@ -377,7 +368,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
newName =
newName.length > 0 ? `${newName} ${DEFAULT_CREDENTIAL_POSTFIX}` : DEFAULT_CREDENTIAL_NAME;
}
const rootStore = useRootStore();
const res = await credentialsApi.getCredentialsNewName(rootStore.restApiContext, newName);
return res.name;
} catch (e) {
@ -407,12 +397,19 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
};
const getCredentialTranslation = async (credentialType: string): Promise<object> => {
const rootStore = useRootStore();
return await makeRestApiRequest(rootStore.restApiContext, 'GET', '/credential-translation', {
credentialType,
});
};
const claimFreeAiCredits = async (projectId?: string): Promise<ICredentialsResponse> => {
const credential = await aiApi.claimFreeAiCredits(rootStore.restApiContext, {
projectId,
});
upsertCredential(credential);
return credential;
};
// #endregion
return {
@ -449,6 +446,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
testCredential,
getCredentialTranslation,
setCredentialSharedWith,
claimFreeAiCredits,
};
});

View file

@ -59,6 +59,7 @@ describe('CredentialsView', () => {
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
isManaged: false,
},
];
const projectsStore = mockedStore(useProjectsStore);
@ -77,6 +78,7 @@ describe('CredentialsView', () => {
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
},
{
id: '2',
@ -84,6 +86,7 @@ describe('CredentialsView', () => {
type: 'test2',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
isManaged: false,
},
];
const projectsStore = mockedStore(useProjectsStore);
@ -124,6 +127,7 @@ describe('CredentialsView', () => {
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
isManaged: false,
},
];
const { getByTestId } = renderComponent();

View file

@ -51,5 +51,6 @@ export const newCredential = (
updatedAt: faker.date.past().toISOString(),
id: faker.string.alphanumeric({ length: 16 }),
name: faker.commerce.productName(),
isManaged: false,
...opts,
});