Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 17 additions & 3 deletions packages/plugin-charts/src/AdvancedChartImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => [
Expand Down Expand Up @@ -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 })}
/>
<YAxis yAxisId="left" tickLine={false} axisLine={false} />
<YAxis yAxisId="right" orientation="right" tickLine={false} axisLine={false} />
Expand Down Expand Up @@ -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 })}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend
Expand Down
135 changes: 134 additions & 1 deletion packages/plugin-charts/src/ObjectChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { ChartRenderer } from './ChartRenderer';
import { ComponentRegistry, extractRecords } from '@object-ui/core';
import { AlertCircle } from 'lucide-react';

/**
* Humanize a snake_case or kebab-case string into Title Case.
* Local implementation to avoid a dependency on @object-ui/fields.
*/
export function humanizeLabel(value: string): string {
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
Comment on lines 7 to +13
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

humanizeLabel() duplicates the existing implementation in @object-ui/fields (packages/fields/src/index.tsx). If keeping charts independent from fields is important, consider moving this shared helper to a common package (e.g. @object-ui/core utils) so the behavior doesn’t diverge over time across packages.

Suggested change
/**
* Humanize a snake_case or kebab-case string into Title Case.
* Local implementation to avoid a dependency on @object-ui/fields.
*/
export function humanizeLabel(value: string): string {
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
import startCase from 'lodash/startCase';
/**
* Humanize a snake_case or kebab-case string into Title Case.
* Uses lodash's startCase to avoid duplicating logic from other packages.
*/
export function humanizeLabel(value: string): string {
const normalized = value.replace(/[_-]+/g, ' ').trim();
return startCase(normalized);

Copilot uses AI. Check for mistakes.
}

/**
* Client-side aggregation for fetched records.
* Groups records by `groupBy` field and applies the aggregation function
Expand Down Expand Up @@ -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<any[]> {
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<string, string> = {};
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,
});
Comment on lines +131 to +141
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lookup label resolution hardcodes the ID field in the query as id ($filter: { id: { $in: ids } }). Elsewhere (e.g. LookupField) metadata can specify id_field, and some datasets use _id. Consider deriving the ID field from lookup metadata (fallback to id) and normalizing ids to strings to improve compatibility across DataSource implementations.

Copilot uses AI. Check for mistakes.
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<string, string> = {};
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';

Expand Down Expand Up @@ -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);
}
Expand All @@ -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 };
Expand Down
Loading