Skip to content

Commit 1860732

Browse files
Copilothotlong
andcommitted
feat(plugin-charts): add groupBy value→label resolution and remove XAxis truncation
- Add resolveGroupByLabels() to convert groupBy field values to display labels using field metadata (select options, lookup record names, humanizeLabel fallback) - Integrate label resolution into ObjectChart fetchData flow - Remove value.slice(0, 3) truncation from AdvancedChartImpl XAxis tickFormatters - Replace with adaptive formatting: mobile truncates at 8 chars, desktop shows full labels with angle=-35 rotation for long text - Add comprehensive tests for label resolution (17 tests) Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/eae827ef-e746-4d58-8b55-9f9e8fcd1dd0 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 0a6c6dd commit 1860732

3 files changed

Lines changed: 418 additions & 3 deletions

File tree

packages/plugin-charts/src/AdvancedChartImpl.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,12 @@ export default function AdvancedChartImpl({
245245
tickMargin={10}
246246
axisLine={false}
247247
interval={isMobile ? Math.ceil(data.length / 5) : 0}
248-
tickFormatter={(value) => (value && typeof value === 'string') ? value.slice(0, 3) : value}
248+
tickFormatter={(value) => {
249+
if (!value || typeof value !== 'string') return value;
250+
if (isMobile && value.length > 8) return value.slice(0, 8) + '…';
251+
return value;
252+
}}
253+
{...(!isMobile && data.some((d: any) => String(d[xAxisKey] || '').length > 5) && { angle: -35, textAnchor: 'end', height: 60 })}
249254
/>
250255
<YAxis yAxisId="left" tickLine={false} axisLine={false} />
251256
<YAxis yAxisId="right" orientation="right" tickLine={false} axisLine={false} />
@@ -282,7 +287,12 @@ export default function AdvancedChartImpl({
282287
tickMargin={10}
283288
axisLine={false}
284289
interval={isMobile ? Math.ceil(data.length / 5) : 0}
285-
tickFormatter={(value) => (value && typeof value === 'string') ? value.slice(0, 3) : value}
290+
tickFormatter={(value) => {
291+
if (!value || typeof value !== 'string') return value;
292+
if (isMobile && value.length > 8) return value.slice(0, 8) + '…';
293+
return value;
294+
}}
295+
{...(!isMobile && data.some((d: any) => String(d[xAxisKey] || '').length > 5) && { angle: -35, textAnchor: 'end', height: 60 })}
286296
/>
287297
<ChartTooltip content={<ChartTooltipContent />} />
288298
<ChartLegend

packages/plugin-charts/src/ObjectChart.tsx

Lines changed: 129 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,114 @@ 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+
try {
135+
const results = await dataSource.find(referenceTo, {
136+
$filter: { id: { $in: ids } },
137+
$top: ids.length,
138+
});
139+
const records = extractRecords(results);
140+
141+
// Build id→name map using common display fields
142+
const idToName: Record<string, string> = {};
143+
for (const rec of records) {
144+
const id = String(rec.id ?? rec._id ?? '');
145+
const name = rec.name || rec.label || rec.title || id;
146+
if (id) idToName[id] = String(name);
147+
}
148+
149+
return data.map(row => {
150+
const rawValue = String(row[groupByField] ?? '');
151+
return {
152+
...row,
153+
[groupByField]: idToName[rawValue] || rawValue,
154+
};
155+
});
156+
} catch (e) {
157+
console.warn('[ObjectChart] Failed to resolve lookup labels:', e);
158+
return data;
159+
}
160+
}
161+
162+
// --- fallback for other field types ---
163+
return data.map(row => ({
164+
...row,
165+
[groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
166+
}));
167+
}
168+
53169
// Re-export extractRecords from @object-ui/core for backward compatibility
54170
export { extractRecords } from '@object-ui/core';
55171

@@ -98,6 +214,18 @@ export const ObjectChart = (props: any) => {
98214
return;
99215
}
100216

217+
// Resolve groupBy value→label using field metadata.
218+
// The groupBy field is determined from aggregate config or xAxisKey.
219+
const groupByField = schema.aggregate?.groupBy || schema.xAxisKey;
220+
if (groupByField && typeof ds.getObjectSchema === 'function') {
221+
try {
222+
const objectSchema = await ds.getObjectSchema(schema.objectName);
223+
data = await resolveGroupByLabels(data, groupByField, objectSchema, ds);
224+
} catch {
225+
// Schema fetch failed — continue with raw values
226+
}
227+
}
228+
101229
if (mounted.current) {
102230
setFetchedData(data);
103231
}
@@ -109,7 +237,7 @@ export const ObjectChart = (props: any) => {
109237
} finally {
110238
if (mounted.current) setLoading(false);
111239
}
112-
}, [schema.objectName, schema.aggregate, schema.filter]);
240+
}, [schema.objectName, schema.aggregate, schema.filter, schema.xAxisKey]);
113241

114242
useEffect(() => {
115243
const mounted = { current: true };

0 commit comments

Comments
 (0)