import classNames from 'classnames'; import type { EditorView } from 'codemirror'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { v4 as uuid } from 'uuid'; import { usePrompt } from '../../hooks/usePrompt'; import { DropMarker } from '../DropMarker'; import { SelectFile } from '../SelectFile'; import { Checkbox } from './Checkbox'; import { Dropdown } from './Dropdown'; import type { GenericCompletionConfig } from './Editor/genericCompletion'; import { Icon } from './Icon'; import { IconButton } from './IconButton'; import type { InputProps } from './Input'; import { Input } from './Input'; import { RadioDropdown } from './RadioDropdown'; export type PairEditorProps = { pairs: Pair[]; onChange: (pairs: Pair[]) => void; forceUpdateKey?: string; className?: string; namePlaceholder?: string; valuePlaceholder?: string; valueType?: 'text' | 'password'; nameAutocomplete?: GenericCompletionConfig; valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; nameAutocompleteVariables?: boolean; valueAutocompleteVariables?: boolean; allowFileValues?: boolean; nameValidate?: InputProps['validate']; valueValidate?: InputProps['validate']; noScroll?: boolean; }; export type Pair = { id?: string; enabled?: boolean; name: string; value: string; contentType?: string; isFile?: boolean; }; type PairContainer = { pair: Pair; id: string; }; export function PairEditor({ className, forceUpdateKey, nameAutocomplete, nameAutocompleteVariables, namePlaceholder, nameValidate, valueType, onChange, noScroll, pairs: originalPairs, valueAutocomplete, valueAutocompleteVariables, valuePlaceholder, valueValidate, allowFileValues, }: PairEditorProps) { const [forceFocusPairId, setForceFocusPairId] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); const [pairs, setPairs] = useState(() => { // Remove empty headers on initial render const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === '')); const pairs = nonEmpty.map((pair) => newPairContainer(pair)); return [...pairs, newPairContainer()]; }); useEffect(() => { // Remove empty headers on initial render // TODO: Make this not refresh the entire editor when forceUpdateKey changes, using some // sort of diff method or deterministic IDs based on array index and update key const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === '')); const pairs = nonEmpty.map((pair) => newPairContainer(pair)); setPairs([...pairs, newPairContainer()]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [forceUpdateKey]); const setPairsAndSave = useCallback( (fn: (pairs: PairContainer[]) => PairContainer[]) => { setPairs((oldPairs) => { const pairs = fn(oldPairs).map((p) => p.pair); onChange(pairs); return fn(oldPairs); }); }, [onChange], ); const handleMove = useCallback( (id, side) => { const dragIndex = pairs.findIndex((r) => r.id === id); setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1); }, [pairs], ); const handleEnd = useCallback( (id: string) => { if (hoveredIndex === null) return; setHoveredIndex(null); setPairsAndSave((pairs) => { const index = pairs.findIndex((p) => p.id === id); const pair = pairs[index]; if (pair === undefined) return pairs; const newPairs = pairs.filter((p) => p.id !== id); if (hoveredIndex > index) newPairs.splice(hoveredIndex - 1, 0, pair); else newPairs.splice(hoveredIndex, 0, pair); return newPairs; }); }, [hoveredIndex, setPairsAndSave], ); const handleChange = useCallback( (pair: PairContainer) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), [setPairsAndSave], ); const handleDelete = useCallback( (pair: PairContainer, focusPrevious: boolean) => { if (focusPrevious) { const index = pairs.findIndex((p) => p.id === pair.id); const id = pairs[index - 1]?.id ?? null; setForceFocusPairId(id); } return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)); }, [setPairsAndSave, setForceFocusPairId, pairs], ); const handleFocus = useCallback( (pair: PairContainer) => setPairs((pairs) => { setForceFocusPairId(null); // Remove focus override when something focused const isLast = pair.id === pairs[pairs.length - 1]?.id; return isLast ? [...pairs, newPairContainer()] : pairs; }), [], ); // Ensure there's always at least one pair useEffect(() => { if (pairs.length === 0) { setPairs((pairs) => [...pairs, newPairContainer()]); } }, [pairs]); return (
{pairs.map((p, i) => { const isLast = i === pairs.length - 1; return ( {hoveredIndex === i && } ); })}
); } enum ItemTypes { ROW = 'pair-row', } type PairEditorRowProps = { className?: string; pairContainer: PairContainer; forceFocusPairId?: string | null; onMove: (id: string, side: 'above' | 'below') => void; onEnd: (id: string) => void; onChange: (pair: PairContainer) => void; onDelete?: (pair: PairContainer, focusPrevious: boolean) => void; onFocus?: (pair: PairContainer) => void; onSubmit?: (pair: PairContainer) => void; isLast?: boolean; } & Pick< PairEditorProps, | 'nameAutocomplete' | 'valueAutocomplete' | 'nameAutocompleteVariables' | 'valueAutocompleteVariables' | 'valueType' | 'namePlaceholder' | 'valuePlaceholder' | 'nameValidate' | 'valueValidate' | 'forceUpdateKey' | 'allowFileValues' >; function PairEditorRow({ allowFileValues, className, forceFocusPairId, forceUpdateKey, isLast, nameAutocomplete, nameAutocompleteVariables, namePlaceholder, nameValidate, onChange, onDelete, onEnd, onFocus, onMove, pairContainer, valueAutocomplete, valueAutocompleteVariables, valuePlaceholder, valueValidate, valueType, }: PairEditorRowProps) { const { id } = pairContainer; const ref = useRef(null); const prompt = usePrompt(); const nameInputRef = useRef(null); useEffect(() => { if (forceFocusPairId === pairContainer.id) { nameInputRef.current?.focus(); } }, [forceFocusPairId, pairContainer.id]); const handleChangeEnabled = useMemo( () => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }), [id, onChange, pairContainer.pair], ); const handleChangeName = useMemo( () => (name: string) => onChange({ id, pair: { ...pairContainer.pair, name } }), [onChange, id, pairContainer.pair], ); const handleChangeValueText = useMemo( () => (value: string) => onChange({ id, pair: { ...pairContainer.pair, value, isFile: false } }), [onChange, id, pairContainer.pair], ); const handleChangeValueFile = useMemo( () => ({ filePath }: { filePath: string | null }) => onChange({ id, pair: { ...pairContainer.pair, value: filePath ?? '', isFile: true }, }), [onChange, id, pairContainer.pair], ); const handleChangeValueContentType = useMemo( () => (contentType: string) => onChange({ id, pair: { ...pairContainer.pair, contentType } }), [onChange, id, pairContainer.pair], ); const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]); const handleDelete = useCallback( () => onDelete?.(pairContainer, false), [onDelete, pairContainer], ); const [, connectDrop] = useDrop( { accept: ItemTypes.ROW, hover: (_, monitor) => { if (!ref.current) return; const hoverBoundingRect = ref.current?.getBoundingClientRect(); const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const clientOffset = monitor.getClientOffset(); const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; onMove(pairContainer.id, hoverClientY < hoverMiddleY ? 'above' : 'below'); }, }, [onMove], ); const [, connectDrag] = useDrag( { type: ItemTypes.ROW, item: () => pairContainer, collect: (m) => ({ isDragging: m.isDragging() }), end: () => onEnd(pairContainer.id), }, [pairContainer, onEnd], ); connectDrag(ref); connectDrop(ref); return (
{!isLast ? (
) : ( )}
{pairContainer.pair.isFile ? ( ) : ( )}
{allowFileValues ? ( { if (v === 'file') handleChangeValueFile({ filePath: '' }); else handleChangeValueText(''); }} items={[ { label: 'Text', value: 'text' }, { label: 'File', value: 'file' }, ]} extraItems={[ { key: 'mime', label: 'Set Content-Type', leftSlot: , hidden: !pairContainer.pair.isFile, onSelect: async () => { const v = await prompt({ id: 'content-type', require: false, title: 'Override Content-Type', label: 'Content-Type', placeholder: 'text/plain', defaultValue: pairContainer.pair.contentType ?? '', name: 'content-type', confirmLabel: 'Set', description: 'Leave blank to auto-detect', }); handleChangeValueContentType(v); }, }, { key: 'clear-file', label: 'Unset File', leftSlot: , hidden: !pairContainer.pair.isFile, onSelect: async () => { handleChangeValueFile({ filePath: null }); }, }, { key: 'delete', label: 'Delete', onSelect: handleDelete, variant: 'danger', leftSlot: , }, ]} > ) : ( )}
); } const newPairContainer = (initialPair?: Pair): PairContainer => { const id = initialPair?.id ?? uuid(); const pair = initialPair ?? { name: '', value: '', enabled: true, isFile: false }; return { id, pair }; };