Skip to content
Open
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
59 changes: 56 additions & 3 deletions components/src/FormatControls/FormatControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -59,6 +63,7 @@ describe('FormatControls', () => {
renderFormatControls({ unit: 'bytes' }, onChange);

const unitSelector = getUnitSelector();
// bytes: no Custom label — tab Short values → Unit → Decimals
userEvent.tab();
userEvent.tab();
expect(unitSelector).toHaveFocus();
Expand All @@ -76,6 +81,20 @@ describe('FormatControls', () => {
});
});

it('hides custom label for unsupported units (bytes)', () => {
renderFormatControls({ unit: 'bytes' });
expect(screen.queryByRole('textbox', { name: 'custom unit label' })).not.toBeInTheDocument();
});

it('drops customLabel when switching to an unsupported unit', () => {
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' });
});

it('can change the decimal places by clicking', () => {
const onChange = vi.fn();
renderFormatControls({ unit: 'decimal', decimalPlaces: 0, shortValues: true }, onChange);
Expand All @@ -98,8 +117,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);
Expand All @@ -115,6 +134,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);
Expand Down
41 changes: 38 additions & 3 deletions components/src/FormatControls/FormatControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
}: {
Expand All @@ -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 (
<>
<OptionsEditorControl
Expand All @@ -89,6 +108,22 @@ export function FormatControls({ value, onChange, disabled = false }: FormatCont
label="Unit"
control={<UnitSelector value={value} onChange={handleUnitChange} disabled={disabled} />}
/>
{showCustomLabel && (
<OptionsEditorControl
label="Custom label"
control={
<TextField
size="small"
fullWidth
value={value.customLabel ?? ''}
onChange={(e) => handleCustomLabelChange(e.target.value)}
placeholder="Optional display override (e.g. pnr/mn)"
disabled={disabled}
inputProps={{ 'aria-label': 'custom unit label', maxLength: 32 }}
/>
}
/>
)}
<OptionsEditorControl
label="Decimals"
control={
Expand Down
4 changes: 2 additions & 2 deletions components/src/FormatControls/UnitSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ export function UnitSelector({ value, onChange, disabled = false, ...otherProps
const handleChange = (_: unknown, newValue: AutocompleteUnitOption | null): void => {
if (newValue === null) {
onChange(undefined);
} else {
onChange({ unit: newValue.id } as FormatOptions);
return;
}
onChange({ unit: newValue.id } as FormatOptions);
};

return (
Expand Down
127 changes: 127 additions & 0 deletions components/src/model/custom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// 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, count-rate throughput', () => {
expect(supportsCustomLabel('decimal')).toBe(true);
expect(supportsCustomLabel('ops/sec')).toBe(true);
expect(supportsCustomLabel('milliseconds')).toBe(true);
expect(supportsCustomLabel('months')).toBe(true);
expect(supportsCustomLabel('percent')).toBe(true);
expect(supportsCustomLabel(undefined)).toBe(true);
});

it('rejects bits/bytes and currency (SI prefix / complex formats)', () => {
expect(supportsCustomLabel('bytes')).toBe(false);
expect(supportsCustomLabel('bits/sec')).toBe(false);
expect(supportsCustomLabel('bytes/sec')).toBe(false);
expect(supportsCustomLabel('decbytes/sec')).toBe(false);
expect(supportsCustomLabel('usd')).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('does not apply to unsupported units (bytes/sec)', () => {
expect(applyCustomLabel('1.5KB/s', 'wire', 'bytes/sec')).toBe('1.5KB/s');
});

it('returns unchanged when label empty', () => {
expect(applyCustomLabel('42 ops/sec', '', 'ops/sec')).toBe('42 ops/sec');
expect(applyCustomLabel('42 ops/sec', undefined, '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');
});
});

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('throughput ops/sec compact', () => {
expect(formatValue(1500, { unit: 'ops/sec', shortValues: true, customLabel: 'trx/s' })).toBe('1.5K trx/s');
});

it('bytes/sec ignores customLabel (not on whitelist)', () => {
const withLabel = formatValue(1500, { unit: 'bytes/sec', shortValues: true, customLabel: 'wire' });
const plain = formatValue(1500, { unit: 'bytes/sec', shortValues: true });
expect(withLabel).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/);
}
});
});
Loading
Loading