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.
180 lines
4.7 KiB
TypeScript
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>
|
|
)
|
|
}
|