Skip to content

Commit

Permalink
feat: Nudge users to become template creators if eligible (#8357)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomi authored Jan 17, 2024
1 parent 3912c5e commit 9945701
Show file tree
Hide file tree
Showing 14 changed files with 385 additions and 3 deletions.
18 changes: 18 additions & 0 deletions cypress/composables/becomeTemplateCreatorCta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//#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/become-creator`, {
body: becomeCreator,
});
};

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

const WorkflowsPage = new WorkflowsPageClass();

describe('Become creator CTA', () => {
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 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 @@ -280,6 +280,11 @@ export class Server extends AbstractServer {
controllers.push(MFAController);
}

if (!config.getEnv('endpoints.disableUi')) {
const { CtaController } = await import('@/controllers/cta.controller');
controllers.push(CtaController);
}

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

Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/controllers/cta.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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
* messages that are shown to users in the UI.
*/
@Authorized()
@RestController('/cta')
export class CtaController {
constructor(private readonly ctaService: CtaService) {}

@Get('/become-creator')
async getCta(req: AuthenticatedRequest, res: express.Response) {
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,11 @@
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';
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
import { Role } from '@/databases/entities/Role';

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

async queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(userId: User['id']): Promise<number> {
return await this.createQueryBuilder('workflow_statistics')
.innerJoin(WorkflowEntity, 'workflow', 'workflow.id = workflow_statistics.workflowId')
.innerJoin(
SharedWorkflow,
'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();
}
}
18 changes: 18 additions & 0 deletions packages/cli/src/services/cta.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Service } from 'typedi';
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository';
import type { User } from '@/databases/entities/User';

@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.queryNumWorkflowsUserHasWithFiveOrMoreProdExecs(
userId,
);

return numWfsWithOver5ProdExecutions >= 3;
}
}
54 changes: 54 additions & 0 deletions packages/cli/test/integration/cta.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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 successful production executions',
async (expected, numWorkflows, numExecutions) => {
const workflows = await createManyWorkflows(numWorkflows, { active: true }, user);

await Promise.all(
workflows.map(
async (workflow) =>
await 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;
}
8 changes: 8 additions & 0 deletions packages/editor-ui/src/api/ctas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { IRestApiContext } from '@/Interface';
import { get } from '@/utils/apiUtils';

export async function getBecomeCreatorCta(context: IRestApiContext): Promise<boolean> {
const response = await get(context.baseUrl, '/cta/become-creator');

return response;
}
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>
<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>

<n8n-button
:class="$style.becomeCreatorButton"
:label="i18n.baseText('becomeCreator.buttonText')"
size="xmini"
type="secondary"
element="a"
href="https://creators.n8n.io/hub"
target="_blank"
/>
</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);
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: var(--color-text-light);
background-color: transparent;
}
.becomeCreatorButton {
margin: var(--spacing-s);
}
</style>
Loading

0 comments on commit 9945701

Please sign in to comment.