Show step preview placeholders as expandable values, not a flat string replace

A node's Condition/prompt preview resolved ${{name}} placeholders with a
single-pass string replace, so a runtime value got duplicated wherever the
same placeholder repeated in the source text (e.g. a Conditional's
${{x}} != null && ${{x}}.contains(...) pattern) and long/verbose values were
dumped inline unbounded. Reuse the existing template-placeholder machinery
(already used for HumanDecisionBlock/HumanInteractionBlock text) instead: a
new "template" field type on the settings dialog renders each placeholder as
its own expandable segment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-12 13:26:58 +02:00
parent 81f2a890f2
commit f41962993c
4 changed files with 53 additions and 36 deletions

View File

@ -1,6 +1,6 @@
import { Injectable, signal } from "@angular/core";
export type NodeSettingFieldType = "text" | "password" | "textarea" | "select" | "checkbox" | "number" | "display";
export type NodeSettingFieldType = "text" | "password" | "textarea" | "select" | "checkbox" | "number" | "display" | "template";
export type NodeSettingOption = {
label: string;
@ -52,6 +52,13 @@ export type NodeSettingField = {
*/
group?: string;
options?: NodeSettingOption[];
/**
* Only for type `template`: the substitution map for `${{name}}` placeholders in the field's
* value. Rendered as per-placeholder expandable segments (see TemplatePlaceholderTextComponent)
* instead of a flat string replace, so a resolved value never gets silently duplicated or turns
* the preview into an unreadable wall of text.
*/
templateValues?: Record<string, unknown>;
};
export type NodeSettingsValues = Record<string, string | boolean | number>;
@ -77,7 +84,7 @@ export function validateFieldValue(
constraints: FieldValueConstraints,
value: string | boolean | number | null | undefined
): string | null {
if (constraints.type === 'checkbox' || constraints.type === 'display') return null;
if (constraints.type === 'checkbox' || constraints.type === 'display' || constraints.type === 'template') return null;
const text = typeof value === 'string' ? value.trim() : value == null ? '' : String(value);
if (text.length === 0) {

View File

@ -85,6 +85,15 @@
<div class="text-sm text-slate-800 whitespace-pre-wrap break-words">{{ draft[field.key] }}</div>
</fieldset>
}
@case ('template') {
<fieldset class="mt-1 border border-slate-200 rounded-md bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold text-slate-600">{{ field.label }}</legend>
<app-template-placeholder-text
[text]="draftText(field.key)"
[values]="field.templateValues ?? {}">
</app-template-placeholder-text>
</fieldset>
}
@case ('textarea') {
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>

View File

@ -14,11 +14,12 @@ import {
NodeSettingsValues,
validateFieldValue
} from '@services/dialogs/node-settings-dialog';
import { TemplatePlaceholderTextComponent } from '@shared/template-placeholder-text/template-placeholder-text';
@Component({
selector: 'app-node-settings-dialog-host',
standalone: true,
imports: [CommonModule, FormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, MatTooltipModule],
imports: [CommonModule, FormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, MatTooltipModule, TemplatePlaceholderTextComponent],
templateUrl: './node-settings-dialog.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
@ -151,6 +152,11 @@ export class NodeSettingsDialogHostComponent {
}
}
draftText(key: string): string {
const value = this.draft[key];
return typeof value === 'string' ? value : String(value ?? '');
}
isPasswordVisible(key: string): boolean {
return this.passwordVisibility[key] === true;
}

View File

@ -648,14 +648,14 @@ export class TaskStepNodeComponent {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
void this.openReadonlyTextDialog(field.label, this.resolvePreviewText(field.value));
void this.openReadonlyTextDialog(field.label, field.value);
}
openMainContentPreview(field: MainContentView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
void this.openReadonlyTextDialog(field.label, this.resolvePreviewText(field.rawValue));
void this.openReadonlyTextDialog(field.label, field.rawValue);
}
private get blockConfiguration(): Record<string, any> | null {
@ -1531,46 +1531,41 @@ export class TaskStepNodeComponent {
return lineCount > 2 || normalized.length > 80;
}
/**
* A value with `${{name}}` placeholders is shown as text plus its own expandable segment per
* placeholder (via the `template` field type / TemplatePlaceholderTextComponent), never as a
* flat string replace - the latter silently duplicates a resolved value wherever the same
* placeholder repeats in the source text (e.g. `${{x}} != null && ${{x}}.contains(...)`), and
* dumps a raw, unbounded runtime value inline.
*/
private async openReadonlyTextDialog(label: string, value: string) {
const source = String(value ?? '');
const hasPlaceholders = source.includes('${{');
await this.settingsDialog.open({
title: label,
previewOnly: true,
fields: [
{
key: 'value',
label,
type: 'textarea',
readonly: true,
rows: 18
}
hasPlaceholders
? {
key: 'value',
label,
type: 'template',
readonly: true,
templateValues: this.templateSubstitutions()
}
: {
key: 'value',
label,
type: 'textarea',
readonly: true,
rows: 18
}
],
initial: {
value
value: source
}
});
}
private resolvePreviewText(value: string): string {
const source = String(value ?? '');
if (!source.includes('${{')) return source;
return source.replace(/\$\{\{\s*([^}]+?)\s*\}\}/g, (token, rawKey: string) => {
const key = String(rawKey ?? '').trim();
if (!key) return token;
const configInputs = this.blockConfiguration?.['__executionInputs'];
const inputs = configInputs && typeof configInputs === 'object' && !Array.isArray(configInputs)
? configInputs as Record<string, unknown>
: null;
if (!inputs || !Object.prototype.hasOwnProperty.call(inputs, key)) {
return token;
}
const resolved = inputs[key];
if (resolved == null) return token;
if (typeof resolved === 'string') return resolved;
return valueToDisplayString(resolved);
});
}
}