diff --git a/packages/pluggableWidgets/chart-playground-web/package.json b/packages/pluggableWidgets/chart-playground-web/package.json index dd93e66bec..bb700520b8 100644 --- a/packages/pluggableWidgets/chart-playground-web/package.json +++ b/packages/pluggableWidgets/chart-playground-web/package.json @@ -40,14 +40,10 @@ "verify": "rui-verify-package-format" }, "dependencies": { - "@codemirror/lang-json": "^6.0.2", - "@codemirror/lint": "^6.8.5", "@mendix/shared-charts": "workspace:*", "@mendix/widget-plugin-component-kit": "workspace:*", "@mendix/widget-plugin-hooks": "workspace:*", - "@mendix/widget-plugin-platform": "workspace:*", - "@uiw/codemirror-theme-github": "^4.23.13", - "@uiw/react-codemirror": "^4.23.13" + "@mendix/widget-plugin-platform": "workspace:*" }, "devDependencies": { "@mendix/automation-utils": "workspace:*", diff --git a/packages/pluggableWidgets/chart-playground-web/src/components/CodeEditor.tsx b/packages/pluggableWidgets/chart-playground-web/src/components/CodeEditor.tsx index ec6e1c5f7d..7cda2cdcf0 100644 --- a/packages/pluggableWidgets/chart-playground-web/src/components/CodeEditor.tsx +++ b/packages/pluggableWidgets/chart-playground-web/src/components/CodeEditor.tsx @@ -1,52 +1,19 @@ -import { json, jsonParseLinter } from "@codemirror/lang-json"; -import { linter, lintGutter } from "@codemirror/lint"; -import { githubLight } from "@uiw/codemirror-theme-github"; -import CodeMirror, { type Extension } from "@uiw/react-codemirror"; -import { ReactElement, useEffect, useMemo, useRef, useState } from "react"; - -export type EditorChangeHandler = (value: string) => void; +import { ReactElement } from "react"; export interface CodeEditorProps { - defaultValue: string; - onChange?: EditorChangeHandler; + value: string; + onChange?: (value: string) => void; readOnly?: boolean; height?: string; } export function CodeEditor(props: CodeEditorProps): ReactElement { - const [value, onChange] = useEditorState({ initState: props.defaultValue, onChange: props.onChange }); - const extensions = useMemo( - () => [ - json(), - linter(jsonParseLinter(), { - // default is 750ms - delay: 300 - }), - lintGutter() - ], - [] - ); return ( - props.onChange?.(e.target.value)} + style={{ height: props.height ?? "200px", width: "100%", fontFamily: "monospace" }} readOnly={props.readOnly} - extensions={extensions} /> ); } - -function useEditorState(params: { initState: string; onChange?: EditorChangeHandler }): [string, EditorChangeHandler] { - const listener = useRef(params.onChange); - const [value, setValue] = useState(params.initState); - const { current: onValueChange } = useRef(value => { - setValue(value); - listener.current?.(value); - }); - useEffect(() => { - listener.current = params.onChange; - }); - return [value, onValueChange]; -} diff --git a/packages/pluggableWidgets/chart-playground-web/src/components/ComposedEditor.tsx b/packages/pluggableWidgets/chart-playground-web/src/components/ComposedEditor.tsx index f7f6a24586..5423b348c0 100644 --- a/packages/pluggableWidgets/chart-playground-web/src/components/ComposedEditor.tsx +++ b/packages/pluggableWidgets/chart-playground-web/src/components/ComposedEditor.tsx @@ -1,10 +1,10 @@ -import { Alert } from "@mendix/widget-plugin-component-kit/Alert"; -import { useOnClickOutside } from "@mendix/widget-plugin-hooks/useOnClickOutside"; import classNames from "classnames"; import { Fragment, ReactElement, ReactNode, RefObject, useCallback, useRef, useState } from "react"; +import { Alert } from "@mendix/widget-plugin-component-kit/Alert"; +import { useOnClickOutside } from "@mendix/widget-plugin-hooks/useOnClickOutside"; import "../ui/Playground.scss"; +import { CodeEditor } from "./CodeEditor"; import { Select, SelectOption, Sidebar, SidebarHeader, SidebarHeaderTools, SidebarPanel } from "./Sidebar"; -import { CodeEditor, EditorChangeHandler } from "./CodeEditor"; interface WrapperProps { renderPanels: ReactNode; @@ -71,8 +71,8 @@ const SidebarContentTooltip = (): ReactElement => { }; export interface ComposedEditorProps { - defaultEditorValue: string; - onEditorChange: EditorChangeHandler; + value: string; + onEditorChange: (value: string) => void; modelerCode: string; onViewSelectChange: (value: string) => void; viewSelectValue: string; @@ -138,11 +138,7 @@ export function ComposedEditor(props: ComposedEditorProps): ReactElement { heading={topPanelHeader} > - + diff --git a/packages/pluggableWidgets/chart-playground-web/src/components/Playground.tsx b/packages/pluggableWidgets/chart-playground-web/src/components/Playground.tsx index 7772c4c6ee..d699525198 100644 --- a/packages/pluggableWidgets/chart-playground-web/src/components/Playground.tsx +++ b/packages/pluggableWidgets/chart-playground-web/src/components/Playground.tsx @@ -1,16 +1,23 @@ -import { PlaygroundData, usePlaygroundContext } from "@mendix/shared-charts/main"; -import { Alert } from "@mendix/widget-plugin-component-kit/Alert"; import { ReactElement } from "react"; -import { useComposedEditorController } from "../helpers/useComposedEditorController"; +import { PlaygroundDataV1, PlaygroundDataV2, usePlaygroundContext } from "@mendix/shared-charts/main"; +import { Alert } from "@mendix/widget-plugin-component-kit/Alert"; import { ComposedEditor } from "./ComposedEditor"; +import { useComposedEditorController } from "../helpers/useComposedEditorController"; import "../ui/Playground.scss"; +import { useV2EditorController } from "../helpers/useV2EdtiorController"; -function Editor({ data }: { data: PlaygroundData }): ReactElement { +function EditorGen1({ data }: { data: PlaygroundDataV1 }): ReactElement { const props = useComposedEditorController(data); return ; } +function EditorGen2({ data }: { data: PlaygroundDataV2 }): ReactElement { + const props = useV2EditorController(data); + + return ; +} + export function Playground(): ReactElement { const ctx = usePlaygroundContext(); @@ -18,5 +25,11 @@ export function Playground(): ReactElement { return {ctx.error.message}; } - return ; + const { data } = ctx; + + if (Object.hasOwn(data, "type") && (data as PlaygroundDataV2).type === "editor.data.v2") { + return ; + } + + return ; } diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts index c716a12468..02367d1cc4 100644 --- a/packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/useComposedEditorController.ts @@ -1,6 +1,5 @@ -import { fallback, PlaygroundData } from "@mendix/shared-charts/main"; -import { EditorChangeHandler } from "../components/CodeEditor"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { fallback, PlaygroundDataV1 } from "@mendix/shared-charts/main"; import { ComposedEditorProps } from "../components/ComposedEditor"; import { SelectOption } from "../components/Sidebar"; @@ -14,13 +13,13 @@ const irrelevantSeriesKeys = ["x", "y", "z", "customSeriesOptions", "dataSourceI type ConfigKey = "layout" | "config" | number; -function getEditorCode({ store }: PlaygroundData, key: ConfigKey): string { +function getEditorCode({ store }: PlaygroundDataV1, key: ConfigKey): string { let value = typeof key === "number" ? store.state.data.at(key) : store.state[key]; value = value ?? '{ "error": "value is unavailable" }'; return value; } -function getModelerCode(data: PlaygroundData, key: ConfigKey): Partial | Partial | Partial { +function getModelerCode(data: PlaygroundDataV1, key: ConfigKey): Partial | Partial | Partial { if (key === "layout") { return data.layoutOptions; } @@ -31,8 +30,15 @@ function getModelerCode(data: PlaygroundData, key: ConfigKey): Partial | P const entries = Object.entries(data.plotData.at(key) ?? {}).filter(([key]) => !irrelevantSeriesKeys.includes(key)); return Object.fromEntries(entries) as Partial; } +function prettifyJson(json: string): string { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return '{ "error": "invalid JSON" }'; + } +} -export function useComposedEditorController(data: PlaygroundData): ComposedEditorProps { +export function useComposedEditorController(data: PlaygroundDataV1): ComposedEditorProps { const [key, setKey] = useState("layout"); const onViewSelectChange = (value: string): void => { @@ -56,20 +62,34 @@ export function useComposedEditorController(data: PlaygroundData): ComposedEdito ]; }, [data.plotData]); - const onEditorChange: EditorChangeHandler = (json): void => { - json = fallback(json); - try { - JSON.parse(json); - data.store.set(key, json); - // eslint-disable-next-line no-empty - } catch {} - }; + const store = data.store; + const code = prettifyJson(getEditorCode(data, key)); + const [input, setInput] = useState(() => code); + const onEditorChange = useCallback( + (value: string): void => { + setInput(value); + try { + const json = fallback(value); + JSON.parse(value); + store.set(key, json); + // eslint-disable-next-line no-empty + } catch {} + }, + [store, key] + ); + + useEffect( + () => + // eslint-disable-next-line react-hooks/set-state-in-effect + setInput(code), + [code] + ); return { viewSelectValue: key.toString(), viewSelectOptions: options, onViewSelectChange, - defaultEditorValue: getEditorCode(data, key), + value: input, modelerCode: useMemo(() => JSON.stringify(getModelerCode(data, key), null, 2), [data, key]), onEditorChange }; diff --git a/packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EdtiorController.ts b/packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EdtiorController.ts new file mode 100644 index 0000000000..c208f5fa4b --- /dev/null +++ b/packages/pluggableWidgets/chart-playground-web/src/helpers/useV2EdtiorController.ts @@ -0,0 +1,108 @@ +import { observable, reaction, runInAction } from "mobx"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { PlaygroundDataV2 } from "@mendix/shared-charts/main"; +import { ComposedEditorProps } from "../components/ComposedEditor"; +import { SelectOption } from "../components/Sidebar"; + +type ConfigKey = "layout" | "config" | number; + +const irrelevantSeriesKeys = ["x", "y", "z", "customSeriesOptions", "dataSourceItems"]; + +function getEditorCode(store: PlaygroundDataV2["store"], key: ConfigKey): string { + if (key === "layout") { + return store.layoutJson ?? '{ "error": "value is unavailable" }'; + } + if (key === "config") { + return store.configJson ?? '{ "error": "value is unavailable" }'; + } + return store.dataJson.at(key) ?? '{ "error": "value is unavailable" }'; +} + +function getModelerCode(data: PlaygroundDataV2, key: ConfigKey): object { + if (key === "layout") { + return data.layoutOptions; + } + if (key === "config") { + return data.configOptions; + } + const entries = Object.entries(data.plotData.at(key) ?? {}).filter(([k]) => !irrelevantSeriesKeys.includes(k)); + return Object.fromEntries(entries); +} + +function prettifyJson(json: string): string { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return '{ "error": "invalid JSON" }'; + } +} + +export function useV2EditorController(context: PlaygroundDataV2): ComposedEditorProps { + const [key, setKey] = useState("layout"); + const keyBox = useState(() => observable.box(key))[0]; + + const onViewSelectChange = (value: string): void => { + let newKey: ConfigKey; + if (value === "layout" || value === "config") { + newKey = value; + } else { + const n = parseInt(value, 10); + newKey = isNaN(n) ? "layout" : n; + } + setKey(newKey); + runInAction(() => keyBox.set(newKey)); + }; + + const store = context.store; + + const options: SelectOption[] = useMemo(() => { + return [ + { name: "Layout", value: "layout", isDefaultSelected: true }, + ...store.data.map((trace, index) => ({ + name: (trace.name as string) || `trace ${index}`, + value: index, + isDefaultSelected: false + })), + { name: "Configuration", value: "config", isDefaultSelected: false } + ]; + }, [store.data]); + + const code = prettifyJson(getEditorCode(store, key)); + const [input, setInput] = useState(() => code); + const onEditorChange = useCallback( + (value: string): void => { + setInput(value); + try { + // Parse string before sending to store + const obj = JSON.parse(value); + if (key === "layout") { + store.setLayout(obj); + } else if (key === "config") { + store.setConfig(obj); + } else { + store.setDataAt(key, value); + } + // eslint-disable-next-line no-empty + } catch {} + }, + [store, key] + ); + + useEffect( + () => + reaction( + () => getEditorCode(store, keyBox.get()), + code => setInput(prettifyJson(code)) + ), + [store, keyBox] + ); + + return { + viewSelectValue: key.toString(), + viewSelectOptions: options, + onViewSelectChange, + value: input, + modelerCode: useMemo(() => JSON.stringify(getModelerCode(context, key), null, 2), [context, key]), + onEditorChange + }; +} diff --git a/packages/pluggableWidgets/chart-playground-web/typings/modules.d.ts b/packages/pluggableWidgets/chart-playground-web/typings/modules.d.ts new file mode 100644 index 0000000000..0292a33a0f --- /dev/null +++ b/packages/pluggableWidgets/chart-playground-web/typings/modules.d.ts @@ -0,0 +1 @@ +declare module "*.scss"; diff --git a/packages/pluggableWidgets/custom-chart-web/package.json b/packages/pluggableWidgets/custom-chart-web/package.json index 1e9076e762..0932cd05b1 100644 --- a/packages/pluggableWidgets/custom-chart-web/package.json +++ b/packages/pluggableWidgets/custom-chart-web/package.json @@ -50,6 +50,7 @@ "@mendix/widget-plugin-platform": "workspace:*", "classnames": "^2.5.1", "deepmerge": "^4.3.1", + "mobx-react-lite": "4.0.7", "plotly.js-dist-min": "^3.0.0" }, "devDependencies": { diff --git a/packages/pluggableWidgets/custom-chart-web/src/CustomChart.tsx b/packages/pluggableWidgets/custom-chart-web/src/CustomChart.tsx index 697a15fc36..ac1cc12c5d 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/CustomChart.tsx +++ b/packages/pluggableWidgets/custom-chart-web/src/CustomChart.tsx @@ -1,12 +1,13 @@ -import { constructWrapperStyle, getPlaygroundContext } from "@mendix/shared-charts/main"; import { Fragment, ReactElement } from "react"; +import { constructWrapperStyle, getPlaygroundContext } from "@mendix/shared-charts/main"; import { CustomChartContainerProps } from "../typings/CustomChartProps"; import { useCustomChart } from "./hooks/useCustomChart"; import "./ui/CustomChart.scss"; +import { observer } from "mobx-react-lite"; const PlaygroundContext = getPlaygroundContext(); -export default function CustomChart(props: CustomChartContainerProps): ReactElement { +const Container = observer(function CustomChart(props: CustomChartContainerProps): ReactElement { const { playgroundData, ref } = useCustomChart(props); const wrapperStyle = constructWrapperStyle(props); @@ -16,4 +17,8 @@ export default function CustomChart(props: CustomChartContainerProps): ReactElem
); +}); + +export default function CustomChart(props: CustomChartContainerProps): ReactElement { + return ; } diff --git a/packages/pluggableWidgets/custom-chart-web/src/__tests__/__snapshots__/mergeChartProps.spec.ts.snap b/packages/pluggableWidgets/custom-chart-web/src/__tests__/__snapshots__/mergeChartProps.spec.ts.snap deleted file mode 100644 index dae080df89..0000000000 --- a/packages/pluggableWidgets/custom-chart-web/src/__tests__/__snapshots__/mergeChartProps.spec.ts.snap +++ /dev/null @@ -1,71 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`mergeChartProps utility function Data merging behavior should only process traces that exist in chart props 1`] = `[]`; - -exports[`mergeChartProps utility function Data merging behavior should preserve original trace data when editor state has missing or falsy entries 1`] = `[]`; - -exports[`mergeChartProps utility function Edge cases should handle empty chart props data 1`] = `[]`; - -exports[`mergeChartProps utility function Edge cases should handle falsy values in editor state data without warnings 1`] = `[]`; - -exports[`mergeChartProps utility function Edge cases should handle null JSON parsing 1`] = `[]`; - -exports[`mergeChartProps utility function Edge cases should handle various malformed JSON types 1`] = ` -[ - [ - "Editor props for trace(1) is not a valid JSON:{invalid json}", - ], - [ - "Please make sure the props is a valid JSON string.", - ], - [ - "Editor props for trace(3) is not a valid JSON:undefined", - ], - [ - "Please make sure the props is a valid JSON string.", - ], -] -`; - -exports[`mergeChartProps utility function JSON parsing failures generate console warnings should handle mixed valid and invalid JSON 1`] = ` -[ - [ - "Editor props for trace(1) is not a valid JSON:invalid json", - ], - [ - "Please make sure the props is a valid JSON string.", - ], -] -`; - -exports[`mergeChartProps utility function JSON parsing failures generate console warnings should not warn for valid JSON 1`] = `[]`; - -exports[`mergeChartProps utility function JSON parsing failures generate console warnings should not warn when editor state has undefined entries 1`] = `[]`; - -exports[`mergeChartProps utility function JSON parsing failures generate console warnings should warn for invalid JSON in editor state data 1`] = ` -[ - [ - "Editor props for trace(0) is not a valid JSON:invalid json", - ], - [ - "Please make sure the props is a valid JSON string.", - ], -] -`; - -exports[`mergeChartProps utility function JSON parsing failures generate console warnings should warn for multiple invalid JSON entries 1`] = ` -[ - [ - "Editor props for trace(0) is not a valid JSON:invalid json", - ], - [ - "Please make sure the props is a valid JSON string.", - ], - [ - "Editor props for trace(1) is not a valid JSON:{broken: json}", - ], - [ - "Please make sure the props is a valid JSON string.", - ], -] -`; diff --git a/packages/pluggableWidgets/custom-chart-web/src/__tests__/mergeChartProps.spec.ts b/packages/pluggableWidgets/custom-chart-web/src/__tests__/mergeChartProps.spec.ts deleted file mode 100644 index 41bf40fb1a..0000000000 --- a/packages/pluggableWidgets/custom-chart-web/src/__tests__/mergeChartProps.spec.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { EditorStoreState } from "@mendix/shared-charts/main"; -import { ChartProps } from "../components/PlotlyChart"; -import { mergeChartProps } from "../utils/utils"; - -// Mock console.warn to capture warnings -let consoleWarnSpy: jest.SpyInstance; -let consoleMockCalls: string[][]; - -beforeEach(() => { - consoleMockCalls = []; - consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation((...args: any[]) => { - consoleMockCalls.push(args.map(arg => String(arg))); - }); -}); - -afterEach(() => { - consoleWarnSpy.mockRestore(); -}); - -describe("mergeChartProps utility function", () => { - describe("JSON parsing failures generate console warnings", () => { - it("should warn for invalid JSON in editor state data", () => { - // Arrange - const chartProps: ChartProps = { - data: [{ x: [1, 2], y: [3, 4], type: "scatter" }], - layout: { title: { text: "Test Chart" } }, - config: { displayModeBar: false }, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: ["invalid json"] - }; - - // Act - mergeChartProps(chartProps, editorState); - - // Assert - expect(consoleMockCalls).toMatchSnapshot(); - }); - - it("should warn for multiple invalid JSON entries", () => { - // Arrange - const chartProps: ChartProps = { - data: [ - { x: [1], y: [2], type: "scatter" }, - { x: [3], y: [4], type: "bar" } - ], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: ["invalid json", "{broken: json}"] - }; - - // Act - mergeChartProps(chartProps, editorState); - - // Assert - expect(consoleMockCalls).toMatchSnapshot(); - }); - - it("should not warn when editor state has undefined entries", () => { - // Arrange - const chartProps: ChartProps = { - data: [{ x: [1], y: [2], type: "scatter" }], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: [] // Empty array, so data[0] will be undefined - }; - - // Act - mergeChartProps(chartProps, editorState); - - // Assert - expect(consoleMockCalls).toMatchSnapshot(); - }); - - it("should not warn for valid JSON", () => { - // Arrange - const chartProps: ChartProps = { - data: [{ x: [1], y: [2], type: "scatter" }], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: ['{"marker": {"color": "red"}}'] - }; - - // Act - mergeChartProps(chartProps, editorState); - - // Assert - expect(consoleMockCalls).toMatchSnapshot(); - }); - - it("should handle mixed valid and invalid JSON", () => { - // Arrange - const chartProps: ChartProps = { - data: [ - { x: [1], y: [2], type: "scatter" }, - { x: [3], y: [4], type: "bar" }, - { x: [5], y: [6], type: "scatter" as const } - ], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: [ - '{"marker": {"color": "red"}}', // Valid - "invalid json", // Invalid - '{"marker": {"color": "blue"}}' // Valid - ] - }; - - // Act - mergeChartProps(chartProps, editorState); - - // Assert - expect(consoleMockCalls).toMatchSnapshot(); - }); - }); - - describe("Data merging behavior", () => { - it("should merge valid editor state data with chart props", () => { - // Arrange - const chartProps: ChartProps = { - data: [{ x: [1, 2], y: [3, 4], type: "scatter", name: "original" }], - layout: { title: { text: "Original Title" } }, - config: { displayModeBar: true }, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: '{"title": "Modified Title"}', - config: '{"displayModeBar": false}', - data: ['{"marker": {"color": "red"}, "name": "modified"}'] - }; - - // Act - const result = mergeChartProps(chartProps, editorState); - - // Assert - expect(result.data[0]).toEqual({ - x: [1, 2], - y: [3, 4], - type: "scatter", - name: "modified", // Overridden by editor state - marker: { color: "red" } // Added by editor state - }); - expect(result.layout.title).toBe("Modified Title"); - expect(result.config.displayModeBar).toBe(false); - }); - - it("should use empty object for invalid JSON entries", () => { - // Arrange - const chartProps: ChartProps = { - data: [{ x: [1], y: [2], type: "scatter", name: "original" }], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: ["invalid json"] - }; - - // Act - const result = mergeChartProps(chartProps, editorState); - - // Assert - expect(result.data[0]).toEqual({ - x: [1], - y: [2], - type: "scatter", - name: "original" // Original properties preserved - }); - }); - - it("should only process traces that exist in chart props", () => { - // Arrange - const chartProps: ChartProps = { - data: [{ x: [1], y: [2], type: "scatter" }], // Only 1 trace - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: [ - '{"marker": {"color": "red"}}', // Will be processed - '{"marker": {"color": "blue"}}', // Will be ignored - "invalid json" // Will be ignored - ] - }; - - // Act - const result = mergeChartProps(chartProps, editorState); - - // Assert - expect(result.data).toHaveLength(1); // Only 1 trace in result - expect(result.data[0]).toEqual({ - x: [1], - y: [2], - type: "scatter", - marker: { color: "red" } - }); - expect(consoleMockCalls).toMatchSnapshot(); // No warnings for ignored entries - }); - - it("should preserve original trace data when editor state has missing or falsy entries", () => { - // Arrange - const chartProps: ChartProps = { - data: [ - { x: [1], y: [1], type: "scatter", name: "original1", marker: { color: "blue" } }, - { x: [2], y: [2], type: "bar", name: "original2" }, - { x: [3], y: [3], type: "scatter", name: "original3" } - ], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: [""] // Only one entry (empty string), so traces 1 and 2 will get undefined - }; - - // Act - const result = mergeChartProps(chartProps, editorState); - - // Assert - All original trace data should be preserved - expect(result.data[0]).toEqual({ - x: [1], - y: [1], - type: "scatter", - name: "original1", - marker: { color: "blue" } - }); - expect(result.data[1]).toEqual({ x: [2], y: [2], type: "bar", name: "original2" }); - expect(result.data[2]).toEqual({ x: [3], y: [3], type: "scatter", name: "original3" }); - expect(consoleMockCalls).toMatchSnapshot(); - }); - }); - - describe("Edge cases", () => { - it("should handle empty chart props data", () => { - // Arrange - const chartProps: ChartProps = { - data: [], // No traces - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: ["invalid json"] // Will be ignored - }; - - // Act - const result = mergeChartProps(chartProps, editorState); - - // Assert - expect(result.data).toHaveLength(0); - expect(consoleMockCalls).toMatchSnapshot(); - }); - - it("should handle null JSON parsing", () => { - // Arrange - const chartProps: ChartProps = { - data: [{ x: [1], y: [2], type: "scatter" }], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: ["null"] // Valid JSON that parses to null - }; - - // Act - const result = mergeChartProps(chartProps, editorState); - - // Assert - expect(result.data[0]).toEqual({ - x: [1], - y: [2], - type: "scatter" - // null gets merged, but doesn't add properties - }); - expect(consoleMockCalls).toMatchSnapshot(); - }); - - it("should handle various malformed JSON types", () => { - // Arrange - const chartProps: ChartProps = { - data: [ - { x: [1], y: [1], type: "scatter" }, - { x: [2], y: [2], type: "bar" }, - { x: [3], y: [3], type: "scatter" as const }, - { x: [4], y: [4], type: "scatter" } - ], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: [ - '{"valid": "json"}', // Valid - "{invalid json}", // Invalid - "", // Invalid empty string - treated as falsy, early return - "undefined" // Invalid - ] - }; - - // Act - mergeChartProps(chartProps, editorState); - - // Assert - expect(consoleMockCalls).toMatchSnapshot(); - }); - - it("should handle falsy values in editor state data without warnings", () => { - // Arrange - const chartProps: ChartProps = { - data: [ - { x: [1], y: [1], type: "scatter", name: "trace1" }, - { x: [2], y: [2], type: "bar", name: "trace2" }, - { x: [3], y: [3], type: "scatter", name: "trace3" } - ], - layout: {}, - config: {}, - width: 800, - height: 600 - }; - - const editorState: EditorStoreState = { - layout: "{}", - config: "{}", - data: [ - "", // Empty string - falsy - null as any, // null - falsy - undefined as any // undefined - falsy - ] - }; - - // Act - const result = mergeChartProps(chartProps, editorState); - - // Assert - No warnings should be generated for falsy values - expect(consoleMockCalls).toMatchSnapshot(); - // Original traces should be returned unchanged - expect(result.data[0]).toEqual({ x: [1], y: [1], type: "scatter", name: "trace1" }); - expect(result.data[1]).toEqual({ x: [2], y: [2], type: "bar", name: "trace2" }); - expect(result.data[2]).toEqual({ x: [3], y: [3], type: "scatter", name: "trace3" }); - }); - }); -}); diff --git a/packages/pluggableWidgets/custom-chart-web/src/components/PlotlyChart.ts b/packages/pluggableWidgets/custom-chart-web/src/components/PlotlyChart.ts index 0b3023cc85..23e6c92e26 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/components/PlotlyChart.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/components/PlotlyChart.ts @@ -1,12 +1,10 @@ import Plotly, { Config, Data, Layout, PlotlyHTMLElement } from "plotly.js-dist-min"; const { newPlot, purge, react } = Plotly; -export interface ChartProps { +export interface PlotlyChartProps { data: Data[]; layout: Partial; config: Partial; - width: number; - height: number; onClick?: (data: any) => void; } @@ -17,7 +15,7 @@ export class PlotlyChart { private layout: Partial; private config: Partial; - constructor(element: HTMLElement, props: ChartProps) { + constructor(element: HTMLElement, props: PlotlyChartProps) { this.element = element; this.data = props.data; this.layout = props.layout; @@ -25,7 +23,7 @@ export class PlotlyChart { this.init(props); } - private init(props: ChartProps): void { + private init(props: PlotlyChartProps): void { newPlot(this.element, this.data, this.layout, this.config) .then(plotlyElement => { this.plotlyElement = plotlyElement; @@ -38,7 +36,7 @@ export class PlotlyChart { }); } - update(props: Partial): void { + update(props: Partial): void { if (props.data) { this.data = props.data; } diff --git a/packages/pluggableWidgets/custom-chart-web/src/controllers/ChartPropsController.ts b/packages/pluggableWidgets/custom-chart-web/src/controllers/ChartPropsController.ts index 0683f8b46c..8642a4a425 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/controllers/ChartPropsController.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/controllers/ChartPropsController.ts @@ -1,41 +1,31 @@ -import { EditorStoreState } from "@mendix/shared-charts/main"; -import { DerivedPropsGate, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; -import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; import { makeAutoObservable } from "mobx"; import { Config, Data, Layout } from "plotly.js-dist-min"; -import { ChartProps } from "../components/PlotlyChart"; -import { mergeChartProps, parseConfig, parseData, parseLayout } from "../utils/utils"; +import { DerivedPropsGate, SetupComponent, SetupComponentHost } from "@mendix/widget-plugin-mobx-kit/main"; +import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; import { ControllerProps } from "./typings"; +import { PlotlyChartProps } from "../components/PlotlyChart"; +import { parseConfig, parseData, parseLayout } from "../utils/utils"; interface SizeProvider { width: number; height: number; } -interface ChartPropsControllerSpec { - propsGate: DerivedPropsGate; - sizeProvider: SizeProvider; - editorStateGate: DerivedPropsGate; -} - export class ChartPropsController implements SetupComponent { private cleanup: undefined | (() => void) = undefined; - private editorStateGate: DerivedPropsGate; - private propsGate: DerivedPropsGate; - private sizeProvider: SizeProvider; - constructor(host: SetupComponentHost, spec: ChartPropsControllerSpec) { + constructor( + host: SetupComponentHost, + private gate: DerivedPropsGate, + private sizeProvider: SizeProvider + ) { host.add(this); - this.editorStateGate = spec.editorStateGate; - this.propsGate = spec.propsGate; - this.sizeProvider = spec.sizeProvider; - makeAutoObservable(this, { setup: false }); } private get props(): ControllerProps { - return this.propsGate.props; + return this.gate.props; } private get configurationOptions(): string { @@ -70,59 +60,7 @@ export class ChartPropsController implements SetupComponent { return () => this.cleanup?.(); } - private get chartConfig(): ChartProps["config"] { - return { - displayModeBar: false, - ...this.config - }; - } - - private get chartLayout(): ChartProps["layout"] { - return { - ...this.layout, - width: this.sizeProvider.width, - height: this.sizeProvider.height, - autosize: true, - font: { - family: "Open Sans, sans-serif", - size: Math.max(12 * (this.sizeProvider.width / 1000), 8), - ...this.layout.font - }, - legend: { - font: { - size: Math.max(10 * (this.sizeProvider.width / 1000), 7), - ...this.layout.legend?.font - }, - itemwidth: Math.max(10 * (this.sizeProvider.width / 1000), 3), - itemsizing: "constant", - ...this.layout.legend - }, - xaxis: { - tickfont: { - size: Math.max(10 * (this.sizeProvider.width / 1000), 7), - ...this.layout.xaxis?.tickfont - }, - ...this.layout.xaxis - }, - yaxis: { - tickfont: { - size: Math.max(10 * (this.sizeProvider.width / 1000), 7), - ...this.layout.yaxis?.tickfont - }, - ...this.layout.yaxis - }, - margin: { - l: 60, - r: 60, - t: 60, - b: 60, - pad: 10, - ...this.layout.margin - } - }; - } - - private get chartOnClick(): (data: any) => void { + get chartOnClick(): (data: any) => void { return (data: any): void => { if (this.props.eventDataAttribute && data.points && data.points.length > 0) { const point = data.points[0]; @@ -156,19 +94,17 @@ export class ChartPropsController implements SetupComponent { }; } - get chartProps(): ChartProps { + get chartProps(): PlotlyChartProps { return { - config: this.chartConfig, + config: this.config, data: this.data, - layout: this.chartLayout, - onClick: this.chartOnClick, - width: this.sizeProvider.width, - height: this.sizeProvider.height + layout: this.layout, + onClick: this.chartOnClick }; } get config(): Partial { - return parseConfig(this.configurationOptions); + return { displayModeBar: false, ...parseConfig(this.configurationOptions) }; } get data(): Data[] { @@ -176,12 +112,35 @@ export class ChartPropsController implements SetupComponent { } get layout(): Partial { - return parseLayout(this.layoutStatic, this.layoutAttribute, this.sampleLayout); - } + const { width, height } = this.sizeProvider; + const parsed = parseLayout(this.layoutStatic, this.layoutAttribute, this.sampleLayout); + const scale = (base: number, min: number): number => Math.max(base * (width / 1000), min); - get mergedProps(): ChartProps { - const props = this.chartProps; - const state = this.editorStateGate.props; - return mergeChartProps(props, state); + return { + ...parsed, + width, + height, + autosize: true, + font: { + family: "Open Sans, sans-serif", + size: scale(12, 8), + ...parsed.font + }, + legend: { + font: { size: scale(10, 7), ...parsed.legend?.font }, + itemwidth: scale(10, 3), + itemsizing: "constant", + ...parsed.legend + }, + xaxis: { + tickfont: { size: scale(10, 7), ...parsed.xaxis?.tickfont }, + ...parsed.xaxis + }, + yaxis: { + tickfont: { size: scale(10, 7), ...parsed.yaxis?.tickfont }, + ...parsed.yaxis + }, + margin: { l: 60, r: 60, t: 60, b: 60, pad: 10, ...parsed.margin } + }; } } diff --git a/packages/pluggableWidgets/custom-chart-web/src/controllers/CustomChartControllerHost.ts b/packages/pluggableWidgets/custom-chart-web/src/controllers/CustomChartControllerHost.ts index bd062aa319..9cc4eada46 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/controllers/CustomChartControllerHost.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/controllers/CustomChartControllerHost.ts @@ -1,28 +1,48 @@ -import { EditorStoreState } from "@mendix/shared-charts/main"; -import { DerivedPropsGate, SetupHost } from "@mendix/widget-plugin-mobx-kit/main"; +import { computed } from "mobx"; + +import { Data } from "plotly.js-dist-min"; +import { EditableChartStore, EditableChartStoreProps } from "@mendix/shared-charts/main"; +import { ComputedAtom, DerivedPropsGate, SetupHost } from "@mendix/widget-plugin-mobx-kit/main"; import { ChartPropsController } from "./ChartPropsController"; import { PlotlyController } from "./PlotlyController"; import { ResizeController } from "./ResizeController"; import { ControllerProps } from "./typings"; - -interface CustomChartControllerHostSpec { - propsGate: DerivedPropsGate; - editorStateGate: DerivedPropsGate; -} +import { PlotlyChartProps } from "../components/PlotlyChart"; export class CustomChartControllerHost extends SetupHost { resizeCtrl: ResizeController; - chartPropsController: ChartPropsController; - plotlyController: PlotlyController; + adapter: ChartPropsController; + chartViewModel: PlotlyController; + store: EditableChartStore; + storePropsAtom: ComputedAtom; + chartViewModelPropsAtom: ComputedAtom; - constructor(spec: CustomChartControllerHostSpec) { + constructor(gate: DerivedPropsGate) { super(); this.resizeCtrl = new ResizeController(this); - this.chartPropsController = new ChartPropsController(this, { - propsGate: spec.propsGate, - sizeProvider: this.resizeCtrl, - editorStateGate: spec.editorStateGate - }); - this.plotlyController = new PlotlyController({ propsProvider: this.chartPropsController }); + this.adapter = new ChartPropsController(this, gate, this.resizeCtrl); + this.storePropsAtom = storeAtom(this.adapter); + this.store = new EditableChartStore(this, this.storePropsAtom); + this.chartViewModelPropsAtom = viewModelAtom(this.store, this.adapter); + this.chartViewModel = new PlotlyController(this.chartViewModelPropsAtom); } } + +function viewModelAtom(store: EditableChartStore, adapter: ChartPropsController): ComputedAtom { + return computed(() => { + return { + data: store.data as Data[], + layout: store.layout, + config: store.config, + onClick: adapter.chartOnClick + }; + }); +} + +function storeAtom(source: ChartPropsController): ComputedAtom { + return computed(() => ({ + layout: source.layout, + config: source.config, + data: source.data + })); +} diff --git a/packages/pluggableWidgets/custom-chart-web/src/controllers/PlotlyController.ts b/packages/pluggableWidgets/custom-chart-web/src/controllers/PlotlyController.ts index f3caf84ccb..b55c93fb4a 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/controllers/PlotlyController.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/controllers/PlotlyController.ts @@ -1,31 +1,21 @@ import { autorun } from "mobx"; -import { ChartProps, PlotlyChart } from "../components/PlotlyChart"; - -interface PropsProvider { - mergedProps: ChartProps; -} - -interface PlotlyControllerSpec { - propsProvider: PropsProvider; -} +import { ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main"; +import { PlotlyChartProps, PlotlyChart } from "../components/PlotlyChart"; export class PlotlyController { private cleanup: undefined | (() => void) = undefined; - private propsProvider: PropsProvider; - constructor(spec: PlotlyControllerSpec) { - this.propsProvider = spec.propsProvider; - } + constructor(private props: ComputedAtom) {} setChart = (target: HTMLDivElement | null): void => { if (target === null) { this.cleanup?.(); } else { - const chart = new PlotlyChart(target, this.propsProvider.mergedProps); + const chart = new PlotlyChart(target, this.props.get()); const dispose = autorun( () => { - chart.update(this.propsProvider.mergedProps); + chart.update(this.props.get()); }, { delay: 100 } ); diff --git a/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts b/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts index c384dea2d8..67bfb3fd42 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/hooks/useCustomChart.ts @@ -1,13 +1,36 @@ -import { EditorStoreState, initStateFromProps, PlaygroundData, useEditorStore } from "@mendix/shared-charts/main"; -import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; -import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; -import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; +import { computed } from "mobx"; import { CSSProperties, Ref, RefCallback, useEffect } from "react"; import { CustomChartControllerHost } from "src/controllers/CustomChartControllerHost"; import { mergeRefs } from "src/utils/mergeRefs"; +import { PlaygroundData } from "@mendix/shared-charts/main"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; +import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; +import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { CustomChartContainerProps } from "../../typings/CustomChartProps"; import { ControllerProps } from "../controllers/typings"; +// TODO: replace with get-dimensions from widget-plugin-platform +function getContainerStyle( + width: number, + widthUnit: CustomChartContainerProps["widthUnit"], + height: number, + heightUnit: CustomChartContainerProps["heightUnit"] +): CSSProperties { + const style: CSSProperties = { + width: widthUnit === "percentage" ? `${width}%` : `${width}px` + }; + + if (heightUnit === "percentageOfWidth") { + style.paddingBottom = widthUnit === "percentage" ? `${height}%` : `${width / 2}px`; + } else if (heightUnit === "pixels") { + style.height = `${height}px`; + } else if (heightUnit === "percentageOfParent") { + style.height = `${height}%`; + } + + return style; +} + interface UseCustomChartReturn { containerStyle: CSSProperties; playgroundData: PlaygroundData; @@ -15,55 +38,32 @@ interface UseCustomChartReturn { } export function useCustomChart(props: CustomChartContainerProps): UseCustomChartReturn { - const propsGateProvider = useConst(() => new GateProvider(props)); - const editorStateGateProvider = useConst( - () => new GateProvider({ layout: "{}", config: "{}", data: [] }) - ); + const gateProvider = useConst(() => new GateProvider(props)); + const { - chartPropsController, - plotlyController, + store, + chartViewModel, resizeCtrl: resizeController - } = useSetup( - () => - new CustomChartControllerHost({ - propsGate: propsGateProvider.gate, - editorStateGate: editorStateGateProvider.gate - }) - ); - - const editorStore = useEditorStore({ - initState: initStateFromProps(chartPropsController.data), - dataSourceKey: chartPropsController.data - }); - - useEffect(() => { - propsGateProvider.setProps(props); - }); + } = useSetup(() => new CustomChartControllerHost(gateProvider.gate)); useEffect(() => { - editorStateGateProvider.setProps(editorStore.state); + gateProvider.setProps(props); }); - const containerStyle: CSSProperties = { - width: props.widthUnit === "percentage" ? `${props.width}%` : `${props.width}px` - }; - - if (props.heightUnit === "percentageOfWidth") { - containerStyle.paddingBottom = props.widthUnit === "percentage" ? `${props.height}%` : `${props.width / 2}px`; - } else if (props.heightUnit === "pixels") { - containerStyle.height = `${props.height}px`; - } else if (props.heightUnit === "percentageOfParent") { - containerStyle.height = `${props.height}%`; - } + const containerStyle = getContainerStyle(props.width, props.widthUnit, props.height, props.heightUnit); + const playgroundData = computed( + (): PlaygroundData => ({ + type: "editor.data.v2", + store, + plotData: store.data, + layoutOptions: {}, + configOptions: {} + }) + ).get(); return { containerStyle, - playgroundData: { - store: editorStore, - plotData: chartPropsController.data, - layoutOptions: chartPropsController.layout, - configOptions: chartPropsController.config - }, - ref: mergeRefs(resizeController.setTarget, plotlyController.setChart) + playgroundData, + ref: mergeRefs(resizeController.setTarget, chartViewModel.setChart) }; } diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/mergePlaygroundState.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/mergePlaygroundState.ts deleted file mode 100644 index 785c32e596..0000000000 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/mergePlaygroundState.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EditorStoreState } from "@mendix/shared-charts/main"; -import { ChartProps } from "../components/PlotlyChart"; -import { parseConfig, parseLayout } from "./utils"; - -export function mergePlaygroundState(props: ChartProps, state: EditorStoreState): ChartProps { - return { - ...props, - config: { - ...props.config, - ...parseConfig(state.config) - }, - layout: { - ...props.layout, - ...parseLayout(state.layout) - }, - data: props.data.map((trace, index) => ({ ...trace, customSeriesOptions: state.data[index] })) - }; -} diff --git a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts index 94936c0e4f..ef58b13ea3 100644 --- a/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts +++ b/packages/pluggableWidgets/custom-chart-web/src/utils/utils.ts @@ -1,7 +1,5 @@ -import { EditorStoreState } from "@mendix/shared-charts/main"; import deepmerge from "deepmerge"; import { Config, Data, Layout } from "plotly.js-dist-min"; -import { ChartProps } from "../components/PlotlyChart"; // Plotly-specific deep merge: arrays are replaced (not concatenated) to match Plotly expectations const deepmergePlotly = (target: T, source: T): T => @@ -88,28 +86,3 @@ export function parseConfig(configOptions?: string): Partial { return {}; } } - -export function mergeChartProps(chartProps: ChartProps, editorState: EditorStoreState): ChartProps { - return { - ...chartProps, - config: deepmergePlotly(chartProps.config, parseConfig(editorState.config)), - layout: deepmergePlotly(chartProps.layout, parseLayout(editorState.layout)), - data: chartProps.data.map((trace, index) => { - let stateTrace: Data | null = null; - try { - if (!editorState.data || !editorState.data[index]) { - return trace; - } - stateTrace = JSON.parse(editorState.data[index]); - } catch { - console.warn(`Editor props for trace(${index}) is not a valid JSON:${editorState.data[index]}`); - console.warn("Please make sure the props is a valid JSON string."); - } - // deepmerge can't handle null, so return trace unchanged if stateTrace is null/undefined - if (stateTrace == null || typeof stateTrace !== "object") { - return trace; - } - return deepmergePlotly(trace, stateTrace); - }) - }; -} diff --git a/packages/pluggableWidgets/custom-chart-web/typings/modules.d.ts b/packages/pluggableWidgets/custom-chart-web/typings/modules.d.ts new file mode 100644 index 0000000000..0292a33a0f --- /dev/null +++ b/packages/pluggableWidgets/custom-chart-web/typings/modules.d.ts @@ -0,0 +1 @@ +declare module "*.scss"; diff --git a/packages/shared/charts/package.json b/packages/shared/charts/package.json index 049ca71f11..f9c69970e7 100644 --- a/packages/shared/charts/package.json +++ b/packages/shared/charts/package.json @@ -34,8 +34,10 @@ "test": "jest" }, "dependencies": { + "@types/jest": "^30.0.0", "classnames": "^2.5.1", "deepmerge": "^4.3.1", + "mobx": "6.12.3", "plotly.js-dist-min": "^3.0.0", "react-plotly.js": "^2.6.0" }, @@ -45,6 +47,7 @@ "@mendix/tsconfig-web-widgets": "workspace:^", "@mendix/widget-plugin-component-kit": "workspace:*", "@mendix/widget-plugin-hooks": "workspace:*", + "@mendix/widget-plugin-mobx-kit": "workspace:*", "@mendix/widget-plugin-platform": "workspace:*", "@mendix/widget-plugin-test-utils": "workspace:*", "@rollup/plugin-commonjs": "^28.0.3", diff --git a/packages/shared/charts/src/helpers/playground-context.ts b/packages/shared/charts/src/helpers/playground-context.ts index bc67d54d62..b55d114202 100644 --- a/packages/shared/charts/src/helpers/playground-context.ts +++ b/packages/shared/charts/src/helpers/playground-context.ts @@ -1,15 +1,26 @@ -import type { Data } from "plotly.js-dist-min"; +import type { Data, Config, Layout } from "plotly.js-dist-min"; import { Context, createContext, useContext, useMemo } from "react"; -import { ChartProps } from "../components/types"; import { EditorStore } from "./EditorStore"; -/** As of charts v4, this props are not changing over the widget lifetime. */ -type StaticProps = Pick; +import { ChartProps } from "../components/types"; +import { EditableChartStore } from "../main"; -export type PlaygroundData = StaticProps & { +export type PlaygroundDataV1 = { plotData: Array>; store: EditorStore; + configOptions: Partial; + layoutOptions: Partial; +}; + +export type PlaygroundDataV2 = { + type: "editor.data.v2"; + store: EditableChartStore; + plotData: Array>; + configOptions: Partial; + layoutOptions: Partial; }; +export type PlaygroundData = PlaygroundDataV1 | PlaygroundDataV2; + // We use Symbol.for to make this symbol accessible through whole runtime. const contextSymbol = Symbol.for("ChartsPlaygroundContext"); diff --git a/packages/shared/charts/src/main.ts b/packages/shared/charts/src/main.ts index 82cd1c5617..d87f179eb6 100644 --- a/packages/shared/charts/src/main.ts +++ b/packages/shared/charts/src/main.ts @@ -12,6 +12,8 @@ export { setupBasicSeries } from "./utils/setupBasicSeries"; export { getPlaygroundContext, usePlaygroundContext }; import { getPlaygroundContext, usePlaygroundContext } from "./helpers/playground-context"; export * from "./helpers/useEditorStore"; +export { EditableChartStore } from "./model/stores/EditableChart.store"; +export type { EditableChartStoreProps } from "./model/stores/EditableChart.store"; // Rollup does "tree shaking" too well. This results // in situation in which some exported members gets removed diff --git a/packages/shared/charts/src/model/stores/EditableChart.store.ts b/packages/shared/charts/src/model/stores/EditableChart.store.ts new file mode 100644 index 0000000000..e03987394f --- /dev/null +++ b/packages/shared/charts/src/model/stores/EditableChart.store.ts @@ -0,0 +1,159 @@ +import { action, autorun, makeAutoObservable, observable } from "mobx"; +import { SetupComponentHost, SetupComponent, disposeBatch, ComputedAtom } from "@mendix/widget-plugin-mobx-kit/main"; + +export type JSONString = string; + +export interface EditableChartStoreProps { + layout: Record; + config: Record; + data: Array>; +} + +/** + * EditableChartStore holds the current layout, configuration, and data for a chart. + * These fields are made observable by MobX so any component relying on them + * will automatically react to changes. + */ +export class EditableChartStore implements SetupComponent { + /** + * Current layout configuration. + */ + layout: Record = {}; + + /** + * Current configuration object. + */ + config: Record = {}; + + /** + * Array of data items. + */ + data: Array> = []; + + constructor( + host: SetupComponentHost, + private props: ComputedAtom + ) { + host.add(this); + makeAutoObservable(this, { + layout: observable.ref, + config: observable.ref, + data: observable.ref, + setup: false, + setLayout: action, + setConfig: action, + setDataAt: action, + reset: action + }); + } + + setup(): (() => void) | void { + const [add, disposeAll] = disposeBatch(); + + add( + autorun(() => { + const props = this.props.get(); + this.reset(props.layout, props.config, props.data); + }) + ); + + return disposeAll; + } + + /** + * JSON string representation of the current layout. + * @returns Stringified layout object. + */ + get layoutJson(): JSONString { + return JSON.stringify(this.layout); + } + + /** + * Replace the layout with a shallow copy of the provided object. + * Null/undefined values are ignored to prevent accidental clearing. + * @param layout - New layout object (will be shallow-copied) + */ + setLayout(layout: Record): void { + if (layout != null) { + this.layout = { ...layout }; + } + } + + /** + * JSON string representation of the current configuration. + * @returns Stringified configuration object. + */ + get configJson(): JSONString { + return JSON.stringify(this.config); + } + + /** + * Replace the configuration with a shallow copy of the provided object. + * @param config - New config object (will be shallow-copy) + */ + setConfig(config: Record): void { + if (config != null) { + this.config = { ...config }; + } + } + + /** + * JSON string representation of the current data array. + * @returns Stringified data array. + */ + get dataJson(): JSONString[] { + return this.data.map(item => JSON.stringify(item)); + } + + /** + * Replace the entire data array with a validated copy. + * Throws if the input is not an array. + * @param data - New data array to set + */ + private setData(data: Array>): void { + if (!Array.isArray(data)) { + throw new Error(`setData expects an array, got ${typeof data}`); + } + this.data = [...data]; + } + + /** + * Parse a JSON string and replace the data item at the given index. + * Performs error handling for invalid JSON or malformed objects. + * @param index - Position in the data array to replace + * @param jsonString - JSON string representing the new item + */ + setDataAt(index: number, jsonString: string): void { + if (index < 0 || index >= this.data.length) { + return; + } + + try { + const parsed = JSON.parse(jsonString); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const nextData = [...this.data]; + nextData[index] = parsed as Record; + this.data = nextData; + } + } catch (error) { + console.warn(`Invalid JSON in setDataAt at index ${index}:`, jsonString, error); + } + } + + /** + * Reset the entire store with new layout, configuration, and data objects. + * Each argument is shallow-copied into the corresponding field. + * @param layout - New layout object + * @param config - New config object + * @param data - New data array + */ + reset( + layout: Record, + config: Record, + data: Array> + ): void { + this.setLayout(layout); + this.setConfig(config); + this.setData(data); + } +} diff --git a/packages/shared/charts/tsconfig.build.json b/packages/shared/charts/tsconfig.build.json index 8b55c70b78..4b32025e6d 100644 --- a/packages/shared/charts/tsconfig.build.json +++ b/packages/shared/charts/tsconfig.build.json @@ -6,7 +6,8 @@ "outDir": "./dist", "rootDir": "./src", "jsx": "react-jsx", - "jsxFactory": "" + "jsxFactory": "", + "types": ["jest"] }, "references": [{ "path": "./rollup/tsconfig.json" }] } diff --git a/packages/shared/charts/tsconfig.build.tsbuildinfo b/packages/shared/charts/tsconfig.build.tsbuildinfo index db92937453..e6965c099c 100644 --- a/packages/shared/charts/tsconfig.build.tsbuildinfo +++ b/packages/shared/charts/tsconfig.build.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.ts","./src/preview.ts","./src/components/chart.tsx","./src/components/chartpreview.tsx","./src/components/chartview.tsx","./src/components/chartwidget.tsx","./src/components/types.ts","./src/helpers/editorstore.ts","./src/helpers/playground-context.ts","./src/helpers/usechartcontroller.ts","./src/helpers/useeditorstore.ts","./src/hooks/useplotchartdataseries.ts","./src/typings/declare-svg.d.ts","./src/typings/global.d.ts","./src/typings/json-source-map.d.ts","./src/utils/aggregations.ts","./src/utils/chartstyles.ts","./src/utils/compareattrvaluesasc.ts","./src/utils/configs.ts","./src/utils/equality.ts","./src/utils/json.ts","./src/utils/preview-utils.ts","./src/utils/setupbasicseries.ts","./src/utils/themefolderconfig.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.ts","./src/preview.ts","./src/components/Chart.tsx","./src/components/ChartPreview.tsx","./src/components/ChartView.tsx","./src/components/ChartWidget.tsx","./src/components/types.ts","./src/helpers/EditorStore.ts","./src/helpers/playground-context.ts","./src/helpers/useChartController.ts","./src/helpers/useEditorStore.ts","./src/hooks/usePlotChartDataSeries.ts","./src/model/stores/EditableChart.store.ts","./src/typings/declare-svg.d.ts","./src/typings/global.d.ts","./src/typings/json-source-map.d.ts","./src/utils/aggregations.ts","./src/utils/chartStyles.ts","./src/utils/compareAttrValuesAsc.ts","./src/utils/configs.ts","./src/utils/equality.ts","./src/utils/json.ts","./src/utils/preview-utils.ts","./src/utils/setupBasicSeries.ts","./src/utils/themeFolderConfig.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/shared/charts/tsconfig.tsbuildinfo b/packages/shared/charts/tsconfig.tsbuildinfo new file mode 100644 index 0000000000..7047deba0c --- /dev/null +++ b/packages/shared/charts/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/main.ts","./src/preview.ts","./src/components/Chart.tsx","./src/components/ChartPreview.tsx","./src/components/ChartView.tsx","./src/components/ChartWidget.tsx","./src/components/types.ts","./src/components/__tests__/configs.spec.ts","./src/helpers/EditorStore.ts","./src/helpers/playground-context.ts","./src/helpers/useChartController.ts","./src/helpers/useEditorStore.ts","./src/hooks/usePlotChartDataSeries.ts","./src/hooks/__tests__/usePlotChartDataSeries.spec.ts","./src/model/stores/EditableChart.store.ts","./src/typings/declare-svg.d.ts","./src/typings/global.d.ts","./src/typings/json-source-map.d.ts","./src/utils/aggregations.ts","./src/utils/chartStyles.ts","./src/utils/compareAttrValuesAsc.ts","./src/utils/configs.ts","./src/utils/equality.ts","./src/utils/json.ts","./src/utils/preview-utils.ts","./src/utils/setupBasicSeries.ts","./src/utils/themeFolderConfig.ts","./src/utils/__tests__/aggregations.spec.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a929ecc93..dac9d0a74f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1094,6 +1094,9 @@ importers: deepmerge: specifier: ^4.3.1 version: 4.3.1 + mobx-react-lite: + specifier: 4.0.7 + version: 4.0.7(patch_hash=47fd2d1b5c35554ddd4fa32fcaa928a16fda9f82dca0ff68bcdc1f7c3e5f9d1a)(mobx@6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9))(react-dom@18.3.1(react@18.3.1))(react-native@0.82.0(@babel/core@7.29.0)(@types/react@19.2.2)(react@18.3.1))(react@18.3.1) plotly.js-dist-min: specifier: ^3.0.0 version: 3.1.1 @@ -2573,12 +2576,18 @@ importers: packages/shared/charts: dependencies: + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 classnames: specifier: ^2.5.1 version: 2.5.1 deepmerge: specifier: ^4.3.1 version: 4.3.1 + mobx: + specifier: 6.12.3 + version: 6.12.3(patch_hash=39c55279e8f75c9a322eba64dd22e1a398f621c64bbfc3632e55a97f46edfeb9) plotly.js-dist-min: specifier: ^3.0.0 version: 3.1.1 @@ -2601,6 +2610,9 @@ importers: '@mendix/widget-plugin-hooks': specifier: workspace:* version: link:../widget-plugin-hooks + '@mendix/widget-plugin-mobx-kit': + specifier: workspace:* + version: link:../widget-plugin-mobx-kit '@mendix/widget-plugin-platform': specifier: workspace:* version: link:../widget-plugin-platform @@ -12624,7 +12636,7 @@ snapshots: identity-obj-proxy: 3.0.0 jasmine: 3.99.0 jasmine-core: 3.99.1 - jest: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) + jest: 29.7.0(@types/node@22.14.1) jest-environment-jsdom: 29.7.0 jest-jasmine2: 29.7.0 jest-junit: 13.2.0 @@ -13299,7 +13311,7 @@ snapshots: react-test-renderer: 19.2.4(react@18.3.1) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) + jest: 29.7.0(@types/node@22.14.1) '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -17104,6 +17116,18 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest@29.7.0(@types/node@22.14.1): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) @@ -19942,7 +19966,7 @@ snapshots: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3)) + jest: 29.7.0(@types/node@22.14.1) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6