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:
+123
-40
@@ -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 { Copy, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -285,6 +296,93 @@ function formatTokenHint(n: number | string | null | undefined): string {
|
|||||||
return `= ${v.toLocaleString()} tokens`
|
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
|
// Tier condition row
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -332,13 +430,10 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
min={0}
|
min={0}
|
||||||
value={condition.value === '' ? '' : Number(condition.value)}
|
value={condition.value}
|
||||||
onChange={(event) =>
|
onValueChange={(value) => onChange({ ...condition, value })}
|
||||||
onChange({ ...condition, value: event.target.value })
|
|
||||||
}
|
|
||||||
placeholder='tokens'
|
placeholder='tokens'
|
||||||
className='w-32'
|
className='w-32'
|
||||||
/>
|
/>
|
||||||
@@ -381,12 +476,11 @@ function PriceField({
|
|||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<Label className='text-xs'>{label}</Label>
|
<Label className='text-xs'>{label}</Label>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
min={0}
|
min={0}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={Number.isFinite(value) ? value : 0}
|
value={Number.isFinite(value) ? value : 0}
|
||||||
onChange={(event) => onChange(Number(event.target.value) || 0)}
|
onValueChange={onChange}
|
||||||
className='w-32'
|
className='w-32'
|
||||||
/>
|
/>
|
||||||
{showSuffix && (
|
{showSuffix && (
|
||||||
@@ -802,32 +896,29 @@ function RuleConditionRow({
|
|||||||
</Select>
|
</Select>
|
||||||
{timeCond.mode === MATCH_RANGE ? (
|
{timeCond.mode === MATCH_RANGE ? (
|
||||||
<>
|
<>
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
value={timeCond.rangeStart}
|
value={timeCond.rangeStart}
|
||||||
onChange={(event) =>
|
onValueChange={(value) =>
|
||||||
onChange({ ...timeCond, rangeStart: event.target.value })
|
onChange({ ...timeCond, rangeStart: String(value) })
|
||||||
}
|
}
|
||||||
placeholder='start'
|
placeholder='start'
|
||||||
className='w-20'
|
className='w-20'
|
||||||
/>
|
/>
|
||||||
<span className='text-muted-foreground text-xs'>~</span>
|
<span className='text-muted-foreground text-xs'>~</span>
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
value={timeCond.rangeEnd}
|
value={timeCond.rangeEnd}
|
||||||
onChange={(event) =>
|
onValueChange={(value) =>
|
||||||
onChange({ ...timeCond, rangeEnd: event.target.value })
|
onChange({ ...timeCond, rangeEnd: String(value) })
|
||||||
}
|
}
|
||||||
placeholder='end'
|
placeholder='end'
|
||||||
className='w-20'
|
className='w-20'
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
value={timeCond.value}
|
value={timeCond.value}
|
||||||
onChange={(event) =>
|
onValueChange={(value) =>
|
||||||
onChange({ ...timeCond, value: event.target.value })
|
onChange({ ...timeCond, value: String(value) })
|
||||||
}
|
}
|
||||||
placeholder='value'
|
placeholder='value'
|
||||||
className='w-24'
|
className='w-24'
|
||||||
@@ -991,13 +1082,12 @@ function RuleGroupCard({
|
|||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Label className='text-xs'>{t('Multiplier')}</Label>
|
<Label className='text-xs'>{t('Multiplier')}</Label>
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
min={0}
|
min={0}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={group.multiplier}
|
value={group.multiplier}
|
||||||
onChange={(event) =>
|
onValueChange={(value) =>
|
||||||
onChange({ ...group, multiplier: event.target.value })
|
onChange({ ...group, multiplier: String(value) })
|
||||||
}
|
}
|
||||||
className='w-32'
|
className='w-32'
|
||||||
placeholder='1.0'
|
placeholder='1.0'
|
||||||
@@ -1114,24 +1204,18 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
|||||||
<div className='grid grid-cols-2 gap-3'>
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<Label className='text-xs'>{t('Input tokens')} (p)</Label>
|
<Label className='text-xs'>{t('Input tokens')} (p)</Label>
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
min={0}
|
min={0}
|
||||||
value={promptTokens}
|
value={promptTokens}
|
||||||
onChange={(event) =>
|
onValueChange={setPromptTokens}
|
||||||
setPromptTokens(Number(event.target.value) || 0)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<Label className='text-xs'>{t('Output tokens')} (c)</Label>
|
<Label className='text-xs'>{t('Output tokens')} (c)</Label>
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
min={0}
|
min={0}
|
||||||
value={completionTokens}
|
value={completionTokens}
|
||||||
onChange={(event) =>
|
onValueChange={setCompletionTokens}
|
||||||
setCompletionTokens(Number(event.target.value) || 0)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1151,14 +1235,13 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
|
|||||||
<Label className='text-xs'>
|
<Label className='text-xs'>
|
||||||
{variable.shortLabel} ({variable.key})
|
{variable.shortLabel} ({variable.key})
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DraftNumberInput
|
||||||
type='number'
|
|
||||||
min={0}
|
min={0}
|
||||||
value={extras[stateKey]}
|
value={extras[stateKey]}
|
||||||
onChange={(event) =>
|
onValueChange={(value) =>
|
||||||
setExtras((prev) => ({
|
setExtras((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[stateKey]: Number(event.target.value) || 0,
|
[stateKey]: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user