Overview
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.
Developer reference
Build patterns behind this template
Use this template as a reference while reviewing the Custom Code developer guide. Learn how the required export structure, input parameters, and rendering patterns fit together.
Source preview
Code preview
import React from 'react';
type 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('');
}
}Implementation notes
User 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
| Parameter | Type | Required | Purpose | Example Value |
| --- | --- | --- | --- | --- |
| `dataListId` | Expression editor | Yes | Target Yeeflow data list used as the data source. | `2034150397060198400` |
| `categoryField` | Expression editor | Yes | Field ID/name used to group records. | `Status` |
| `titleText` | Plain text | No | Title shown at the top of the module. | `Stock Status Distribution` |
| `subtitleText` | Plain text | No | Supporting text below the title. | `Current inventory grouped by stock status` |
| `chartType` | Plain text | No | Chart presentation mode. | `donut` |
| `maxCategories` | Numeric config | No | Maximum categories shown before remaining groups are combined. | `8` |
| `showLegend` | Expression editor | No | Whether the legend/count list is shown. | `true` |
| `showCount` | Expression editor | No | Whether record counts are shown. | `true` |
| `emptyText` | Plain text | No | Text shown when no data is available. | `No records found` |
| `colorMode` | Plain text | No | Color palette used by the chart. | `yeeflow` |
| `sortMode` | Plain text | No | Category sorting behavior. | `countDesc` |
| `filterExpression` | Expression editor | No | Optional query filter object, array, or JSON value. | `[{"field":"Status","operator":"neq","value":"Closed"}]` |
| `height` | Numeric config | No | Minimum chart area height in pixels. | `280` |
| `pageSize` | Numeric config | No | Maximum records queried for aggregation. | `500` |
| `unknownLabel` | Plain text | No | Label used when the category value is empty. | `Unknown` |
| `otherLabel` | Plain text | No | Label 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.Configuration
Example configuration
# Distribution Chart Module Example Config
## Example 1: Stock Status Donut
| Parameter | Example Value |
| --- | --- |
| `dataListId` | `2034150397060198400` |
| `categoryField` | `StockStatus` |
| `titleText` | `Stock Status Distribution` |
| `subtitleText` | `Current stock records grouped by status` |
| `chartType` | `donut` |
| `maxCategories` | `6` |
| `showLegend` | `true` |
| `showCount` | `true` |
| `emptyText` | `No stock records found` |
| `colorMode` | `status` |
| `sortMode` | `countDesc` |
| `height` | `280` |
| `pageSize` | `500` |
| `unknownLabel` | `Unspecified` |
| `otherLabel` | `Other statuses` |
## Example 2: Department Horizontal Bar
| Parameter | Example Value |
| --- | --- |
| `dataListId` | `2034150397060198400` |
| `categoryField` | `Department` |
| `titleText` | `Open Requests by Department` |
| `subtitleText` | `Active request volume by owning department` |
| `chartType` | `horizontalBar` |
| `maxCategories` | `10` |
| `showLegend` | `false` |
| `showCount` | `true` |
| `colorMode` | `yeeflow` |
| `sortMode` | `countDesc` |
| `height` | `320` |
## Optional Filter Expression Example
Use this only if your Yeeflow runtime supports this filter shape:
```json
[
{
"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.





