Distribution Chart Module

Display category distribution, workload spread, or status mix in a reusable dashboard chart module.

Custom CodeUI Design & LayoutsYeeflow
custom codedistribution chartanalyticsdashboardtsx

What this template helps teams build

Overview

The Distribution Chart Module template is a reusable custom code control concept for Yeeflow builders. It helps teams add a focused interface pattern for distribution analytics and dashboard reporting while keeping the asset in review before public launch.

Key capabilities

  • Supports dashboard & analytics scenarios
  • Designed for use in Dashboard, Application page
  • Includes TSX source, user guidance, and example configuration
  • Prepared as a preview asset for technical review and future public documentation

Recommended use cases

  • distribution analytics and dashboard reporting
  • Dashboard & Analytics 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 DistributionItem = {
  label: string;
  count: number;
  percent: number;
  color: string;
};

export class CodeInApplication implements CodeInComp {
  description() {
    return 'Distribution Chart Module - reusable Yeeflow dashboard custom code template for grouped category/count analytics.';
  }

  requiredFields(params?: CodeInParams) {
    return [];
  }

  inputParameters(): InputParameter[] {
    return [
      {
        id: 'dataListId',
        name: 'Data List ID',
        type: 'variable',
        desc: 'Target Yeeflow data list ID used as the chart data source. Supports expression editor values.',
      },
      {
        id: 'categoryField',
        name: 'Category Field',
        type: 'variable',
        desc: 'Field ID/name used to group records, such as Status, Department, Region, Owner, or Category.',
      },
      {
        id: 'titleText',
        name: 'Title Text',
        type: 'string',
        desc: 'Optional module title shown above the chart.',
      },
      {
        id: 'subtitleText',
        name: 'Subtitle Text',
        type: 'string',
        desc: 'Optional helper text shown below the title.',
      },
      {
        id: 'chartType',
        name: 'Chart Type',
        type: 'string',
        desc: 'Chart mode: donut, pie, horizontalBar, bar, or verticalBar. Default is donut.',
      },
      {
        id: 'maxCategories',
        name: 'Max Categories',
        type: 'string',
        desc: 'Maximum number of category groups shown before remaining groups are combined as Other. Default is 8.',
      },
      {
        id: 'showLegend',
        name: 'Show Legend',
        type: 'variable',
        desc: 'Whether the legend/count list is shown. Supports true/false or dynamic expression.',
      },
      {
        id: 'showCount',
        name: 'Show Count',
        type: 'variable',
        desc: 'Whether record counts are shown in chart labels and legend rows. Supports true/false or dynamic expression.',
      },
      {
        id: 'emptyText',
        name: 'Empty Text',
        type: 'string',
        desc: 'Text shown when no records or no category values are available.',
      },
      {
        id: 'colorMode',
        name: 'Color Mode',
        type: 'string',
        desc: 'Color palette: yeeflow, soft, status, or slate. Default is yeeflow.',
      },
      {
        id: 'sortMode',
        name: 'Sort Mode',
        type: 'string',
        desc: 'Sort behavior: countDesc, countAsc, labelAsc, labelDesc, or source. Default is countDesc.',
      },
      {
        id: 'filterExpression',
        name: 'Filter Expression',
        type: 'variable',
        desc: 'Optional Yeeflow query filter object/array/JSON from expression editor. Leave blank for all records.',
      },
      {
        id: 'height',
        name: 'Height',
        type: 'string',
        desc: 'Optional chart area height in pixels. Default is 280.',
      },
      {
        id: 'pageSize',
        name: 'Page Size',
        type: 'string',
        desc: 'Maximum records queried for aggregation. Default is 500.',
      },
      {
        id: 'unknownLabel',
        name: 'Unknown Label',
        type: 'string',
        desc: 'Label used when the category field is empty. Default is Unknown.',
      },
      {
        id: 'otherLabel',
        name: 'Other Label',
        type: 'string',
        desc: 'Label used when categories beyond Max Categories are combined. Default is Other.',
      },
    ];
  }

  render(context: CodeInContext, fieldsValues: any, readonly: boolean) {
    return (
      <DistributionChartModule
        context={context}
        fieldsValues={fieldsValues}
        readonly={readonly}
        params={(context && context.params) || {}}
      />
    );
  }
}

class DistributionChartModule extends React.Component<any, any> {
  private mountedFlag: boolean;

  constructor(props: any) {
    super(props);
    this.mountedFlag = false;
    this.state = {
      loading: false,
      errorText: '',
      items: [],
      totalCount: 0,
    };
  }

  componentDidMount() {
    this.mountedFlag = true;
    this.loadData();
  }

  componentDidUpdate(previousProps: any) {
    if (this.getConfigKey(previousProps.params || {}) !== this.getConfigKey(this.props.params || {})) {
      this.loadData();
    }
  }

  componentWillUnmount() {
    this.mountedFlag = false;
  }

  getConfig() {
    const params = this.props.params || {};
    return {
      dataListId: this.normalizeToString(params.dataListId),
      categoryField: this.normalizeToString(params.categoryField),
      titleText: this.normalizeToString(params.titleText) || 'Distribution',
      subtitleText: this.normalizeToString(params.subtitleText),
      chartType: this.normalizeChartType(this.normalizeToString(params.chartType)),
      maxCategories: Math.max(1, this.normalizeToNumber(params.maxCategories, 8)),
      showLegend: this.normalizeToBoolean(params.showLegend, true),
      showCount: this.normalizeToBoolean(params.showCount, true),
      emptyText: this.normalizeToString(params.emptyText) || 'No distribution data available',
      colorMode: this.normalizeColorMode(this.normalizeToString(params.colorMode)),
      sortMode: this.normalizeSortMode(this.normalizeToString(params.sortMode)),
      filterExpression: this.resolveParameterValue(params.filterExpression),
      height: Math.max(180, this.normalizeToNumber(params.height, 280)),
      pageSize: Math.max(20, this.normalizeToNumber(params.pageSize, 500)),
      unknownLabel: this.normalizeToString(params.unknownLabel) || 'Unknown',
      otherLabel: this.normalizeToString(params.otherLabel) || 'Other',
    };
  }

  getConfigKey(params: any) {
    const config = {
      dataListId: this.normalizeToString(params.dataListId),
      categoryField: this.normalizeToString(params.categoryField),
      chartType: this.normalizeToString(params.chartType),
      maxCategories: this.normalizeToString(params.maxCategories),
      colorMode: this.normalizeToString(params.colorMode),
      sortMode: this.normalizeToString(params.sortMode),
      filterExpression: this.normalizeToString(this.resolveParameterValue(params.filterExpression)),
      pageSize: this.normalizeToString(params.pageSize),
    };
    try {
      return JSON.stringify(config);
    } catch (error) {
      return String(new Date().getTime());
    }
  }

  loadData() {
    const config = this.getConfig();
    if (!config.dataListId || !config.categoryField) {
      this.setState({
        loading: false,
        errorText: '',
        items: [],
        totalCount: 0,
      });
      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({
        loading: false,
        errorText: 'Data list service is not available',
        items: [],
        totalCount: 0,
      });
      return;
    }

    this.setState({ loading: true, errorText: '' });
    this.queryListItems(client, config)
      .then((rows: any[]) => {
        const result = this.aggregateRows(rows, config);
        if (this.mountedFlag) {
          this.setState({
            loading: false,
            errorText: '',
            items: result.items,
            totalCount: result.totalCount,
          });
        }
      })
      .catch((error: any) => {
        if (this.mountedFlag) {
          this.setState({
            loading: false,
            errorText: 'Unable to load chart data',
            items: [],
            totalCount: 0,
          });
        }
      });
  }

  queryListItems(client: any, config: any): Promise<any[]> {
    const selectedFields = ['ListDataID', 'ListDataId', config.categoryField];
    const parsedFilter = this.safeParseJson(config.filterExpression);
    const filterValue = parsedFilter !== null && parsedFilter !== undefined ? parsedFilter : config.filterExpression;
    const basePayload: any = {
      listId: config.dataListId,
      dataListId: config.dataListId,
      fields: selectedFields,
      fieldIds: selectedFields,
      selectedFields: selectedFields,
      pageIndex: 1,
      pageNo: 1,
      pageSize: config.pageSize,
    };

    /*
      Yeeflow tenants may expose slightly different queryItems signatures and filter names.
      The module sends common aliases and then extracts rows defensively from the returned shape.
    */
    if (filterValue !== undefined && filterValue !== null && this.normalizeToString(filterValue) !== '') {
      basePayload.filters = filterValue;
      basePayload.filter = filterValue;
      basePayload.where = filterValue;
      basePayload.filterExpression = filterValue;
    }

    const payloadWithoutListId = this.cloneObject(basePayload);
    delete payloadWithoutListId.listId;
    delete payloadWithoutListId.dataListId;

    const attempts = [
      () => client.lists.queryItems(basePayload),
      () => client.lists.queryItems(config.dataListId, payloadWithoutListId),
      () => client.lists.queryItems(config.dataListId, basePayload),
      () => client.lists.queryItems(config.dataListId, payloadWithoutListId, {}),
      () => client.lists.queryItems(config.dataListId, basePayload, {}),
    ];

    return this.tryQueryAttempts(attempts, 0);
  }

  tryQueryAttempts(attempts: any[], index: number): Promise<any[]> {
    if (index >= attempts.length) {
      return Promise.resolve([]);
    }
    let result: any;
    try {
      result = attempts[index]();
    } catch (error) {
      return this.tryQueryAttempts(attempts, index + 1);
    }
    return Promise.resolve(result)
      .then((response: any) => {
        const rows = this.extractRows(response);
        if (rows.length === 0 && index < attempts.length - 1) {
          return this.tryQueryAttempts(attempts, index + 1);
        }
        return rows;
      })
      .catch(() => this.tryQueryAttempts(attempts, index + 1));
  }

  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 index = 0; index < candidates.length; index += 1) {
      if (Array.isArray(candidates[index])) {
        return candidates[index];
      }
      if (candidates[index] && typeof candidates[index] === 'object') {
        const nested = this.extractRows(candidates[index]);
        if (nested.length > 0) {
          return nested;
        }
      }
    }
    return [];
  }

  aggregateRows(rows: any[], config: any) {
    const buckets: any = {};
    const sourceOrder: string[] = [];
    for (let index = 0; index < rows.length; index += 1) {
      const rawValue = this.readItemValue(rows[index], config.categoryField);
      const category = this.normalizeCategory(rawValue, config.unknownLabel);
      if (!buckets[category]) {
        buckets[category] = 0;
        sourceOrder.push(category);
      }
      buckets[category] += 1;
    }

    let items = sourceOrder.map((label) => ({
      label: label,
      count: buckets[label],
      percent: 0,
      color: '',
    }));
    items = this.sortDistributionItems(items, config.sortMode, sourceOrder);
    items = this.limitDistributionItems(items, config.maxCategories, config.otherLabel);

    const totalCount = items.reduce((total, item) => total + item.count, 0);
    const colors = this.getPalette(config.colorMode);
    const finalItems = items.map((item, index) => ({
      label: item.label,
      count: item.count,
      percent: totalCount > 0 ? Math.round((item.count / totalCount) * 1000) / 10 : 0,
      color: colors[index % colors.length],
    }));
    return {
      items: finalItems,
      totalCount: totalCount,
    };
  }

  sortDistributionItems(items: any[], sortMode: string, sourceOrder: string[]) {
    const ordered = items.slice();
    if (sortMode === 'countAsc') {
      return ordered.sort((a, b) => a.count - b.count || a.label.localeCompare(b.label));
    }
    if (sortMode === 'labelAsc') {
      return ordered.sort((a, b) => a.label.localeCompare(b.label));
    }
    if (sortMode === 'labelDesc') {
      return ordered.sort((a, b) => b.label.localeCompare(a.label));
    }
    if (sortMode === 'source') {
      return ordered.sort((a, b) => sourceOrder.indexOf(a.label) - sourceOrder.indexOf(b.label));
    }
    return ordered.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
  }

  limitDistributionItems(items: any[], maxCategories: number, otherLabel: string) {
    if (items.length <= maxCategories) {
      return items;
    }
    const visible = items.slice(0, maxCategories);
    const remaining = items.slice(maxCategories);
    const otherCount = remaining.reduce((total, item) => total + item.count, 0);
    if (otherCount > 0) {
      visible.push({
        label: otherLabel,
        count: otherCount,
        percent: 0,
        color: '',
      });
    }
    return visible;
  }

  readItemValue(item: any, fieldName: string): any {
    if (!item || !fieldName) {
      return undefined;
    }
    const direct = this.readObjectValueByKey(item, fieldName);
    if (direct !== undefined) {
      return this.normalizeCellValue(direct);
    }
    const containers = [
      item.values,
      item.Values,
      item.fields,
      item.Fields,
      item.data,
      item.Data,
      item.item,
      item.Item,
    ];
    for (let index = 0; index < containers.length; index += 1) {
      const value = this.readObjectValueByKey(containers[index], fieldName);
      if (value !== undefined) {
        return this.normalizeCellValue(value);
      }
    }
    const arrays = [item.fieldValues, item.FieldValues, item.fieldsValues, item.FieldsValues];
    for (let groupIndex = 0; groupIndex < arrays.length; groupIndex += 1) {
      const group = arrays[groupIndex];
      if (Array.isArray(group)) {
        for (let index = 0; index < group.length; index += 1) {
          const entry = group[index];
          const entryKey = this.normalizeToString(this.firstDefined([
            entry && entry.fieldId,
            entry && entry.FieldId,
            entry && entry.fieldID,
            entry && entry.FieldID,
            entry && entry.fieldCode,
            entry && entry.FieldCode,
            entry && entry.name,
            entry && entry.Name,
            entry && entry.key,
            entry && entry.Key,
          ]));
          if (entryKey && entryKey.toLowerCase() === fieldName.toLowerCase()) {
            return this.normalizeCellValue(this.firstDefined([
              entry.value,
              entry.Value,
              entry.fieldValue,
              entry.FieldValue,
              entry.dataValue,
              entry.DataValue,
              entry.text,
              entry.Text,
            ]));
          }
        }
      }
    }
    return undefined;
  }

  normalizeCellValue(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.normalizeCategory(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;
  }

  normalizeCategory(value: any, fallback: string): string {
    const cell = this.normalizeCellValue(value);
    const text = this.normalizeToString(cell);
    return text || fallback || 'Unknown';
  }

  render() {
    const config = this.getConfig();
    const items: DistributionItem[] = this.state.items || [];
    const hasConfig = Boolean(config.dataListId && config.categoryField);
    const hasData = items.length > 0 && this.state.totalCount > 0;
    return (
      <div className="yf-dist-module">
        <style>{this.getStyles()}</style>
        <div className="yf-dist-header">
          <div>
            <div className="yf-dist-title">{config.titleText}</div>
            {config.subtitleText ? <div className="yf-dist-subtitle">{config.subtitleText}</div> : null}
          </div>
          {hasData ? <div className="yf-dist-total">{this.formatNumber(this.state.totalCount)} total</div> : null}
        </div>

        {!hasConfig ? (
          <div className="yf-dist-empty">Configure Data List ID and Category Field to load the distribution.</div>
        ) : this.state.loading ? (
          <div className="yf-dist-loading">
            <span className="yf-dist-spinner" />
            Loading distribution data
          </div>
        ) : this.state.errorText ? (
          <div className="yf-dist-empty yf-dist-error">{this.state.errorText}</div>
        ) : !hasData ? (
          <div className="yf-dist-empty">{config.emptyText}</div>
        ) : (
          <div className={'yf-dist-body yf-dist-body-' + config.chartType} style={{ minHeight: config.height + 'px' }}>
            <div className="yf-dist-chart">
              {this.renderChart(items, config)}
            </div>
            {config.showLegend ? <div className="yf-dist-legend">{this.renderLegend(items, config)}</div> : null}
          </div>
        )}
      </div>
    );
  }

  renderChart(items: DistributionItem[], config: any) {
    if (config.chartType === 'horizontalBar' || config.chartType === 'bar') {
      return this.renderHorizontalBars(items, config);
    }
    if (config.chartType === 'verticalBar') {
      return this.renderVerticalBars(items, config);
    }
    if (config.chartType === 'pie') {
      return this.renderPie(items, config);
    }
    return this.renderDonut(items, config);
  }

  renderDonut(items: DistributionItem[], config: any) {
    let offset = 25;
    const radius = 42;
    const circumference = 2 * Math.PI * radius;
    const total = items.reduce((sum, item) => sum + item.count, 0);
    return (
      <div className="yf-dist-donut-wrap">
        <svg className="yf-dist-donut" viewBox="0 0 120 120" role="img">
          <circle className="yf-dist-donut-track" cx="60" cy="60" r={radius} />
          {items.map((item, index) => {
            const share = total > 0 ? item.count / total : 0;
            const dash = share * circumference;
            const segment = (
              <circle
                key={item.label + index}
                className="yf-dist-donut-segment"
                cx="60"
                cy="60"
                r={radius}
                stroke={item.color}
                strokeDasharray={dash + ' ' + (circumference - dash)}
                strokeDashoffset={offset}
              />
            );
            offset -= share * 100;
            return segment;
          })}
        </svg>
        <div className="yf-dist-center">
          <div className="yf-dist-center-value">{this.formatNumber(total)}</div>
          <div className="yf-dist-center-label">records</div>
        </div>
      </div>
    );
  }

  renderPie(items: DistributionItem[], config: any) {
    const total = items.reduce((sum, item) => sum + item.count, 0);
    let startAngle = -90;
    return (
      <div className="yf-dist-donut-wrap">
        <svg className="yf-dist-pie" viewBox="0 0 120 120" role="img">
          {items.map((item, index) => {
            const share = total > 0 ? item.count / total : 0;
            const endAngle = startAngle + share * 360;
            const path = this.describeArc(60, 60, 48, startAngle, endAngle);
            startAngle = endAngle;
            return <path key={item.label + index} d={path} fill={item.color} />;
          })}
        </svg>
      </div>
    );
  }

  renderHorizontalBars(items: DistributionItem[], config: any) {
    const maxCount = Math.max.apply(null, items.map((item) => item.count));
    return (
      <div className="yf-dist-bars">
        {items.map((item, index) => {
          const width = maxCount > 0 ? Math.max(3, Math.round((item.count / maxCount) * 100)) : 0;
          return (
            <div className="yf-dist-bar-row" key={item.label + index}>
              <div className="yf-dist-bar-label">{item.label}</div>
              <div className="yf-dist-bar-track">
                <div className="yf-dist-bar-fill" style={{ width: width + '%', backgroundColor: item.color }} />
              </div>
              {config.showCount ? <div className="yf-dist-bar-count">{this.formatNumber(item.count)}</div> : null}
            </div>
          );
        })}
      </div>
    );
  }

  renderVerticalBars(items: DistributionItem[], config: any) {
    const maxCount = Math.max.apply(null, items.map((item) => item.count));
    return (
      <div className="yf-dist-vbars">
        {items.map((item, index) => {
          const height = maxCount > 0 ? Math.max(8, Math.round((item.count / maxCount) * 100)) : 0;
          return (
            <div className="yf-dist-vbar" key={item.label + index}>
              <div className="yf-dist-vbar-meter">
                <div className="yf-dist-vbar-fill" style={{ height: height + '%', backgroundColor: item.color }} />
              </div>
              {config.showCount ? <div className="yf-dist-vbar-count">{this.formatNumber(item.count)}</div> : null}
              <div className="yf-dist-vbar-label" title={item.label}>{item.label}</div>
            </div>
          );
        })}
      </div>
    );
  }

  renderLegend(items: DistributionItem[], config: any) {
    return items.map((item, index) => (
      <div className="yf-dist-legend-row" key={item.label + index}>
        <span className="yf-dist-swatch" style={{ backgroundColor: item.color }} />
        <span className="yf-dist-legend-label" title={item.label}>{item.label}</span>
        <span className="yf-dist-legend-percent">{item.percent}%</span>
        {config.showCount ? <span className="yf-dist-legend-count">{this.formatNumber(item.count)}</span> : null}
      </div>
    ));
  }

  describeArc(cx: number, cy: number, radius: number, startAngle: number, endAngle: number) {
    if (endAngle - startAngle >= 359.99) {
      return 'M ' + cx + ' ' + cy + ' m -' + radius + ', 0 a ' + radius + ',' + radius + ' 0 1,0 ' + (radius * 2) + ',0 a ' + radius + ',' + radius + ' 0 1,0 -' + (radius * 2) + ',0';
    }
    const start = this.polarToCartesian(cx, cy, radius, endAngle);
    const end = this.polarToCartesian(cx, cy, radius, startAngle);
    const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
    return [
      'M', cx, cy,
      'L', start.x, start.y,
      'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y,
      'Z',
    ].join(' ');
  }

  polarToCartesian(cx: number, cy: number, radius: number, angleInDegrees: number) {
    const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
    return {
      x: cx + radius * Math.cos(angleInRadians),
      y: cy + radius * Math.sin(angleInRadians),
    };
  }

  normalizeChartType(value: string) {
    const text = (value || '').toLowerCase();
    if (text === 'pie') {
      return 'pie';
    }
    if (text === 'horizontalbar' || text === 'horizontal-bar' || text === 'bar') {
      return 'horizontalBar';
    }
    if (text === 'verticalbar' || text === 'vertical-bar' || text === 'column') {
      return 'verticalBar';
    }
    return 'donut';
  }

  normalizeSortMode(value: string) {
    const text = (value || '').toLowerCase();
    if (text === 'countasc' || text === 'count-asc') {
      return 'countAsc';
    }
    if (text === 'labelasc' || text === 'label-asc') {
      return 'labelAsc';
    }
    if (text === 'labeldesc' || text === 'label-desc') {
      return 'labelDesc';
    }
    if (text === 'source' || text === 'original') {
      return 'source';
    }
    return 'countDesc';
  }

  normalizeColorMode(value: string) {
    const text = (value || '').toLowerCase();
    if (text === 'soft' || text === 'status' || text === 'slate') {
      return text;
    }
    return 'yeeflow';
  }

  getPalette(colorMode: string) {
    if (colorMode === 'soft') {
      return ['#5B8DEF', '#55BFA3', '#F2B84B', '#EC6F7A', '#8E7CF4', '#4FB3D8', '#A7B1C2', '#6F8FAF'];
    }
    if (colorMode === 'status') {
      return ['#2563EB', '#16A34A', '#F59E0B', '#DC2626', '#7C3AED', '#0891B2', '#64748B', '#0F766E'];
    }
    if (colorMode === 'slate') {
      return ['#315A8C', '#52708C', '#6B879C', '#879CAB', '#9AA7B2', '#AEB7C0', '#6D7D90', '#40556F'];
    }
    return ['#1F6BFF', '#13A3A5', '#F5A623', '#E45665', '#7A5AF8', '#2E90FA', '#667085', '#12B76A'];
  }

  resolveParameterValue(value: any): any {
    if (value === undefined || value === null) {
      return '';
    }
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || Array.isArray(value)) {
      return value;
    }
    if (typeof value === 'object') {
      const preferred = this.firstDefined([
        value.value,
        value.Value,
        value.data,
        value.Data,
        value.result,
        value.Result,
        value.key,
        value.Key,
        value.id,
        value.Id,
        value.fieldId,
        value.FieldId,
        value.fieldName,
        value.FieldName,
        value.label,
        value.Label,
        value.name,
        value.Name,
      ]);
      if (preferred !== undefined) {
        return this.resolveParameterValue(preferred);
      }
    }
    return value;
  }

  safeParseJson(value: any): any {
    if (Array.isArray(value) || (value && typeof value === 'object')) {
      return value;
    }
    const text = this.normalizeToString(value);
    if (!text) {
      return null;
    }
    try {
      return JSON.parse(text);
    } catch (error) {
      return null;
    }
  }

  normalizeToString(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.normalizeToString(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.normalizeToString(preferred);
      }
      try {
        return JSON.stringify(resolved);
      } catch (error) {
        return '';
      }
    }
    return '';
  }

  normalizeToBoolean(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.normalizeToString(resolved).toLowerCase();
    if (text === 'true' || text === '1' || text === 'yes' || text === 'y' || text === 'on') {
      return true;
    }
    if (text === 'false' || text === '0' || text === 'no' || text === 'n' || text === 'off') {
      return false;
    }
    return fallback;
  }

  normalizeToNumber(value: any, fallback: number): number {
    const text = this.normalizeToString(value);
    if (!text) {
      return fallback;
    }
    const parsed = Number(text.replace(/,/g, ''));
    return isNaN(parsed) ? fallback : parsed;
  }

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

  firstDefined(values: any[]) {
    for (let index = 0; index < values.length; index += 1) {
      if (values[index] !== undefined && values[index] !== null) {
        return values[index];
      }
    }
    return undefined;
  }

  cloneObject(value: any) {
    const output: any = {};
    const keys = Object.keys(value || {});
    for (let index = 0; index < keys.length; index += 1) {
      output[keys[index]] = value[keys[index]];
    }
    return output;
  }

  formatNumber(value: number) {
    try {
      return Number(value || 0).toLocaleString();
    } catch (error) {
      return String(value || 0);
    }
  }

  getStyles() {
    return [
      '.yf-dist-module{box-sizing:border-box;width:100%;min-width:0;background:#fff;border:1px solid #e5eaf3;border-radius:8px;padding:18px 18px 16px;color:#111827;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 1px 2px rgba(16,24,40,.04);}',
      '.yf-dist-module *{box-sizing:border-box;}',
      '.yf-dist-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;}',
      '.yf-dist-title{font-size:16px;line-height:22px;font-weight:700;color:#101828;}',
      '.yf-dist-subtitle{margin-top:3px;font-size:12px;line-height:18px;color:#667085;}',
      '.yf-dist-total{flex:0 0 auto;font-size:12px;line-height:18px;color:#475467;background:#f8fafc;border:1px solid #e5eaf3;border-radius:999px;padding:4px 10px;}',
      '.yf-dist-body{display:grid;grid-template-columns:minmax(180px,1fr) minmax(180px,260px);gap:18px;align-items:center;}',
      '.yf-dist-body-horizontalBar,.yf-dist-body-verticalBar{grid-template-columns:minmax(240px,1fr) minmax(180px,240px);}',
      '.yf-dist-chart{min-width:0;width:100%;display:flex;align-items:center;justify-content:center;}',
      '.yf-dist-donut-wrap{position:relative;width:220px;max-width:100%;aspect-ratio:1/1;display:flex;align-items:center;justify-content:center;}',
      '.yf-dist-donut,.yf-dist-pie{width:100%;height:100%;display:block;}',
      '.yf-dist-donut-track{fill:none;stroke:#eef2f7;stroke-width:14;}',
      '.yf-dist-donut-segment{fill:none;stroke-width:14;stroke-linecap:round;transform:rotate(-90deg);transform-origin:60px 60px;transition:stroke-dasharray .2s ease;}',
      '.yf-dist-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;pointer-events:none;}',
      '.yf-dist-center-value{font-size:26px;line-height:32px;font-weight:750;color:#101828;}',
      '.yf-dist-center-label{font-size:12px;line-height:16px;color:#667085;}',
      '.yf-dist-bars{width:100%;display:flex;flex-direction:column;gap:12px;}',
      '.yf-dist-bar-row{display:grid;grid-template-columns:minmax(84px,150px) minmax(120px,1fr) auto;gap:10px;align-items:center;}',
      '.yf-dist-bar-label{min-width:0;font-size:12px;line-height:16px;color:#344054;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
      '.yf-dist-bar-track{height:10px;background:#eef2f7;border-radius:999px;overflow:hidden;}',
      '.yf-dist-bar-fill{height:100%;border-radius:999px;min-width:3px;}',
      '.yf-dist-bar-count{font-size:12px;line-height:16px;color:#475467;font-variant-numeric:tabular-nums;text-align:right;min-width:34px;}',
      '.yf-dist-vbars{width:100%;min-height:220px;display:flex;align-items:flex-end;justify-content:center;gap:12px;padding:8px 4px 0;}',
      '.yf-dist-vbar{flex:1 1 0;min-width:28px;max-width:58px;display:flex;flex-direction:column;align-items:center;gap:6px;}',
      '.yf-dist-vbar-meter{height:170px;width:100%;border-radius:8px;background:#eef2f7;display:flex;align-items:flex-end;overflow:hidden;}',
      '.yf-dist-vbar-fill{width:100%;border-radius:8px 8px 0 0;}',
      '.yf-dist-vbar-count{font-size:11px;line-height:14px;color:#475467;font-variant-numeric:tabular-nums;}',
      '.yf-dist-vbar-label{width:100%;font-size:11px;line-height:14px;color:#667085;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
      '.yf-dist-legend{display:flex;flex-direction:column;gap:8px;min-width:0;}',
      '.yf-dist-legend-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:8px;align-items:center;min-height:24px;}',
      '.yf-dist-swatch{width:9px;height:9px;border-radius:999px;box-shadow:0 0 0 3px rgba(148,163,184,.12);}',
      '.yf-dist-legend-label{font-size:12px;line-height:16px;color:#344054;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
      '.yf-dist-legend-percent{font-size:12px;line-height:16px;color:#667085;font-variant-numeric:tabular-nums;text-align:right;}',
      '.yf-dist-legend-count{font-size:12px;line-height:16px;font-weight:650;color:#101828;font-variant-numeric:tabular-nums;text-align:right;}',
      '.yf-dist-empty,.yf-dist-loading{min-height:180px;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:24px;}',
      '.yf-dist-error{color:#b42318;background:#fff7f6;border-color:#fecdca;}',
      '.yf-dist-loading{gap:10px;}',
      '.yf-dist-spinner{width:16px;height:16px;border-radius:999px;border:2px solid #d8deea;border-top-color:#1f6bff;animation:yfDistSpin 900ms linear infinite;}',
      '@keyframes yfDistSpin{to{transform:rotate(360deg);}}',
      '@media(max-width:640px){.yf-dist-module{padding:16px;}.yf-dist-header{flex-direction:column;align-items:flex-start;}.yf-dist-body,.yf-dist-body-horizontalBar,.yf-dist-body-verticalBar{grid-template-columns:1fr;gap:16px;}.yf-dist-body{min-height:0!important;}.yf-dist-donut-wrap{width:190px;}.yf-dist-bar-row{grid-template-columns:minmax(74px,120px) minmax(100px,1fr) auto;}.yf-dist-vbars{overflow-x:auto;justify-content:flex-start;padding-bottom:4px;}.yf-dist-vbar{min-width:42px;}.yf-dist-legend{width:100%;}}',
    ].join('');
  }
}

User guide

Download guide

Distribution Chart Module

Short Description

Distribution Chart Module is a reusable Yeeflow dashboard custom code template that groups records from a data list by category and displays the result as a clean chart module.

Purpose / What This Is For

Use this control when a dashboard needs to show how records are distributed across categories such as status, department, source, warehouse zone, owner, region, product category, or approval stage.

It solves a common dashboard problem: users often need a quick visual answer to questions like "How many records are in each status?" or "Which department owns the most open requests?" Instead of building a custom chart each time, teams can configure this reusable module with a data list and a category field.

Supported Placement

This template is optimized for:

  • Dashboard page

It is not designed as an approval-form input control and does not write values back to form fields.

When To Use This Template

Use Distribution Chart Module when:

  • You need a category/count summary from a Yeeflow data list.
  • You want a dashboard chart that can be reused across multiple apps.
  • The chart should group records by one field and count the number of records in each group.
  • You need a donut, pie, horizontal bar, or vertical bar view.
  • You want a customer-friendly dashboard block with clear empty, loading, and error states.
Input Parameters Overview
ParameterTypeRequiredPurposeExample Value
dataListIdExpression editorYesTarget Yeeflow data list used as the data source.2034150397060198400
categoryFieldExpression editorYesField ID/name used to group records.Status
titleTextPlain textNoTitle shown at the top of the module.Stock Status Distribution
subtitleTextPlain textNoSupporting text below the title.Current inventory grouped by stock status
chartTypePlain textNoChart presentation mode.donut
maxCategoriesNumeric configNoMaximum categories shown before remaining groups are combined.8
showLegendExpression editorNoWhether the legend/count list is shown.true
showCountExpression editorNoWhether record counts are shown.true
emptyTextPlain textNoText shown when no data is available.No records found
colorModePlain textNoColor palette used by the chart.yeeflow
sortModePlain textNoCategory sorting behavior.countDesc
filterExpressionExpression editorNoOptional query filter object, array, or JSON value.[{"field":"Status","operator":"neq","value":"Closed"}]
heightNumeric configNoMinimum chart area height in pixels.280
pageSizeNumeric configNoMaximum records queried for aggregation.500
unknownLabelPlain textNoLabel used when the category value is empty.Unknown
otherLabelPlain textNoLabel used for categories beyond the maximum.Other
Detailed Parameter Explanation
dataListId

The Yeeflow data list ID to query. This should point to the list that contains the records you want to summarize.

This parameter uses the expression editor so it can be configured from a static value, variable, temp variable, or expression result. The runtime value is normalized safely before the query runs.

categoryField

The field ID or field name used for grouping records. For example, if you set categoryField to Status, all records with the same Status value are counted together.

Use the actual field ID/name that Yeeflow returns in list query results. The code reads common row shapes defensively, including direct fields, values, Fields, and array-style field values.

titleText

Optional title displayed at the top of the module. This is display text only, so a plain text value is recommended.

subtitleText

Optional helper text shown under the title. Use it to explain the data scope, such as "Open cases by source" or "Inventory grouped by warehouse zone."

chartType

Controls the chart presentation.

Supported values:

  • donut
  • pie
  • horizontalBar
  • bar
  • verticalBar

If blank or invalid, the module uses donut.

maxCategories

Limits how many categories are displayed. If there are more categories than the configured maximum, the remaining categories are combined into the Other group.

Default: 8

showLegend

Controls whether the legend/count list appears beside or below the chart.

This parameter uses the expression editor and supports values such as true, false, 1, 0, yes, and no.

showCount

Controls whether numeric record counts are shown in the chart labels and legend rows.

This parameter uses the expression editor and supports dynamic true/false values.

emptyText

Text shown when the query succeeds but no records or categories are available.

colorMode

Controls the chart palette.

Supported values:

  • yeeflow
  • soft
  • status
  • slate

Default: yeeflow

sortMode

Controls category order.

Supported values:

  • countDesc: largest groups first
  • countAsc: smallest groups first
  • labelAsc: A to Z
  • labelDesc: Z to A
  • source: first-seen order from the returned records

Default: countDesc

filterExpression

Optional filter passed to the list query. This can be a JSON string, object, array, variable, or temp variable from the expression editor.

The template passes common filter aliases (filters, filter, where, and filterExpression) because exact Yeeflow query filter support may differ by runtime. If the filter shape is not supported by the current tenant/runtime, leave this parameter blank or adjust the filter format to match that environment.

height

Minimum chart area height in pixels. Use this to align multiple dashboard modules visually.

Default: 280

pageSize

Maximum number of records queried for aggregation. Increase it for larger lists, but keep dashboard performance in mind.

Default: 500

unknownLabel

Label used when a record has no value in the category field.

Default: Unknown

otherLabel

Label used when categories beyond maxCategories are combined.

Default: Other

Step-By-Step Setup Guide
  1. Add a Custom Code control to a Yeeflow dashboard page.
  2. Open the code editor and paste the distribution-chart-module.tsx code.
  3. Set dataListId to the target data list ID.
  4. Set categoryField to the field ID/name to group by.
  5. Add a clear titleText, such as Stock Status Distribution.
  6. Choose a chartType, such as donut or horizontalBar.
  7. Set maxCategories, showLegend, and showCount based on the dashboard layout.
  8. Optionally configure filterExpression to limit which records are included.
  9. Preview the dashboard and confirm the chart shows the expected counts.
  10. Publish the dashboard when the result is correct.
Result / Expected Output

After configuration, users see a dashboard card with:

  • title and optional subtitle
  • donut, pie, horizontal bar, or vertical bar chart
  • total record count
  • optional legend with category percentages and counts
  • empty/loading/error states when appropriate

The template reads records from the configured data list and displays aggregated counts. It does not save values back to fields or variables.

Real Business Examples
Stock Status Distribution

Group inventory records by StockStatus to show how many items are Available, Low Stock, Reserved, or Out of Stock.

Warehouse Zone Distribution

Group assets or stock items by WarehouseZone to show where operational volume is concentrated.

Request Source Type

Group service requests by SourceType, such as Email, Portal, Phone, Partner, or Internal.

Department Ownership

Group open tasks, approvals, or cases by Department so managers can quickly see workload distribution.

Notes / Assumptions / Limitations
  • The template is optimized for dashboard display and does not write output values.
  • The query uses yeeSDKClient.lists.queryItems(...).
  • The code sends common query payload aliases for compatibility, but the exact filter format may vary by Yeeflow runtime.
  • The module counts returned records; it does not perform server-side grouping.
  • Very large lists should use a reasonable pageSize or a dashboard-specific filtered list for performance.
  • Empty category values are grouped under Unknown or the configured unknownLabel.
  • Categories beyond maxCategories are combined under Other or the configured otherLabel.
Testing Checklist
  • Confirm the module renders on a dashboard page.
  • Confirm dataListId loads the correct data list.
  • Confirm categoryField groups records correctly.
  • Test donut, pie, horizontalBar, and verticalBar.
  • Test showLegend = true and showLegend = false.
  • Test showCount = true and showCount = false.
  • Test empty/null category values.
  • Test maxCategories with more categories than the limit.
  • Test empty data state.
  • Test missing configuration state.
  • Test a valid filterExpression if filters are used.
  • Test mobile or narrow dashboard widths.
Troubleshooting
The chart says to configure Data List ID and Category Field

Check that dataListId and categoryField are both configured. If using expression editor values, confirm the expression returns a real list ID and field ID/name.

The chart shows no data

Confirm the data list contains records and the selected category field has values. Also check whether filterExpression is excluding all records.

Counts do not match expectations

The template counts records returned by queryItems. Check pageSize, filters, and whether the data list has more records than the query limit.

Category labels look wrong

Confirm that categoryField is the correct Yeeflow field ID/name. Some fields return object values, lookup values, or display text; the template normalizes common shapes, but custom fields may need the exact field key.

Filter does not work

Filter support can vary by Yeeflow runtime. Try leaving filterExpression blank first. Then test the filter shape expected by your specific data list query environment.

Example configuration

Download config

Distribution Chart Module Example Config

Example 1: Stock Status Donut
ParameterExample Value
dataListId2034150397060198400
categoryFieldStockStatus
titleTextStock Status Distribution
subtitleTextCurrent stock records grouped by status
chartTypedonut
maxCategories6
showLegendtrue
showCounttrue
emptyTextNo stock records found
colorModestatus
sortModecountDesc
height280
pageSize500
unknownLabelUnspecified
otherLabelOther statuses
Example 2: Department Horizontal Bar
ParameterExample Value
dataListId2034150397060198400
categoryFieldDepartment
titleTextOpen Requests by Department
subtitleTextActive request volume by owning department
chartTypehorizontalBar
maxCategories10
showLegendfalse
showCounttrue
colorModeyeeflow
sortModecountDesc
height320
Optional Filter Expression Example

Use this only if your Yeeflow runtime supports this filter shape:

[
  {
    "field": "Status",
    "operator": "neq",
    "value": "Closed"
  }
]

If the chart returns no data after adding a filter, test again with filterExpression blank to confirm the data list and category field are configured correctly.