diff --git a/components/src/FormatControls/FormatControls.test.tsx b/components/src/FormatControls/FormatControls.test.tsx index 7b582c15..6b5f6236 100644 --- a/components/src/FormatControls/FormatControls.test.tsx +++ b/components/src/FormatControls/FormatControls.test.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { FormatOptions } from '../model'; @@ -38,6 +38,10 @@ describe('FormatControls', () => { return screen.getByRole('checkbox', { name: 'Short values' }); }; + const getCustomLabelInput = (): HTMLElement => { + return screen.getByRole('textbox', { name: 'custom unit label' }); + }; + it('can change the unit by clicking', () => { const onChange = vi.fn(); renderFormatControls({ unit: 'minutes' }, onChange); @@ -59,6 +63,7 @@ describe('FormatControls', () => { renderFormatControls({ unit: 'bytes' }, onChange); const unitSelector = getUnitSelector(); + // Tab: Short values → Unit (Custom label present for bytes) userEvent.tab(); userEvent.tab(); expect(unitSelector).toHaveFocus(); @@ -76,6 +81,23 @@ describe('FormatControls', () => { }); }); + it('shows custom label for data-size units (bytes)', () => { + renderFormatControls({ unit: 'bytes' }); + expect(screen.getByRole('textbox', { name: 'custom unit label' })).toBeInTheDocument(); + }); + + it('preserves customLabel when switching to bytes', () => { + const onChange = vi.fn(); + renderFormatControls({ unit: 'ops/sec', customLabel: 'pnr/mn' }, onChange); + + userEvent.click(getUnitSelector()); + userEvent.click(screen.getByRole('option', { name: 'Bytes (IEC)' })); + expect(onChange).toHaveBeenCalledWith({ + unit: 'bytes', + customLabel: 'pnr/mn', + }); + }); + it('can change the decimal places by clicking', () => { const onChange = vi.fn(); renderFormatControls({ unit: 'decimal', decimalPlaces: 0, shortValues: true }, onChange); @@ -98,8 +120,8 @@ describe('FormatControls', () => { renderFormatControls({ unit: 'percent' }, onChange); const decimalPlacesSelector = getDecimalPlacesSelector(); - userEvent.tab(); - userEvent.tab(); + // Focus Decimals directly (tab order includes Custom label after Unit). + decimalPlacesSelector.focus(); expect(decimalPlacesSelector).toHaveFocus(); userEvent.clear(decimalPlacesSelector); @@ -115,6 +137,40 @@ describe('FormatControls', () => { }); }); + it('can set a custom label (spaces allowed in raw onChange)', () => { + const onChange = vi.fn(); + renderFormatControls({ unit: 'ops/sec' }, onChange); + + const input = getCustomLabelInput(); + // Controlled field + mock onChange does not re-render; fire a full value change. + fireEvent.change(input, { target: { value: 'pnr / mn' } }); + expect(onChange).toHaveBeenCalledWith({ + unit: 'ops/sec', + customLabel: 'pnr / mn', + }); + }); + + it('clears customLabel when the field is emptied', () => { + const onChange = vi.fn(); + renderFormatControls({ unit: 'ops/sec', customLabel: 'pnr/mn' }, onChange); + + const input = getCustomLabelInput(); + fireEvent.change(input, { target: { value: '' } }); + expect(onChange).toHaveBeenCalledWith({ unit: 'ops/sec' }); + }); + + it('preserves customLabel when changing unit', () => { + const onChange = vi.fn(); + renderFormatControls({ unit: 'ops/sec', customLabel: 'pnr/mn' }, onChange); + + userEvent.click(getUnitSelector()); + userEvent.click(screen.getByRole('option', { name: 'Decimal' })); + expect(onChange).toHaveBeenCalledWith({ + unit: 'decimal', + customLabel: 'pnr/mn', + }); + }); + it('can change shortValues by clicking', () => { const onChange = vi.fn(); renderFormatControls({ unit: 'decimal', decimalPlaces: 3, shortValues: true }, onChange); diff --git a/components/src/FormatControls/FormatControls.tsx b/components/src/FormatControls/FormatControls.tsx index 68224413..1de71c9f 100644 --- a/components/src/FormatControls/FormatControls.tsx +++ b/components/src/FormatControls/FormatControls.tsx @@ -11,11 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. import type { SwitchProps } from '@mui/material'; -import { Switch } from '@mui/material'; +import { Switch, TextField } from '@mui/material'; import type { ReactElement } from 'react'; import type { FormatOptions } from '../model'; -import { isUnitWithDecimalPlaces, isUnitWithShortValues, shouldShortenValues } from '../model'; +import { isUnitWithDecimalPlaces, isUnitWithShortValues, shouldShortenValues, supportsCustomLabel } from '../model'; import { OptionsEditorControl } from '../OptionsEditorLayout'; import { SettingsAutocomplete } from '../SettingsAutocomplete'; import { UnitSelector } from './UnitSelector'; @@ -46,9 +46,18 @@ export function FormatControls({ value, onChange, disabled = false }: FormatCont const hasShortValues = isUnitWithShortValues(value); const handleUnitChange = (newValue: FormatOptions | undefined): void => { - onChange(newValue || { unit: 'decimal' }); // Fallback to 'decimal' if undefined + const next = newValue || { unit: 'decimal' }; + const customLabel = value.customLabel?.trim(); + // Keep label only when the new unit supports customLabel (whitelist). + if (customLabel && supportsCustomLabel(next.unit)) { + onChange({ ...next, customLabel: value.customLabel }); + return; + } + onChange(next); }; + const showCustomLabel = supportsCustomLabel(value.unit); + const handleDecimalPlacesChange = ({ decimalPlaces, }: { @@ -73,6 +82,16 @@ export function FormatControls({ value, onChange, disabled = false }: FormatCont } }; + const handleCustomLabelChange = (raw: string): void => { + // Keep raw input while typing (spaces allowed). Trim only when clearing / display. + if (raw === '') { + const { customLabel: _removed, ...rest } = value; + onChange(rest as FormatOptions); + return; + } + onChange({ ...value, customLabel: raw }); + }; + return ( <> } /> + {showCustomLabel && ( + handleCustomLabelChange(e.target.value)} + placeholder="Optional display override (e.g. pnr/mn)" + disabled={disabled} + inputProps={{ 'aria-label': 'custom unit label', maxLength: 32 }} + /> + } + /> + )} { if (newValue === null) { onChange(undefined); - } else { - onChange({ unit: newValue.id } as FormatOptions); + return; } + onChange({ unit: newValue.id } as FormatOptions); }; return ( diff --git a/components/src/model/custom.test.ts b/components/src/model/custom.test.ts new file mode 100644 index 00000000..080f5856 --- /dev/null +++ b/components/src/model/custom.test.ts @@ -0,0 +1,156 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, it } from 'vitest'; + +import { applyCustomLabel, supportsCustomLabel } from './custom'; +import { formatValue } from './units'; + +describe('supportsCustomLabel', () => { + it('allows decimal, time, percent, throughput, data sizes, currency', () => { + expect(supportsCustomLabel('decimal')).toBe(true); + expect(supportsCustomLabel('ops/sec')).toBe(true); + expect(supportsCustomLabel('milliseconds')).toBe(true); + expect(supportsCustomLabel('bytes')).toBe(true); + expect(supportsCustomLabel('bytes/sec')).toBe(true); + expect(supportsCustomLabel('usd')).toBe(true); + expect(supportsCustomLabel(undefined)).toBe(true); + }); + + it('rejects date/time identity formats', () => { + expect(supportsCustomLabel('datetime-iso')).toBe(false); + expect(supportsCustomLabel('unix-timestamp')).toBe(false); + }); +}); + +describe('applyCustomLabel', () => { + it('replaces spaced unit suffix', () => { + expect(applyCustomLabel('1.5K ops/sec', 'pnr/mn', 'ops/sec')).toBe('1.5K pnr/mn'); + }); + + it('replaces percent suffix without leaving %', () => { + expect(applyCustomLabel('12%', 'load', 'percent')).toBe('12 load'); + }); + + it('replaces celsius suffix', () => { + expect(applyCustomLabel('11°C', 'room', 'celsius')).toBe('11 room'); + }); + + it('returns unchanged when label empty', () => { + expect(applyCustomLabel('42 ops/sec', '', 'ops/sec')).toBe('42 ops/sec'); + }); + + it('replaces Intl time narrow suffixes (ms, s)', () => { + expect(applyCustomLabel('500ms', 'latency', 'milliseconds')).toBe('500 latency'); + expect(applyCustomLabel('1.5s', 'wait', 'seconds')).toBe('1.5 wait'); + }); + + it('replaces Intl time long suffixes (month)', () => { + expect(applyCustomLabel('1 month', 'period', 'months')).toBe('1 period'); + expect(applyCustomLabel('2 months', 'period', 'months')).toBe('2 period'); + }); + + it('time zero sentinel', () => { + expect(applyCustomLabel('0s', 'period', 'months')).toBe('0 period'); + }); + + it('data size: keeps IEC scale, replaces base unit (not glued gigaprout)', () => { + expect(applyCustomLabel('1.46 KiB', 'prout', 'bytes')).toBe('1.46 Ki prout'); + expect(applyCustomLabel('1.4 GiB', 'prout', 'bytes')).toBe('1.4 Gi prout'); + expect(applyCustomLabel('500 bytes', 'prout', 'bytes')).toBe('500 prout'); + }); + + it('data size rate: keeps scale and /s', () => { + expect(applyCustomLabel('1.46 KiB/s', 'wire', 'bytes/sec')).toBe('1.46 Ki wire/s'); + expect(applyCustomLabel('1.5 KB/s', 'wire', 'decbytes/sec')).toBe('1.5 K wire/s'); + expect(applyCustomLabel('500 bytes/sec', 'wire', 'bytes/sec')).toBe('500 wire/sec'); + }); + + it('currency: amount + custom label', () => { + expect(applyCustomLabel('$1,500', 'credits', 'usd')).toBe('1,500 credits'); + expect(applyCustomLabel('€12.3', 'credits', 'eur')).toBe('12.3 credits'); + }); + + it('dates unchanged', () => { + const iso = '2023-11-14T22:13:20.000Z'; + expect(applyCustomLabel(iso, 'ignored', 'datetime-iso')).toBe(iso); + }); +}); + +describe('formatValue with customLabel', () => { + it('keeps unit key ops/sec and shows custom label', () => { + expect(formatValue(1500, { unit: 'ops/sec', shortValues: true, customLabel: 'pnr/mn' })).toBe('1.5K pnr/mn'); + }); + + it('works with decimal base', () => { + expect(formatValue(12.34, { unit: 'decimal', decimalPlaces: 1, customLabel: 'pax/mn' })).toBe('12.3 pax/mn'); + }); + + it('percent custom label replaces %', () => { + const out = formatValue(0.5, { unit: 'percent', customLabel: 'util' }); + expect(out).not.toContain('%'); + expect(out).toContain('util'); + }); + + it('without customLabel is unchanged', () => { + expect(formatValue(10, { unit: 'ops/sec' })).toBe('10 ops/sec'); + }); + + it('time months: no residual month/ms unit text', () => { + const out = formatValue(1, { unit: 'months', customLabel: 'billing' }); + expect(out.toLowerCase()).not.toMatch(/month|ms\b|week|day/); + expect(out).toContain('billing'); + }); + + it('time milliseconds: no residual ms', () => { + const out = formatValue(500, { unit: 'milliseconds', customLabel: 'latency' }); + expect(out.toLowerCase()).not.toContain('ms'); + expect(out).toContain('latency'); + }); + + it('bytes: scale kept, base replaced', () => { + const out = formatValue(1_500_000_000, { unit: 'bytes', shortValues: true, customLabel: 'prout' }); + expect(out).toMatch(/Gi prout$/); + expect(out.toLowerCase()).not.toContain('gib'); + expect(out).not.toMatch(/gigaprout/i); + }); + + it('bytes/sec: scale + rate kept', () => { + const out = formatValue(1_500_000, { unit: 'bytes/sec', shortValues: true, customLabel: 'wire' }); + expect(out).toMatch(/Mi wire\/s$/); + }); + + it('usd custom label', () => { + const out = formatValue(1500, { unit: 'usd', customLabel: 'credits' }); + expect(out).toContain('credits'); + expect(out).not.toContain('$'); + }); + + it('datetime-iso ignores customLabel', () => { + const plain = formatValue(1_700_000_000, { unit: 'datetime-iso' }); + const labeled = formatValue(1_700_000_000, { unit: 'datetime-iso', customLabel: 'when' }); + expect(labeled).toBe(plain); + }); + + // Time ticks rescale via Intl (month / week / day / ms); customLabel must fully replace each. + it('months customLabel: every tick scale is fully overridden (no mixed suffixes)', () => { + const label = 'custom'; + const fmt = { unit: 'months' as const, customLabel: label }; + const values = [1, 0.5, 0.1, 0.01, 0]; + for (const v of values) { + const out = formatValue(v, fmt); + expect(out, `value=${v}`).toMatch(new RegExp(`${label}$`)); + expect(out.toLowerCase(), `value=${v}`).not.toMatch(/\b(month|months|week|weeks|day|days|hour|ms|s)\b/); + } + }); +}); diff --git a/components/src/model/custom.ts b/components/src/model/custom.ts new file mode 100644 index 00000000..115217c7 --- /dev/null +++ b/components/src/model/custom.ts @@ -0,0 +1,235 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Optional display override on standard formats. + * `unit` remains the stable key (multi-axis, maps, UI config); + * `customLabel` only changes the text shown after the scaled quantity. + * + * Data sizes (bytes/bits): keep SI/IEC scale (Ki, Mi, K, M, …) and replace the + * base unit — e.g. "1.46 KiB" + "prout" → "1.46 Ki prout" (not "gigaprout"). + * Dates stay untouched (structured timestamps). + */ + +export type WithCustomLabel = { + customLabel?: string; +}; + +/** Units where customLabel is not applied (unstructured / identity formats). */ +const CUSTOM_LABEL_EXCLUDED_UNITS = new Set([ + 'datetime-iso', + 'datetime-us', + 'datetime-local', + 'date-iso', + 'date-us', + 'date-local', + 'time-local', + 'time-iso', + 'time-us', + 'relative-time', + 'unix-timestamp', + 'unix-timestamp-ms', +]); + +const DATA_SIZE_UNITS = new Set([ + 'bytes', + 'decbytes', + 'bits', + 'decbits', + 'bytes/sec', + 'decbytes/sec', + 'bits/sec', + 'decbits/sec', +]); + +const CURRENCY_UNITS = new Set([ + 'usd', + 'eur', + 'gbp', + 'jpy', + 'cny', + 'cad', + 'aud', + 'chf', + 'hkd', + 'sgd', + 'sek', + 'krw', + 'nok', + 'nzd', + 'inr', + 'mxn', + 'twd', + 'zar', + 'brl', + 'dkk', + 'pln', + 'thb', + 'ils', + 'czk', + 'clp', + 'php', + 'aed', + 'cop', + 'sar', + 'myr', + 'ron', + 'afn', +]); + +/** + * Whether FormatControls / formatValue should honor customLabel for this unit key. + */ +export function supportsCustomLabel(unit?: string): boolean { + if (!unit) { + return true; + } + return !CUSTOM_LABEL_EXCLUDED_UNITS.has(unit); +} + +/** Sign + number (grouped / scientific). */ +const LEADING_NUMBER = /^[+-]?(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?/; + +/** + * If customLabel is set and the unit is supported, replace the standard unit text + * with the display label. Dates leave `formatted` unchanged. + */ +export function applyCustomLabel(formatted: string, customLabel?: string, unit?: string): string { + const label = customLabel?.trim(); + if (!label) { + return formatted; + } + if (!supportsCustomLabel(unit)) { + return formatted; + } + + if (unit && DATA_SIZE_UNITS.has(unit)) { + return applyDataSizeCustomLabel(formatted, label); + } + + if (unit && CURRENCY_UNITS.has(unit)) { + return applyCurrencyCustomLabel(formatted, label); + } + + // formatTime zero sentinel + if (formatted === '0s') { + return `0 ${label}`; + } + + // Percent: "12.3%" → "12.3 load" + if (unit === 'percent' || unit === 'percent-decimal') { + if (formatted.endsWith('%')) { + return `${formatted.slice(0, -1).trimEnd()} ${label}`; + } + } + + // Temperature: "11°C" / "52°F" + if (unit === 'celsius' && formatted.endsWith('°C')) { + return `${formatted.slice(0, -2).trimEnd()} ${label}`; + } + if (unit === 'fahrenheit' && formatted.endsWith('°F')) { + return `${formatted.slice(0, -2).trimEnd()} ${label}`; + } + + // Exact unit-key suffix: "10 ops/sec", "1.5K ops/sec" + if (unit && formatted.endsWith(` ${unit}`)) { + return `${formatted.slice(0, -(unit.length + 1))} ${label}`; + } + + // Time / decimal: keep leading quantity only (drops "ms", " month", …) + const qty = leadingQuantity(formatted); + if (qty !== null) { + return `${qty} ${label}`; + } + + return `${formatted} ${label}`; +} + +/** + * Bytes/bits (+ optional /s|/sec): keep scale (Ki, Mi, K, M, …), replace base unit. + * "1.46 KiB" + prout → "1.46 Ki prout" + * "1.5 KB/s" + wire → "1.5 K wire/s" + * "500 bytes" + prout → "500 prout" + */ +function applyDataSizeCustomLabel(formatted: string, label: string): string { + let rate = ''; + let body = formatted.trim(); + const rateM = body.match(/(\/s(?:ec)?)$/i); + if (rateM?.[1]) { + rate = rateM[1]; + body = body.slice(0, -rate.length).trimEnd(); + } + + // Long form: "500 bytes", "1,500 bits" + const long = body.match(/^(.+?)\s+(bytes?|bits?)$/i); + if (long?.[1]) { + return `${long[1].trim()} ${label}${rate}`; + } + + // Short form: "1.46 KiB", "1.5 KB", "1.46 Kib", "1.5 Kb" (optional space before scale) + const short = body.match(/^(.+?)\s*([KMGTPE]i?)\s*([Bb])$/); + if (short?.[1] && short[2]) { + return `${short[1].trim()} ${short[2]} ${label}${rate}`; + } + + const qty = leadingQuantity(body); + if (qty !== null) { + return `${qty} ${label}${rate}`; + } + return `${formatted} ${label}`; +} + +/** + * Currency: drop symbol/code, keep amount + custom label. + * "$1,500" + credits → "1,500 credits" + */ +function applyCurrencyCustomLabel(formatted: string, label: string): string { + // Strip common leading/trailing currency symbols and ISO codes + let body = formatted.trim(); + body = body.replace(/^[^\d+-]+/, ''); // leading $ € … + body = body.replace(/\s*[A-Z]{3}$/, ''); // trailing USD + body = body.trim(); + const qty = leadingQuantity(body); + if (qty !== null) { + // Keep grouping from the rest of the amount if present after qty + const rest = body.slice(qty.length); + // If rest is only grouping leftovers empty, use qty; else full body without symbols + if (rest === '' || /^[\d,.]+$/.test(body.replace(LEADING_NUMBER, ''))) { + return `${body} ${label}`; + } + return `${body} ${label}`; + } + return `${body} ${label}`; +} + +/** + * Numeric prefix of a formatted value, keeping SI compact (1.5K) + * but not unit letters glued to the number (500ms → 500, not 500m). + */ +function leadingQuantity(formatted: string): string | null { + const s = formatted.trimStart(); + const num = s.match(LEADING_NUMBER); + if (!num?.[0]) { + return null; + } + const head = num[0]; + const rest = s.slice(head.length); + + // Compact decimal suffix K/M/B/T only when not starting a longer unit word (ms, min, …). + const compact = rest.match(/^([KMBT])(?=\s|$)/i); + if (compact) { + return head + compact[1]; + } + + return head; +} diff --git a/components/src/model/index.ts b/components/src/model/index.ts index 11e524e2..248acbe4 100644 --- a/components/src/model/index.ts +++ b/components/src/model/index.ts @@ -25,6 +25,7 @@ export * from './percent'; export * from './temperature'; export * from './decimal'; export * from './throughput'; +export * from './custom'; export * from './formatterCache'; export * from './units'; export * from './utils'; diff --git a/components/src/model/units.ts b/components/src/model/units.ts index 59f1717f..0b984492 100644 --- a/components/src/model/units.ts +++ b/components/src/model/units.ts @@ -17,6 +17,8 @@ import type { BytesFormatOptions } from './bytes'; import { formatBytes, BYTES_GROUP_CONFIG, BYTES_UNIT_CONFIG } from './bytes'; import type { CurrencyFormatOptions } from './currency'; import { formatCurrency, CURRENCY_GROUP_CONFIG, CURRENCY_UNIT_CONFIG } from './currency'; +import type { WithCustomLabel } from './custom'; +import { applyCustomLabel, supportsCustomLabel } from './custom'; import type { DateFormatOptions } from './date'; import { formatDate, DATE_GROUP_CONFIG, DATE_UNIT_CONFIG } from './date'; import type { DecimalFormatOptions } from './decimal'; @@ -62,7 +64,8 @@ export const UNIT_CONFIG = { ...DATE_UNIT_CONFIG, } as const; -export type FormatOptions = +/** Standard unit options plus optional display override (customLabel). */ +export type FormatOptions = ( | TimeFormatOptions | PercentFormatOptions | DecimalFormatOptions @@ -71,7 +74,9 @@ export type FormatOptions = | ThroughputFormatOptions | CurrencyFormatOptions | TemperatureFormatOptions - | DateFormatOptions; + | DateFormatOptions +) & + WithCustomLabel; type HasDecimalPlaces = UnitOpt extends { decimalPlaces?: number } ? UnitOpt : never; type HasShortValues = UnitOpt extends { shortValues?: boolean } ? UnitOpt : never; @@ -81,49 +86,42 @@ export function formatValue(value: number, formatOptions?: FormatOptions): strin return value.toString(); } + let formatted: string; if (isBytesUnit(formatOptions)) { - return formatBytes(value, formatOptions); + formatted = formatBytes(value, formatOptions); + } else if (isBitsUnit(formatOptions)) { + formatted = formatBits(value, formatOptions); + } else if (isDecimalUnit(formatOptions)) { + formatted = formatDecimal(value, formatOptions); + } else if (isPercentUnit(formatOptions)) { + formatted = formatPercent(value, formatOptions); + } else if (isTimeUnit(formatOptions)) { + formatted = formatTime(value, formatOptions); + } else if (isThroughputUnit(formatOptions)) { + formatted = formatThroughput(value, formatOptions); + } else if (isCurrencyUnit(formatOptions)) { + formatted = formatCurrency(value, formatOptions); + } else if (isDateUnit(formatOptions)) { + formatted = formatDate(value, formatOptions); + } else if (isTemperatureUnit(formatOptions)) { + formatted = formatTemperature(value, formatOptions); + } else { + const exhaustive: never = formatOptions; + throw new Error(`Unknown unit options ${exhaustive}`); } - if (isBitsUnit(formatOptions)) { - return formatBits(value, formatOptions); - } - - if (isDecimalUnit(formatOptions)) { - return formatDecimal(value, formatOptions); - } - - if (isPercentUnit(formatOptions)) { - return formatPercent(value, formatOptions); - } - - if (isTimeUnit(formatOptions)) { - return formatTime(value, formatOptions); - } - - if (isThroughputUnit(formatOptions)) { - return formatThroughput(value, formatOptions); - } - - if (isCurrencyUnit(formatOptions)) { - return formatCurrency(value, formatOptions); - } - - if (isDateUnit(formatOptions)) { - return formatDate(value, formatOptions); - } - - if (isTemperatureUnit(formatOptions)) { - return formatTemperature(value, formatOptions); - } - - const exhaustive: never = formatOptions; - throw new Error(`Unknown unit options ${exhaustive}`); + return applyCustomLabel(formatted, formatOptions.customLabel, formatOptions.unit); } export function getUnitConfig(formatOptions: FormatOptions): UnitConfig { const unit = formatOptions.unit ?? 'decimal'; - return UNIT_CONFIG[unit]; + const config = UNIT_CONFIG[unit]; + const customLabel = formatOptions.customLabel?.trim(); + // Only override display name when customLabel is supported for this unit key. + if (customLabel && supportsCustomLabel(unit)) { + return { ...config, label: customLabel }; + } + return config; } export function getUnitGroup(formatOptions: FormatOptions): UnitGroup { @@ -136,23 +134,23 @@ export function getUnitGroupConfig(formatOptions: FormatOptions): UnitGroupConfi } // Type guards -export function isTimeUnit(formatOptions: FormatOptions): formatOptions is TimeFormatOptions { +export function isTimeUnit(formatOptions: FormatOptions): formatOptions is TimeFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Time'; } -export function isPercentUnit(formatOptions: FormatOptions): formatOptions is PercentFormatOptions { +export function isPercentUnit(formatOptions: FormatOptions): formatOptions is PercentFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Percent'; } -export function isDecimalUnit(formatOptions: FormatOptions): formatOptions is DecimalFormatOptions { +export function isDecimalUnit(formatOptions: FormatOptions): formatOptions is DecimalFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Decimal'; } -export function isBytesUnit(formatOptions: FormatOptions): formatOptions is BytesFormatOptions { +export function isBytesUnit(formatOptions: FormatOptions): formatOptions is BytesFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Bytes'; } -export function isBitsUnit(formatOptions: FormatOptions): formatOptions is BitsFormatOptions { +export function isBitsUnit(formatOptions: FormatOptions): formatOptions is BitsFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Bits'; } @@ -170,18 +168,22 @@ export function isUnitWithShortValues(formatOptions: FormatOptions): formatOptio return !!groupConfig.shortValues; } -export function isThroughputUnit(formatOptions: FormatOptions): formatOptions is ThroughputFormatOptions { +export function isThroughputUnit( + formatOptions: FormatOptions, +): formatOptions is ThroughputFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Throughput'; } -export function isCurrencyUnit(formatOptions: FormatOptions): formatOptions is CurrencyFormatOptions { +export function isCurrencyUnit(formatOptions: FormatOptions): formatOptions is CurrencyFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Currency'; } -export function isDateUnit(formatOptions: FormatOptions): formatOptions is DateFormatOptions { +export function isDateUnit(formatOptions: FormatOptions): formatOptions is DateFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Date'; } -export function isTemperatureUnit(formatOptions: FormatOptions): formatOptions is TemperatureFormatOptions { +export function isTemperatureUnit( + formatOptions: FormatOptions, +): formatOptions is TemperatureFormatOptions & WithCustomLabel { return getUnitGroup(formatOptions) === 'Temperature'; } diff --git a/cue-test/common/format.cue b/cue-test/common/format.cue index cac80338..313c50d6 100644 --- a/cue-test/common/format.cue +++ b/cue-test/common/format.cue @@ -14,6 +14,15 @@ package common myFormat: #format & { + unit: "decimal" decimalPlaces: 0 shortValues: false } + +// Standard unit key + org display label (multi-axis still keys on unit). +myFormatWithCustomLabel: #format & { + unit: "ops/sec" + customLabel: "pnr/mn" + shortValues: true + decimalPlaces: 1 +} diff --git a/cue/common/format.cue b/cue/common/format.cue index 4c0f13bc..561f59a2 100644 --- a/cue/common/format.cue +++ b/cue/common/format.cue @@ -13,19 +13,27 @@ package common -#format: #simpleFormat | #floatFormat | #shortenableFormat +import "strings" + +// Shared optional fields once; unit is refined by each format branch. +#format: { + unit?: string + // Optional display override (axis / legend / tooltip). unit stays the stable key. + customLabel?: strings.MinRunes(1) + #simpleFormat | #floatFormat | #shortenableFormat +} #simpleFormat: { - unit?: #dateFormat.unit + unit?: #dateFormat.unit } #floatFormat: { - unit?: #timeFormat.unit | #percentFormat.unit | #currencyFormat.unit | #temperatureFormat.unit - decimalPlaces?: number + unit?: #timeFormat.unit | #percentFormat.unit | #currencyFormat.unit | #temperatureFormat.unit + decimalPlaces?: number } #shortenableFormat: { - unit?: #decimalFormat.unit | #bitsFormat.unit | #bytesFormat.unit | #throughputFormat.unit + unit?: #decimalFormat.unit | #bitsFormat.unit | #bytesFormat.unit | #throughputFormat.unit decimalPlaces?: number shortValues?: bool }