diff --git a/jest-addon.config.js b/jest-addon.config.js index aef0f2b..84dd00e 100644 --- a/jest-addon.config.js +++ b/jest-addon.config.js @@ -1,19 +1,22 @@ -require('dotenv').config({ path: __dirname + '/.env' }) +require('dotenv').config({ path: __dirname + '/.env' }); -const fs = require('fs') -const path = require('path') +const fs = require('fs'); +const path = require('path'); const voltoSlatePath = fs.existsSync( path.join(__dirname, '../../../node_modules/@plone/volto-slate/src'), ) ? '/node_modules/@plone/volto-slate/src' - : '/node_modules/@plone/volto/packages/volto-slate/src' + : '/node_modules/@plone/volto/packages/volto-slate/src'; module.exports = { testMatch: ['**/src/addons/**/?(*.)+(spec|test).[jt]s?(x)'], collectCoverageFrom: [ - 'src/addons/**/src/**/*.{js,jsx,ts,tsx}', + 'src/addons/volto-plotlycharts/src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', + '!**/*.test.{js,jsx,ts,tsx}', + '!**/*.stories.{js,jsx,ts,tsx}', + '!**/*.spec.{js,jsx,ts,tsx}', ], moduleNameMapper: { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', @@ -55,4 +58,4 @@ module.exports = { '/node_modules/@eeacms/volto-plotlycharts/jest.setup.js', ], }), -} +}; diff --git a/src/Blocks/EmbedVisualization/Edit.test.jsx b/src/Blocks/EmbedVisualization/Edit.test.jsx new file mode 100644 index 0000000..c34b229 --- /dev/null +++ b/src/Blocks/EmbedVisualization/Edit.test.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import Edit from './Edit'; + +jest.mock('semantic-ui-react', () => ({ + Message: ({ children }) =>
{children}
, +})); + +jest.mock('@plone/volto/components/manage/Sidebar/SidebarPortal', () => ({ + __esModule: true, + default: ({ children, selected }) => ( + + ), +})); + +jest.mock('@plone/volto/components/manage/Form/BlockDataForm', () => ({ + __esModule: true, + default: (props) => ( +
+ ), +})); + +jest.mock('@eeacms/volto-plotlycharts/PlotlyComponent', () => (props) => ( +
+)); + +describe('EmbedVisualization Edit', () => { + it('shows a message until a visualization is selected', () => { + const component = renderer.create( + , + ); + + expect( + component.root.findByProps({ className: 'mock-message' }), + ).toBeTruthy(); + expect( + component.root.findByProps({ className: 'mock-block-data-form' }), + ).toBeTruthy(); + }); + + it('renders PlotlyComponent and updates block data from the sidebar form', () => { + const onChangeBlock = jest.fn(); + const component = renderer.create( + , + ); + const plotly = component.root.findByProps({ + className: 'mock-plotly-component', + }); + const form = component.root.findByProps({ + className: 'mock-block-data-form', + }); + + expect(plotly.props['data-props'].mode).toBe('edit'); + expect(plotly.props['data-props'].data).toMatchObject({ + vis_url: '/visualization', + download_button: true, + has_data_query_by_context: true, + with_sources: true, + with_more_info: true, + with_notes: false, + }); + + act(() => { + form.props['data-props'].onChangeField('title', 'Updated'); + }); + + expect(onChangeBlock).toHaveBeenCalledWith('block-id', { + vis_url: '/visualization', + with_notes: false, + title: 'Updated', + }); + }); +}); diff --git a/src/Blocks/EmbedVisualization/View.test.jsx b/src/Blocks/EmbedVisualization/View.test.jsx new file mode 100644 index 0000000..a14b3aa --- /dev/null +++ b/src/Blocks/EmbedVisualization/View.test.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import View from './View'; + +jest.mock('@eeacms/volto-plotlycharts/PlotlyComponent', () => (props) => ( +
+)); + +describe('EmbedVisualization View', () => { + it('passes embedded visualization defaults to PlotlyComponent', () => { + const component = renderer.create( + , + ); + const plotly = component.root.findByProps({ + className: 'mock-plotly-component', + }); + + expect( + component.root.findByProps({ className: 'embed-visualization view' }), + ).toBeTruthy(); + expect(plotly.props['data-props']).toMatchObject({ mode: 'preview' }); + expect(plotly.props['data-props'].data).toMatchObject({ + title: 'Embedded chart', + download_button: true, + has_data_query_by_context: true, + with_sources: true, + llm_summary: 'Summary', + }); + }); +}); diff --git a/src/Blocks/PlotlyChart/Edit.test.jsx b/src/Blocks/PlotlyChart/Edit.test.jsx new file mode 100644 index 0000000..1c7c19b --- /dev/null +++ b/src/Blocks/PlotlyChart/Edit.test.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import Edit from './Edit'; + +jest.mock('semantic-ui-react', () => { + const Modal = ({ children, open, className }) => ( +
+ {children} +
+ ); + Modal.Content = ({ children, scrolling }) => ( +
+ {children} +
+ ); + + return { + Button: ({ children, onClick }) => ( + + ), + Modal, + }; +}); + +jest.mock('@plone/volto/components/manage/Sidebar/SidebarPortal', () => ({ + __esModule: true, + default: ({ children }) => , +})); + +jest.mock('@plone/volto/components/manage/Form/BlockDataForm', () => ({ + __esModule: true, + default: (props) => ( + + ), +})); + +jest.mock('./View', () => (props) => ( +
+)); + +describe('PlotlyChart Edit', () => { + beforeEach(() => { + global.__SERVER__ = false; + }); + + it('renders editor controls, block view, and sidebar form', () => { + const component = renderer.create( + , + ); + + expect(component.root.findByType('button').children).toEqual([ + 'Open Chart Editor', + ]); + expect( + component.root.findByProps({ className: 'mock-plotly-view' }).props[ + 'data-props' + ].mode, + ).toBe('edit'); + expect( + component.root.findByProps({ className: 'mock-block-data-form' }), + ).toBeTruthy(); + }); + + it('opens the chart editor modal and updates fields from the sidebar form', () => { + const onChangeBlock = jest.fn(); + const preventDefault = jest.fn(); + const stopPropagation = jest.fn(); + const component = renderer.create( + , + ); + + act(() => { + component.root.findByType('button').props.onClick({ + preventDefault, + stopPropagation, + }); + }); + + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + expect( + component.root.findByProps({ + className: 'chart-editor-modal plotly-editor--theme-provider', + }), + ).toBeTruthy(); + + act(() => { + component.root + .findByProps({ className: 'mock-block-data-form' }) + .props['data-props'].onChangeField('title', 'Updated'); + }); + + expect(onChangeBlock).toHaveBeenCalledWith('block-id', { + title: 'Updated', + visualization: { data: [] }, + }); + }); +}); diff --git a/src/Blocks/PlotlyChart/View.test.jsx b/src/Blocks/PlotlyChart/View.test.jsx new file mode 100644 index 0000000..0f57dce --- /dev/null +++ b/src/Blocks/PlotlyChart/View.test.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import View from './View'; + +jest.mock('@eeacms/volto-plotlycharts/PlotlyComponent', () => (props) => ( +
+)); + +describe('PlotlyChart View', () => { + it('renders PlotlyComponent with default print/export options', () => { + const component = renderer.create( + , + ); + const plotly = component.root.findByProps({ + className: 'mock-plotly-component', + }); + + expect( + component.root.findByProps({ className: 'plotly-chart' }), + ).toBeTruthy(); + expect(plotly.props['data-props'].data).toMatchObject({ + title: 'Chart', + download_button: true, + has_data_query_by_context: true, + with_sources: false, + }); + }); +}); diff --git a/src/Blocks/Treemap/Edit.test.jsx b/src/Blocks/Treemap/Edit.test.jsx new file mode 100644 index 0000000..8c9aafc --- /dev/null +++ b/src/Blocks/Treemap/Edit.test.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import Edit from './Edit'; + +jest.mock('redux', () => ({ + compose: + (...fns) => + (value) => + fns.reduceRight((acc, fn) => fn(acc), value), +})); + +jest.mock('@eeacms/volto-datablocks/hocs', () => ({ + connectToProviderData: () => (Component) => (props) => ( + + ), +})); + +jest.mock('@plone/volto/components/manage/Sidebar/SidebarPortal', () => ({ + __esModule: true, + default: ({ children }) => , +})); + +jest.mock('@plone/volto/components/manage/Form/BlockDataForm', () => ({ + __esModule: true, + default: (props) => ( + + ), +})); + +jest.mock('./View', () => (props) => ( +
+)); + +describe('Treemap Edit', () => { + it('renders TreemapView and builds schema choices from provider data', () => { + const component = renderer.create( + , + ); + const form = component.root.findByProps({ + className: 'mock-block-data-form', + }); + + expect( + component.root.findByProps({ className: 'mock-treemap-view' }), + ).toBeTruthy(); + expect( + form.props['data-props'].schema.properties.size_column.choices, + ).toEqual([ + ['size', 'size'], + ['label', 'label'], + ]); + }); + + it('updates block data from the sidebar form', () => { + const onChangeBlock = jest.fn(); + const component = renderer.create( + , + ); + const form = component.root.findByProps({ + className: 'mock-block-data-form', + }); + + act(() => { + form.props['data-props'].onChangeField('size_column', 'size'); + }); + + expect(onChangeBlock).toHaveBeenCalledWith('block-id', { + url: '/data', + size_column: 'size', + }); + }); +}); diff --git a/src/Blocks/Treemap/View.test.jsx b/src/Blocks/Treemap/View.test.jsx new file mode 100644 index 0000000..1feb70a --- /dev/null +++ b/src/Blocks/Treemap/View.test.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import View from './View'; + +jest.mock('./Treemap', () => (props) => ( +
+)); + +jest.mock('@eeacms/volto-datablocks/hocs', () => ({ + withBlockData: (Component) => (props) => ( + + ), +})); + +describe('Treemap View', () => { + it('renders Treemap with block data props', () => { + const component = renderer.create(); + const treemap = component.root.findByProps({ className: 'mock-treemap' }); + + expect(treemap.props['data-props'].data).toEqual({ title: 'Treemap' }); + expect(treemap.props['data-props'].provider_data).toEqual({ value: [1] }); + }); +}); diff --git a/src/PlotlyComponent/Placeholder.test.jsx b/src/PlotlyComponent/Placeholder.test.jsx new file mode 100644 index 0000000..e2d3332 --- /dev/null +++ b/src/PlotlyComponent/Placeholder.test.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import Placeholder from './Placeholder'; + +describe('Placeholder', () => { + it('renders the Plotly loading placeholder SVG', () => { + const component = renderer.create(); + const root = component.root; + + expect(root.findByProps({ className: 'plotly-placeholder' })).toBeTruthy(); + expect(root.findByType('svg').props.viewBox).toBe('0 0 240 240'); + expect(root.findAllByProps({ className: 'bar' })).toHaveLength(5); + }); +}); diff --git a/src/PlotlyComponent/Plot.jsx b/src/PlotlyComponent/Plot.jsx index 060cd87..14ae730 100644 --- a/src/PlotlyComponent/Plot.jsx +++ b/src/PlotlyComponent/Plot.jsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useMemo } from 'react'; +import React, { forwardRef, useEffect, useMemo, useRef } from 'react'; import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable'; import { useHistory } from 'react-router-dom'; @@ -16,6 +16,8 @@ const Plot = forwardRef((props, ref) => { const plotly = props.plotlyMinLib?.default || props.plotlyMinLib; const plotlyComponentFactory = props.plotlyComponentFactory?.default || props.plotlyComponentFactory; + const graphDiv = useRef(null); + const originalPrintLayout = useRef(null); const PlotlyComponent = useMemo(() => { return plotlyComponentFactory(plotly); @@ -94,6 +96,130 @@ const Plot = forwardRef((props, ref) => { } }; + const setGraphDiv = (value) => { + graphDiv.current = value; + + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + ref.current = value; + } + }; + + useEffect(() => { + if (typeof window === 'undefined') return undefined; + + const resizePlot = () => { + if (!graphDiv.current || !plotly?.Plots?.resize) return; + + try { + plotly.Plots.resize(graphDiv.current); + } catch { + // Plotly can throw if the graph is being unmounted during print cleanup. + } + }; + + const relayoutPlot = (layoutUpdate) => { + if (!graphDiv.current || !plotly?.relayout) return false; + + try { + const result = plotly.relayout(graphDiv.current, layoutUpdate); + result?.catch?.(() => {}); + return true; + } catch { + return false; + } + }; + + const getWidth = (element) => + Math.floor(element?.getBoundingClientRect?.().width || 0); + + const getPrintWidth = () => { + const chart = graphDiv.current?.closest?.( + '.embed-visualization, .plotly-component', + ); + const content = graphDiv.current?.closest?.( + '#page-document, .content-area', + ); + const widths = [ + getWidth(chart), + getWidth(content), + getWidth(graphDiv.current?.parentElement), + ].filter((width) => width > 0); + + return widths.length ? Math.min(...widths) : 0; + }; + + const fitPlotToPrintWidth = () => { + const targetWidth = getPrintWidth(); + const currentWidth = + graphDiv.current?._fullLayout?.width || layout?.width || 0; + + if (!targetWidth || !currentWidth || currentWidth <= targetWidth) { + resizePlot(); + return; + } + + if (!originalPrintLayout.current) { + originalPrintLayout.current = { + width: graphDiv.current?._fullLayout?.width || layout?.width, + height: graphDiv.current?._fullLayout?.height || layout?.height, + }; + } + + if (!relayoutPlot({ width: targetWidth })) { + resizePlot(); + } + }; + + const restorePlotWidth = () => { + if (!originalPrintLayout.current) { + resizePlot(); + return; + } + + const { width, height } = originalPrintLayout.current; + originalPrintLayout.current = null; + + const layoutUpdate = { + ...(width ? { width } : {}), + ...(height ? { height } : {}), + }; + + if (!Object.keys(layoutUpdate).length || !relayoutPlot(layoutUpdate)) { + resizePlot(); + } + }; + + const handleBeforePrint = () => { + fitPlotToPrintWidth(); + const nextFrame = window.requestAnimationFrame + ? (callback) => window.requestAnimationFrame(callback) + : (callback) => window.setTimeout(callback, 0); + nextFrame(fitPlotToPrintWidth); + window.setTimeout(fitPlotToPrintWidth, 100); + }; + + const printMediaQuery = window.matchMedia?.('print'); + const handlePrintMediaChange = (event) => { + if (event.matches) { + handleBeforePrint(); + } else { + restorePlotWidth(); + } + }; + + window.addEventListener('beforeprint', handleBeforePrint); + window.addEventListener('afterprint', restorePlotWidth); + printMediaQuery?.addEventListener?.('change', handlePrintMediaChange); + + return () => { + window.removeEventListener('beforeprint', handleBeforePrint); + window.removeEventListener('afterprint', restorePlotWidth); + printMediaQuery?.removeEventListener?.('change', handlePrintMediaChange); + }; + }, [layout?.height, layout?.width, plotly]); + return ( { ...(autoscale ? { autosize: false } : {}), }} onInitialized={(...args) => { - if (ref) { - ref.current = args[1]; - } + setGraphDiv(args[1]); if (onInitialized) { onInitialized(...args); } diff --git a/src/Views/VisualizationView.test.jsx b/src/Views/VisualizationView.test.jsx new file mode 100644 index 0000000..b76dab6 --- /dev/null +++ b/src/Views/VisualizationView.test.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import VisualizationView from './VisualizationView'; + +jest.mock('semantic-ui-react', () => ({ + Container: ({ children, ...props }) =>
{children}
, +})); + +jest.mock('@plone/volto/helpers/Blocks/Blocks', () => ({ + hasBlocksData: jest.fn((content) => Boolean(content.blocks_layout)), +})); + +jest.mock('@plone/volto/components/theme/View/RenderBlocks', () => (props) => ( +
+)); + +jest.mock('@eeacms/volto-embed/helpers', () => ({ + pickMetadata: jest.fn((content) => ({ picked: content.title })), +})); + +jest.mock('@eeacms/volto-plotlycharts/PlotlyComponent', () => (props) => ( +
+)); + +describe('VisualizationView', () => { + it('renders PlotlyComponent when content has no blocks', () => { + const content = { + title: 'Visualization', + visualization: { '@id': '/chart' }, + llm_summary: 'Summary', + }; + const component = renderer.create(); + const plotly = component.root.findByProps({ + className: 'mock-plotly-component', + }); + + expect(component.root.findByProps({ id: 'page-document' })).toBeTruthy(); + expect(plotly.props['data-props'].data).toMatchObject({ + with_sources: false, + with_notes: false, + with_more_info: false, + download_button: true, + with_enlarge: true, + with_share: true, + properties: { picked: 'Visualization' }, + visualization: { '@id': '/chart' }, + llm_summary: 'Summary', + }); + }); + + it('renders blocks when content has blocks data', () => { + const component = renderer.create( + , + ); + + expect( + component.root.findByProps({ className: 'mock-render-blocks' }), + ).toBeTruthy(); + }); +}); diff --git a/src/Widgets/VisualizationViewWidget.test.jsx b/src/Widgets/VisualizationViewWidget.test.jsx new file mode 100644 index 0000000..ec799dd --- /dev/null +++ b/src/Widgets/VisualizationViewWidget.test.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import VisualizationViewWidget from './VisualizationViewWidget'; + +jest.mock('react-redux', () => ({ + connect: (mapStateToProps) => (Component) => (props) => ( + + ), +})); + +jest.mock('@eeacms/volto-embed/helpers', () => ({ + pickMetadata: jest.fn((content) => ({ picked: content.title })), +})); + +jest.mock('@eeacms/volto-plotlycharts/PlotlyComponent', () => (props) => ( +
+)); + +describe('VisualizationViewWidget', () => { + it('uses provided content metadata and selected visualization value', () => { + const component = renderer.create( + , + ); + const plotly = component.root.findByProps({ + className: 'mock-plotly-component', + }); + + expect(plotly.props['data-props'].data).toMatchObject({ + with_sources: false, + with_notes: false, + with_more_info: false, + download_button: true, + with_enlarge: true, + with_share: true, + visualization: { '@id': '/visualization' }, + properties: { picked: 'Provided title' }, + llm_summary: 'Provided summary', + }); + }); + + it('falls back to content from state when content prop is missing', () => { + const component = renderer.create( + , + ); + const plotly = component.root.findByProps({ + className: 'mock-plotly-component', + }); + + expect(plotly.props['data-props'].data.properties).toEqual({ + picked: 'State title', + }); + }); +});