diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3da84f..8d2b63b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
+### [14.0.4](https://github.com/eea/volto-plotlycharts/compare/14.0.3...14.0.4) - 25 May 2026
+
+#### :rocket: New Features
+
+- feat(PlotlyComponent): structure figure-info for indexing + embed LLM summary [Miu Razvan - [`0790ba3`](https://github.com/eea/volto-plotlycharts/commit/0790ba3abb17be707d4b7bcf1102ff3db28752b7)]
+
+#### :bug: Bug Fixes
+
+- fix: embed data table only includes columns used by chart traces [Miu Razvan - [`3086bb5`](https://github.com/eea/volto-plotlycharts/commit/3086bb5ecd0f295f48539d293fb9d77efee39254)]
+
+#### :house: Internal changes
+
+- style: Automated code fix [eea-jenkins - [`bf22b4a`](https://github.com/eea/volto-plotlycharts/commit/bf22b4af2f6a359c510cd6a8330de3282758b5bb)]
+
+#### :hammer_and_wrench: Others
+
+- Add unit tests [Miu Razvan - [`d9385aa`](https://github.com/eea/volto-plotlycharts/commit/d9385aa94dd090ed50dad69f73f65b4c8e597c9b)]
+- remove unnused variable [Miu Razvan - [`27b484a`](https://github.com/eea/volto-plotlycharts/commit/27b484a5c7278e26f2d31a057ab115d6d1f60343)]
+- Pin @plone/scripts to 3.x [Miu Razvan - [`db5bdb8`](https://github.com/eea/volto-plotlycharts/commit/db5bdb8e3635be352b5c72fe9a902df2bd5228b3)]
### [14.0.3](https://github.com/eea/volto-plotlycharts/compare/14.0.2...14.0.3) - 22 April 2026
#### :hammer_and_wrench: Others
diff --git a/package.json b/package.json
index fa04d3d..10e8058 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@eeacms/volto-plotlycharts",
- "version": "14.0.3",
+ "version": "14.0.4",
"description": "Plotly Charts and Editor integration for Volto",
"main": "src/index.js",
"author": "European Environment Agency: IDM2 A-Team",
@@ -29,7 +29,7 @@
"@eeacms/volto-datablocks": "*",
"@eeacms/volto-embed": "*",
"@eeacms/volto-matomo": "*",
- "@plone/scripts": "*",
+ "@plone/scripts": "^3.10.6",
"handsontable": "^15.2.0",
"jsoneditor": "10.2.0",
"jszip": "3.10.1",
@@ -39,7 +39,7 @@
},
"devDependencies": {
"@cypress/code-coverage": "^3.10.0",
- "@plone/scripts": "*",
+ "@plone/scripts": "^3.10.6",
"babel-plugin-transform-class-properties": "^6.24.1",
"cypress": "13.1.0",
"dotenv": "^16.3.2",
diff --git a/src/PlotlyComponent/PlotlyComponent.jsx b/src/PlotlyComponent/PlotlyComponent.jsx
index 7efb37d..73704b8 100644
--- a/src/PlotlyComponent/PlotlyComponent.jsx
+++ b/src/PlotlyComponent/PlotlyComponent.jsx
@@ -1,38 +1,38 @@
-import { useMemo, useRef, useEffect, useState, useCallback } from 'react';
-import { useIntl } from 'react-intl';
-import { compose } from 'redux';
-import { connect } from 'react-redux';
-import isArray from 'lodash/isArray';
-import uniqBy from 'lodash/uniqBy';
-import sortBy from 'lodash/sortBy';
-import isNil from 'lodash/isNil';
-import debounce from 'lodash/debounce';
-import cx from 'classnames';
-import { FormField } from 'semantic-ui-react';
// import { constants } from '@eeacms/react-chart-editor';
-import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable';
-import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
import { VisibilitySensor } from '@eeacms/volto-datablocks/components';
import { connectToProviderData } from '@eeacms/volto-datablocks/hocs';
-import { connectBlockToVisualization } from '@eeacms/volto-plotlycharts/hocs';
+import { Toolbar } from '@eeacms/volto-plotlycharts/Utils';
+import {
+ getMetadataFlags,
+ processMetadataArrays,
+} from '@eeacms/volto-plotlycharts/Utils/utils';
import {
deleteGeneratedFigureMetadataBlock,
- getFigurePosition,
getFigureMetadata,
+ getFigurePosition,
insertFigureMetadataBeforeBlock,
} from '@eeacms/volto-plotlycharts/helpers';
import {
- updateTrace,
updateDataSources,
+ updateTrace,
} from '@eeacms/volto-plotlycharts/helpers/plotly';
import { applyFilters } from '@eeacms/volto-plotlycharts/helpers/transforms';
-import { Toolbar } from '@eeacms/volto-plotlycharts/Utils';
-import Plot from './Plot';
+import { connectBlockToVisualization } from '@eeacms/volto-plotlycharts/hocs';
+import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable';
+import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
+import cx from 'classnames';
+import debounce from 'lodash/debounce';
+import isArray from 'lodash/isArray';
+import isNil from 'lodash/isNil';
+import sortBy from 'lodash/sortBy';
+import uniqBy from 'lodash/uniqBy';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { compose } from 'redux';
+import { FormField } from 'semantic-ui-react';
import Placeholder from './Placeholder';
-import {
- getMetadataFlags,
- processMetadataArrays,
-} from '@eeacms/volto-plotlycharts/Utils/utils';
+import Plot from './Plot';
// generateCSVForDataset,
// generateOriginalCSV,
@@ -58,6 +58,11 @@ function stripHtml(html) {
return doc.body.textContent || '';
}
+// Only null/undefined/blank strings are empty. `0` and `false` are real data.
+function isEmptyValue(value) {
+ return isNil(value) || (typeof value === 'string' && value.trim() === '');
+}
+
function UnconnectedPlotlyComponent(props) {
const intl = useIntl();
const { reactChartEditor } = props;
@@ -82,12 +87,7 @@ function UnconnectedPlotlyComponent(props) {
onDeleteBlock,
blocksConfig,
} = props;
- const {
- height,
- vis_url,
- with_metadata_section = true,
- llm_summary,
- } = props.data;
+ const { height, vis_url, with_metadata_section = true } = props.data;
const [initialized, setInitialized] = useState(false);
const [filtersState, setFiltersState] = useState([]);
const [autoscaleHeight, setAutoscaleHeight] = useState(null);
@@ -446,65 +446,89 @@ function UnconnectedPlotlyComponent(props) {
data={toolbarData}
provider_metadata={provider_metadata}
/>
-
- {llm_summary && (
-
- {llm_summary}
-
- )}
);
}
-function prepareEmbedData(dataSources, provider_metadata, core_metadata) {
- let array = [];
+function prepareEmbedData(
+ dataSources,
+ traces,
+ provider_metadata,
+ core_metadata,
+) {
+ const array = [];
+ const srcKeys = traces.reduce((acc, trace) => {
+ Object.keys(trace).forEach((key) => {
+ if (key.endsWith('src')) {
+ if (!acc.includes(trace[key])) {
+ acc.push(trace[key]);
+ }
+ }
+ });
+ return acc;
+ }, []);
Object.entries(dataSources).forEach(([key, items]) => {
+ if (!srcKeys.includes(key)) return;
items.forEach((item, index) => {
if (!array[index]) array[index] = {};
array[index][key] = item;
});
});
- let readme = provider_metadata?.readme ? [provider_metadata?.readme] : [];
+ const readme = provider_metadata?.readme ? [provider_metadata?.readme] : [];
const metadataFlags = getMetadataFlags(core_metadata);
const metadataArrays = processMetadataArrays(core_metadata, metadataFlags);
- return { array, readme, metadataArrays, metadataFlags };
+ return { array, readme, metadataArrays };
}
-function Table({ rows }) {
- const stableKeys = Object.keys(rows?.[0] || {});
+// Column-major rendering: one line per column, values comma-joined in their
+// original row order so positional meaning is preserved. Blank cells become "-",
+// fully-empty columns are dropped. Generic — also used for metadata tables.
+function Table({ rows, title }) {
+ if (!isArray(rows) || rows.length === 0) return null;
+
+ // Union of keys across all rows, preserving first-appearance order.
+ const keys = rows.reduce((acc, row) => {
+ Object.keys(row || {}).forEach((key) => {
+ if (!acc.includes(key)) acc.push(key);
+ });
+ return acc;
+ }, []);
+
+ const columns = keys
+ .map((key) => {
+ const values = rows.map((row) =>
+ isEmptyValue(row?.[key]) ? '-' : String(row[key]),
+ );
+ // Drop columns that carry no data at all.
+ if (values.every((value) => value === '-')) return null;
+ return { key, text: values.join(', ') };
+ })
+ .filter(Boolean);
+
+ // No renderable column -> render nothing, including the heading. This keeps
+ // the heading and its content as a single visibility decision.
+ if (columns.length === 0) return null;
return (
-
-
-
- {stableKeys.map((key) => (
- | {key} |
- ))}
-
-
-
- {rows.map((row, index) => (
-
- {stableKeys.map((key) => (
- | {row[key]} |
- ))}
-
+ <>
+ {title && {title}:
}
+
+ {columns.map((column) => (
+ -
+ {column.key}: {column.text}
+
))}
-
-
+
+ >
);
}
function EmbedData(props) {
- const { provider_metadata, content, block } = props; // reactChartEditorLib,
+ const { provider_metadata, block } = props; // reactChartEditorLib,
const visualization = props.data?.visualization || {};
- const { dataSources = {}, layout } = visualization;
+ const { dataSources = {}, layout, data: traces = [] } = visualization;
const {
data_provenance,
@@ -524,65 +548,71 @@ function EmbedData(props) {
const embedData = prepareEmbedData(
dataSources,
+ traces,
provider_metadata,
core_metadata,
);
- const { array, readme, metadataArrays, metadataFlags } = embedData;
+ const { array, readme, metadataArrays } = embedData;
+
+ const subtitle = stripHtml(layout?.title?.subtitle?.text || '');
+ const llmSummary = stripHtml(props.data?.llm_summary || '');
return (
-
{content.title}
{!!layout?.title?.text && (
-
{stripHtml(layout.title.text)}
+
+ Visualization title: {stripHtml(layout.title.text)}
+
)}
- {!!layout?.title?.subtitle?.text && (
-
- {stripHtml(layout.title.subtitle.text)}
-
+ {!!subtitle && subtitle !== layout?.yaxis?.title?.text && (
+
+ {layout?.yaxis?.title?.text
+ ? 'Subtitle: '
+ : 'Secondary label (shown as chart subtitle; may also serve as the y-axis title): '}
+ {subtitle}
+
)}
+ {!!llmSummary &&
Summary: {llmSummary}
}
{!!layout?.xaxis?.title?.text && (
-
{stripHtml(layout.xaxis.title.text)}
+
+ X axis title: {stripHtml(layout.xaxis.title.text)}
+
)}
{!!layout?.yaxis?.title?.text && (
-
{stripHtml(layout.yaxis.title.text)}
+
+ Y axis title: {stripHtml(layout.yaxis.title.text)}
+
)}
-
+
+ In each data section below, every line is one column: its values are
+ listed in row order and align by position across columns — the Nth value
+ in every column belongs to the same record. "-" means no value.
+
- {metadataFlags.hasDataProvenance && (
- <>
-
Data Provenance
-
- >
- )}
- {metadataFlags.hasOtherOrganisation && (
- <>
-
Other Organisations
-
- >
- )}
- {metadataFlags.hasTemporalCoverage && (
- <>
-
Temporal Coverage
-
- >
- )}
- {metadataFlags.hasGeoCoverage && (
- <>
-
Geographical Coverage
-
- >
- )}
- {metadataFlags.hasPublisher && (
- <>
-
Publisher
-
- >
- )}
+
+
+
+
+
+
+
{readme}
diff --git a/src/Utils/utils.test.js b/src/Utils/utils.test.js
new file mode 100644
index 0000000..cd98e07
--- /dev/null
+++ b/src/Utils/utils.test.js
@@ -0,0 +1,189 @@
+import {
+ generateFinalCSV,
+ generateOriginalCSV,
+ getMetadataFlags,
+ processMetadataArrays,
+ generateCSVForDataset,
+} from './utils';
+
+jest.mock('@eeacms/volto-matomo/utils', () => ({
+ trackLink: jest.fn(),
+}));
+
+const emptyCoreMetadata = {};
+
+describe('getMetadataFlags', () => {
+ it('should be all false for empty metadata', () => {
+ expect(getMetadataFlags(emptyCoreMetadata)).toEqual({
+ hasDataProvenance: false,
+ hasOtherOrganisation: false,
+ hasTemporalCoverage: false,
+ hasGeoCoverage: false,
+ hasPublisher: false,
+ });
+ });
+
+ it('should flag present metadata sections', () => {
+ expect(
+ getMetadataFlags({
+ data_provenance: ['Source A'],
+ publisher: ['EEA'],
+ }),
+ ).toMatchObject({
+ hasDataProvenance: true,
+ hasPublisher: true,
+ hasOtherOrganisation: false,
+ });
+ });
+});
+
+describe('processMetadataArrays', () => {
+ it('should return empty arrays when no flag is set', () => {
+ const flags = getMetadataFlags(emptyCoreMetadata);
+ expect(processMetadataArrays(emptyCoreMetadata, flags)).toEqual({
+ data_provenance_array: [],
+ other_organisation_array: [],
+ temporal_coverage_array: [],
+ geo_coverage_array: [],
+ publisher_array: [],
+ });
+ });
+
+ it('should populate arrays for flagged sections', () => {
+ const core_metadata = {
+ data_provenance: ['Source A', 'Source B'],
+ };
+ const flags = getMetadataFlags(core_metadata);
+ const result = processMetadataArrays(core_metadata, flags);
+
+ expect(result.data_provenance_array.length).toBeGreaterThan(0);
+ // each entry keyed by a column that includes "data_provenance" or "Sources"
+ result.data_provenance_array.forEach((row) => {
+ Object.keys(row).forEach((key) => {
+ expect(key.includes('data_provenance') || key.includes('Sources')).toBe(
+ true,
+ );
+ });
+ });
+ });
+});
+
+describe('generateFinalCSV', () => {
+ it('should concatenate the download source line and data with no metadata', () => {
+ const flags = getMetadataFlags(emptyCoreMetadata);
+ const arrays = processMetadataArrays(emptyCoreMetadata, flags);
+
+ const csv = generateFinalCSV(
+ [{ region: 'EU', value: 10 }],
+ [],
+ flags,
+ arrays,
+ 'http://example.org',
+ );
+
+ expect(csv).toContain('Downloaded from: ');
+ expect(csv).toContain('http://example.org');
+ expect(csv).toContain('region,value');
+ expect(csv).toContain('EU');
+ });
+
+ it('should include metadata sections when flags are set', () => {
+ const core_metadata = { publisher: ['EEA'] };
+ const flags = getMetadataFlags(core_metadata);
+ const arrays = processMetadataArrays(core_metadata, flags);
+
+ const csv = generateFinalCSV(
+ [{ region: 'EU', value: 10 }],
+ [],
+ flags,
+ arrays,
+ 'http://example.org',
+ );
+
+ expect(csv).toContain('EEA');
+ });
+});
+
+describe('generateOriginalCSV', () => {
+ it('should build CSV from data sources without columns', () => {
+ const csv = generateOriginalCSV(
+ { region: ['EU', 'US'], value: [10, 20] },
+ [],
+ {},
+ 'http://example.org',
+ emptyCoreMetadata,
+ );
+
+ expect(csv).toContain('region,value');
+ expect(csv).toContain('EU');
+ expect(csv).toContain('US');
+ });
+
+ it('should order columns according to the provided columns list', () => {
+ const csv = generateOriginalCSV(
+ { value: [10], region: ['EU'] },
+ ['region', 'value'],
+ {},
+ 'http://example.org',
+ emptyCoreMetadata,
+ );
+
+ expect(csv).toContain('region,value');
+ });
+
+ it('should prepend the readme from provider metadata', () => {
+ const csv = generateOriginalCSV(
+ { region: ['EU'] },
+ [],
+ { readme: 'Read this' },
+ 'http://example.org',
+ emptyCoreMetadata,
+ );
+
+ expect(csv).toContain('Read this');
+ });
+});
+
+describe('generateCSVForDataset', () => {
+ const reactChartEditorLib = {
+ constants: { TRACE_SRC_ATTRIBUTES: ['x', 'y'] },
+ getAttrsPath: (trace) => {
+ const attrs = {};
+ if (trace.x) attrs.x = trace.x;
+ if (trace.y) attrs.y = trace.y;
+ return attrs;
+ },
+ getSrcAttr: (trace, attr) => ({ value: trace[`${attr}src`] }),
+ };
+
+ it('should build CSV from trace-processed data', () => {
+ const csv = generateCSVForDataset(
+ { year: [2020, 2021], region: ['EU', 'US'] },
+ {
+ data: [{ x: [2020, 2021], xsrc: 'year' }],
+ layout: {},
+ },
+ {},
+ emptyCoreMetadata,
+ 'http://example.org',
+ reactChartEditorLib,
+ );
+
+ expect(csv).toContain('year');
+ expect(csv).toContain('2020');
+ });
+
+ it('should fall back to data sources when traces yield no data', () => {
+ const csv = generateCSVForDataset(
+ { region: ['EU', 'US'], value: [10, 20] },
+ { data: [], layout: {} },
+ {},
+ emptyCoreMetadata,
+ 'http://example.org',
+ reactChartEditorLib,
+ );
+
+ expect(csv).toContain('region,value');
+ expect(csv).toContain('EU');
+ });
+});
diff --git a/src/actions.test.js b/src/actions.test.js
new file mode 100644
index 0000000..f40d095
--- /dev/null
+++ b/src/actions.test.js
@@ -0,0 +1,26 @@
+import { getVisualization, removeVisualization } from './actions';
+import { GET_VISUALIZATION, REMOVE_VISUALIZATION } from './constants';
+
+describe('actions', () => {
+ describe('getVisualization', () => {
+ it('should build a GET_VISUALIZATION request action', () => {
+ expect(getVisualization('/path/to/chart')).toEqual({
+ type: GET_VISUALIZATION,
+ path: '/path/to/chart',
+ request: {
+ op: 'get',
+ path: '/path/to/chart/@visualization',
+ },
+ });
+ });
+ });
+
+ describe('removeVisualization', () => {
+ it('should build a REMOVE_VISUALIZATION action', () => {
+ expect(removeVisualization('/path/to/chart')).toEqual({
+ type: REMOVE_VISUALIZATION,
+ path: '/path/to/chart',
+ });
+ });
+ });
+});
diff --git a/src/helpers/editor.test.js b/src/helpers/editor.test.js
new file mode 100644
index 0000000..ec57806
--- /dev/null
+++ b/src/helpers/editor.test.js
@@ -0,0 +1,60 @@
+import { destroyEditor, onPasteEditor, validateEditor } from './editor';
+
+jest.mock('react-toastify', () => ({
+ toast: { warn: jest.fn() },
+}));
+
+jest.mock('@plone/volto/components/manage/Toast/Toast', () => () => null);
+
+describe('editor helpers', () => {
+ describe('destroyEditor', () => {
+ it('should call destroy on a provided editor', () => {
+ const editor = { destroy: jest.fn() };
+ destroyEditor(editor);
+ expect(editor.destroy).toHaveBeenCalled();
+ });
+
+ it('should do nothing when no editor is given', () => {
+ expect(() => destroyEditor(null)).not.toThrow();
+ });
+ });
+
+ describe('onPasteEditor', () => {
+ it('should repair and format the editor', () => {
+ const editor = {
+ current: { repair: jest.fn(), format: jest.fn() },
+ };
+ onPasteEditor(editor);
+ expect(editor.current.repair).toHaveBeenCalled();
+ expect(editor.current.format).toHaveBeenCalled();
+ });
+
+ it('should swallow errors thrown while repairing', () => {
+ const editor = {
+ current: {
+ repair: () => {
+ throw new Error('bad');
+ },
+ format: jest.fn(),
+ },
+ };
+ expect(() => onPasteEditor(editor)).not.toThrow();
+ });
+ });
+
+ describe('validateEditor', () => {
+ it('should return true when there are no validation errors', async () => {
+ const editor = { current: { validate: jest.fn().mockResolvedValue([]) } };
+ await expect(validateEditor(editor)).resolves.toBe(true);
+ });
+
+ it('should warn and return false when validation errors exist', async () => {
+ const { toast } = require('react-toastify');
+ const editor = {
+ current: { validate: jest.fn().mockResolvedValue([{ message: 'x' }]) },
+ };
+ await expect(validateEditor(editor)).resolves.toBe(false);
+ expect(toast.warn).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/middlewares/data_visualizations.test.js b/src/middlewares/data_visualizations.test.js
new file mode 100644
index 0000000..392b6f9
--- /dev/null
+++ b/src/middlewares/data_visualizations.test.js
@@ -0,0 +1,58 @@
+import { data_visualizations } from './data_visualizations';
+import { GET_VISUALIZATION } from '@eeacms/volto-plotlycharts/constants';
+
+const runMiddleware = (action, state) => {
+ const dispatch = jest.fn();
+ const next = jest.fn((a) => a);
+ const store = {
+ getState: () => state,
+ dispatch,
+ };
+ const [mw] = data_visualizations([]);
+ const result = mw(store)(next)(action);
+ return { dispatch, next, result };
+};
+
+describe('data_visualizations middleware', () => {
+ it('should append the provided middlewares', () => {
+ const extra = () => {};
+ const chain = data_visualizations([extra]);
+ expect(chain).toHaveLength(2);
+ expect(chain[1]).toBe(extra);
+ });
+
+ it('should dispatch a PENDING action and forward when not already pending', () => {
+ const { dispatch, next } = runMiddleware(
+ { type: GET_VISUALIZATION, path: '/chart' },
+ { data_visualizations: { pendingVisualizations: {} } },
+ );
+
+ expect(dispatch).toHaveBeenCalledWith({
+ type: `${GET_VISUALIZATION}_PENDING`,
+ path: '/chart',
+ });
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('should short-circuit when the path is already pending', () => {
+ const { dispatch, next, result } = runMiddleware(
+ { type: GET_VISUALIZATION, path: '/chart' },
+ { data_visualizations: { pendingVisualizations: { '/chart': true } } },
+ );
+
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ expect(result).toBeUndefined();
+ });
+
+ it('should forward unrelated actions untouched', () => {
+ const action = { type: 'SOMETHING_ELSE' };
+ const { dispatch, next, result } = runMiddleware(action, {
+ data_visualizations: { pendingVisualizations: {} },
+ });
+
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith(action);
+ expect(result).toBe(action);
+ });
+});
diff --git a/src/reducers/data_visualizations.test.js b/src/reducers/data_visualizations.test.js
new file mode 100644
index 0000000..feb1b24
--- /dev/null
+++ b/src/reducers/data_visualizations.test.js
@@ -0,0 +1,108 @@
+import data_visualizations from './data_visualizations';
+import {
+ GET_VISUALIZATION,
+ REMOVE_VISUALIZATION,
+} from '@eeacms/volto-plotlycharts/constants';
+
+const initialState = {
+ data: {},
+ error: null,
+ loaded: false,
+ loading: false,
+ pendingVisualizations: {},
+ failedVisualizations: {},
+ requested: [],
+};
+
+describe('data_visualizations reducer', () => {
+ it('should return the initial state by default', () => {
+ expect(data_visualizations(undefined, {})).toEqual(initialState);
+ });
+
+ it('should mark a path pending on GET_VISUALIZATION_PENDING', () => {
+ const state = data_visualizations(undefined, {
+ type: `${GET_VISUALIZATION}_PENDING`,
+ path: '/chart/@visualization',
+ });
+
+ expect(state.loading).toBe(true);
+ expect(state.loaded).toBe(false);
+ expect(state.error).toBe(null);
+ expect(state.requested).toEqual(['/chart']);
+ expect(state.pendingVisualizations).toEqual({ '/chart': true });
+ });
+
+ it('should clear a failed flag when a pending request restarts', () => {
+ const prev = {
+ ...initialState,
+ failedVisualizations: { '/chart': true },
+ };
+ const state = data_visualizations(prev, {
+ type: `${GET_VISUALIZATION}_PENDING`,
+ path: '/chart/@visualization',
+ });
+
+ expect(state.failedVisualizations).toEqual({});
+ });
+
+ it('should store the result on GET_VISUALIZATION_SUCCESS', () => {
+ const prev = {
+ ...initialState,
+ requested: ['/chart'],
+ pendingVisualizations: { '/chart': true },
+ };
+ const state = data_visualizations(prev, {
+ type: `${GET_VISUALIZATION}_SUCCESS`,
+ path: '/chart/@visualization',
+ result: { foo: 'bar' },
+ });
+
+ expect(state.loaded).toBe(true);
+ expect(state.loading).toBe(false);
+ expect(state.data).toEqual({ '/chart': { foo: 'bar' } });
+ expect(state.requested).toEqual([]);
+ expect(state.pendingVisualizations).toEqual({});
+ });
+
+ it('should record the error and failed flag on GET_VISUALIZATION_FAIL', () => {
+ const prev = {
+ ...initialState,
+ requested: ['/chart'],
+ pendingVisualizations: { '/chart': true },
+ };
+ const state = data_visualizations(prev, {
+ type: `${GET_VISUALIZATION}_FAIL`,
+ path: '/chart/@visualization',
+ error: 'boom',
+ });
+
+ expect(state.error).toBe('boom');
+ expect(state.loaded).toBe(false);
+ expect(state.loading).toBe(false);
+ expect(state.requested).toEqual([]);
+ expect(state.pendingVisualizations).toEqual({});
+ expect(state.failedVisualizations).toEqual({ '/chart': true });
+ });
+
+ it('should drop stored data on REMOVE_VISUALIZATION', () => {
+ const prev = {
+ ...initialState,
+ data: { '/chart': { foo: 'bar' }, '/other': { baz: 1 } },
+ };
+ const state = data_visualizations(prev, {
+ type: REMOVE_VISUALIZATION,
+ path: '/chart',
+ });
+
+ expect(state.data).toEqual({ '/other': { baz: 1 } });
+ });
+
+ it('should handle actions without a path', () => {
+ const state = data_visualizations(undefined, {
+ type: `${GET_VISUALIZATION}_PENDING`,
+ });
+
+ expect(state.requested).toEqual([undefined]);
+ expect(state.pendingVisualizations).toEqual({ undefined: true });
+ });
+});