Skip to content
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
67 changes: 67 additions & 0 deletions apps/api/src/app/workflows-v2/e2e/upsert-workflow.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,6 +1186,73 @@ describe('Upsert Workflow #novu-v2', () => {
});
});

describe('chat editorType inference', () => {
const mailyBody = JSON.stringify({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello from blocks' }] }],
});

it('sets editorType to block when the chat body is Maily JSON', async () => {
const createResponse = await session.testAgent.post('/v2/workflows').send({
__source: WorkflowCreationSourceEnum.Editor,
name: 'Chat EditorType Block Workflow',
workflowId: `chat-editor-type-block-${randomUUID()}`,
active: true,
steps: [
{
name: 'Chat Step',
type: StepTypeEnum.CHAT,
controlValues: { body: mailyBody },
},
],
});

expect(createResponse.status).to.equal(201);
expect(createResponse.body.data.steps[0].controls.values.editorType).to.equal('block');
expect(createResponse.body.data.steps[0].issues?.controls?.editorType).to.equal(undefined);
});

it('sets editorType to text when the chat body is plain text', async () => {
const createResponse = await session.testAgent.post('/v2/workflows').send({
__source: WorkflowCreationSourceEnum.Editor,
name: 'Chat EditorType Text Workflow',
workflowId: `chat-editor-type-text-${randomUUID()}`,
active: true,
steps: [
{
name: 'Chat Step',
type: StepTypeEnum.CHAT,
controlValues: { body: 'hello {{payload.foo}}' },
},
],
});

expect(createResponse.status).to.equal(201);
expect(createResponse.body.data.steps[0].controls.values.editorType).to.equal('text');
expect(createResponse.body.data.steps[0].issues?.controls?.editorType).to.equal(undefined);
});

it('does not report editorType enum issues when editorType is empty and body is Maily JSON', async () => {
const createResponse = await session.testAgent.post('/v2/workflows').send({
__source: WorkflowCreationSourceEnum.Editor,
name: 'Chat EditorType Empty Workflow',
workflowId: `chat-editor-type-empty-${randomUUID()}`,
active: true,
steps: [
{
name: 'Chat Step',
type: StepTypeEnum.CHAT,
controlValues: { body: mailyBody, editorType: '' },
},
],
});

expect(createResponse.status).to.equal(201);
expect(createResponse.body.data.steps[0].controls.values.editorType).to.equal('block');
expect(createResponse.body.data.steps[0].issues?.controls?.editorType).to.equal(undefined);
});
});

describe('workflow agent assignment', () => {
async function createTestAgent(identifier: string, name = identifier) {
const response = await session.testAgent.post('/v1/agents').send({ name, identifier });
Expand Down
12 changes: 11 additions & 1 deletion apps/dashboard/src/components/chat-editor-select.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { RiCodeSSlashFill, RiDashboardLine } from 'react-icons/ri';
import { ConfirmationModal } from '@/components/confirmation-modal';
Expand Down Expand Up @@ -27,6 +27,16 @@ export const ChatEditorSelect = ({
const { control, setValue } = useFormContext();
const [pendingEditorType, setPendingEditorType] = useState<ChatEditorType | null>(null);
const body = useWatch({ name: 'body', control });
const editorType = useWatch({ name: 'editorType', control });

useEffect(() => {
const resolvedEditorType = deriveChatEditorType(body, editorType, true);
if (editorType === resolvedEditorType) {
return;
}

setValue('editorType', resolvedEditorType, { shouldDirty: false, shouldValidate: false });
}, [body, editorType, setValue]);

return (
<FormField
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ describe('deriveChatEditorType', () => {
expect(deriveChatEditorType(mailyBody, 'text', true)).toBe('text');
});

it('routes Maily JSON to block when editorType is unset', () => {
it('routes Maily JSON to block when editorType is unset or invalid', () => {
expect(deriveChatEditorType(mailyBody, undefined, true)).toBe('block');
expect(deriveChatEditorType(mailyBody, '', true)).toBe('block');
});

it('routes non-empty plain/Liquid bodies to text when editorType is unset', () => {
Expand Down
17 changes: 16 additions & 1 deletion apps/dashboard/src/pages/edit-step-template-v2.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { ContentIssueEnum, StepResponseDto, StepUpdateDto, WorkflowResponseDto } from '@novu/shared';
import {
ContentIssueEnum,
FeatureFlagsKeysEnum,
StepResponseDto,
StepTypeEnum,
StepUpdateDto,
WorkflowResponseDto,
} from '@novu/shared';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { PageMeta } from '@/components/page-meta';
import { Form } from '@/components/primitives/form/form';
import { flattenIssues, updateStepInWorkflow } from '@/components/workflow-editor/step-utils';
import { deriveChatEditorType } from '@/components/workflow-editor/steps/chat/derive-chat-editor-type';
import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context';
import { StepEditorLayout } from '@/components/workflow-editor/steps/step-editor-layout';
import { UpdateWorkflowFn, useWorkflow } from '@/components/workflow-editor/workflow-provider';
import { useDataRef } from '@/hooks/use-data-ref';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { useFormAutosave } from '@/hooks/use-form-autosave';
import { getControlsDefaultValues } from '@/utils/default-values';

Expand Down Expand Up @@ -40,6 +49,7 @@ type StepTemplateFormProps = {
};

function StepTemplateForm({ workflow, step, update }: StepTemplateFormProps) {
const isChatBlockEditorEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_CHAT_BLOCK_EDITOR_ENABLED);
const form = useForm({
defaultValues: getControlsDefaultValues(step),
shouldFocusError: false,
Expand Down Expand Up @@ -88,6 +98,11 @@ function StepTemplateForm({ workflow, step, update }: StepTemplateFormProps) {
const { providerOverrides, ...controlValues } = data as Record<string, unknown> & {
providerOverrides?: StepUpdateDto['providerOverrides'];
};

if (step.type === StepTypeEnum.CHAT && isChatBlockEditorEnabled) {
controlValues.editorType = deriveChatEditorType(controlValues.body, controlValues.editorType, true);
}

const fp = JSON.stringify({
v: controlValues,
po: providerOverrides,
Expand Down
13 changes: 12 additions & 1 deletion libs/application-generic/src/dtos/workflow/chat-control.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
import { SkipControlDto } from './skip.dto';

export class ChatControlDto extends SkipControlDto {
@ApiPropertyOptional({ description: 'Content of the chat message.' })
@IsString()
@IsOptional()
body: string;

@ApiPropertyOptional({
description:
'Type of editor to use for the body. When omitted, inferred from the body: Maily JSON is "block", otherwise "text".',
enum: ['block', 'text'],
})
@ValidateIf((_, value) => value !== undefined && value !== null && value !== '')
@IsIn(['block', 'text'])
@IsString()
@IsOptional()
editorType?: 'block' | 'text';
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ export const chatControlZodSchema = z
.object({
skip: skipZodSchema,
body: z.string(),
// Optional with no static default so flag-off orgs never persist editorType.
// When IS_CHAT_BLOCK_EDITOR_ENABLED is on, the dashboard derives 'block' for
// empty/new steps and 'text' for legacy raw bodies.
// Optional with no static default so flag-off orgs never persist editorType
// for empty steps. When a body is present, upsert/sanitize infers 'block'
// from Maily JSON and 'text' from plain/Liquid content — matching email's
// persist-a-valid-editorType behavior without forcing a schema default.
editorType: z.enum(['block', 'text']).optional(),
})
.strict();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { StepIssuesDto } from '../../dtos/step-issues.dto';
import { EmailRenderOutput } from '../../dtos/workflow/generate-preview-response.dto';
import { WorkflowResponseDto } from '../../dtos/workflow/workflow-response.dto';
import { Instrument, InstrumentUsecase } from '../../instrumentation';
import { EmailControlType } from '../../schemas/control';
import { ChatControlType, EmailControlType } from '../../schemas/control';
import { AnalyticsService } from '../../services';
import {
computeWorkflowStatus,
Expand All @@ -37,6 +37,7 @@ import {
slugifyOrRandom,
} from '../../utils';
import { isStringifiedMailyJSONContent } from '../../utils/maily-utils';
import { resolveChatEditorType } from '../../utils/resolve-chat-editor-type';
import { isStepResolverActive } from '../../utils/step-resolver-control-state';
import { NotificationStep } from '../../value-objects';
import { SendWebhookMessage } from '../../webhooks';
Expand Down Expand Up @@ -526,6 +527,22 @@ export class UpsertWorkflowUseCase {
}
}

if (
step.template?.type === StepTypeEnum.CHAT &&
(command.workflowDto.origin === ResourceOriginEnum.NOVU_CLOUD ||
command.workflowDto.origin === ResourceOriginEnum.NOVU_CLOUD_V1) &&
!isStepResolverActive(step.template?.stepResolverHash)
) {
const chatControlValues = newControlValues as ChatControlType;
const resolvedEditorType = resolveChatEditorType(chatControlValues.body, chatControlValues.editorType);

if (resolvedEditorType) {
chatControlValues.editorType = resolvedEditorType;
} else {
delete chatControlValues.editorType;
}
}

return this.upsertControlValuesUseCase.execute(
UpsertControlValuesCommand.create({
organizationId: command.user.organizationId,
Expand Down
1 change: 1 addition & 0 deletions libs/application-generic/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export * from './parse-payload-schema';
export * from './parse-step-variables';
export * from './payload';
export * from './provider-overrides';
export * from './resolve-chat-editor-type';
export * from './safe-set-path';
export * from './sanitize-control-values';
export * from './shorten-environment-name';
Expand Down
47 changes: 47 additions & 0 deletions libs/application-generic/src/utils/issues.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { JsonSchemaTypeEnum } from '@novu/dal';
import { ContentIssueEnum, StepTypeEnum } from '@novu/shared';
import type { PinoLogger } from 'nestjs-pino';
import { describe, expect, it } from 'vitest';
import { chatControlSchema } from '../schemas/control';
import { processControlValuesBySchema } from './issues';
import { dashboardSanitizeControlValues } from './sanitize-control-values';

const logger = { error: () => {} } as unknown as PinoLogger;

const mailyBody = JSON.stringify({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
});

describe('processControlValuesBySchema', () => {
it('maps additionalProperties failures to UNSUPPORTED_PROPERTY for any strict schema', () => {
Expand All @@ -28,4 +38,41 @@ describe('processControlValuesBySchema', () => {
},
]);
});

it('rejects empty-string chat editorType against the control schema', () => {
const issues = processControlValuesBySchema({
controlSchema: chatControlSchema,
controlValues: { body: mailyBody, editorType: '' },
stepType: StepTypeEnum.CHAT,
});

expect(issues.controls?.editorType?.[0]?.message).toBe('must be equal to one of the allowed values');
});

it('does not flag editorType after sanitizing a Maily chat body with an empty editorType', () => {
const sanitized = dashboardSanitizeControlValues(logger, { body: mailyBody, editorType: '' }, StepTypeEnum.CHAT);

const issues = processControlValuesBySchema({
controlSchema: chatControlSchema,
controlValues: sanitized || {},
stepType: StepTypeEnum.CHAT,
});

expect(sanitized).toEqual({ body: mailyBody, editorType: 'block' });
expect(issues.controls?.editorType).toBeUndefined();
});

it('does not flag editorType for an empty chat step after sanitize', () => {
const sanitized = dashboardSanitizeControlValues(logger, { body: '', editorType: '' }, StepTypeEnum.CHAT);

const issues = processControlValuesBySchema({
controlSchema: chatControlSchema,
controlValues: sanitized || {},
stepType: StepTypeEnum.CHAT,
});

expect(sanitized).not.toHaveProperty('editorType');
expect(issues.controls?.editorType).toBeUndefined();
expect(issues.controls?.body).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { resolveChatEditorType } from './resolve-chat-editor-type';

const mailyBody = JSON.stringify({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
});

describe('resolveChatEditorType', () => {
it('prefers an explicit editorType', () => {
expect(resolveChatEditorType('{% if true %}x{% endif %}', 'block')).toBe('block');
expect(resolveChatEditorType(mailyBody, 'text')).toBe('text');
});

it('maps Maily JSON to block when editorType is unset or invalid', () => {
expect(resolveChatEditorType(mailyBody, undefined)).toBe('block');
expect(resolveChatEditorType(mailyBody, '')).toBe('block');
expect(resolveChatEditorType(mailyBody, 'html')).toBe('block');
});

it('maps non-empty plain/Liquid bodies to text when editorType is unset', () => {
expect(resolveChatEditorType('{% if true %}x{% endif %}', undefined)).toBe('text');
expect(resolveChatEditorType('hello {{payload.foo}}', '')).toBe('text');
});

it('returns undefined for empty bodies when editorType is unset', () => {
expect(resolveChatEditorType('', undefined)).toBeUndefined();
expect(resolveChatEditorType(undefined, '')).toBeUndefined();
});
});
25 changes: 25 additions & 0 deletions libs/application-generic/src/utils/resolve-chat-editor-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isStringifiedMailyJSONContent } from './maily-utils';

export type ChatEditorType = 'block' | 'text';

/**
* Resolve a persistable chat `editorType` from control values.
* Explicit `block`/`text` always wins. Otherwise Maily JSON bodies map to
* `block` and any other non-empty string maps to `text`. Empty/missing bodies
* return `undefined` so flag-off orgs do not persist a value.
*/
export function resolveChatEditorType(body: unknown, editorType: unknown): ChatEditorType | undefined {
if (editorType === 'block' || editorType === 'text') {
return editorType;
}

if (typeof body === 'string' && isStringifiedMailyJSONContent(body)) {
return 'block';
}

if (typeof body === 'string' && body.length > 0) {
return 'text';
}

return undefined;
}
26 changes: 24 additions & 2 deletions libs/application-generic/src/utils/sanitize-control-values.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('dashboardSanitizeControlValues', () => {
stepType
);

expect(sanitized).toEqual({ body: 'hello', providerOverrides: { slack: { text: 'hi' } } });
expect(sanitized).toMatchObject({ body: 'hello', providerOverrides: { slack: { text: 'hi' } } });
}
);

Expand All @@ -31,9 +31,31 @@ describe('dashboardSanitizeControlValues', () => {
expect(sanitized).toEqual({ body: 'hello', editorType: 'text' });
});

it('omits chat editorType when absent', () => {
it('infers chat editorType as text from a plain body when editorType is absent', () => {
const sanitized = dashboardSanitizeControlValues(logger, { body: 'hello' }, StepTypeEnum.CHAT);

expect(sanitized).toEqual({ body: 'hello', editorType: 'text' });
});

it('infers chat editorType as block from Maily JSON when editorType is unset or invalid', () => {
const mailyBody = JSON.stringify({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
});

expect(dashboardSanitizeControlValues(logger, { body: mailyBody, editorType: '' }, StepTypeEnum.CHAT)).toEqual({
body: mailyBody,
editorType: 'block',
});
expect(dashboardSanitizeControlValues(logger, { body: mailyBody }, StepTypeEnum.CHAT)).toEqual({
body: mailyBody,
editorType: 'block',
});
});

it('omits chat editorType when body and editorType are both empty', () => {
const sanitized = dashboardSanitizeControlValues(logger, { body: '', editorType: '' }, StepTypeEnum.CHAT);

expect(sanitized).not.toHaveProperty('editorType');
});
});
Loading
Loading