Skip to content

Commit

Permalink
fix: fix use template telemetry events
Browse files Browse the repository at this point in the history
  • Loading branch information
tomi committed Jan 4, 2024
1 parent b986263 commit d40b4e2
Show file tree
Hide file tree
Showing 9 changed files with 2,361 additions and 93 deletions.
2 changes: 2 additions & 0 deletions packages/editor-ui/src/stores/templates.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ function getSearchKey(query: ITemplatesQuery): string {
return JSON.stringify([query.search || '', [...query.categories].sort()]);
}

export type TemplatesStore = ReturnType<typeof useTemplatesStore>;

export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
state: (): ITemplateState => ({
categories: {},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { VIEWS } from '@/constants';
import { Telemetry } from '@/plugins/telemetry';
import type { NodeTypesStore } from '@/stores/nodeTypes.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { PosthogStore } from '@/stores/posthog.store';
import { usePostHog } from '@/stores/posthog.store';
import type { TemplatesStore } from '@/stores/templates.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useTemplateWorkflow } from '@/utils/templates/templateActions';
import {
nodeTypeRespondToWebhookV1,
nodeTypeShopifyTriggerV1,
nodeTypeTelegramV1,
nodeTypeTwitterV1,
nodeTypeWebhookV1,
nodeTypeWebhookV1_1,
nodeTypesSet,
} from '@/utils/testData/nodeTypeTestData';
import {
fullCreateApiEndpointTemplate,
fullShopifyTelegramTwitterTemplate,
} from '@/utils/testData/templateTestData';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { vi } from 'vitest';
import type { Router } from 'vue-router';

describe('templateActions', () => {
describe('useTemplateWorkflow', () => {
const telemetry = new Telemetry();
const externalHooks = {
run: vi.fn(),
};
const router: Router = {
push: vi.fn(),
resolve: vi.fn(),
} as unknown as Router;
let nodeTypesStore: NodeTypesStore;
let posthogStore: PosthogStore;
let templatesStore: TemplatesStore;

beforeEach(() => {
vi.resetAllMocks();
setActivePinia(
createTestingPinia({
stubActions: false,
}),
);

vi.spyOn(telemetry, 'track').mockImplementation(() => {});
nodeTypesStore = useNodeTypesStore();
posthogStore = usePostHog();
templatesStore = useTemplatesStore();
});

describe('When feature flag is disabled', () => {
const templateId = '1';

beforeEach(async () => {
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(false);

await useTemplateWorkflow({
externalHooks,
posthogStore,
nodeTypesStore,
telemetry,
templateId,
templatesStore,
router,
});
});

it('should navigate to correct url', async () => {
expect(router.push).toHaveBeenCalledWith({
name: VIEWS.TEMPLATE_IMPORT,
params: { id: templateId },
});
});

it("should track 'User inserted workflow template'", async () => {
expect(telemetry.track).toHaveBeenCalledWith(
'User inserted workflow template',
{
source: 'workflow',
template_id: templateId,
wf_template_repo_session_id: '',
},
{ withPostHog: true },
);
});
});

describe('When feature flag is enabled and template has nodes requiring credentials', () => {
const templateId = fullShopifyTelegramTwitterTemplate.id.toString();

beforeEach(async () => {
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(true);
templatesStore.addWorkflows([fullShopifyTelegramTwitterTemplate]);
nodeTypesStore.setNodeTypes([
nodeTypeTelegramV1,
nodeTypeTwitterV1,
nodeTypeShopifyTriggerV1,
]);
vi.spyOn(nodeTypesStore, 'loadNodeTypesIfNotLoaded').mockResolvedValue();

await useTemplateWorkflow({
externalHooks,
posthogStore,
nodeTypesStore,
telemetry,
templateId,
templatesStore,
router,
});
});

it('should navigate to correct url', async () => {
expect(router.push).toHaveBeenCalledWith({
name: VIEWS.TEMPLATE_SETUP,
params: { id: templateId },
});
});
});

describe("When feature flag is enabled and template doesn't have nodes requiring credentials", () => {
const templateId = fullCreateApiEndpointTemplate.id.toString();

beforeEach(async () => {
posthogStore.isFeatureEnabled = vi.fn().mockReturnValue(true);
templatesStore.addWorkflows([fullCreateApiEndpointTemplate]);
nodeTypesStore.setNodeTypes([
nodeTypeWebhookV1,
nodeTypeWebhookV1_1,
nodeTypeRespondToWebhookV1,
...Object.values(nodeTypesSet),
]);
vi.spyOn(nodeTypesStore, 'loadNodeTypesIfNotLoaded').mockResolvedValue();

await useTemplateWorkflow({
externalHooks,
posthogStore,
nodeTypesStore,
telemetry,
templateId,
templatesStore,
router,
});
});

it('should navigate to correct url', async () => {
expect(router.push).toHaveBeenCalledWith({
name: VIEWS.TEMPLATE_IMPORT,
params: { id: templateId },
});
});
});
});
});
134 changes: 115 additions & 19 deletions packages/editor-ui/src/utils/templates/templateActions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { INodeUi, IWorkflowData, IWorkflowTemplate } from '@/Interface';
import type {
INodeUi,
ITemplatesWorkflowFull,
IWorkflowData,
IWorkflowTemplate,
} from '@/Interface';
import { getNewWorkflow } from '@/api/workflows';
import { TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT, VIEWS } from '@/constants';
import type { useRootStore } from '@/stores/n8nRoot.store';
Expand All @@ -7,9 +12,19 @@ import type { useWorkflowsStore } from '@/stores/workflows.store';
import { getFixedNodesList } from '@/utils/nodeViewUtils';
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
import { replaceAllTemplateNodeCredentials } from '@/utils/templates/templateTransforms';
import {
getNodesRequiringCredentials,
replaceAllTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms';
import type { INodeCredentialsDetails } from 'n8n-workflow';
import type { RouteLocationRaw, Router } from 'vue-router';
import type { TemplatesStore } from '@/stores/templates.store';
import type { NodeTypesStore } from '@/stores/nodeTypes.store';
import type { Telemetry } from '@/plugins/telemetry';
import type { useExternalHooks } from '@/composables/useExternalHooks';
import { assert } from '@/utils/assert';

type ExternalHooks = ReturnType<typeof useExternalHooks>;

/**
* Creates a new workflow from a template
Expand Down Expand Up @@ -49,28 +64,55 @@ export async function createWorkflowFromTemplate(opts: {
}

/**
* Opens the template credential setup view (or workflow view
* if the feature flag is disabled)
* Opens the template credential setup view
*/
export async function openTemplateCredentialSetup(opts: {
posthogStore: PosthogStore;
async function openTemplateCredentialSetup(opts: {
templateId: string;
router: Router;
inNewBrowserTab?: boolean;
}) {
const { router, templateId, inNewBrowserTab = false } = opts;

const routeLocation: RouteLocationRaw = {
name: VIEWS.TEMPLATE_SETUP,
params: { id: templateId },
};

if (inNewBrowserTab) {
const route = router.resolve(routeLocation);
window.open(route.href, '_blank');
} else {
await router.push(routeLocation);
}
}

/**
* Opens the given template's workflow on NodeView. Fires necessary
* telemetry events.
*/
async function openTemplateWorkflowOnNodeView(opts: {
externalHooks: ExternalHooks;
templateId: string;
templatesStore: TemplatesStore;
router: Router;
inNewBrowserTab?: boolean;
telemetry: Telemetry;
}) {
const { router, templateId, inNewBrowserTab = false, posthogStore } = opts;

const routeLocation: RouteLocationRaw = posthogStore.isFeatureEnabled(
TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT,
)
? {
name: VIEWS.TEMPLATE_SETUP,
params: { id: templateId },
}
: {
name: VIEWS.TEMPLATE_IMPORT,
params: { id: templateId },
};
const { externalHooks, templateId, templatesStore, telemetry, inNewBrowserTab, router } = opts;
const routeLocation: RouteLocationRaw = {
name: VIEWS.TEMPLATE_IMPORT,
params: { id: templateId },
};
const telemetryPayload = {
source: 'workflow',
template_id: templateId,
wf_template_repo_session_id: templatesStore.currentSessionId,
};

telemetry.track('User inserted workflow template', telemetryPayload, {
withPostHog: true,
});
await externalHooks.run('templatesWorkflowView.openWorkflow', telemetryPayload);

if (inNewBrowserTab) {
const route = router.resolve(routeLocation);
Expand All @@ -79,3 +121,57 @@ export async function openTemplateCredentialSetup(opts: {
await router.push(routeLocation);
}
}

function hasTemplateCredentials(
nodeTypeProvider: NodeTypeProvider,
template: ITemplatesWorkflowFull,
) {
const nodesRequiringCreds = getNodesRequiringCredentials(nodeTypeProvider, template);

return nodesRequiringCreds.length > 0;
}

async function getFullTemplate(templatesStore: TemplatesStore, templateId: string) {
const template = templatesStore.getFullTemplateById(templateId);
if (template) {
return template;
}

await templatesStore.fetchTemplateById(templateId);
return templatesStore.getFullTemplateById(templateId);
}

/**
* Uses the given template by opening the template workflow on NodeView
* or the template credential setup view. Fires necessary telemetry events.
*/
export async function useTemplateWorkflow(opts: {
externalHooks: ExternalHooks;
nodeTypesStore: NodeTypesStore;
posthogStore: PosthogStore;
templateId: string;
templatesStore: TemplatesStore;
router: Router;
inNewBrowserTab?: boolean;
telemetry: Telemetry;
}) {
const { nodeTypesStore, posthogStore, templateId, templatesStore } = opts;

const openCredentialSetup = posthogStore.isFeatureEnabled(TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT);
if (!openCredentialSetup) {
await openTemplateWorkflowOnNodeView(opts);
return;
}

const [template] = await Promise.all([
getFullTemplate(templatesStore, templateId),
nodeTypesStore.loadNodeTypesIfNotLoaded(),
]);
assert(template);

if (hasTemplateCredentials(nodeTypesStore, template)) {
await openTemplateCredentialSetup(opts);
} else {
await openTemplateWorkflowOnNodeView(opts);
}
}
39 changes: 37 additions & 2 deletions packages/editor-ui/src/utils/templates/templateTransforms.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { IWorkflowTemplateNode, IWorkflowTemplateNodeCredentials } from '@/Interface';
import type {
ITemplatesWorkflowFull,
IWorkflowTemplateNode,
IWorkflowTemplateNodeCredentials,
} from '@/Interface';
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
import { getNodeTypeDisplayableCredentials } from '@/utils/nodes/nodeTransforms';
import type { NormalizedTemplateNodeCredentials } from '@/utils/templates/templateTypes';
import type { INodeCredentials, INodeCredentialsDetails } from 'n8n-workflow';
import type {
INodeCredentialDescription,
INodeCredentials,
INodeCredentialsDetails,
} from 'n8n-workflow';

export type IWorkflowTemplateNodeWithCredentials = IWorkflowTemplateNode &
Required<Pick<IWorkflowTemplateNode, 'credentials'>>;
Expand All @@ -17,6 +25,11 @@ const credentialKeySymbol = Symbol('credentialKey');
*/
export type TemplateCredentialKey = string & { [credentialKeySymbol]: never };

export type TemplateNodeWithRequiredCredential = {
node: IWorkflowTemplateNode;
requiredCredentials: INodeCredentialDescription[];
};

/**
* Forms a key from credential type name and credential name
*/
Expand Down Expand Up @@ -120,3 +133,25 @@ export const replaceAllTemplateNodeCredentials = (
};
});
};

/**
* Returns the nodes in the template that require credentials
* and the required credentials for each node.
*/
export const getNodesRequiringCredentials = (
nodeTypeProvider: NodeTypeProvider,
template: ITemplatesWorkflowFull,
): TemplateNodeWithRequiredCredential[] => {
if (!template) {
return [];
}

const nodesWithCredentials: TemplateNodeWithRequiredCredential[] = template.workflow.nodes
.map((node) => ({
node,
requiredCredentials: getNodeTypeDisplayableCredentials(nodeTypeProvider, node),
}))
.filter(({ requiredCredentials }) => requiredCredentials.length > 0);

return nodesWithCredentials;
};
Loading

0 comments on commit d40b4e2

Please sign in to comment.