Skip to content

Commit 066a336

Browse files
authored
Merge pull request #1165 from objectstack-ai/copilot/fix-charts-groupby-label-issue
2 parents 4b8ac8e + a897212 commit 066a336

4 files changed

Lines changed: 482 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- **Charts groupBy value→label resolution** (`@object-ui/plugin-charts`): Chart X-axis labels now display human-readable labels instead of raw values. Select/picklist fields resolve value→label via field metadata options, lookup/master_detail fields batch-fetch referenced record names, and all other fields fall back to `humanizeLabel()` (snake_case → Title Case). Removed hardcoded `value.slice(0, 3)` truncation from `AdvancedChartImpl.tsx` XAxis tick formatters — desktop now shows full labels with angle rotation for long text, mobile truncates at 8 characters with "…".
13+
1214
- **Analytics aggregate measures format** (`@object-ui/data-objectstack`): Fixed `aggregate()` method to send `measures` as string array (`['amount_sum']`, `['count']`) instead of object array (`[{ field, function }]`). The backend `MemoryAnalyticsService.resolveMeasure()` expects strings and calls `.split('.')`, causing `TypeError: t.split is not a function` when receiving objects. Also fixed `dimensions` to send an empty array when `groupBy` is `'_all'` (single-bucket aggregation), and added response mapping to rename measure keys (e.g. `amount_sum`) back to the original field name (`amount`) for consumer compatibility. Additionally fixed chart rendering blank issue: the `rawRows` extraction now handles the `{ rows: [...] }` envelope (when the SDK unwraps the outer `{ success, data }` wrapper) and the `{ data: { rows: [...] } }` envelope (when the SDK returns the full response), matching the actual shape returned by the analytics API (`/api/v1/analytics/query`).
1315
- **Fields SSR build** (`@object-ui/fields`): Added `@object-ui/i18n` to Vite `external` in `vite.config.ts` and converted to regex-based externalization pattern (consistent with `@object-ui/components`) to prevent `react-i18next` CJS code from being bundled. Fixes `"dynamic usage of require is not supported"` error during Next.js SSR prerendering of `/docs/components/basic/text`.
1416
- **Console build** (`@object-ui/console`): Added missing `@object-ui/plugin-chatbot` devDependency that caused `TS2307: Cannot find module '@object-ui/plugin-chatbot'` during build.

packages/plugin-charts/src/AdvancedChartImpl.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ export default function AdvancedChartImpl({
110110
combo: BarChart,
111111
}[chartType] || BarChart;
112112

113-
console.log('📈 Rendering Chart:', { chartType, dataLength: data.length, config, series, xAxisKey });
113+
// Memoize whether any X-axis label is long enough to warrant angle rotation
114+
const hasLongLabels = React.useMemo(
115+
() => data.some((d: any) => String(d[xAxisKey] || '').length > 5),
116+
[data, xAxisKey],
117+
);
114118

115119
// Helper function to get color palette
116120
const getPalette = () => [
@@ -245,7 +249,12 @@ export default function AdvancedChartImpl({
245249
tickMargin={10}
246250
axisLine={false}
247251
interval={isMobile ? Math.ceil(data.length / 5) : 0}
248-
tickFormatter={(value) => (value && typeof value === 'string') ? value.slice(0, 3) : value}
252+
tickFormatter={(value) => {
253+
if (!value || typeof value !== 'string') return value;
254+
if (isMobile && value.length > 8) return value.slice(0, 8) + '…';
255+
return value;
256+
}}
257+
{...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end', height: 60 })}
249258
/>
250259
<YAxis yAxisId="left" tickLine={false} axisLine={false} />
251260
<YAxis yAxisId="right" orientation="right" tickLine={false} axisLine={false} />
@@ -282,7 +291,12 @@ export default function AdvancedChartImpl({
282291
tickMargin={10}
283292
axisLine={false}
284293
interval={isMobile ? Math.ceil(data.length / 5) : 0}
285-
tickFormatter={(value) => (value && typeof value === 'string') ? value.slice(0, 3) : value}
294+
tickFormatter={(value) => {
295+
if (!value || typeof value !== 'string') return value;
296+
if (isMobile && value.length > 8) return value.slice(0, 8) + '…';
297+
return value;
298+
}}
299+
{...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end', height: 60 })}
286300
/>
287301
<ChartTooltip content={<ChartTooltipContent />} />
288302
<ChartLegend

packages/plugin-charts/src/ObjectChart.tsx

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { ChartRenderer } from './ChartRenderer';
55
import { ComponentRegistry, extractRecords } from '@object-ui/core';
66
import { AlertCircle } from 'lucide-react';
77

8+
/**
9+
* Humanize a snake_case or kebab-case string into Title Case.
10+
* Local implementation to avoid a dependency on @object-ui/fields.
11+
*/
12+
export function humanizeLabel(value: string): string {
13+
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
14+
}
15+
816
/**
917
* Client-side aggregation for fetched records.
1018
* Groups records by `groupBy` field and applies the aggregation function
@@ -50,6 +58,119 @@ export function aggregateRecords(
5058
});
5159
}
5260

61+
/**
62+
* Resolve groupBy field values to human-readable labels using field metadata.
63+
*
64+
* - **select/picklist** fields: maps value→label via `field.options`.
65+
* - **lookup/master_detail** fields: batch-fetches referenced records
66+
* via `dataSource.find()` and maps id→name.
67+
* - **fallback**: applies `humanizeLabel()` to convert snake_case/kebab-case
68+
* values into Title Case.
69+
*
70+
* The resolved data is a new array with the groupBy key replaced by its label.
71+
* This function is pure data-layer logic — the rendering layer does not need
72+
* to perform any value→label conversion.
73+
*/
74+
export async function resolveGroupByLabels(
75+
data: any[],
76+
groupByField: string,
77+
objectSchema: any,
78+
dataSource?: any,
79+
): Promise<any[]> {
80+
if (!data.length || !groupByField) return data;
81+
82+
const fieldDef = objectSchema?.fields?.[groupByField];
83+
if (!fieldDef) {
84+
// No metadata available — apply humanizeLabel as fallback
85+
return data.map(row => ({
86+
...row,
87+
[groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
88+
}));
89+
}
90+
91+
const fieldType = fieldDef.type;
92+
93+
// --- select / picklist / dropdown fields ---
94+
if (fieldType === 'select' || fieldType === 'picklist' || fieldType === 'dropdown') {
95+
const options: Array<{ value: string; label: string } | string> = fieldDef.options || [];
96+
if (options.length === 0) {
97+
return data.map(row => ({
98+
...row,
99+
[groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
100+
}));
101+
}
102+
103+
// Build value→label map (options can be {value,label} objects or plain strings)
104+
const labelMap: Record<string, string> = {};
105+
for (const opt of options) {
106+
if (typeof opt === 'string') {
107+
labelMap[opt] = opt;
108+
} else if (opt && typeof opt === 'object') {
109+
labelMap[String(opt.value)] = opt.label || String(opt.value);
110+
}
111+
}
112+
113+
return data.map(row => {
114+
const rawValue = String(row[groupByField] ?? '');
115+
return {
116+
...row,
117+
[groupByField]: labelMap[rawValue] || humanizeLabel(rawValue),
118+
};
119+
});
120+
}
121+
122+
// --- lookup / master_detail fields ---
123+
if (fieldType === 'lookup' || fieldType === 'master_detail') {
124+
const referenceTo = fieldDef.reference_to || fieldDef.reference;
125+
if (!referenceTo || !dataSource || typeof dataSource.find !== 'function') {
126+
// Cannot resolve — return as-is
127+
return data;
128+
}
129+
130+
// Collect unique IDs to fetch
131+
const ids = [...new Set(data.map(row => row[groupByField]).filter(v => v != null))];
132+
if (ids.length === 0) return data;
133+
134+
// Derive the ID field from metadata (fallback to 'id')
135+
const idField: string = fieldDef.id_field || 'id';
136+
137+
try {
138+
const results = await dataSource.find(referenceTo, {
139+
$filter: { [idField]: { $in: ids } },
140+
$top: ids.length,
141+
});
142+
const records = extractRecords(results);
143+
144+
// Build id→label map using display field from metadata with sensible fallbacks
145+
const displayField: string =
146+
fieldDef.reference_field || fieldDef.display_field || 'name';
147+
const idToName: Record<string, string> = {};
148+
for (const rec of records) {
149+
const id = String(rec[idField] ?? rec.id ?? rec._id ?? '');
150+
const name = rec[displayField] || rec.name || rec.label || rec.title || id;
151+
if (id) idToName[id] = String(name);
152+
}
153+
154+
return data.map(row => {
155+
const rawValue = String(row[groupByField] ?? '');
156+
return {
157+
...row,
158+
[groupByField]: idToName[rawValue] || rawValue,
159+
};
160+
});
161+
} catch (e) {
162+
console.warn('[ObjectChart] Failed to resolve lookup labels:', e);
163+
return data;
164+
}
165+
}
166+
167+
// --- fallback for other field types ---
168+
return data.map(row => ({
169+
...row,
170+
[groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
171+
}));
172+
}
173+
53174
// Re-export extractRecords from @object-ui/core for backward compatibility
54175
export { extractRecords } from '@object-ui/core';
55176

@@ -98,6 +219,18 @@ export const ObjectChart = (props: any) => {
98219
return;
99220
}
100221

222+
// Resolve groupBy value→label using field metadata.
223+
// The groupBy field is determined from aggregate config or xAxisKey.
224+
const groupByField = schema.aggregate?.groupBy || schema.xAxisKey;
225+
if (groupByField && typeof ds.getObjectSchema === 'function') {
226+
try {
227+
const objectSchema = await ds.getObjectSchema(schema.objectName);
228+
data = await resolveGroupByLabels(data, groupByField, objectSchema, ds);
229+
} catch {
230+
// Schema fetch failed — continue with raw values
231+
}
232+
}
233+
101234
if (mounted.current) {
102235
setFetchedData(data);
103236
}
@@ -109,7 +242,7 @@ export const ObjectChart = (props: any) => {
109242
} finally {
110243
if (mounted.current) setLoading(false);
111244
}
112-
}, [schema.objectName, schema.aggregate, schema.filter]);
245+
}, [schema.objectName, schema.aggregate, schema.filter, schema.xAxisKey]);
113246

114247
useEffect(() => {
115248
const mounted = { current: true };

0 commit comments

Comments
 (0)