Skip to content

Commit

Permalink
Merge pull request #5323 from novuhq/add-custom-step
Browse files Browse the repository at this point in the history
feat(wip): add custom step
  • Loading branch information
ainouzgali committed May 5, 2024
2 parents fb347b8 + 711988e commit 1dbf84d
Show file tree
Hide file tree
Showing 20 changed files with 441 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .idea/.name

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .source
29 changes: 29 additions & 0 deletions apps/api/e2e/echo.server.ts
@@ -0,0 +1,29 @@
import * as http from 'http';
import * as express from 'express';
import { serve } from '@novu/echo/express';
import { Echo } from '@novu/echo';

class EchoServer {
private server: express.Express;
private app: http.Server;
private port = 9999;
public echo = new Echo({ devModeBypassAuthentication: true });

get serverPath() {
return `http://localhost:${this.port}`;
}

async start() {
this.server = express();
this.server.use(express.json());
this.server.use(serve({ client: this.echo }));

this.app = await this.server.listen(this.port);
}

async stop() {
await this.app.close();
}
}

export const echoServer = new EchoServer();
4 changes: 4 additions & 0 deletions apps/api/e2e/setup.ts
Expand Up @@ -4,6 +4,7 @@ import * as sinon from 'sinon';
import * as chai from 'chai';

import { bootstrap } from '../src/bootstrap';
import { echoServer } from './echo.server';

const dalService = new DalService();

Expand All @@ -13,11 +14,14 @@ before(async () => {
*/
chai.config.truncateThreshold = 0;
await testServer.create(await bootstrap());
await echoServer.start();

await dalService.connect(process.env.MONGO_URL);
});

after(async () => {
await testServer.teardown();
await echoServer.stop();

try {
await dalService.destroy();
Expand Down
4 changes: 3 additions & 1 deletion apps/api/package.json
Expand Up @@ -47,6 +47,7 @@
"@novu/shared": "^0.24.1",
"@novu/stateless": "^0.24.1",
"@novu/testing": "^0.24.1",
"@novu/echo": "^0.0.1-alpha.25",
"@sendgrid/mail": "^8.1.0",
"@sentry/hub": "^7.40.0",
"@sentry/node": "^7.40.0",
Expand Down Expand Up @@ -114,7 +115,8 @@
"ts-loader": "~9.4.0",
"ts-node": "~10.9.1",
"tsconfig-paths": "~4.1.0",
"typescript": "4.9.5"
"typescript": "4.9.5",
"express": "^4.17.1"
},
"optionalDependencies": {
"@novu/ee-echo-api": "^0.24.1",
Expand Down
102 changes: 102 additions & 0 deletions apps/api/src/app/events/e2e/echo-trigger.e2e-ee.ts
@@ -0,0 +1,102 @@
import axios from 'axios';
import { expect } from 'chai';
import { UserSession, SubscribersService } from '@novu/testing';
import { MessageRepository, SubscriberEntity, NotificationTemplateRepository } from '@novu/dal';
import { StepTypeEnum } from '@novu/shared';
import { echoServer } from '../../../../e2e/echo.server';

const eventTriggerPath = '/v1/events/trigger';

describe('Echo Trigger ', async () => {
let session: UserSession;
const messageRepository = new MessageRepository();
const workflowsRepository = new NotificationTemplateRepository();
let subscriber: SubscriberEntity;
let subscriberService: SubscribersService;

beforeEach(async () => {
session = new UserSession();
await session.initialize();
subscriberService = new SubscribersService(session.organization._id, session.environment._id);
subscriber = await subscriberService.createSubscriber();
});

it('should trigger the echo workflow', async () => {
const workflowId = 'hello-world';
await echoServer.echo.workflow(
workflowId,
async ({ step, payload }) => {
await step.email(
'send-email',
async (inputs) => {
return {
subject: 'This is an email subject ' + inputs.name,
body: 'Body result ' + payload.name,
};
},
{
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', default: 'TEST' },
},
} as const,
}
);
},
{
payloadSchema: {
type: 'object',
properties: {
name: { type: 'string', default: 'default_name' },
},
required: [],
additionalProperties: false,
} as const,
}
);

const resultDiscover = await axios.get(echoServer.serverPath + '/echo?action=discover');

await session.testAgent.post(`/v1/echo/sync`).send({
chimeraUrl: echoServer.serverPath + '/echo',
workflows: resultDiscover.data.workflows,
});

const workflow = await workflowsRepository.findByTriggerIdentifier(session.environment._id, workflowId);
expect(workflow).to.be.ok;

if (!workflow) {
throw new Error('Workflow not found');
}

await axios.post(
`${session.serverUrl}${eventTriggerPath}`,
{
name: workflowId,
to: {
subscriberId: subscriber.subscriberId,
email: '[email protected]',
},
payload: {
name: 'test_name',
},
},
{
headers: {
authorization: `ApiKey ${session.apiKey}`,
},
}
);
await session.awaitRunningJobs(workflow._id);

const messagesAfter = await messageRepository.find({
_environmentId: session.environment._id,
_subscriberId: subscriber._id,
channel: StepTypeEnum.EMAIL,
});

expect(messagesAfter.length).to.be.eq(1);
expect(messagesAfter[0].subject).to.include('This is an email subject TEST');
});
});
Expand Up @@ -116,7 +116,10 @@ export class GetActiveIntegrationsStatus {
for (const step of uniqueSteps) {
const stepType = step.template?.type;
const skipStep =
stepType === StepTypeEnum.DELAY || stepType === StepTypeEnum.DIGEST || stepType === StepTypeEnum.TRIGGER;
stepType === StepTypeEnum.DELAY ||
stepType === StepTypeEnum.DIGEST ||
stepType === StepTypeEnum.TRIGGER ||
stepType === StepTypeEnum.CUSTOM;
const isStepWithPrimaryIntegration = stepType === StepTypeEnum.EMAIL || stepType === StepTypeEnum.SMS;
if (stepType && !skipStep) {
const hasActiveIntegrations = activeChannelsStatus[stepType].hasActiveIntegrations;
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/pages/templates/components/ChannelPreview.tsx
Expand Up @@ -5,6 +5,7 @@ import { ChatPreview, EmailPreview, InAppPreview, PushPreview, SmsPreview } from
import { useNavigateFromEditor } from '../hooks/useNavigateFromEditor';
import { useStepIndex } from '../hooks/useStepIndex';
import { ChannelPreviewSidebar } from './ChannelPreviewSidebar';
import { TemplateCustomEditor } from './custom-editor/TemplateCustomEditor';

export const PreviewComponent = ({ channel }: { channel: StepTypeEnum }) => {
switch (channel) {
Expand Down Expand Up @@ -32,8 +33,11 @@ export const PreviewComponent = ({ channel }: { channel: StepTypeEnum }) => {
case StepTypeEnum.DIGEST:
return <>DIGEST</>;

case StepTypeEnum.CUSTOM:
return <TemplateCustomEditor />;

default:
return <>dummy</>;
return <>Unknown Step</>;
}
};

Expand Down
21 changes: 20 additions & 1 deletion apps/web/src/pages/templates/components/ChannelStepEditor.tsx
Expand Up @@ -11,12 +11,15 @@ import { DigestMetadata } from '../workflow/DigestMetadata';
import { DelayMetadata } from '../workflow/DelayMetadata';
import { useStepIndex } from '../hooks/useStepIndex';
import { useNavigateFromEditor } from '../hooks/useNavigateFromEditor';
import { TemplateCustomEditor } from './custom-editor/TemplateCustomEditor';
import { useTemplateEditorForm } from './TemplateEditorFormProvider';

export const ChannelStepEditor = () => {
const { channel } = useParams<{
channel: StepTypeEnum | undefined;
}>();
const { stepIndex } = useStepIndex();
const { stepIndex, step } = useStepIndex();
const { template } = useTemplateEditorForm();

useNavigateFromEditor();

Expand All @@ -40,6 +43,22 @@ export const ChannelStepEditor = () => {
);
}

if (channel === StepTypeEnum.CUSTOM) {
return (
<StepEditorSidebar>
<TemplateCustomEditor />
</StepEditorSidebar>
);
}

if (template?.type === 'ECHO' && (channel === StepTypeEnum.DIGEST || channel === StepTypeEnum.DELAY)) {
return (
<StepEditorSidebar>
<TemplateCustomEditor />
</StepEditorSidebar>
);
}

return (
<>
<StepEditorSidebar>
Expand Down
@@ -0,0 +1,37 @@
import { Controller, useFormContext } from 'react-hook-form';
import { StepSettings } from '../../workflow/SideBar/StepSettings';
import { useStepFormPath } from '../../hooks/useStepFormPath';
import { useState } from 'react';
import { Grid, Stack } from '@mantine/core';
import { InputVariablesForm } from '../InputVariablesForm';

export function TemplateCustomEditor() {
const stepFormPath = useStepFormPath();
const { control } = useFormContext();

const [inputVariables, setInputVariables] = useState();

return (
<>
<StepSettings />
<Grid gutter={24}>
<Grid.Col span={'auto'}>
<Controller
name={`${stepFormPath}.template.content` as any}
defaultValue=""
control={control}
render={({ field }) => (
<Stack spacing={8}>
<InputVariablesForm
onChange={(values) => {
setInputVariables(values);
}}
/>
</Stack>
)}
/>
</Grid.Col>
</Grid>
</>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/pages/templates/constants.tsx
Expand Up @@ -47,7 +47,7 @@ export const ordinalNumbers = {
10: 'tenth',
};

export const stepNames: Record<StepTypeEnum | ChannelTypeEnum, string> = {
export const stepNames: Record<StepTypeEnum, string> = {
email: 'Email',
chat: 'Chat',
in_app: 'In-App',
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/pages/templates/workflow/SideBar/AddStepMenu.tsx
Expand Up @@ -5,6 +5,7 @@ import { colors, DragButton, Tooltip } from '@novu/design-system';
import { useEnvController } from '../../../../hooks';
import { channels, NodeTypeEnum } from '../../../../utils/channels';
import { TOP_ROW_HEIGHT } from '../WorkflowEditor';
import { StepTypeEnum } from '@novu/shared';

export function AddStepMenu({
setDragging,
Expand All @@ -26,6 +27,7 @@ export function AddStepMenu({
<Stack spacing={18} mb={16}>
{channels
.filter((channel) => channel.type === NodeTypeEnum.ACTION)
.filter((channel) => channel.channelType !== StepTypeEnum.CUSTOM)
.map((channel, index) => (
<DraggableNode key={index} channel={channel} setDragging={setDragging} onDragStart={onDragStart} />
))}
Expand Down
22 changes: 16 additions & 6 deletions apps/web/src/utils/channels.ts
Expand Up @@ -8,6 +8,7 @@ import {
InAppFilled,
PushFilled,
SmsFilled,
Bolt,
} from '@novu/design-system';

export enum NodeTypeEnum {
Expand All @@ -16,7 +17,7 @@ export enum NodeTypeEnum {
}

interface IChannelDefinition {
tabKey: StepTypeEnum | ChannelTypeEnum;
tabKey: StepTypeEnum;
label: string;
description: string;
Icon: React.FC<any>;
Expand All @@ -35,7 +36,7 @@ export const CHANNEL_TYPE_TO_STRING: Record<ChannelTypeEnum, string> = {

export const channels: IChannelDefinition[] = [
{
tabKey: ChannelTypeEnum.IN_APP,
tabKey: StepTypeEnum.IN_APP,
label: 'In-App',
description: 'Send notifications to the in-app notification center',
Icon: InAppFilled,
Expand All @@ -44,7 +45,7 @@ export const channels: IChannelDefinition[] = [
type: NodeTypeEnum.CHANNEL,
},
{
tabKey: ChannelTypeEnum.EMAIL,
tabKey: StepTypeEnum.EMAIL,
label: 'Email',
description: 'Send using one of our email integrations',
Icon: EmailFilled,
Expand All @@ -53,7 +54,7 @@ export const channels: IChannelDefinition[] = [
type: NodeTypeEnum.CHANNEL,
},
{
tabKey: ChannelTypeEnum.SMS,
tabKey: StepTypeEnum.SMS,
label: 'SMS',
description: "Send an SMS directly to the user's phone",
Icon: SmsFilled,
Expand All @@ -80,7 +81,16 @@ export const channels: IChannelDefinition[] = [
type: NodeTypeEnum.ACTION,
},
{
tabKey: ChannelTypeEnum.CHAT,
tabKey: StepTypeEnum.CUSTOM,
label: 'Custom',
description: 'Run custom code',
Icon: Bolt,
testId: 'customSelector',
channelType: StepTypeEnum.CUSTOM,
type: NodeTypeEnum.ACTION,
},
{
tabKey: StepTypeEnum.CHAT,
label: 'Chat',
description: 'Send a chat message',
Icon: ChatFilled,
Expand All @@ -89,7 +99,7 @@ export const channels: IChannelDefinition[] = [
type: NodeTypeEnum.CHANNEL,
},
{
tabKey: ChannelTypeEnum.PUSH,
tabKey: StepTypeEnum.PUSH,
label: 'Push',
description: "Send an Push Notification to a user's device",
Icon: PushFilled,
Expand Down

0 comments on commit 1dbf84d

Please sign in to comment.