Skip to content

Commit 49c0190

Browse files
feat(dashboard): Digest liquid helper and popover handler (#7439)
Co-authored-by: Sokratis Vidros <[email protected]>
1 parent 21f86f4 commit 49c0190

File tree

7 files changed

+186
-6
lines changed

7 files changed

+186
-6
lines changed

apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Template, Liquid, RenderError, LiquidError } from 'liquidjs';
2-
import { isValidTemplate, extractLiquidExpressions } from './parser-utils';
1+
import { Liquid, LiquidError, RenderError, Template } from 'liquidjs';
2+
import { extractLiquidExpressions, isValidTemplate } from './parser-utils';
33

44
const LIQUID_CONFIG = {
55
strictVariables: true,
@@ -123,10 +123,14 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables {
123123
}
124124

125125
function parseByLiquid(rawOutput: string): TemplateVariables {
126+
const parserEngine = new Liquid(LIQUID_CONFIG);
127+
128+
// Register digest filter for validation of digest transformers
129+
parserEngine.registerFilter('digest', () => '');
130+
126131
const validVariables: Variable[] = [];
127132
const invalidVariables: Variable[] = [];
128-
const engine = new Liquid(LIQUID_CONFIG);
129-
const parsed = engine.parse(rawOutput) as unknown as Template[];
133+
const parsed = parserEngine.parse(rawOutput) as unknown as Template[];
130134

131135
parsed.forEach((template: Template) => {
132136
if (isOutputToken(template)) {

apps/api/src/app/workflows-v2/util/utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/* eslint-disable no-param-reassign */
2+
import { JSONSchemaDto } from '@novu/shared';
23
import difference from 'lodash/difference';
34
import isArray from 'lodash/isArray';
45
import isObject from 'lodash/isObject';
56
import reduce from 'lodash/reduce';
6-
import { JSONSchemaDto } from '@novu/shared';
77
import { MAILY_ITERABLE_MARK } from '../../environments-v1/usecases/output-renderers/maily-to-liquid/maily.types';
88

99
export function findMissingKeys(requiredRecord: object, actualRecord: object) {

apps/dashboard/src/components/primitives/control-input/variable-popover/constants.ts

+13
Original file line numberDiff line numberDiff line change
@@ -308,4 +308,17 @@ export const FILTERS: Filters[] = [
308308
example: '"fun%20%26%20games" | url_decode → fun & games',
309309
sampleValue: 'fun%20%26%20games',
310310
},
311+
{
312+
label: 'Digest',
313+
value: 'digest',
314+
hasParam: true,
315+
description: 'Format a list of names with optional key path and separator',
316+
example: 'events | digest: 2, "name", ", " → John, Jane and 3 others',
317+
params: [
318+
{ placeholder: 'Max names to show', type: 'number' },
319+
{ placeholder: 'Object key path (optional)', type: 'string' },
320+
{ placeholder: 'Custom separator (optional)', type: 'string' },
321+
],
322+
sampleValue: '[{ name: "John" }, { name: "Jane" }]',
323+
},
311324
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useMemo } from 'react';
2+
import { FILTERS } from '../constants';
3+
import { Filters, FilterWithParam } from '../types';
4+
5+
type SuggestionGroup = {
6+
label: string;
7+
filters: Filters[];
8+
};
9+
10+
export function useSuggestedFilters(variableName: string, currentFilters: FilterWithParam[]): SuggestionGroup[] {
11+
return useMemo(() => {
12+
const currentFilterValues = new Set(currentFilters.map((f) => f.value));
13+
const suggestedFilters: Filters[] = [];
14+
15+
const addSuggestions = (filterValues: string[]) => {
16+
const newFilters = FILTERS.filter((f) => filterValues.includes(f.value) && !currentFilterValues.has(f.value));
17+
18+
suggestedFilters.push(...newFilters);
19+
};
20+
21+
if (isStepsEventsPattern(variableName)) {
22+
addSuggestions(['digest']);
23+
}
24+
25+
if (isDateVariable(variableName)) {
26+
addSuggestions(['date']);
27+
}
28+
29+
if (isNumberVariable(variableName)) {
30+
addSuggestions(['round', 'floor', 'ceil', 'abs', 'plus', 'minus', 'times', 'divided_by']);
31+
}
32+
33+
if (isArrayVariable(variableName)) {
34+
addSuggestions(['first', 'last', 'join', 'map', 'where', 'size']);
35+
}
36+
37+
if (isTextVariable(variableName)) {
38+
addSuggestions(['upcase', 'downcase', 'capitalize', 'truncate', 'truncatewords']);
39+
}
40+
41+
return suggestedFilters.length > 0 ? [{ label: 'Suggested', filters: suggestedFilters }] : [];
42+
}, [variableName, currentFilters]);
43+
}
44+
45+
function isDateVariable(name: string): boolean {
46+
const datePatterns = ['date', 'time', 'created', 'updated', 'timestamp', 'scheduled'];
47+
48+
return datePatterns.some((pattern) => name.toLowerCase().includes(pattern));
49+
}
50+
51+
function isNumberVariable(name: string): boolean {
52+
const numberPatterns = ['count', 'amount', 'total', 'price', 'quantity', 'number', 'sum', 'age'];
53+
54+
return numberPatterns.some((pattern) => name.toLowerCase().includes(pattern));
55+
}
56+
57+
function isArrayVariable(name: string): boolean {
58+
const arrayPatterns = ['list', 'array', 'items', 'collection', 'set', 'group', 'events'];
59+
60+
return arrayPatterns.some((pattern) => name.toLowerCase().includes(pattern));
61+
}
62+
63+
function isTextVariable(name: string): boolean {
64+
const textPatterns = ['name', 'title', 'description', 'text', 'message', 'content', 'label'];
65+
66+
return textPatterns.some((pattern) => name.toLowerCase().includes(pattern));
67+
}
68+
69+
function isStepsEventsPattern(name: string): boolean {
70+
return /^steps\..*\.events$/.test(name);
71+
}

apps/dashboard/src/components/primitives/control-input/variable-popover/variable-popover.tsx

+23-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CommandInput,
77
CommandItem,
88
CommandList,
9+
CommandSeparator,
910
} from '@/components/primitives/command';
1011
import { FormControl, FormItem } from '@/components/primitives/form/form';
1112
import { Input } from '@/components/primitives/input';
@@ -21,8 +22,9 @@ import { FilterItem } from './components/filter-item';
2122
import { FilterPreview } from './components/filter-preview';
2223
import { ReorderFiltersGroup } from './components/reorder-filters-group';
2324
import { useFilterManager } from './hooks/use-filter-manager';
25+
import { useSuggestedFilters } from './hooks/use-suggested-filters';
2426
import { useVariableParser } from './hooks/use-variable-parser';
25-
import type { FilterWithParam, VariablePopoverProps } from './types';
27+
import type { Filters, FilterWithParam, VariablePopoverProps } from './types';
2628
import { formatLiquidVariable, getDefaultSampleValue } from './utils';
2729

2830
export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) {
@@ -84,6 +86,7 @@ export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) {
8486
onUpdate: setFilters,
8587
});
8688

89+
const suggestedFilters = useSuggestedFilters(name, filters);
8790
const filteredFilters = useMemo(() => getFilteredFilters(searchQuery), [getFilteredFilters, searchQuery]);
8891

8992
const currentLiquidValue = useMemo(
@@ -173,6 +176,25 @@ export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) {
173176

174177
<CommandList className="max-h-[300px]">
175178
<CommandEmpty>No filters found</CommandEmpty>
179+
{suggestedFilters.length > 0 && !searchQuery && (
180+
<>
181+
<CommandGroup heading="Suggested">
182+
{suggestedFilters[0].filters.map((filterItem: Filters) => (
183+
<CommandItem
184+
key={filterItem.value}
185+
onSelect={() => {
186+
handleFilterToggle(filterItem.value);
187+
setSearchQuery('');
188+
setIsCommandOpen(false);
189+
}}
190+
>
191+
<FilterItem filter={filterItem} />
192+
</CommandItem>
193+
))}
194+
</CommandGroup>
195+
{suggestedFilters.length > 0 && <CommandSeparator />}
196+
</>
197+
)}
176198
{filteredFilters.length > 0 && (
177199
<CommandGroup>
178200
{filteredFilters.map((filter) => (

packages/framework/src/client.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Liquid } from 'liquidjs';
2+
import { digest } from './filters/digest';
23

34
import { ChannelStepEnum, PostActionEnum } from './constants';
45
import {
@@ -76,9 +77,11 @@ export class Client {
7677
this.apiUrl = builtOpts.apiUrl;
7778
this.secretKey = builtOpts.secretKey;
7879
this.strictAuthentication = builtOpts.strictAuthentication;
80+
7981
this.templateEngine.registerFilter('json', (value, spaces) =>
8082
stringifyDataStructureWithSingleQuotes(value, spaces)
8183
);
84+
this.templateEngine.registerFilter('digest', digest);
8285
}
8386

8487
private buildOptions(providedOptions?: ClientOptions) {
+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
type NestedObject = Record<string, unknown>;
2+
3+
function getNestedValue(obj: NestedObject, path: string): string {
4+
const value = path.split('.').reduce((current: unknown, key) => {
5+
if (current && typeof current === 'object') {
6+
return (current as Record<string, unknown>)[key];
7+
}
8+
9+
return undefined;
10+
}, obj);
11+
12+
if (value === null || value === undefined) return '';
13+
if (typeof value === 'string') return value;
14+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
15+
if (typeof value === 'object') {
16+
const stringified = JSON.stringify(value);
17+
18+
return stringified === '{}' ? '' : stringified;
19+
}
20+
21+
return '';
22+
}
23+
24+
/**
25+
* Format a list of items for digest notifications with configurable behavior
26+
* Default formatting:
27+
* - 1 item: "John"
28+
* - 2 items: "John and Josh"
29+
* - 3 items: "John, Josh and Sarah"
30+
* - 4+ items: "John, Josh and 2 others"
31+
*
32+
* @param array The array of items to format
33+
* @param maxNames Maximum names to show before using "others"
34+
* @param keyPath Path to extract from objects (e.g., "name" or "profile.name")
35+
* @param separator Custom separator between names (default: ", ")
36+
* @returns Formatted string
37+
*
38+
* Examples:
39+
* {{ actors | digest }} => "John, Josh and 2 others"
40+
* {{ actors | digest: 2 }} => "John, Josh and 3 others"
41+
* {{ users | digest: 2, "name" }} => For array of {name: string}
42+
* {{ users | digest: 2, "profile.name", "•" }} => "John • Josh and 3 others"
43+
*/
44+
export function digest(array: unknown, maxNames = 2, keyPath?: string, separator = ', '): string {
45+
if (!Array.isArray(array) || array.length === 0) return '';
46+
47+
const values = keyPath
48+
? array.map((item) => {
49+
if (typeof item !== 'object' || !item) return '';
50+
51+
return getNestedValue(item as NestedObject, keyPath);
52+
})
53+
: array;
54+
55+
if (values.length === 1) return values[0];
56+
if (values.length === 2) return `${values[0]} and ${values[1]}`;
57+
58+
if (values.length === 3 && maxNames >= 3) {
59+
return `${values[0]}, ${separator}${values[1]} and ${values[2]}`;
60+
}
61+
62+
// Use "others" format for 4+ items or when maxNames is less than array length
63+
const shownItems = values.slice(0, maxNames);
64+
const othersCount = values.length - maxNames;
65+
66+
return `${shownItems.join(separator)} and ${othersCount} ${othersCount === 1 ? 'other' : 'others'}`;
67+
}

0 commit comments

Comments
 (0)