import type { EditorView } from '@codemirror/view'; import classNames from 'classnames'; import { forwardRef, Fragment, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { useToggle } from '../../hooks/useToggle'; import { languageFromContentType } from '../../lib/contentType'; import { showDialog } from '../../lib/dialog'; import { generateId } from '../../lib/generateId'; import { showPrompt } from '../../lib/prompt'; import { DropMarker } from '../DropMarker'; import { SelectFile } from '../SelectFile'; import { Button } from './Button'; import { Checkbox } from './Checkbox'; import type { DropdownItem } from './Dropdown'; import { Dropdown } from './Dropdown'; import { Editor } from './Editor/Editor'; import type { GenericCompletionConfig } from './Editor/genericCompletion'; import { Icon } from './Icon'; import { IconButton } from './IconButton'; import type { InputProps } from './Input'; import { Input } from './Input'; import { PlainInput } from './PlainInput'; import type { RadioDropdownItem } from './RadioDropdown'; import { RadioDropdown } from './RadioDropdown'; export interface PairEditorRef { focusValue(index: number): void; } export type PairEditorProps = { allowFileValues?: boolean; allowMultilineValues?: boolean; className?: string; forcedEnvironmentId?: string; forceUpdateKey?: string; nameAutocomplete?: GenericCompletionConfig; nameAutocompleteFunctions?: boolean; nameAutocompleteVariables?: boolean; namePlaceholder?: string; nameValidate?: InputProps['validate']; noScroll?: boolean; onChange: (pairs: PairWithId[]) => void; pairs: Pair[]; stateKey: InputProps['stateKey']; valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; valueAutocompleteFunctions?: boolean; valueAutocompleteVariables?: boolean; valuePlaceholder?: string; valueType?: InputProps['type'] | ((pair: Pair) => InputProps['type']); valueValidate?: InputProps['validate']; }; export type Pair = { id?: string; enabled?: boolean; name: string; value: string; contentType?: string; isFile?: boolean; readOnlyName?: boolean; }; export type PairWithId = Pair & { id: string; }; /** Max number of pairs to show before prompting the user to reveal the rest */ const MAX_INITIAL_PAIRS = 50; export const PairEditor = forwardRef(function PairEditor( { allowFileValues, allowMultilineValues, className, forcedEnvironmentId, forceUpdateKey, nameAutocomplete, nameAutocompleteFunctions, nameAutocompleteVariables, namePlaceholder, nameValidate, noScroll, onChange, pairs: originalPairs, stateKey, valueAutocomplete, valueAutocompleteFunctions, valueAutocompleteVariables, valuePlaceholder, valueType, valueValidate, }: PairEditorProps, ref, ) { const [forceFocusNamePairId, setForceFocusNamePairId] = useState(null); const [forceFocusValuePairId, setForceFocusValuePairId] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); const [pairs, setPairs] = useState([]); const [showAll, toggleShowAll] = useToggle(false); useImperativeHandle( ref, () => ({ focusValue(index: number) { const id = pairs[index]?.id ?? 'n/a'; setForceFocusValuePairId(id); }, }), [pairs], ); useEffect(() => { // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs) const newPairs: PairWithId[] = []; for (let i = 0; i < originalPairs.length; i++) { const p = originalPairs[i]; if (!p) continue; // Make TS happy if (isPairEmpty(p)) continue; newPairs.push(ensurePairId(p)); } // Add empty last pair if there is none const lastPair = newPairs[newPairs.length - 1]; if (lastPair == null || !isPairEmpty(lastPair)) { newPairs.push(emptyPair()); } setPairs(newPairs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [forceUpdateKey]); const setPairsAndSave = useCallback( (fn: (pairs: PairWithId[]) => PairWithId[]) => { setPairs((oldPairs) => { const pairs = fn(oldPairs); onChange(pairs); return pairs; }); }, [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: PairWithId) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), [setPairsAndSave], ); const handleDelete = useCallback( (pair: Pair, focusPrevious: boolean) => { if (focusPrevious) { const index = pairs.findIndex((p) => p.id === pair.id); const id = pairs[index - 1]?.id ?? null; setForceFocusNamePairId(id); } return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)); }, [setPairsAndSave, setForceFocusNamePairId, pairs], ); const handleFocus = useCallback( (pair: Pair) => setPairs((pairs) => { setForceFocusNamePairId(null); // Remove focus override when something focused setForceFocusValuePairId(null); // Remove focus override when something focused const isLast = pair.id === pairs[pairs.length - 1]?.id; if (isLast) { const prevPair = pairs[pairs.length - 1]; setForceFocusNamePairId(prevPair?.id ?? null); return [...pairs, emptyPair()]; } else { return pairs; } }), [], ); return (
{pairs.map((p, i) => { if (!showAll && i > MAX_INITIAL_PAIRS) return null; const isLast = i === pairs.length - 1; return ( {hoveredIndex === i && } ); })} {!showAll && pairs.length > MAX_INITIAL_PAIRS && ( )}
); }); enum ItemTypes { ROW = 'pair-row', } type PairEditorRowProps = { className?: string; pair: PairWithId; forceFocusNamePairId?: string | null; forceFocusValuePairId?: string | null; onMove: (id: string, side: 'above' | 'below') => void; onEnd: (id: string) => void; onChange: (pair: PairWithId) => void; onDelete?: (pair: PairWithId, focusPrevious: boolean) => void; onFocus?: (pair: PairWithId) => void; onSubmit?: (pair: PairWithId) => void; isLast?: boolean; index: number; } & Pick< PairEditorProps, | 'allowFileValues' | 'allowMultilineValues' | 'forcedEnvironmentId' | 'forceUpdateKey' | 'nameAutocomplete' | 'nameAutocompleteVariables' | 'namePlaceholder' | 'nameValidate' | 'nameAutocompleteFunctions' | 'stateKey' | 'valueAutocomplete' | 'valueAutocompleteFunctions' | 'valueAutocompleteVariables' | 'valuePlaceholder' | 'valueType' | 'valueValidate' >; function PairEditorRow({ allowFileValues, allowMultilineValues, className, forcedEnvironmentId, forceFocusNamePairId, forceFocusValuePairId, forceUpdateKey, index, isLast, nameAutocomplete, namePlaceholder, nameValidate, nameAutocompleteFunctions, nameAutocompleteVariables, onChange, onDelete, onEnd, onFocus, onMove, pair, stateKey, valueAutocomplete, valueAutocompleteFunctions, valueAutocompleteVariables, valuePlaceholder, valueType, valueValidate, }: PairEditorRowProps) { const ref = useRef(null); const nameInputRef = useRef(null); const valueInputRef = useRef(null); useEffect(() => { if (forceFocusNamePairId === pair.id) { nameInputRef.current?.focus(); } }, [forceFocusNamePairId, pair.id]); useEffect(() => { if (forceFocusValuePairId === pair.id) { valueInputRef.current?.focus(); } }, [forceFocusValuePairId, pair.id]); const handleFocus = useCallback(() => onFocus?.(pair), [onFocus, pair]); const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]); const handleChangeEnabled = useMemo( () => (enabled: boolean) => onChange({ ...pair, enabled }), [onChange, pair], ); const handleChangeName = useMemo( () => (name: string) => onChange({ ...pair, name }), [onChange, pair], ); const handleChangeValueText = useMemo( () => (value: string) => onChange({ ...pair, value, isFile: false }), [onChange, pair], ); const handleChangeValueFile = useMemo( () => ({ filePath }: { filePath: string | null }) => onChange({ ...pair, value: filePath ?? '', isFile: true }), [onChange, pair], ); const handleChangeValueContentType = useMemo( () => (contentType: string) => onChange({ ...pair, contentType }), [onChange, pair], ); const handleEditMultiLineValue = useCallback( () => showDialog({ id: 'pair-edit-multiline', size: 'dynamic', title: <>Edit {pair.name}, render: ({ hide }) => ( ), }), [handleChangeValueText, pair.contentType, pair.name, pair.value], ); const defaultItems = useMemo( (): DropdownItem[] => [ { label: 'Edit Multi-line', onSelect: handleEditMultiLineValue, hidden: !allowMultilineValues, }, { label: 'Delete', onSelect: handleDelete, color: 'danger', }, ], [allowMultilineValues, handleDelete, handleEditMultiLineValue], ); 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(pair.id, hoverClientY < hoverMiddleY ? 'above' : 'below'); }, }, [onMove], ); const [, connectDrag] = useDrag( { type: ItemTypes.ROW, item: () => pair, collect: (m) => ({ isDragging: m.isDragging() }), end: () => onEnd(pair.id), }, [pair, onEnd], ); connectDrag(ref); connectDrop(ref); return (
{!isLast ? (
) : ( )}
{isLast ? ( // Use PlainInput for last ones because there's a unique bug where clicking below // the Codemirror input focuses it. ) : ( )}
{pair.isFile ? ( ) : isLast ? ( // Use PlainInput for last ones because there's a unique bug where clicking below // the Codemirror input focuses it. ) : pair.value.includes('\n') ? ( ) : ( )}
{allowFileValues ? ( ) : ( )}
); } const fileItems: RadioDropdownItem[] = [ { label: 'Text', value: 'text' }, { label: 'File', value: 'file' }, ]; function FileActionsDropdown({ pair, onChangeFile, onChangeText, onChangeContentType, onDelete, editMultiLine, }: { pair: Pair; onChangeFile: ({ filePath }: { filePath: string | null }) => void; onChangeText: (text: string) => void; onChangeContentType: (contentType: string) => void; onDelete: () => void; editMultiLine: () => void; }) { const onChange = useCallback( (v: string) => { if (v === 'file') onChangeFile({ filePath: '' }); else onChangeText(''); }, [onChangeFile, onChangeText], ); const itemsAfter = useMemo( () => [ { label: 'Edit Multi-Line', leftSlot: , hidden: pair.isFile, onSelect: editMultiLine, }, { label: 'Set Content-Type', leftSlot: , onSelect: async () => { const contentType = await showPrompt({ id: 'content-type', title: 'Override Content-Type', label: 'Content-Type', placeholder: 'text/plain', defaultValue: pair.contentType ?? '', confirmText: 'Set', description: 'Leave blank to auto-detect', }); if (contentType == null) return; onChangeContentType(contentType); }, }, { label: 'Unset File', leftSlot: , hidden: pair.isFile, onSelect: async () => { onChangeFile({ filePath: null }); }, }, { label: 'Delete', onSelect: onDelete, variant: 'danger', leftSlot: , color: 'danger', }, ], [editMultiLine, onChangeContentType, onChangeFile, onDelete, pair.contentType, pair.isFile], ); return ( ); } function emptyPair(): PairWithId { return ensurePairId({ enabled: true, name: '', value: '' }); } function isPairEmpty(pair: Pair): boolean { return !pair.name && !pair.value; } function MultilineEditDialog({ defaultValue, contentType, onChange, hide, }: { defaultValue: string; contentType: string | null; onChange: (value: string) => void; hide: () => void; }) { const [value, setValue] = useState(defaultValue); const language = languageFromContentType(contentType, value); return (
); } // eslint-disable-next-line react-refresh/only-export-components export function ensurePairId(p: Pair): PairWithId { if (typeof p.id === 'string') { return p as PairWithId; } else { return { ...p, id: p.id ?? generateId() }; } }