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(api): add ui scehma #6764

Merged
merged 8 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion apps/api/src/app/step-schemas/dtos/step-schema.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,102 @@
import { JSONSchema } from 'json-schema-to-ts';
import { StepType } from '@novu/shared';

import { ActionStepEnum, ChannelStepEnum } from '@novu/framework/internal';

const chatProperties = {
body: { type: 'chatBody' },
djabarovgeorge marked this conversation as resolved.
Show resolved Hide resolved
};

const emailProperties = {
subject: { type: 'emailSubject' },
body: { type: 'emailBody' },
};

const inAppProperties = {
subject: { type: 'inAppSubject' },
body: { type: 'inAppBody' },
avatar: { type: 'inAppAvatar' },
primaryAction: { type: 'inAppPrimaryAction' },
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wanted to clarify the reasoning behind our approach. The goal was not to create a one-to-one mapping but rather to specify particular overrides for the component type. Having a complete mapping may not be necessary, as it requires the front-end to duplicate specific elements for each type of step, which can lead to unnecessary complexity.
Could we revisit this to ensure it aligns with our original objectives?

secondaryAction: { type: 'inAppSecondaryAction' },
data: { type: 'inAppData' },
redirect: { type: 'inAppRedirect' },
};

const pushProperties = {
subject: { type: 'pushSubject' },
body: { type: 'pushBody' },
Copy link
Contributor Author

Choose a reason for hiding this comment

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

why not just text? it's going to make the FE work super problematic

Copy link
Contributor

Choose a reason for hiding this comment

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

as discussed in the meeting, in this solution, the client knows something rather than nothing the second solution suggested in the DX guide. Here, the client needs to know how to render pushBody. In the future, we can expand this approach to provide even more data to the client if needed.

};

const smsProperties = {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

also it's not correct, because in code first they can have as many controls as they want, they shouldn't be limited

Copy link
Contributor

Choose a reason for hiding this comment

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

i think we still need support for code first, we will need to store uiSchema on sync in the database and then extract it here.

body: { type: 'smsBody' },
};

const delayProperties = {
type: { type: 'delayType' },
amount: { type: 'delayAmount' },
unit: { type: 'delayUnit' },
};

const digestProperties = {
amount: { type: 'digestAmount' },
unit: { type: 'digestUnit' },
digestKey: { type: 'digestKey' },
lookBackWindow: { type: 'digestLookBackWindow' },
};

const customProperties = {};

export type UiSchema =
| {
type: 'email';
properties: typeof emailProperties;
}
| {
type: 'sms';
properties: typeof smsProperties;
}
| {
type: 'push';
properties: typeof pushProperties;
}
| {
type: 'chat';
properties: typeof chatProperties;
}
| {
type: 'in_app';
properties: typeof inAppProperties;
}
| {
type: 'delay';
properties: typeof delayProperties;
}
| {
type: 'digest';
properties: typeof digestProperties;
}
| {
type: 'custom';
properties: typeof customProperties;
};

export const mapStepTypeToUiSchema = {
[ChannelStepEnum.SMS]: { type: 'sms', properties: smsProperties },
[ChannelStepEnum.EMAIL]: { type: 'email', properties: emailProperties },
[ChannelStepEnum.PUSH]: { type: 'push', properties: pushProperties },
[ChannelStepEnum.CHAT]: { type: 'chat', properties: chatProperties },
[ChannelStepEnum.IN_APP]: { type: 'in_app', properties: inAppProperties },
[ActionStepEnum.DELAY]: { type: 'delay', properties: delayProperties },
[ActionStepEnum.DIGEST]: { type: 'digest', properties: digestProperties },
[ActionStepEnum.CUSTOM]: { type: 'custom', properties: customProperties },
} as const satisfies Record<ChannelStepEnum | ActionStepEnum, UiSchema>;

export class ControlsDto {
schema: JSONSchema;
uiSchema: UiSchema;
}

export type StepSchemaDto = {
controls: JSONSchema;
Copy link
Contributor

Choose a reason for hiding this comment

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

As discussed, I've already removed the controls from the DTO in PR #6742.

controls: ControlsDto;
variables: JSONSchema;
};
119 changes: 65 additions & 54 deletions apps/api/src/app/step-schemas/e2e/get-step-schema.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { expect } from 'chai';
import { UserSession } from '@novu/testing';
import { CreateWorkflowDto, StepTypeEnum, WorkflowCreationSourceEnum, WorkflowResponseDto } from '@novu/shared';

describe('Get Step Schema - /step-schemas?workflowId=:workflowId&stepId=:stepId&stepType=:stepType (GET)', async () => {
describe('Get Step Schema - /steps?workflowId=:workflowId&stepId=:stepId&stepType=:stepType (GET)', async () => {
let session: UserSession;
let createdWorkflow: WorkflowResponseDto;

Expand All @@ -16,64 +16,75 @@ describe('Get Step Schema - /step-schemas?workflowId=:workflowId&stepId=:stepId&
it('should get step schema for in app step type', async function () {
const { data } = (await getStepSchema({ session, stepType: StepTypeEnum.IN_APP })).body;

// in app output schema
expect(data.controls.type).to.equal('object');
expect(data.controls.properties).to.have.property('subject');
expect(data.controls.properties.subject.type).to.equal('string');
expect(data.controls.properties).to.have.property('body');
expect(data.controls.properties.body.type).to.equal('string');
expect(data.controls.properties).to.have.property('avatar');
expect(data.controls.properties.avatar.type).to.equal('string');
expect(data.controls.properties.avatar.format).to.equal('uri');
expect(data.controls.properties).to.have.property('primaryAction');
expect(data.controls.properties.primaryAction.type).to.equal('object');
expect(data.controls.properties.primaryAction.properties).to.have.property('label');
expect(data.controls.properties.primaryAction.properties.label.type).to.equal('string');
expect(data.controls.properties.primaryAction.properties).to.have.property('redirect');
expect(data.controls.properties.primaryAction.properties.redirect.type).to.equal('object');
expect(data.controls.properties.primaryAction.required).to.deep.equal(['label']);
expect(data.controls.properties).to.have.property('secondaryAction');
expect(data.controls.properties.secondaryAction.type).to.equal('object');
expect(data.controls.properties.secondaryAction.properties).to.have.property('label');
expect(data.controls.properties.secondaryAction.properties.label.type).to.equal('string');
expect(data.controls.properties.secondaryAction.properties).to.have.property('redirect');
expect(data.controls.properties.secondaryAction.properties.redirect.type).to.equal('object');
expect(data.controls.properties.secondaryAction.required).to.deep.equal(['label']);
expect(data.controls.properties).to.have.property('data');
expect(data.controls.properties.data.type).to.equal('object');
expect(data.controls.properties.data.additionalProperties).to.be.true;
expect(data.controls.properties).to.have.property('redirect');
expect(data.controls.properties.redirect.type).to.equal('object');
expect(data.controls.properties.redirect.properties).to.have.property('url');
expect(data.controls.properties.redirect.properties.url.type).to.equal('string');
expect(data.controls.properties.redirect.properties).to.have.property('target');
expect(data.controls.properties.redirect.properties.target.type).to.equal('string');
expect(data.controls.required).to.deep.equal(['body']);
expect(data.controls.additionalProperties).to.be.false;
expect(data.controls.description).to.not.be.empty;
const { controls, variables } = data;
const { schema, uiSchema } = controls;

expect(data.variables.type).to.equal('object');
expect(data.variables.description).to.not.be.empty;
expect(data.variables.properties).to.have.property('subscriber');
expect(data.variables.properties.subscriber.type).to.equal('object');
expect(data.variables.properties.subscriber.description).to.not.be.empty;
expect(data.variables.properties.subscriber.properties).to.have.property('firstName');
expect(data.variables.properties.subscriber.properties.firstName.type).to.equal('string');
expect(data.variables.properties.subscriber.properties).to.have.property('lastName');
expect(data.variables.properties.subscriber.properties.lastName.type).to.equal('string');
expect(data.variables.properties.subscriber.properties).to.have.property('email');
expect(data.variables.properties.subscriber.properties.email.type).to.equal('string');
expect(data.variables.properties.subscriber.required).to.deep.equal([
expect(schema.type).to.equal('object');
expect(schema.properties).to.have.property('subject');
expect(schema.properties.subject.type).to.equal('string');
expect(schema.properties).to.have.property('body');
expect(schema.properties.body.type).to.equal('string');
expect(schema.properties).to.have.property('avatar');
expect(schema.properties.avatar.type).to.equal('string');
expect(schema.properties.avatar.format).to.equal('uri');
expect(schema.properties).to.have.property('primaryAction');
expect(schema.properties.primaryAction.type).to.equal('object');
expect(schema.properties.primaryAction.properties).to.have.property('label');
expect(schema.properties.primaryAction.properties.label.type).to.equal('string');
expect(schema.properties.primaryAction.properties).to.have.property('redirect');
expect(schema.properties.primaryAction.properties.redirect.type).to.equal('object');
expect(schema.properties.primaryAction.required).to.deep.equal(['label']);
expect(schema.properties).to.have.property('secondaryAction');
expect(schema.properties.secondaryAction.type).to.equal('object');
expect(schema.properties.secondaryAction.properties).to.have.property('label');
expect(schema.properties.secondaryAction.properties.label.type).to.equal('string');
expect(schema.properties.secondaryAction.properties).to.have.property('redirect');
expect(schema.properties.secondaryAction.properties.redirect.type).to.equal('object');
expect(schema.properties.secondaryAction.required).to.deep.equal(['label']);
expect(schema.properties).to.have.property('data');
expect(schema.properties.data.type).to.equal('object');
expect(schema.properties.data.additionalProperties).to.be.true;
expect(schema.properties).to.have.property('redirect');
expect(schema.properties.redirect.type).to.equal('object');
expect(schema.properties.redirect.properties).to.have.property('url');
expect(schema.properties.redirect.properties.url.type).to.equal('string');
expect(schema.properties.redirect.properties).to.have.property('target');
expect(schema.properties.redirect.properties.target.type).to.equal('string');
expect(schema.required).to.deep.equal(['body']);
expect(schema.additionalProperties).to.be.false;
expect(schema.description).to.not.be.empty;

expect(uiSchema.type).to.equal('in_app');
expect(uiSchema.properties.subject.type).to.equal('inAppSubject');
expect(uiSchema.properties.body.type).to.equal('inAppBody');
expect(uiSchema.properties.avatar.type).to.equal('inAppAvatar');
expect(uiSchema.properties.primaryAction.type).to.equal('inAppPrimaryAction');
expect(uiSchema.properties.secondaryAction.type).to.equal('inAppSecondaryAction');
expect(uiSchema.properties.data.type).to.equal('inAppData');
expect(uiSchema.properties.redirect.type).to.equal('inAppRedirect');

expect(variables.type).to.equal('object');
expect(variables.description).to.not.be.empty;
expect(variables.properties).to.have.property('subscriber');
expect(variables.properties.subscriber.type).to.equal('object');
expect(variables.properties.subscriber.description).to.not.be.empty;
expect(variables.properties.subscriber.properties).to.have.property('firstName');
expect(variables.properties.subscriber.properties.firstName.type).to.equal('string');
expect(variables.properties.subscriber.properties).to.have.property('lastName');
expect(variables.properties.subscriber.properties.lastName.type).to.equal('string');
expect(variables.properties.subscriber.properties).to.have.property('email');
expect(variables.properties.subscriber.properties.email.type).to.equal('string');
expect(variables.properties.subscriber.required).to.deep.equal([
'firstName',
'lastName',
'email',
'subscriberId',
]);
expect(data.variables.properties).to.have.property('steps');
expect(data.variables.properties.steps.type).to.equal('object');
expect(data.variables.properties.steps.description).to.not.be.empty;
expect(data.variables.required).to.deep.equal(['subscriber']);
expect(data.variables.additionalProperties).to.be.false;
expect(variables.properties).to.have.property('steps');
expect(variables.properties.steps.type).to.equal('object');
expect(variables.properties.steps.description).to.not.be.empty;
expect(variables.required).to.deep.equal(['subscriber']);
expect(variables.additionalProperties).to.be.false;
});

it('should get error for invalid step type', async function () {
Expand Down Expand Up @@ -130,5 +141,5 @@ const getStepSchema = async ({
if (stepId) queryParams.append('stepId', stepId);
if (stepType) queryParams.append('stepType', stepType);

return await session.testAgent.get(`/v1/step-schemas?${queryParams.toString()}`);
return await session.testAgent.get(`/v1/steps?${queryParams.toString()}`);
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,45 @@
import { ActionStepEnum, actionStepSchemas, ChannelStepEnum, channelStepSchemas } from '@novu/framework/internal';
import { EmailStepControlSchema } from '@novu/shared';
import { ControlsDto, mapStepTypeToUiSchema } from '../../dtos/step-schema.dto';

export const mapStepTypeToOutput = {
[ChannelStepEnum.SMS]: channelStepSchemas[ChannelStepEnum.SMS].output,
[ChannelStepEnum.EMAIL]: EmailStepControlSchema,
[ChannelStepEnum.PUSH]: channelStepSchemas[ChannelStepEnum.PUSH].output,
[ChannelStepEnum.CHAT]: channelStepSchemas[ChannelStepEnum.CHAT].output,
[ChannelStepEnum.IN_APP]: channelStepSchemas[ChannelStepEnum.IN_APP].output,
[ActionStepEnum.DELAY]: actionStepSchemas[ActionStepEnum.DELAY].output,
[ActionStepEnum.DIGEST]: actionStepSchemas[ActionStepEnum.DIGEST].output,
export const PERMISSIVE_EMPTY_SCHEMA = {
type: 'object',
properties: {},
required: [],
additionalProperties: true,
} as const;

export const mapStepTypeToControlSchema: Record<ChannelStepEnum | ActionStepEnum, ControlsDto> = {
[ChannelStepEnum.SMS]: {
schema: channelStepSchemas[ChannelStepEnum.SMS].output,
uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.SMS],
},
[ChannelStepEnum.EMAIL]: {
schema: EmailStepControlSchema,
uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.EMAIL],
},
[ChannelStepEnum.PUSH]: {
schema: channelStepSchemas[ChannelStepEnum.PUSH].output,
uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.PUSH],
},
[ChannelStepEnum.CHAT]: {
schema: channelStepSchemas[ChannelStepEnum.CHAT].output,
uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.CHAT],
},
[ChannelStepEnum.IN_APP]: {
schema: channelStepSchemas[ChannelStepEnum.IN_APP].output,
uiSchema: mapStepTypeToUiSchema[ChannelStepEnum.IN_APP],
},
[ActionStepEnum.DELAY]: {
schema: actionStepSchemas[ActionStepEnum.DELAY].output,
uiSchema: mapStepTypeToUiSchema[ActionStepEnum.DELAY],
},
[ActionStepEnum.DIGEST]: {
schema: actionStepSchemas[ActionStepEnum.DIGEST].output,
uiSchema: mapStepTypeToUiSchema[ActionStepEnum.DIGEST],
},
[ActionStepEnum.CUSTOM]: {
schema: PERMISSIVE_EMPTY_SCHEMA,
uiSchema: mapStepTypeToUiSchema[ActionStepEnum.CUSTOM],
},
};
2 changes: 1 addition & 1 deletion apps/api/src/app/step-schemas/step-schemas.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { GetStepSchemaUseCase } from './usecases/get-step-schema/get-step-schema
import { StepSchemaDto } from './dtos/step-schema.dto';
import { ParseSlugIdPipe } from '../workflows-v2/pipes/parse-slug-id.pipe';

@Controller('/step-schemas')
@Controller('/steps')
@UserAuthentication()
@UseInterceptors(ClassSerializerInterceptor)
export class StepSchemasController {
Expand Down
Loading
Loading