Overview
What this template helps teams build
Overview
The Approval Decision Panel template is a reusable custom code control concept for Yeeflow builders. It helps teams add a focused interface pattern for approval review and decision workflows while keeping the asset in review before public launch.
Key capabilities
- Supports approval & review scenarios
- Designed for use in Approval form, Workflow task page
- Includes TSX source, user guidance, and example configuration
- Prepared as a preview asset for technical review and future public documentation
Recommended use cases
- approval review and decision workflows
- Approval & Review template library planning
- Internal builder education and implementation review
Governance notes
This record is created in review status for editorial and product validation. It should not be positioned as launchable until implementation, security, runtime, and documentation review are complete.
Developer reference
Build patterns behind this template
Use this template as a reference while reviewing the Custom Code developer guide. Learn how the required export structure, input parameters, and rendering patterns fit together.
Source preview
Code preview
import React from 'react';
type DecisionOption = {
code: string;
label: string;
tone: string;
helper: string;
requireComment: boolean;
};
type WriteTargetInfo = {
paramName: string;
raw: any;
candidates: string[];
displayValue: string;
};
export class CodeInApplication implements CodeInComp {
description() {
return 'Approval Decision Panel - reusable Yeeflow approval-form decision block with comments and validation.';
}
requiredFields(params?: CodeInParams) {
const required: string[] = [];
const safeParams: any = params || {};
const targetMap: any = {};
['decisionField', 'commentField'].forEach((key) => {
const candidates = this.collectRequiredFieldCandidates(safeParams[key]).filter((value) => this.isLikelyWritableTargetName(value));
if (candidates.length > 0) {
targetMap[key] = candidates[0];
}
candidates.forEach((value) => {
if (value && required.indexOf(value) === -1) {
required.push(value);
}
});
});
/*
Expression-editor writable targets may evaluate to the target's current value
before render(). requiredFields(params) receives raw configuration early enough
to capture the chosen approval form field or variable key.
*/
(this as any).approvalDecisionTargetMap = targetMap;
return required;
}
inputParameters(): InputParameter[] {
return [
{
id: 'decisionField',
name: 'Decision Field',
type: 'variable',
desc: 'Writable approval form field or variable used to save the selected decision value.',
},
{
id: 'commentField',
name: 'Comment Field',
type: 'variable',
desc: 'Writable approval form field or variable used to save approver comments or reason text.',
},
{
id: 'titleText',
name: 'Title Text',
type: 'string',
desc: 'Panel title shown above the decision controls.',
},
{
id: 'subtitleText',
name: 'Subtitle Text',
type: 'string',
desc: 'Optional helper text shown below the title.',
},
{
id: 'approveLabel',
name: 'Approve Label',
type: 'string',
desc: 'Button label for the approve decision. Default is Approve.',
},
{
id: 'rejectLabel',
name: 'Reject Label',
type: 'string',
desc: 'Button label for the reject decision. Default is Reject.',
},
{
id: 'reviseLabel',
name: 'Request Changes Label',
type: 'string',
desc: 'Button label for the request-changes decision. Default is Request Changes.',
},
{
id: 'defaultDecision',
name: 'Default Decision',
type: 'variable',
desc: 'Optional default decision code, such as approve, reject, or revise. Used only when no saved value exists.',
},
{
id: 'requireCommentOnReject',
name: 'Require Comment On Reject',
type: 'variable',
desc: 'Whether Reject requires a comment before the decision value is saved. Supports true/false or dynamic expression.',
},
{
id: 'requireCommentOnRevise',
name: 'Require Comment On Request Changes',
type: 'variable',
desc: 'Whether Request Changes requires a comment before the decision value is saved. Supports true/false or dynamic expression.',
},
{
id: 'showCommentBox',
name: 'Show Comment Box',
type: 'variable',
desc: 'Whether the comment/reason text box is shown. Supports true/false or dynamic expression.',
},
{
id: 'commentPlaceholder',
name: 'Comment Placeholder',
type: 'string',
desc: 'Placeholder text shown inside the comment box.',
},
{
id: 'panelStyle',
name: 'Panel Style',
type: 'string',
desc: 'Visual style: standard, compact, or bordered. Default is standard.',
},
{
id: 'showStatusSummary',
name: 'Show Status Summary',
type: 'variable',
desc: 'Whether selected decision summary text is shown. Supports true/false or dynamic expression.',
},
{
id: 'readonlyText',
name: 'Readonly Text',
type: 'string',
desc: 'Text shown when the control is readonly and no decision has been saved.',
},
{
id: 'decisionOptionsJson',
name: 'Decision Options JSON',
type: 'variable',
desc: 'Optional JSON array for custom decisions. Each item can include code, label, tone, helper, and requireComment.',
},
{
id: 'validationMessageReject',
name: 'Reject Validation Message',
type: 'string',
desc: 'Optional message shown when Reject requires a comment.',
},
{
id: 'validationMessageRevise',
name: 'Request Changes Validation Message',
type: 'string',
desc: 'Optional message shown when Request Changes requires a comment.',
},
];
}
render(context: CodeInContext, fieldsValues: any, readonly: boolean) {
return (
<ApprovalDecisionPanel
context={context}
fieldsValues={fieldsValues}
readonly={readonly}
params={(context && context.params) || {}}
configuredOutputTargets={(this as any).approvalDecisionTargetMap || {}}
/>
);
}
normalizeExpressionText(value: any) {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
if (Array.isArray(value)) {
return value.map((item) => this.normalizeExpressionText(item)).filter(Boolean).join(',');
}
if (typeof value === 'object') {
return this.normalizeExpressionText(
value.value !== undefined ? value.value :
value.Value !== undefined ? value.Value :
value.key !== undefined ? value.key :
value.Key !== undefined ? value.Key :
value.id !== undefined ? value.id :
value.Id !== undefined ? value.Id :
value.fieldId !== undefined ? value.fieldId :
value.FieldId !== undefined ? value.FieldId :
value.name !== undefined ? value.name :
value.Name !== undefined ? value.Name :
value.label !== undefined ? value.label :
value.Label !== undefined ? value.Label :
value.title !== undefined ? value.title :
value.Title !== undefined ? value.Title : ''
);
}
return String(value).trim();
}
collectRequiredFieldCandidates(value: any) {
const candidates: string[] = [];
const seen: any = {};
const add = (candidate: any) => {
const cleaned = this.normalizeExpressionText(candidate);
if (cleaned && !seen[cleaned]) {
seen[cleaned] = true;
candidates.push(cleaned);
}
};
const visit = (current: any, depth: number) => {
if (current === undefined || current === null || depth > 3) {
return;
}
if (typeof current === 'string' || typeof current === 'number' || typeof current === 'boolean') {
add(current);
return;
}
if (Array.isArray(current)) {
current.forEach((item) => visit(item, depth + 1));
return;
}
if (typeof current !== 'object') {
return;
}
[
'fieldId', 'FieldId', 'fieldName', 'FieldName',
'variableId', 'VariableId', 'variableName', 'VariableName',
'tempVariableId', 'TempVariableId', 'tempVariableName', 'TempVariableName',
'tempVarId', 'TempVarId', 'id', 'Id', 'key', 'Key',
'name', 'Name', 'code', 'Code', 'label', 'Label', 'title', 'Title',
'path', 'Path', 'value', 'Value',
].forEach((key) => {
if (current[key] !== undefined && current[key] !== null) {
add(current[key]);
}
});
['target', 'Target', 'binding', 'Binding', 'field', 'Field', 'variable', 'Variable', 'tempVariable', 'TempVariable', 'data', 'Data', 'meta', 'Meta', 'metadata', 'Metadata'].forEach((key) => {
if (current[key] !== undefined && current[key] !== null) {
visit(current[key], depth + 1);
}
});
};
visit(value, 0);
return candidates;
}
isLikelyWritableTargetName(candidate: any) {
const value = this.normalizeExpressionText(candidate);
if (!value || value === '[]' || value === '{}') {
return false;
}
if (value.charAt(0) === '[' || value.charAt(0) === '{') {
return false;
}
if (value.indexOf('":') !== -1 || value.indexOf('","') !== -1) {
return false;
}
return value.length <= 160;
}
}
class ApprovalDecisionPanel extends React.Component<any, any> {
constructor(props: any) {
super(props);
const config = this.getConfigFromProps(props);
const savedDecision = this.readCurrentTargetValue(config.decisionTarget);
const savedComment = this.readCurrentTargetValue(config.commentTarget);
const initialDecision = this.normalizeDecisionCode(savedDecision) || this.normalizeDecisionCode(config.defaultDecision);
const initialComment = this.toCleanString(savedComment);
this.state = {
decision: initialDecision,
comment: initialComment,
validationMessage: this.getValidationMessage(initialDecision, initialComment, config),
};
}
componentDidUpdate(previousProps: any) {
if (this.getConfigKey(previousProps) !== this.getConfigKey(this.props)) {
const config = this.getConfig();
const savedDecision = this.readCurrentTargetValue(config.decisionTarget);
const savedComment = this.readCurrentTargetValue(config.commentTarget);
const nextDecision = this.normalizeDecisionCode(savedDecision) || this.normalizeDecisionCode(config.defaultDecision);
const nextComment = this.toCleanString(savedComment);
this.setState({
decision: nextDecision,
comment: nextComment,
validationMessage: this.getValidationMessage(nextDecision, nextComment, config),
});
}
}
getConfig() {
return this.getConfigFromProps(this.props);
}
getConfigFromProps(props: any) {
const params = (props && props.params) || {};
const decisionTarget = this.resolveWriteTargetFromProps(props, 'decisionField', params.decisionField);
const commentTarget = this.resolveWriteTargetFromProps(props, 'commentField', params.commentField);
const requireReject = this.toBoolean(params.requireCommentOnReject, true);
const requireRevise = this.toBoolean(params.requireCommentOnRevise, true);
return {
decisionTarget: decisionTarget,
commentTarget: commentTarget,
titleText: this.toCleanString(params.titleText) || 'Approval Decision',
subtitleText: this.toCleanString(params.subtitleText) || 'Select your decision and add a comment when needed.',
approveLabel: this.toCleanString(params.approveLabel) || 'Approve',
rejectLabel: this.toCleanString(params.rejectLabel) || 'Reject',
reviseLabel: this.toCleanString(params.reviseLabel) || 'Request Changes',
defaultDecision: this.toCleanString(params.defaultDecision),
requireCommentOnReject: requireReject,
requireCommentOnRevise: requireRevise,
showCommentBox: this.toBoolean(params.showCommentBox, true),
commentPlaceholder: this.toCleanString(params.commentPlaceholder) || 'Add a comment or reason...',
panelStyle: this.normalizePanelStyle(params.panelStyle),
showStatusSummary: this.toBoolean(params.showStatusSummary, true),
readonlyText: this.toCleanString(params.readonlyText) || 'No approval decision has been captured yet.',
validationMessageReject: this.toCleanString(params.validationMessageReject) || 'Please add a comment before rejecting.',
validationMessageRevise: this.toCleanString(params.validationMessageRevise) || 'Please add a comment before requesting changes.',
options: this.buildDecisionOptions(params, requireReject, requireRevise),
};
}
getConfigKey(props: any) {
const params = (props && props.params) || {};
const configuredTargets = (props && props.configuredOutputTargets) || {};
try {
return JSON.stringify({
decisionField: this.toCleanString(configuredTargets.decisionField || params.decisionField),
commentField: this.toCleanString(configuredTargets.commentField || params.commentField),
defaultDecision: this.toCleanString(params.defaultDecision),
decisionOptionsJson: this.toCleanString(this.resolveParameterValue(params.decisionOptionsJson)),
});
} catch (error) {
return String(new Date().getTime());
}
}
buildDecisionOptions(params: any, requireReject: boolean, requireRevise: boolean): DecisionOption[] {
const configured = this.parseDecisionOptions(params.decisionOptionsJson);
if (configured.length > 0) {
return configured;
}
return [
{
code: 'approve',
label: this.toCleanString(params.approveLabel) || 'Approve',
tone: 'approve',
helper: 'Confirm this request can continue.',
requireComment: false,
},
{
code: 'reject',
label: this.toCleanString(params.rejectLabel) || 'Reject',
tone: 'reject',
helper: 'Decline this request with a reason.',
requireComment: requireReject,
},
{
code: 'revise',
label: this.toCleanString(params.reviseLabel) || 'Request Changes',
tone: 'revise',
helper: 'Send back for correction or more information.',
requireComment: requireRevise,
},
];
}
parseDecisionOptions(rawValue: any): DecisionOption[] {
const resolved = this.resolveParameterValue(rawValue);
const parsed = this.safeParseJson(resolved);
const source = Array.isArray(parsed) ? parsed : Array.isArray(resolved) ? resolved : [];
const result: DecisionOption[] = [];
source.forEach((item: any, index: number) => {
if (!item) {
return;
}
if (typeof item === 'string') {
const code = this.normalizeDecisionCode(item);
if (code) {
result.push({
code: code,
label: this.toTitleLabel(code),
tone: this.normalizeTone(code),
helper: '',
requireComment: false,
});
}
return;
}
if (typeof item === 'object') {
const code = this.normalizeDecisionCode(this.firstDefined([item.code, item.value, item.key, item.id, item.name]));
const label = this.toCleanString(this.firstDefined([item.label, item.title, item.text, item.name])) || this.toTitleLabel(code);
if (!code || !label) {
return;
}
result.push({
code: code,
label: label,
tone: this.normalizeTone(this.toCleanString(this.firstDefined([item.tone, item.color, item.type])) || code),
helper: this.toCleanString(this.firstDefined([item.helper, item.description, item.subtitle])),
requireComment: this.toBoolean(this.firstDefined([item.requireComment, item.requiresComment, item.commentRequired]), false),
});
}
});
return this.dedupeOptions(result);
}
dedupeOptions(options: DecisionOption[]) {
const seen: any = {};
const result: DecisionOption[] = [];
options.forEach((option) => {
const code = this.normalizeDecisionCode(option && option.code);
if (!code || seen[code]) {
return;
}
seen[code] = true;
result.push({
code: code,
label: this.toCleanString(option.label) || this.toTitleLabel(code),
tone: this.normalizeTone(option.tone || code),
helper: this.toCleanString(option.helper),
requireComment: option.requireComment === true,
});
});
return result;
}
handleDecisionClick(code: string) {
if (this.props.readonly) {
return;
}
const config = this.getConfig();
const nextDecision = this.normalizeDecisionCode(code);
const nextComment = this.state.comment || '';
const validationMessage = this.getValidationMessage(nextDecision, nextComment, config);
this.setState({
decision: nextDecision,
validationMessage: validationMessage,
});
this.persistOutputs(nextDecision, nextComment, config, validationMessage);
}
handleCommentChange(event: any) {
if (this.props.readonly) {
return;
}
const config = this.getConfig();
const nextComment = event && event.target ? event.target.value : '';
const validationMessage = this.getValidationMessage(this.state.decision, nextComment, config);
this.setState({
comment: nextComment,
validationMessage: validationMessage,
});
this.persistOutputs(this.state.decision, nextComment, config, validationMessage);
}
persistOutputs(decision: string, comment: string, config: any, validationMessage: string) {
if (config.commentTarget.candidates.length > 0) {
this.writeTargetValue(config.commentTarget, comment || '');
}
if (decision && !validationMessage && config.decisionTarget.candidates.length > 0) {
this.writeTargetValue(config.decisionTarget, decision);
}
if (decision && validationMessage && config.decisionTarget.candidates.length > 0) {
this.writeTargetValue(config.decisionTarget, '');
}
if (!decision && config.decisionTarget.candidates.length > 0) {
this.writeTargetValue(config.decisionTarget, '');
}
}
getValidationMessage(decision: string, comment: string, config: any) {
const option = this.findOption(decision, config.options);
if (!option) {
return '';
}
const needsComment = option.requireComment === true ||
(option.code === 'reject' && config.requireCommentOnReject) ||
(option.code === 'revise' && config.requireCommentOnRevise);
if (needsComment && !this.toCleanString(comment)) {
if (option.code === 'reject') {
return config.validationMessageReject;
}
if (option.code === 'revise') {
return config.validationMessageRevise;
}
return 'Please add a comment before using this decision.';
}
return '';
}
findOption(code: string, options: DecisionOption[]) {
const normalized = this.normalizeDecisionCode(code);
for (let index = 0; index < (options || []).length; index += 1) {
if (options[index].code === normalized) {
return options[index];
}
}
return null;
}
readCurrentTargetValue(target: WriteTargetInfo) {
const candidates = target && target.candidates ? target.candidates : [];
for (let index = 0; index < candidates.length; index += 1) {
const value = this.readCurrentValue(candidates[index]);
if (value !== undefined && value !== null && this.toCleanString(value) !== '') {
return value;
}
}
return '';
}
readCurrentValue(fieldName: string): any {
const context = this.props.context || {};
const fieldsValues = this.props.fieldsValues || {};
if (!fieldName) {
return undefined;
}
if (fieldsValues && fieldsValues[fieldName] !== undefined) {
return fieldsValues[fieldName];
}
if (context && typeof context.getFieldValue === 'function') {
try {
return context.getFieldValue(fieldName);
} catch (error) {
return undefined;
}
}
return undefined;
}
writeTargetValue(target: WriteTargetInfo, value: any) {
const candidates = target && target.candidates ? target.candidates : [];
for (let index = 0; index < candidates.length; index += 1) {
if (this.writeFieldValue(candidates[index], value)) {
return true;
}
}
return false;
}
writeFieldValue(fieldName: string, value: any) {
const context = this.props.context || {};
const fieldsValues = this.props.fieldsValues || {};
if (!fieldName) {
return false;
}
const writerNames = [
'setFieldValue',
'setFormFieldValue',
'setFieldData',
'setValue',
'setVariableValue',
'setVariable',
'setTempVariableValue',
'setTempVariable',
'setTemporaryVariableValue',
'setTemporaryVariable',
'updateFieldValue',
'updateVariableValue',
];
const setterHosts = this.getSetterHosts(context);
for (let hostIndex = 0; hostIndex < setterHosts.length; hostIndex += 1) {
const host = setterHosts[hostIndex].host;
for (let writerIndex = 0; writerIndex < writerNames.length; writerIndex += 1) {
const writer = host && host[writerNames[writerIndex]];
if (typeof writer === 'function') {
try {
const result = writer.call(host, fieldName, value);
if (result !== false) {
return true;
}
} catch (error) {
/* Continue trying other setter paths. */
}
}
}
}
const objectSetterNames = ['setFieldsValue', 'setFieldValues', 'setValues', 'setVariables', 'setTempVariables'];
for (let hostIndex = 0; hostIndex < setterHosts.length; hostIndex += 1) {
const host = setterHosts[hostIndex].host;
for (let writerIndex = 0; writerIndex < objectSetterNames.length; writerIndex += 1) {
const writer = host && host[objectSetterNames[writerIndex]];
if (typeof writer === 'function') {
try {
const payload: any = {};
payload[fieldName] = value;
const result = writer.call(host, payload);
if (result !== false) {
return true;
}
} catch (error) {
/* Continue trying other setter paths. */
}
}
}
}
fieldsValues[fieldName] = value;
return true;
}
getSetterHosts(context: any) {
const hosts: any[] = [];
const addHost = (name: string, host: any) => {
if (host && hosts.filter((item) => item.host === host).length === 0) {
hosts.push({ name: name, host: host });
}
};
addHost('context', context);
addHost('context.formContext', context && context.formContext);
addHost('context.pageContext', context && context.pageContext);
addHost('context.variableContext', context && context.variableContext);
addHost('context.variables', context && context.variables);
addHost('context.tempVariables', context && context.tempVariables);
addHost('context.runtimeContext', context && context.runtimeContext);
addHost('context.dataContext', context && context.dataContext);
return hosts;
}
resolveWriteTargetFromProps(props: any, paramName: string, rawValue: any): WriteTargetInfo {
const configuredTargets = (props && props.configuredOutputTargets) || {};
const requiredFieldFallbackTarget = this.getRequiredFieldFallbackTarget(props, paramName);
const candidates = this.mergeTargetCandidates(
this.collectWritableTargetCandidates(configuredTargets[paramName]),
this.mergeTargetCandidates(
this.collectWritableTargetCandidates(rawValue),
this.collectWritableTargetCandidates(requiredFieldFallbackTarget)
)
).filter((candidate) => this.isLikelyWritableTargetName(candidate));
return {
paramName: paramName,
raw: rawValue,
candidates: candidates,
displayValue: candidates.length > 0 ? candidates[0] : this.toCleanString(rawValue),
};
}
getRequiredFieldFallbackTarget(props: any, paramName: string) {
const fieldsValues = (props && props.fieldsValues) || {};
const keys = Object.keys(fieldsValues).filter((key) => this.isLikelyWritableTargetName(key));
const indexMap: any = {
decisionField: 0,
commentField: 1,
};
const index = indexMap[paramName];
return index !== undefined && keys[index] ? keys[index] : '';
}
collectWritableTargetCandidates(rawValue: any) {
const candidates: string[] = [];
const seen: any = {};
const addCandidate = (value: any) => {
const cleaned = this.toCleanString(value);
if (!cleaned || seen[cleaned]) {
return;
}
seen[cleaned] = true;
candidates.push(cleaned);
};
const visit = (value: any, depth: number) => {
if (value === undefined || value === null || depth > 4) {
return;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
addCandidate(value);
this.extractExpressionTokens(value).forEach(addCandidate);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => visit(item, depth + 1));
return;
}
if (typeof value !== 'object') {
return;
}
[
'fieldId', 'FieldId', 'fieldName', 'FieldName',
'variableId', 'VariableId', 'variableName', 'VariableName',
'tempVariableId', 'TempVariableId', 'tempVariableName', 'TempVariableName',
'tempVarId', 'TempVarId', 'id', 'Id', 'key', 'Key',
'name', 'Name', 'code', 'Code', 'label', 'Label', 'title', 'Title',
'path', 'Path', 'value', 'Value',
].forEach((key) => {
if (value[key] !== undefined && value[key] !== null) {
addCandidate(value[key]);
this.extractExpressionTokens(value[key]).forEach(addCandidate);
}
});
['target', 'Target', 'binding', 'Binding', 'field', 'Field', 'variable', 'Variable', 'tempVariable', 'TempVariable', 'expression', 'Expression', 'data', 'Data', 'meta', 'Meta', 'metadata', 'Metadata', 'props', 'Props'].forEach((key) => {
if (value[key] !== undefined && value[key] !== null) {
visit(value[key], depth + 1);
}
});
};
visit(rawValue, 0);
return candidates;
}
mergeTargetCandidates(primary: string[], secondary: string[]) {
const candidates: string[] = [];
const seen: any = {};
(primary || []).concat(secondary || []).forEach((candidate) => {
const cleaned = this.toCleanString(candidate);
if (cleaned && !seen[cleaned]) {
seen[cleaned] = true;
candidates.push(cleaned);
}
});
return candidates;
}
extractExpressionTokens(value: any) {
const text = this.toCleanString(value);
if (!text) {
return [];
}
const tokens: string[] = [];
const patterns = [
/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g,
/@\{\s*([A-Za-z0-9_.-]+)\s*\}/g,
/\$\{\s*([A-Za-z0-9_.-]+)\s*\}/g,
/\b(?:TempVariables|tempVariables|Variables|variables|Fields|fields)\.([A-Za-z0-9_.-]+)\b/g,
];
patterns.forEach((pattern) => {
let match = pattern.exec(text);
while (match) {
if (match[1]) {
tokens.push(match[1]);
const parts = match[1].split('.');
if (parts.length > 1) {
tokens.push(parts[parts.length - 1]);
}
}
match = pattern.exec(text);
}
});
return tokens;
}
isLikelyWritableTargetName(candidate: any) {
const value = this.toCleanString(candidate);
if (!value || value === '[]' || value === '{}') {
return false;
}
if (value.charAt(0) === '[' || value.charAt(0) === '{') {
return false;
}
if (value.indexOf('":') !== -1 || value.indexOf('","') !== -1) {
return false;
}
return value.length <= 160;
}
resolveParameterValue(value: any): any {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || Array.isArray(value)) {
return value;
}
if (typeof value === 'object') {
const preferred = this.firstDefined([
value.value,
value.Value,
value.data,
value.Data,
value.result,
value.Result,
value.key,
value.Key,
value.id,
value.Id,
value.fieldId,
value.FieldId,
value.fieldName,
value.FieldName,
value.label,
value.Label,
value.name,
value.Name,
]);
if (preferred !== undefined) {
return this.resolveParameterValue(preferred);
}
}
return value;
}
safeParseJson(value: any): any {
if (Array.isArray(value) || (value && typeof value === 'object')) {
return value;
}
const text = this.toCleanString(value);
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}
toCleanString(value: any): string {
const resolved = this.resolveParameterValue(value);
if (resolved === undefined || resolved === null) {
return '';
}
if (typeof resolved === 'string' || typeof resolved === 'number' || typeof resolved === 'boolean') {
return String(resolved).trim();
}
if (Array.isArray(resolved)) {
return resolved.map((item) => this.toCleanString(item)).filter(Boolean).join(', ');
}
if (typeof resolved === 'object') {
const preferred = this.firstDefined([
resolved.value,
resolved.Value,
resolved.text,
resolved.Text,
resolved.display,
resolved.Display,
resolved.label,
resolved.Label,
resolved.name,
resolved.Name,
resolved.title,
resolved.Title,
resolved.key,
resolved.Key,
]);
if (preferred !== undefined) {
return this.toCleanString(preferred);
}
try {
return JSON.stringify(resolved);
} catch (error) {
return '';
}
}
return '';
}
toBoolean(value: any, fallback: boolean) {
if (value === true || value === false) {
return value;
}
if (Array.isArray(value) && value.length > 0) {
return this.toBoolean(value[0], fallback);
}
if (value && typeof value === 'object') {
const objectValue = this.firstDefined([
value.value,
value.Value,
value.key,
value.Key,
value.checked,
value.Checked,
value.selected,
value.Selected,
value.label,
value.Label,
]);
if (objectValue !== undefined) {
return this.toBoolean(objectValue, fallback);
}
}
const cleaned = this.toCleanString(value).toLowerCase();
if (['true', '1', 'yes', 'y', 'on'].indexOf(cleaned) !== -1) {
return true;
}
if (['false', '0', 'no', 'n', 'off'].indexOf(cleaned) !== -1) {
return false;
}
return fallback;
}
normalizeDecisionCode(value: any) {
const text = this.toCleanString(value).toLowerCase();
if (!text) {
return '';
}
if (text === 'request changes' || text === 'requestchanges' || text === 'request_change' || text === 'request-change' || text === 'revise' || text === 'revision') {
return 'revise';
}
if (text === 'approved') {
return 'approve';
}
if (text === 'rejected') {
return 'reject';
}
return text.replace(/\s+/g, '-');
}
normalizeTone(value: any) {
const text = this.toCleanString(value).toLowerCase();
if (text === 'approve' || text === 'approved' || text === 'success' || text === 'green') {
return 'approve';
}
if (text === 'reject' || text === 'rejected' || text === 'danger' || text === 'red') {
return 'reject';
}
if (text === 'revise' || text === 'request-changes' || text === 'warning' || text === 'amber') {
return 'revise';
}
if (text === 'escalate' || text === 'violet' || text === 'purple') {
return 'escalate';
}
if (text === 'hold' || text === 'neutral' || text === 'gray' || text === 'grey') {
return 'hold';
}
return 'neutral';
}
normalizePanelStyle(value: any) {
const text = this.toCleanString(value).toLowerCase();
if (text === 'compact' || text === 'bordered') {
return text;
}
return 'standard';
}
toTitleLabel(value: string) {
const text = this.toCleanString(value).replace(/[-_]+/g, ' ');
return text.replace(/\w\S*/g, (word) => word.charAt(0).toUpperCase() + word.substr(1).toLowerCase());
}
firstDefined(values: any[]) {
for (let index = 0; index < values.length; index += 1) {
if (values[index] !== undefined && values[index] !== null) {
return values[index];
}
}
return undefined;
}
render() {
const config = this.getConfig();
const selectedOption = this.findOption(this.state.decision, config.options);
const readonly = this.props.readonly === true;
const panelClass = 'adp-panel adp-panel-' + config.panelStyle + (readonly ? ' adp-readonly' : '');
return (
<div className={panelClass}>
<style>{this.getStyles()}</style>
<div className="adp-header">
<div>
<div className="adp-title">{config.titleText}</div>
{config.subtitleText ? <div className="adp-subtitle">{config.subtitleText}</div> : null}
</div>
{readonly ? <span className="adp-mode-badge">Readonly</span> : null}
</div>
{readonly ? (
<div className="adp-readonly-box">
{selectedOption ? (
<div className={'adp-readonly-decision adp-tone-' + selectedOption.tone}>
<span className="adp-readonly-dot" />
<span>{selectedOption.label}</span>
</div>
) : (
<div className="adp-readonly-empty">{config.readonlyText}</div>
)}
{this.state.comment ? <div className="adp-readonly-comment">{this.state.comment}</div> : null}
</div>
) : (
<div>
<div className="adp-options" role="group" aria-label="Approval decision">
{config.options.map((option: DecisionOption) => this.renderDecisionButton(option, selectedOption))}
</div>
{config.showCommentBox ? (
<div className="adp-comment-wrap">
<textarea
className="adp-comment"
value={this.state.comment || ''}
placeholder={config.commentPlaceholder}
onChange={(event) => this.handleCommentChange(event)}
/>
</div>
) : null}
{this.state.validationMessage ? (
<div className="adp-validation">{this.state.validationMessage}</div>
) : null}
{config.showStatusSummary ? this.renderStatusSummary(selectedOption, config) : null}
</div>
)}
</div>
);
}
renderDecisionButton(option: DecisionOption, selectedOption: DecisionOption | null) {
const isSelected = Boolean(selectedOption && selectedOption.code === option.code);
const className = 'adp-option adp-tone-' + option.tone + (isSelected ? ' adp-option-selected' : '');
return (
<button
type="button"
className={className}
key={option.code}
onClick={() => this.handleDecisionClick(option.code)}
>
<span className="adp-option-main">
<span className="adp-option-mark" />
<span className="adp-option-label">{option.label}</span>
</span>
{option.helper ? <span className="adp-option-helper">{option.helper}</span> : null}
</button>
);
}
renderStatusSummary(selectedOption: DecisionOption | null, config: any) {
if (!selectedOption) {
return <div className="adp-summary">No decision selected yet.</div>;
}
const commentRequired = this.getValidationMessage(selectedOption.code, '', config) !== '';
return (
<div className="adp-summary">
Selected: <strong>{selectedOption.label}</strong>
{commentRequired ? <span> - comment required</span> : null}
</div>
);
}
getStyles() {
return [
'.adp-panel{box-sizing:border-box;width:100%;min-width:0;background:#fff;border:1px solid #e5eaf3;border-radius:8px;padding:18px;color:#111827;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 1px 2px rgba(16,24,40,.04);}',
'.adp-panel *{box-sizing:border-box;}',
'.adp-panel-compact{padding:14px;}',
'.adp-panel-bordered{border-color:#cbd7ea;box-shadow:none;}',
'.adp-readonly{background:#f8fafc;}',
'.adp-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:14px;}',
'.adp-title{font-size:16px;line-height:22px;font-weight:700;color:#101828;}',
'.adp-subtitle{margin-top:3px;font-size:12px;line-height:18px;color:#667085;}',
'.adp-mode-badge{flex:0 0 auto;font-size:12px;line-height:18px;color:#667085;background:#eef2f7;border:1px solid #d8deea;border-radius:999px;padding:3px 9px;}',
'.adp-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;}',
'.adp-option{appearance:none;border:1px solid #d8deea;background:#fff;border-radius:8px;padding:12px 12px;text-align:left;cursor:pointer;color:#344054;min-height:72px;transition:border-color .15s ease,background-color .15s ease,box-shadow .15s ease;}',
'.adp-option:hover{border-color:#b8c7dc;background:#fbfcfe;}',
'.adp-option-selected{box-shadow:0 0 0 3px rgba(20,111,246,.12);}',
'.adp-option-main{display:flex;align-items:center;gap:8px;}',
'.adp-option-mark{width:14px;height:14px;border-radius:999px;border:2px solid #b8c7dc;background:#fff;flex:0 0 auto;}',
'.adp-option-selected .adp-option-mark{border-width:4px;}',
'.adp-option-label{font-size:13px;line-height:18px;font-weight:700;color:#101828;}',
'.adp-option-helper{display:block;margin-top:6px;font-size:12px;line-height:17px;color:#667085;}',
'.adp-tone-approve.adp-option-selected{border-color:#12b76a;background:#f2fbf6;}',
'.adp-tone-approve.adp-option-selected .adp-option-mark{border-color:#12b76a;}',
'.adp-tone-reject.adp-option-selected{border-color:#e45665;background:#fff7f7;}',
'.adp-tone-reject.adp-option-selected .adp-option-mark{border-color:#e45665;}',
'.adp-tone-revise.adp-option-selected{border-color:#f5a623;background:#fffbf2;}',
'.adp-tone-revise.adp-option-selected .adp-option-mark{border-color:#f5a623;}',
'.adp-tone-escalate.adp-option-selected{border-color:#7a5af8;background:#f7f5ff;}',
'.adp-tone-escalate.adp-option-selected .adp-option-mark{border-color:#7a5af8;}',
'.adp-tone-hold.adp-option-selected,.adp-tone-neutral.adp-option-selected{border-color:#667085;background:#f8fafc;}',
'.adp-tone-hold.adp-option-selected .adp-option-mark,.adp-tone-neutral.adp-option-selected .adp-option-mark{border-color:#667085;}',
'.adp-comment-wrap{margin-top:12px;}',
'.adp-comment{display:block;width:100%;min-height:92px;resize:vertical;border:1px solid #d8deea;border-radius:8px;padding:10px 12px;color:#101828;background:#fff;font-size:13px;line-height:20px;font-family:inherit;outline:none;}',
'.adp-comment:focus{border-color:#146ff6;box-shadow:0 0 0 3px rgba(20,111,246,.12);}',
'.adp-validation{margin-top:10px;border:1px solid #fecdca;background:#fff7f6;color:#b42318;border-radius:8px;padding:9px 10px;font-size:12px;line-height:18px;}',
'.adp-summary{margin-top:10px;font-size:12px;line-height:18px;color:#667085;background:#f8fafc;border:1px solid #e5eaf3;border-radius:8px;padding:9px 10px;}',
'.adp-summary strong{color:#101828;}',
'.adp-readonly-box{border:1px solid #e5eaf3;border-radius:8px;background:#fff;padding:12px;}',
'.adp-readonly-decision{display:inline-flex;align-items:center;gap:8px;border-radius:999px;padding:5px 10px;font-size:13px;line-height:18px;font-weight:700;background:#f8fafc;color:#344054;}',
'.adp-readonly-dot{width:8px;height:8px;border-radius:999px;background:#667085;}',
'.adp-readonly-decision.adp-tone-approve{background:#f2fbf6;color:#067647;}',
'.adp-readonly-decision.adp-tone-approve .adp-readonly-dot{background:#12b76a;}',
'.adp-readonly-decision.adp-tone-reject{background:#fff7f7;color:#b42318;}',
'.adp-readonly-decision.adp-tone-reject .adp-readonly-dot{background:#e45665;}',
'.adp-readonly-decision.adp-tone-revise{background:#fffbf2;color:#b54708;}',
'.adp-readonly-decision.adp-tone-revise .adp-readonly-dot{background:#f5a623;}',
'.adp-readonly-empty{font-size:13px;line-height:20px;color:#667085;}',
'.adp-readonly-comment{margin-top:10px;padding-top:10px;border-top:1px solid #eef2f7;font-size:13px;line-height:20px;color:#344054;white-space:pre-wrap;}',
'@media(max-width:640px){.adp-panel{padding:16px;}.adp-header{flex-direction:column;align-items:flex-start;}.adp-options{grid-template-columns:1fr;}.adp-option{min-height:auto;}}',
].join('');
}
}Implementation notes
User guide
# Approval Decision Panel ## Short Description Approval Decision Panel is a reusable Yeeflow approval-form custom code template that lets approvers choose a decision, enter comments, validate required reasons, and save the result into configured form fields or variables. ## Purpose / What This Is For Use this control when an approval form needs a clear, consistent decision block instead of scattered fields or generic radio buttons. It helps customers standardize approval experiences for: - approve / reject / request changes - risk review approvals - contract approval - vendor approval - purchase approval - master data change approval - exception approval - policy and compliance review The template captures the approver's selected decision and optional or required comments, then writes those values back to configured Yeeflow fields or variables. ## Supported Placement This template is optimized for: - Approval form It can write to approval form fields and compatible variables when the Yeeflow runtime exposes writable setter methods. ## When To Use This Template Use Approval Decision Panel when: - Approvers need a clean, guided decision area. - Rejection or revision decisions should require a reason. - The same decision pattern should be reused across multiple approval workflows. - Delivery teams want configurable labels, comments, and optional custom decision options. - Readonly viewers should see the captured decision clearly. ## Recommended Defaults | Parameter | Recommended Value | | --- | --- | | `titleText` | `Approval Decision` | | `subtitleText` | `Select your decision and add a comment when needed.` | | `approveLabel` | `Approve` | | `rejectLabel` | `Reject` | | `reviseLabel` | `Request Changes` | | `requireCommentOnReject` | `true` | | `requireCommentOnRevise` | `true` | | `showCommentBox` | `true` | | `panelStyle` | `standard` | | `showStatusSummary` | `true` | ## Input Parameters Overview | Parameter | Type | Required | Purpose | Example Value | | --- | --- | --- | --- | --- | | `decisionField` | Writable target / expression editor | Yes | Field or variable used to save selected decision code. | `ApprovalDecision` | | `commentField` | Writable target / expression editor | Recommended | Field or variable used to save approver comment/reason. | `ApprovalComment` | | `titleText` | Plain text | No | Title shown above the panel. | `Approval Decision` | | `subtitleText` | Plain text | No | Supporting text below the title. | `Review the request and choose an action.` | | `approveLabel` | Plain text | No | Label for the approve button. | `Approve` | | `rejectLabel` | Plain text | No | Label for the reject button. | `Reject` | | `reviseLabel` | Plain text | No | Label for the request-changes button. | `Request Changes` | | `defaultDecision` | Expression editor | No | Optional decision selected when no saved value exists. | `approve` | | `requireCommentOnReject` | Expression editor boolean | No | Requires comment before Reject is saved. | `true` | | `requireCommentOnRevise` | Expression editor boolean | No | Requires comment before Request Changes is saved. | `true` | | `showCommentBox` | Expression editor boolean | No | Shows or hides the comment box. | `true` | | `commentPlaceholder` | Plain text | No | Placeholder text in comment box. | `Add your reason or comment...` | | `panelStyle` | Plain text | No | Visual style: `standard`, `compact`, or `bordered`. | `standard` | | `showStatusSummary` | Expression editor boolean | No | Shows selected decision summary. | `true` | | `readonlyText` | Plain text | No | Text shown in readonly mode when no decision exists. | `No decision captured yet.` | | `decisionOptionsJson` | Expression editor | No | Optional JSON array for custom decisions. | See example config | | `validationMessageReject` | Plain text | No | Custom Reject validation message. | `Please explain why this is rejected.` | | `validationMessageRevise` | Plain text | No | Custom Request Changes validation message. | `Please describe the required changes.` | ## Detailed Parameter Explanation ### `decisionField` The writable form field or variable that stores the selected decision code. Standard saved values are: - `approve` - `reject` - `revise` This parameter uses the expression editor because delivery teams may select a form field, variable, or dynamic binding from the selector. The code resolves the actual writable target defensively before saving. ### `commentField` The writable field or variable that stores the approver comment or reason text. This is strongly recommended when rejection or revision decisions require comments. ### `titleText` Title displayed above the decision controls. ### `subtitleText` Optional helper text under the title. Use it to guide approvers without adding too much instruction. ### `approveLabel`, `rejectLabel`, `reviseLabel` Button labels for the three standard decisions. Changing labels does not change the saved decision codes unless you use `decisionOptionsJson`. ### `defaultDecision` Optional default selected decision when the target field has no existing value. Common values: - `approve` - `reject` - `revise` ### `requireCommentOnReject` When true, the Reject decision shows a validation message until a comment is entered. The decision value is only saved after the comment requirement is satisfied. ### `requireCommentOnRevise` When true, the Request Changes decision shows a validation message until a comment is entered. The decision value is only saved after the comment requirement is satisfied. ### `showCommentBox` Controls whether the comment text area is shown. If comments are required for any decision, keep this enabled. ### `commentPlaceholder` Placeholder text displayed inside the comment box. ### `panelStyle` Visual style for the panel. Supported values: - `standard` - `compact` - `bordered` ### `showStatusSummary` Shows a short summary below the controls, such as the selected decision and whether a comment is required. ### `readonlyText` Text shown when the approval form is readonly and no saved decision is available. ### `decisionOptionsJson` Optional JSON array for custom decision options. Each option can include: - `code` - `label` - `tone` - `helper` - `requireComment` Supported tones include: - `approve` - `reject` - `revise` - `escalate` - `hold` - `neutral` ### `validationMessageReject` Optional custom validation message when Reject requires a comment. ### `validationMessageRevise` Optional custom validation message when Request Changes requires a comment. ## Step-By-Step Setup Guide 1. Add the Custom Code control to a Yeeflow approval form. 2. Open the code editor and paste `approval-decision-panel.tsx`. 3. Create or identify a field to store the decision value. 4. Create or identify a field to store the approval comment. 5. Configure `decisionField` by selecting the decision field from the expression/variable selector. 6. Configure `commentField` by selecting the comment field from the expression/variable selector. 7. Set labels and helper text as needed. 8. Keep `requireCommentOnReject` and `requireCommentOnRevise` enabled for governed approvals. 9. Preview the approval form. 10. Select Approve, Reject, and Request Changes and confirm the configured fields update correctly. 11. Test readonly mode by viewing a submitted or completed approval. ## Result / Expected Output When the user selects a decision: - The selected decision is visually highlighted. - The decision code is saved into `decisionField` when valid. - The comment text is saved into `commentField`. - If Reject or Request Changes requires a comment, the decision is not saved until a comment is entered. - Readonly mode displays the saved decision and comment without editable controls. ## Real Business Examples ### Purchase Approval Approvers choose Approve, Reject, or Request Changes. Reject and Request Changes require a reason for audit visibility. ### Vendor Approval Procurement teams use the panel to approve a new vendor, reject it, or request corrected business documents. ### Contract Approval Legal reviewers approve a contract, reject it with a reason, or request changes before final approval. ### Policy Exception Approval Risk teams use stricter validation so rejection and revision always include clear comments. ## Notes / Assumptions / Limitations - The template is optimized for approval forms. - `decisionField` and `commentField` are writable target parameters, not ordinary text settings. - The control writes values through available Yeeflow setter methods such as `setFieldValue`, `setFormFieldValue`, or variable setter paths when exposed. - Validation happens inside the custom control. For full workflow blocking, pair this with Yeeflow field-required or workflow validation rules where appropriate. - Custom decisions can be configured through `decisionOptionsJson`, but the standard three decisions are recommended for first rollout. - The panel does not execute workflow actions directly. It captures decision data for the form/workflow to use. ## Testing Checklist - Select Approve and confirm `decisionField` saves `approve`. - Select Reject with no comment and confirm validation appears. - Add a Reject comment and confirm `decisionField` saves `reject`. - Select Request Changes with no comment and confirm validation appears. - Add a Request Changes comment and confirm `decisionField` saves `revise`. - Confirm `commentField` updates when the comment changes. - Test default decision behavior. - Test readonly mode with existing saved values. - Test missing `decisionField` configuration. - Test custom `decisionOptionsJson` if used. - Test narrow/mobile form layout. ## Troubleshooting ### The decision does not save Confirm `decisionField` is configured as a writable form field or variable. Use the selector rather than display text when possible. ### Comments do not save Confirm `commentField` points to a writable text/multiline text field. ### Reject or Request Changes never saves If comment-required validation is enabled, enter a comment first. The control intentionally waits to save the decision until the required comment exists. ### Existing values do not show in readonly mode Confirm `decisionField` and `commentField` are included in the form and available to the custom code runtime. ### Custom options do not appear Check that `decisionOptionsJson` is valid JSON and is an array. If unsure, remove it and test the standard options first.
Configuration
Example configuration
# Approval Decision Panel Example Config
## Standard Approval Setup
| Parameter | Example Value |
| --- | --- |
| `decisionField` | Select the approval decision field, for example `ApprovalDecision` |
| `commentField` | Select the approval comment field, for example `ApprovalComment` |
| `titleText` | `Approval Decision` |
| `subtitleText` | `Review the request and choose your decision.` |
| `approveLabel` | `Approve` |
| `rejectLabel` | `Reject` |
| `reviseLabel` | `Request Changes` |
| `defaultDecision` | blank |
| `requireCommentOnReject` | `true` |
| `requireCommentOnRevise` | `true` |
| `showCommentBox` | `true` |
| `commentPlaceholder` | `Add your reason or comment...` |
| `panelStyle` | `standard` |
| `showStatusSummary` | `true` |
| `readonlyText` | `No approval decision has been captured yet.` |
## Custom Decision Options Example
Use `decisionOptionsJson` only when the standard Approve, Reject, and Request Changes decisions are not enough.
```json
[
{
"code": "approve",
"label": "Approve",
"tone": "approve",
"helper": "Confirm this request can continue.",
"requireComment": false
},
{
"code": "reject",
"label": "Reject",
"tone": "reject",
"helper": "Decline this request with a reason.",
"requireComment": true
},
{
"code": "revise",
"label": "Request Changes",
"tone": "revise",
"helper": "Send back for correction.",
"requireComment": true
},
{
"code": "escalate",
"label": "Escalate",
"tone": "escalate",
"helper": "Route this request to a higher authority.",
"requireComment": true
},
{
"code": "hold",
"label": "Hold",
"tone": "hold",
"helper": "Pause until more information is available.",
"requireComment": false
}
]
```
## Saved Output
For the standard setup:
| User action | Saved decision value | Comment behavior |
| --- | --- | --- |
| Approve | `approve` | Comment optional |
| Reject | `reject` | Comment required when `requireCommentOnReject = true` |
| Request Changes | `revise` | Comment required when `requireCommentOnRevise = true` |




