fix: improve tiered pricing number input editing (#4536)
* fix: follow required marker styling convention * fix: improve tiered pricing number input editing
This commit is contained in:
parent
8f3c41ae77
commit
5f86839c7e
@ -1,4 +1,15 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FocusEvent,
|
||||
type InputHTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react'
|
||||
import { Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@ -285,6 +296,93 @@ function formatTokenHint(n: number | string | null | undefined): string {
|
||||
return `= ${v.toLocaleString()} tokens`
|
||||
}
|
||||
|
||||
function formatNumberDraft(value: number | string): string {
|
||||
if (value === '') return ''
|
||||
if (typeof value === 'number')
|
||||
return Number.isFinite(value) ? String(value) : '0'
|
||||
return value
|
||||
}
|
||||
|
||||
function parseNumberDraft(value: string): number {
|
||||
if (value.trim() === '') return 0
|
||||
const next = Number(value)
|
||||
return Number.isFinite(next) ? next : 0
|
||||
}
|
||||
|
||||
function isZeroDraft(value: string): boolean {
|
||||
return value.trim() !== '' && parseNumberDraft(value) === 0
|
||||
}
|
||||
|
||||
type DraftNumberInputProps = Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
'type' | 'value' | 'onChange'
|
||||
> & {
|
||||
value: number | string
|
||||
onValueChange: (next: number) => void
|
||||
selectZeroOnFocus?: boolean
|
||||
}
|
||||
|
||||
function DraftNumberInput({
|
||||
value,
|
||||
onValueChange,
|
||||
selectZeroOnFocus = true,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onMouseUp,
|
||||
...props
|
||||
}: DraftNumberInputProps) {
|
||||
const [draft, setDraft] = useState(() => formatNumberDraft(value))
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) {
|
||||
setDraft(formatNumberDraft(value))
|
||||
}
|
||||
}, [focused, value])
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const nextDraft = event.target.value
|
||||
setDraft(nextDraft)
|
||||
onValueChange(parseNumberDraft(nextDraft))
|
||||
}
|
||||
|
||||
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
|
||||
setFocused(true)
|
||||
onFocus?.(event)
|
||||
if (selectZeroOnFocus && isZeroDraft(event.currentTarget.value)) {
|
||||
event.currentTarget.select()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = (event: ReactMouseEvent<HTMLInputElement>) => {
|
||||
onMouseUp?.(event)
|
||||
if (selectZeroOnFocus && isZeroDraft(event.currentTarget.value)) {
|
||||
event.preventDefault()
|
||||
event.currentTarget.select()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
const normalized = parseNumberDraft(event.currentTarget.value)
|
||||
setFocused(false)
|
||||
setDraft(String(normalized))
|
||||
onValueChange(normalized)
|
||||
onBlur?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
type='number'
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onMouseUp={handleMouseUp}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier condition row
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -332,13 +430,10 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={condition.value === '' ? '' : Number(condition.value)}
|
||||
onChange={(event) =>
|
||||
onChange({ ...condition, value: event.target.value })
|
||||
}
|
||||
value={condition.value}
|
||||
onValueChange={(value) => onChange({ ...condition, value })}
|
||||
placeholder='tokens'
|
||||
className='w-32'
|
||||
/>
|
||||
@ -381,12 +476,11 @@ function PriceField({
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{label}</Label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
onChange={(event) => onChange(Number(event.target.value) || 0)}
|
||||
onValueChange={onChange}
|
||||
className='w-32'
|
||||
/>
|
||||
{showSuffix && (
|
||||
@ -802,32 +896,29 @@ function RuleConditionRow({
|
||||
</Select>
|
||||
{timeCond.mode === MATCH_RANGE ? (
|
||||
<>
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
value={timeCond.rangeStart}
|
||||
onChange={(event) =>
|
||||
onChange({ ...timeCond, rangeStart: event.target.value })
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, rangeStart: String(value) })
|
||||
}
|
||||
placeholder='start'
|
||||
className='w-20'
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>~</span>
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
value={timeCond.rangeEnd}
|
||||
onChange={(event) =>
|
||||
onChange({ ...timeCond, rangeEnd: event.target.value })
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, rangeEnd: String(value) })
|
||||
}
|
||||
placeholder='end'
|
||||
className='w-20'
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
value={timeCond.value}
|
||||
onChange={(event) =>
|
||||
onChange({ ...timeCond, value: event.target.value })
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...timeCond, value: String(value) })
|
||||
}
|
||||
placeholder='value'
|
||||
className='w-24'
|
||||
@ -991,13 +1082,12 @@ function RuleGroupCard({
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className='text-xs'>{t('Multiplier')}</Label>
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={group.multiplier}
|
||||
onChange={(event) =>
|
||||
onChange({ ...group, multiplier: event.target.value })
|
||||
onValueChange={(value) =>
|
||||
onChange({ ...group, multiplier: String(value) })
|
||||
}
|
||||
className='w-32'
|
||||
placeholder='1.0'
|
||||
@ -1114,24 +1204,18 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{t('Input tokens')} (p)</Label>
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={promptTokens}
|
||||
onChange={(event) =>
|
||||
setPromptTokens(Number(event.target.value) || 0)
|
||||
}
|
||||
onValueChange={setPromptTokens}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Label className='text-xs'>{t('Output tokens')} (c)</Label>
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={completionTokens}
|
||||
onChange={(event) =>
|
||||
setCompletionTokens(Number(event.target.value) || 0)
|
||||
}
|
||||
onValueChange={setCompletionTokens}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -1151,14 +1235,13 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
||||
<Label className='text-xs'>
|
||||
{variable.shortLabel} ({variable.key})
|
||||
</Label>
|
||||
<Input
|
||||
type='number'
|
||||
<DraftNumberInput
|
||||
min={0}
|
||||
value={extras[stateKey]}
|
||||
onChange={(event) =>
|
||||
onValueChange={(value) =>
|
||||
setExtras((prev) => ({
|
||||
...prev,
|
||||
[stateKey]: Number(event.target.value) || 0,
|
||||
[stateKey]: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user