picoclaw/web/frontend/src/components/channels/channel-array-list-field.tsx
wenjie ba6992234f
feat(web): support list editing for channel array fields (#2595)
Add reusable channel array list controls and parsing utilities for channel forms.
Normalize channel string-array payloads in the backend, including pasted values,
numeric IDs, hidden characters, duplicates, and empty clears.
Also allow FlexibleStringSlice to unmarshal null values and cover the new behavior
with backend and config tests.
2026-04-21 16:04:28 +08:00

180 lines
4.7 KiB
TypeScript

import { IconX } from "@tabler/icons-react"
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import {
mergeUniqueStringItems,
parseConservativeStringListInput,
} from "@/components/channels/channel-array-utils"
import { Field } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
type StringListParser = (raw: string) => string[]
export type ArrayFieldFlusher = () => string[] | null
type RegisterArrayFieldFlusher = (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false
}
return left.every((item, index) => item === right[index])
}
interface ChannelArrayListFieldProps {
label: string
hint?: string
error?: string
required?: boolean
value: string[]
onChange: (value: string[]) => void
placeholder?: string
parser?: StringListParser
fieldPath?: string
registerFlusher?: RegisterArrayFieldFlusher
resetVersion?: number
}
export function ChannelArrayListField({
label,
hint,
error,
required,
value,
onChange,
placeholder,
parser = parseConservativeStringListInput,
fieldPath,
registerFlusher,
resetVersion,
}: ChannelArrayListFieldProps) {
const { t } = useTranslation()
const [draft, setDraft] = useState("")
const draftRef = useRef("")
const valueRef = useRef(value)
const localValueRef = useRef(value)
const parserRef = useRef(parser)
const onChangeRef = useRef(onChange)
useEffect(() => {
valueRef.current = value
localValueRef.current = value
}, [value])
useEffect(() => {
draftRef.current = ""
setDraft("")
}, [resetVersion])
useEffect(() => {
parserRef.current = parser
}, [parser])
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
const commitDraft = useCallback(() => {
const rawDraft = draftRef.current
if (rawDraft.trim() === "") {
if (!areStringArraysEqual(localValueRef.current, valueRef.current)) {
return localValueRef.current
}
draftRef.current = ""
setDraft("")
return null
}
draftRef.current = ""
setDraft("")
const nextItems = parserRef.current(rawDraft)
if (nextItems.length === 0) {
return null
}
const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems)
localValueRef.current = mergedItems
onChangeRef.current(mergedItems)
return mergedItems
}, [])
useEffect(() => {
if (!fieldPath || !registerFlusher) {
return
}
registerFlusher(fieldPath, commitDraft)
return () => registerFlusher(fieldPath, null)
}, [commitDraft, fieldPath, registerFlusher])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") {
return
}
event.preventDefault()
commitDraft()
}
const handleRemove = (index: number) => {
const nextValue = value.filter((_, itemIndex) => itemIndex !== index)
localValueRef.current = nextValue
onChangeRef.current(nextValue)
}
return (
<Field label={label} hint={hint} error={error} required={required}>
<div className="space-y-3">
{value.length > 0 && (
<div className="flex flex-wrap gap-2">
{value.map((item, index) => (
<span
key={`${item}-${index}`}
className="bg-muted text-foreground inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-1 text-xs"
>
<span className="break-all">{item}</span>
<button
type="button"
onClick={() => handleRemove(index)}
className="text-muted-foreground hover:text-foreground shrink-0 transition-colors"
aria-label={t("channels.field.removeListItem", {
value: item,
})}
>
<IconX className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<Input
value={draft}
onChange={(event) => {
const nextDraft = event.target.value
draftRef.current = nextDraft
setDraft(nextDraft)
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
<Button
type="button"
size="sm"
onClick={commitDraft}
disabled={draft.trim() === ""}
>
{t("common.confirm")}
</Button>
</div>
</div>
</Field>
)
}