Skip to content

Conversation

@LetItRock
Copy link
Contributor

What changed? Why was the change needed?

Implemented the validation logic for the liquid variable filters, which is built on top of the step issues.
The filter args validation function is defined close to the LiquidJS filter, meaning that in the Framework. We can later decide if we want to do similar validation in the Framework.

Screenshots

Screenshot 2025-04-22 at 18 02 31

Screen.Recording.2025-04-22.at.18.01.22.mov

@linear
Copy link

linear bot commented Apr 22, 2025

@netlify
Copy link

netlify bot commented Apr 22, 2025

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

Name Link
🔨 Latest commit b411e67
🔍 Latest deploy log https://app.netlify.com/sites/dashboard-v2-novu-staging/deploys/6808e3b904ebe600081c4b9a
😎 Deploy Preview https://deploy-preview-8178.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.

@LetItRock LetItRock force-pushed the nv-5705-validate-liquidjs-filter-args branch from ef89a0b to 8273085 Compare April 22, 2025 16:26
@pkg-pr-new
Copy link

pkg-pr-new bot commented Apr 22, 2025

Open in StackBlitz

npm i https://pkg.pr.new/novuhq/novu/@novu/framework@8178
npm i https://pkg.pr.new/novuhq/novu@8178
npm i https://pkg.pr.new/novuhq/novu/@novu/providers@8178
npm i https://pkg.pr.new/novuhq/novu/@novu/shared@8178

commit: b411e67

Comment on lines 151 to 157
if ('filterMessage' in error) {
return {
message: `Variable ${error.output} filter ${error.filterMessage}`,
issueType: StepContentIssueEnum.INVALID_FILTER_ARG_IN_VARIABLE,
variableName: error.output,
};
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The LiquidJS filter errors have the filterMessage field so this way we differentiate it from the variable errors.
In the future, there will be more fields like filterBegin: number; filterEnd: number, which could tell the exact place in the template where the error is.

Comment on lines 43 to 46
const { validVariables, invalidVariables } = extractLiquidTemplateVariables({
template: JSON.stringify(variableControlValue),
variableSchema,
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We now have only one place where we create valid/invalid variables.
This change also simplifies the future extension for the additional invalid variables information, like a start, end positions in the template, which the FE could then use to inform the user.

};
}

function isPropertyAllowed(schema: Record<string, unknown>, propertyPath: 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.

this function is used in the another file

export function extractLiquidTemplateVariables(template: string): TemplateVariables {
export function extractLiquidTemplateVariables({
template,
variableSchema,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

pass the variableSchema to be able to validate the variables against it

const validVariables: Variable[] = [];
const invalidVariables: Variable[] = [];
const parsed = parserEngine.parse(rawOutput) as unknown as Template[];
function isPropertyAllowed(schema: Record<string, unknown> | undefined, propertyPath: 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.

reused

Comment on lines +233 to +234
const filters = extractFilters(template);
const filterIssues = validateFilters(filters, isDigestEventsVariable);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Extract filters from the parsed liquid template, and then validate the filters.
The single filter has the information about the filter name and args, also includes the position in the template.

Comment on lines +105 to +118
const argsSchema = z.object({
keyPath: requireKeyPath ? z.string().min(1, 'must be non-empty') : z.string().optional().default(DEFAULT_KEY_PATH),
limit: z
.number()
.optional()
.default(DEFAULT_LIMIT)
.refine((val) => {
return val >= 0;
}, 'must be greater than or equal to 0'),
overflowSuffix: z.string().optional().default(DEFAULT_OVERFLOW_SUFFIX),
wordsConnector: z.string().optional().default(DEFAULT_WORDS_CONNECTOR),
twoWordsConnector: z.string().optional().default(DEFAULT_TWO_WORDS_CONNECTOR),
lastWordConnector: z.string().optional().default(DEFAULT_LAST_WORD_CONNECTOR),
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Zod schema for the filter args

Comment on lines +121 to +132
args.forEach((arg, index) => {
if (!Array.isArray(arg)) {
let value: string | number = arg.getText();
if (arg.kind === TokenKind.Quoted) {
value = (arg as QuotedToken).content;
} else if (arg.kind === TokenKind.Number) {
value = (arg as NumberToken).content;
}
const argName = ARG_INDEX_TO_ARG_NAME[index];
argsObject[argName] = value;
}
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

convert args array to object that will be validated

Comment on lines +137 to +156
for (const error of result.error.issues) {
let type = 'string';
if ('type' in error) {
type = error.type;
}

const path = error.path[0];
const argIndexToArgName = Object.entries(ARG_INDEX_TO_ARG_NAME).find(([_, argName]) => argName === path);
const argIndex = argIndexToArgName ? parseInt(argIndexToArgName[0], 10) : null;
const token = typeof argIndex === 'number' ? args[argIndex] : null;

if (token && !Array.isArray(token)) {
issues.push({
message: `"toSentence" expects a ${type}${error.message ? ` that ${error.message}` : ''} for argument "${path}"`,
begin: token.begin,
end: token.end,
value: token.getText(),
});
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

build the validation errors

output: string;
};

type InvalidContentVariable = Variable & {
Copy link
Contributor

Choose a reason for hiding this comment

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

I am not sure I prefer having two separate types for this. It introduces too many types and I think it makes the code more cumbersome that it should be. Variables are still variables, regardless if they appear in filters or in content, right?

Alternatively, I believe extending the Variable object with extra optional attributes is a better idea. For now, the filterMessage is only used here to define the error message.

My suggestion is just populate the existing variable.message field for now with the following copywriting:

Filter ${error.filterMessage} in ${variable.name}.

Notice that I used the name not the full output of the variable for a shorter and friendlier message.

Copy link
Contributor Author

@LetItRock LetItRock Apr 22, 2025

Choose a reason for hiding this comment

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

I've separated the types because in the future there could be details about the filter start and end positions. Also we don't have a way to differentiate these validation errors to build the different messages, that's why I introduced the filterMessage field.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So I'll revert to one type but with two fields for message, unless we want the type.

): LiquidFilterIssue[] {
const { requireKeyPath = false } = options;
const issues: LiquidFilterIssue[] = [];
if (args.length < 1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@LetItRock I am not sure I follow the logic here. Can't we verify args only using zod by converting the filter arguyments arguments to an object without having to do all this pre and post processing?

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'm not sure what you mean by pre/post processing. Can you clarify or pseudo code?

converting the filter arguyments arguments to an object

This is what I do at line 121


But if it's about the args, there are a few reasons it takes an args array:

  • the LiquidJS engine produces the output tokens for variables where the filters have args as an array
  • we don't need to have the conversion logic on the API side for every filter, so the code is generic there
  • it's aligned with the order of defined args in the filter function

Copy link
Contributor

@SokratisVidros SokratisVidros left a comment

Choose a reason for hiding this comment

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

My bad, I wanted to request changes :)

@LetItRock LetItRock merged commit aa3a725 into next Apr 23, 2025
32 checks passed
@LetItRock LetItRock deleted the nv-5705-validate-liquidjs-filter-args branch April 23, 2025 15:34
bricehemery pushed a commit to jack-agency/novu that referenced this pull request Jul 7, 2025
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.

2 participants