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
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ export * from './utils/drill-down.js';
export * from './utils/date-macros.js';
export * from './utils/compare-to.js';
export * from './utils/chart-series.js';
export * from './utils/dataset-format.js';
78 changes: 78 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import {
formatMeasure,
formatDimensionValue,
buildDatasetFieldHelpers,
type DatasetResultField,
} from '../dataset-format';

describe('formatMeasure', () => {
it('renders null as an em dash and passes non-numbers through', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure('n/a')).toBe('n/a');
});

it('formats a plain number with no currency and no misleading $', () => {
expect(formatMeasure(1234, '0,0')).toBe('1,234');
// No format hint → integers render verbatim (the documented plain path).
expect(formatMeasure(1234)).toBe('1234');
expect(formatMeasure(1234, '0,0')).not.toContain('$');
});

it('uses the declared currency via Intl (CNY → ¥ family, never bare/wrong)', () => {
const out = formatMeasure(1234, '0,0', 'CNY');
expect(out).toMatch(/[¥¥]|CN¥/);
expect(out).toContain('1,234');
});

it('honors a legacy $ literal in the format string when there is no currency', () => {
expect(formatMeasure(1000, '$0,0')).toBe('$1,000');
});

it('applies percent and decimal hints', () => {
expect(formatMeasure(50, '0%')).toBe('50%');
expect(formatMeasure(12.5, '0.0')).toBe('12.5');
});

it('falls back to plain formatting for an unknown currency code', () => {
expect(formatMeasure(1234, '0,0', 'NOTACODE')).toBe('1,234');
});
});

describe('formatDimensionValue', () => {
it('tidies nulls and integers, leaves strings intact', () => {
expect(formatDimensionValue(null)).toBe('—');
expect(formatDimensionValue(42)).toBe('42');
expect(formatDimensionValue('Backlog')).toBe('Backlog');
});
});

describe('buildDatasetFieldHelpers', () => {
const fields: DatasetResultField[] = [
{ name: 'status', type: 'string', label: 'Stage' },
{ name: 'amount', type: 'number', label: 'Amount', format: '0,0', currency: 'USD' },
];

it('headerLabel: field label → i18n fieldLabel → raw name', () => {
const fieldLabel = (_o: string, _f: string, fb: string) => `i18n:${fb}`;
const { headerLabel } = buildDatasetFieldHelpers(fields, 'deal', fieldLabel);
// i18n hook wraps the field label fallback.
expect(headerLabel('status')).toBe('i18n:Stage');
// unknown field → raw name flows through the i18n layer.
expect(headerLabel('missing')).toBe('i18n:missing');
});

it('headerLabel falls back to field label when no object/fieldLabel given', () => {
const { headerLabel } = buildDatasetFieldHelpers(fields, undefined);
expect(headerLabel('amount')).toBe('Amount');
expect(headerLabel('missing')).toBe('missing');
});

it('measureField exposes format/currency', () => {
const { measureField } = buildDatasetFieldHelpers(fields, 'deal');
expect(measureField('amount')?.format).toBe('0,0');
expect(measureField('amount')?.currency).toBe('USD');
expect(measureField('nope')).toBeUndefined();
});
});
110 changes: 110 additions & 0 deletions packages/core/src/utils/dataset-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* dataset-format — shared value formatting for semantic-layer `queryDataset`
* results (ADR-0021). Used by every dataset-bound surface (dashboard widgets,
* reports, the dataset preview) so a measure renders identically everywhere:
* a currency amount carries its declared currency symbol, a plain number stays
* a plain number, and a dimension label passes through untouched.
*
* Pure (no React / i18n) so it can live in `@object-ui/core` and be imported
* by both `@object-ui/plugin-dashboard` and `@object-ui/plugin-report`.
*/

/**
* Column metadata the analytics server returns alongside the rows: a display
* `label` for both dimensions and measures, plus a measure's numeral `format`
* and declared `currency`. A superset of {@link ChartResultField} (which only
* needs name/label/format).
*/
export interface DatasetResultField {
name: string;
type?: string;
label?: string;
format?: string;
currency?: string;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
* string — an amount with no declared currency must render as a plain number,
* never a misleading "$". The numeral `format` hint (e.g. "0,0", "0.0%")
* controls grouping / decimals / percent; it can't be baked into the row value
* server-side (the same number feeds charts), so it is applied here.
*/
export function formatMeasure(v: unknown, format?: string, currency?: string): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);

const decimals = format ? (format.split('.')[1]?.match(/0/g)?.length ?? 0) : undefined;

if (currency) {
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 2,
}).format(v);
} catch {
// Unknown currency code → fall through to plain number formatting.
}
}

if (!format) {
// No format hint → preserve the plain rendering (integers verbatim).
return Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
const isPercent = format.includes('%');
// A legacy "$" literal in the format string is still honored (explicit author
// choice) — but it is NOT how a real currency field gets its symbol.
const legacyDollar = format.includes('$') ? '$' : '';
const body = v.toLocaleString(undefined, { minimumFractionDigits: decimals ?? 0, maximumFractionDigits: decimals ?? 0 });
return `${legacyDollar}${body}${isPercent ? '%' : ''}`;
}

/**
* Format a non-measure (dimension / label) value — the server already resolves
* dimension display labels, so this only tidies numbers and nulls.
*/
export function formatDimensionValue(v: unknown): string {
if (v == null) return '—';
if (typeof v === 'number') {
return Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
return String(v);
}

/**
* Resolve a column header / measure helper pair for one result set.
* `headerLabel` maps a dimension/measure NAME to its display label — the
* server-enriched field `label`, then (when `fieldLabel` is supplied) through
* the i18n field-label convention so a translated label wins, then the raw name
* as a last resort. `measureField` exposes a field's `format`/`currency`.
*
* `fieldLabel` is injected (rather than imported) so this stays React/i18n-free;
* callers pass `useSafeFieldLabel().fieldLabel`.
*/
export function buildDatasetFieldHelpers(
fields: DatasetResultField[] | undefined,
object: string | undefined,
fieldLabel?: (objectName: string, fieldName: string, fallback: string) => string,
): {
measureField: (name: string) => DatasetResultField | undefined;
headerLabel: (name: string) => string;
} {
const fieldByName = new Map((fields ?? []).map((f) => [f.name, f] as const));
const measureField = (name: string) => fieldByName.get(name);
const headerLabel = (name: string) => {
const fallback = measureField(name)?.label ?? name;
return object && fieldLabel ? fieldLabel(object, name, fallback) : fallback;
};
return { measureField, headerLabel };
}
4 changes: 3 additions & 1 deletion packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1836,7 +1836,9 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
},
): Promise<{
rows: Array<Record<string, unknown>>;
fields: Array<{ name: string; type: string }>;
/** Column metadata: a display `label` (dimensions and measures), and a
* measure's numeral `format` + declared `currency` for value formatting. */
fields: Array<{ name: string; type: string; label?: string; format?: string; currency?: string }>;
/** ADR-0021 D2 drill-through: the dataset's base object (records to drill into). */
object?: string;
/** Drillable dimension NAME → underlying object FIELD name. */
Expand Down
2 changes: 1 addition & 1 deletion packages/i18n/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export { createI18n, getDirection, getAvailableLanguages, type I18nConfig, type
export { I18nProvider, useObjectTranslation, useI18nContext, type I18nProviderProps } from './provider';

// Safe translation hook factory
export { createSafeTranslation } from './useSafeTranslation';
export { createSafeTranslation, useSafeTranslate } from './useSafeTranslation';

// Convention-based object/field label i18n
export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel';
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ const ar = {
language: "اللغة",
},
report: {
total: "الإجمالي",
rowTotal: "إجمالي الصف",
columnTotal: "إجمالي العمود",
grandTotal: "الإجمالي الكلي",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ const de = {
language: "Sprache",
},
report: {
total: "Gesamt",
rowTotal: "Zeilensumme",
columnTotal: "Spaltensumme",
grandTotal: "Gesamtsumme",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ const en = {
loading: 'Loading chart...',
},
report: {
total: 'Total',
rowTotal: 'Row Total',
columnTotal: 'Column Total',
grandTotal: 'Grand Total',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ const es = {
language: "Idioma",
},
report: {
total: "Total",
rowTotal: "Total fila",
columnTotal: "Total columna",
grandTotal: "Gran total",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ const fr = {
language: "Langue",
},
report: {
total: "Total",
rowTotal: "Total ligne",
columnTotal: "Total colonne",
grandTotal: "Total général",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ const ja = {
language: "言語",
},
report: {
total: "合計",
rowTotal: "行合計",
columnTotal: "列合計",
grandTotal: "総合計",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ const ko = {
language: "언어",
},
report: {
total: "합계",
rowTotal: "행 합계",
columnTotal: "열 합계",
grandTotal: "총합계",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ const pt = {
language: "Idioma",
},
report: {
total: "Total",
rowTotal: "Total da linha",
columnTotal: "Total da coluna",
grandTotal: "Total geral",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,7 @@ const ru = {
language: "Язык",
},
report: {
total: "Итого",
rowTotal: "Итог строки",
columnTotal: "Итог столбца",
grandTotal: "Общий итог",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ const zh = {
loading: '图表加载中...',
},
report: {
total: '总计',
rowTotal: '行合计',
columnTotal: '列合计',
grandTotal: '总计',
Expand Down
30 changes: 30 additions & 0 deletions packages/i18n/src/useSafeTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,33 @@ export function createSafeTranslation(
}
};
}

/**
* Per-call graceful translate hook for plugin renderers.
*
* Returns `t(keyOrKeys, fallback)`: tries each i18n key in order and returns the
* first real translation; when no `I18nProvider` is mounted (tests / standalone)
* or every key is missing, returns the English `fallback` — never a raw key.
*
* Unlike {@link createSafeTranslation} (a factory keyed by a defaults map), this
* takes the English default at each call site, which suits one-off labels like
* "Total". The key-array form supports a migration fallback chain, e.g.
* `tt(['common.total', 'dashboard.total'], 'Total')`.
*/
export function useSafeTranslate(): (keyOrKeys: string | string[], fallback: string) => string {
let t: ((key: string) => string) | undefined;
try {
t = useObjectTranslation().t;
} catch {
t = undefined;
}
return (keyOrKeys, fallback) => {
if (!t) return fallback;
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
for (const key of keys) {
const v = t(key);
if (v && v !== key) return v;
}
return fallback;
};
}
Loading
Loading