Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Nudge users to become template creators if eligible #8357

Merged
merged 11 commits into from
Jan 17, 2024
20 changes: 20 additions & 0 deletions cypress/composables/becomeTemplateCreatorCta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//#region Getters

export const getBecomeTemplateCreatorCta = () => cy.getByTestId('become-template-creator-cta');

export const getCloseBecomeTemplateCreatorCtaButton = () =>
cy.getByTestId('close-become-template-creator-cta');

//#endregion

//#region Actions

export const interceptCtaRequestWithResponse = (becomeCreator: boolean) => {
return cy.intercept('GET', `/rest/cta`, {
body: {
becomeCreator,
},
});
};

//#endregion
34 changes: 34 additions & 0 deletions cypress/e2e/37-become-creator-cta.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
getBecomeTemplateCreatorCta,
getCloseBecomeTemplateCreatorCtaButton,
interceptCtaRequestWithResponse,
} from '../composables/becomeTemplateCreatorCta';
import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';

const WorkflowsPage = new WorkflowsPageClass();

describe('Become creator CTA', () => {
beforeEach(() => {});
tomi marked this conversation as resolved.
Show resolved Hide resolved

it('should not show the CTA if user is not eligible', () => {
interceptCtaRequestWithResponse(false).as('cta');
cy.visit(WorkflowsPage.url);

cy.wait('@cta');

getBecomeTemplateCreatorCta().should('not.exist');
});

it('should show the CTA', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('should show the CTA', () => {
it('should show the CTA if the user is eligible', () => {

interceptCtaRequestWithResponse(true).as('cta');
cy.visit(WorkflowsPage.url);

cy.wait('@cta');

getBecomeTemplateCreatorCta().should('be.visible');

getCloseBecomeTemplateCreatorCtaButton().click();

getBecomeTemplateCreatorCta().should('not.exist');
});
});
5 changes: 5 additions & 0 deletions packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,11 @@ export class Server extends AbstractServer {
controllers.push(MFAController);
}

if (this.frontendService) {
const { CtaController } = await import('@/controllers/cta.controller');
controllers.push(CtaController);
}
tomi marked this conversation as resolved.
Show resolved Hide resolved

controllers.forEach((controller) => registerController(app, controller));
}

Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/controllers/cta.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import express from 'express';
import { Authorized, Get, RestController } from '@/decorators';
import { AuthenticatedRequest } from '@/requests';
import { CtaService } from '@/services/cta.service';

/**
* Controller for Call to Action (CTA) endpoints. CTAs are certain
* notifications/messages that are shown to users in the UI.
*/
@Authorized()
@RestController('/cta')
export class CtaController {
ivov marked this conversation as resolved.
Show resolved Hide resolved
constructor(private readonly ctaService: CtaService) {}

@Get('/')
async getCta(req: AuthenticatedRequest, res: express.Response) {
ivov marked this conversation as resolved.
Show resolved Hide resolved
const becomeCreator = await this.ctaService.getBecomeCreatorCta(req.user.id);

res.json({
becomeCreator,
});
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Service } from 'typedi';
import { DataSource, QueryFailedError, Repository } from 'typeorm';
import config from '@/config';
import type { StatisticsNames } from '../entities/WorkflowStatistics';
import { WorkflowStatistics } from '../entities/WorkflowStatistics';
import { StatisticsNames, WorkflowStatistics } from '../entities/WorkflowStatistics';
import type { User } from '@/databases/entities/User';

type StatisticsInsertResult = 'insert' | 'failed' | 'alreadyExists';
type StatisticsUpsertResult = StatisticsInsertResult | 'update';
Expand Down Expand Up @@ -98,4 +98,23 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
throw error;
}
}

async queryNumWorkflowsUserHasWithOver5ProdExecs(userId: User['id']): Promise<number> {
ivov marked this conversation as resolved.
Show resolved Hide resolved
const numWorkflows = await this.createQueryBuilder('workflow_statistics')
ivov marked this conversation as resolved.
Show resolved Hide resolved
.innerJoin('workflow_entity', 'workflow', 'workflow.id = workflow_statistics.workflowId')
.innerJoin(
'shared_workflow',
'shared_workflow',
'shared_workflow.workflowId = workflow_statistics.workflowId',
)
.innerJoin('role', 'role', 'role.id = shared_workflow.roleId')
.where('shared_workflow.userId = :userId', { userId })
.andWhere('workflow.active = :isActive', { isActive: true })
.andWhere('workflow_statistics.name = :name', { name: StatisticsNames.productionSuccess })
.andWhere('workflow_statistics.count >= 5')
.andWhere('role.name = :roleName', { roleName: 'owner' })
.getCount();

return numWorkflows;
}
}
20 changes: 20 additions & 0 deletions packages/cli/src/services/cta.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Service } from 'typedi';
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository';
import type { User } from '@/databases/entities/User';

export type UserCtas = {
becomeCreator: boolean;
};
tomi marked this conversation as resolved.
Show resolved Hide resolved

@Service()
export class CtaService {
constructor(private readonly workflowStatisticsRepository: WorkflowStatisticsRepository) {}

async getBecomeCreatorCta(userId: User['id']) {
// There need to be at least 3 workflows with at least 5 executions
const numWfsWithOver5ProdExecutions =
await this.workflowStatisticsRepository.queryNumWorkflowsUserHasWithOver5ProdExecs(userId);

return numWfsWithOver5ProdExecutions >= 3;
}
}
53 changes: 53 additions & 0 deletions packages/cli/test/integration/cta.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Container from 'typedi';
import * as testDb from './shared/testDb';
import { CtaService } from '@/services/cta.service';
import { createUser } from './shared/db/users';
import { createManyWorkflows } from './shared/db/workflows';
import type { User } from '@/databases/entities/User';
import { createWorkflowStatisticsItem } from './shared/db/workflowStatistics';
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';

describe('CtaService', () => {
let ctaService: CtaService;
let user: User;

beforeAll(async () => {
await testDb.init();

ctaService = Container.get(CtaService);
user = await createUser();
});

afterAll(async () => {
await testDb.terminate();
});

describe('getBecomeCreatorCta()', () => {
afterEach(async () => {
await testDb.truncate(['Workflow', 'SharedWorkflow']);
});

test.each([
[false, 0, 0],
[false, 2, 5],
[false, 3, 4],
[true, 3, 5],
])(
'should return %p if user has %d active workflows with %d executions',
ivov marked this conversation as resolved.
Show resolved Hide resolved
async (expected, numWorkflows, numExecutions) => {
const workflows = await createManyWorkflows(numWorkflows, { active: true }, user);

await Promise.all(
workflows.map(async (workflow) =>
createWorkflowStatisticsItem(workflow.id, {
count: numExecutions,
name: StatisticsNames.productionSuccess,
}),
),
);

expect(await ctaService.getBecomeCreatorCta(user.id)).toBe(expected);
},
);
});
});
21 changes: 21 additions & 0 deletions packages/cli/test/integration/shared/db/workflowStatistics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Container from 'typedi';
import { StatisticsNames, type WorkflowStatistics } from '@/databases/entities/WorkflowStatistics';
import type { Workflow } from 'n8n-workflow';
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository';

export async function createWorkflowStatisticsItem(
workflowId: Workflow['id'],
data?: Partial<WorkflowStatistics>,
) {
const entity = Container.get(WorkflowStatisticsRepository).create({
count: 0,
latestEvent: new Date().toISOString(),
name: StatisticsNames.manualSuccess,
...(data ?? {}),
workflowId,
});

await Container.get(WorkflowStatisticsRepository).insert(entity);

return entity;
}
12 changes: 12 additions & 0 deletions packages/editor-ui/src/api/ctas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { IRestApiContext } from '@/Interface';
import { get } from '@/utils/apiUtils';

export type UserCtas = {
becomeCreator: boolean;
};

export async function getUserCtas(context: IRestApiContext): Promise<UserCtas> {
const response = await get(context.baseUrl, '/cta');

return response;
ivov marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useBecomeTemplateCreatorStore } from './becomeTemplateCreatorStore';
import { useI18n } from '@/composables/useI18n';

const i18n = useI18n();
const store = useBecomeTemplateCreatorStore();
</script>

<template>
ivov marked this conversation as resolved.
Show resolved Hide resolved
<div
v-if="store.showBecomeCreatorCta"
:class="$style.container"
data-test-id="become-template-creator-cta"
>
<div :class="$style.textAndCloseButton">
<p :class="$style.text">
{{ i18n.baseText('becomeCreator.text') }}
</p>

<button
:class="$style.closeButton"
data-test-id="close-become-template-creator-cta"
@click="store.dismissCta()"
>
<n8n-icon icon="times" size="xsmall" :title="i18n.baseText('generic.close')" />
</button>
</div>

<a :class="$style.becomeButtonLink" href="https://creators.n8n.io/hub" target="_blank">
<n8n-button
ivov marked this conversation as resolved.
Show resolved Hide resolved
:label="i18n.baseText('becomeCreator.buttonText')"
size="xmini"
type="secondary"
/>
</a>
</div>
</template>

<style module lang="scss">
.container {
display: flex;
flex-direction: column;
background-color: var(--color-background-light);
border: var(--border-base);
border-right: 0;
}

.textAndCloseButton {
display: flex;
margin-top: var(--spacing-xs);
margin-left: var(--spacing-s);
margin-right: var(--spacing-2xs);
}

.text {
flex: 1;
font-size: var(--font-size-3xs);
tomi marked this conversation as resolved.
Show resolved Hide resolved
line-height: var(--font-line-height-compact);
}

.closeButton {
flex: 0;
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing-2xs);
height: var(--spacing-2xs);
border: none;
color: #909398;
tomi marked this conversation as resolved.
Show resolved Hide resolved
background-color: transparent;
}

.becomeButtonLink {
display: flex;
flex-direction: column;
margin: var(--spacing-s);
}
</style>
Loading
Loading