fix(model-pricing): detect visual pricing draft changes on save

- expose a draft commit handle from the model pricing editor panel before saving.
- commit the open visual editor into the parent form before page-level save runs.
- support both desktop side editor and mobile sheet save paths.
This commit is contained in:
QuentinHsu
2026-06-03 14:49:08 +08:00
parent 7aaa533265
commit abad0d3cc0
3 changed files with 881 additions and 802 deletions
@@ -16,7 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useMemo, useState } from 'react'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -130,6 +137,10 @@ type ModelPricingEditorPanelProps = Omit<
className?: string
}
export type ModelPricingEditorPanelHandle = {
commitDraft: () => Promise<ModelRatioData | null>
}
type PreviewRow = {
key: string
label: string
@@ -377,14 +388,13 @@ function buildPreviewRows(
]
}
export function ModelPricingSheet({
open,
onOpenChange,
onSave,
onCancel,
editData,
selectedTargetCount = 0,
}: ModelPricingSheetProps) {
export const ModelPricingSheet = forwardRef<
ModelPricingEditorPanelHandle,
ModelPricingSheetProps
>(function ModelPricingSheet(
{ open, onOpenChange, onSave, onCancel, editData, selectedTargetCount = 0 },
ref
) {
const { t } = useTranslation()
const title = editData ? t('Edit model pricing') : t('Add model pricing')
const description = editData?.name || t('New model')
@@ -400,6 +410,7 @@ export function ModelPricingSheet({
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<ModelPricingEditorPanel
ref={ref}
onSave={onSave}
editData={editData}
selectedTargetCount={selectedTargetCount}
@@ -412,15 +423,15 @@ export function ModelPricingSheet({
</SheetContent>
</Sheet>
)
}
})
export function ModelPricingEditorPanel({
onSave,
editData,
selectedTargetCount = 0,
onCancel,
className,
}: ModelPricingEditorPanelProps) {
export const ModelPricingEditorPanel = forwardRef<
ModelPricingEditorPanelHandle,
ModelPricingEditorPanelProps
>(function ModelPricingEditorPanel(
{ onSave, editData, selectedTargetCount = 0, onCancel, className },
ref
) {
const { t } = useTranslation()
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
const [promptPrice, setPromptPrice] = useState('')
@@ -687,7 +698,7 @@ export function ModelPricingEditorPanel({
return nextWarnings
}, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
const handleSubmit = (values: ModelPricingFormValues) => {
const validatePricingValues = useCallback(() => {
if (
pricingMode === 'per-token' &&
toNumberOrNull(promptPrice) === null &&
@@ -698,7 +709,7 @@ export function ModelPricingEditorPanel({
form.setError('ratio', {
message: t('Input price is required before saving dependent prices.'),
})
return
return false
}
if (
@@ -709,9 +720,14 @@ export function ModelPricingEditorPanel({
form.setError('audioRatio', {
message: t('Audio output price requires an audio input price.'),
})
return
return false
}
return true
}, [form, laneEnabled, lanePrices, pricingMode, promptPrice, t])
const buildSubmitData = useCallback(
(values: ModelPricingFormValues) => {
const data: ModelRatioData = {
name: values.name.trim(),
billingMode: pricingMode,
@@ -730,6 +746,27 @@ export function ModelPricingEditorPanel({
data.requestRuleExpr = requestRuleExpr
}
return data
},
[billingExpr, pricingMode, requestRuleExpr]
)
useImperativeHandle(
ref,
() => ({
commitDraft: async () => {
const isValid = await form.trigger()
if (!isValid || !validatePricingValues()) return null
return buildSubmitData(form.getValues())
},
}),
[form, validatePricingValues, buildSubmitData]
)
const handleSubmit = (values: ModelPricingFormValues) => {
if (!validatePricingValues()) return
const data = buildSubmitData(values)
onSave(data)
form.reset()
onCancel?.()
@@ -980,7 +1017,7 @@ export function ModelPricingEditorPanel({
</Form>
</div>
)
}
})
function PriceInput(props: {
value: string
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { memo, useCallback, useState } from 'react'
import { memo, useCallback, useRef, useState } from 'react'
import { type UseFormReturn } from 'react-hook-form'
import { Code2, Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -38,7 +38,10 @@ import {
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { ModelRatioVisualEditor } from './model-ratio-visual-editor'
import {
ModelRatioVisualEditor,
type ModelRatioVisualEditorHandle,
} from './model-ratio-visual-editor'
type ModelFormValues = {
ModelPrice: string
@@ -71,6 +74,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
}: ModelRatioFormProps) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const visualEditorRef = useRef<ModelRatioVisualEditorHandle>(null)
const handleFieldChange = useCallback(
(field: keyof ModelFormValues, value: string) => {
@@ -86,6 +90,15 @@ export const ModelRatioForm = memo(function ModelRatioForm({
setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual'))
}, [])
const handleSave = useCallback(async () => {
if (editMode === 'visual') {
const committed = await visualEditorRef.current?.commitOpenEditor()
if (committed === false) return
}
await form.handleSubmit(onSave)()
}, [editMode, form, onSave])
return (
<div className='space-y-6'>
<div className='flex justify-end'>
@@ -118,7 +131,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
<Button
type='button'
size='sm'
onClick={form.handleSubmit(onSave)}
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('Saving...') : t('Save model prices')}
@@ -127,6 +140,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({
{editMode === 'visual' ? (
<div className='space-y-6'>
<ModelRatioVisualEditor
ref={visualEditorRef}
modelPrice={form.watch('ModelPrice')}
modelRatio={form.watch('ModelRatio')}
cacheRatio={form.watch('CacheRatio')}
@@ -16,7 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo, memo, useCallback, useEffect } from 'react'
import {
useState,
useMemo,
memo,
useCallback,
useEffect,
forwardRef,
useImperativeHandle,
useRef,
} from 'react'
import {
type ColumnDef,
type ColumnFiltersState,
@@ -62,6 +71,7 @@ import {
import { safeJsonParse } from '../utils/json-parser'
import {
ModelPricingEditorPanel,
type ModelPricingEditorPanelHandle,
ModelPricingSheet,
type ModelRatioData,
} from './model-pricing-sheet'
@@ -97,6 +107,10 @@ type ModelRow = {
hasConflict: boolean
}
export type ModelRatioVisualEditorHandle = {
commitOpenEditor: () => Promise<boolean>
}
const STORAGE_KEY = 'model-ratio-column-visibility'
const hasValue = (value?: string) => value !== undefined && value !== ''
@@ -192,8 +206,11 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
return details.length > 0 ? details.join(' · ') : t('Base input price only')
}
export const ModelRatioVisualEditor = memo(
function ModelRatioVisualEditor({
const ModelRatioVisualEditorComponent = forwardRef<
ModelRatioVisualEditorHandle,
ModelRatioVisualEditorProps
>(function ModelRatioVisualEditor(
{
modelPrice,
modelRatio,
cacheRatio,
@@ -205,7 +222,9 @@ export const ModelRatioVisualEditor = memo(
billingMode,
billingExpr,
onChange,
}: ModelRatioVisualEditorProps) {
},
ref
) {
const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 767px)')
const [sheetOpen, setSheetOpen] = useState(false)
@@ -215,6 +234,7 @@ export const ModelRatioVisualEditor = memo(
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [globalFilter, setGlobalFilter] = useState('')
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const editorPanelRef = useRef<ModelPricingEditorPanelHandle>(null)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
@@ -291,20 +311,14 @@ export const ModelRatioVisualEditor = memo(
audioCompletionRatio,
{ fallback: {}, context: 'audio completion ratios' }
)
const billingModeMap = safeJsonParse<Record<string, string>>(
billingMode,
{
const billingModeMap = safeJsonParse<Record<string, string>>(billingMode, {
fallback: {},
context: 'billing mode',
}
)
const billingExprMap = safeJsonParse<Record<string, string>>(
billingExpr,
{
})
const billingExprMap = safeJsonParse<Record<string, string>>(billingExpr, {
fallback: {},
context: 'billing expression',
}
)
})
const modelNames = new Set([
...Object.keys(priceMap),
@@ -454,8 +468,7 @@ export const ModelRatioVisualEditor = memo(
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
(updater) => {
setGlobalFilter((previous) => {
const next =
typeof updater === 'function' ? updater(previous) : updater
const next = typeof updater === 'function' ? updater(previous) : updater
if (next !== previous) {
setEditData(null)
setEditorOpen(false)
@@ -842,9 +855,7 @@ export const ModelRatioVisualEditor = memo(
setEditData(data)
setEditorOpen(true)
toast.success(
t(
'Pricing changes saved to draft. Click "Save model prices" to apply.'
)
t('Pricing changes saved to draft. Click "Save model prices" to apply.')
)
},
[persistPricingData, t]
@@ -875,6 +886,21 @@ export const ModelRatioVisualEditor = memo(
)
}, [editData, persistPricingData, t, table])
useImperativeHandle(
ref,
() => ({
commitOpenEditor: async () => {
if (!editorOpen || !editorPanelRef.current) return true
const data = await editorPanelRef.current.commitDraft()
if (!data) return false
persistPricingData(data)
setEditData(data)
return true
},
}),
[editorOpen, persistPricingData]
)
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
return (
@@ -944,9 +970,7 @@ export const ModelRatioVisualEditor = memo(
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
data-state={row.getIsSelected() ? 'selected' : undefined}
className={
editData?.name === row.original.name
? 'bg-muted/45'
@@ -954,8 +978,7 @@ export const ModelRatioVisualEditor = memo(
}
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('button, [role="checkbox"]'))
return
if (target.closest('button, [role="checkbox"]')) return
handleEdit(row.original)
}}
>
@@ -982,6 +1005,7 @@ export const ModelRatioVisualEditor = memo(
<div className='hidden min-w-0 md:block'>
{editorOpen ? (
<ModelPricingEditorPanel
ref={editorPanelRef}
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
@@ -1018,6 +1042,7 @@ export const ModelRatioVisualEditor = memo(
{isMobile && (
<ModelPricingSheet
ref={editorPanelRef}
open={sheetOpen}
onOpenChange={setSheetOpen}
onSave={handleSave}
@@ -1028,7 +1053,10 @@ export const ModelRatioVisualEditor = memo(
)}
</div>
)
},
})
export const ModelRatioVisualEditor = memo(
ModelRatioVisualEditorComponent,
// Custom equality check - only re-render if JSON props actually changed
(prevProps, nextProps) => {
return (