Use amount input on rules page instead of plain text field (#2566)

* Use amount input on rules page instead of plain text field

* Add release notes

* Remove unneeded attributes

* Support percent formatting

* Lint + typecheck

* Fix latent bug

* Handle existing data correctly

* PR feedback: naming

* PR feedback: force percent to a positive number

* PR feedback: reset percent to 100 upon changing input type

* Fix input clamping behaviour

* Empty commit to bump ci

* PR feedback: prop cleanup

* PR feedback: no default number format

* PR feedback: cosmetic refactor
This commit is contained in:
Julian Dominguez-Schatz
2024-06-05 11:25:37 -04:00
committed by GitHub
parent d62919a357
commit b89a32025a
5 changed files with 176 additions and 0 deletions

View File

@@ -235,6 +235,7 @@ function ConditionEditor({
value={value}
multi={op === 'oneOf' || op === 'notOneOf'}
onChange={v => onChange('value', v)}
numberFormatType="currency"
/>
);
}
@@ -365,6 +366,7 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
op={op}
value={value}
onChange={v => onChange('value', v)}
numberFormatType="currency"
/>
</View>
</>
@@ -386,6 +388,9 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
key={inputKey}
field={field}
type="number"
numberFormatType={
options.method === 'fixed-percent' ? 'percentage' : 'currency'
}
value={value}
onChange={v => onChange('value', v)}
/>

View File

@@ -7,6 +7,7 @@ import { integerToCurrency } from 'loot-core/src/shared/util';
export type FormatType =
| 'string'
| 'number'
| 'percentage'
| 'financial'
| 'financial-with-sign';
@@ -25,6 +26,8 @@ function format(
return val;
case 'number':
return '' + value;
case 'percentage':
return value + '%';
case 'financial-with-sign':
const formatted = format(value, 'financial', formatter);
if (typeof value === 'number' && value >= 0) {

View File

@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import { useReports } from 'loot-core/client/data-hooks/reports';
import { getMonthYearFormat } from 'loot-core/src/shared/months';
import { integerToAmount, amountToInteger } from 'loot-core/src/shared/util';
import { useCategories } from '../../hooks/useCategories';
import { useDateFormat } from '../../hooks/useDateFormat';
@@ -18,10 +19,14 @@ import { Checkbox } from '../forms';
import { DateSelect } from '../select/DateSelect';
import { RecurringSchedulePicker } from '../select/RecurringSchedulePicker';
import { AmountInput } from './AmountInput';
import { PercentInput } from './PercentInput';
export function GenericInput({
field,
subfield,
type,
numberFormatType = undefined,
multi,
value,
inputRef,
@@ -33,6 +38,37 @@ export function GenericInput({
const saved = useSelector(state => state.queries.saved);
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const getNumberInputByFormatType = numberFormatType => {
switch (numberFormatType) {
case 'currency':
return (
<AmountInput
inputRef={inputRef}
value={amountToInteger(value)}
onUpdate={v => onChange(integerToAmount(v))}
/>
);
case 'percentage':
return (
<PercentInput
inputRef={inputRef}
value={value}
onUpdatePercent={onChange}
/>
);
default:
return (
<Input
inputRef={inputRef}
defaultValue={value || ''}
placeholder="nothing"
onEnter={e => onChange(e.target.value)}
onBlur={e => onChange(e.target.value)}
/>
);
}
};
// This makes the UI more resilient in case of faulty data
if (multi && !Array.isArray(value)) {
value = [];
@@ -208,6 +244,8 @@ export function GenericInput({
onSelect={onChange}
/>
);
} else if (type === 'number') {
content = getNumberInputByFormatType(numberFormatType);
} else {
content = (
<Input

View File

@@ -0,0 +1,124 @@
import React, {
type Ref,
useRef,
useState,
useEffect,
type FocusEventHandler,
type FocusEvent,
} from 'react';
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
import { useMergedRefs } from '../../hooks/useMergedRefs';
import { type CSSProperties } from '../../style';
import { Input } from '../common/Input';
import { useFormat } from '../spreadsheet/useFormat';
type PercentInputProps = {
id?: string;
inputRef?: Ref<HTMLInputElement>;
value?: number;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChangeValue?: (value: string) => void;
onUpdatePercent?: (percent: number) => void;
style?: CSSProperties;
focused?: boolean;
disabled?: boolean;
};
const clampToPercent = (value: number) => Math.max(Math.min(value, 100), 0);
export function PercentInput({
id,
inputRef,
value: initialValue = 0,
onFocus,
onBlur,
onChangeValue,
onUpdatePercent,
style,
focused,
disabled = false,
}: PercentInputProps) {
const format = useFormat();
const [value, setValue] = useState(() =>
format(clampToPercent(initialValue), 'percentage'),
);
useEffect(() => {
const clampedInitialValue = clampToPercent(initialValue);
if (clampedInitialValue !== initialValue) {
setValue(format(clampedInitialValue, 'percentage'));
onUpdatePercent?.(clampedInitialValue);
}
}, [initialValue, onUpdatePercent, format]);
const ref = useRef<HTMLInputElement>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
useEffect(() => {
if (focused) {
ref.current?.focus();
}
}, [focused]);
function onSelectionChange() {
if (!ref.current) {
return;
}
const selectionStart = ref.current.selectionStart;
const selectionEnd = ref.current.selectionEnd;
if (
selectionStart === selectionEnd &&
selectionStart !== null &&
selectionStart >= ref.current.value.length
) {
ref.current.setSelectionRange(
ref.current.value.length - 1,
ref.current.value.length - 1,
);
}
}
function onInputTextChange(val: string) {
const number = val.replace(/[^0-9.]/g, '');
setValue(number ? format(number, 'percentage') : '');
onChangeValue?.(number);
}
function fireUpdate() {
const clampedValue = clampToPercent(evalArithmetic(value.replace('%', '')));
onUpdatePercent?.(clampedValue);
onInputTextChange(String(clampedValue));
}
function onInputAmountBlur(e: FocusEvent<HTMLInputElement>) {
if (!ref.current?.contains(e.relatedTarget)) {
fireUpdate();
}
onBlur?.(e);
}
return (
<Input
id={id}
inputRef={mergedRef}
inputMode="decimal"
value={value}
disabled={disabled}
focused={focused}
style={{ flex: 1, alignItems: 'stretch', ...style }}
onKeyUp={e => {
if (e.key === 'Enter') {
fireUpdate();
}
}}
onChangeValue={onInputTextChange}
onBlur={onInputAmountBlur}
onFocus={onFocus}
onSelect={onSelectionChange}
/>
);
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [jfdoming]
---
Use `AmountInput` on rules page to get formatting/sign toggle button