mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(emcn): show per-chip error tooltips on invalid email chips (#4998)
* improvement(emcn): show per-chip error tooltips on invalid email chips * improvement(emcn): fall back to generic reason for invalid email chips
This commit is contained in:
@@ -152,7 +152,7 @@ export default function PlaygroundPage() {
|
||||
const [dateRangeEnd, setDateRangeEnd] = useState('')
|
||||
const [tagItems, setTagItems] = useState<TagItem[]>([
|
||||
{ value: 'user@example.com', isValid: true },
|
||||
{ value: 'invalid-email', isValid: false },
|
||||
{ value: 'invalid-email', isValid: false, error: 'Invalid email format' },
|
||||
])
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
|
||||
+8
-1
@@ -134,7 +134,14 @@ export function CredentialSets() {
|
||||
return false
|
||||
}
|
||||
|
||||
setEmailItems((prev) => [...prev, { value: normalized, isValid }])
|
||||
setEmailItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
value: normalized,
|
||||
isValid,
|
||||
error: isValid ? undefined : (validation.reason ?? 'Invalid email format'),
|
||||
},
|
||||
])
|
||||
|
||||
if (isValid) {
|
||||
setEmailError(null)
|
||||
|
||||
+4
-1
@@ -655,7 +655,10 @@ function AuthSelector({
|
||||
setEmailError('')
|
||||
onEmailsChange([...emails, normalized])
|
||||
} else {
|
||||
setInvalidEmailItems((prev) => [...prev, { value: normalized, isValid }])
|
||||
setInvalidEmailItems((prev) => [
|
||||
...prev,
|
||||
{ value: normalized, isValid, error: validation.reason ?? 'Invalid email format' },
|
||||
])
|
||||
}
|
||||
|
||||
return isValid
|
||||
|
||||
@@ -388,12 +388,14 @@ export interface ChipModalEmailsFieldProps extends ChipModalFieldBaseProps {
|
||||
/**
|
||||
* Optional domain-level validator. Runs AFTER the field's internal format
|
||||
* check passes. Return an error message to reject the email (added as an
|
||||
* invalid chip and surfaced in the inline banner); return `null` to accept.
|
||||
* invalid chip whose reason shows in a tooltip on hover); return `null`
|
||||
* to accept.
|
||||
*/
|
||||
validate?: (email: string) => string | null
|
||||
/**
|
||||
* External error (e.g. server-side submit failure). Takes precedence over
|
||||
* the field's internal validation banner while present.
|
||||
* External error (e.g. server-side submit failure), rendered in the inline
|
||||
* banner below the field. Per-email rejection reasons are shown on the
|
||||
* invalid chips themselves, not here.
|
||||
*/
|
||||
error?: React.ReactNode
|
||||
/** Auto-focus the input when the field mounts. */
|
||||
@@ -561,8 +563,11 @@ function derivePlaceholderWithTags(placeholder: string): string {
|
||||
|
||||
/**
|
||||
* Internal renderer for {@link ChipModalField} `type='emails'`. Owns the
|
||||
* chip lifecycle (valid + invalid items, dedupe, inline error banner) and
|
||||
* lifts only the valid email list up to the consumer via `onChange`.
|
||||
* chip lifecycle (valid + invalid items, dedupe, per-chip error tooltips)
|
||||
* and lifts only the valid email list up to the consumer via `onChange`.
|
||||
* Each rejected entry carries its rejection reason on the chip itself,
|
||||
* surfaced as a tooltip; the inline banner is reserved for the consumer's
|
||||
* `error` (e.g. server-side submit failures).
|
||||
*/
|
||||
function ChipModalEmailsControl({
|
||||
value,
|
||||
@@ -576,7 +581,6 @@ function ChipModalEmailsControl({
|
||||
errorId,
|
||||
}: ChipModalEmailsFieldProps & { id: string; errorId: string }) {
|
||||
const [items, setItems] = React.useState<TagItem[]>([])
|
||||
const [internalError, setInternalError] = React.useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* Reconcile internal `items` with the consumer's `value` when the latter
|
||||
@@ -600,23 +604,24 @@ function ChipModalEmailsControl({
|
||||
if (!email) return false
|
||||
if (items.some((item) => item.value === email)) return false
|
||||
|
||||
if (!quickValidateEmail(email).isValid) {
|
||||
setItems((prev) => [...prev, { value: email, isValid: false }])
|
||||
setInternalError(null)
|
||||
const formatCheck = quickValidateEmail(email)
|
||||
if (!formatCheck.isValid) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{ value: email, isValid: false, error: formatCheck.reason ?? 'Invalid email format' },
|
||||
])
|
||||
return false
|
||||
}
|
||||
|
||||
const reason = validate?.(email)
|
||||
if (reason) {
|
||||
setItems((prev) => [...prev, { value: email, isValid: false }])
|
||||
setInternalError(reason)
|
||||
setItems((prev) => [...prev, { value: email, isValid: false, error: reason }])
|
||||
return false
|
||||
}
|
||||
|
||||
const next = [...items, { value: email, isValid: true }]
|
||||
setItems(next)
|
||||
onChange(next.filter((item) => item.isValid).map((item) => item.value))
|
||||
setInternalError(null)
|
||||
return true
|
||||
},
|
||||
[items, validate, onChange]
|
||||
@@ -630,17 +635,10 @@ function ChipModalEmailsControl({
|
||||
if (wasValid) {
|
||||
onChange(next.filter((item) => item.isValid).map((item) => item.value))
|
||||
}
|
||||
setInternalError(null)
|
||||
},
|
||||
[items, onChange]
|
||||
)
|
||||
|
||||
const handleInputChange = React.useCallback(() => {
|
||||
setInternalError(null)
|
||||
}, [])
|
||||
|
||||
const banner = error ?? internalError
|
||||
|
||||
return (
|
||||
<>
|
||||
<TagInput
|
||||
@@ -648,16 +646,15 @@ function ChipModalEmailsControl({
|
||||
items={items}
|
||||
onAdd={handleAdd}
|
||||
onRemove={handleRemove}
|
||||
onInputChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholderWithTags={derivePlaceholderWithTags(placeholder)}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
id={id}
|
||||
/>
|
||||
{banner && (
|
||||
{error && (
|
||||
<p id={errorId} role='alert' className={CHIP_MODAL_FIELD_ERROR_CLASS}>
|
||||
{banner}
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -81,6 +81,11 @@ const tagInputVariants = cva(
|
||||
export interface TagItem {
|
||||
value: string
|
||||
isValid: boolean
|
||||
/**
|
||||
* Why the item is invalid. Shown in a tooltip on the invalid chip (and as
|
||||
* screen-reader-only text inside it). Ignored when `isValid` is true.
|
||||
*/
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +167,9 @@ const TagInputTag = React.memo(function TagInputTag({
|
||||
onRemove(item.value, index, item.isValid)
|
||||
}, [item.value, item.isValid, index, onRemove])
|
||||
|
||||
return (
|
||||
const showError = !item.isValid && !!item.error
|
||||
|
||||
const tag = (
|
||||
<ChipTag
|
||||
variant='invite'
|
||||
invalid={!item.isValid}
|
||||
@@ -174,9 +181,19 @@ const TagInputTag = React.memo(function TagInputTag({
|
||||
<span className='min-w-0 flex-1 translate-y-[0.5px] truncate font-medium font-sans text-sm leading-5'>
|
||||
{item.value}
|
||||
</span>
|
||||
{showError && <span className='sr-only'>{item.error}</span>}
|
||||
{suffix}
|
||||
</ChipTag>
|
||||
)
|
||||
|
||||
if (!showError) return tag
|
||||
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{tag}</Tooltip.Trigger>
|
||||
<Tooltip.Content>{item.error}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user