Skip to content

Commit 711200d

Browse files
committed
fix(chart-playground-web): stop store echo from resetting the editor caret to the end
1 parent 90143e9 commit 711200d

6 files changed

Lines changed: 120 additions & 24 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
jest.mock("plotly.js-dist-min", () => ({}));
2+
jest.mock("react-plotly.js", () => jest.fn(() => null));
3+
jest.mock("react-plotly.js/factory", () => jest.fn(() => jest.fn(() => null)));
4+
5+
import { act, renderHook } from "@testing-library/react";
6+
import { EditorStore, PlaygroundDataV1 } from "@mendix/shared-charts/main";
7+
import { useComposedEditorController } from "../useComposedEditorController";
8+
9+
function setupData(): PlaygroundDataV1 {
10+
const store = new EditorStore();
11+
store.reset({ layout: '{"a":1}', config: "{}", data: [] });
12+
return {
13+
plotData: [],
14+
store,
15+
configOptions: {},
16+
layoutOptions: {}
17+
};
18+
}
19+
20+
describe("useComposedEditorController", () => {
21+
it("does not clobber the user's in-progress edit with the store's reformatted echo", () => {
22+
const data = setupData();
23+
const { result } = renderHook(() => useComposedEditorController(data));
24+
25+
act(() => {
26+
// Compact, single-line input — the store round-trips this through
27+
// JSON.parse/JSON.stringify and the hook re-derives `code` via
28+
// prettifyJson, which would reformat it to multiple lines.
29+
result.current.onEditorChange('{"a":1,"b":2}');
30+
});
31+
32+
// The value shown to the user must remain exactly what they typed,
33+
// not get replaced by the store's reformatted echo of that same edit.
34+
expect(result.current.value).toBe('{"a":1,"b":2}');
35+
});
36+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
jest.mock("plotly.js-dist-min", () => ({}));
2+
jest.mock("react-plotly.js", () => jest.fn(() => null));
3+
jest.mock("react-plotly.js/factory", () => jest.fn(() => jest.fn(() => null)));
4+
5+
import { act, renderHook } from "@testing-library/react";
6+
import { EditableChartStore, PlaygroundDataV2 } from "@mendix/shared-charts/main";
7+
import { useV2EditorController } from "../useV2EditorController";
8+
9+
function setupData(): PlaygroundDataV2 {
10+
const host = { add: () => {} };
11+
const props = { get: () => ({ layout: {}, config: {}, data: [] }) };
12+
const store = new EditableChartStore(host as any, props as any);
13+
return {
14+
type: "editor.data.v2",
15+
store,
16+
plotData: [],
17+
configOptions: {},
18+
layoutOptions: {}
19+
};
20+
}
21+
22+
describe("useV2EditorController", () => {
23+
it("does not clobber the user's in-progress edit with the store's reformatted echo", () => {
24+
const data = setupData();
25+
const { result } = renderHook(() => useV2EditorController(data));
26+
27+
act(() => {
28+
// Compact, single-line input — the store round-trips this through
29+
// JSON.parse/JSON.stringify and the reaction re-derives `code` via
30+
// prettifyJson, which would reformat it to multiple lines.
31+
result.current.onEditorChange('{"a":1,"b":2}');
32+
});
33+
34+
// The value shown to the user must remain exactly what they typed,
35+
// not get replaced by the store's reformatted echo of that same edit.
36+
expect(result.current.value).toBe('{"a":1,"b":2}');
37+
});
38+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export function prettifyJson(json: string): string {
2+
try {
3+
return JSON.stringify(JSON.parse(json), null, 2);
4+
} catch {
5+
return '{ "error": "invalid JSON" }';
6+
}
7+
}
8+
9+
export function isEquivalentJson(a: string, b: string): boolean {
10+
try {
11+
return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
12+
} catch {
13+
return a === b;
14+
}
15+
}

packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useCallback, useEffect, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { fallback, PlaygroundDataV1 } from "@mendix/shared-charts/main";
3+
import { isEquivalentJson, prettifyJson } from "./editorJson";
34
import { ComposedEditorProps } from "../components/ComposedEditor";
45
import { SelectOption } from "../components/Sidebar";
56

@@ -30,13 +31,6 @@ function getModelerCode(data: PlaygroundDataV1, key: ConfigKey): Partial<Data> |
3031
const entries = Object.entries(data.plotData.at(key) ?? {}).filter(([key]) => !irrelevantSeriesKeys.includes(key));
3132
return Object.fromEntries(entries) as Partial<Data>;
3233
}
33-
function prettifyJson(json: string): string {
34-
try {
35-
return JSON.stringify(JSON.parse(json), null, 2);
36-
} catch {
37-
return '{ "error": "invalid JSON" }';
38-
}
39-
}
4034

4135
export function useComposedEditorController(data: PlaygroundDataV1): ComposedEditorProps {
4236
const [key, setKey] = useState<ConfigKey>("layout");
@@ -65,25 +59,32 @@ export function useComposedEditorController(data: PlaygroundDataV1): ComposedEdi
6559
const store = data.store;
6660
const code = prettifyJson(getEditorCode(data, key));
6761
const [input, setInput] = useState(() => code);
62+
const lastOwnEditRef = useRef<string | null>(null);
6863
const onEditorChange = useCallback(
6964
(value: string): void => {
7065
setInput(value);
7166
try {
7267
const json = fallback(value);
7368
JSON.parse(value);
69+
lastOwnEditRef.current = json;
7470
store.set(key, json);
7571
// eslint-disable-next-line no-empty
7672
} catch {}
7773
},
7874
[store, key]
7975
);
8076

81-
useEffect(
82-
() =>
83-
// eslint-disable-next-line react-hooks/set-state-in-effect
84-
setInput(code),
85-
[code]
86-
);
77+
useEffect(() => {
78+
// Skip resync when `code` is just the store echoing back the user's own last edit —
79+
// otherwise every keystroke replaces the controlled value and resets the cursor to the end.
80+
if (lastOwnEditRef.current !== null && isEquivalentJson(code, lastOwnEditRef.current)) {
81+
lastOwnEditRef.current = null;
82+
return;
83+
}
84+
lastOwnEditRef.current = null;
85+
// eslint-disable-next-line react-hooks/set-state-in-effect
86+
setInput(code);
87+
}, [code]);
8788

8889
return {
8990
viewSelectValue: key.toString(),

packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EditorController.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { observable, reaction, runInAction } from "mobx";
2-
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
import { PlaygroundDataV2 } from "@mendix/shared-charts/main";
4+
import { isEquivalentJson, prettifyJson } from "./editorJson";
45
import { ComposedEditorProps } from "../components/ComposedEditor";
56
import { SelectOption } from "../components/Sidebar";
67

@@ -29,14 +30,6 @@ function getModelerCode(data: PlaygroundDataV2, key: ConfigKey): object {
2930
return Object.fromEntries(entries);
3031
}
3132

32-
function prettifyJson(json: string): string {
33-
try {
34-
return JSON.stringify(JSON.parse(json), null, 2);
35-
} catch {
36-
return '{ "error": "invalid JSON" }';
37-
}
38-
}
39-
4033
export function useV2EditorController(context: PlaygroundDataV2): ComposedEditorProps {
4134
const [key, setKey] = useState<ConfigKey>("layout");
4235
const keyBox = useState(() => observable.box<ConfigKey>(key))[0];
@@ -69,12 +62,14 @@ export function useV2EditorController(context: PlaygroundDataV2): ComposedEditor
6962

7063
const code = prettifyJson(getEditorCode(store, key));
7164
const [input, setInput] = useState(() => code);
65+
const lastOwnEditRef = useRef<string | null>(null);
7266
const onEditorChange = useCallback(
7367
(value: string): void => {
7468
setInput(value);
7569
try {
7670
// Parse string before sending to store
7771
const obj = JSON.parse(value);
72+
lastOwnEditRef.current = value;
7873
if (key === "layout") {
7974
store.setLayout(obj);
8075
} else if (key === "config") {
@@ -92,7 +87,17 @@ export function useV2EditorController(context: PlaygroundDataV2): ComposedEditor
9287
() =>
9388
reaction(
9489
() => getEditorCode(store, keyBox.get()),
95-
code => setInput(prettifyJson(code))
90+
rawCode => {
91+
// Skip resync when this is just the store echoing back the user's own last
92+
// edit — otherwise every keystroke replaces the controlled value and resets
93+
// the cursor to the end.
94+
if (lastOwnEditRef.current !== null && isEquivalentJson(rawCode, lastOwnEditRef.current)) {
95+
lastOwnEditRef.current = null;
96+
return;
97+
}
98+
lastOwnEditRef.current = null;
99+
setInput(prettifyJson(rawCode));
100+
}
96101
),
97102
[store, keyBox]
98103
);

packages/shared/charts/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./components/ChartWidget";
2+
export { EditorStore } from "./helpers/EditorStore";
23
export type * from "./helpers/EditorStore";
34
export type * from "./helpers/playground-context";
45
export type * from "./helpers/useEditorStore";

0 commit comments

Comments
 (0)