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): Digest liquid helper and popover handler #7439

Open
wants to merge 6 commits into
base: pills-for-all-inputs
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,6 @@ export const TRANSFORMERS: Transformer[] = [
example: '"2024-01-20" | date: "%B %d, %Y" → January 20, 2024',
params: [{ placeholder: 'Format (e.g. "%Y-%m-%d")', description: 'strftime format', type: 'string' }],
},
{
label: 'Default',
value: 'default',
hasParam: true,
description: 'Use default value if input is empty',
example: '"" | default: "¯\\_(ツ)_/¯" → ¯\\_(ツ)_/¯',
params: [{ placeholder: 'Default value', type: 'string' }],
},
{
label: 'JSON',
value: 'json',
Expand Down Expand Up @@ -266,4 +258,16 @@ export const TRANSFORMERS: Transformer[] = [
description: 'Decode URL-encoded string',
example: '"fun%20%26%20games" | url_decode → fun & games',
},
{
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' },
],
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import { TRANSFORMERS } from '../constants';
import type { Transformer, TransformerWithParam } from '../types';

type SuggestionGroup = {
label: string;
transformers: Transformer[];
};

export function useSuggestedTransformers(
variableName: string,
currentTransformers: TransformerWithParam[]
): SuggestionGroup[] {
return useMemo(() => {
const currentTransformerValues = new Set(currentTransformers.map((t) => t.value));
const suggestedTransformers: Transformer[] = [];

const addSuggestions = (transformerValues: string[]) => {
const newTransformers = TRANSFORMERS.filter(
(t) => transformerValues.includes(t.value) && !currentTransformerValues.has(t.value)
);

suggestedTransformers.push(...newTransformers);
};

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 suggestedTransformers.length > 0 ? [{ label: 'Suggested', transformers: suggestedTransformers }] : [];
}, [variableName, currentTransformers]);
}

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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/primitives/command';
import { FormControl, FormItem } from '@/components/primitives/form/form';
import { Input, InputField } from '@/components/primitives/input';
Expand All @@ -17,6 +18,7 @@ import { Code2 } from '../../../icons/code-2';
import { Separator } from '../../separator';
import { TransformerItem } from './components/transformer-item';
import { TransformerList } from './components/transformer-list';
import { useSuggestedTransformers } from './hooks/use-suggested-transformers';
import { useTransformerManager } from './hooks/use-transformer-manager';
import { useVariableParser } from './hooks/use-variable-parser';
import type { VariablePopoverProps } from './types';
Expand Down Expand Up @@ -78,20 +80,16 @@ export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) {
);

const handleRawLiquidChange = useCallback((value: string) => {
// Remove {{ and }} and trim
const content = value.replace(/^\{\{\s*|\s*\}\}$/g, '').trim();

// Split by pipe and trim each part
const parts = content.split('|').map((part) => part.trim());

// First part is the name
const newName = parts[0];
setName(newName);

// Process each part after the name
parts.slice(1).forEach((part) => {
if (part.startsWith('default:')) {
// Extract default value, handling quotes
const newDefaultVal = part
.replace('default:', '')
.trim()
Expand All @@ -101,6 +99,8 @@ export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) {
});
}, []);

const suggestedTransformers = useSuggestedTransformers(name, transformers);

const filteredTransformers = useMemo(
() => getFilteredTransformers(searchQuery),
[getFilteredTransformers, searchQuery]
Expand Down Expand Up @@ -149,6 +149,7 @@ export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) {
</div>
</FormControl>
</FormItem>

<FormItem>
<FormControl>
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -201,8 +202,27 @@ export function VariablePopover({ variable, onUpdate }: VariablePopoverProps) {

<CommandList className="max-h-[300px]">
<CommandEmpty>No modifiers found</CommandEmpty>
{suggestedTransformers.length > 0 && !searchQuery && (
<>
<CommandGroup heading="Suggested">
{suggestedTransformers[0].transformers.map((transformer) => (
<CommandItem
key={transformer.value}
onSelect={() => {
handleTransformerToggle(transformer.value);
setSearchQuery('');
setIsCommandOpen(false);
}}
>
<TransformerItem transformer={transformer} />
</CommandItem>
))}
</CommandGroup>
{filteredTransformers.length > 0 && <CommandSeparator />}
</>
)}
{filteredTransformers.length > 0 && (
<CommandGroup>
<CommandGroup heading={searchQuery ? 'Search Results' : 'All Modifiers'}>
{filteredTransformers.map((transformer) => (
<CommandItem
key={transformer.value}
Expand Down
3 changes: 3 additions & 0 deletions packages/framework/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Liquid } from 'liquidjs';
import { digest } from './filters/digest';

import { ChannelStepEnum, PostActionEnum } from './constants';
import {
Expand Down Expand Up @@ -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) {
Expand Down
67 changes: 67 additions & 0 deletions packages/framework/src/filters/digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
type NestedObject = Record<string, unknown>;

function getNestedValue(obj: NestedObject, path: string): string {
const value = path.split('.').reduce((current: unknown, key) => {
if (current && typeof current === 'object') {
return (current as Record<string, unknown>)[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 {
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 sure if that's the best name 🤔

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'}`;
}
Loading