feat: Allow customizing empty-period fill value (#1617)

Closes HDX-3220
Closes HDX-1718
Closes HDX-3205

# Summary

This PR adds an option that allows users to customize the 0-fill behavior on time charts. The default behavior remains to fill all empty intervals with 0. The user can now disable the filling behavior. When fill is disabled, series will appear to be interpolated.

This PR also consolidates various display settings into a drawer, replacing the existing Number Format drawer. In the process, various form-related bugs were fixed in the drawer, and micro/nano second input factors were added.

## New Chart Display Settings Drawer

<img width="1697" height="979" alt="Screenshot 2026-01-20 at 9 10 59 AM" src="https://github.com/user-attachments/assets/1683666a-7c56-4018-8e5b-2c6c814f0cd2" />

## Zero-fill behavior

Enabled (default):

<img width="1458" height="494" alt="Screenshot 2026-01-20 at 9 12 45 AM" src="https://github.com/user-attachments/assets/0306644e-d2ff-46d6-998b-eb458d5c9ccc" />

Disabled:

<img width="1456" height="505" alt="Screenshot 2026-01-20 at 9 12 37 AM" src="https://github.com/user-attachments/assets/f084887e-4099-4365-af4f-73eceaf5dc3d" />
This commit is contained in:
Drew Davis 2026-01-21 17:13:16 -05:00 committed by GitHub
parent 418828e89d
commit ddc54e43f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 494 additions and 258 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Allow customizing zero-fill behavior

View file

@ -19,6 +19,7 @@ import {
import {
AggregateFunction as AggFnV2,
ChartConfigWithDateRange,
ChartConfigWithOptDateRange,
ChartConfigWithOptTimestamp,
DisplayType,
Filter,
@ -1157,3 +1158,10 @@ export function buildMVDateRangeIndicator({
/>
);
}
export function shouldFillNullsWithZero(
fillNulls: ChartConfigWithOptDateRange['fillNulls'],
): boolean {
// To match legacy behavior, fill nulls with 0 unless explicitly disabled
return fillNulls !== false;
}

View file

@ -500,6 +500,7 @@ export const MemoChart = memo(function MemoChart({
})}
name={seriesName}
isAnimationActive={false}
connectNulls
/>
);
});

View file

@ -326,7 +326,7 @@ describe('ChartUtils', () => {
]);
});
it('should zero-fill missing time buckets', () => {
it('should zero-fill missing time buckets when generateEmptyBuckets is undefined', () => {
const res = {
data: [
{
@ -372,7 +372,6 @@ describe('ChartUtils', () => {
currentPeriodResponse: res,
dateRange: [new Date(1764159780000), new Date(1764159900000)],
granularity: '1 minute',
generateEmptyBuckets: true,
});
expect(actual.graphResults).toEqual([
@ -442,6 +441,97 @@ describe('ChartUtils', () => {
]);
});
it('should fill missing time buckets with zero when generateEmptyBuckets is true', () => {
const res = {
data: [
{
'count()': 10,
__hdx_time_bucket: '2025-11-26T12:23:00Z',
},
{
'count()': 20,
__hdx_time_bucket: '2025-11-26T12:25:00Z',
},
],
meta: [
{
name: 'count()',
type: 'UInt64',
},
{
name: '__hdx_time_bucket',
type: 'DateTime',
},
],
};
const actual = formatResponseForTimeChart({
currentPeriodResponse: res,
dateRange: [new Date(1764159780000), new Date(1764159900000)],
granularity: '1 minute',
generateEmptyBuckets: true,
});
expect(actual.graphResults).toEqual([
{
__hdx_time_bucket: 1764159780,
'count()': 10,
},
{
__hdx_time_bucket: 1764159840,
'count()': 0,
},
{
__hdx_time_bucket: 1764159900,
'count()': 20,
},
]);
});
it('should not fill missing time buckets when generateEmptyBuckets is false', () => {
const res = {
data: [
{
'count()': 10,
__hdx_time_bucket: '2025-11-26T12:23:00Z',
},
{
'count()': 20,
__hdx_time_bucket: '2025-11-26T12:25:00Z',
},
],
meta: [
{
name: 'count()',
type: 'UInt64',
},
{
name: '__hdx_time_bucket',
type: 'DateTime',
},
],
};
const actual = formatResponseForTimeChart({
currentPeriodResponse: res,
dateRange: [new Date(1764159780000), new Date(1764159900000)],
granularity: '1 minute',
generateEmptyBuckets: false,
});
// Should only have the two data points, no filled buckets
expect(actual.graphResults).toEqual([
{
__hdx_time_bucket: 1764159780,
'count()': 10,
},
{
__hdx_time_bucket: 1764159900,
'count()': 20,
},
]);
});
it('should plot previous period data when provided, shifted to align with current period', () => {
const currentPeriodResponse = {
data: [

View file

@ -149,12 +149,12 @@ describe('formatNumber', () => {
expect(formatNumber(1234567, format)).toBe('1,234,567');
});
it('applies factor multiplication', () => {
it('does not apply factor multiplication', () => {
const format: NumberFormat = {
output: 'number',
factor: 0.001, // Convert to milliseconds
};
expect(formatNumber(1000, format)).toBe('1');
expect(formatNumber(1000, format)).toBe('1000');
});
});
@ -211,6 +211,40 @@ describe('formatNumber', () => {
});
});
describe('time format', () => {
it('formats seconds input', () => {
const format: NumberFormat = {
output: 'time',
factor: 1, // seconds
};
expect(formatNumber(3661, format)).toBe('1:01:01');
});
it('formats milliseconds input', () => {
const format: NumberFormat = {
output: 'time',
factor: 0.001, // milliseconds
};
expect(formatNumber(61000, format)).toBe('0:01:01');
});
it('formats microseconds input', () => {
const format: NumberFormat = {
output: 'time',
factor: 0.000001, // microseconds
};
expect(formatNumber(1000000, format)).toBe('0:00:01');
});
it('formats nanoseconds input', () => {
const format: NumberFormat = {
output: 'time',
factor: 0.000000001, // nanoseconds
};
expect(formatNumber(1000000001, format)).toBe('0:00:01');
});
});
describe('unit handling', () => {
it('appends unit to formatted number', () => {
const format: NumberFormat = {

View file

@ -0,0 +1,144 @@
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import {
ChartConfigWithDateRange,
DisplayType,
} from '@hyperdx/common-utils/dist/types';
import {
Box,
Button,
Checkbox,
Divider,
Drawer,
Group,
Stack,
} from '@mantine/core';
import { shouldFillNullsWithZero } from '@/ChartUtils';
import { FormatTime } from '@/useFormatTime';
import { DEFAULT_NUMBER_FORMAT, NumberFormatForm } from './NumberFormat';
export type ChartConfigDisplaySettings = Pick<
ChartConfigWithDateRange,
| 'numberFormat'
| 'alignDateRangeToGranularity'
| 'fillNulls'
| 'compareToPreviousPeriod'
>;
interface ChartDisplaySettingsDrawerProps {
opened: boolean;
settings: ChartConfigDisplaySettings;
displayType: DisplayType;
previousDateRange?: [Date, Date];
onChange: (settings: ChartConfigDisplaySettings) => void;
onClose: () => void;
}
function applyDefaultSettings({
numberFormat,
alignDateRangeToGranularity,
compareToPreviousPeriod,
fillNulls,
}: ChartConfigDisplaySettings): ChartConfigDisplaySettings {
return {
numberFormat: numberFormat ?? DEFAULT_NUMBER_FORMAT,
alignDateRangeToGranularity:
alignDateRangeToGranularity == null ? true : alignDateRangeToGranularity,
fillNulls: fillNulls ?? 0,
compareToPreviousPeriod: compareToPreviousPeriod ?? false,
};
}
export default function ChartDisplaySettingsDrawer({
settings,
opened,
displayType,
onChange,
onClose,
previousDateRange,
}: ChartDisplaySettingsDrawerProps) {
const { control, handleSubmit, register, reset, setValue } =
useForm<ChartConfigDisplaySettings>({
defaultValues: applyDefaultSettings(settings),
});
const fillNulls = useWatch({ control, name: 'fillNulls' });
const isFillNullsEnabled = shouldFillNullsWithZero(fillNulls);
const handleClose = useCallback(() => {
reset(applyDefaultSettings(settings)); // Reset to default values, without saving
onClose();
}, [onClose, reset, settings]);
const applyChanges = useCallback(() => {
handleSubmit(onChange)();
onClose();
}, [onChange, handleSubmit, onClose]);
const resetToDefaults = useCallback(() => {
reset(applyDefaultSettings({}));
}, [reset]);
const isTimeChart =
displayType === DisplayType.Line || displayType === DisplayType.StackedBar;
return (
<Drawer
title="Display Settings"
opened={opened}
onClose={handleClose}
position="right"
>
<Stack>
{isTimeChart && (
<>
<Checkbox
size="xs"
label="Show Complete Intervals"
{...register('alignDateRangeToGranularity')}
/>
<Box>
<Checkbox
size="xs"
label="Fill Missing Intervals with Zero"
checked={isFillNullsEnabled}
onChange={e => {
setValue('fillNulls', e.currentTarget.checked ? 0 : false);
}}
/>
</Box>
<Checkbox
size="xs"
label="Compare to Previous Period"
description={
previousDateRange && (
<>
(
<FormatTime value={previousDateRange[0]} format="short" />
{' - '}
<FormatTime value={previousDateRange[1]} format="short" />)
</>
)
}
{...register('compareToPreviousPeriod')}
/>
<Divider />
</>
)}
<NumberFormatForm control={control} />
<Divider />
<Group gap="xs" mt="xs" justify="space-between">
<Button type="submit" variant="secondary" onClick={resetToDefaults}>
Reset to Defaults
</Button>
<Button type="submit" variant="primary" onClick={applyChanges}>
Apply
</Button>
</Group>
</Stack>
</Drawer>
);
}

View file

@ -45,6 +45,7 @@ import {
Text,
Textarea,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconArrowDown,
IconArrowUp,
@ -82,7 +83,6 @@ import { GranularityPickerControlled } from '@/GranularityPicker';
import { useFetchMetricResourceAttrs } from '@/hooks/useFetchMetricResourceAttrs';
import SearchInputV2 from '@/SearchInputV2';
import { getFirstTimestampValueExpression, useSource } from '@/source';
import { FormatTime } from '@/useFormatTime';
import {
getMetricTableName,
optionsToSelectData,
@ -99,20 +99,20 @@ import {
} from '@/utils/alerts';
import HDXMarkdownChart from '../HDXMarkdownChart';
import type { NumberFormat } from '../types';
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
import { AggFnSelectControlled } from './AggFnSelect';
import ChartDisplaySettingsDrawer, {
ChartConfigDisplaySettings,
} from './ChartDisplaySettingsDrawer';
import DBNumberChart from './DBNumberChart';
import DBSqlRowTableWithSideBar from './DBSqlRowTableWithSidebar';
import {
CheckBoxControlled,
InputControlled,
SwitchControlled,
TextInputControlled,
} from './InputControlled';
import { MetricNameSelect } from './MetricNameSelect';
import { NumberFormatInput } from './NumberFormat';
import SaveToDashboardModal from './SaveToDashboardModal';
import SourceSchemaPreview from './SourceSchemaPreview';
import { SourceSelectControlled } from './SourceSelect';
@ -160,30 +160,6 @@ const validateMetricNames = (
return false;
};
const NumberFormatInputControlled = ({
control,
onSubmit,
}: {
control: Control<any>;
onSubmit: () => void;
}) => {
return (
<Controller
control={control}
name="numberFormat"
render={({ field: { onChange, value } }) => (
<NumberFormatInput
onChange={(newValue?: NumberFormat) => {
onChange(newValue);
onSubmit();
}}
value={value}
/>
)}
/>
);
};
type SeriesItem = NonNullable<
SavedChartConfigWithSelectArray['select']
>[number];
@ -537,14 +513,6 @@ export default function EditTimeChartForm({
const whereLanguage = useWatch({ control, name: 'whereLanguage' });
const alert = useWatch({ control, name: 'alert' });
const seriesReturnType = useWatch({ control, name: 'seriesReturnType' });
const compareToPreviousPeriod = useWatch({
control,
name: 'compareToPreviousPeriod',
});
const alignDateRangeToGranularity = useWatch({
control,
name: 'alignDateRangeToGranularity',
});
const groupBy = useWatch({ control, name: 'groupBy' });
const displayType =
useWatch({ control, name: 'displayType' }) ?? DisplayType.Line;
@ -580,6 +548,41 @@ export default function EditTimeChartForm({
const showGeneratedSql = ['table', 'time', 'number'].includes(activeTab); // Whether to show the generated SQL preview
const showSampleEvents = tableSource?.kind !== SourceKind.Metric;
const [
alignDateRangeToGranularity,
fillNulls,
compareToPreviousPeriod,
numberFormat,
] = useWatch({
control,
name: [
'alignDateRangeToGranularity',
'fillNulls',
'compareToPreviousPeriod',
'numberFormat',
],
});
const displaySettings: ChartConfigDisplaySettings = useMemo(
() => ({
alignDateRangeToGranularity,
fillNulls,
compareToPreviousPeriod,
numberFormat,
}),
[
alignDateRangeToGranularity,
fillNulls,
compareToPreviousPeriod,
numberFormat,
],
);
const [
displaySettingsOpened,
{ open: openDisplaySettings, close: closeDisplaySettings },
] = useDisclosure(false);
// Only update this on submit, otherwise we'll have issues
// with using the source value from the last submit
// (ex. ignoring local custom source updates)
@ -762,34 +765,6 @@ export default function EditTimeChartForm({
});
}, [dateRange]);
// Trigger a search when "Show Complete Intervals" changes
useEffect(() => {
setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
if (config == null) {
return config;
}
return {
...config,
alignDateRangeToGranularity,
};
});
}, [alignDateRangeToGranularity]);
// Trigger a search when "compare to previous period" changes
useEffect(() => {
setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
if (config == null) {
return config;
}
return {
...config,
compareToPreviousPeriod,
};
});
}, [compareToPreviousPeriod]);
const queryReady = isQueryReady(queriedConfig);
// The chart config to use when showing the user the generated SQL
@ -877,6 +852,22 @@ export default function EditTimeChartForm({
// Need to force a rerender on change as the modal will not be mounted when initially rendered
const [parentRef, setParentRef] = useState<HTMLElement | null>(null);
const handleUpdateDisplaySettings = useCallback(
({
numberFormat,
alignDateRangeToGranularity,
fillNulls,
compareToPreviousPeriod,
}: ChartConfigDisplaySettings) => {
setValue('numberFormat', numberFormat);
setValue('alignDateRangeToGranularity', alignDateRangeToGranularity);
setValue('fillNulls', fillNulls);
setValue('compareToPreviousPeriod', compareToPreviousPeriod);
onSubmit();
},
[setValue, onSubmit],
);
return (
<div ref={setParentRef} data-testid={dataTestId}>
<Controller
@ -1091,10 +1082,13 @@ export default function EditTimeChartForm({
</Button>
)}
</Group>
<NumberFormatInputControlled
control={control}
onSubmit={onSubmit}
/>
<Button
onClick={openDisplaySettings}
size="compact-sm"
variant="secondary"
>
Display Settings
</Button>
</Flex>
</>
) : (
@ -1288,33 +1282,6 @@ export default function EditTimeChartForm({
)}
</Flex>
</Flex>
{activeTab === 'time' && (
<Group justify="end" mb="xs">
<SwitchControlled
control={control}
name="alignDateRangeToGranularity"
label="Show Complete Intervals"
/>
<SwitchControlled
control={control}
name="compareToPreviousPeriod"
label={
<>
Compare to Previous Period{' '}
{!dashboardId && (
<>
(
<FormatTime value={previousDateRange?.[0]} format="short" />
{' - '}
<FormatTime value={previousDateRange?.[1]} format="short" />
)
</>
)}
</>
}
/>
</Group>
)}
{!queryReady && activeTab !== 'markdown' ? (
<Paper shadow="xs" p="xl">
<Center mih={400}>
@ -1462,6 +1429,14 @@ export default function EditTimeChartForm({
opened={saveToDashboardModalOpen}
onClose={() => setSaveToDashboardModalOpen(false)}
/>
<ChartDisplaySettingsDrawer
opened={displaySettingsOpened}
settings={displaySettings}
previousDateRange={!dashboardId ? previousDateRange : undefined}
displayType={displayType}
onChange={handleUpdateDisplaySettings}
onClose={closeDisplaySettings}
/>
</div>
);
}

View file

@ -36,6 +36,7 @@ import {
formatResponseForTimeChart,
getPreviousDateRange,
PreviousPeriodSuffix,
shouldFillNullsWithZero,
useTimeChartSettings,
} from '@/ChartUtils';
import { MemoChart } from '@/HDXMultiSeriesTimeChart';
@ -395,7 +396,7 @@ function DBTimeChartComponent({
: undefined,
dateRange,
granularity,
generateEmptyBuckets: fillNulls !== false,
generateEmptyBuckets: shouldFillNullsWithZero(fillNulls),
source,
hiddenSeries,
previousPeriodOffsetSeconds,

View file

@ -1,36 +1,26 @@
import * as React from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useMemo } from 'react';
import { Control, Controller, useWatch } from 'react-hook-form';
import { NumberFormat } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Button,
Checkbox as MCheckbox,
Drawer,
NativeSelect,
Paper,
Slider,
Stack,
TextInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconClock,
IconCurrencyDollar,
IconDatabase,
IconNumbers,
IconPercentage,
IconX,
} from '@tabler/icons-react';
import { NumberFormat } from '../types';
import { formatNumber } from '../utils';
const FORMAT_NAMES: Record<string, string> = {
number: 'Number',
currency: 'Currency',
percent: 'Percentage',
byte: 'Bytes',
time: 'Time',
};
import { ChartConfigDisplaySettings } from './ChartDisplaySettingsDrawer';
const FORMAT_ICONS: Record<string, React.ReactNode> = {
number: <IconNumbers size={14} />,
@ -40,48 +30,26 @@ const FORMAT_ICONS: Record<string, React.ReactNode> = {
time: <IconClock size={14} />,
};
const DEFAULT_NUMBER_FORMAT: NumberFormat = {
const TEST_NUMBER = 1234;
export const DEFAULT_NUMBER_FORMAT: NumberFormat = {
factor: 1,
output: 'number',
output: 'number' as const,
mantissa: 2,
thousandSeparated: true,
average: false,
decimalBytes: false,
};
const TEST_NUMBER = 1234;
export const NumberFormatForm: React.FC<{
value?: NumberFormat;
onApply: (value: NumberFormat) => void;
onClose: () => void;
}> = ({ value, onApply, onClose }) => {
const { register, handleSubmit, control, setValue } = useForm<NumberFormat>({
defaultValues: value ?? DEFAULT_NUMBER_FORMAT,
});
const format = useWatch({ control });
control: Control<ChartConfigDisplaySettings>;
}> = ({ control }) => {
const format =
useWatch({ control, name: 'numberFormat' }) ?? DEFAULT_NUMBER_FORMAT;
return (
<>
<Stack style={{ flex: 1 }}>
{/* <TextInput
label="Coefficient"
type="number"
description="Multiply number by this value before formatting. You can use it to convert source value to seconds, bytes, base currency, etc."
{...register('factor', { valueAsNumber: true })}
rightSectionWidth={70}
rightSection={
<Button
variant="default"
compact
size="sm"
onClick={() => setValue('factor', 1)}
>
Reset
</Button>
}
/> */}
<div
style={{
display: 'flex',
@ -91,25 +59,34 @@ export const NumberFormatForm: React.FC<{
gap: 10,
}}
>
<NativeSelect
label="Output format"
leftSection={format.output && FORMAT_ICONS[format.output]}
style={{ flex: 1 }}
data={[
{ value: 'number', label: 'Number' },
{ value: 'currency', label: 'Currency' },
{ value: 'byte', label: 'Bytes' },
{ value: 'percent', label: 'Percentage' },
{ value: 'time', label: 'Time' },
]}
{...register('output')}
<Controller
control={control}
key="numberFormat.output"
name="numberFormat.output"
render={({ field }) => (
<NativeSelect
{...field}
label="Output format"
leftSection={format.output && FORMAT_ICONS[format.output]}
style={{ flex: 1 }}
data={[
{ value: 'number', label: 'Number' },
{ value: 'currency', label: 'Currency' },
{ value: 'byte', label: 'Bytes' },
{ value: 'percent', label: 'Percentage' },
{ value: 'time', label: 'Time' },
]}
/>
)}
/>
{format.output === 'currency' && (
<TextInput
w={80}
label="Symbol"
placeholder="$"
{...register('currencySymbol')}
<Controller
control={control}
key="numberFormat.currencySymbol"
name="numberFormat.currencySymbol"
render={({ field }) => (
<TextInput {...field} w={80} label="Symbol" placeholder="$" />
)}
/>
)}
</div>
@ -123,124 +100,121 @@ export const NumberFormatForm: React.FC<{
>
Example
</div>
{formatNumber(TEST_NUMBER, format as NumberFormat)}
{formatNumber(TEST_NUMBER || 0, format)}
</Paper>
</div>
{format.output !== 'time' && (
<div>
<div className="fs-8 mt-2 fw-bold mb-1">Decimals</div>
<Slider
mb="xl"
min={0}
max={10}
label={value => `Decimals: ${value}`}
marks={[
{ value: 0, label: '0' },
{ value: 10, label: '10' },
]}
value={format.mantissa}
onChange={value => {
setValue('mantissa', value);
}}
<Controller
control={control}
key="numberFormat.mantissa"
name="numberFormat.mantissa"
render={({ field: { value, onChange } }) => (
<Slider
mb="xl"
min={0}
max={10}
label={val => `Decimals: ${val}`}
marks={[
{ value: 0, label: '0' },
{ value: 10, label: '10' },
]}
value={value ?? 2}
onChange={onChange}
/>
)}
/>
</div>
)}
<Stack gap="xs">
{format.output === 'byte' ? (
<MCheckbox
size="xs"
label="Decimal base"
description="Use 1KB = 1000 bytes"
{...register('decimalBytes')}
<Controller
control={control}
key="numberFormat.decimalBytes"
name="numberFormat.decimalBytes"
render={({ field: { value, onChange, ...field } }) => {
return (
<MCheckbox
{...field}
size="xs"
label="Decimal base"
description="Use 1KB = 1000 bytes"
checked={value}
onChange={onChange}
/>
);
}}
/>
) : format.output === 'time' ? (
<NativeSelect
size="sm"
label="Input unit"
{...register('factor', {
setValueAs: value => parseFloat(value),
})}
data={[
{ value: '1', label: 'Seconds' },
{ value: '0.001', label: 'Milliseconds' },
]}
<Controller
control={control}
key="numberFormat.factor"
name="numberFormat.factor"
render={({ field: { value, onChange, ...field } }) => {
const options = useMemo(
() => [
{ value: '1', label: 'Seconds' },
{ value: '0.001', label: 'Milliseconds' },
{ value: '0.000001', label: 'Microseconds' },
{ value: '0.000000001', label: 'Nanoseconds' },
],
[],
);
const stringValue =
options.find(option => parseFloat(option.value) === value)
?.value ?? '1';
return (
<NativeSelect
{...field}
size="sm"
label="Input unit"
value={stringValue}
onChange={e => onChange(parseFloat(e.target.value))}
data={options}
/>
);
}}
/>
) : (
<>
<MCheckbox
size="xs"
label="Separate thousands"
description="For example: 1,234,567"
{...register('thousandSeparated')}
<Controller
control={control}
key="numberFormat.thousandSeparated"
name="numberFormat.thousandSeparated"
render={({ field: { value, onChange, ...field } }) => (
<MCheckbox
{...field}
size="xs"
label="Separate thousands"
description="For example: 1,234,567"
checked={value}
onChange={onChange}
/>
)}
/>
<MCheckbox
size="xs"
label="Large number format"
description="For example: 1.2m"
{...register('average')}
<Controller
control={control}
key="numberFormat.average"
name="numberFormat.average"
render={({ field: { value, onChange, ...field } }) => (
<MCheckbox
{...field}
size="xs"
label="Large number format"
description="For example: 1.2m"
checked={value}
onChange={onChange}
/>
)}
/>
</>
)}
</Stack>
<Stack gap="xs" mt="xs">
<Button type="submit" onClick={handleSubmit(onApply)}>
Apply
</Button>
<Button onClick={onClose} variant="secondary">
Cancel
</Button>
</Stack>
</Stack>
</>
);
};
export const NumberFormatInput: React.FC<{
value?: NumberFormat;
onChange: (value?: NumberFormat) => void;
}> = ({ value, onChange }) => {
const [opened, { open, close }] = useDisclosure(false);
const handleApply = React.useCallback(
(value?: NumberFormat) => {
onChange(value);
close();
},
[onChange, close],
);
return (
<>
<Drawer
opened={opened}
onClose={close}
title="Number format"
position="right"
padding="lg"
zIndex={100000}
>
<NumberFormatForm value={value} onApply={handleApply} onClose={close} />
</Drawer>
<Button.Group>
<Button
onClick={open}
size="compact-sm"
variant="secondary"
leftSection={value?.output && FORMAT_ICONS[value.output]}
>
{value?.output ? FORMAT_NAMES[value.output] : 'Set number format'}
</Button>
{value?.output && (
<ActionIcon
size="sm"
variant="secondary"
onClick={() => handleApply(undefined)}
>
<IconX size={14} />
</ActionIcon>
)}
</Button.Group>
</>
);
};

View file

@ -630,8 +630,12 @@ export const formatNumber = (
currencySymbol: options.currencySymbol || '$',
}),
};
// Factor is only currently available for the time output
const factor = options.output === 'time' ? (options.factor ?? 1) : 1;
return (
numbro(value * (options.factor ?? 1)).format(numbroFormat) +
numbro(value * factor).format(numbroFormat) +
(options.unit ? ` ${options.unit}` : '')
);
};