Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ This guide covers the complete process of adding custom filters or tags to the t

Adding a new filter/tag requires updates in multiple places:

1. **Engine registration** (server-side)
2. **UI parsing** (frontend validation)
3. **Autocompletion** (UI suggestions)
4. **OSS contribution** (optional, recommended)
1. **Engine registration** (server-side execution)
2. **UI parsing** (frontend validation — prevents editor errors)
3. **UI hover evaluation** (frontend expression evaluator — enables hover tooltips)
4. **Autocompletion** (UI suggestions in Monaco editor)
5. **Foreach schema awareness** (if the filter changes the output type, e.g. object → array)
6. **Tests** (server-side and schema tests)
7. **OSS contribution** (optional, recommended)

## Step 1: Register in the Engine

Expand Down Expand Up @@ -89,7 +92,29 @@ function getLiquidInstance(): Liquid {

**Why?** This prevents parsing errors when users type the filter in the UI editor.

## Step 3: Add to Autocompletion
## Step 3: Register in the UI Hover Expression Evaluator

The hover tooltip in the YAML editor evaluates template expressions client-side to show live values.
Custom filters must also be registered here, otherwise hover evaluation falls back to path-only
resolution and shows incorrect results (or "undefined").

**File:** `src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/template_expression/evaluate_expression.ts`

```typescript
// Register custom filters that match server-side exactly
liquidEngine.registerFilter('my_custom_filter', (value: unknown): unknown => {
// Full implementation (same as server-side) — this runs during hover evaluation
if (typeof value !== 'string') {
return value;
}
return transformedValue;
});
```

**Why?** Unlike the validation instance (Step 2) which only needs a no-op stub, the hover evaluator
needs the **real implementation** since it evaluates expressions against actual execution data.

## Step 4: Add to Autocompletion

Add your filter to the autocompletion provider:

Expand All @@ -113,7 +138,30 @@ export const LIQUID_FILTERS = [
- `insertText`: Snippet for autocompletion (use `${1:placeholder}` for arguments)
- `example`: Usage example

## Step 4: Write Tests
## Step 5: Update Foreach Schema Awareness (if type-changing)

If your filter transforms the output type (e.g., `entries` converts an object to an array of `{key, value}` pairs),
the foreach schema resolver needs to know about it. Otherwise, the editor will show "Expected array for foreach
iteration, but got object" when using `| your_filter` in a `foreach:` expression.

**File:** `src/platform/plugins/shared/workflows_management/public/features/workflow_context/lib/get_foreach_state_schema.ts`

Add a check for your filter in the `getForeachItemSchema` function, before the final `else` branch
that throws `InvalidForeachParameterError`:

```typescript
} else if (iterableSchema instanceof z.ZodObject && hasMyFilter) {
// my_filter converts objects to arrays of a known shape
return z.object({ /* your item schema */ });
} else {
throw new InvalidForeachParameterError(...);
}
```

**When is this needed?** Only when the filter changes the type in a way that affects `foreach` iteration
(e.g., object → array). Filters that preserve the type (e.g., `json_parse`, `upcase`) don't need this.

## Step 6: Write Tests

Add tests for your filter:

Expand All @@ -137,7 +185,7 @@ describe('my_custom_filter', () => {
});
```

## Step 5: Contribute to OSS (Optional)
## Step 7: Contribute to OSS (Optional)

If you want to contribute your filter to the [liquidjs](https://github.com/harttle/liquidjs) project:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,79 @@ describe('WorkflowTemplatingEngine', () => {
});
});

describe('custom entries filter', () => {
it('should convert an object to an array of {key, value} pairs', () => {
const template = '${{ data | entries }}';
const context = { data: { a: 1, b: 2, c: 3 } };
const result = templatingEngine.render({ out: template }, context);
expect(result.out).toEqual([
{ key: 'a', value: 1 },
{ key: 'b', value: 2 },
{ key: 'c', value: 3 },
]);
});

it('should handle nested object values', () => {
const template = '${{ data | entries }}';
const context = {
data: {
'index-001': { settings: { lifecycle: { name: 'old-policy' } } },
'index-002': { settings: { lifecycle: { name: 'new-policy' } } },
},
};
const result = templatingEngine.render({ out: template }, context);
expect(result.out).toEqual([
{ key: 'index-001', value: { settings: { lifecycle: { name: 'old-policy' } } } },
{ key: 'index-002', value: { settings: { lifecycle: { name: 'new-policy' } } } },
]);
});

it('should return arrays as-is', () => {
const template = '${{ data | entries }}';
const context = { data: [1, 2, 3] };
const result = templatingEngine.render({ out: template }, context);
expect(result.out).toEqual([1, 2, 3]);
});

it('should return null as-is', () => {
const template = '{{ data | entries }}';
const context = { data: null };
const result = templatingEngine.render(template, context);
expect(result).toBe('');
});

it('should return non-object primitives as-is', () => {
const template = '{{ data | entries }}';
const context = { data: 'hello' };
const result = templatingEngine.render(template, context);
expect(result).toBe('hello');
});

it('should handle empty objects', () => {
const template = '${{ data | entries }}';
const context = { data: {} };
const result = templatingEngine.render({ out: template }, context);
expect(result.out).toEqual([]);
});

it('should work with evaluateExpression for foreach usage', () => {
const template = '{{ data | entries }}';
const context = { data: { x: 10, y: 20 } };
const result = templatingEngine.evaluateExpression(template, context);
expect(result).toEqual([
{ key: 'x', value: 10 },
{ key: 'y', value: 20 },
]);
});

it('should chain with other filters', () => {
const template = '${{ data | entries | first }}';
const context = { data: { a: 1, b: 2 } };
const result = templatingEngine.render({ out: template }, context);
expect(result.out).toEqual({ key: 'a', value: 1 });
});
});

describe('filter chaining', () => {
it('should chain json and json_parse filters', () => {
const template = '{{ data | json | json_parse | json }}';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export class WorkflowTemplatingEngine {
return value;
}
});

// register entries filter that converts an object into an array of {key, value} pairs
this.engine.registerFilter('entries', (value: unknown): unknown => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value;
}
return Object.entries(value).map(([k, v]) => ({ key: k, value: v }));
});
}

public render<T>(obj: T, context: Record<string, unknown>): T {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ function getLiquidInstance(): Liquid {
liquidInstance.registerFilter('json_parse', (value: unknown): unknown => {
return value;
});
liquidInstance.registerFilter('entries', (value: unknown): unknown => {
return value;
});
}
return liquidInstance;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,54 @@ describe('getForeachStateSchema', () => {
);
});

it('should return {key, value} item schema when object has | entries filter', () => {
const stepContext = DynamicStepContextSchema.extend({
consts: z.object({
settings: z.object({ name: z.string(), surname: z.string() }),
}),
});
const foreachStateSchema = getForeachStateSchema(stepContext, {
foreach: '{{consts.settings | entries}}',
type: 'foreach',
name: 'foreach-step',
});
expectZodSchemaEqual(
foreachStateSchema,
ForEachContextSchema.extend({
item: z.object({ key: z.string(), value: z.unknown() }),
items: z.array(z.object({ key: z.string(), value: z.unknown() })),
})
);
});

it('should return {key, value} item schema for deeply nested object with | entries filter and spaces', () => {
const stepContext = DynamicStepContextSchema.extend({
consts: z.object({
es_settings_response: z.object({
'.ds-logs-docker-000001': z.object({
settings: z.object({
index: z.object({
lifecycle: z.object({ name: z.literal('old-policy') }),
}),
}),
}),
}),
}),
});
const foreachStateSchema = getForeachStateSchema(stepContext, {
foreach: '{{ consts.es_settings_response | entries }}',
type: 'foreach',
name: 'foreach-step',
});
expectZodSchemaEqual(
foreachStateSchema,
ForEachContextSchema.extend({
item: z.object({ key: z.string(), value: z.unknown() }),
items: z.array(z.object({ key: z.string(), value: z.unknown() })),
})
);
});

it('should return an unknown schema with a description if the foreach parameter is not a valid JSON', () => {
const stepContext = DynamicStepContextSchema.extend({
consts: z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,15 @@ const extractForeachItemSchemaFromJson = (foreachParam: string): z.ZodType => {
return extractForeachItemSchemaFromArray(foreachParamParsed);
};

const ENTRIES_FILTER = 'entries';
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

entries is intended for object/maps, but this schema branch only triggers for z.ZodObject. In practice, many map-shaped payloads (e.g., Elasticsearch index-name → settings) are modeled as z.ZodRecord, so foreach: '{{ someMap | entries }}' may still throw 'Expected array...' in the editor. Consider extending the condition to also handle z.ZodRecord (and ideally use the record's value schema for value when available) so the schema matches real-world map usage.

Copilot uses AI. Check for mistakes.

export function getForeachItemSchema(
stepContextSchema: typeof DynamicStepContextSchema,
foreachParam: string
): z.ZodType {
const parsedPath = parseVariablePath(foreachParam);
const iterateOverPath = parsedPath?.propertyPath;
const hasEntriesFilter = parsedPath?.filters?.includes(ENTRIES_FILTER) ?? false;
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

entries is intended for object/maps, but this schema branch only triggers for z.ZodObject. In practice, many map-shaped payloads (e.g., Elasticsearch index-name → settings) are modeled as z.ZodRecord, so foreach: '{{ someMap | entries }}' may still throw 'Expected array...' in the editor. Consider extending the condition to also handle z.ZodRecord (and ideally use the record's value schema for value when available) so the schema matches real-world map usage.

Copilot uses AI. Check for mistakes.

// If we have a valid variable path syntax (e.g., {{some.path}})
if (parsedPath && !parsedPath.errors && iterateOverPath) {
Expand Down Expand Up @@ -140,6 +143,8 @@ export function getForeachItemSchema(
InvalidForeachParameterErrorCodes.INVALID_UNION
);
}
} else if (iterableSchema instanceof z.ZodObject && hasEntriesFilter) {
return z.object({ key: z.string(), value: z.unknown() });
Comment on lines +146 to +147
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

entries is intended for object/maps, but this schema branch only triggers for z.ZodObject. In practice, many map-shaped payloads (e.g., Elasticsearch index-name → settings) are modeled as z.ZodRecord, so foreach: '{{ someMap | entries }}' may still throw 'Expected array...' in the editor. Consider extending the condition to also handle z.ZodRecord (and ideally use the record's value schema for value when available) so the schema matches real-world map usage.

Suggested change
} else if (iterableSchema instanceof z.ZodObject && hasEntriesFilter) {
return z.object({ key: z.string(), value: z.unknown() });
} else if (
hasEntriesFilter &&
(iterableSchema instanceof z.ZodObject || iterableSchema instanceof z.ZodRecord)
) {
// When using the "entries" filter on an object or record, we expose { key, value } items.
// For ZodRecord, try to reuse the record's value schema if available; otherwise fall back to unknown.
let valueSchema: z.ZodType = z.unknown();
if (iterableSchema instanceof z.ZodRecord) {
const recordAny = iterableSchema as any;
const recordValueSchema: z.ZodType | undefined =
recordAny.valueType ?? recordAny._def?.valueType;
if (recordValueSchema) {
valueSchema = recordValueSchema;
}
}
return z.object({ key: z.string(), value: valueSchema });

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not reproducible today. I investigated: (1) inferZodType() always produces z.ZodObject for objects, never ZodRecord. (2) elasticsearch.request has no output schema — returns z.unknown(). (3) Typed ES connectors use ZodObject/ZodUnion, not ZodRecord. No current code path produces a ZodRecord here. Will add when we implement output schemas for ES request steps.

Comment thread
shahargl marked this conversation as resolved.
} else {
throw new InvalidForeachParameterError(
`Expected array for foreach iteration, but got ${getZodTypeName(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ export const LIQUID_FILTERS = [
insertText: 'downcase',
example: '{{ "HELLO" | downcase }} => hello',
},
{
name: 'entries',
description:
'Converts an object into an array of {key, value} pairs, enabling iteration over object keys with foreach',
insertText: 'entries',
example:
'{{ {"a": 1, "b": 2} | entries }} => [{"key": "a", "value": 1}, {"key": "b", "value": 2}]',
},
{
name: 'escape',
description: 'Escapes a string by replacing characters with escape sequences',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,35 @@ export function buildExecutionContext(
return null;
}

// Build steps object from step executions
// Build steps object from step executions.
// For foreach steps the server emits multiple executions with the same stepId
// (enter, each iteration advance, exit). Preserve the entry that carries
// state.index so findForeachContext can re-evaluate the foreach expression.
const steps: Record<string, StepExecutionData> = {};

for (const stepExecution of stepExecutions) {
steps[stepExecution.stepId] = {
output: stepExecution.output as JsonValue | undefined,
error: stepExecution.error,
input: stepExecution.input as JsonValue | undefined,
status: stepExecution.status,
state: stepExecution.state as JsonObject | undefined,
};
const existing = steps[stepExecution.stepId];
const newState = stepExecution.state as JsonObject | undefined;

if (
existing?.state &&
typeof existing.state.index === 'number' &&
newState &&
typeof newState.index !== 'number'
) {
// Don't overwrite a foreach entry (has state.index) with the exit (no index)
existing.output = existing.output ?? (stepExecution.output as JsonValue | undefined);
existing.error = existing.error ?? stepExecution.error;
existing.status = stepExecution.status;
} else {
steps[stepExecution.stepId] = {
output: stepExecution.output as JsonValue | undefined,
error: stepExecution.error,
input: stepExecution.input as JsonValue | undefined,
status: stepExecution.status,
state: newState,
};
}
}

// Build full context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,40 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider {
return stepData;
}

/**
* Ensure foreach step input AND referenced step outputs are loaded.
* The foreach expression (e.g. "{{ steps.X.output | entries }}") needs
* both the foreach step's input (to know the expression) and the
* referenced step's output (to evaluate it).
*/
private async enrichForeachStepInput(context: ExecutionContext): Promise<ExecutionContext> {
let enrichedSteps = context.steps;
let changed = false;

const enrich = async (sId: string, stepData: StepExecutionData) => {
const fullData = await this.fetchStepDataIfNeeded(stepData, sId);
if (fullData && fullData !== stepData) {
if (!changed) {
enrichedSteps = { ...context.steps };
changed = true;
}
enrichedSteps[sId] = fullData;
}
};

for (const [sId, stepData] of Object.entries(context.steps)) {
const needsInput =
stepData.state && typeof stepData.state.index === 'number' && stepData.input === undefined;
const needsOutput = stepData.output === undefined;

if (needsInput || needsOutput) {
await enrich(sId, stepData);
}
}

return changed ? { ...context, steps: enrichedSteps } : context;
}

/**
* Handle hover for template expressions {{ }}
*/
Expand Down Expand Up @@ -447,6 +481,12 @@ export class UnifiedHoverProvider implements monaco.languages.HoverProvider {
}
}

// For foreach.* paths, ensure the foreach step's input is loaded
// so findForeachContext can re-evaluate the foreach expression.
if (templateInfo.pathSegments[0] === 'foreach') {
evalContext = await this.enrichForeachStepInput(evalContext);
}

// Determine what to evaluate
let value: JsonValue | undefined;
let evaluatedPath: string;
Expand Down
Loading
Loading