diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts index 81cb9386eae..519c58289e2 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts @@ -1,5 +1,5 @@ -import { Template, Liquid, RenderError, LiquidError } from 'liquidjs'; -import { isValidTemplate, extractLiquidExpressions } from './parser-utils'; +import { Liquid, LiquidError, RenderError, Template } from 'liquidjs'; +import { extractLiquidExpressions, isValidTemplate } from './parser-utils'; const LIQUID_CONFIG = { strictVariables: true, @@ -123,10 +123,14 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables { } function parseByLiquid(rawOutput: string): TemplateVariables { + const parserEngine = new Liquid(LIQUID_CONFIG); + + // Register digest filter for validation of digest transformers + parserEngine.registerFilter('digest', () => ''); + const validVariables: Variable[] = []; const invalidVariables: Variable[] = []; - const engine = new Liquid(LIQUID_CONFIG); - const parsed = engine.parse(rawOutput) as unknown as Template[]; + const parsed = parserEngine.parse(rawOutput) as unknown as Template[]; parsed.forEach((template: Template) => { if (isOutputToken(template)) { diff --git a/apps/api/src/app/workflows-v2/util/utils.ts b/apps/api/src/app/workflows-v2/util/utils.ts index b6f57866ad3..b3b7470f4f0 100644 --- a/apps/api/src/app/workflows-v2/util/utils.ts +++ b/apps/api/src/app/workflows-v2/util/utils.ts @@ -1,9 +1,9 @@ /* eslint-disable no-param-reassign */ +import { JSONSchemaDto } from '@novu/shared'; import difference from 'lodash/difference'; import isArray from 'lodash/isArray'; import isObject from 'lodash/isObject'; import reduce from 'lodash/reduce'; -import { JSONSchemaDto } from '@novu/shared'; import { MAILY_ITERABLE_MARK } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/maily.types'; export function findMissingKeys(requiredRecord: object, actualRecord: object) { diff --git a/apps/dashboard/src/components/primitives/control-input/variable-popover/constants.ts b/apps/dashboard/src/components/primitives/control-input/variable-popover/constants.ts index 22a30ca2889..d92d0a3d531 100644 --- a/apps/dashboard/src/components/primitives/control-input/variable-popover/constants.ts +++ b/apps/dashboard/src/components/primitives/control-input/variable-popover/constants.ts @@ -308,4 +308,17 @@ export const FILTERS: Filters[] = [ example: '"fun%20%26%20games" | url_decode → fun & games', sampleValue: 'fun%20%26%20games', }, + { + label: 'Digest', + value: 'digest', + hasParam: true, + description: 'Format a list of names with optional key path and separator', + example: 'events | digest: 2, "name", ", " → John, Jane and 3 others', + params: [ + { placeholder: 'Max names to show', type: 'number' }, + { placeholder: 'Object key path (optional)', type: 'string' }, + { placeholder: 'Custom separator (optional)', type: 'string' }, + ], + sampleValue: '[{ name: "John" }, { name: "Jane" }]', + }, ]; diff --git a/apps/dashboard/src/components/primitives/control-input/variable-popover/hooks/use-suggested-filters.ts b/apps/dashboard/src/components/primitives/control-input/variable-popover/hooks/use-suggested-filters.ts new file mode 100644 index 00000000000..6ae433e0d89 --- /dev/null +++ b/apps/dashboard/src/components/primitives/control-input/variable-popover/hooks/use-suggested-filters.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import { FILTERS } from '../constants'; +import { Filters, FilterWithParam } from '../types'; + +type SuggestionGroup = { + label: string; + filters: Filters[]; +}; + +export function useSuggestedFilters(variableName: string, currentFilters: FilterWithParam[]): SuggestionGroup[] { + return useMemo(() => { + const currentFilterValues = new Set(currentFilters.map((f) => f.value)); + const suggestedFilters: Filters[] = []; + + const addSuggestions = (filterValues: string[]) => { + const newFilters = FILTERS.filter((f) => filterValues.includes(f.value) && !currentFilterValues.has(f.value)); + + suggestedFilters.push(...newFilters); + }; + + if (isStepsEventsPattern(variableName)) { + addSuggestions(['digest']); + } + + if (isDateVariable(variableName)) { + addSuggestions(['date']); + } + + if (isNumberVariable(variableName)) { + addSuggestions(['round', 'floor', 'ceil', 'abs', 'plus', 'minus', 'times', 'divided_by']); + } + + if (isArrayVariable(variableName)) { + addSuggestions(['first', 'last', 'join', 'map', 'where', 'size']); + } + + if (isTextVariable(variableName)) { + addSuggestions(['upcase', 'downcase', 'capitalize', 'truncate', 'truncatewords']); + } + + return suggestedFilters.length > 0 ? [{ label: 'Suggested', filters: suggestedFilters }] : []; + }, [variableName, currentFilters]); +} + +function isDateVariable(name: string): boolean { + const datePatterns = ['date', 'time', 'created', 'updated', 'timestamp', 'scheduled']; + + return datePatterns.some((pattern) => name.toLowerCase().includes(pattern)); +} + +function isNumberVariable(name: string): boolean { + const numberPatterns = ['count', 'amount', 'total', 'price', 'quantity', 'number', 'sum', 'age']; + + return numberPatterns.some((pattern) => name.toLowerCase().includes(pattern)); +} + +function isArrayVariable(name: string): boolean { + const arrayPatterns = ['list', 'array', 'items', 'collection', 'set', 'group', 'events']; + + return arrayPatterns.some((pattern) => name.toLowerCase().includes(pattern)); +} + +function isTextVariable(name: string): boolean { + const textPatterns = ['name', 'title', 'description', 'text', 'message', 'content', 'label']; + + return textPatterns.some((pattern) => name.toLowerCase().includes(pattern)); +} + +function isStepsEventsPattern(name: string): boolean { + return /^steps\..*\.events$/.test(name); +} diff --git a/apps/dashboard/src/components/primitives/control-input/variable-popover/variable-popover.tsx b/apps/dashboard/src/components/primitives/control-input/variable-popover/variable-popover.tsx index 6d320906caf..7d888f8d4a3 100644 --- a/apps/dashboard/src/components/primitives/control-input/variable-popover/variable-popover.tsx +++ b/apps/dashboard/src/components/primitives/control-input/variable-popover/variable-popover.tsx @@ -6,6 +6,7 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator, } from '@/components/primitives/command'; import { FormControl, FormItem } from '@/components/primitives/form/form'; import { Input } from '@/components/primitives/input'; @@ -21,8 +22,9 @@ import { FilterItem } from './components/filter-item'; import { FilterPreview } from './components/filter-preview'; import { ReorderFiltersGroup } from './components/reorder-filters-group'; import { useFilterManager } from './hooks/use-filter-manager'; +import { useSuggestedFilters } from './hooks/use-suggested-filters'; import { useVariableParser } from './hooks/use-variable-parser'; -import type { FilterWithParam, VariablePopoverProps } from './types'; +import type { Filters, FilterWithParam, VariablePopoverProps } from './types'; import { formatLiquidVariable, getDefaultSampleValue } from './utils'; export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) { @@ -84,6 +86,7 @@ export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) { onUpdate: setFilters, }); + const suggestedFilters = useSuggestedFilters(name, filters); const filteredFilters = useMemo(() => getFilteredFilters(searchQuery), [getFilteredFilters, searchQuery]); const currentLiquidValue = useMemo( @@ -173,6 +176,25 @@ export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) { No filters found + {suggestedFilters.length > 0 && !searchQuery && ( + <> + + {suggestedFilters[0].filters.map((filterItem: Filters) => ( + { + handleFilterToggle(filterItem.value); + setSearchQuery(''); + setIsCommandOpen(false); + }} + > + + + ))} + + {suggestedFilters.length > 0 && } + + )} {filteredFilters.length > 0 && ( {filteredFilters.map((filter) => ( diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts index b47295073c4..d42dbebf43f 100644 --- a/packages/framework/src/client.ts +++ b/packages/framework/src/client.ts @@ -1,4 +1,5 @@ import { Liquid } from 'liquidjs'; +import { digest } from './filters/digest'; import { ChannelStepEnum, PostActionEnum } from './constants'; import { @@ -76,9 +77,11 @@ export class Client { this.apiUrl = builtOpts.apiUrl; this.secretKey = builtOpts.secretKey; this.strictAuthentication = builtOpts.strictAuthentication; + this.templateEngine.registerFilter('json', (value, spaces) => stringifyDataStructureWithSingleQuotes(value, spaces) ); + this.templateEngine.registerFilter('digest', digest); } private buildOptions(providedOptions?: ClientOptions) { diff --git a/packages/framework/src/filters/digest.ts b/packages/framework/src/filters/digest.ts new file mode 100644 index 00000000000..48516d76a6c --- /dev/null +++ b/packages/framework/src/filters/digest.ts @@ -0,0 +1,67 @@ +type NestedObject = Record; + +function getNestedValue(obj: NestedObject, path: string): string { + const value = path.split('.').reduce((current: unknown, key) => { + if (current && typeof current === 'object') { + return (current as Record)[key]; + } + + return undefined; + }, obj); + + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'object') { + const stringified = JSON.stringify(value); + + return stringified === '{}' ? '' : stringified; + } + + return ''; +} + +/** + * Format a list of items for digest notifications with configurable behavior + * Default formatting: + * - 1 item: "John" + * - 2 items: "John and Josh" + * - 3 items: "John, Josh and Sarah" + * - 4+ items: "John, Josh and 2 others" + * + * @param array The array of items to format + * @param maxNames Maximum names to show before using "others" + * @param keyPath Path to extract from objects (e.g., "name" or "profile.name") + * @param separator Custom separator between names (default: ", ") + * @returns Formatted string + * + * Examples: + * {{ actors | digest }} => "John, Josh and 2 others" + * {{ actors | digest: 2 }} => "John, Josh and 3 others" + * {{ users | digest: 2, "name" }} => For array of {name: string} + * {{ users | digest: 2, "profile.name", "•" }} => "John • Josh and 3 others" + */ +export function digest(array: unknown, maxNames = 2, keyPath?: string, separator = ', '): string { + if (!Array.isArray(array) || array.length === 0) return ''; + + const values = keyPath + ? array.map((item) => { + if (typeof item !== 'object' || !item) return ''; + + return getNestedValue(item as NestedObject, keyPath); + }) + : array; + + if (values.length === 1) return values[0]; + if (values.length === 2) return `${values[0]} and ${values[1]}`; + + if (values.length === 3 && maxNames >= 3) { + return `${values[0]}, ${separator}${values[1]} and ${values[2]}`; + } + + // Use "others" format for 4+ items or when maxNames is less than array length + const shownItems = values.slice(0, maxNames); + const othersCount = values.length - maxNames; + + return `${shownItems.join(separator)} and ${othersCount} ${othersCount === 1 ? 'other' : 'others'}`; +}