Approval Timeline

Visualize approval steps, approver activity, status changes, and review history for workflow records.

Custom CodeUI Design & LayoutsYeeflow
custom codeapproval timelineaudit trailworkflow historytsx

Explore the key screens

Explore the key screens and structure included in this template.

Abstract illustration for Approval Timeline, showing approval history and audit visibility.

What this template helps teams build

Overview

The Approval Timeline template is a reusable custom code control concept for Yeeflow builders. It helps teams add a focused interface pattern for approval history and audit visibility while keeping the asset in review before public launch.

Key capabilities

  • Supports approval & review scenarios
  • Designed for use in Approval form, Record detail, Dashboard
  • Includes TSX source, user guidance, and example configuration
  • Prepared as a preview asset for technical review and future public documentation

Recommended use cases

  • approval history and audit visibility
  • 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.

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.

Code preview

Download source file
import React from 'react';

const TEMPLATE_KIND = 'approval-timeline';
const TEMPLATE_TITLE = 'Approval Timeline';
const TEMPLATE_DESC = 'Approval Timeline - reusable Yeeflow approval form timeline for approval history, actors, timestamps, and comments.';
const WRITABLE_TARGETS = [];

export class CodeInApplication implements CodeInComp {
  description() { return TEMPLATE_DESC; }
  requiredFields(params?: CodeInParams) { return []; }
  inputParameters(): InputParameter[] { return [
      {"id": "sourceListId", "name": "Source List ID", "type": "variable", "desc": "List containing approval history events.", "example": "203..."},
      {"id": "recordIdField", "name": "Record ID Field", "type": "variable", "desc": "Field used to match the current approval record.", "example": "RequestId"},
      {"id": "recordIdValue", "name": "Record ID Value", "type": "variable", "desc": "Current record ID or expression value.", "example": "{{ListDataID}}"},
      {"id": "eventDateField", "name": "Event Date Field", "type": "variable", "desc": "Timestamp field.", "example": "CreatedTime"},
      {"id": "actorField", "name": "Actor Field", "type": "variable", "desc": "Approver/actor field.", "example": "Approver"},
      {"id": "decisionField", "name": "Decision Field", "type": "variable", "desc": "Decision/status field.", "example": "Decision"},
      {"id": "commentField", "name": "Comment Field", "type": "variable", "desc": "Comment field.", "example": "Comment"},
      {"id": "titleText", "name": "Title Text", "type": "string", "desc": "Panel title.", "example": "Approval Timeline"},
      {"id": "subtitleText", "name": "Subtitle Text", "type": "string", "desc": "Panel subtitle.", "example": "Review history"},
      {"id": "maxItems", "name": "Max Items", "type": "string", "desc": "Max events shown.", "example": "8"},
      {"id": "emptyText", "name": "Empty Text", "type": "string", "desc": "Empty state text.", "example": "No history yet"},
      {"id": "filterExpression", "name": "Filter Expression", "type": "variable", "desc": "Optional query filter.", "example": ""}
    ]; }
  render(context: CodeInContext, fieldsValues: any, readonly: boolean) { return <ReusableTemplatePanel context={context} fieldsValues={fieldsValues} readonly={readonly} params={(context && context.params) || {}} configuredOutputTargets={(this as any).configuredTargetMap || {}} />; }
}

class ReusableTemplatePanel extends React.Component<any, any> {
  private mountedFlag: boolean;
  constructor(props: any) {
    super(props);
    this.mountedFlag = false;
    this.state = this.createInitialState(props);
  }
  componentDidMount() { this.mountedFlag = true; if (this.needsQuery()) { this.loadData(); } }
  componentDidUpdate(previousProps: any) { if (this.getConfigKey(previousProps) !== this.getConfigKey(this.props)) { this.setState(this.createInitialState(this.props), () => { if (this.needsQuery()) { this.loadData(); } }); } }
  componentWillUnmount() { this.mountedFlag = false; }
  createInitialState(props: any) { const config=this.getConfigFromProps(props); return { loading:false,errorText:'',rows:[],selected:[],tags:this.readInitialArray(config),checklist:this.readInitialChecklist(config),parentValue:this.toString(this.readTarget(config.parentTarget)),childValue:this.toString(this.readTarget(config.childTarget)),textValue:'',validationMessage:'' }; }
  needsQuery() { return ['approval-timeline','related-record-summary-grid','hierarchical-selector','dependent-selector','activity-timeline','exception-alert-panel'].indexOf(TEMPLATE_KIND)!==-1; }
  getConfig() { return this.getConfigFromProps(this.props); }
  getConfigFromProps(props: any) { const params=(props&&props.params)||{}; return {
    titleText:this.toString(params.titleText)||TEMPLATE_TITLE,
    subtitleText:this.toString(params.subtitleText)||'Approval history and decision events.',
    emptyText:this.toString(params.emptyText)||'No records to display.',
    dataListId:this.toString(params.dataListId||params.sourceListId||params.targetListId),
    recordIdValue:this.toString(params.recordIdValue),
    recordIdField:this.toString(params.recordIdField),
    relationField:this.toString(params.relationField),
    relationValue:this.toString(params.relationValue),
    displayedFields:this.parseStringArray(params.displayedFieldsJson||params.displayedFields),
    eventDateField:this.toString(params.eventDateField||params.dateField||params.sortField),
    actorField:this.toString(params.actorField),
    titleField:this.toString(params.titleField||params.labelField||params.eventTitleField),
    statusField:this.toString(params.statusField||params.decisionField||params.severityField),
    commentFieldName:this.toString(params.commentField||params.descriptionField),
    idField:this.toString(params.idField)||'ListDataID',
    parentField:this.toString(params.parentField),
    childField:this.toString(params.childField),
    labelField:this.toString(params.labelField||params.titleField)||'Title',
    valueField:this.toString(params.valueField)||'ListDataID',
    filterExpression:this.resolveParameterValue(params.filterExpression),
    maxItems:Math.max(1,this.toNumber(params.maxItems||params.maxRecords||params.maxResults,8)),
    displayType:this.toString(params.displayType)||'card',
    saveMode:this.toString(params.saveMode)||'json',
    delimiter:this.toString(params.delimiter)||',',
    placeholderText:this.toString(params.placeholderText)||'Type and press Enter',
    allowDuplicates:this.toBoolean(params.allowDuplicates,false),
    maxTags:Math.max(1,this.toNumber(params.maxTags||params.maxItems,20)),
    items:this.parseChecklistItems(params.itemsJson),
    requireAllChecked:this.toBoolean(params.requireAllChecked,true),
    showProgress:this.toBoolean(params.showProgress,true),
    multiSelect:this.toBoolean(params.multiSelect,false),
    rules:this.parseRules(params.rulesJson),
    showSeverity:this.toBoolean(params.showSeverity,true),
    saveTarget:this.resolveWriteTarget('saveToField',params.saveToField),
    selectedItemsTarget:this.resolveWriteTarget('selectedItemsField',params.selectedItemsField),
    parentTarget:this.resolveWriteTarget('parentSaveToField',params.parentSaveToField),
    childTarget:this.resolveWriteTarget('childSaveToField',params.childSaveToField),
  }; }
  getConfigKey(props:any) { try { return JSON.stringify((props&&props.params)||{}); } catch(error) { return String(new Date().getTime()); } }
  loadData() { const config=this.getConfig(); if(!config.dataListId){return;} const client=this.props.context&&this.props.context.modules&&this.props.context.modules.yeeSDKClient; if(!client||!client.lists||typeof client.lists.queryItems!=='function'){this.setState({errorText:'Data list service is not available'});return;} const fields=[config.recordIdField,config.relationField,config.eventDateField,config.actorField,config.titleField,config.statusField,config.commentFieldName,config.idField,config.parentField,config.childField,config.labelField,config.valueField].concat(config.displayedFields).filter(Boolean); this.setState({loading:true,errorText:''}); this.queryList(client,config.dataListId,fields,Math.max(50,config.maxItems*10),config.filterExpression).then((rows)=>{if(this.mountedFlag){this.setState({loading:false,rows:this.filterRows(rows,config)});}}).catch(()=>{if(this.mountedFlag){this.setState({loading:false,errorText:'Unable to load records'});}}); }
  filterRows(rows:any[],config:any) { let output=rows||[]; if(config.relationField&&config.relationValue){output=output.filter((r)=>this.toString(this.readField(r,config.relationField))===config.relationValue);} if(config.recordIdField&&config.recordIdValue){output=output.filter((r)=>this.toString(this.readField(r,config.recordIdField))===config.recordIdValue);} if(config.eventDateField){output=output.slice().sort((a,b)=>{const ad=this.parseDate(this.readField(a,config.eventDateField));const bd=this.parseDate(this.readField(b,config.eventDateField));return (bd?bd.getTime():0)-(ad?ad.getTime():0);});} return output.slice(0,config.maxItems); }
  readInitialArray(config:any) { const raw=this.readTarget(config.saveTarget); const parsed=this.safeParseJson(raw); if(Array.isArray(parsed)){return parsed.map((x)=>this.toString(x)).filter(Boolean);} return this.toString(raw).split(config.delimiter||',').map((x)=>x.trim()).filter(Boolean); }
  readInitialChecklist(config:any) { const raw=this.readTarget(config.saveTarget); const parsed=this.safeParseJson(raw); const map:any={}; if(Array.isArray(parsed)){parsed.forEach((x:any)=>{if(x&&typeof x==='object'){map[this.toString(x.id||x.value||x.label)]=x.checked===true;}});} return map; }
  parseStringArray(value:any) { const parsed=this.safeParseJson(value); if(Array.isArray(parsed)){return parsed.map((x)=>this.toString(x)).filter(Boolean);} return this.toString(value).split(',').map((x)=>x.trim()).filter(Boolean); }
  parseChecklistItems(value:any) { const parsed=this.safeParseJson(value); const src=Array.isArray(parsed)?parsed:[]; return src.map((item:any,index:number)=>{ if(typeof item==='string'){return {id:item,label:item,required:true};} return {id:this.toString(item.id||item.value||item.key)||'item-'+index,label:this.toString(item.label||item.title||item.name)||('Item '+(index+1)),required:this.toBoolean(item.required,true),helper:this.toString(item.helper||item.description)}; }); }
  parseRules(value:any) { const parsed=this.safeParseJson(value); return Array.isArray(parsed)?parsed:[]; }
  persistTags(tags:string[],config:any) { const clean=config.allowDuplicates?tags:this.unique(tags); const value=config.saveMode==='delimiter'?clean.join(config.delimiter):JSON.stringify(clean); this.writeTarget(config.saveTarget,value); this.setState({tags:clean,textValue:''}); }
  persistChecklist(map:any,config:any) { const value=config.items.map((item:any)=>({id:item.id,label:item.label,checked:map[item.id]===true,required:item.required===true})); this.writeTarget(config.saveTarget,JSON.stringify(value)); const required=config.items.filter((x:any)=>x.required!==false); const done=required.filter((x:any)=>map[x.id]===true).length; this.setState({checklist:map,validationMessage:config.requireAllChecked&&done<required.length?'Complete all required checklist items before submitting.':''}); }
  unique(values:string[]) { const seen:any={}; const out:string[]=[]; values.forEach((v)=>{const s=this.toString(v); const k=s.toLowerCase(); if(s&&!seen[k]){seen[k]=true;out.push(s);}}); return out; }

  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;
  }
  toString(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.toString(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.toString(preferred); }
      try { return JSON.stringify(resolved); } catch (error) { return ''; }
    }
    return '';
  }
  toBoolean(value: any, fallback: boolean): boolean {
    const resolved = this.resolveParameterValue(value);
    if (resolved === undefined || resolved === null || resolved === '') { return fallback; }
    if (typeof resolved === 'boolean') { return resolved; }
    if (typeof resolved === 'number') { return resolved !== 0; }
    const text = this.toString(resolved).toLowerCase();
    if (['true','1','yes','y','on'].indexOf(text) !== -1) { return true; }
    if (['false','0','no','n','off'].indexOf(text) !== -1) { return false; }
    return fallback;
  }
  toNumber(value: any, fallback: number): number { const parsed = Number(this.toString(value).replace(/,/g,'')); return isNaN(parsed) ? fallback : parsed; }
  safeParseJson(value: any): any { if (Array.isArray(value) || (value && typeof value === 'object')) { return value; } const text=this.toString(value); if(!text){return null;} try{return JSON.parse(text);}catch(error){return null;} }
  firstDefined(values: any[]) { for (let i=0;i<values.length;i+=1){ if(values[i]!==undefined && values[i]!==null){ return values[i]; } } return undefined; }
  readObjectValue(source: any, key: string): any { if(!source || typeof source !== 'object' || !key){return undefined;} if(source[key]!==undefined){return source[key];} const wanted=key.toLowerCase(); const keys=Object.keys(source); for(let i=0;i<keys.length;i+=1){ if(keys[i].toLowerCase()===wanted){ return source[keys[i]]; } } return undefined; }
  normalizeCell(value: any): any { if(value===undefined||value===null){return value;} if(typeof value==='string'||typeof value==='number'||typeof value==='boolean'){return value;} if(Array.isArray(value)){return value.map((item)=>this.toString(this.normalizeCell(item))).filter(Boolean).join(', ');} if(typeof value==='object'){ return this.firstDefined([value.value,value.Value,value.fieldValue,value.FieldValue,value.dataValue,value.DataValue,value.valueText,value.ValueText,value.text,value.Text,value.display,value.Display,value.label,value.Label,value.name,value.Name,value.title,value.Title]); } return value; }
  readField(row: any, field: string): any { if(!row||!field){return undefined;} const direct=this.readObjectValue(row,field); if(direct!==undefined){return this.normalizeCell(direct);} const containers=[row.values,row.Values,row.fields,row.Fields,row.data,row.Data,row.item,row.Item]; for(let i=0;i<containers.length;i+=1){ const v=this.readObjectValue(containers[i],field); if(v!==undefined){return this.normalizeCell(v);} } const arrays=[row.fieldValues,row.FieldValues,row.fieldsValues,row.FieldsValues]; for(let g=0;g<arrays.length;g+=1){ const arr=arrays[g]; if(Array.isArray(arr)){ for(let i=0;i<arr.length;i+=1){ const e=arr[i]||{}; const k=this.toString(this.firstDefined([e.fieldId,e.FieldId,e.fieldID,e.FieldID,e.fieldCode,e.FieldCode,e.name,e.Name,e.key,e.Key])); if(k && k.toLowerCase()===field.toLowerCase()){ return this.normalizeCell(this.firstDefined([e.value,e.Value,e.fieldValue,e.FieldValue,e.dataValue,e.DataValue,e.text,e.Text])); } } } } return undefined; }
  extractRows(response: any): any[] { if(!response){return [];} if(Array.isArray(response)){return response;} const candidates=[response.items,response.Items,response.data,response.Data,response.rows,response.Rows,response.records,response.Records,response.list,response.List,response.result,response.Result,response.value,response.Value]; for(let i=0;i<candidates.length;i+=1){ if(Array.isArray(candidates[i])){return candidates[i];} if(candidates[i]&&typeof candidates[i]==='object'){ const nested=this.extractRows(candidates[i]); if(nested.length>0){return nested;} } } return []; }
  queryList(client: any, listId: string, fields: string[], pageSize: number, filter: any): Promise<any[]> { const selected=['ListDataID','ListDataId'].concat(fields||[]); const parsed=this.safeParseJson(filter); const filterValue=parsed!==null&&parsed!==undefined?parsed:filter; const payload:any={listId:listId,dataListId:listId,fields:selected,fieldIds:selected,selectedFields:selected,pageIndex:1,pageNo:1,pageSize:pageSize}; if(filterValue!==undefined&&filterValue!==null&&this.toString(filterValue)!==''){payload.filters=filterValue;payload.filter=filterValue;payload.where=filterValue;payload.filterExpression=filterValue;} const payload2:any={}; Object.keys(payload).forEach((k)=>{payload2[k]=payload[k];}); delete payload2.listId; delete payload2.dataListId; const attempts=[()=>client.lists.queryItems(payload),()=>client.lists.queryItems(listId,payload2),()=>client.lists.queryItems(listId,payload),()=>client.lists.queryItems(listId,payload2,{})]; const run=(index:number):Promise<any[]>=>{ if(index>=attempts.length){return Promise.resolve([]);} let result:any; try{result=attempts[index]();}catch(error){return run(index+1);} return Promise.resolve(result).then((response)=>{const rows=this.extractRows(response); if(rows.length===0&&index<attempts.length-1){return run(index+1);} return rows;}).catch(()=>run(index+1)); }; return run(0); }
  parseDate(value: any): Date | null { const cell=this.normalizeCell(value); if(cell===undefined||cell===null||cell===''){return null;} if(cell instanceof Date && !isNaN(cell.getTime())){return cell;} if(typeof cell==='number'){ if(cell>100000000000){const d=new Date(cell);return isNaN(d.getTime())?null:d;} if(cell>1000000000){const d=new Date(cell*1000);return isNaN(d.getTime())?null:d;} if(cell>20000&&cell<80000){const d=new Date(Math.round((cell-25569)*86400*1000));return isNaN(d.getTime())?null:d;} } const text=this.toString(cell); const iso=/^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(text); if(iso){return new Date(Number(iso[1]),Number(iso[2])-1,Number(iso[3]));} const parsed=new Date(text); return isNaN(parsed.getTime())?null:parsed; }
  formatDate(value: any): string { const d=this.parseDate(value); if(!d){return this.toString(value);} return d.getFullYear()+'-'+this.pad(d.getMonth()+1)+'-'+this.pad(d.getDate()); }
  pad(value:number){return value<10?'0'+value:String(value);}



  resolveWriteTarget(paramName: string, rawValue: any): any { const configured=(this.props&&this.props.configuredOutputTargets)||{}; const candidates=this.mergeCandidates(this.collectTargets(configured[paramName]),this.collectTargets(rawValue)).filter((x)=>this.isLikelyTarget(x)); return {paramName:paramName,candidates:candidates,displayValue:candidates[0]||this.toString(rawValue)}; }
  collectTargets(rawValue:any): string[]{ const out:string[]=[]; const seen:any={}; const add=(v:any)=>{const s=this.toString(v); if(s&&!seen[s]){seen[s]=true;out.push(s);}}; const visit=(v:any,d:number)=>{ if(v===undefined||v===null||d>4){return;} if(typeof v==='string'||typeof v==='number'||typeof v==='boolean'){add(v);return;} if(Array.isArray(v)){v.forEach((x)=>visit(x,d+1));return;} if(typeof v!=='object'){return;} ['fieldId','FieldId','fieldName','FieldName','variableId','VariableId','variableName','VariableName','tempVariableId','TempVariableId','tempVariableName','TempVariableName','id','Id','key','Key','name','Name','code','Code','label','Label','title','Title','path','Path','value','Value'].forEach((k)=>{if(v[k]!==undefined&&v[k]!==null){add(v[k]);}}); ['target','Target','binding','Binding','field','Field','variable','Variable','tempVariable','TempVariable','data','Data','meta','Meta','metadata','Metadata'].forEach((k)=>{if(v[k]!==undefined&&v[k]!==null){visit(v[k],d+1);}}); }; visit(rawValue,0); return out; }
  mergeCandidates(a:string[],b:string[]){const out:string[]=[];const seen:any={};(a||[]).concat(b||[]).forEach((x)=>{const s=this.toString(x);if(s&&!seen[s]){seen[s]=true;out.push(s);}});return out;}
  isLikelyTarget(value:any){const s=this.toString(value); if(!s||s==='[]'||s==='{}'){return false;} if(s.charAt(0)==='['||s.charAt(0)==='{'){return false;} if(s.indexOf('\":')!==-1||s.indexOf('\",\"')!==-1){return false;} return s.length<=160;}
  writeTarget(target:any,value:any){const candidates=target&&target.candidates?target.candidates:[]; for(let i=0;i<candidates.length;i+=1){ if(this.writeValue(candidates[i],value)){return true;} } return false;}
  writeValue(name:string,value:any){const context=this.props.context||{}; const fieldsValues=this.props.fieldsValues||{}; const hosts=[context,context.formContext,context.pageContext,context.variableContext,context.variables,context.tempVariables,context.runtimeContext,context.dataContext].filter(Boolean); const methods=['setFieldValue','setFormFieldValue','setFieldData','setValue','setVariableValue','setVariable','setTempVariableValue','setTempVariable','updateFieldValue','updateVariableValue']; for(let h=0;h<hosts.length;h+=1){for(let m=0;m<methods.length;m+=1){const fn=hosts[h][methods[m]]; if(typeof fn==='function'){try{const result=fn.call(hosts[h],name,value); if(result!==false){return true;}}catch(error){}}}} const objectMethods=['setFieldsValue','setFieldValues','setValues','setVariables','setTempVariables']; for(let h=0;h<hosts.length;h+=1){for(let m=0;m<objectMethods.length;m+=1){const fn=hosts[h][objectMethods[m]]; if(typeof fn==='function'){try{const payload:any={};payload[name]=value;const result=fn.call(hosts[h],payload); if(result!==false){return true;}}catch(error){}}}} fieldsValues[name]=value; return true;}
  readTarget(target:any){const candidates=target&&target.candidates?target.candidates:[]; const context=this.props.context||{}; const fieldsValues=this.props.fieldsValues||{}; for(let i=0;i<candidates.length;i+=1){const k=candidates[i]; if(fieldsValues&&fieldsValues[k]!==undefined){return fieldsValues[k];} if(context&&typeof context.getFieldValue==='function'){try{const v=context.getFieldValue(k); if(v!==undefined&&v!==null&&this.toString(v)!==''){return v;}}catch(error){}}} return '';}

  render() { const config=this.getConfig(); return <div className="yft-panel"><style>{this.getStyles()}</style><div className="yft-header"><div><div className="yft-title">{config.titleText}</div><div className="yft-subtitle">{config.subtitleText}</div></div>{this.renderBadge(config)}</div>{this.renderBody(config)}</div>; }
  renderBadge(config:any) { if(TEMPLATE_KIND==='multi-entry-tag-input'){return <span className="yft-badge">{this.state.tags.length} / {config.maxTags}</span>;} if(TEMPLATE_KIND==='checklist-compliance-block'){const total=config.items.length; const done=config.items.filter((x:any)=>this.state.checklist[x.id]===true).length; return <span className="yft-badge">{done} / {total}</span>;} if(this.state.rows&&this.state.rows.length){return <span className="yft-badge">{this.state.rows.length} records</span>;} return null; }
  renderBody(config:any) { if(this.state.loading){return <div className="yft-loading">Loading...</div>;} if(this.state.errorText){return <div className="yft-empty yft-error">{this.state.errorText}</div>;} if(TEMPLATE_KIND==='multi-entry-tag-input'){return this.renderTagInput(config);} if(TEMPLATE_KIND==='checklist-compliance-block'){return this.renderChecklist(config);} if(TEMPLATE_KIND==='hierarchical-selector'){return this.renderTree(config);} if(TEMPLATE_KIND==='dependent-selector'){return this.renderDependent(config);} if(TEMPLATE_KIND==='exception-alert-panel'){return this.renderAlerts(config);} if(TEMPLATE_KIND==='related-record-summary-grid'){return this.renderRelated(config);} return this.renderTimeline(config); }
  renderTagInput(config:any) { const readonly=this.props.readonly; return <div><div className="yft-chiprow">{this.state.tags.map((tag:string,index:number)=><span className="yft-chip" key={tag+'-'+index}>{tag}{!readonly?<button type="button" onClick={()=>{const next=this.state.tags.slice();next.splice(index,1);this.persistTags(next,config);}}>x</button>:null}</span>)}</div>{!readonly?<input className="yft-input" value={this.state.textValue} placeholder={config.placeholderText} onChange={(e)=>this.setState({textValue:e.target.value})} onKeyDown={(e)=>{if(e.key==='Enter'){e.preventDefault();const value=this.toString(this.state.textValue);if(value&&this.state.tags.length<config.maxTags){this.persistTags(this.state.tags.concat([value]),config);}}}} />:null}</div>; }
  renderChecklist(config:any) { if(!config.items.length){return <div className="yft-empty">Configure checklist items JSON.</div>;} const total=config.items.length; const done=config.items.filter((x:any)=>this.state.checklist[x.id]===true).length; return <div>{config.items.map((item:any)=><label className="yft-row" key={item.id}><input type="checkbox" checked={this.state.checklist[item.id]===true} disabled={this.props.readonly} onChange={(e)=>{const map:any={};Object.keys(this.state.checklist).forEach((k)=>map[k]=this.state.checklist[k]);map[item.id]=e.target.checked;this.persistChecklist(map,config);}} /> <span className="yft-row-title">{item.label}{item.required!==false?' *':''}</span>{item.helper?<div className="yft-row-meta">{item.helper}</div>:null}</label>)}{config.showProgress?<div className="yft-progress"><span style={{width:(total?Math.round(done/total*100):0)+'%'}} /></div>:null}{this.state.validationMessage?<div className="yft-validation">{this.state.validationMessage}</div>:null}</div>; }
  renderTimeline(config:any) { const rows=this.state.rows||[]; if(!rows.length){return <div className="yft-empty">{config.emptyText}</div>;} return <div className="yft-timeline">{rows.map((row:any,index:number)=><div className="yft-event" key={index}><div className="yft-row-title">{this.toString(this.readField(row,config.titleField))||this.toString(this.readField(row,config.statusField))||'Event'}</div><div className="yft-row-meta">{this.formatDate(this.readField(row,config.eventDateField))} {this.toString(this.readField(row,config.actorField))}</div>{this.toString(this.readField(row,config.commentFieldName))?<div className="yft-row-meta">{this.toString(this.readField(row,config.commentFieldName))}</div>:null}</div>)}</div>; }
  renderRelated(config:any) { const rows=this.state.rows||[]; if(!rows.length){return <div className="yft-empty">{config.emptyText}</div>;} const fields=config.displayedFields.length?config.displayedFields:[config.titleField,config.statusField,config.commentFieldName].filter(Boolean); if(config.displayType==='table'){return <table className="yft-table"><thead><tr>{fields.map((f:string)=><th key={f}>{f}</th>)}</tr></thead><tbody>{rows.map((row:any,i:number)=><tr key={i}>{fields.map((f:string)=><td key={f}>{this.toString(this.readField(row,f))}</td>)}</tr>)}</tbody></table>;} return <div className="yft-grid">{rows.map((row:any,i:number)=><div className="yft-card" key={i}>{fields.map((f:string,index:number)=><div key={f} className={index===0?'yft-row-title':'yft-row-meta'}>{index>0?f+': ':''}{this.toString(this.readField(row,f))}</div>)}</div>)}</div>; }
  renderTree(config:any) { const rows=this.state.rows||[]; if(!rows.length){return <div className="yft-empty">{config.emptyText}</div>;} const byParent:any={}; rows.forEach((r:any)=>{const p=this.toString(this.readField(r,config.parentField)); if(!byParent[p]){byParent[p]=[];} byParent[p].push(r);}); const renderNodes=(parent:string):any=> (byParent[parent]||[]).map((row:any,index:number)=>{const value=this.toString(this.readField(row,config.valueField)||this.readField(row,config.idField)); const label=this.toString(this.readField(row,config.labelField))||value; const selected=this.state.selected.indexOf(value)!==-1; return <div className="yft-tree-node" key={value||index}><button type="button" className={selected?'yft-btn yft-btn-active':'yft-btn'} onClick={()=>{let next=config.multiSelect?this.state.selected.slice():[]; const pos=next.indexOf(value); if(pos===-1){next.push(value);}else{next.splice(pos,1);} this.writeTarget(config.saveTarget,JSON.stringify(next)); this.setState({selected:next});}}>{label}</button><div className="yft-tree-children">{renderNodes(value)}</div></div>;}); return <div>{renderNodes('')}</div>; }
  renderDependent(config:any) { const rows=this.state.rows||[]; const parents=this.unique(rows.map((r:any)=>this.toString(this.readField(r,config.parentField))).filter(Boolean)); const children=this.unique(rows.filter((r:any)=>this.toString(this.readField(r,config.parentField))===this.state.parentValue).map((r:any)=>this.toString(this.readField(r,config.childField))).filter(Boolean)); return <div className="yft-grid"><div><div className="yft-muted">Parent</div><select className="yft-select" value={this.state.parentValue} disabled={this.props.readonly} onChange={(e)=>{this.setState({parentValue:e.target.value,childValue:''});this.writeTarget(config.parentTarget,e.target.value);this.writeTarget(config.childTarget,'');}}><option value="">Select</option>{parents.map((p)=><option key={p} value={p}>{p}</option>)}</select></div><div><div className="yft-muted">Child</div><select className="yft-select" value={this.state.childValue} disabled={this.props.readonly||!this.state.parentValue} onChange={(e)=>{this.setState({childValue:e.target.value});this.writeTarget(config.childTarget,e.target.value);}}><option value="">Select</option>{children.map((c)=><option key={c} value={c}>{c}</option>)}</select></div></div>; }
  renderAlerts(config:any) { const rows=this.state.rows||[]; const rules=config.rules; let alerts:any[]=[]; if(rules.length){ rows.forEach((row:any)=>{ rules.forEach((rule:any)=>{const field=this.toString(rule.field); const op=this.toString(rule.operator||'equals'); const expected=this.toString(rule.value); const actual=this.toString(this.readField(row,field)); const hit=op==='notEmpty'?actual!=='':op==='empty'?actual==='':op==='contains'?actual.indexOf(expected)!==-1:actual===expected; if(hit){alerts.push({title:this.toString(rule.title)||this.toString(this.readField(row,config.titleField))||'Alert',message:this.toString(rule.message)||field+' '+op+' '+expected,severity:this.toString(rule.severity)||'warning'});} }); });} else { alerts=rows.map((row:any)=>({title:this.toString(this.readField(row,config.titleField))||'Alert',message:this.toString(this.readField(row,config.commentFieldName)),severity:this.toString(this.readField(row,config.statusField))||'warning'})); } if(!alerts.length){return <div className="yft-empty">{config.emptyText}</div>;} return <div>{alerts.slice(0,config.maxItems).map((a:any,i:number)=><div className={'yft-alert yft-alert-'+(a.severity||'warning')} key={i}><div className="yft-row-title">{a.title}</div>{a.message?<div className="yft-row-meta">{a.message}</div>:null}</div>)}</div>; }
  getStyles() { return [".yft-panel{box-sizing:border-box;width:100%;min-width:0;background:#fff;border:1px solid #e5eaf3;border-radius:8px;padding:16px;color:#111827;font-family:Inter,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;box-shadow:0 1px 2px rgba(16,24,40,.04);}", ".yft-panel *{box-sizing:border-box;}", ".yft-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:14px;}", ".yft-title{font-size:16px;line-height:22px;font-weight:700;color:#101828;}", ".yft-subtitle{font-size:12px;line-height:18px;color:#667085;margin-top:3px;}", ".yft-badge{border:1px solid #e5eaf3;background:#f8fafc;border-radius:999px;padding:3px 9px;font-size:12px;color:#475467;}", ".yft-empty,.yft-loading{min-height:120px;display:flex;align-items:center;justify-content:center;text-align:center;border:1px dashed #d8deea;border-radius:8px;background:#f8fafc;color:#667085;font-size:13px;line-height:20px;padding:20px;}", ".yft-error{color:#b42318;background:#fff7f6;border-color:#fecdca;}", ".yft-row{border:1px solid #e5eaf3;border-radius:8px;padding:10px 12px;background:#fff;margin-bottom:8px;}", ".yft-row-title{font-size:13px;font-weight:700;line-height:18px;color:#101828;}", ".yft-row-meta{font-size:12px;line-height:18px;color:#667085;margin-top:3px;}", ".yft-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;}", ".yft-card{border:1px solid #e5eaf3;border-radius:8px;background:#fff;padding:12px;}", ".yft-table{width:100%;border-collapse:separate;border-spacing:0;border:1px solid #e5eaf3;border-radius:8px;overflow:hidden;}", ".yft-table th{background:#f8fafc;color:#475467;font-size:12px;text-align:left;padding:9px;border-bottom:1px solid #e5eaf3;}", ".yft-table td{font-size:13px;color:#344054;padding:9px;border-bottom:1px solid #eef2f7;}", ".yft-chiprow{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px;}", ".yft-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid #c8dafc;background:#edf4ff;color:#0d4fb3;border-radius:999px;padding:5px 9px;font-size:13px;line-height:18px;}", ".yft-chip button{appearance:none;border:0;background:transparent;color:#0d4fb3;cursor:pointer;font-size:15px;line-height:15px;padding:0;}", ".yft-input,.yft-textarea,.yft-select{width:100%;border:1px solid #d8deea;border-radius:8px;padding:9px 10px;font-size:13px;line-height:20px;color:#101828;background:#fff;outline:none;font-family:inherit;}", ".yft-textarea{min-height:86px;resize:vertical;}", ".yft-input:focus,.yft-textarea:focus,.yft-select:focus{border-color:#146ff6;box-shadow:0 0 0 3px rgba(20,111,246,.12);}", ".yft-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px;}", ".yft-btn{appearance:none;border:1px solid #d8deea;background:#fff;color:#344054;border-radius:8px;padding:8px 11px;font-size:13px;font-weight:650;cursor:pointer;}", ".yft-btn-active{border-color:#146ff6;background:#edf4ff;color:#0d4fb3;}", ".yft-btn-primary{border-color:#146ff6;background:#146ff6;color:#fff;}", ".yft-validation{margin-top:10px;border:1px solid #fecdca;background:#fff7f6;color:#b42318;border-radius:8px;padding:9px 10px;font-size:12px;line-height:18px;}", ".yft-progress{height:8px;background:#eef2f7;border-radius:999px;overflow:hidden;margin-top:10px;}", ".yft-progress span{display:block;height:100%;background:#146ff6;border-radius:999px;}", ".yft-timeline{position:relative;padding-left:18px;}", ".yft-timeline:before{content:\"\";position:absolute;left:5px;top:4px;bottom:4px;width:2px;background:#e5eaf3;}", ".yft-event{position:relative;margin-bottom:12px;border:1px solid #e5eaf3;border-radius:8px;padding:10px 12px;background:#fff;}", ".yft-event:before{content:\"\";position:absolute;left:-17px;top:14px;width:10px;height:10px;border-radius:999px;background:#146ff6;box-shadow:0 0 0 4px #edf4ff;}", ".yft-alert{border:1px solid #fecdca;background:#fff7f6;border-radius:8px;padding:10px 12px;margin-bottom:8px;color:#7a271a;}", ".yft-alert-warning{border-color:#fedf89;background:#fffbeb;color:#7a4b00;}", ".yft-alert-info{border-color:#b2ddff;background:#eff8ff;color:#1849a9;}", ".yft-alert-success{border-color:#abefc6;background:#f0fdf4;color:#067647;}", ".yft-tree-node{border:1px solid #e5eaf3;border-radius:8px;padding:9px 10px;margin-bottom:8px;background:#fff;}", ".yft-tree-children{margin-left:18px;margin-top:8px;}", ".yft-muted{color:#667085;font-size:12px;line-height:18px;}", "@media(max-width:640px){.yft-panel{padding:14px}.yft-header{flex-direction:column}.yft-table{display:block;overflow-x:auto}.yft-actions{flex-direction:column}.yft-btn{width:100%;text-align:center}}"].join(''); }
}

User guide

Download guide
# Approval Timeline

## Short Description

Approval Timeline - reusable Yeeflow approval form timeline for approval history, actors, timestamps, and comments.

## Purpose / What This Is For

Shows approval history and decision events so reviewers can understand what happened before the current step.

## Supported Placement

Approval form

## When To Use

Use this control when teams need a reusable Yeeflow control for approval audit visibility and governed review history.

## Recommended Defaults

Start with the required source/save parameters, keep labels concise, and test with a small data set before publishing.

## Input Parameters Overview

| Parameter | Type | Purpose |
| --- | --- | --- |
| `sourceListId` | `variable` | List containing approval history events. |
| `recordIdField` | `variable` | Field used to match the current approval record. |
| `recordIdValue` | `variable` | Current record ID or expression value. |
| `eventDateField` | `variable` | Timestamp field. |
| `actorField` | `variable` | Approver/actor field. |
| `decisionField` | `variable` | Decision/status field. |
| `commentField` | `variable` | Comment field. |
| `titleText` | `string` | Panel title. |
| `subtitleText` | `string` | Panel subtitle. |
| `maxItems` | `string` | Max events shown. |
| `emptyText` | `string` | Empty state text. |
| `filterExpression` | `variable` | Optional query filter. |

## Detailed Parameter Explanation

Configure expression-editor parameters with Yeeflow fields, variables, temp variables, or static values as appropriate. Writable target parameters save output values and are resolved defensively at runtime. Plain text parameters control display labels and helper text.

## Step-By-Step Setup

1. Add a Custom Code control to the supported Yeeflow placement.
2. Paste the `approval-timeline.tsx` code file.
3. Configure required data source, field, and save-target parameters.
4. Configure labels, limits, and display options.
5. Preview with realistic records.
6. Confirm readonly, empty, and validation states.
7. Publish when behavior is correct.

## Result / Expected Output

A vertical timeline of approval events, actors, dates, statuses, and comments.

## Real Business Examples

- Procurement approval and vendor review.
- Case, request, or task management.
- Policy, compliance, and operational control checks.
- Department, location, product, or category-driven workflows.

## Notes / Assumptions / Limitations

- Runtime query behavior uses `yeeSDKClient.lists.queryItems(...)` where list data is needed.
- Expression-editor values are normalized from strings, booleans, objects, arrays, and variable selector values.
- Writable targets use defensive setter paths and a local `fieldsValues` fallback.
- Tenant-specific query filter shapes may require adjustment in `filterExpression`.

## Testing Checklist

- Required parameters configured.
- Empty state displays safely.
- Existing saved values initialize correctly when applicable.
- Save targets update as expected when applicable.
- Readonly mode prevents editing.
- Narrow layout remains readable.

## Troubleshooting

If data does not appear, verify list ID, field IDs, filter expression, and permissions. If saving does not work, confirm the target parameter was selected as a writable form field or variable and that the field exists on the form.

Example configuration

Download config
# Approval Timeline Example Config

| Parameter | Example Value |
| --- | --- |
| `sourceListId` | `203...` |
| `recordIdField` | `RequestId` |
| `recordIdValue` | `{{ListDataID}}` |
| `eventDateField` | `CreatedTime` |
| `actorField` | `Approver` |
| `decisionField` | `Decision` |
| `commentField` | `Comment` |
| `titleText` | `Approval Timeline` |
| `subtitleText` | `Review history` |
| `maxItems` | `8` |
| `emptyText` | `No history yet` |
| `filterExpression` | `` |

Adjust field IDs and list IDs to match the target Yeeflow application.