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 @@ -13,10 +13,15 @@ export class ExpandEmailEditorSchemaUsecase {
async execute(command: ExpandEmailEditorSchemaCommand): Promise<TipTapNode> {
const emailSchemaHydrated = this.hydrate(command);

return await this.processShowAndForControls(
const processed = await this.processSpecialNodeTypes(
command.fullPayloadForRender as unknown as Record<string, unknown>,
emailSchemaHydrated
);

// needs to be done after the special node types are processed
this.processVariableNodeTypes(processed, command.fullPayloadForRender as unknown as Record<string, unknown>);

return processed;
}

private hydrate(command: ExpandEmailEditorSchemaCommand) {
Expand All @@ -28,60 +33,98 @@ export class ExpandEmailEditorSchemaUsecase {
return hydratedEmailSchema;
}

private async processShowAndForControls(
variables: Record<string, unknown>,
private async processSpecialNodeTypes(variables: Record<string, unknown>, rootNode: TipTapNode): Promise<TipTapNode> {
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
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: Record<string, unknown>,
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;
}
}
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved

private async processNode(node: TipTapNode, variables: Record<string, unknown>, 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: { showIfKey: unknown } },
variables: Record<string, unknown>,
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
parent?: TipTapNode
): Promise<void> {
const shouldShow = await this.evaluateShowCondition(variables, node);
if (!shouldShow && parent?.content) {
parent.content = parent.content.filter((pNode) => pNode !== node);

return;
}
if (node.attrs) {
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: { each: unknown } },
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
variables: Record<string, unknown>,
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
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: Record<string, unknown>,
node: TipTapNode & { attrs: { [MailyAttrsEnum.SHOW_IF_KEY]: unknown } }
): Promise<boolean> {
const { [MailyAttrsEnum.SHOW_IF_KEY]: showIfKey } = node.attrs;
if (showIfKey === undefined) return true;

for (const value of jsonArrOfValues) {
const hydratedContent = this.replacePlaceholders(templateContent, value);
expandedContent.push(...hydratedContent);
const parsedShowIfValue = await parseLiquid(showIfKey as string, variables);

return typeof parsedShowIfValue === 'boolean' ? parsedShowIfValue : this.stringToBoolean(parsedShowIfValue);
}

private processVariableNodeTypes(node: TipTapNode, variables: Record<string, unknown>) {
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: { each: unknown } } {
return !!(node.attrs && 'each' in node.attrs);
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
}

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,60 +135,74 @@ 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: { each: unknown } },
variables: Record<string, unknown>
): TipTapNode[] {
const iterablePath = node.attrs.each as string;
const nodeContent = node.content || [];
const expandedContent: TipTapNode[] = [];

return this.regularExpansion(eachObject, templateContent);
}
const iterableArray = this.getValueByPath(variables, iterablePath) as [{ [key: string]: unknown }];
Copy link
Contributor

Choose a reason for hiding this comment

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

here we do not need to getValueByPath, id suggest doing the following:
go to variableAttributeConfig
add a new entry, { attr: MailyAttrsEnum.EACH, flag: MailyAttrsEnum.EACH }
the result will be that in the pipeline we will wrap each attribute (node.attrs.each) with {{...}}
then you could get the value from the variable using liquid js.

Suggested change
const iterableArray = this.getValueByPath(variables, iterablePath) as [{ [key: string]: unknown }];
const iterableArray = await parseLiquid(iterablePath as string, variables);

let me know what you think.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case, iterableArray accepts and returns this:

// variables, iterablePath = "payload.comments"
const iterableArray = this.getValueByPath(variables, iterablePath) as [{ [key: string]: unknown }];
/**
* iterableArray = [
*  {
*    name: '{{payload.comments.name}}',
* },
* {
*   name: '{{payload.comments.name}}',
* },
* {
*   name: '{{payload.comments.name}}',
* }
*]
**/

What you are suggesting, would return just the iterable path string, not the actual array:

const iterableArray = await parseLiquid(iterablePath as string, variables);
// iterableArray = payload.comments

Copy link
Contributor

Choose a reason for hiding this comment

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

not sure if i understand it should return an array.

Editor:

for block {{ payload.comments }}

Variable:

{"payload" : {
    "comments": [
      " a_1 ",
      " a_2 ",
      " a_3 "
    ]
  }
}

image

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It currently doesnt work like this because we dont have for block {{ payload.comments }} anywhere in the maily json, we have just each: {{ payload.comments}}. That said its interesting idea to explore I will have a look if we can do this separately.

Copy link
Contributor

@djabarovgeorge djabarovgeorge Jan 14, 2025

Choose a reason for hiding this comment

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

for semantics representation aside, this one should work, doesn't it?
we have each: {{ payload.comments}}
with data variables: {"payload":{"comments":[" a_1 "," a_2 "," a_3 "]}}
once we execute const iterableArray = await parseLiquid(iterablePath as string, variables);
we should get the array [" a_1 "," a_2 "," a_3 "]


for (const [index] of iterableArray.entries()) {
const contentToExpand =
(this.isOrderedList(nodeContent) || this.isBulletList(nodeContent)) && nodeContent[0].content
? nodeContent[0].content
: nodeContent;

private removeNodeFromParent(node: TipTapNode, parentNode?: TipTapNode) {
if (parentNode && parentNode.content) {
parentNode.content.splice(parentNode.content.indexOf(node), 1);
const hydratedContent = this.addIndexesToPlaceholders(contentToExpand, iterablePath, index);
expandedContent.push(...hydratedContent);
}
}

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;
return expandedContent;
}

if (showIfKey === undefined) {
return;
}
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 };

const parsedShowIfValue = await parseLiquid(showIfKey as string, variables);
const showIfValueBoolean =
typeof parsedShowIfValue === 'boolean' ? parsedShowIfValue : this.stringToBoolean(parsedShowIfValue);
if (this.isAVariableNode(newNode)) {
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.addIndexesToPlaceholders(newNode.content, iterablePath, index);
}

if (!showIfValueBoolean) {
this.removeNodeFromParent(node, parentNode);
} else {
delete node.attrs[MailyAttrsEnum.SHOW_IF_KEY];
}
return newNode;
});
ChmaraX marked this conversation as resolved.
Show resolved Hide resolved
}

private stringToBoolean(value: unknown): boolean {
Expand All @@ -157,27 +214,7 @@ export class ExpandEmailEditorSchemaUsecase {
}

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
}

private replacePlaceholders(nodes: TipTapNode[], payload: Record<string, any>): 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;
}
} else if (newNode.content) {
newNode.content = this.replacePlaceholders(newNode.content, payload);
}

return newNode;
});
return newNode.type === 'variable';
}

private getValueByPath(obj: Record<string, any>, path: string): any {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ export class HydrateEmailSchemaUseCase {
index: number
) {
content[index] = {
type: 'text',
type: 'variable',
Copy link
Contributor

Choose a reason for hiding this comment

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

was there a specific reason for this change? When parsing the variable type from Maily, we need to convert it to text because Liquid is responsible for variable parsing.
in addition, this helps us avoid any side effects from Maily's variable parsing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is required for the 'for' logic down the line. The output from this hydrate schema usecase looks something like this:

{
      "type": "for",
      "content": [
        {
          "type": "paragraph",
          "content": [
            {
              "type": "text",
              "text": "comments: "
            },
            {
              "type": "variable", <--- This needs to be variable not text
              "text": "{{ payload.comments.text }}"
            }
          ],
          "attrs": {
            "textAlign": "left"
          }
        }
      ],
 }

If it would be replaced by type: text here, I wouldn't be able to know which node is variable and which text, therefore I wouldn't be able to transform the variable here from {{ payload.comments.text }} to {{ payload.comments[0].text }}

text: node.attrs.id,
};
}

private forNodeLogic(
private async forNodeLogic(
node: TipTapNode & {
attrs: { each: string };
},
Expand All @@ -54,15 +54,11 @@ export class HydrateEmailSchemaUseCase {
placeholderAggregation: PlaceholderAggregation
) {
const itemPointerToDefaultRecord = this.collectAllItemPlaceholders(node);
const resolvedValueForPlaceholder = this.getResolvedValueForPlaceholder(
masterPayload,
node,
itemPointerToDefaultRecord,
placeholderAggregation
);
await this.getResolvedValueForPlaceholder(masterPayload, node, itemPointerToDefaultRecord, placeholderAggregation);

content[index] = {
type: 'for',
attrs: { each: resolvedValueForPlaceholder },
type: 'paragraph',
attrs: { each: node.attrs.each },
content: node.content,
};
}
Expand Down Expand Up @@ -133,7 +129,7 @@ export class HydrateEmailSchemaUseCase {
if (node.type === 'for') {
return;
}
if (this.isPayloadValue(node)) {
if (this.isVariableNode(node)) {
const { id } = node.attrs;
payloadValues[`${node.attrs.id}`] = node.attrs.fallback || `{{item.${id}}}`;
}
Expand Down Expand Up @@ -177,10 +173,6 @@ export class HydrateEmailSchemaUseCase {

return mockPayload;
}

private isPayloadValue(node: TipTapNode): node is { type: 'payloadValue'; attrs: { id: string; fallback?: string } } {
return !!(node.type === 'payloadValue' && node.attrs && typeof node.attrs.id === 'string');
}
}

export const TipTapSchema = z
Expand Down
Loading
Loading