feat: add currency input field and enhance currency formatting utilities
- Introduced `FieldCurrencyInput` component for handling currency input in forms, supporting formatted display while maintaining numeric values. - Implemented `RenderCurrency` component for displaying currency values with proper formatting. - Enhanced `CurrencyUtils` to include parsing and formatting functions for Rupiah, ensuring accurate representation in UI. - Updated various components and forms to utilize the new currency input and rendering capabilities, improving user experience in financial data entry. - Added unit tests for currency input and formatting functionalities to ensure reliability and correctness. These changes enhance the application's handling of currency inputs and displays, providing a more user-friendly experience for financial transactions.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { FieldCurrencyInput } from '../fields/currency-input.field';
|
||||
|
||||
vi.mock('@repo/core-i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { exists: () => false },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('FieldCurrencyInput', () => {
|
||||
it('renders a formatted Rupiah value while keeping the form value as a number', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit, getValues } = useForm({
|
||||
defaultValues: { price: 12500.12345 },
|
||||
});
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldCurrencyInput name="price" control={control} label="Price" />
|
||||
<div data-testid="stored">{String(getValues('price'))}</div>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
expect(screen.getByLabelText('Price')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('stored')).toHaveTextContent('12500.12345');
|
||||
|
||||
await userEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ price: 12500.12345 }, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a number on change, not a formatted string', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({
|
||||
defaultValues: { price: '' as number | '' },
|
||||
});
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldCurrencyInput name="price" control={control} label="Price" />
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
const input = screen.getByLabelText('Price');
|
||||
await user.type(input, '15000,12345');
|
||||
await user.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onSubmit.mock.calls[0][0].price).toBe(15000.12345);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { NumberInput, type NumberInputProps } from '@mantine/core';
|
||||
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
|
||||
import { CURRENCY_DATA_SCALE, CurrencyUtils, toCurrencyNumber } from '@repo/utils';
|
||||
import { useTranslatedError } from '../useTranslatedError';
|
||||
|
||||
type ManagedProps = 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error';
|
||||
|
||||
export type FieldCurrencyInputProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = UseControllerProps<TFieldValues, TName> & Omit<NumberInputProps, ManagedProps>;
|
||||
|
||||
function toFieldNumber(next: string | number): number | '' {
|
||||
if (next === '') return '';
|
||||
const numeric = typeof next === 'number' ? next : Number(next);
|
||||
return Number.isFinite(numeric) ? numeric : '';
|
||||
}
|
||||
|
||||
function FieldCurrencyInputInner<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FieldCurrencyInputProps<TFieldValues, TName>) {
|
||||
const { name, control, rules, shouldUnregister, defaultValue, disabled, ...mantineProps } = props;
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController<TFieldValues, TName>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
});
|
||||
const translatedError = useTranslatedError(error?.message);
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
hideControls
|
||||
allowNegative={false}
|
||||
min={0}
|
||||
thousandSeparator="."
|
||||
decimalSeparator=","
|
||||
prefix={CurrencyUtils.getGlobalPrefix()}
|
||||
{...mantineProps}
|
||||
decimalScale={CURRENCY_DATA_SCALE}
|
||||
value={toCurrencyNumber(field.value)}
|
||||
onChange={(next) => field.onChange(toFieldNumber(next))}
|
||||
onBlur={field.onBlur}
|
||||
error={translatedError}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const FieldCurrencyInput = React.memo(FieldCurrencyInputInner) as typeof FieldCurrencyInputInner;
|
||||
(FieldCurrencyInput as { displayName?: string }).displayName = 'FieldCurrencyInput';
|
||||
@@ -25,6 +25,8 @@ export { FieldTextInput } from './fields/text-input.field';
|
||||
export { FieldPasswordInput } from './fields/password-input.field';
|
||||
export { FieldTextarea } from './fields/textarea.field';
|
||||
export { FieldNumberInput } from './fields/number-input.field';
|
||||
export { FieldCurrencyInput } from './fields/currency-input.field';
|
||||
export type { FieldCurrencyInputProps } from './fields/currency-input.field';
|
||||
export { FieldJsonInput } from './fields/json-input.field';
|
||||
export { FieldPinInput } from './fields/pin-input.field';
|
||||
export { FieldAutocomplete } from './fields/autocomplete.field';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './field-value';
|
||||
export * from './render-date';
|
||||
export * from './render-currency';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { RenderCurrency } from './render-currency';
|
||||
|
||||
describe('RenderCurrency', () => {
|
||||
it('renders Rupiah with two display decimals', () => {
|
||||
render(<RenderCurrency value="12500.12345" />);
|
||||
expect(screen.getByText('Rp 12.500,12')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the fallback when empty', () => {
|
||||
render(<RenderCurrency value={null} />);
|
||||
expect(screen.getByText('-')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { formatRupiah } from '@repo/utils';
|
||||
|
||||
export interface RenderCurrencyProps {
|
||||
value?: string | number | null;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
export function RenderCurrency({ value, fallback = '-' }: RenderCurrencyProps) {
|
||||
const formatted = formatRupiah(value);
|
||||
return <>{formatted || fallback}</>;
|
||||
}
|
||||
@@ -53,10 +53,7 @@ import { TableFilterDrawer, TableFilterConfig } from './components/table-filter-
|
||||
import { EntityId } from '../../../../../../core-api/src/data-services/types';
|
||||
import { computeActionColumnWidth } from './action-column.utils';
|
||||
import { formatAuditActor, formatAuditTimestamp, resolveAuditValue } from './audit-column.utils';
|
||||
import {
|
||||
resolveServerSideInitialRowCount,
|
||||
shouldRestorePaginationPage,
|
||||
} from './server-side-index.utils';
|
||||
import { resolveServerSideInitialRowCount, shouldRestorePaginationPage } from './server-side-index.utils';
|
||||
|
||||
export * from 'ag-grid-community';
|
||||
export * from 'ag-grid-react';
|
||||
|
||||
+2
-6
@@ -16,16 +16,12 @@ export function resolveServerSideInitialRowCount(
|
||||
return meta?.total;
|
||||
}
|
||||
|
||||
export function shouldRestorePaginationPage(
|
||||
meta: Pick<StandardPaginationMeta, 'page'> | null | undefined,
|
||||
): boolean {
|
||||
export function shouldRestorePaginationPage(meta: Pick<StandardPaginationMeta, 'page'> | null | undefined): boolean {
|
||||
return Boolean(meta?.page && meta.page > 1);
|
||||
}
|
||||
|
||||
/** Drop stale total/page after create so the next index fetch can grow by one row. */
|
||||
export function paginationMetaAfterCreate(
|
||||
meta: StandardPaginationMeta | null | undefined,
|
||||
): StandardPaginationMeta {
|
||||
export function paginationMetaAfterCreate(meta: StandardPaginationMeta | null | undefined): StandardPaginationMeta {
|
||||
return {
|
||||
limit: meta?.limit ?? DEFAULT_PAGE_SIZE,
|
||||
page: 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { CurrencyUtils } from './currency.utils';
|
||||
import { CurrencyUtils, formatRupiah, toCurrencyNumber } from './currency.utils';
|
||||
|
||||
describe('CurrencyUtils', () => {
|
||||
let currency: CurrencyUtils;
|
||||
@@ -70,7 +70,14 @@ describe('CurrencyUtils', () => {
|
||||
|
||||
it('should round decimal when decimalScale is set', () => {
|
||||
const c = new CurrencyUtils({ decimalScale: 2 });
|
||||
expect(c.format(1234.5678)).toBe('Rp 1.234,56');
|
||||
expect(c.format(1234.5678)).toBe('Rp 1.234,57');
|
||||
});
|
||||
|
||||
it('should pad to decimalScale for integers and single-decimal values', () => {
|
||||
const c = new CurrencyUtils({ decimalScale: 2 });
|
||||
expect(c.format(1234)).toBe('Rp 1.234,00');
|
||||
expect(c.format(1234.5)).toBe('Rp 1.234,50');
|
||||
expect(c.format('12500.12345')).toBe('Rp 12.500,12');
|
||||
});
|
||||
|
||||
it('should handle zero and negative values', () => {
|
||||
@@ -108,7 +115,42 @@ describe('CurrencyUtils', () => {
|
||||
|
||||
it('should format numbers with multiple decimals correctly when decimalScale is set', () => {
|
||||
const c = new CurrencyUtils({ decimalScale: 3 });
|
||||
expect(c.format(1234.56789)).toBe('Rp 1.234,567');
|
||||
expect(c.format(1234.56789)).toBe('Rp 1.234,568');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parsing to number', () => {
|
||||
it('should parse formatted string to a number without rounding', () => {
|
||||
expect(currency.parseToNumber('Rp 1.234.567')).toBe(1234567);
|
||||
expect(currency.parseToNumber('Rp 1.234,56789')).toBe(1234.56789);
|
||||
});
|
||||
|
||||
it('should return null for empty input', () => {
|
||||
expect(currency.parseToNumber(null)).toBeNull();
|
||||
expect(currency.parseToNumber(undefined)).toBeNull();
|
||||
expect(currency.parseToNumber('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRupiah', () => {
|
||||
it('should format with two display decimals without changing the source', () => {
|
||||
const stored = '12500.12345';
|
||||
expect(formatRupiah(stored)).toBe('Rp 12.500,12');
|
||||
expect(stored).toBe('12500.12345');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCurrencyNumber', () => {
|
||||
it('should keep numeric values as numbers', () => {
|
||||
expect(toCurrencyNumber(12500.12345)).toBe(12500.12345);
|
||||
expect(toCurrencyNumber('12500.12345')).toBe(12500.12345);
|
||||
});
|
||||
|
||||
it('should return empty string for blank or invalid values', () => {
|
||||
expect(toCurrencyNumber('')).toBe('');
|
||||
expect(toCurrencyNumber(null)).toBe('');
|
||||
expect(toCurrencyNumber(undefined)).toBe('');
|
||||
expect(toCurrencyNumber('abc')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,16 @@
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { NumberUtils } from '../number/number.utils';
|
||||
|
||||
export type CurrencyInput = number | string | null | undefined;
|
||||
|
||||
/** Display-only decimal places for Rupiah (tables, details, blurred inputs). */
|
||||
export const CURRENCY_DISPLAY_SCALE = 2;
|
||||
|
||||
/** Stored / form decimal places. Display must not truncate this precision. */
|
||||
export const CURRENCY_DATA_SCALE = 5;
|
||||
|
||||
export interface CurrencyOptions {
|
||||
/** Currency symbol or prefix for display, e.g., "Rp ", "$" */
|
||||
prefix?: string;
|
||||
@@ -120,20 +128,20 @@ export class CurrencyUtils {
|
||||
format(value: CurrencyInput): string {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
|
||||
const stringValue = `${value}`;
|
||||
const [integer, decimal] = stringValue.split('.');
|
||||
|
||||
// Add thousand separator to integer part
|
||||
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator);
|
||||
|
||||
// If no decimalScale or no decimal part, display full decimal
|
||||
if (this.decimalScale === undefined || !decimal) {
|
||||
if (this.decimalScale === undefined) {
|
||||
const stringValue = `${value}`;
|
||||
const [integer, decimal] = stringValue.split('.');
|
||||
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator);
|
||||
return `${this.prefix}${formattedInteger}${decimal ? this.decimalSeparator + decimal : ''}`;
|
||||
}
|
||||
|
||||
// Display rounded decimal according to decimalScale
|
||||
const roundedDecimal = decimal.slice(0, this.decimalScale);
|
||||
return `${this.prefix}${formattedInteger}${this.decimalSeparator}${roundedDecimal}`;
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return '';
|
||||
|
||||
const rounded = NumberUtils.roundToDecimal(numeric, this.decimalScale);
|
||||
const [integer, decimal] = rounded.toFixed(this.decimalScale).split('.');
|
||||
const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator);
|
||||
return `${this.prefix}${formattedInteger}${this.decimalSeparator}${decimal}`;
|
||||
}
|
||||
|
||||
/** ------------------------------------------------------------
|
||||
@@ -164,4 +172,33 @@ export class CurrencyUtils {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a formatted currency string into a number without rounding.
|
||||
* Returns null when the input is empty or not numeric.
|
||||
*/
|
||||
parseToNumber(displayValue: string | undefined | null): number | null {
|
||||
const raw = this.parseToRaw(displayValue);
|
||||
if (!raw) return null;
|
||||
const numeric = Number(raw);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
}
|
||||
|
||||
const displayCurrency = new CurrencyUtils({ decimalScale: CURRENCY_DISPLAY_SCALE });
|
||||
|
||||
/** Format a value as Rupiah with at most two display decimals. Does not mutate the source. */
|
||||
export function formatRupiah(value: CurrencyInput): string {
|
||||
return displayCurrency.format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a stored price (number or decimal string) into a NumberInput value.
|
||||
* Empty / invalid inputs stay as '' so the form field can remain blank.
|
||||
*/
|
||||
export function toCurrencyNumber(value: CurrencyInput): number | '' {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : '';
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user