Skip to content

Commit

Permalink
Merge branch 'next' into billing-alerts
Browse files Browse the repository at this point in the history
  • Loading branch information
scopsy committed Nov 7, 2024
2 parents 0faca84 + b427d72 commit fd55158
Show file tree
Hide file tree
Showing 55 changed files with 4,074 additions and 1,714 deletions.
20 changes: 20 additions & 0 deletions apps/api/src/app/workflows-v2/shared/build-string-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { JSONSchema } from 'json-schema-to-ts';

/**
* Builds a JSON schema object where each variable becomes a string property.
*/
export function buildJSONSchema(variables: Record<string, unknown>): JSONSchema {
const properties: Record<string, JSONSchema> = {};

for (const [variableKey, variableValue] of Object.entries(variables)) {
properties[variableKey] = {
type: 'string',
default: variableValue,
};
}

return {
type: 'object',
properties,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mapStepTypeToResult } from '../../shared';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
import { InvalidStepException } from '../../exceptions/invalid-step.exception';
import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder';
import { buildJSONSchema } from '../../shared/build-string-schema';

@Injectable()
export class GetStepDataUsecase {
Expand Down Expand Up @@ -39,12 +40,12 @@ export class GetStepDataUsecase {
};
}

private buildPayloadSchema(controlValues: Record<string, any>) {
private buildPayloadSchema(controlValues: Record<string, unknown>) {
const payloadVariables = this.buildDefaultPayloadUseCase.execute({
controlValues,
}).previewPayload.payload;

return buildStringSchema(payloadVariables || {});
return buildJSONSchema(payloadVariables || {});
}

private async fetchWorkflow(command: GetStepDataCommand) {
Expand Down Expand Up @@ -158,22 +159,3 @@ function buildPreviousStepsSchema(previousSteps: NotificationStepEntity[] | unde
description: 'Previous Steps Results',
} as const satisfies JSONSchema;
}

/**
* Builds a JSON schema object where each variable becomes a string property.
*/
function buildStringSchema(variables: Record<string, unknown>): JSONSchema {
const properties: Record<string, JSONSchema> = {};

for (const [variableKey, variableValue] of Object.entries(variables)) {
properties[variableKey] = {
type: 'string',
default: variableValue,
};
}

return {
type: 'object',
properties,
};
}
108 changes: 70 additions & 38 deletions apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,89 @@
import { JSONSchema } from 'json-schema-to-ts';
import { Injectable } from '@nestjs/common';
import { NotificationTemplateEntity } from '@novu/dal';
import { UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared';
import { ControlValuesRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal';
import { ControlValuesLevelEnum, StepTypeEnum, UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared';

import { WorkflowTestDataCommand } from './test-data.command';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
import { GetWorkflowByIdsCommand } from '../get-workflow-by-ids/get-workflow-by-ids.command';

const buildToFieldSchema = ({ user }: { user: UserSessionData }) =>
({
type: 'object',
properties: {
subscriberId: { type: 'string', default: user._id },
/*
* TODO: the email and phone fields should be dynamic based on the workflow steps
* if the workflow has has an email step, then email is required etc
*/
email: { type: 'string', default: user.email ?? '', format: 'email' },
phone: { type: 'string', default: '' },
},
required: ['subscriberId', 'email', 'phone'],
additionalProperties: false,
}) as const satisfies JSONSchema;

const buildPayloadSchema = () =>
({
type: 'object',
description: 'Schema representing the workflow payload',
properties: {
/*
* TODO: the properties should be dynamic based on the workflow variables
*/
example: { type: 'string', description: 'Example field', default: 'payload.example' },
},
required: ['subscriberId', 'email', 'phone'],
additionalProperties: false,
}) as const satisfies JSONSchema;
import { BuildDefaultPayloadUseCase } from '../build-payload-from-placeholder';
import { buildJSONSchema } from '../../shared/build-string-schema';

@Injectable()
export class WorkflowTestDataUseCase {
constructor(private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase) {}
constructor(
private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase,
private controlValuesRepository: ControlValuesRepository,
private buildDefaultPayloadUseCase: BuildDefaultPayloadUseCase
) {}

async execute(command: WorkflowTestDataCommand): Promise<WorkflowTestDataResponseDto> {
const _workflowEntity: NotificationTemplateEntity | null = await this.getWorkflowByIdsUseCase.execute(
const _workflowEntity: NotificationTemplateEntity = await this.fetchWorkflow(command);
const toSchema = buildToFieldSchema({ user: command.user, steps: _workflowEntity.steps });
const payloadSchema = await this.buildPayloadSchema(command, _workflowEntity);

return {
to: toSchema,
payload: payloadSchema,
};
}

private async fetchWorkflow(command: WorkflowTestDataCommand): Promise<NotificationTemplateEntity> {
return await this.getWorkflowByIdsUseCase.execute(
GetWorkflowByIdsCommand.create({
...command,
identifierOrInternalId: command.identifierOrInternalId,
})
);
}

return {
to: buildToFieldSchema({ user: command.user }),
payload: buildPayloadSchema(),
};
private async buildPayloadSchema(command: WorkflowTestDataCommand, _workflowEntity: NotificationTemplateEntity) {
let payloadVariables: Record<string, unknown> = {};
for (const step of _workflowEntity.steps) {
const newValues = await this.getValues(command.user, step._templateId, _workflowEntity._id);

/*
* we need to build the payload defaults for each step,
* because of possible duplicated values (like subject, body, etc...)
*/
const currPayloadVariables = this.buildDefaultPayloadUseCase.execute({
controlValues: newValues,
}).previewPayload.payload;
payloadVariables = { ...payloadVariables, ...currPayloadVariables };
}

return buildJSONSchema(payloadVariables || {});
}

private async getValues(user: UserSessionData, _stepId: string, _workflowId: string) {
const controlValuesEntity = await this.controlValuesRepository.findOne({
_environmentId: user.environmentId,
_organizationId: user.organizationId,
_workflowId,
_stepId,
level: ControlValuesLevelEnum.STEP_CONTROLS,
});

return controlValuesEntity?.controls || {};
}
}

const buildToFieldSchema = ({ user, steps }: { user: UserSessionData; steps: NotificationStepEntity[] }) => {
const isEmailExist = isContainsStepType(steps, StepTypeEnum.EMAIL);
const isSmsExist = isContainsStepType(steps, StepTypeEnum.SMS);

return {
type: 'object',
properties: {
subscriberId: { type: 'string', default: user._id },
...(isEmailExist ? { email: { type: 'string', default: user.email ?? '', format: 'email' } } : {}),
...(isSmsExist ? { phone: { type: 'string', default: '' } } : {}),
},
required: ['subscriberId', ...(isEmailExist ? ['email'] : []), ...(isSmsExist ? ['phone'] : [])],
additionalProperties: false,
} as const satisfies JSONSchema;
};

function isContainsStepType(steps: NotificationStepEntity[], type: StepTypeEnum) {
return steps.some((step) => step.template?.type === type);
}
76 changes: 76 additions & 0 deletions apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,70 @@ describe('Workflow Controller E2E API Testing', () => {
});
});

describe('Get Test Data Permutations', () => {
it('should get test data', async () => {
const steps = [
{
...buildEmailStep(),
controlValues: {
body: 'Welcome to our newsletter {{bodyText}}{{bodyText2}}{{payload.emailPrefixBodyText}}',
subject: 'Welcome to our newsletter {{subjectText}} {{payload.prefixSubjectText}}',
},
},
{ ...buildInAppStep(), controlValues: { subject: 'Welcome to our newsletter {{payload.inAppSubjectText}}' } },
];
const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto('', { steps });
const res = await session.testAgent.post(`${v2Prefix}/workflows`).send(createWorkflowDto);
expect(res.status).to.be.equal(201);
const workflowCreated: WorkflowResponseDto = res.body.data;
const workflowTestData = await getWorkflowTestData(workflowCreated._id);

expect(workflowTestData).to.be.ok;
expect(workflowTestData.payload).to.deep.equal({
type: 'object',
properties: {
emailPrefixBodyText: {
type: 'string',
default: '{{payload.emailPrefixBodyText}}',
},
prefixSubjectText: {
type: 'string',
default: '{{payload.prefixSubjectText}}',
},
inAppSubjectText: {
type: 'string',
default: '{{payload.inAppSubjectText}}',
},
},
});

/*
* Validate the 'to' schema
* Note: Can't use deep comparison since emails differ between local and CI environments due to user sessions
*/
const toSchema = workflowTestData.to;
if (
typeof toSchema === 'boolean' ||
typeof toSchema.properties?.subscriberId === 'boolean' ||
typeof toSchema.properties?.email === 'boolean'
) {
expect((toSchema as any).type).to.be.a('boolean');
expect(((toSchema as any).properties?.subscriberId as any).type).to.be.a('boolean');
expect(((toSchema as any).properties?.email as any).type).to.be.a('boolean');
throw new Error('To schema is not a boolean');
}
expect(toSchema.type).to.equal('object');
expect(toSchema.properties?.subscriberId.type).to.equal('string');
expect(toSchema.properties?.subscriberId.default).to.equal(session.user._id);
expect(toSchema.properties?.email.type).to.equal('string');
expect(toSchema.properties?.email.format).to.equal('email');
expect(toSchema.properties?.email.default).to.be.a('string');
expect(toSchema.properties?.email.default).to.not.equal('');
expect(toSchema.required).to.deep.equal(['subscriberId', 'email']);
expect(toSchema.additionalProperties).to.be.false;
});
});

async function updateWorkflowRest(id: string, workflow: UpdateWorkflowDto): Promise<WorkflowResponseDto> {
const novuRestResult = await workflowsClient.updateWorkflow(id, workflow);
if (novuRestResult.isSuccessResult()) {
Expand Down Expand Up @@ -522,6 +586,18 @@ describe('Workflow Controller E2E API Testing', () => {
return value;
}

async function getWorkflowTestData(workflowId: string, envId?: string) {
const novuRestResult = await createWorkflowClient(session.serverUrl, getHeaders(envId)).getWorkflowTestData(
workflowId
);
if (!novuRestResult.isSuccessResult()) {
throw new Error(novuRestResult.error!.responseText);
}
const { value } = novuRestResult;

return value;
}

async function getWorkflowStepControlValues(
workflow: WorkflowResponseDto,
step: StepDto & { _id: string; slug: Slug; stepId: string },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ export const WorkflowEditorProvider = ({ children }: { children: ReactNode }) =>
updateWorkflow({ id: workflow._id, workflow: { ...workflow, ...data } as any });
},
enabled: !isReadOnly,
shouldSaveImmediately: (previousData, data) => {
const currentStepsLength = data?.steps?.length ?? 0;
const wasStepsLengthAltered = previousData.steps != null && currentStepsLength !== previousData.steps?.length;

return wasStepsLengthAltered;
},
});

const addStep = useCallback(
Expand Down
29 changes: 13 additions & 16 deletions apps/dashboard/src/hooks/use-form-autosave.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { FieldValues, SubmitHandler, UseFormReturn, useWatch } from 'react-hook-form';
import { DeepPartialSkipArrayKey, FieldValues, SubmitHandler, UseFormReturn, useWatch } from 'react-hook-form';
import useDeepCompareEffect from 'use-deep-compare-effect';
import { useDebounce } from './use-debounce';
import { useDataRef } from './use-data-ref';
import { useCallback, useRef } from 'react';
import { useRef } from 'react';

export const useFormAutoSave = <T extends FieldValues>({
onSubmit,
form,
enabled = true,
shouldSaveImmediately,
}: {
onSubmit: SubmitHandler<T>;
form: UseFormReturn<T>;
enabled?: boolean;
shouldSaveImmediately?: (
watchedData: DeepPartialSkipArrayKey<T>,
previousWatchedData: DeepPartialSkipArrayKey<T> | null
) => boolean;
}) => {
const onSubmitRef = useDataRef(onSubmit);
const { formState, control, handleSubmit } = form;
const previousStepsLength = useRef<number | null>(null);

const watchedData = useWatch<T>({
control,
Expand All @@ -29,29 +33,22 @@ export const useFormAutoSave = <T extends FieldValues>({

const debouncedSave = useDebounce(save, 500);

const checkStepsDeleted = useCallback(() => {
const currentStepsLength = watchedData.steps?.length ?? 0;
const wasStepDeleted = previousStepsLength.current !== null && currentStepsLength < previousStepsLength.current;

previousStepsLength.current = currentStepsLength;

return wasStepDeleted;
}, [watchedData]);
const previousWatchedData = useRef<DeepPartialSkipArrayKey<T> | null>(null);

useDeepCompareEffect(() => {
if (!formState.isDirty) {
// set the previous steps length to the current steps length upon mount
previousStepsLength.current = watchedData.steps?.length ?? 0;

previousWatchedData.current = watchedData;
return;
}

const wasStepsDeleted = checkStepsDeleted();
const immediateSave = shouldSaveImmediately?.(watchedData, previousWatchedData.current) || false;

if (wasStepsDeleted) {
if (immediateSave) {
save();
} else {
debouncedSave();
}

previousWatchedData.current = watchedData;
}, [watchedData]);
};
Loading

0 comments on commit fd55158

Please sign in to comment.