mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(ui): surface silent validation and rejection errors across editors and modals (#5394)
* fix(ui): surface silent validation and rejection errors across editors and modals * improvement(knowledge): toast chunk validation errors instead of inline footer text * fix(tables): reject invalid date/number in expanded cell editor, dedupe invalid-input toasts * fix(knowledge): toast every failed chunk validation attempt, replacing the previous toast * fix(knowledge): dismiss stale validation toast once content validates * revert(ui): drop chat identifier error rendering and profile-name toast
This commit is contained in:
+14
-3
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { handleKeyboardActivation, Label, Switch } from '@sim/emcn'
|
||||
import { handleKeyboardActivation, Label, Switch, toast } from '@sim/emcn'
|
||||
import { isApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getKnowledgeChunkContract } from '@/lib/api/contracts/knowledge'
|
||||
@@ -59,6 +59,7 @@ export function ChunkEditor({
|
||||
|
||||
const [editedContent, setEditedContent] = useState(isCreateMode ? '' : chunkContent)
|
||||
const [savedContent, setSavedContent] = useState(chunkContent)
|
||||
const validationToastIdRef = useRef<string | null>(null)
|
||||
const [tokenizerOn, setTokenizerOn] = useState(false)
|
||||
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
|
||||
const savedContentRef = useRef(chunkContent)
|
||||
@@ -108,8 +109,18 @@ export function ChunkEditor({
|
||||
const handleSave = useCallback(async () => {
|
||||
const content = editedContentRef.current
|
||||
const trimmed = content.trim()
|
||||
if (trimmed.length === 0) throw new Error('Content cannot be empty')
|
||||
if (trimmed.length > 10000) throw new Error('Content exceeds maximum length')
|
||||
/** Toast every failed attempt, replacing the previous validation toast so retries refresh instead of stack. */
|
||||
const failValidation = (message: string): never => {
|
||||
if (validationToastIdRef.current) toast.dismiss(validationToastIdRef.current)
|
||||
validationToastIdRef.current = toast.error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
if (trimmed.length === 0) failValidation('Content cannot be empty')
|
||||
if (trimmed.length > 10000) failValidation('Content exceeds maximum length (10,000 characters)')
|
||||
if (validationToastIdRef.current) {
|
||||
toast.dismiss(validationToastIdRef.current)
|
||||
validationToastIdRef.current = null
|
||||
}
|
||||
|
||||
if (isCreateMode) {
|
||||
const created = await createChunk({
|
||||
|
||||
+28
-5
@@ -197,12 +197,28 @@ function ExpandedCellEditor({
|
||||
textareaRef,
|
||||
}: ExpandedCellEditorProps) {
|
||||
const [draftValue, setDraftValue] = useState(initialValue)
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = () => {
|
||||
// `displayToStorage` only normalizes dates — it returns null for anything else.
|
||||
// Fall back to the raw draft for non-date columns, matching the inline editor.
|
||||
const raw = displayToStorage(draftValue) ?? draftValue
|
||||
const cleaned = cleanCellValue(raw, column)
|
||||
let cleaned: unknown
|
||||
try {
|
||||
cleaned = cleanCellValue(raw, column)
|
||||
} catch {
|
||||
setParseError('Invalid JSON')
|
||||
return
|
||||
}
|
||||
/** `cleanCellValue` nulls unparseable dates/numbers instead of throwing — reject rather than silently clear. */
|
||||
if (
|
||||
cleaned === null &&
|
||||
draftValue.trim() !== '' &&
|
||||
(column.type === 'date' || column.type === 'number')
|
||||
) {
|
||||
setParseError(column.type === 'date' ? 'Invalid date' : 'Invalid number')
|
||||
return
|
||||
}
|
||||
onSave(rowId, column.key, cleaned, 'blur')
|
||||
onClose()
|
||||
}
|
||||
@@ -219,16 +235,23 @@ function ExpandedCellEditor({
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draftValue}
|
||||
onChange={(e) => setDraftValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDraftValue(e.target.value)
|
||||
setParseError(null)
|
||||
}}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
className='min-h-0 flex-1 resize-none bg-transparent px-2.5 py-2 font-sans text-[var(--text-primary)] text-small outline-none placeholder:text-[var(--text-muted)]'
|
||||
spellCheck={false}
|
||||
autoCorrect='off'
|
||||
/>
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-t bg-[var(--surface-2)] px-2 py-1.5'>
|
||||
<span className='text-[var(--text-tertiary)] text-caption'>
|
||||
<kbd className='font-mono'>↵</kbd> save · <kbd className='font-mono'>esc</kbd> cancel
|
||||
</span>
|
||||
{parseError ? (
|
||||
<span className='text-[var(--text-error)] text-caption'>{parseError}</span>
|
||||
) : (
|
||||
<span className='text-[var(--text-tertiary)] text-caption'>
|
||||
<kbd className='font-mono'>↵</kbd> save · <kbd className='font-mono'>esc</kbd> cancel
|
||||
</span>
|
||||
)}
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Button variant='ghost' size='sm' onClick={onClose}>
|
||||
Cancel
|
||||
|
||||
+54
-12
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Calendar, cn, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn'
|
||||
import { Calendar, cn, Popover, PopoverAnchor, PopoverContent, toast } from '@sim/emcn'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
import type { SaveReason } from '../../../types'
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ function InlineDateEditor({
|
||||
const [draft, setDraft] = useState(() =>
|
||||
initialCharacter !== undefined ? initialCharacter : storageToDisplay(storedValue)
|
||||
)
|
||||
const [invalid, setInvalid] = useState(false)
|
||||
|
||||
const pickerValue = displayToStorage(draft) || storedValue || undefined
|
||||
|
||||
@@ -64,13 +65,24 @@ function InlineDateEditor({
|
||||
const doSave = useCallback(
|
||||
(reason: SaveReason, storageVal?: string) => {
|
||||
if (doneRef.current) return
|
||||
doneRef.current = true
|
||||
clearTimeout(blurTimeoutRef.current)
|
||||
const raw = storageVal ?? displayToStorage(draft) ?? draft
|
||||
const val = raw && !Number.isNaN(Date.parse(raw)) ? raw : null
|
||||
onSave(val, reason)
|
||||
if (raw && Number.isNaN(Date.parse(raw))) {
|
||||
if (reason === 'blur') {
|
||||
if (!invalid) toast.error('Invalid date')
|
||||
doneRef.current = true
|
||||
onCancel()
|
||||
} else {
|
||||
toast.error('Invalid date')
|
||||
setInvalid(true)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
doneRef.current = true
|
||||
onSave(raw || null, reason)
|
||||
},
|
||||
[draft, onSave]
|
||||
[draft, invalid, onSave, onCancel]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -116,12 +128,16 @@ function InlineDateEditor({
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value)
|
||||
setInvalid(false)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
placeholder='mm/dd/yyyy'
|
||||
className={cn(
|
||||
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none'
|
||||
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none',
|
||||
invalid && 'text-[var(--text-error)]'
|
||||
)}
|
||||
/>
|
||||
<Popover open onOpenChange={handlePickerOpenChange}>
|
||||
@@ -146,6 +162,7 @@ function InlineTextEditor({
|
||||
const [draft, setDraft] = useState(() =>
|
||||
initialCharacter !== undefined ? initialCharacter : formatValueForInput(value, column.type)
|
||||
)
|
||||
const [invalid, setInvalid] = useState(false)
|
||||
const doneRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -161,14 +178,33 @@ function InlineTextEditor({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const rejectDraft = (message: string, reason: SaveReason) => {
|
||||
if (reason === 'blur') {
|
||||
if (!invalid) toast.error(message)
|
||||
doneRef.current = true
|
||||
onCancel()
|
||||
} else {
|
||||
toast.error(message)
|
||||
setInvalid(true)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const doSave = (reason: SaveReason) => {
|
||||
if (doneRef.current) return
|
||||
doneRef.current = true
|
||||
let cleaned: unknown
|
||||
try {
|
||||
onSave(cleanCellValue(draft, column), reason)
|
||||
cleaned = cleanCellValue(draft, column)
|
||||
} catch {
|
||||
onCancel()
|
||||
rejectDraft('Invalid JSON', reason)
|
||||
return
|
||||
}
|
||||
if (column.type === 'number' && cleaned === null && draft.trim() !== '') {
|
||||
rejectDraft('Invalid number', reason)
|
||||
return
|
||||
}
|
||||
doneRef.current = true
|
||||
onSave(cleaned, reason)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -193,11 +229,17 @@ function InlineTextEditor({
|
||||
type='text'
|
||||
inputMode={isNumber ? 'decimal' : undefined}
|
||||
value={draft ?? ''}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value)
|
||||
setInvalid(false)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={handleEditorWheel}
|
||||
onBlur={() => doSave('blur')}
|
||||
className='w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none'
|
||||
className={cn(
|
||||
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none',
|
||||
invalid && 'text-[var(--text-error)]'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+7
-2
@@ -30,7 +30,7 @@ const RichMarkdownField = dynamic(
|
||||
}
|
||||
)
|
||||
|
||||
/** A high cap that only guards against abuse — no visible counter; normal descriptions never reach it. */
|
||||
/** A high cap that only guards against abuse — a counter message appears only when exceeded. */
|
||||
const MAX_DESCRIPTION_LENGTH = 50_000
|
||||
|
||||
interface VersionDescriptionModalProps {
|
||||
@@ -136,6 +136,11 @@ export function VersionDescriptionModal({
|
||||
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
|
||||
</span>
|
||||
}
|
||||
error={
|
||||
isTooLong
|
||||
? `Description exceeds the ${MAX_DESCRIPTION_LENGTH.toLocaleString()}-character limit (currently ${description.length.toLocaleString()})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<RichMarkdownField
|
||||
value={description}
|
||||
@@ -145,7 +150,7 @@ export function VersionDescriptionModal({
|
||||
maxHeight={420}
|
||||
disabled={isGenerating}
|
||||
isStreaming={isGenerating}
|
||||
error={description.length > MAX_DESCRIPTION_LENGTH}
|
||||
error={isTooLong}
|
||||
workspaceId={workspaceId}
|
||||
disableTagging
|
||||
/>
|
||||
|
||||
+20
-6
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
} from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
|
||||
interface CreateWorkspaceModalProps {
|
||||
open: boolean
|
||||
@@ -27,17 +28,25 @@ export function CreateWorkspaceModal({
|
||||
isCreating,
|
||||
}: CreateWorkspaceModalProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const [prevOpen, setPrevOpen] = useState(open)
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open)
|
||||
if (open) {
|
||||
setName('')
|
||||
setError(null)
|
||||
}
|
||||
}, [open])
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || isCreating) return
|
||||
await onConfirm(trimmed)
|
||||
try {
|
||||
await onConfirm(trimmed)
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to create workspace'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -47,6 +56,11 @@ export function CreateWorkspaceModal({
|
||||
}
|
||||
}
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Create workspace'>
|
||||
<ChipModalHeader onClose={() => onOpenChange(false)}>Create workspace</ChipModalHeader>
|
||||
@@ -55,14 +69,14 @@ export function CreateWorkspaceModal({
|
||||
type='input'
|
||||
title='Name'
|
||||
value={name}
|
||||
onChange={setName}
|
||||
onChange={handleNameChange}
|
||||
placeholder='Workspace name'
|
||||
maxLength={100}
|
||||
autoComplete='off'
|
||||
disabled={isCreating}
|
||||
required
|
||||
/>
|
||||
<ChipModalError>{undefined}</ChipModalError>
|
||||
<ChipModalError>{error ?? undefined}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
|
||||
+1
@@ -176,6 +176,7 @@ export function useWorkspaceManagement({
|
||||
await switchWorkspace(newWorkspace)
|
||||
} catch (error) {
|
||||
logger.error('Error creating workspace:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
Reference in New Issue
Block a user