Skip to content

Commit d508201

Browse files
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
1 parent 88330b4 commit d508201

6 files changed

Lines changed: 124 additions & 28 deletions

File tree

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4-
import { handleKeyboardActivation, Label, Switch } from '@sim/emcn'
4+
import { handleKeyboardActivation, Label, Switch, toast } from '@sim/emcn'
55
import { isApiClientError } from '@/lib/api/client/errors'
66
import { requestJson } from '@/lib/api/client/request'
77
import { getKnowledgeChunkContract } from '@/lib/api/contracts/knowledge'
@@ -59,6 +59,7 @@ export function ChunkEditor({
5959

6060
const [editedContent, setEditedContent] = useState(isCreateMode ? '' : chunkContent)
6161
const [savedContent, setSavedContent] = useState(chunkContent)
62+
const validationToastIdRef = useRef<string | null>(null)
6263
const [tokenizerOn, setTokenizerOn] = useState(false)
6364
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
6465
const savedContentRef = useRef(chunkContent)
@@ -108,8 +109,18 @@ export function ChunkEditor({
108109
const handleSave = useCallback(async () => {
109110
const content = editedContentRef.current
110111
const trimmed = content.trim()
111-
if (trimmed.length === 0) throw new Error('Content cannot be empty')
112-
if (trimmed.length > 10000) throw new Error('Content exceeds maximum length')
112+
/** Toast every failed attempt, replacing the previous validation toast so retries refresh instead of stack. */
113+
const failValidation = (message: string): never => {
114+
if (validationToastIdRef.current) toast.dismiss(validationToastIdRef.current)
115+
validationToastIdRef.current = toast.error(message)
116+
throw new Error(message)
117+
}
118+
if (trimmed.length === 0) failValidation('Content cannot be empty')
119+
if (trimmed.length > 10000) failValidation('Content exceeds maximum length (10,000 characters)')
120+
if (validationToastIdRef.current) {
121+
toast.dismiss(validationToastIdRef.current)
122+
validationToastIdRef.current = null
123+
}
113124

114125
if (isCreateMode) {
115126
const created = await createChunk({

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,28 @@ function ExpandedCellEditor({
197197
textareaRef,
198198
}: ExpandedCellEditorProps) {
199199
const [draftValue, setDraftValue] = useState(initialValue)
200+
const [parseError, setParseError] = useState<string | null>(null)
200201

201202
const handleSave = () => {
202203
// `displayToStorage` only normalizes dates — it returns null for anything else.
203204
// Fall back to the raw draft for non-date columns, matching the inline editor.
204205
const raw = displayToStorage(draftValue) ?? draftValue
205-
const cleaned = cleanCellValue(raw, column)
206+
let cleaned: unknown
207+
try {
208+
cleaned = cleanCellValue(raw, column)
209+
} catch {
210+
setParseError('Invalid JSON')
211+
return
212+
}
213+
/** `cleanCellValue` nulls unparseable dates/numbers instead of throwing — reject rather than silently clear. */
214+
if (
215+
cleaned === null &&
216+
draftValue.trim() !== '' &&
217+
(column.type === 'date' || column.type === 'number')
218+
) {
219+
setParseError(column.type === 'date' ? 'Invalid date' : 'Invalid number')
220+
return
221+
}
206222
onSave(rowId, column.key, cleaned, 'blur')
207223
onClose()
208224
}
@@ -219,16 +235,23 @@ function ExpandedCellEditor({
219235
<textarea
220236
ref={textareaRef}
221237
value={draftValue}
222-
onChange={(e) => setDraftValue(e.target.value)}
238+
onChange={(e) => {
239+
setDraftValue(e.target.value)
240+
setParseError(null)
241+
}}
223242
onKeyDown={handleTextareaKeyDown}
224243
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)]'
225244
spellCheck={false}
226245
autoCorrect='off'
227246
/>
228247
<div className='flex items-center justify-between border-[var(--border)] border-t bg-[var(--surface-2)] px-2 py-1.5'>
229-
<span className='text-[var(--text-tertiary)] text-caption'>
230-
<kbd className='font-mono'></kbd> save · <kbd className='font-mono'>esc</kbd> cancel
231-
</span>
248+
{parseError ? (
249+
<span className='text-[var(--text-error)] text-caption'>{parseError}</span>
250+
) : (
251+
<span className='text-[var(--text-tertiary)] text-caption'>
252+
<kbd className='font-mono'></kbd> save · <kbd className='font-mono'>esc</kbd> cancel
253+
</span>
254+
)}
232255
<div className='flex items-center gap-1.5'>
233256
<Button variant='ghost' size='sm' onClick={onClose}>
234257
Cancel

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useCallback, useEffect, useRef, useState } from 'react'
4-
import { Calendar, cn, Popover, PopoverAnchor, PopoverContent } from '@sim/emcn'
4+
import { Calendar, cn, Popover, PopoverAnchor, PopoverContent, toast } from '@sim/emcn'
55
import type { ColumnDefinition } from '@/lib/table'
66
import type { SaveReason } from '../../../types'
77
import {
@@ -44,6 +44,7 @@ function InlineDateEditor({
4444
const [draft, setDraft] = useState(() =>
4545
initialCharacter !== undefined ? initialCharacter : storageToDisplay(storedValue)
4646
)
47+
const [invalid, setInvalid] = useState(false)
4748

4849
const pickerValue = displayToStorage(draft) || storedValue || undefined
4950

@@ -64,13 +65,24 @@ function InlineDateEditor({
6465
const doSave = useCallback(
6566
(reason: SaveReason, storageVal?: string) => {
6667
if (doneRef.current) return
67-
doneRef.current = true
6868
clearTimeout(blurTimeoutRef.current)
6969
const raw = storageVal ?? displayToStorage(draft) ?? draft
70-
const val = raw && !Number.isNaN(Date.parse(raw)) ? raw : null
71-
onSave(val, reason)
70+
if (raw && Number.isNaN(Date.parse(raw))) {
71+
if (reason === 'blur') {
72+
if (!invalid) toast.error('Invalid date')
73+
doneRef.current = true
74+
onCancel()
75+
} else {
76+
toast.error('Invalid date')
77+
setInvalid(true)
78+
inputRef.current?.focus()
79+
}
80+
return
81+
}
82+
doneRef.current = true
83+
onSave(raw || null, reason)
7284
},
73-
[draft, onSave]
85+
[draft, invalid, onSave, onCancel]
7486
)
7587

7688
const handleKeyDown = useCallback(
@@ -116,12 +128,16 @@ function InlineDateEditor({
116128
ref={inputRef}
117129
type='text'
118130
value={draft}
119-
onChange={(e) => setDraft(e.target.value)}
131+
onChange={(e) => {
132+
setDraft(e.target.value)
133+
setInvalid(false)
134+
}}
120135
onKeyDown={handleKeyDown}
121136
onBlur={handleBlur}
122137
placeholder='mm/dd/yyyy'
123138
className={cn(
124-
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none'
139+
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none',
140+
invalid && 'text-[var(--text-error)]'
125141
)}
126142
/>
127143
<Popover open onOpenChange={handlePickerOpenChange}>
@@ -146,6 +162,7 @@ function InlineTextEditor({
146162
const [draft, setDraft] = useState(() =>
147163
initialCharacter !== undefined ? initialCharacter : formatValueForInput(value, column.type)
148164
)
165+
const [invalid, setInvalid] = useState(false)
149166
const doneRef = useRef(false)
150167

151168
useEffect(() => {
@@ -161,14 +178,33 @@ function InlineTextEditor({
161178
}
162179
}, [])
163180

181+
const rejectDraft = (message: string, reason: SaveReason) => {
182+
if (reason === 'blur') {
183+
if (!invalid) toast.error(message)
184+
doneRef.current = true
185+
onCancel()
186+
} else {
187+
toast.error(message)
188+
setInvalid(true)
189+
inputRef.current?.focus()
190+
}
191+
}
192+
164193
const doSave = (reason: SaveReason) => {
165194
if (doneRef.current) return
166-
doneRef.current = true
195+
let cleaned: unknown
167196
try {
168-
onSave(cleanCellValue(draft, column), reason)
197+
cleaned = cleanCellValue(draft, column)
169198
} catch {
170-
onCancel()
199+
rejectDraft('Invalid JSON', reason)
200+
return
201+
}
202+
if (column.type === 'number' && cleaned === null && draft.trim() !== '') {
203+
rejectDraft('Invalid number', reason)
204+
return
171205
}
206+
doneRef.current = true
207+
onSave(cleaned, reason)
172208
}
173209

174210
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -193,11 +229,17 @@ function InlineTextEditor({
193229
type='text'
194230
inputMode={isNumber ? 'decimal' : undefined}
195231
value={draft ?? ''}
196-
onChange={(e) => setDraft(e.target.value)}
232+
onChange={(e) => {
233+
setDraft(e.target.value)
234+
setInvalid(false)
235+
}}
197236
onKeyDown={handleKeyDown}
198237
onWheel={handleEditorWheel}
199238
onBlur={() => doSave('blur')}
200-
className='w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none'
239+
className={cn(
240+
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none',
241+
invalid && 'text-[var(--text-error)]'
242+
)}
201243
/>
202244
)
203245
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const RichMarkdownField = dynamic(
3030
}
3131
)
3232

33-
/** A high cap that only guards against abuse — no visible counter; normal descriptions never reach it. */
33+
/** A high cap that only guards against abuse — a counter message appears only when exceeded. */
3434
const MAX_DESCRIPTION_LENGTH = 50_000
3535

3636
interface VersionDescriptionModalProps {
@@ -136,6 +136,11 @@ export function VersionDescriptionModal({
136136
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
137137
</span>
138138
}
139+
error={
140+
isTooLong
141+
? `Description exceeds the ${MAX_DESCRIPTION_LENGTH.toLocaleString()}-character limit (currently ${description.length.toLocaleString()})`
142+
: undefined
143+
}
139144
>
140145
<RichMarkdownField
141146
value={description}
@@ -145,7 +150,7 @@ export function VersionDescriptionModal({
145150
maxHeight={420}
146151
disabled={isGenerating}
147152
isStreaming={isGenerating}
148-
error={description.length > MAX_DESCRIPTION_LENGTH}
153+
error={isTooLong}
149154
workspaceId={workspaceId}
150155
disableTagging
151156
/>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useState } from 'react'
3+
import { useState } from 'react'
44
import {
55
ChipModal,
66
ChipModalBody,
@@ -9,6 +9,7 @@ import {
99
ChipModalFooter,
1010
ChipModalHeader,
1111
} from '@sim/emcn'
12+
import { getErrorMessage } from '@sim/utils/errors'
1213

1314
interface CreateWorkspaceModalProps {
1415
open: boolean
@@ -27,17 +28,25 @@ export function CreateWorkspaceModal({
2728
isCreating,
2829
}: CreateWorkspaceModalProps) {
2930
const [name, setName] = useState('')
31+
const [error, setError] = useState<string | null>(null)
3032

31-
useEffect(() => {
33+
const [prevOpen, setPrevOpen] = useState(open)
34+
if (prevOpen !== open) {
35+
setPrevOpen(open)
3236
if (open) {
3337
setName('')
38+
setError(null)
3439
}
35-
}, [open])
40+
}
3641

3742
const handleSubmit = async () => {
3843
const trimmed = name.trim()
3944
if (!trimmed || isCreating) return
40-
await onConfirm(trimmed)
45+
try {
46+
await onConfirm(trimmed)
47+
} catch (err) {
48+
setError(getErrorMessage(err, 'Failed to create workspace'))
49+
}
4150
}
4251

4352
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -47,6 +56,11 @@ export function CreateWorkspaceModal({
4756
}
4857
}
4958

59+
const handleNameChange = (value: string) => {
60+
setName(value)
61+
setError(null)
62+
}
63+
5064
return (
5165
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Create workspace'>
5266
<ChipModalHeader onClose={() => onOpenChange(false)}>Create workspace</ChipModalHeader>
@@ -55,14 +69,14 @@ export function CreateWorkspaceModal({
5569
type='input'
5670
title='Name'
5771
value={name}
58-
onChange={setName}
72+
onChange={handleNameChange}
5973
placeholder='Workspace name'
6074
maxLength={100}
6175
autoComplete='off'
6276
disabled={isCreating}
6377
required
6478
/>
65-
<ChipModalError>{undefined}</ChipModalError>
79+
<ChipModalError>{error ?? undefined}</ChipModalError>
6680
</ChipModalBody>
6781
<ChipModalFooter
6882
onCancel={() => onOpenChange(false)}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export function useWorkspaceManagement({
176176
await switchWorkspace(newWorkspace)
177177
} catch (error) {
178178
logger.error('Error creating workspace:', error)
179+
throw error
179180
}
180181
},
181182
// eslint-disable-next-line react-hooks/exhaustive-deps

0 commit comments

Comments
 (0)