diff --git a/webapp/src/components/common/JSONEditor/index.tsx b/webapp/src/components/common/JSONEditor/index.tsx index 6c7a0c19b5..b89d4b0982 100644 --- a/webapp/src/components/common/JSONEditor/index.tsx +++ b/webapp/src/components/common/JSONEditor/index.tsx @@ -1,39 +1,176 @@ -import JSONEditorLib, { JSONEditorOptions } from "jsoneditor"; -import { useRef } from "react"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import JSONEditorClass, { + type JSONEditorOptions, + type HistoryItem, +} from "jsoneditor"; +import { useMemo, useRef } from "react"; import { useDeepCompareEffect, useMount } from "react-use"; import "jsoneditor/dist/jsoneditor.min.css"; import "./dark-theme.css"; +import { PromiseAny } from "../../../utils/tsUtils"; +import useAutoUpdateRef from "../../../hooks/useAutoUpdateRef"; +import { createSaveButton } from "./utils"; +import * as R from "ramda"; +import * as RA from "ramda-adjunct"; +import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; +import { toError } from "../../../utils/fnUtils"; -interface JSONEditorProps extends JSONEditorOptions { - // eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface JSONEditorProps extends JSONEditorOptions { json: any; + onSave?: (json: any) => PromiseAny; + onSaveSuccessful?: (json: any) => any; } -function JSONEditor({ json, ...options }: JSONEditorProps) { +function JSONEditor(props: JSONEditorProps) { + const { json, onSave, onSaveSuccessful, ...options } = props; const ref = useRef(null); - const editorRef = useRef(); + const editorRef = useRef(); + const onSaveRef = useAutoUpdateRef(onSave); + const callbackOptionsRef = useAutoUpdateRef>( + R.pickBy(RA.isFunction, options), + ); + const saveBtn = useMemo(() => createSaveButton(handleSaveClick), []); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + /** + * The history item corresponding to the saved JSON. + * Only for some modes. + */ + const presentHistoryItem = useRef(null); + + // Initialize the JSON editor useMount(() => { if (!ref.current) { return; } - const editor = new JSONEditorLib(ref.current, options); + const editor = new JSONEditorClass(ref.current, { + ...options, + ...callbackOptionsRef.current, + onChange: handleChange, + onModeChange: handleModeChange, + }); editor.set(json); + editorRef.current = editor; + initSave(); + return () => editor.destroy(); }); + // Update JSON when `json` prop change useDeepCompareEffect(() => { - if (!editorRef.current) { - return; - } + const editor = editorRef.current; - editorRef.current.set(json); - editorRef.current.expandAll(); + if (editor) { + editor.set(json); + editor.expandAll?.(); + } }, [json]); + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + function handleChange() { + callbackOptionsRef.current.onChange?.(); + + // Update the save button state + + const editor = editorRef.current; + + // Use undo/redo history to determine if the JSON is dirty + if (editor?.history?.history) { + updateSaveState( + presentHistoryItem.current !== + (editor.history.history[editor.history.index] ?? null), + ); + } else { + updateSaveState(true); + } + } + + function handleModeChange( + ...args: Parameters> + ) { + callbackOptionsRef.current.onModeChange?.(...args); + // Menu is reset when the mode changes + initSave(); + } + + async function handleSaveClick() { + const onSave = onSaveRef.current; + const editor = editorRef.current; + + if (onSave && editor) { + let json; + + try { + json = editor.get(); + } catch (err) { + enqueueErrorSnackbar("Invalid JSON", toError(err)); + return; + } + + try { + await onSave(json); + + updateSaveState(false); + onSaveSuccessful?.(json); + + presentHistoryItem.current = + editor?.history?.history?.[editor.history.index] ?? null; + } catch (err) { + enqueueErrorSnackbar("test", toError(err)); + } + } + } + + //////////////////////////////////////////////////////////////// + // Save + //////////////////////////////////////////////////////////////// + + function initSave() { + const editor = editorRef.current; + + presentHistoryItem.current = null; + saveBtn.remove(); + + if ( + // The save button is added to the menu only when the `onSave` callback is provided + onSaveRef.current && + editor && + ["tree", "form", "code", "text"].includes(editor.getMode()) + ) { + updateSaveState(false); + editor.menu.append(saveBtn); + } + } + + function updateSaveState(enable: boolean) { + // Update the save button style + saveBtn.style.opacity = enable ? "1" : "0.1"; + saveBtn.disabled = !enable; + + // Changing the mode resets undo/redo history and undo/redo are not available in all modes. + // So the change mode mode button is disabled when the JSON is dirty. + + const editorModeBtn = editorRef.current?.menu.querySelector( + "button.jsoneditor-modes", + ); + + if (enable) { + editorModeBtn?.setAttribute("disabled", ""); + } else { + editorModeBtn?.removeAttribute("disabled"); + } + } + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + return
; } diff --git a/webapp/src/components/common/JSONEditor/utils.ts b/webapp/src/components/common/JSONEditor/utils.ts new file mode 100644 index 0000000000..d827e24623 --- /dev/null +++ b/webapp/src/components/common/JSONEditor/utils.ts @@ -0,0 +1,20 @@ +import i18n from "../../../i18n"; + +export function createSaveButton(onClick: VoidFunction) { + const saveBtn = document.createElement("button"); + saveBtn.classList.add("jsoneditor-separator"); + saveBtn.title = i18n.t("global.save"); + saveBtn.style.backgroundImage = + "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAA7E" + + "AAAOxAGVKw4bAAAA6klEQVQ4jaXSMUoDYRQE4G+XJYhFkNR2ggewtvQE3kS09QQKIjmDWHsEwcIrWGulIiIhBh" + + "HHIptkDYlhdcr3v5l5/3tTJNlHHxu4wYPlqLCHM5wWRUGS+8ywuYiV5DzJdZJukqu69ySJCt1G79cS5x3sooPP" + + "unaEp7Iea5XAMhyXLQnzqCo0RdaXNF7iFkOsNR+KJO+N4iOef3Essd0wHc0LtMVossAXbNUjsniZza92cIfeRG" + + "BQFMVrC+ePJAP0JqptzzfllH8kT/HfHCjNovlngTc/49yGO6xwiH6SC+MzrtpJaZybHg6+AW/yWkXws02vAAAA" + + "AElFTkSuQmCC)"; + saveBtn.style.backgroundRepeat = "no-repeat"; + saveBtn.style.backgroundPosition = "center"; + + saveBtn.addEventListener("click", onClick); + + return saveBtn; +} diff --git a/webapp/src/types/jsoneditor.d.ts b/webapp/src/types/jsoneditor.d.ts new file mode 100644 index 0000000000..d07793f51e --- /dev/null +++ b/webapp/src/types/jsoneditor.d.ts @@ -0,0 +1,32 @@ +import "jsoneditor"; + +declare module "jsoneditor" { + export interface HistoryItem { + action: string; + params: object; + timestamp: Date; + } + + export default interface JSONEditor { + /** + * Only available for mode `code`. + */ + aceEditor?: AceAjax.Editor; + /** + * Expand all fields. Only applicable for mode `tree`, `view`, and `form`. + */ + expandAll?: VoidFunction; + /** + * Only available for mode `tree`, `form`, and `preview`. + */ + history?: { + /** + * Only available for mode `tree`, and `form`. + */ + history?: HistoryItem[]; + index: number; + onChange: () => void; + }; + menu: HTMLDivElement; + } +}