Skip to content

Commit ff9d494

Browse files
committed
improvement(emcn): show per-chip error tooltips on invalid email chips
1 parent e2523e0 commit ff9d494

5 files changed

Lines changed: 46 additions & 26 deletions

File tree

apps/sim/app/playground/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export default function PlaygroundPage() {
152152
const [dateRangeEnd, setDateRangeEnd] = useState('')
153153
const [tagItems, setTagItems] = useState<TagItem[]>([
154154
{ value: 'user@example.com', isValid: true },
155-
{ value: 'invalid-email', isValid: false },
155+
{ value: 'invalid-email', isValid: false, error: 'Invalid email format' },
156156
])
157157

158158
const toggleDarkMode = () => {

apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ export function CredentialSets() {
134134
return false
135135
}
136136

137-
setEmailItems((prev) => [...prev, { value: normalized, isValid }])
137+
setEmailItems((prev) => [
138+
...prev,
139+
{ value: normalized, isValid, error: isValid ? undefined : validation.reason },
140+
])
138141

139142
if (isValid) {
140143
setEmailError(null)

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,10 @@ function AuthSelector({
655655
setEmailError('')
656656
onEmailsChange([...emails, normalized])
657657
} else {
658-
setInvalidEmailItems((prev) => [...prev, { value: normalized, isValid }])
658+
setInvalidEmailItems((prev) => [
659+
...prev,
660+
{ value: normalized, isValid, error: validation.reason },
661+
])
659662
}
660663

661664
return isValid

apps/sim/components/emcn/components/chip-modal/chip-modal.tsx

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -388,12 +388,14 @@ export interface ChipModalEmailsFieldProps extends ChipModalFieldBaseProps {
388388
/**
389389
* Optional domain-level validator. Runs AFTER the field's internal format
390390
* check passes. Return an error message to reject the email (added as an
391-
* invalid chip and surfaced in the inline banner); return `null` to accept.
391+
* invalid chip whose reason shows in a tooltip on hover); return `null`
392+
* to accept.
392393
*/
393394
validate?: (email: string) => string | null
394395
/**
395-
* External error (e.g. server-side submit failure). Takes precedence over
396-
* the field's internal validation banner while present.
396+
* External error (e.g. server-side submit failure), rendered in the inline
397+
* banner below the field. Per-email rejection reasons are shown on the
398+
* invalid chips themselves, not here.
397399
*/
398400
error?: React.ReactNode
399401
/** Auto-focus the input when the field mounts. */
@@ -561,8 +563,11 @@ function derivePlaceholderWithTags(placeholder: string): string {
561563

562564
/**
563565
* Internal renderer for {@link ChipModalField} `type='emails'`. Owns the
564-
* chip lifecycle (valid + invalid items, dedupe, inline error banner) and
565-
* lifts only the valid email list up to the consumer via `onChange`.
566+
* chip lifecycle (valid + invalid items, dedupe, per-chip error tooltips)
567+
* and lifts only the valid email list up to the consumer via `onChange`.
568+
* Each rejected entry carries its rejection reason on the chip itself,
569+
* surfaced as a tooltip; the inline banner is reserved for the consumer's
570+
* `error` (e.g. server-side submit failures).
566571
*/
567572
function ChipModalEmailsControl({
568573
value,
@@ -576,7 +581,6 @@ function ChipModalEmailsControl({
576581
errorId,
577582
}: ChipModalEmailsFieldProps & { id: string; errorId: string }) {
578583
const [items, setItems] = React.useState<TagItem[]>([])
579-
const [internalError, setInternalError] = React.useState<string | null>(null)
580584

581585
/**
582586
* Reconcile internal `items` with the consumer's `value` when the latter
@@ -600,23 +604,24 @@ function ChipModalEmailsControl({
600604
if (!email) return false
601605
if (items.some((item) => item.value === email)) return false
602606

603-
if (!quickValidateEmail(email).isValid) {
604-
setItems((prev) => [...prev, { value: email, isValid: false }])
605-
setInternalError(null)
607+
const formatCheck = quickValidateEmail(email)
608+
if (!formatCheck.isValid) {
609+
setItems((prev) => [
610+
...prev,
611+
{ value: email, isValid: false, error: formatCheck.reason ?? 'Invalid email format' },
612+
])
606613
return false
607614
}
608615

609616
const reason = validate?.(email)
610617
if (reason) {
611-
setItems((prev) => [...prev, { value: email, isValid: false }])
612-
setInternalError(reason)
618+
setItems((prev) => [...prev, { value: email, isValid: false, error: reason }])
613619
return false
614620
}
615621

616622
const next = [...items, { value: email, isValid: true }]
617623
setItems(next)
618624
onChange(next.filter((item) => item.isValid).map((item) => item.value))
619-
setInternalError(null)
620625
return true
621626
},
622627
[items, validate, onChange]
@@ -630,34 +635,26 @@ function ChipModalEmailsControl({
630635
if (wasValid) {
631636
onChange(next.filter((item) => item.isValid).map((item) => item.value))
632637
}
633-
setInternalError(null)
634638
},
635639
[items, onChange]
636640
)
637641

638-
const handleInputChange = React.useCallback(() => {
639-
setInternalError(null)
640-
}, [])
641-
642-
const banner = error ?? internalError
643-
644642
return (
645643
<>
646644
<TagInput
647645
variant='block'
648646
items={items}
649647
onAdd={handleAdd}
650648
onRemove={handleRemove}
651-
onInputChange={handleInputChange}
652649
placeholder={placeholder}
653650
placeholderWithTags={derivePlaceholderWithTags(placeholder)}
654651
disabled={disabled}
655652
autoFocus={autoFocus}
656653
id={id}
657654
/>
658-
{banner && (
655+
{error && (
659656
<p id={errorId} role='alert' className={CHIP_MODAL_FIELD_ERROR_CLASS}>
660-
{banner}
657+
{error}
661658
</p>
662659
)}
663660
</>

apps/sim/components/emcn/components/tag-input/tag-input.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ const tagInputVariants = cva(
8181
export interface TagItem {
8282
value: string
8383
isValid: boolean
84+
/**
85+
* Why the item is invalid. Shown in a tooltip on the invalid chip (and as
86+
* screen-reader-only text inside it). Ignored when `isValid` is true.
87+
*/
88+
error?: string
8489
}
8590

8691
/**
@@ -162,7 +167,9 @@ const TagInputTag = React.memo(function TagInputTag({
162167
onRemove(item.value, index, item.isValid)
163168
}, [item.value, item.isValid, index, onRemove])
164169

165-
return (
170+
const showError = !item.isValid && !!item.error
171+
172+
const tag = (
166173
<ChipTag
167174
variant='invite'
168175
invalid={!item.isValid}
@@ -174,9 +181,19 @@ const TagInputTag = React.memo(function TagInputTag({
174181
<span className='min-w-0 flex-1 translate-y-[0.5px] truncate font-medium font-sans text-sm leading-5'>
175182
{item.value}
176183
</span>
184+
{showError && <span className='sr-only'>{item.error}</span>}
177185
{suffix}
178186
</ChipTag>
179187
)
188+
189+
if (!showError) return tag
190+
191+
return (
192+
<Tooltip.Root>
193+
<Tooltip.Trigger asChild>{tag}</Tooltip.Trigger>
194+
<Tooltip.Content>{item.error}</Tooltip.Content>
195+
</Tooltip.Root>
196+
)
180197
})
181198

182199
/**

0 commit comments

Comments
 (0)