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

Open
wants to merge 5 commits into
base: next
Choose a base branch
from

Conversation

ChmaraX
Copy link
Contributor

@ChmaraX ChmaraX commented Jan 10, 2025

What changed? Why was the change needed?

  • DX: iterable needs to be referenced in full, e.g. when looping over {{payload.comments}}, item is {{payload.comments.id}}
  • the preview always generates 3 example items in the array
  • I've tried to restore the functionality with the least amount of code change, but in my opinion heavy refactoring is required for the entire preview

High-level overview of how the parsing flow goes so we can reference it later when refactoring:
image

for-loop.mov

Copy link

netlify bot commented Jan 10, 2025

Deploy Preview for dev-web-novu ready!

Name Link
🔨 Latest commit 009d9ad
🔍 Latest deploy log https://app.netlify.com/sites/dev-web-novu/deploys/67853cefd117700008fa1dce
😎 Deploy Preview https://deploy-preview-7483.dashboard.novu-staging.co
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link

netlify bot commented Jan 10, 2025

Deploy Preview for dashboard-v2-novu-staging ready!

Name Link
🔨 Latest commit 009d9ad
🔍 Latest deploy log https://app.netlify.com/sites/dashboard-v2-novu-staging/deploys/67853cefabcab10008361599
😎 Deploy Preview https://deploy-preview-7483.dashboard-v2.novu-staging.co
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Comment on lines +43 to +60
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;
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Reworked the previously recursive function to something easier to read using queue.

Comment on lines +185 to +205
private addIndexesToPlaceholders(nodes: TipTapNode[], iterablePath: string, index: number): TipTapNode[] {
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;
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Instead of replacing for loop values directly in this usecase as it was before, we create just placeholders {{ payload.comments[0].author }} and leave the actual value substitution for Liquid parser down the line

@@ -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;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

No idea what is payloadValue but I was not able to find it anywhere in the TipTapNode

@@ -132,10 +132,13 @@ export const Maily = (props: MailyProps) => {
return dedupAndSortVariables(filteredVariables, queryWithoutSuffix);
}

const iterableName = editor?.getAttributes('for')?.each;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

for now the iterable name is the full path, e.g. - we can polish this in separate PR, it was too difficult for me to change all that at once

for each "payload.comments"
   {{payload.comments.id}}

@ChmaraX ChmaraX changed the title feat(dashboard): restore 'for' block; refactor feat(dashboard): restore email editor 'for' block Jan 10, 2025
Copy link
Contributor

@djabarovgeorge djabarovgeorge left a comment

Choose a reason for hiding this comment

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

Amazing work, i left a couple of comments about my opinion of responsibility spread in the code and the meaning of it.

@@ -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 }}

Comment on lines 89 to 91
const tipTapNode = isStringTipTapNode(controlValue) ? JSON.parse(controlValue) : controlValue;
const iterableArrayPaths = this.extractIterableArrayPaths(tipTapNode);
const variablesExampleResult = this.buildVariablesExample(iterableArrayPaths, validVariableNames);
Copy link
Contributor

Choose a reason for hiding this comment

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

this is not the right place to build the for variables. please let me know if there was any objective to push this logic here.

id argue that the responsibility of creating the variables under buildVariables.
please note that once transformMailyContentToLiquid executes the following transformation

From:

### for block {{ payload.comments }}
{{ payload.comments.postBody | upcase}}

To:

{{ payload.comments[0].postBody | upcase }}

you will get those variables out of the box under validVariableNames in line 87.
The left part in this use case will be to multiply the arrays by 3.

Additional key points why this is an issue.

  • we are building payload schema on fly based on control value, where we reuse transformMailyContentToLiquid, not adding this transformation logic there will create incorrect payload schema of object instead of a list
    image
  • we are reusing the buildVariables in order to build the Payload for Test Workflow, meaning that now it will generate an object instead of an array
    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.

Changed as we discussed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also I noticed we are doing this:

  1. In step preview, we send the payload example in HTTP response to client.

  2. In trigger, we first encode the payload to JSONSchema, then on client we decode the JSONSchema back to payload in create-mock-object-from-schema. Its difficult to preserve all the properties of the payload in the schema (e.g. 3x example elements in array).

Not sure but maybe we can simplify this by sending the payload directly to trigger as well.

},
};
});
const content = node.content.map((contentNodeChild) => processNode(contentNodeChild));
Copy link
Contributor

Choose a reason for hiding this comment

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

As mentioned above here we should
Transform From:

### for block {{ payload.comments }}
{{ payload.comments.postBody | upcase}}

To:

{{ payload.comments[0].postBody | upcase }}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed as we discussed

Comment on lines -98 to -183

private getResolvedValueForPlaceholder(
masterPayload: PreviewPayload,
node: TipTapNode & {
attrs: { each: string };
},
itemPointerToDefaultRecord: Record<string, string>,
placeholderAggregation: PlaceholderAggregation
) {
let resolvedValueIfFound = this.getValueByPath(masterPayload, node.attrs.each);

if (!resolvedValueIfFound) {
resolvedValueIfFound = [
this.buildElement(itemPointerToDefaultRecord, '1'),
this.buildElement(itemPointerToDefaultRecord, '2'),
];
}
placeholderAggregation.nestedForPlaceholders[`{{${node.attrs.each}}}`] =
this.buildNestedVariableRecord(itemPointerToDefaultRecord);

return resolvedValueIfFound;
}

private buildNestedVariableRecord(itemPointerToDefaultRecord: Record<string, string>) {
const transformedObj: Record<string, string> = {};

Object.entries(itemPointerToDefaultRecord).forEach(([key, value]) => {
transformedObj[value] = value;
});

return transformedObj;
}
private collectAllItemPlaceholders(nodeExt: TipTapNode) {
const payloadValues = {};
const traverse = (node: TipTapNode) => {
if (node.type === 'for') {
return;
}
if (this.isPayloadValue(node)) {
const { id } = node.attrs;
payloadValues[`${node.attrs.id}`] = node.attrs.fallback || `{{item.${id}}}`;
}
if (node.content && Array.isArray(node.content)) {
node.content.forEach(traverse);
}
};
nodeExt.content?.forEach(traverse);

return payloadValues;
}

private getValueByPath(masterPayload: Record<string, any>, placeholderRef: string): any {
const keys = placeholderRef.split('.');

return keys.reduce((currentObj, key) => {
if (currentObj && typeof currentObj === 'object' && key in currentObj) {
return currentObj[key];
}

return undefined;
}, masterPayload);
}

private buildElement(itemPointerToDefaultRecord: Record<string, string>, suffix: string) {
const mockPayload: Record<string, any> = {};
Object.keys(itemPointerToDefaultRecord).forEach((key) => {
const keys = key.split('.');
let current = mockPayload;
keys.forEach((innerKey, index) => {
if (!current[innerKey]) {
current[innerKey] = {};
}
if (index === keys.length - 1) {
current[innerKey] = itemPointerToDefaultRecord[key] + suffix;
} else {
current = current[innerKey];
}
});
});

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');
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

not needed since we are removing related stuff here: #7484

Copy link

pkg-pr-new bot commented Jan 13, 2025

Open in Stackblitz

@novu/client

npm i https://pkg.pr.new/novuhq/novu/@novu/client@7483

@novu/headless

npm i https://pkg.pr.new/novuhq/novu/@novu/headless@7483

@novu/notification-center

npm i https://pkg.pr.new/novuhq/novu/@novu/notification-center@7483

@novu/node

npm i https://pkg.pr.new/novuhq/novu/@novu/node@7483

novu

npm i https://pkg.pr.new/novuhq/novu@7483

@novu/providers

npm i https://pkg.pr.new/novuhq/novu/@novu/providers@7483

@novu/shared

npm i https://pkg.pr.new/novuhq/novu/@novu/shared@7483

commit: 009d9ad

}

return {
hydratedEmailSchema: emailBody,
placeholderAggregation,
placeholderAggregation, // TODO: remove this
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Useless since we are removing this here: #7484

We can remove this line after the mentioned PR is done, I'm keeping it here to not break types down the line.

Copy link
Contributor

Choose a reason for hiding this comment

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

@djabarovgeorge please make sure we merge the above ASAP, preferably today.

@@ -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> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Something is a bit off with naming. Is the first parameter Novu Variables? Isn't fullPayloadForRender of this type?

Copy link
Contributor

Choose a reason for hiding this comment

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

correct Novu Variables and fullPayloadForRender are equal.

}

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.

return !!(node.attrs && 'each' in node.attrs);
private async handleShowNode(
node: TipTapNode & { attrs: { showIfKey: unknown } },
variables: Record<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.

Ditto about adding stricter typing to variables.

}

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

Choose a reason for hiding this comment

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

As discussed above

Suggested change
private async handleEachNode(
private async handleEachAttr(

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: { each: unknown } },
Copy link
Contributor

Choose a reason for hiding this comment

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

Ditto about enhancing the TipTapNode shared type

Copy link
Contributor

Choose a reason for hiding this comment

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

I believe in this context we are talking about enhancing Maily JSONContent (TipTap based) opinionated type

return node.attrs?.[MailyAttrsEnum.SHOW_IF_KEY] !== undefined;
private async handleEachNode(
node: TipTapNode & { attrs: { each: unknown } },
variables: Record<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.

Ditto on stronger types on this.

}

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

Choose a reason for hiding this comment

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

Suggested change
return !!(node.attrs && 'each' in node.attrs);
return !!(node.attrs && MailyAttrsEnum.EACH_KEY in node.attrs);

}

return {
hydratedEmailSchema: emailBody,
placeholderAggregation,
placeholderAggregation, // TODO: remove this
Copy link
Contributor

Choose a reason for hiding this comment

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

@djabarovgeorge please make sure we merge the above ASAP, preferably today.

Copy link
Contributor

Choose a reason for hiding this comment

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

Does this change add support for arrays? can we have a small test in liquid-parser.spec.ts for this new logic?

Copy link
Contributor

Choose a reason for hiding this comment

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

the validation became even more complex, this is fine for now but i wonder if we could make the validation work without this change.


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.

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[] {

}
}

function duplicateArrayItems(obj: Record<string, any>, size = 3): void {
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 moving this logic to the "generate preview" use case or making it configurable? Since this multiplication is only needed for previews right now, it could be triggered on demand. In the future, we could include it in the trigger tests if necessary.

Copy link
Contributor

@djabarovgeorge djabarovgeorge left a comment

Choose a reason for hiding this comment

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

Amazing work, looks super clean! please go over the comments

as a side thought, what do we think about adding indexes tp the preview? At first glance, it’s hard to understand that each value in the payload corresponds to a separate line in the preview, especially since the strings are identical.
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants