-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Changes from all commits
3414ba5
7e1a59b
beae876
24f87b8
009d9ad
18fd649
f851d8d
fee034e
04463d7
7c6384c
9c8d4a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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( | ||||||
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[]) { | ||||||
|
@@ -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[] { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what do you think about?
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But the placeholders ( const parsedTipTap = await this.parseTipTapNodeByLiquid(expandedMailyContent, renderCommand); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Liquid JS calls the value with filters wrapped with and the main logic in this function is In addition, apologies for missing this earlier I noticed that we are using |
||||||
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]; | ||||||
|
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
andshow
are a specific type of nodes. We are not processing the attribute only, we are processing an entire node which hasattr = "for"
, similar asstep.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 asattr
or anything:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about handleEach, handleShow?