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(dashboard): restore email editor 'for' block #7483

Merged
merged 11 commits into from
Jan 14, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { Liquid } from 'liquidjs';
import { EmailRenderOutput, TipTapNode } from '@novu/shared';
import { InstrumentUsecase } from '@novu/application-generic';
import { RenderCommand } from './render-command';
import { FullPayloadForRender, RenderCommand } from './render-command';
import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase';

export class EmailOutputRendererCommand extends RenderCommand {}
Expand Down Expand Up @@ -47,16 +47,13 @@ export class EmailOutputRendererUsecase {
tiptapNode: TipTapNode,
renderCommand: EmailOutputRendererCommand
): Promise<TipTapNode> {
const parsedString = await parseLiquid(
JSON.stringify(tiptapNode),
renderCommand.fullPayloadForRender as unknown as Record<string, unknown>
);
const parsedString = await parseLiquid(JSON.stringify(tiptapNode), renderCommand.fullPayloadForRender);

return JSON.parse(parsedString);
}
}

export const parseLiquid = async (value: string, variables: Record<string, unknown>): Promise<string> => {
export const parseLiquid = async (value: string, variables: FullPayloadForRender): Promise<string> => {
const client = new Liquid({
outputEscape: (output) => {
return stringifyDataStructureWithSingleQuotes(output);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,83 +5,116 @@ import { MailyAttrsEnum } from '@novu/application-generic';
import { ExpandEmailEditorSchemaCommand } from './expand-email-editor-schema-command';
import { HydrateEmailSchemaUseCase } from './hydrate-email-schema.usecase';
import { parseLiquid } from './email-output-renderer.usecase';
import { FullPayloadForRender } from './render-command';

@Injectable()
export class ExpandEmailEditorSchemaUsecase {
constructor(private hydrateEmailSchemaUseCase: HydrateEmailSchemaUseCase) {}

async execute(command: ExpandEmailEditorSchemaCommand): Promise<TipTapNode> {
const emailSchemaHydrated = this.hydrate(command);

return await this.processShowAndForControls(
command.fullPayloadForRender as unknown as Record<string, unknown>,
emailSchemaHydrated
);
}

private hydrate(command: ExpandEmailEditorSchemaCommand) {
const { hydratedEmailSchema } = this.hydrateEmailSchemaUseCase.execute({
const hydratedEmailSchema = this.hydrateEmailSchemaUseCase.execute({
emailEditor: command.emailEditorJson,
fullPayloadForRender: command.fullPayloadForRender,
});

return hydratedEmailSchema;
const processed = await this.processSpecialNodeTypes(command.fullPayloadForRender, hydratedEmailSchema);

// needs to be done after the special node types are processed
this.processVariableNodeTypes(processed, command.fullPayloadForRender);

return processed;
}

private async processShowAndForControls(
variables: Record<string, unknown>,
private async processSpecialNodeTypes(variables: FullPayloadForRender, rootNode: TipTapNode): Promise<TipTapNode> {
const processedNode = structuredClone(rootNode);
await this.traverseAndProcessNodes(processedNode, variables);

return processedNode;
}

private async traverseAndProcessNodes(
node: TipTapNode,
parentNode?: TipTapNode
): Promise<TipTapNode> {
if (node.content) {
const processedContent: TipTapNode[] = [];
for (const innerNode of node.content) {
const processed = await this.processShowAndForControls(variables, innerNode, parentNode);
if (processed) {
processedContent.push(processed);
variables: FullPayloadForRender,
parent?: TipTapNode
): Promise<void> {
const queue: Array<{ node: TipTapNode; parent?: TipTapNode }> = [{ node, parent }];

while (queue.length > 0) {
const current = queue.shift()!;
await this.processNode(current.node, variables, current.parent);

if (current.node.content) {
for (const childNode of current.node.content) {
queue.push({ node: childNode, parent: current.node });
}
}
node.content = processedContent;
}
}

private async processNode(node: TipTapNode, variables: FullPayloadForRender, parent?: TipTapNode): Promise<void> {
if (this.hasShow(node)) {
await this.hideShowIfNeeded(variables, node, parentNode);
} else if (this.hasEach(node)) {
const newContent = this.expendedForEach(node);
node.content = newContent;
if (parentNode && parentNode.content) {
this.insertArrayAt(parentNode.content, parentNode.content.indexOf(node), newContent);
parentNode.content.splice(parentNode.content.indexOf(node), 1);
}
await this.handleShowNode(node, variables, parent);
}

return node;
}
private insertArrayAt(array: any[], index: number, newArray: any[]) {
if (index < 0 || index > array.length) {
throw new Error('Index out of bounds');
if (this.hasEach(node)) {
await this.handleEachNode(node, variables, parent);
}
array.splice(index, 0, ...newArray);
}

private hasEach(node: TipTapNode): node is TipTapNode & { attrs: { each: unknown } } {
return !!(node.attrs && 'each' in node.attrs);
private async handleShowNode(
Copy link
Contributor

Choose a reason for hiding this comment

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

Show is an Attribute, not a node. So to be precise we should name this as handleShowAttr

Copy link
Contributor

Choose a reason for hiding this comment

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

even more precise would be handleNodeByShowAttr because here we decide if to show a Node based on an attribute.

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 have to disagree here, I think this way the naming is more common and understandable.

We are processing nodes and the for and show are a specific type of nodes. We are not processing the attribute only, we are processing an entire node which has attr = "for", similar as step.type = "email".

When you first time look at this method, its clear that if the node is of type for you do something with the node - you dont need to know about some internal property name such as attr or anything:

  private async processNode(node: TipTapNode, variables: FullPayloadForRender, parent?: TipTapNode): Promise<void> {
    if (this.hasShow(node)) {
      await this.handleShowNode(node, variables, parent);
    }

    if (this.hasEach(node)) {
      await this.handleEachNode(node, variables, parent);
    }
  }

Copy link
Contributor

Choose a reason for hiding this comment

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

How about handleEach, handleShow?

node: TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } },
variables: FullPayloadForRender,
parent?: TipTapNode
): Promise<void> {
const shouldShow = await this.evaluateShowCondition(variables, node);
if (!shouldShow && parent?.content) {
parent.content = parent.content.filter((pNode) => pNode !== node);

return;
}

// @ts-ignore
delete node.attrs[MailyAttrsEnum.SHOW_IF_KEY];
}

private hasShow(node: TipTapNode): node is TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } } {
return node.attrs?.[MailyAttrsEnum.SHOW_IF_KEY] !== undefined;
private async handleEachNode(
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
node: TipTapNode & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } },
variables: FullPayloadForRender,
parent?: TipTapNode
): Promise<void> {
const newContent = this.multiplyForEachNode(node, variables);
if (parent?.content) {
const nodeIndex = parent.content.indexOf(node);
parent.content = [...parent.content.slice(0, nodeIndex), ...newContent, ...parent.content.slice(nodeIndex + 1)];
} else {
node.content = newContent;
}
}

private regularExpansion(eachObject: any, templateContent: TipTapNode[]): TipTapNode[] {
const expandedContent: TipTapNode[] = [];
const jsonArrOfValues = eachObject as unknown as [{ [key: string]: string }];
private async evaluateShowCondition(
variables: FullPayloadForRender,
node: TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } }
): Promise<boolean> {
const { [MailyAttrsEnum.SHOW_IF_KEY]: showIfKey } = node.attrs;
const parsedShowIfValue = await parseLiquid(showIfKey, variables);

for (const value of jsonArrOfValues) {
const hydratedContent = this.replacePlaceholders(templateContent, value);
expandedContent.push(...hydratedContent);
return this.stringToBoolean(parsedShowIfValue);
}

private processVariableNodeTypes(node: TipTapNode, variables: FullPayloadForRender) {
if (this.isAVariableNode(node)) {
node.type = 'text'; // set 'variable' to 'text' to for Liquid to recognize it
}

return expandedContent;
node.content?.forEach((innerNode) => this.processVariableNodeTypes(innerNode, variables));
}

private hasEach(node: TipTapNode): node is TipTapNode & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } } {
return node.attrs?.[MailyAttrsEnum.EACH_KEY] !== undefined && node.attrs?.[MailyAttrsEnum.EACH_KEY] !== null;
}

private hasShow(node: TipTapNode): node is TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: string } } {
return node.attrs?.[MailyAttrsEnum.SHOW_IF_KEY] !== undefined && node.attrs?.[MailyAttrsEnum.SHOW_IF_KEY] !== null;
}

private isOrderedList(templateContent: TipTapNode[]) {
Expand All @@ -92,94 +125,92 @@ export class ExpandEmailEditorSchemaUsecase {
return templateContent.length === 1 && templateContent[0].type === 'bulletList';
}

private expendedForEach(node: TipTapNode & { attrs: { each: unknown } }): TipTapNode[] {
const eachObject = node.attrs.each;
const templateContent = node.content || [];

/*
* Due to maily limitations in the current implementation, the location of the for
* element is situated on the container of the list making the list a
* child of the for element, if we iterate it we will get the
* wrong behavior of multiple lists instead of list with multiple items.
* due to that when we choose the content to iterate in case we find a list we drill down additional level
* and iterate on the list items
* this prevents us from
* 1. item1
* 1. item2
*
* and turns it into
* 1.item1
* 2.item2
* which is the correct behavior
*
*/
if ((this.isOrderedList(templateContent) || this.isBulletList(templateContent)) && templateContent[0].content) {
return [{ ...templateContent[0], content: this.regularExpansion(eachObject, templateContent[0].content) }];
}
/**
* For 'each' node, multiply the content by the number of items in the iterable array
* and add indexes to the placeholders.
*
* @example
* node:
* {
* type: 'each',
* attrs: { each: 'payload.comments' },
* content: [
* { type: 'variable', text: '{{ payload.comments.author }}' }
* ]
* }
*
* variables:
* { payload: { comments: [{ author: 'John Doe' }, { author: 'Jane Doe' }] } }
*
* result:
* [
* { type: 'text', text: '{{ payload.comments[0].author }}' },
* { type: 'text', text: '{{ payload.comments[1].author }}' }
* ]
*
*/
private multiplyForEachNode(
node: TipTapNode & { attrs: { [MailyAttrsEnum.EACH_KEY]: string } },
variables: FullPayloadForRender
): TipTapNode[] {
const iterablePath = node.attrs[MailyAttrsEnum.EACH_KEY];
const nodeContent = node.content || [];
const expandedContent: TipTapNode[] = [];

return this.regularExpansion(eachObject, templateContent);
}
const iterableArray = this.getValueByPath(variables, iterablePath);

private removeNodeFromParent(node: TipTapNode, parentNode?: TipTapNode) {
if (parentNode && parentNode.content) {
parentNode.content.splice(parentNode.content.indexOf(node), 1);
if (!Array.isArray(iterableArray)) {
throw new Error(`Iterable "${iterablePath}" is not an array`);
}
}

private async hideShowIfNeeded(
variables: Record<string, unknown>,
node: TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: unknown } },
parentNode?: TipTapNode
): Promise<void> {
const { [MailyAttrsEnum.SHOW_IF_KEY]: showIfKey } = node.attrs;
for (const [index] of iterableArray.entries()) {
const contentToExpand =
(this.isOrderedList(nodeContent) || this.isBulletList(nodeContent)) && nodeContent[0].content
? nodeContent[0].content
: nodeContent;

if (showIfKey === undefined) {
return;
}

const parsedShowIfValue = await parseLiquid(showIfKey as string, variables);
const showIfValueBoolean =
typeof parsedShowIfValue === 'boolean' ? parsedShowIfValue : this.stringToBoolean(parsedShowIfValue);

if (!showIfValueBoolean) {
this.removeNodeFromParent(node, parentNode);
} else {
delete node.attrs[MailyAttrsEnum.SHOW_IF_KEY];
}
}

private stringToBoolean(value: unknown): boolean {
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
const hydratedContent = this.addIndexesToPlaceholders(contentToExpand, iterablePath, index);
expandedContent.push(...hydratedContent);
}

return false;
}

private isAVariableNode(newNode: TipTapNode): newNode is TipTapNode & { attrs: { id: string } } {
return newNode.type === 'payloadValue' && newNode.attrs?.id !== undefined;
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
return expandedContent;
}

private replacePlaceholders(nodes: TipTapNode[], payload: Record<string, any>): TipTapNode[] {
private addIndexesToPlaceholders(nodes: TipTapNode[], iterablePath: string, index: number): TipTapNode[] {
Copy link
Contributor

Choose a reason for hiding this comment

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

what do you think about?

Suggested change
private addIndexesToPlaceholders(nodes: TipTapNode[], iterablePath: string, index: number): TipTapNode[] {
private addIndexesToLiquidOutput(nodes: TipTapNode[], iterablePath: string, index: number): TipTapNode[] {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But the placeholders ({{payload.something}}) are not liquid output right? They are more Liquid input, since the parsing by Liquid happens later and this is usecase is an input for that:

 const parsedTipTap = await this.parseTipTapNodeByLiquid(expandedMailyContent, renderCommand);

Copy link
Contributor

Choose a reason for hiding this comment

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

Liquid JS calls the value with filters wrapped with {{...}} an Output i using Output instead of placeholder would help us unify and help us to understand each other better.

and the main logic in this function is
newNode.text = nodePlaceholder.replace(iterablePath, ${iterablePath}[${index}]);
which creates a liquid js Output.
Let me know what you think about it.

In addition, apologies for missing this earlier I noticed that we are using string.replace. Instead, we should use wrapInLiquidOutput whenever we have a Maily fallback attribute.

return nodes.map((node) => {
const newNode: TipTapNode = { ...node };

if (this.isAVariableNode(newNode)) {
const valueByPath = this.getValueByPath(payload, newNode.attrs.id);
if (valueByPath) {
newNode.text = valueByPath;
newNode.type = 'text';
// @ts-ignore
delete newNode.attrs;
}
const nodePlaceholder = newNode.text as string;

/**
* example:
* iterablePath = payload.comments
* nodePlaceholder = {{ payload.comments.author }}
* text = {{ payload.comments[0].author }}
*/
newNode.text = nodePlaceholder.replace(iterablePath, `${iterablePath}[${index}]`);
newNode.type = 'text'; // set 'variable' to 'text' to for Liquid to recognize it
} else if (newNode.content) {
newNode.content = this.replacePlaceholders(newNode.content, payload);
newNode.content = this.addIndexesToPlaceholders(newNode.content, iterablePath, index);
}

return newNode;
});
}

private stringToBoolean(value: unknown): boolean {
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
}

return false;
}

private isAVariableNode(newNode: TipTapNode): newNode is TipTapNode & { attrs: { [MailyAttrsEnum.ID]: string } } {
return newNode.type === 'variable';
}

private getValueByPath(obj: Record<string, any>, path: string): any {
if (path in obj) {
return obj[path];
Expand Down
Loading
Loading