Skip to content

Commit

Permalink
feat(ui-commons): add save feature in JSONEditor component
Browse files Browse the repository at this point in the history
* create a save button in the menu
* add missing typing
  • Loading branch information
skamril committed Sep 10, 2024
1 parent d655968 commit 0b2333b
Show file tree
Hide file tree
Showing 3 changed files with 201 additions and 12 deletions.
161 changes: 149 additions & 12 deletions webapp/src/components/common/JSONEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(null);
const editorRef = useRef<JSONEditorLib>();
const editorRef = useRef<JSONEditorClass>();
const onSaveRef = useAutoUpdateRef(onSave);
const callbackOptionsRef = useAutoUpdateRef<Partial<JSONEditorOptions>>(
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<HistoryItem | null>(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<NonNullable<JSONEditorOptions["onModeChange"]>>
) {
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 <div ref={ref} />;
}

Expand Down
20 changes: 20 additions & 0 deletions webapp/src/components/common/JSONEditor/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions webapp/src/types/jsoneditor.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}

0 comments on commit 0b2333b

Please sign in to comment.