From 0a6c6dd5d4e7e4ede040b0be2719706146330b11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:52:02 +0000 Subject: [PATCH 1/4] Initial plan From 1860732b381706b9b0a052f2113df48f487bf769 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:34:21 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(plugin-charts):=20add=20groupBy=20valu?= =?UTF-8?q?e=E2=86=92label=20resolution=20and=20remove=20XAxis=20truncatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- .../plugin-charts/src/AdvancedChartImpl.tsx | 14 +- packages/plugin-charts/src/ObjectChart.tsx | 130 +++++++- .../ObjectChart.labelResolution.test.ts | 277 ++++++++++++++++++ 3 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx index d6611335e..0bf7eb8b9 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -245,7 +245,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 && data.some((d: any) => String(d[xAxisKey] || '').length > 5) && { angle: -35, textAnchor: 'end', height: 60 })} /> @@ -282,7 +287,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 && data.some((d: any) => String(d[xAxisKey] || '').length > 5) && { 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,114 @@ 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; + + try { + const results = await dataSource.find(referenceTo, { + $filter: { id: { $in: ids } }, + $top: ids.length, + }); + const records = extractRecords(results); + + // Build id→name map using common display fields + const idToName: Record = {}; + for (const rec of records) { + const id = String(rec.id ?? rec._id ?? ''); + const name = 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 +214,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 +237,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..dc12c8e00 --- /dev/null +++ b/packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts @@ -0,0 +1,277 @@ +/** + * 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 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'); + }); +}); From 6e7373da2b619aeefceba489d817fc738b0e7892 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:44:10 +0000 Subject: [PATCH 3/4] refactor(plugin-charts): memoize hasLongLabels check in AdvancedChartImpl Address code review feedback: memoize the data.some() long label check using useMemo to avoid O(n) recalculation on every render. 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> --- CHANGELOG.md | 2 ++ packages/plugin-charts/src/AdvancedChartImpl.tsx | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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 0bf7eb8b9..ab1feec6c 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -112,6 +112,12 @@ export default function AdvancedChartImpl({ 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 = () => [ 'hsl(var(--chart-1))', @@ -250,7 +256,7 @@ export default function AdvancedChartImpl({ if (isMobile && value.length > 8) return value.slice(0, 8) + '…'; return value; }} - {...(!isMobile && data.some((d: any) => String(d[xAxisKey] || '').length > 5) && { angle: -35, textAnchor: 'end', height: 60 })} + {...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end', height: 60 })} /> @@ -292,7 +298,7 @@ export default function AdvancedChartImpl({ if (isMobile && value.length > 8) return value.slice(0, 8) + '…'; return value; }} - {...(!isMobile && data.some((d: any) => String(d[xAxisKey] || '').length > 5) && { angle: -35, textAnchor: 'end', height: 60 })} + {...(!isMobile && hasLongLabels && { angle: -35, textAnchor: 'end', height: 60 })} /> } /> Date: Wed, 1 Apr 2026 03:58:38 +0000 Subject: [PATCH 4/4] fix(plugin-charts): address all review feedback - Use id_field/display_field from lookup metadata instead of hardcoding 'id'/'name' - Remove console.log from AdvancedChartImpl render path - Add tests for display_field and id_field metadata support Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/2a93e570-33d5-4c95-9f22-d13493ed8da4 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugin-charts/src/AdvancedChartImpl.tsx | 2 - packages/plugin-charts/src/ObjectChart.tsx | 13 +++-- .../ObjectChart.labelResolution.test.ts | 52 +++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx index ab1feec6c..73122d0ab 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -110,8 +110,6 @@ 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), diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index e22e1edaf..730cee38a 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -131,18 +131,23 @@ export async function resolveGroupByLabels( 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: { id: { $in: ids } }, + $filter: { [idField]: { $in: ids } }, $top: ids.length, }); const records = extractRecords(results); - // Build id→name map using common display fields + // 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.id ?? rec._id ?? ''); - const name = rec.name || rec.label || rec.title || id; + 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); } diff --git a/packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts b/packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts index dc12c8e00..0fa4191ac 100644 --- a/packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts +++ b/packages/plugin-charts/src/__tests__/ObjectChart.labelResolution.test.ts @@ -162,6 +162,58 @@ describe('resolveGroupByLabels', () => { }); }); + 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 },