Skip to content

Commit

Permalink
feat(dashboard): restore email editor 'for' block (#7483)
Browse files Browse the repository at this point in the history
  • Loading branch information
ChmaraX authored Jan 14, 2025
1 parent e14513e commit fe98e77
Show file tree
Hide file tree
Showing 15 changed files with 395 additions and 323 deletions.
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(
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(
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;
return expandedContent;
}

private replacePlaceholders(nodes: TipTapNode[], payload: Record<string, any>): TipTapNode[] {
private addIndexesToPlaceholders(nodes: TipTapNode[], iterablePath: string, index: number): TipTapNode[] {
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

0 comments on commit fe98e77

Please sign in to comment.