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
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>
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>

<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);
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: var(--color-text-light);
background-color: transparent;
}

.becomeCreatorButton {
margin: var(--spacing-s);
}
</style>
Loading
Loading