diff --git a/src/components/JsonField.tsx b/src/components/JsonField.tsx new file mode 100644 index 000000000..11ae46079 --- /dev/null +++ b/src/components/JsonField.tsx @@ -0,0 +1,286 @@ +import * as React from "react"; +import { + useState, + useRef, + useEffect, + useImperativeHandle, + forwardRef, +} from "react"; +import CopyIcon from "./icons/CopyIcon"; +import XCloseIcon from "./icons/XCloseIcon"; +import { SettingData } from "../interfaces"; + +export interface JsonFieldProps { + setting: SettingData; + value?: any; + disabled?: boolean; + readOnly?: boolean; + onChange?: (value: any) => void; +} + +export interface JsonFieldHandle { + getValue(): any; + clear(): void; +} + +const JsonField = forwardRef( + function JsonField({ setting, value, disabled, readOnly, onChange }, ref) { + const [text, setText] = useState(() => valueToText(value)); + const [jsonError, setJsonError] = useState(null); + const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); + const [previousText, setPreviousText] = useState(null); + const [clearFeedback, setClearFeedback] = useState(false); + const [focused, setFocused] = useState(false); + + const copyTimeoutId = useRef | null>(null); + const clearFeedbackTimeoutId = useRef | null>( + null + ); + + // Sync external value changes to internal text (replaces UNSAFE_componentWillReceiveProps). + // Uses reference equality: callers must not pass a new object literal on every render + // (e.g. value={{ key: "val" }}), or in-progress edits will be wiped. Values sourced + // from Redux state are stable references and satisfy this requirement. + const [prevValue, setPrevValue] = useState(value); + if (prevValue !== value) { + setPrevValue(value); + setText(valueToText(value)); + setJsonError(null); + setPreviousText(null); + setClearFeedback(false); + setCopied(false); + setCopyFailed(false); + } + + useTimeoutCleanup({ + refs: [copyTimeoutId, clearFeedbackTimeoutId], + deps: [value], + }); + + useImperativeHandle( + ref, + () => ({ + getValue() { + const { parsed, error } = parseJson(text); + return error ? undefined : parsed; + }, + clear() { + cancelTimer(clearFeedbackTimeoutId); + cancelTimer(copyTimeoutId); + setText(""); + setJsonError(null); + setCopied(false); + setCopyFailed(false); + setPreviousText(null); + setClearFeedback(false); + if (onChange) onChange(null); + }, + }), + [text, onChange] + ); + + function handleChange(e: React.ChangeEvent) { + const newText = e.target.value; + const { parsed, error } = parseJson(newText); + cancelTimer(clearFeedbackTimeoutId); + cancelTimer(copyTimeoutId); + setText(newText); + setJsonError(error); + setPreviousText(null); + setClearFeedback(false); + setCopied(false); + setCopyFailed(false); + if (!error && onChange) { + onChange(newText.trim() ? parsed : null); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key === "z" && previousText !== null) { + e.preventDefault(); + cancelTimer(clearFeedbackTimeoutId); + const { parsed, error } = parseJson(previousText); + setText(previousText); + setJsonError(error); + setPreviousText(null); + setClearFeedback(false); + if (!error && onChange) { + onChange(previousText.trim() ? parsed : null); + } + } + } + + function handleCopy() { + cancelTimer(copyTimeoutId); + + function applyCopyResult(success: boolean) { + setCopied(success); + setCopyFailed(!success); + copyTimeoutId.current = setTimeout(() => { + setCopied(false); + setCopyFailed(false); + copyTimeoutId.current = null; + }, 2000); + } + + if (!navigator.clipboard) { + applyCopyResult(false); + return; + } + navigator.clipboard.writeText(text).then( + () => applyCopyResult(true), + () => applyCopyResult(false) + ); + } + + function handleClearClick() { + cancelTimer(clearFeedbackTimeoutId); + cancelTimer(copyTimeoutId); + setClearFeedback(true); + clearFeedbackTimeoutId.current = setTimeout(() => { + setClearFeedback(false); + clearFeedbackTimeoutId.current = null; + }, 5000); + setText(""); + setJsonError(null); + setCopied(false); + setCopyFailed(false); + setPreviousText(text); + if (onChange) onChange(null); + } + + // Prevents buttons from stealing focus from the textarea. + function preventButtonBlur(e: React.MouseEvent) { + e.preventDefault(); + } + + const textareaId = `json-field-${setting.key}`; + const descId = setting.description ? `json-desc-${setting.key}` : undefined; + const errorId = `json-error-${setting.key}`; + const showErrorMsg = focused || !!jsonError; + const describedBy = + [descId, jsonError ? errorId : undefined].filter(Boolean).join(" ") || + undefined; + const isEmpty = !text; + + return ( +
+ +
+