Smart Lookup Picker

Help users search, select, and confirm related records or reference values from a guided lookup interface.

Custom CodeUI Design & LayoutsYeeflow
custom codelookup pickerstructured inputformstsx

Explore the key screens

Explore the key screens and structure included in this template.

Abstract illustration for Smart Lookup Picker, showing record lookup and guided selection.

What this template helps teams build

Overview

The Smart Lookup Picker template is a reusable custom code control concept for Yeeflow builders. It helps teams add a focused interface pattern for record lookup and guided selection while keeping the asset in review before public launch.

Key capabilities

  • Supports structured input scenarios
  • Designed for use in Approval form, Data list form
  • Includes TSX source, user guidance, and example configuration
  • Prepared as a preview asset for technical review and future public documentation

Recommended use cases

  • record lookup and guided selection
  • Structured Input 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';

type LookupItem = {
  display: string;
  value: string;
  isManual: boolean;
  source: 'selected' | 'manual';
  listDataId?: string;
};

type LookupOption = {
  display: string;
  value: string;
  listDataId: string;
  raw: any;
};

type WriteTargetInfo = {
  paramName: string;
  raw: any;
  candidates: string[];
  displayValue: string;
};

export class CodeInApplication implements CodeInComp {
  description() {
    return 'Smart Lookup Picker - reusable Yeeflow lookup/search-select control for approval forms and data list forms.';
  }

  requiredFields(params?: CodeInParams) {
    const required: string[] = [];
    const safeParams: any = params || {};
    const outputTargetMap: any = {};
    ['saveToField', 'selectedItemsField', 'newItemsField'].forEach((key) => {
      const candidates = this.collectRequiredFieldCandidates(safeParams[key]).filter((value) => this.isLikelyWritableTargetName(value));
      if (candidates.length > 0) {
        outputTargetMap[key] = candidates[0];
      }
      candidates.forEach((value) => {
        if (value && required.indexOf(value) === -1) {
          required.push(value);
        }
      });
    });
    /*
      Yeeflow variable-selector parameters are evaluated before render(), so an output
      target selected from the variable dropdown can arrive in context.params as the
      variable's current value instead of its writable name. requiredFields(params)
      receives the raw configuration early enough to capture the selected field/temp
      variable key and pass it into the rendered component through this instance.
    */
    (this as any).smartLookupOutputTargetMap = outputTargetMap;
    return required;
  }

  inputParameters(): InputParameter[] {
    return [
      { id: 'dataListId', name: 'Data List ID', type: 'variable', desc: 'Target Yeeflow data list ID used for lookup query.' },
      { id: 'displayField', name: 'Display Field', type: 'variable', desc: 'Field ID/name used for display text and keyword search matching.' },
      { id: 'valueField', name: 'Value Field', type: 'variable', desc: 'Field ID/name used as the saved value for selected matched items.' },
      { id: 'saveToField', name: 'Save To Field', type: 'variable', desc: 'Writable current form field or dashboard temp variable used to save the full combined JSON result. Choose the target variable/field from the selector.' },
      { id: 'selectedItemsField', name: 'Selected Items Field', type: 'variable', desc: 'Writable current form field or dashboard temp variable used to save matched selected values only. Choose the target variable/field from the selector.' },
      { id: 'newItemsField', name: 'New Items Field', type: 'variable', desc: 'Writable current form field or dashboard temp variable used to save manual/free-text items only. Choose the target variable/field from the selector.' },
      { id: 'multiSelect', name: 'Multi Select', type: 'variable', desc: 'Whether multi-select is enabled. Use true or false.' },
      { id: 'allowManualEntry', name: 'Allow Manual Entry', type: 'variable', desc: 'Whether manual/free-text entry is enabled. Use true or false.' },
      { id: 'maxResults', name: 'Max Results', type: 'string', desc: 'Maximum number of matched records shown in the suggestion list.' },
      { id: 'placeholderText', name: 'Placeholder Text', type: 'string', desc: 'Placeholder text shown in the lookup input.' },
      { id: 'labelText', name: 'Label Text', type: 'string', desc: 'Optional label/title shown above the control.' },
      { id: 'noResultText', name: 'No Result Text', type: 'string', desc: 'Text shown when no matched records are found.' },
      { id: 'manualTagText', name: 'Manual Tag Text', type: 'string', desc: 'Optional label shown on manual item chips.' },
      { id: 'minSearchChars', name: 'Minimum Search Characters', type: 'string', desc: 'Optional minimum characters before querying. Default is 1.' },
      { id: 'debounceMs', name: 'Search Debounce Milliseconds', type: 'string', desc: 'Optional search debounce delay. Default is 260.' },
    ];
  }

  render(context: CodeInContext, fieldsValues: any, readonly: boolean) {
    return (
      <SmartLookupPicker
        context={context}
        fieldsValues={fieldsValues}
        readonly={readonly}
        params={(context && context.params) || {}}
        configuredOutputTargets={(this as any).smartLookupOutputTargetMap || {}}
      />
    );
  }

  safeTrim(value: any) {
    return value === undefined || value === null ? '' : String(value).trim();
  }

  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.safeTrim(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 SmartLookupPicker extends React.Component<any, any> {
  searchTimer: any;
  activeRequestId: number;
  wrapperRef: any;

  constructor(props: any) {
    super(props);
    this.searchTimer = null;
    this.activeRequestId = 0;
    this.wrapperRef = null;
    this.state = {
      keyword: '',
      selectedItems: this.getInitialSelectedItems(props),
      options: [],
      loading: false,
      dropdownOpen: false,
      errorText: '',
      lastLoadedRawValue: this.readRawCombinedValue(props),
    };
  }

  componentDidMount() {
    document.addEventListener('mousedown', this.handleDocumentMouseDown);
    this.logSaveDebug('mounted raw output params', {
      saveToField: this.props.params && this.props.params.saveToField,
      selectedItemsField: this.props.params && this.props.params.selectedItemsField,
      newItemsField: this.props.params && this.props.params.newItemsField,
      contextMethodSummary: this.getContextMethodSummary(this.props.context),
    });
    this.persistSelectedItems(this.state.selectedItems);
  }

  componentDidUpdate(prevProps: any) {
    const previousRawValue = this.readRawCombinedValue(prevProps);
    const nextRawValue = this.readRawCombinedValue(this.props);
    if (previousRawValue !== nextRawValue && nextRawValue !== this.state.lastLoadedRawValue) {
      this.setState({
        selectedItems: this.getInitialSelectedItems(this.props),
        lastLoadedRawValue: nextRawValue,
      });
    }
  }

  componentWillUnmount() {
    if (this.searchTimer) {
      clearTimeout(this.searchTimer);
    }
    document.removeEventListener('mousedown', this.handleDocumentMouseDown);
  }

  getConfig() {
    const params = this.props.params || {};
    const saveToTarget = this.resolveWriteTarget('saveToField', params.saveToField);
    const selectedItemsTarget = this.resolveWriteTarget('selectedItemsField', params.selectedItemsField);
    const newItemsTarget = this.resolveWriteTarget('newItemsField', params.newItemsField);
    return {
      dataListId: this.toCleanString(params.dataListId),
      displayField: this.toCleanString(params.displayField),
      valueField: this.toCleanString(params.valueField),
      saveToField: saveToTarget.displayValue,
      selectedItemsField: selectedItemsTarget.displayValue,
      newItemsField: newItemsTarget.displayValue,
      saveToTarget: saveToTarget,
      selectedItemsTarget: selectedItemsTarget,
      newItemsTarget: newItemsTarget,
      multiSelect: this.toBoolean(params.multiSelect, false),
      allowManualEntry: this.toBoolean(params.allowManualEntry, false),
      maxResults: this.toPositiveInt(params.maxResults, 8),
      placeholderText: this.toCleanString(params.placeholderText) || 'Search and select',
      labelText: this.toCleanString(params.labelText),
      noResultText: this.toCleanString(params.noResultText) || 'No matching records found',
      manualTagText: this.toCleanString(params.manualTagText) || 'Manual',
      minSearchChars: this.toPositiveInt(params.minSearchChars, 1),
      debounceMs: this.toPositiveInt(params.debounceMs, 260),
    };
  }

  getInitialSelectedItems(props: any) {
    const config = this.getConfigFromProps(props);
    const combinedRaw = this.readCurrentFieldValue(props, config.saveToField);
    const combinedItems = this.parseCombinedItems(combinedRaw);
    if (combinedItems.length > 0) {
      return this.dedupeItems(combinedItems);
    }

    const matchedValues = this.parseArrayValue(this.readCurrentFieldValue(props, config.selectedItemsField));
    const manualValues = this.parseArrayValue(this.readCurrentFieldValue(props, config.newItemsField));
    const rebuilt = matchedValues.map((value: any) => ({
      display: this.toCleanString(value),
      value: this.toCleanString(value),
      isManual: false,
      source: 'selected' as 'selected',
    })).concat(manualValues.map((value: any) => ({
      display: this.toCleanString(value),
      value: this.toCleanString(value),
      isManual: true,
      source: 'manual' as 'manual',
    })));
    return this.dedupeItems(rebuilt);
  }

  getConfigFromProps(props: any) {
    const params = (props && props.params) || {};
    const saveToTarget = this.resolveWriteTargetFromProps(props, 'saveToField', params.saveToField);
    const selectedItemsTarget = this.resolveWriteTargetFromProps(props, 'selectedItemsField', params.selectedItemsField);
    const newItemsTarget = this.resolveWriteTargetFromProps(props, 'newItemsField', params.newItemsField);
    return {
      saveToField: saveToTarget.displayValue,
      selectedItemsField: selectedItemsTarget.displayValue,
      newItemsField: newItemsTarget.displayValue,
      saveToTarget: saveToTarget,
      selectedItemsTarget: selectedItemsTarget,
      newItemsTarget: newItemsTarget,
    };
  }

  readRawCombinedValue(props: any) {
    const config = this.getConfigFromProps(props);
    return this.toCleanString(this.readCurrentFieldValue(props, config.saveToField));
  }

  readCurrentFieldValue(props: any, fieldName: string) {
    if (!fieldName) {
      return '';
    }
    const fieldsValues = (props && props.fieldsValues) || {};
    if (fieldsValues && fieldsValues[fieldName] !== undefined && fieldsValues[fieldName] !== null) {
      return fieldsValues[fieldName];
    }
    const context = props && props.context;
    if (context && typeof context.getFieldValue === 'function') {
      try {
        const value = context.getFieldValue(fieldName);
        if (value !== undefined && value !== null) {
          return value;
        }
      } catch (error) {
        return '';
      }
    }
    return '';
  }

  parseCombinedItems(rawValue: any) {
    const parsed = this.parseJsonValue(rawValue);
    if (!Array.isArray(parsed)) {
      return [];
    }
    return parsed
      .map((item: any) => {
        if (!item) {
          return null;
        }
        const isManual = item.isManual === true || item.source === 'manual';
        const display = this.toCleanString(item.display || item.value);
        const value = this.toCleanString(item.value || item.display);
        if (!display && !value) {
          return null;
        }
        return {
          display: display || value,
          value: value || display,
          isManual: isManual,
          source: isManual ? 'manual' : 'selected',
          listDataId: this.toCleanString(item.listDataId || item.ListDataID),
        };
      })
      .filter(Boolean);
  }

  parseArrayValue(rawValue: any) {
    const parsed = this.parseJsonValue(rawValue);
    if (Array.isArray(parsed)) {
      return parsed.map((value) => this.toCleanString(value)).filter(Boolean);
    }
    const cleaned = this.toCleanString(rawValue);
    if (!cleaned) {
      return [];
    }
    return cleaned.split(',').map((value) => this.toCleanString(value)).filter(Boolean);
  }

  parseJsonValue(rawValue: any) {
    if (Array.isArray(rawValue) || (rawValue && typeof rawValue === 'object')) {
      return rawValue;
    }
    const cleaned = this.toCleanString(rawValue);
    if (!cleaned) {
      return null;
    }
    try {
      return JSON.parse(cleaned);
    } catch (error) {
      return null;
    }
  }

  handleDocumentMouseDown = (event: any) => {
    if (this.wrapperRef && !this.wrapperRef.contains(event.target)) {
      this.setState({ dropdownOpen: false });
    }
  };

  handleInputChange = (event: any) => {
    const keyword = event.target.value || '';
    this.setState({ keyword: keyword, dropdownOpen: true, errorText: '' });
    this.scheduleSearch(keyword);
  };

  handleInputFocus = () => {
    if (this.state.options.length > 0 || this.state.keyword) {
      this.setState({ dropdownOpen: true });
    }
  };

  handleInputKeyDown = (event: any) => {
    const config = this.getConfig();
    if (event.key === 'Enter') {
      event.preventDefault();
      const keyword = this.toCleanString(this.state.keyword);
      if (config.allowManualEntry && keyword) {
        this.addManualItem(keyword);
      } else if (!config.multiSelect && this.state.options.length > 0) {
        this.addMatchedItem(this.state.options[0]);
      }
    }
    if (event.key === 'Escape') {
      this.setState({ dropdownOpen: false });
    }
  };

  scheduleSearch(keyword: string) {
    const config = this.getConfig();
    if (this.searchTimer) {
      clearTimeout(this.searchTimer);
    }
    const cleaned = this.toCleanString(keyword);
    if (!cleaned || cleaned.length < config.minSearchChars) {
      this.setState({ options: [], loading: false, dropdownOpen: Boolean(cleaned) });
      return;
    }
    this.searchTimer = setTimeout(() => {
      this.runSearch(cleaned);
    }, config.debounceMs);
  }

  async runSearch(keyword: string) {
    const config = this.getConfig();
    const client = this.props.context && this.props.context.modules && this.props.context.modules.yeeSDKClient;
    const canQuery = client && client.lists && typeof client.lists.queryItems === 'function';
    const requestId = this.activeRequestId + 1;
    this.activeRequestId = requestId;

    if (!canQuery || !config.dataListId || !config.displayField || !config.valueField) {
      this.setState({
        options: [],
        loading: false,
        dropdownOpen: true,
        errorText: canQuery ? '' : 'Lookup service is not available',
      });
      return;
    }

    this.setState({ loading: true, dropdownOpen: true, errorText: '' });

    try {
      /*
        Search strategy:
        Different Yeeflow placements/tenants accept different queryItems filter
        payloads. To keep the reusable template reliable, we first try several
        common contains-style payloads, then run a limited broad query and apply
        local contains matching against displayField. This avoids the "no result"
        symptom when the API accepts the request but ignores/rejects a specific
        filter shape.
      */
      let response: any = null;
      let options: LookupOption[] = [];
      const searchPayloads = this.buildSearchQueryPayloads(config, keyword);

      for (let payloadIndex = 0; payloadIndex < searchPayloads.length; payloadIndex += 1) {
        try {
          response = await this.callQueryItems(client, config.dataListId, searchPayloads[payloadIndex]);
          options = this.extractOptions(response, config, keyword);
          this.logSaveDebug('search payload result', {
            payloadIndex: payloadIndex,
            rows: this.extractRows(response).length,
            options: options.length,
          });
          if (options.length > 0) {
            break;
          }
        } catch (searchError) {
          this.logSaveDebug('search payload failed', {
            payloadIndex: payloadIndex,
            error: this.stringifyForLog(searchError),
          });
        }
      }

      if (options.length === 0) {
        try {
          const fallbackResponse = await this.callQueryItems(client, config.dataListId, this.buildFallbackQueryPayload(config));
          const fallbackOptions = this.extractOptions(fallbackResponse, config, keyword);
          this.logSaveDebug('search broad fallback', {
            keyword: keyword,
            filteredRows: this.extractRows(response).length,
            fallbackRows: this.extractRows(fallbackResponse).length,
            fallbackOptions: fallbackOptions.length,
          });
          if (fallbackOptions.length > 0) {
            options = fallbackOptions;
          }
        } catch (fallbackError) {
          /* Keep the empty filtered result when the broad fallback is not supported. */
        }
      }

      if (requestId !== this.activeRequestId) {
        return;
      }

      this.setState({
        options: options.slice(0, config.maxResults),
        loading: false,
        dropdownOpen: true,
        errorText: '',
      });
    } catch (error) {
      if (requestId !== this.activeRequestId) {
        return;
      }
      this.setState({
        options: [],
        loading: false,
        dropdownOpen: true,
        errorText: 'Unable to load matching records',
      });
    }
  }

  buildFilteredQueryPayload(config: any, keyword: string) {
    return this.buildSearchQueryPayloads(config, keyword)[0];
  }

  async callQueryItems(client: any, dataListId: string, payload: any) {
    /*
      Runtime compatibility note:
      Yeeflow examples commonly show queryItems(payload), while some SDK builds expose
      queryItems(dataListId, payload). Try both safely because an unsupported signature
      may return an empty page rather than throwing.
    */
    const payloadWithoutListId = Object.assign({}, payload);
    delete payloadWithoutListId.listId;
    delete payloadWithoutListId.dataListId;
    const attempts = [
      () => client.lists.queryItems(payload),
      () => client.lists.queryItems(dataListId, payloadWithoutListId),
      () => client.lists.queryItems(dataListId, payload),
      () => client.lists.queryItems(dataListId, payloadWithoutListId, {}),
      () => client.lists.queryItems(dataListId, payload, {}),
    ];
    let lastResponse: any = null;
    let lastError: any = null;
    for (let index = 0; index < attempts.length; index += 1) {
      try {
        const response = await attempts[index]();
        const rows = this.extractRows(response);
        this.logSaveDebug('queryItems attempt', {
          attempt: index,
          rows: rows.length,
          hasResponse: !!response,
        });
        if (rows.length > 0) {
          return response;
        }
        if (lastResponse === null) {
          lastResponse = response;
        }
      } catch (error) {
        lastError = error;
        this.logSaveDebug('queryItems attempt failed', {
          attempt: index,
          error: this.stringifyForLog(error),
        });
      }
    }
    if (lastResponse !== null) {
      return lastResponse;
    }
    throw lastError;
  }

  buildSearchQueryPayloads(config: any, keyword: string) {
    const fields = this.uniqueArray(['ListDataID', config.displayField, config.valueField]);

    /*
      Query assumption:
      Yeeflow list search is configured with field IDs/names passed in displayField and valueField.
      This payload uses a contains-style filter on displayField. If a tenant/runtime uses a
      different operator shape, runSearch falls back to loading a limited page and matching
      displayField locally so the control remains safe instead of crashing.
    */
    const basePayload: any = {
      listId: config.dataListId,
      dataListId: config.dataListId,
      fields: fields,
      fieldIds: fields,
      selectedFields: fields,
      pageIndex: 1,
      pageNo: 1,
      pageSize: Math.max(config.maxResults, 20),
    };

    return [
      Object.assign({}, basePayload, {
        filters: [
          {
            fieldId: config.displayField,
            field: config.displayField,
            operator: 'contains',
            compare: 'contains',
            value: keyword,
          },
        ],
      }),
      Object.assign({}, basePayload, {
        filters: [
          {
            fieldId: config.displayField,
            field: config.displayField,
            op: 'contains',
            method: 'contains',
            values: [keyword],
            value: keyword,
          },
        ],
      }),
      Object.assign({}, basePayload, {
        filter: {
          fieldId: config.displayField,
          field: config.displayField,
          operator: 'contains',
          compare: 'contains',
          value: keyword,
        },
      }),
      Object.assign({}, basePayload, {
        query: {
          fieldId: config.displayField,
          field: config.displayField,
          operator: 'contains',
          value: keyword,
        },
      }),
      Object.assign({}, basePayload, {
        where: [
          {
            fieldId: config.displayField,
            field: config.displayField,
            operator: 'contains',
            value: keyword,
          },
        ],
      }),
      Object.assign({}, basePayload, {
        condition: {
          fieldId: config.displayField,
          field: config.displayField,
          operator: 'contains',
          value: keyword,
        },
      }),
      Object.assign({}, basePayload, {
        conditions: [
          {
            fieldId: config.displayField,
            field: config.displayField,
            operator: 'contains',
            value: keyword,
          },
        ],
      }),
      Object.assign({}, basePayload, {
        filterItems: [
          {
            fieldId: config.displayField,
            field: config.displayField,
            operator: 'contains',
            value: keyword,
          },
        ],
      }),
      Object.assign({}, basePayload, {
        query: keyword,
        keyword: keyword,
        search: keyword,
        searchField: config.displayField,
        searchFieldId: config.displayField,
        searchKey: keyword,
      }),
    ];
  }

  buildFallbackQueryPayload(config: any) {
    return {
      listId: config.dataListId,
      dataListId: config.dataListId,
      fields: this.uniqueArray(['ListDataID', config.displayField, config.valueField]),
      fieldIds: this.uniqueArray(['ListDataID', config.displayField, config.valueField]),
      selectedFields: this.uniqueArray(['ListDataID', config.displayField, config.valueField]),
      pageIndex: 1,
      pageNo: 1,
      pageSize: Math.max(config.maxResults, 50),
    };
  }

  extractOptions(response: any, config: any, keyword: string) {
    const rows = this.extractRows(response);
    const normalizedKeyword = this.normalizeText(keyword);
    return rows
      .map((row: any) => {
        const display = this.toCleanString(this.readItemValue(row, config.displayField));
        const value = this.toCleanString(this.readItemValue(row, config.valueField) || this.readItemValue(row, 'ListDataID') || this.readItemValue(row, 'ListDataId'));
        const listDataId = this.toCleanString(this.readItemValue(row, 'ListDataID') || this.readItemValue(row, 'ListDataId') || this.readItemValue(row, 'id'));
        if (!display || !value) {
          return null;
        }
        return {
          display: display,
          value: value,
          listDataId: listDataId,
          raw: row,
        };
      })
      .filter(Boolean)
      .filter((option: LookupOption) => {
        if (!normalizedKeyword) {
          return true;
        }
        return this.normalizeText(option.display).indexOf(normalizedKeyword) !== -1;
      })
      .filter((option: LookupOption) => !this.isSelected(option.value, false));
  }

  extractRows(response: any) {
    if (!response) {
      return [];
    }
    if (Array.isArray(response)) {
      return response;
    }
    const candidates = [
      response.items,
      response.Items,
      response.rows,
      response.Rows,
      response.records,
      response.Records,
      response.listData,
      response.ListData,
      response.data,
      response.Data,
      response.result,
      response.Result,
      response.data && response.data.items,
      response.data && response.data.Items,
      response.data && response.data.data,
      response.data && response.data.Data,
      response.data && response.data.rows,
      response.data && response.data.Rows,
      response.data && response.data.list,
      response.data && response.data.List,
      response.data && response.data.listData,
      response.data && response.data.ListData,
      response.data && response.data.records,
      response.data && response.data.Records,
      response.result && response.result.items,
      response.result && response.result.Items,
      response.result && response.result.data,
      response.result && response.result.Data,
      response.result && response.result.rows,
      response.result && response.result.Rows,
      response.result && response.result.listData,
      response.result && response.result.ListData,
      response.result && response.result.records,
      response.result && response.result.Records,
      response.Data && response.Data.items,
      response.Data && response.Data.Items,
      response.Data && response.Data.data,
      response.Data && response.Data.Data,
      response.Data && response.Data.rows,
      response.Data && response.Data.Rows,
      response.Result && response.Result.items,
      response.Result && response.Result.Items,
      response.Result && response.Result.data,
      response.Result && response.Result.Data,
      response.Result && response.Result.rows,
      response.Result && response.Result.Rows,
    ];
    for (let index = 0; index < candidates.length; index += 1) {
      if (Array.isArray(candidates[index])) {
        return candidates[index];
      }
    }
    return this.findFirstRowArray(response, 0);
  }

  findFirstRowArray(value: any, depth: number): any[] {
    if (!value || depth > 5) {
      return [];
    }
    if (Array.isArray(value)) {
      if (value.length === 0) {
        return value;
      }
      const first = value[0];
      if (first && typeof first === 'object') {
        return value;
      }
      return [];
    }
    if (typeof value !== 'object') {
      return [];
    }
    const preferredKeys = [
      'items', 'Items', 'rows', 'Rows', 'records', 'Records',
      'listData', 'ListData', 'data', 'Data', 'result', 'Result',
      'list', 'List',
    ];
    for (let keyIndex = 0; keyIndex < preferredKeys.length; keyIndex += 1) {
      const child = value[preferredKeys[keyIndex]];
      const found = this.findFirstRowArray(child, depth + 1);
      if (found.length > 0) {
        return found;
      }
    }
    const keys = Object.keys(value);
    for (let index = 0; index < keys.length; index += 1) {
      const found = this.findFirstRowArray(value[keys[index]], depth + 1);
      if (found.length > 0) {
        return found;
      }
    }
    return [];
  }

  readItemValue(row: any, fieldName: string) {
    if (!row || !fieldName) {
      return '';
    }
    const directValue = this.readObjectValueByKey(row, fieldName);
    if (directValue !== undefined && directValue !== null) {
      return this.normalizeCellValue(directValue);
    }
    const fieldMaps = [row.values, row.Values, row.fields, row.Fields, row.data, row.Data, row.item, row.Item];
    for (let mapIndex = 0; mapIndex < fieldMaps.length; mapIndex += 1) {
      const map = fieldMaps[mapIndex];
      if (map && !Array.isArray(map)) {
        const mappedValue = this.readObjectValueByKey(map, fieldName);
        if (mappedValue !== undefined && mappedValue !== null) {
          return this.normalizeCellValue(mappedValue);
        }
      }
    }
    const fieldArrays = [
      row.fieldValues,
      row.FieldValues,
      row.fields,
      row.Fields,
      row.values,
      row.Values,
      row.data,
      row.Data,
      row.cells,
      row.Cells,
      row.columns,
      row.Columns,
    ];
    for (let groupIndex = 0; groupIndex < fieldArrays.length; groupIndex += 1) {
      const group = fieldArrays[groupIndex];
      if (!Array.isArray(group)) {
        continue;
      }
      for (let itemIndex = 0; itemIndex < group.length; itemIndex += 1) {
        const field = group[itemIndex] || {};
        const fieldId = this.toCleanString(
          field.fieldId !== undefined ? field.fieldId :
          field.FieldId !== undefined ? field.FieldId :
          field.FieldID !== undefined ? field.FieldID :
          field.id !== undefined ? field.id :
          field.Id !== undefined ? field.Id :
          field.ID !== undefined ? field.ID :
          field.name !== undefined ? field.name :
          field.Name !== undefined ? field.Name :
          field.fieldName !== undefined ? field.fieldName :
          field.FieldName !== undefined ? field.FieldName :
          field.fieldCode !== undefined ? field.fieldCode :
          field.FieldCode !== undefined ? field.FieldCode :
          field.code !== undefined ? field.code :
          field.Code !== undefined ? field.Code :
          field.key !== undefined ? field.key :
          field.Key !== undefined ? field.Key : ''
        );
        if (this.sameFieldName(fieldId, fieldName)) {
          return this.normalizeCellValue(
            field.value !== undefined ? field.value :
            field.Value !== undefined ? field.Value :
            field.fieldValue !== undefined ? field.fieldValue :
            field.FieldValue !== undefined ? field.FieldValue :
            field.dataValue !== undefined ? field.dataValue :
            field.DataValue !== undefined ? field.DataValue :
            field.valueText !== undefined ? field.valueText :
            field.ValueText !== undefined ? field.ValueText :
            field.text !== undefined ? field.text :
            field.Text !== undefined ? field.Text :
            field.display !== undefined ? field.display :
            field.Display !== undefined ? field.Display :
            field.label !== undefined ? field.label :
            field.Label !== undefined ? field.Label :
            field.name !== undefined ? field.name :
            field.Name !== undefined ? field.Name : ''
          );
        }
      }
    }
    return '';
  }

  readObjectValueByKey(source: any, wantedKey: string) {
    if (!source || typeof source !== 'object' || Array.isArray(source) || !wantedKey) {
      return undefined;
    }
    if (source[wantedKey] !== undefined) {
      return source[wantedKey];
    }
    const keys = Object.keys(source);
    for (let index = 0; index < keys.length; index += 1) {
      if (this.sameFieldName(keys[index], wantedKey)) {
        return source[keys[index]];
      }
    }
    return undefined;
  }

  sameFieldName(left: any, right: any) {
    const leftText = this.toCleanString(left);
    const rightText = this.toCleanString(right);
    if (!leftText || !rightText) {
      return false;
    }
    return leftText === rightText || leftText.toLowerCase() === rightText.toLowerCase();
  }

  normalizeCellValue(value: any) {
    if (value === undefined || value === null) {
      return '';
    }
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
      return value;
    }
    if (Array.isArray(value)) {
      return value.map((item) => this.normalizeCellValue(item)).filter(Boolean).join(', ');
    }
    if (typeof value === 'object') {
      return (
        value.value !== undefined ? value.value :
        value.Value !== undefined ? value.Value :
        value.fieldValue !== undefined ? value.fieldValue :
        value.FieldValue !== undefined ? value.FieldValue :
        value.dataValue !== undefined ? value.dataValue :
        value.DataValue !== undefined ? value.DataValue :
        value.valueText !== undefined ? value.valueText :
        value.ValueText !== undefined ? value.ValueText :
        value.text !== undefined ? value.text :
        value.Text !== undefined ? value.Text :
        value.display !== undefined ? value.display :
        value.Display !== undefined ? value.Display :
        value.label !== undefined ? value.label :
        value.Label !== undefined ? value.Label :
        value.name !== undefined ? value.name :
        value.Name !== undefined ? value.Name :
        value.title !== undefined ? value.title :
        value.Title !== undefined ? value.Title : ''
      );
    }
    return value;
  }

  addMatchedItem(option: LookupOption) {
    const config = this.getConfig();
    if (this.props.readonly || !option) {
      return;
    }
    const item = {
      display: option.display,
      value: option.value,
      isManual: false,
      source: 'selected' as 'selected',
      listDataId: option.listDataId,
    };
    const nextItems = config.multiSelect ? this.dedupeItems(this.state.selectedItems.concat([item])) : [item];
    this.setState({
      selectedItems: nextItems,
      keyword: '',
      options: [],
      dropdownOpen: false,
      errorText: '',
    }, () => this.persistSelectedItems(nextItems));
  }

  addManualItem(text: string) {
    const config = this.getConfig();
    const cleaned = this.toCleanString(text);
    if (this.props.readonly || !config.allowManualEntry || !cleaned) {
      return;
    }
    const item = {
      display: cleaned,
      value: cleaned,
      isManual: true,
      source: 'manual' as 'manual',
    };
    const nextItems = config.multiSelect ? this.dedupeItems(this.state.selectedItems.concat([item])) : [item];
    this.setState({
      selectedItems: nextItems,
      keyword: '',
      options: [],
      dropdownOpen: false,
      errorText: '',
    }, () => this.persistSelectedItems(nextItems));
  }

  removeItem(indexToRemove: number) {
    if (this.props.readonly) {
      return;
    }
    const nextItems = this.state.selectedItems.filter((item: LookupItem, index: number) => index !== indexToRemove);
    this.setState({ selectedItems: nextItems }, () => this.persistSelectedItems(nextItems));
  }

  persistSelectedItems(items: LookupItem[]) {
    const config = this.getConfig();
    const normalizedItems = this.dedupeItems(items).map((item: LookupItem) => ({
      display: item.display,
      value: item.value,
      isManual: item.isManual === true,
      source: item.isManual === true ? 'manual' : 'selected',
    }));
    const matchedValues = normalizedItems
      .filter((item: LookupItem) => item.isManual !== true)
      .map((item: LookupItem) => item.value);
    const manualValues = normalizedItems
      .filter((item: LookupItem) => item.isManual === true)
      .map((item: LookupItem) => item.value);

    this.logSaveDebug('persist payload', {
      rawTargets: {
        saveToField: config.saveToTarget.raw,
        selectedItemsField: config.selectedItemsTarget.raw,
        newItemsField: config.newItemsTarget.raw,
      },
      resolvedTargets: {
        saveToField: config.saveToTarget.candidates,
        selectedItemsField: config.selectedItemsTarget.candidates,
        newItemsField: config.newItemsTarget.candidates,
      },
      normalizedItems: normalizedItems,
      matchedValues: matchedValues,
      manualValues: manualValues,
    });

    if (config.saveToTarget.candidates.length > 0) {
      this.writeTargetValue(config.saveToTarget, JSON.stringify(normalizedItems));
    }
    if (config.selectedItemsTarget.candidates.length > 0) {
      this.writeTargetValue(config.selectedItemsTarget, JSON.stringify(matchedValues));
    }
    if (config.newItemsTarget.candidates.length > 0) {
      this.writeTargetValue(config.newItemsTarget, JSON.stringify(manualValues));
    }
  }

  writeTargetValue(target: WriteTargetInfo, value: any) {
    const candidates = target && target.candidates ? target.candidates : [];
    let saved = false;
    for (let index = 0; index < candidates.length; index += 1) {
      const candidate = candidates[index];
      if (this.writeFieldValue(candidate, value, target.paramName)) {
        saved = true;
        break;
      }
    }
    this.logSaveDebug('write result', {
      paramName: target && target.paramName,
      candidates: candidates,
      succeeded: saved,
      value: value,
    });
    return saved;
  }

  writeFieldValue(fieldName: string, value: any, paramName?: string) {
    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',
      'setDashboardVariableValue',
      'setPageVariableValue',
      'setFilterVariableValue',
      'setFilterValue',
      'updateFieldValue',
      'updateVariableValue',
    ];
    const setterHosts = this.getSetterHosts(context);

    for (let hostIndex = 0; hostIndex < setterHosts.length; hostIndex += 1) {
      const hostInfo = setterHosts[hostIndex];
      const host = hostInfo.host;
      for (let writerIndex = 0; writerIndex < writerNames.length; writerIndex += 1) {
        const writerName = writerNames[writerIndex];
        const writer = host && host[writerName];
        if (typeof writer === 'function') {
          try {
            const result = writer.call(host, fieldName, value);
            this.logSaveDebug('setter succeeded', {
              paramName: paramName,
              target: fieldName,
              host: hostInfo.name,
              writer: writerName,
              result: result,
              value: value,
            });
            if (result === false) {
              continue;
            }
            return true;
          } catch (error) {
            this.logSaveDebug('setter failed', {
              paramName: paramName,
              target: fieldName,
              host: hostInfo.name,
              writer: writerName,
              error: this.stringifyForLog(error),
            });
          }
        }
      }
    }

    const objectSetterNames = ['setFieldsValue', 'setFieldValues', 'setValues', 'setVariables', 'setTempVariables'];
    for (let hostIndex = 0; hostIndex < setterHosts.length; hostIndex += 1) {
      const hostInfo = setterHosts[hostIndex];
      const host = hostInfo.host;
      for (let writerIndex = 0; writerIndex < objectSetterNames.length; writerIndex += 1) {
        const writerName = objectSetterNames[writerIndex];
        const writer = host && host[writerName];
        if (typeof writer === 'function') {
          try {
            const payload: any = {};
            payload[fieldName] = value;
            const result = writer.call(host, payload);
            this.logSaveDebug('object setter succeeded', {
              paramName: paramName,
              target: fieldName,
              host: hostInfo.name,
              writer: writerName,
              result: result,
              value: value,
            });
            if (result === false) {
              continue;
            }
            return true;
          } catch (error) {
            this.logSaveDebug('object setter failed', {
              paramName: paramName,
              target: fieldName,
              host: hostInfo.name,
              writer: writerName,
              error: this.stringifyForLog(error),
            });
          }
        }
      }
    }

    fieldsValues[fieldName] = value;
    this.logSaveDebug('fieldsValues fallback used', {
      paramName: paramName,
      target: fieldName,
      value: 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.dashboardContext', context && context.dashboardContext);
    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;
  }

  dedupeItems(items: LookupItem[]) {
    const seen: any = {};
    const result: LookupItem[] = [];
    (items || []).forEach((item: LookupItem) => {
      if (!item) {
        return;
      }
      const display = this.toCleanString(item.display || item.value);
      const value = this.toCleanString(item.value || item.display);
      if (!display && !value) {
        return;
      }
      const isManual = item.isManual === true || item.source === 'manual';
      const key = (isManual ? 'manual|' : 'selected|') + this.normalizeText(value || display);
      if (seen[key]) {
        return;
      }
      seen[key] = true;
      result.push({
        display: display || value,
        value: value || display,
        isManual: isManual,
        source: isManual ? 'manual' : 'selected',
        listDataId: this.toCleanString(item.listDataId),
      });
    });
    return result;
  }

  isSelected(value: string, isManual: boolean) {
    const key = (isManual ? 'manual|' : 'selected|') + this.normalizeText(value);
    return this.state.selectedItems.some((item: LookupItem) => {
      const itemKey = (item.isManual ? 'manual|' : 'selected|') + this.normalizeText(item.value);
      return itemKey === key;
    });
  }

  resolveWriteTarget(paramName: string, rawValue: any): WriteTargetInfo {
    return this.resolveWriteTargetFromProps(this.props, paramName, rawValue);
  }

  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));
    const displayValue = candidates.length > 0 ? candidates[0] : this.toCleanString(rawValue);
    const target = {
      paramName: paramName,
      raw: rawValue,
      candidates: candidates,
      displayValue: displayValue,
    };

    /*
      DEBUG - Smart Lookup Picker target resolution.
      Keep temporarily while validating Yeeflow expression-editor variable bindings.
      This logs raw output-target parameters and the writable target candidates extracted
      from plain strings, variable-picker objects, expression wrapper objects, and temp
      variable metadata where available.
    */
    this.logSaveDebug('resolve target', {
      paramName: paramName,
      raw: rawValue,
      requiredFieldFallbackTarget: requiredFieldFallbackTarget,
      candidates: candidates,
      contextMethodSummary: this.getContextMethodSummary(props && props.context),
    });

    return target;
  }

  getRequiredFieldFallbackTarget(props: any, paramName: string) {
    /*
      Fallback for Yeeflow dashboard/page variable selectors:
      requiredFields(params) returns the three configured output targets in order. In
      some runtime placements the CodeInApplication instance used for requiredFields()
      is not the same instance used for render(), so the stored target map may not
      survive. fieldsValues still carries the required field/temp variable keys, so
      we can safely use the ordered keys as a write-target fallback.
    */
    const fieldsValues = (props && props.fieldsValues) || {};
    const keys = Object.keys(fieldsValues).filter((key) => this.isLikelyWritableTargetName(key));
    const indexMap: any = {
      saveToField: 0,
      selectedItemsField: 1,
      newItemsField: 2,
    };
    const index = indexMap[paramName];
    return index !== undefined && keys[index] ? keys[index] : '';
  }

  isLikelyWritableTargetName(candidate: any) {
    const value = this.toCleanString(candidate);
    if (!value) {
      return false;
    }
    if (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;
  }

  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;
  }

  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;
      }

      /*
        For write targets selected from Yeeflow's variable dropdown, `value` may be
        the current variable value (often blank), while key/name/label/id metadata
        identifies the variable to update. Therefore target-like keys are collected
        before value-like keys.
      */
      [
        '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',
        'bind',
        'Bind',
        '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;
  }

  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;
  }

  toCleanString(value: any) {
    return this.normalizeExpressionValue(value);
  }

  normalizeText(value: any) {
    return this.toCleanString(value).toLowerCase();
  }

  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 =
        value.value !== undefined ? value.value :
        value.Value !== undefined ? value.Value :
        value.key !== undefined ? value.key :
        value.Key !== undefined ? value.Key :
        value.checked !== undefined ? value.checked :
        value.Checked !== undefined ? value.Checked :
        value.selected !== undefined ? value.selected :
        value.Selected !== undefined ? value.Selected :
        value.label !== undefined ? value.label :
        value.Label !== undefined ? value.Label : undefined;
      if (objectValue !== undefined) {
        return this.toBoolean(objectValue, fallback);
      }
    }
    const cleaned = this.normalizeText(value);
    if (['true', '1', 'yes', 'y', 'on'].indexOf(cleaned) !== -1) {
      return true;
    }
    if (['false', '0', 'no', 'n', 'off'].indexOf(cleaned) !== -1) {
      return false;
    }
    return fallback;
  }

  normalizeExpressionValue(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)) {
      if (value.length === 0) {
        return '';
      }
      return value.map((item) => this.normalizeExpressionValue(item)).filter(Boolean).join(',');
    }
    if (typeof value === 'object') {
      const resolved =
        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 : undefined;
      if (resolved !== undefined) {
        return this.normalizeExpressionValue(resolved);
      }
      try {
        return JSON.stringify(value);
      } catch (error) {
        return '';
      }
    }
    return String(value).trim();
  }

  toPositiveInt(value: any, fallback: number) {
    const parsed = parseInt(String(value || ''), 10);
    return parsed > 0 ? parsed : fallback;
  }

  uniqueArray(values: string[]) {
    const seen: any = {};
    return values.filter((value) => {
      const cleaned = this.toCleanString(value);
      if (!cleaned || seen[cleaned]) {
        return false;
      }
      seen[cleaned] = true;
      return true;
    });
  }

  logSaveDebug(message: string, payload?: any) {
    /*
      DEBUG - Smart Lookup Picker save diagnostics.
      Remove or guard this method after validating output-target behavior across:
      - approval form fields
      - data list form fields
      - dashboard temp variables
      - expression-editor variable picker bindings
    */
    try {
      if (typeof window !== 'undefined') {
        const debugWindow: any = window as any;
        debugWindow.__SmartLookupPickerDebug = debugWindow.__SmartLookupPickerDebug || [];
        debugWindow.__SmartLookupPickerDebug.push({
          message: message,
          payload: payload,
          time: new Date().toISOString(),
        });
        if (debugWindow.__SmartLookupPickerDebug.length > 80) {
          debugWindow.__SmartLookupPickerDebug.shift();
        }
      }
      if (typeof console !== 'undefined' && console && typeof console.log === 'function') {
        console.log('[SmartLookupPicker debug] ' + message, payload);
      }
    } catch (error) {
      /* Debug logging must never break the control. */
    }
  }

  stringifyForLog(value: any) {
    if (value === undefined || value === null) {
      return '';
    }
    if (typeof value === 'string') {
      return value;
    }
    if (value && value.message) {
      return value.message;
    }
    try {
      return JSON.stringify(value);
    } catch (error) {
      return String(value);
    }
  }

  getContextMethodSummary(context: any) {
    const summary: any = {};
    const hosts = this.getSetterHosts(context || {});
    hosts.forEach((hostInfo) => {
      const host = hostInfo.host;
      const methods: string[] = [];
      if (host) {
        [
          'setFieldValue',
          'setFormFieldValue',
          'setValue',
          'setVariableValue',
          'setTempVariableValue',
          'setTempVariable',
          'setDashboardVariableValue',
          'setPageVariableValue',
          'setFilterVariableValue',
          'setFieldsValue',
          'setVariables',
          'setTempVariables',
        ].forEach((name) => {
          if (typeof host[name] === 'function') {
            methods.push(name);
          }
        });
      }
      summary[hostInfo.name] = methods;
    });
    return summary;
  }

  renderSelectedChips(config: any) {
    if (!this.state.selectedItems.length) {
      return null;
    }
    return (
      <div className="slp-chip-row">
        {this.state.selectedItems.map((item: LookupItem, index: number) => (
          <span className={item.isManual ? 'slp-chip slp-chip-manual' : 'slp-chip'} key={(item.isManual ? 'm-' : 's-') + item.value + '-' + index}>
            <span className="slp-chip-text">{item.display}</span>
            {item.isManual ? <span className="slp-chip-badge">{config.manualTagText}</span> : null}
            {!this.props.readonly ? (
              <button type="button" className="slp-chip-remove" onClick={() => this.removeItem(index)} aria-label={'Remove ' + item.display}>
                x
              </button>
            ) : null}
          </span>
        ))}
      </div>
    );
  }

  renderDropdown(config: any) {
    if (!this.state.dropdownOpen || this.props.readonly) {
      return null;
    }
    const hasKeyword = Boolean(this.toCleanString(this.state.keyword));
    return (
      <div className="slp-dropdown">
        {this.state.loading ? <div className="slp-status">Searching...</div> : null}
        {!this.state.loading && this.state.errorText ? <div className="slp-status slp-status-error">{this.state.errorText}</div> : null}
        {!this.state.loading && !this.state.errorText && hasKeyword && this.state.options.length === 0 ? (
          <div className="slp-status">{config.noResultText}</div>
        ) : null}
        {!this.state.loading && !this.state.errorText && this.state.options.map((option: LookupOption) => (
          <button type="button" className="slp-option" key={option.value + '-' + option.listDataId} onMouseDown={(event) => event.preventDefault()} onClick={() => this.addMatchedItem(option)}>
            {config.multiSelect ? <span className="slp-checkbox" aria-hidden="true" /> : null}
            <span className="slp-option-main">
              <span className="slp-option-title">{option.display}</span>
              <span className="slp-option-subtitle">{option.value}</span>
            </span>
          </button>
        ))}
        {!this.state.loading && !this.state.errorText && config.allowManualEntry && hasKeyword ? (
          <button type="button" className="slp-manual-row" onMouseDown={(event) => event.preventDefault()} onClick={() => this.addManualItem(this.state.keyword)}>
            Add "{this.toCleanString(this.state.keyword)}" as manual entry
          </button>
        ) : null}
      </div>
    );
  }

  render() {
    const config = this.getConfig();
    const readonly = this.props.readonly === true;
    const isConfigMissing = !config.dataListId || !config.displayField || !config.valueField;
    return (
      <div className={readonly ? 'slp-root slp-readonly' : 'slp-root'} ref={(node) => { this.wrapperRef = node; }}>
        <style dangerouslySetInnerHTML={{ __html: smartLookupPickerStyles }} />
        {config.labelText ? <div className="slp-label">{config.labelText}</div> : null}
        <div className={isConfigMissing ? 'slp-shell slp-shell-warning' : 'slp-shell'}>
          {this.renderSelectedChips(config)}
          {!readonly ? (
            <input
              className="slp-input"
              value={this.state.keyword}
              placeholder={config.placeholderText}
              onChange={this.handleInputChange}
              onFocus={this.handleInputFocus}
              onKeyDown={this.handleInputKeyDown}
              disabled={readonly}
            />
          ) : null}
          {readonly && !this.state.selectedItems.length ? <div className="slp-readonly-empty">No lookup item selected</div> : null}
        </div>
        {isConfigMissing && !readonly ? (
          <div className="slp-help">Configure dataListId, displayField, and valueField to enable lookup search.</div>
        ) : null}
        {this.renderDropdown(config)}
        {/*
          Testing checklist:
          - single select and multi select
          - manual entry with Enter
          - remove selected item
          - save combined output JSON
          - save matched-only output values
          - save manual-only output text
          - clear input after selection
          - no duplicate first item bug
          - readonly mode
          - no result state
          - invalid saved JSON state
        */}
      </div>
    );
  }
}

const smartLookupPickerStyles = [
  '.slp-root{position:relative;width:100%;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;color:#172033;}',
  '.slp-label{margin-bottom:8px;color:#172033;font-size:13px;font-weight:600;line-height:1.35;}',
  '.slp-shell{min-height:42px;display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:7px 9px;background:#ffffff;border:1px solid #d8e1ee;border-radius:8px;transition:border-color .16s ease,box-shadow .16s ease,background .16s ease;}',
  '.slp-shell:focus-within{border-color:#146ff6;box-shadow:0 0 0 3px rgba(20,111,246,.1);}',
  '.slp-shell-warning{border-color:#f2c36b;background:#fffaf0;}',
  '.slp-input{flex:1 1 180px;min-width:120px;height:28px;border:0;outline:none;background:transparent;color:#172033;font-size:14px;line-height:28px;}',
  '.slp-input::placeholder{color:#8a97aa;}',
  '.slp-chip-row{display:flex;flex-wrap:wrap;gap:6px;}',
  '.slp-chip{display:inline-flex;max-width:100%;align-items:center;gap:6px;min-height:28px;padding:3px 6px 3px 10px;border:1px solid #cfe0ff;border-radius:999px;background:#edf4ff;color:#0d4fb3;font-size:12px;font-weight:500;line-height:18px;}',
  '.slp-chip-manual{border-color:#d9dee7;background:#f5f7fa;color:#4f5d73;}',
  '.slp-chip-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
  '.slp-chip-badge{padding:1px 5px;border-radius:999px;background:#ffffff;color:#6b778c;font-size:10px;font-weight:600;}',
  '.slp-chip-remove{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;cursor:pointer;font-size:13px;line-height:1;}',
  '.slp-chip-remove:hover{background:rgba(20,111,246,.12);}',
  '.slp-dropdown{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 6px);max-height:280px;overflow:auto;padding:6px;border:1px solid #d8e1ee;border-radius:8px;background:#ffffff;box-shadow:0 10px 24px rgba(21,33,51,.12);}',
  '.slp-option,.slp-manual-row{width:100%;display:flex;align-items:center;gap:10px;padding:9px 10px;border:0;border-radius:6px;background:transparent;color:#172033;text-align:left;cursor:pointer;}',
  '.slp-option:hover,.slp-manual-row:hover{background:#f3f7ff;}',
  '.slp-checkbox{width:15px;height:15px;flex:0 0 15px;border:1px solid #aebbd0;border-radius:4px;background:#ffffff;}',
  '.slp-option-main{min-width:0;display:flex;flex-direction:column;gap:2px;}',
  '.slp-option-title{overflow:hidden;color:#172033;font-size:13px;font-weight:600;line-height:18px;text-overflow:ellipsis;white-space:nowrap;}',
  '.slp-option-subtitle{overflow:hidden;color:#7a879a;font-size:12px;line-height:16px;text-overflow:ellipsis;white-space:nowrap;}',
  '.slp-manual-row{margin-top:4px;border-top:1px solid #eef2f7;border-radius:0 0 6px 6px;color:#4f5d73;font-size:13px;}',
  '.slp-status{padding:11px 10px;color:#6b778c;font-size:13px;line-height:18px;}',
  '.slp-status-error{color:#b42318;}',
  '.slp-help{margin-top:6px;color:#8a6200;font-size:12px;line-height:16px;}',
  '.slp-readonly .slp-shell{background:#f8fafc;}',
  '.slp-readonly-empty{color:#8a97aa;font-size:13px;line-height:24px;}',
  '@media (max-width:520px){.slp-shell{padding:7px;}.slp-input{flex-basis:100%;}.slp-dropdown{max-height:240px;}}',
].join('');

User guide

Download guide
# Smart Lookup Picker User Guide

## A. Template name

Smart Lookup Picker

## B. Short description

Smart Lookup Picker is a reusable Yeeflow custom code control that lets users search a Yeeflow data list, select one or more matching records, optionally add manual entries, and save the result back into configured form fields.

## C. Purpose / what this is for

Many business forms need users to choose an existing supplier, customer, product, employee, department, contract, asset, or partner from a master list. A normal text field is too loose, and a static dropdown can become hard to use when the list is large.

Smart Lookup Picker solves this by giving users a search-based picker that queries a configured Yeeflow data list as they type. Users can select matched records, see selected values as chips, remove mistakes, and optionally enter a new/manual value when the right record does not exist yet.

Customers use this control when they want cleaner structured input, fewer spelling mistakes, better master-data consistency, and a faster form experience.

## D. Supported placement

This template is optimized for:

- Approval form
- Data list form
- Dashboard page, when output values are written to dashboard temp variables

It is a data-entry/search control, not a dashboard analytics component.

## E. When to use this control

Use Smart Lookup Picker when users need to search and select records from a Yeeflow data list inside a form.

Best-fit scenarios include:

- selecting a supplier in a purchase request
- selecting a customer in a service case
- selecting one or more products in a request form
- selecting a department, employee, asset, contract, or partner from a master list
- allowing users to enter a new supplier/customer name when it is not yet in the master data

Do not use this control when the option list is very small and a normal dropdown is enough.

## F. Input parameters overview table

| Parameter name | Type | Required | Purpose | Example value |
| --- | --- | --- | --- | --- |
| `dataListId` | Expression / variable | Required | Target Yeeflow data list to search. | Supplier Master list ID |
| `displayField` | Expression / variable | Required | Field used for display text and search matching. | `Title` |
| `valueField` | Expression / variable | Required | Field value saved for selected matched records. | `ListDataID` |
| `saveToField` | Expression / variable selector | Optional, recommended | Current form field or dashboard temp variable that stores the full combined JSON output. | `SupplierLookupJson` |
| `selectedItemsField` | Expression / variable selector | Optional | Current form field or dashboard temp variable that stores matched selected values only. | `SelectedSupplierIds` |
| `newItemsField` | Expression / variable selector | Optional | Current form field or dashboard temp variable that stores manual/free-text entries only. | `NewSupplierNames` |
| `multiSelect` | Expression / variable | Optional | Enables selecting more than one matched or manual item. | `true` |
| `allowManualEntry` | Expression / variable | Optional | Allows users to add typed text as a manual item. | `true` |
| `maxResults` | String | Optional | Maximum number of suggestions shown. | `8` |
| `placeholderText` | String | Optional | Placeholder shown in the search input. | `Search supplier` |
| `labelText` | String | Optional | Label shown above the control. | `Supplier` |
| `noResultText` | String | Optional | Message shown when no records match. | `No suppliers found` |
| `manualTagText` | String | Optional | Badge text shown on manual-entry chips. | `New` |
| `minSearchChars` | String | Optional | Minimum typed characters before search runs. | `2` |
| `debounceMs` | String | Optional | Delay before querying after the user types. | `300` |

## G. Detailed parameter explanation

### `dataListId`

The Yeeflow data list ID that the control searches.

Configure this with the source list that contains the selectable records, such as Supplier Master, Customer Master, Product Master, Employee List, or Asset Register.

This parameter uses the expression editor type, so it can come from a fixed value, form field, temp variable, complex variable, or expression. The final resolved value should be the target data list ID.

### `displayField`

The field shown to users in the suggestion list and selected chips. It is also the field used for keyword matching.

For many Yeeflow lists, `Title` is a valid display field. You can also use a business field such as Supplier Name, Customer Name, Product Name, Employee Name, or Asset Name.

This parameter supports expression-editor values. If an expression returns an object, the template attempts to use common keys such as `value`, `key`, `fieldId`, `name`, or `label`.

### `valueField`

The field saved as the selected value for matched records.

Use a stable identifier whenever possible. Common choices are:

- `ListDataID`
- supplier code
- customer code
- product ID
- employee ID
- asset number

Do not use display text here unless the display text is also the intended saved value. The matched-only output saves this value, not the display text.

### `saveToField`

The current form field used to store the full combined JSON result.

This output includes all selected matched records and manual entries. It is useful when later steps need both display text and structured source information.

Recommended field type: multiline text, JSON/text, or another text field that can store JSON.

Configure this by selecting the target field or dashboard temp variable from the selector. The template captures the selected writable target during `requiredFields(params)`, because Yeeflow may later pass only the variable's current value into `context.params` during Preview/runtime.

Example saved value:

```json
[
  {
    "display": "Partner A",
    "value": "2041029741761015809",
    "isManual": false,
    "source": "selected"
  },
  {
    "display": "custom supplier x",
    "value": "custom supplier x",
    "isManual": true,
    "source": "manual"
  }
]
```

### `selectedItemsField`

The current form field used to store matched selected values only.

Manual entries are not saved here. This field is useful for workflow logic, integration, reporting, or downstream automation that only needs selected source-list IDs/codes.

Example saved value:

```json
["2041029741761015809", "2041029741761015810"]
```

Configure this by selecting the target field or dashboard temp variable from the selector. Manual entries are never saved into this field.

### `newItemsField`

The current form field used to store manual/free-text entries only.

Matched selected records are not saved here. This is useful when the process needs to review or create new master-data records later.

Example saved value:

```json
["abc", "custom supplier x"]
```

Configure this by selecting the target field or dashboard temp variable from the selector. Matched selected records are never saved into this field.

### `multiSelect`

Controls whether users can select multiple items.

Accepted values include:

- `true`
- `false`
- expression returning a boolean
- expression returning an object with a boolean-like `value`, `key`, `checked`, `selected`, or `label`

Use `true` for product lists, email-style selections, multiple assets, or multiple departments. Use `false` when the form should only have one selected supplier, customer, contract, or employee.

### `allowManualEntry`

Controls whether users can add typed text as a manual item.

When enabled, users can type a value and press Enter or click the manual-entry row. Manual entries are visually marked with the configured `manualTagText`.

Use `true` when users may request a new supplier, customer, product, or partner. Use `false` when users must select an existing approved record.

### `maxResults`

Maximum number of matching suggestions shown in the dropdown.

Recommended values:

- `5` for compact forms
- `8` for most forms
- `10` or `15` for broader lookup scenarios

### `placeholderText`

Placeholder text shown in the input before the user types.

Use clear business wording, such as:

- `Search supplier`
- `Search customer`
- `Search product or SKU`
- `Search employee`

### `labelText`

Optional label shown above the control.

Use this when the custom code control itself needs a visible title. Leave it blank if the Yeeflow form already provides a field label near the control.

### `noResultText`

Message shown when no records match the typed keyword.

Examples:

- `No suppliers found`
- `No matching customers`
- `No products found`

### `manualTagText`

Small badge text shown on manual-entry chips.

Examples:

- `Manual`
- `New`
- `Custom`

### `minSearchChars`

Minimum number of characters the user must type before the control queries the data list.

Recommended values:

- `1` for small lists
- `2` for most master-data lists
- `3` for very large lists

### `debounceMs`

Delay in milliseconds before the search query runs after typing.

Recommended values:

- `260` for normal use
- `300` to `500` for slower or larger lists

Higher values reduce query frequency but make the search feel slightly slower.

## H. Step-by-step setup guide

1. Add a custom code control to the approval form or data list form.
2. Paste or upload the `smart-lookup-picker.tsx` code.
3. Open the input parameter settings.
4. Set `dataListId` to the Yeeflow data list you want to search.
5. Set `displayField` to the field users should see and search, such as `Title`.
6. Set `valueField` to the stable value to save, such as `ListDataID`.
7. Create or choose output fields on the current form, or temp variables on a dashboard page, for `saveToField`, `selectedItemsField`, and/or `newItemsField`.
8. Use the selector on those three output parameters to choose the writable field or temp variable.
9. Configure `multiSelect` as `true` or `false`.
10. Configure `allowManualEntry` as `true` or `false`.
11. Set user-facing text such as `placeholderText`, `labelText`, `noResultText`, and `manualTagText`.
12. Save the form or dashboard page.
13. Test search, select, remove, manual entry, and saved output fields.

## I. Result / expected output

After setup, users will see a clean lookup input with optional label text. As they type, the control searches the configured Yeeflow data list and shows matching records in a dropdown panel.

When users select records, selected values appear as chips. In multi-select mode, multiple chips can be added. In single-select mode, selecting a new item replaces the previous selected item.

If manual entry is enabled, users can add typed text as a manual chip. Manual chips look slightly different from matched data-list chips.

The control can save three outputs:

- full combined JSON result into `saveToField`
- matched selected values only into `selectedItemsField`
- manual/free-text values only into `newItemsField`

The saved values update immediately when users select or remove items.

## J. Real business examples

### Supplier picker for purchase requests

Users search the Supplier Master list and select the supplier for a purchase request. If the supplier is not found, manual entry can be enabled so users can enter a proposed new supplier name.

### Customer picker for service cases

Users search the Customer Master list and select the customer linked to a support case. Manual entry can be disabled to ensure only approved customer records are used.

### Product picker for internal requests

Users search a Product Master list and select one or more products. Multi-select can be enabled so several products can be attached to one request.

### Asset picker for maintenance forms

Users search an Asset Register and select the asset being repaired or inspected. The saved value can use `ListDataID` or a business asset number field.

## K. Notes / assumptions / limitations

- `dataListId`, `displayField`, and `valueField` must resolve to usable values at runtime.
- `displayField` can be `Title` if that is the correct display field in the source list.
- `valueField` should normally be `ListDataID` or a stable business ID/code field.
- The template first attempts a filtered data-list query. If that returns no usable rows, it attempts a limited broader query and performs local matching on the display field.
- Search behavior depends on Yeeflow list query permissions and the fields exposed by the selected data list.
- The control is optimized for approval forms and data list forms, and can also be used on dashboard pages with temp variables.
- On dashboard pages, use temp variables as output targets and select them directly in `saveToField`, `selectedItemsField`, and `newItemsField`. The template also falls back to the ordered `requiredFields` / `fieldsValues` keys when Yeeflow evaluates a selected variable into its current value.
- The output fields should be able to store JSON strings.
- Very large lists should use a higher `minSearchChars` value to reduce unnecessary queries.

## L. Testing checklist

- Search by a known display value.
- Select one matched item.
- If `multiSelect` is enabled, select multiple matched items.
- If `allowManualEntry` is enabled, type a new value and press Enter.
- Remove a selected chip.
- Confirm the input clears after selection.
- Confirm `saveToField` stores the combined JSON result.
- Confirm `selectedItemsField` stores only matched selected values.
- Confirm `newItemsField` stores only manual entries.
- Test a no-result search.
- Test readonly mode.
- Test an existing saved JSON value if editing an existing record.

## M. Troubleshooting

### No results appear when typing

Check that `dataListId` points to the correct data list and that the user has permission to read it. Also confirm `displayField` is the correct field ID/name, such as `Title`.

### Results appear but selected output is wrong

Check `valueField`. If you want to save the Yeeflow record ID, use `ListDataID`. If you want a business code, use the correct code field.

### Selected values do not save

Check that `saveToField`, `selectedItemsField`, and/or `newItemsField` point to valid current form fields. The fields should support text/JSON-style values.

On dashboard pages, choose the target temp variables directly in `saveToField`, `selectedItemsField`, and `newItemsField`. The template uses `requiredFields(params)` and the ordered `fieldsValues` keys to preserve those selected target names before Yeeflow evaluates them into current values during Preview/runtime.

### Manual entry does not work

Confirm `allowManualEntry` resolves to `true`. If it is coming from an expression, verify the expression returns a boolean or a clear true-like value.

### Only one item can be selected

Confirm `multiSelect` resolves to `true`. If it is `false` or blank, the control behaves as single-select.

### The control says lookup configuration is incomplete

At least `dataListId`, `displayField`, and `valueField` must be configured and resolve to non-empty values.

### Search feels slow

Increase `debounceMs` only if the source list is slow or large. For better perceived speed, keep `debounceMs` around `260` to `300` and set `minSearchChars` to `2` or `3`.

Example configuration

Download config
# Smart Lookup Picker Example Config

| Parameter | Example Value |
| --- | --- |
| `dataListId` | `2034150397060198400` |
| `displayField` | `Title` |
| `valueField` | `ListDataID` |
| `saveToField` | Select writable field or temp variable, such as `Selected_Value` |
| `selectedItemsField` | Select writable field or temp variable, such as `selectedItemsField` |
| `newItemsField` | Select writable field or temp variable, such as `newItemsField` |
| `multiSelect` | `true` |
| `allowManualEntry` | `true` |
| `maxResults` | `8` |
| `placeholderText` | `Search supplier name` |
| `labelText` | `Supplier` |
| `noResultText` | `No matching records found` |
| `manualTagText` | `New` |

Use the variable selector for writable targets rather than typing plain display text when possible.