diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62ddc6b5e..abde745a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **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 "…".
+
- **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`).
- **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`.
- **Console build** (`@object-ui/console`): Added missing `@object-ui/plugin-chatbot` devDependency that caused `TS2307: Cannot find module '@object-ui/plugin-chatbot'` during build.
diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx
index d6611335e..73122d0ab 100644
--- a/packages/plugin-charts/src/AdvancedChartImpl.tsx
+++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx
@@ -110,7 +110,11 @@ export default function AdvancedChartImpl({
combo: BarChart,
}[chartType] || BarChart;
- console.log('📈 Rendering Chart:', { chartType, dataLength: data.length, config, series, xAxisKey });
+ // Memoize whether any X-axis label is long enough to warrant angle rotation
+ const hasLongLabels = React.useMemo(
+ () => data.some((d: any) => String(d[xAxisKey] || '').length > 5),
+ [data, xAxisKey],
+ );
// Helper function to get color palette
const getPalette = () => [
@@ -245,7 +249,12 @@ export default function AdvancedChartImpl({
tickMargin={10}
axisLine={false}
interval={isMobile ? Math.ceil(data.length / 5) : 0}
- tickFormatter={(value) => (value && typeof value === 'string') ? value.slice(0, 3) : value}
+ tickFormatter={(value) => {
+ if (!value || typeof value !== 'string') return value;
+ if (isMobile && value.length > 8) return value.slice(0, 8) + '…';
+ return value;
+ }}
+ {...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end', height: 60 })}
/>
@@ -282,7 +291,12 @@ export default function AdvancedChartImpl({
tickMargin={10}
axisLine={false}
interval={isMobile ? Math.ceil(data.length / 5) : 0}
- tickFormatter={(value) => (value && typeof value === 'string') ? value.slice(0, 3) : value}
+ tickFormatter={(value) => {
+ if (!value || typeof value !== 'string') return value;
+ if (isMobile && value.length > 8) return value.slice(0, 8) + '…';
+ return value;
+ }}
+ {...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end', height: 60 })}
/>
} />
c.toUpperCase());
+}
+
/**
* Client-side aggregation for fetched records.
* Groups records by `groupBy` field and applies the aggregation function
@@ -50,6 +58,119 @@ export function aggregateRecords(
});
}
+/**
+ * Resolve groupBy field values to human-readable labels using field metadata.
+ *
+ * - **select/picklist** fields: maps value→label via `field.options`.
+ * - **lookup/master_detail** fields: batch-fetches referenced records
+ * via `dataSource.find()` and maps id→name.
+ * - **fallback**: applies `humanizeLabel()` to convert snake_case/kebab-case
+ * values into Title Case.
+ *
+ * The resolved data is a new array with the groupBy key replaced by its label.
+ * This function is pure data-layer logic — the rendering layer does not need
+ * to perform any value→label conversion.
+ */
+export async function resolveGroupByLabels(
+ data: any[],
+ groupByField: string,
+ objectSchema: any,
+ dataSource?: any,
+): Promise {
+ if (!data.length || !groupByField) return data;
+
+ const fieldDef = objectSchema?.fields?.[groupByField];
+ if (!fieldDef) {
+ // No metadata available — apply humanizeLabel as fallback
+ return data.map(row => ({
+ ...row,
+ [groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
+ }));
+ }
+
+ const fieldType = fieldDef.type;
+
+ // --- select / picklist / dropdown fields ---
+ if (fieldType === 'select' || fieldType === 'picklist' || fieldType === 'dropdown') {
+ const options: Array<{ value: string; label: string } | string> = fieldDef.options || [];
+ if (options.length === 0) {
+ return data.map(row => ({
+ ...row,
+ [groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
+ }));
+ }
+
+ // Build value→label map (options can be {value,label} objects or plain strings)
+ const labelMap: Record = {};
+ for (const opt of options) {
+ if (typeof opt === 'string') {
+ labelMap[opt] = opt;
+ } else if (opt && typeof opt === 'object') {
+ labelMap[String(opt.value)] = opt.label || String(opt.value);
+ }
+ }
+
+ return data.map(row => {
+ const rawValue = String(row[groupByField] ?? '');
+ return {
+ ...row,
+ [groupByField]: labelMap[rawValue] || humanizeLabel(rawValue),
+ };
+ });
+ }
+
+ // --- lookup / master_detail fields ---
+ if (fieldType === 'lookup' || fieldType === 'master_detail') {
+ const referenceTo = fieldDef.reference_to || fieldDef.reference;
+ if (!referenceTo || !dataSource || typeof dataSource.find !== 'function') {
+ // Cannot resolve — return as-is
+ return data;
+ }
+
+ // Collect unique IDs to fetch
+ const ids = [...new Set(data.map(row => row[groupByField]).filter(v => v != null))];
+ if (ids.length === 0) return data;
+
+ // Derive the ID field from metadata (fallback to 'id')
+ const idField: string = fieldDef.id_field || 'id';
+
+ try {
+ const results = await dataSource.find(referenceTo, {
+ $filter: { [idField]: { $in: ids } },
+ $top: ids.length,
+ });
+ const records = extractRecords(results);
+
+ // Build id→label map using display field from metadata with sensible fallbacks
+ const displayField: string =
+ fieldDef.reference_field || fieldDef.display_field || 'name';
+ const idToName: Record = {};
+ for (const rec of records) {
+ const id = String(rec[idField] ?? rec.id ?? rec._id ?? '');
+ const name = rec[displayField] || rec.name || rec.label || rec.title || id;
+ if (id) idToName[id] = String(name);
+ }
+
+ return data.map(row => {
+ const rawValue = String(row[groupByField] ?? '');
+ return {
+ ...row,
+ [groupByField]: idToName[rawValue] || rawValue,
+ };
+ });
+ } catch (e) {
+ console.warn('[ObjectChart] Failed to resolve lookup labels:', e);
+ return data;
+ }
+ }
+
+ // --- fallback for other field types ---
+ return data.map(row => ({
+ ...row,
+ [groupByField]: humanizeLabel(String(row[groupByField] ?? '')),
+ }));
+}
+
// Re-export extractRecords from @object-ui/core for backward compatibility
export { extractRecords } from '@object-ui/core';
@@ -98,6 +219,18 @@ export const ObjectChart = (props: any) => {
return;
}
+ // Resolve groupBy value→label using field metadata.
+ // The groupBy field is determined from aggregate config or xAxisKey.
+ const groupByField = schema.aggregate?.groupBy || schema.xAxisKey;
+ if (groupByField && typeof ds.getObjectSchema === 'function') {
+ try {
+ const objectSchema = await ds.getObjectSchema(schema.objectName);
+ data = await resolveGroupByLabels(data, groupByField, objectSchema, ds);
+ } catch {
+ // Schema fetch failed — continue with raw values
+ }
+ }
+
if (mounted.current) {
setFetchedData(data);
}
@@ -109,7 +242,7 @@ export const ObjectChart = (props: any) => {
} finally {
if (mounted.current) setLoading(false);
}
- }, [schema.objectName, schema.aggregate, schema.filter]);
+ }, [schema.objectName, schema.aggregate, schema.filter, schema.xAxisKey]);
useEffect(() => {
const mounted = { current: true };
diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts b/packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts
new file mode 100644
index 000000000..0fa4191ac
--- /dev/null
+++ b/packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts
@@ -0,0 +1,329 @@
+/**
+ * Tests for ObjectChart groupBy value→label resolution.
+ *
+ * Verifies that resolveGroupByLabels():
+ * - Maps select field values to their option labels
+ * - Handles string-only options (value === label)
+ * - Falls back to humanizeLabel() when no options match
+ * - Resolves lookup field IDs to referenced record names
+ * - Gracefully handles missing metadata or dataSource errors
+ * - Falls back to humanizeLabel() for unknown field types
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { resolveGroupByLabels, humanizeLabel } from '../ObjectChart';
+
+describe('humanizeLabel', () => {
+ it('should convert snake_case to Title Case', () => {
+ expect(humanizeLabel('closed_won')).toBe('Closed Won');
+ });
+
+ it('should convert kebab-case to Title Case', () => {
+ expect(humanizeLabel('high-priority')).toBe('High Priority');
+ });
+
+ it('should handle single word', () => {
+ expect(humanizeLabel('active')).toBe('Active');
+ });
+
+ it('should handle empty string', () => {
+ expect(humanizeLabel('')).toBe('');
+ });
+});
+
+describe('resolveGroupByLabels', () => {
+ // ---- select fields ----
+
+ it('should map select field values to option labels', async () => {
+ const data = [
+ { stage: 'closed_won', amount: 500 },
+ { stage: 'prospecting', amount: 200 },
+ { stage: 'closed_lost', amount: 100 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ stage: {
+ type: 'select',
+ options: [
+ { value: 'prospecting', label: 'Prospecting' },
+ { value: 'closed_won', label: 'Closed Won' },
+ { value: 'closed_lost', label: 'Closed Lost' },
+ ],
+ },
+ },
+ };
+
+ const result = await resolveGroupByLabels(data, 'stage', objectSchema);
+
+ expect(result).toEqual([
+ { stage: 'Closed Won', amount: 500 },
+ { stage: 'Prospecting', amount: 200 },
+ { stage: 'Closed Lost', amount: 100 },
+ ]);
+ });
+
+ it('should handle string-only options where value equals label', async () => {
+ const data = [
+ { lead_source: 'Web', count: 10 },
+ { lead_source: 'Phone', count: 5 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ lead_source: {
+ type: 'select',
+ options: ['Web', 'Phone', 'Partner'],
+ },
+ },
+ };
+
+ const result = await resolveGroupByLabels(data, 'lead_source', objectSchema);
+
+ expect(result).toEqual([
+ { lead_source: 'Web', count: 10 },
+ { lead_source: 'Phone', count: 5 },
+ ]);
+ });
+
+ it('should humanize unmatched select values', async () => {
+ const data = [
+ { status: 'not_yet_started', count: 3 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ status: {
+ type: 'select',
+ options: [
+ { value: 'active', label: 'Active' },
+ ],
+ },
+ },
+ };
+
+ const result = await resolveGroupByLabels(data, 'status', objectSchema);
+
+ expect(result[0].status).toBe('Not Yet Started');
+ });
+
+ it('should humanize values when select field has empty options', async () => {
+ const data = [
+ { status: 'in_progress', count: 1 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ status: {
+ type: 'select',
+ options: [],
+ },
+ },
+ };
+
+ const result = await resolveGroupByLabels(data, 'status', objectSchema);
+ expect(result[0].status).toBe('In Progress');
+ });
+
+ // ---- lookup fields ----
+
+ it('should resolve lookup field IDs to record names', async () => {
+ const data = [
+ { account: '1', amount: 500 },
+ { account: '2', amount: 300 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ account: {
+ type: 'lookup',
+ reference_to: 'accounts',
+ },
+ },
+ };
+
+ const mockFind = vi.fn().mockResolvedValue([
+ { id: '1', name: 'Acme Corp' },
+ { id: '2', name: 'Globex Inc' },
+ ]);
+
+ const dataSource = { find: mockFind };
+
+ const result = await resolveGroupByLabels(data, 'account', objectSchema, dataSource);
+
+ expect(result).toEqual([
+ { account: 'Acme Corp', amount: 500 },
+ { account: 'Globex Inc', amount: 300 },
+ ]);
+
+ expect(mockFind).toHaveBeenCalledWith('accounts', {
+ $filter: { id: { $in: ['1', '2'] } },
+ $top: 2,
+ });
+ });
+
+ it('should use display_field from metadata for lookup label', async () => {
+ const data = [
+ { project: 'p1', hours: 40 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ project: {
+ type: 'lookup',
+ reference_to: 'projects',
+ display_field: 'title',
+ },
+ },
+ };
+
+ const mockFind = vi.fn().mockResolvedValue([
+ { id: 'p1', title: 'Website Redesign', name: 'PRJ-001' },
+ ]);
+
+ const result = await resolveGroupByLabels(data, 'project', objectSchema, { find: mockFind });
+
+ expect(result[0].project).toBe('Website Redesign');
+ });
+
+ it('should use id_field from metadata for lookup query', async () => {
+ const data = [
+ { owner: 'uid_42', count: 5 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ owner: {
+ type: 'lookup',
+ reference_to: 'users',
+ id_field: 'uid',
+ },
+ },
+ };
+
+ const mockFind = vi.fn().mockResolvedValue([
+ { uid: 'uid_42', name: 'Jane Doe' },
+ ]);
+
+ const result = await resolveGroupByLabels(data, 'owner', objectSchema, { find: mockFind });
+
+ expect(result[0].owner).toBe('Jane Doe');
+ expect(mockFind).toHaveBeenCalledWith('users', {
+ $filter: { uid: { $in: ['uid_42'] } },
+ $top: 1,
+ });
+ });
+
+ it('should handle lookup with reference property (ObjectStack convention)', async () => {
+ const data = [
+ { customer: '10', total: 100 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ customer: {
+ type: 'master_detail',
+ reference: 'customers',
+ },
+ },
+ };
+
+ const mockFind = vi.fn().mockResolvedValue([
+ { id: '10', name: 'Big Client' },
+ ]);
+
+ const result = await resolveGroupByLabels(data, 'customer', objectSchema, { find: mockFind });
+
+ expect(result[0].customer).toBe('Big Client');
+ });
+
+ it('should keep raw values when lookup fetch fails', async () => {
+ const data = [
+ { account: '1', amount: 500 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ account: {
+ type: 'lookup',
+ reference_to: 'accounts',
+ },
+ },
+ };
+
+ const dataSource = {
+ find: vi.fn().mockRejectedValue(new Error('Network error')),
+ };
+
+ const result = await resolveGroupByLabels(data, 'account', objectSchema, dataSource);
+
+ // Should gracefully return original data
+ expect(result[0].account).toBe('1');
+ });
+
+ it('should keep raw values when no dataSource is available for lookup', async () => {
+ const data = [
+ { account: '1', amount: 500 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ account: {
+ type: 'lookup',
+ reference_to: 'accounts',
+ },
+ },
+ };
+
+ const result = await resolveGroupByLabels(data, 'account', objectSchema);
+
+ expect(result[0].account).toBe('1');
+ });
+
+ // ---- fallback / edge cases ----
+
+ it('should humanize values when no field metadata exists', async () => {
+ const data = [
+ { category: 'high_priority', count: 5 },
+ ];
+
+ const objectSchema = { fields: {} };
+
+ const result = await resolveGroupByLabels(data, 'category', objectSchema);
+
+ expect(result[0].category).toBe('High Priority');
+ });
+
+ it('should humanize values for unknown field types', async () => {
+ const data = [
+ { region: 'north_america', revenue: 1000 },
+ ];
+
+ const objectSchema = {
+ fields: {
+ region: { type: 'text' },
+ },
+ };
+
+ const result = await resolveGroupByLabels(data, 'region', objectSchema);
+
+ expect(result[0].region).toBe('North America');
+ });
+
+ it('should return empty array for empty data', async () => {
+ const result = await resolveGroupByLabels([], 'stage', { fields: {} });
+ expect(result).toEqual([]);
+ });
+
+ it('should return data as-is when groupByField is empty', async () => {
+ const data = [{ stage: 'a', count: 1 }];
+ const result = await resolveGroupByLabels(data, '', { fields: {} });
+ expect(result).toEqual(data);
+ });
+
+ it('should handle null objectSchema gracefully', async () => {
+ const data = [{ stage: 'closed_won', count: 1 }];
+ const result = await resolveGroupByLabels(data, 'stage', null);
+ expect(result[0].stage).toBe('Closed Won');
+ });
+});