Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
238 changes: 134 additions & 104 deletions src/PlotlyComponent/PlotlyComponent.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -446,65 +446,89 @@ function UnconnectedPlotlyComponent(props) {
data={toolbarData}
provider_metadata={provider_metadata}
/>

{llm_summary && (
<div
className="llm-summary"
style={{ display: 'none' }}
aria-hidden="true"
>
{llm_summary}
</div>
)}
</div>
);
}

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 (
<table className="embed-data-table">
<thead>
<tr>
{stableKeys.map((key) => (
<th key={key}>{key}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
{stableKeys.map((key) => (
<td key={key}>{row[key]}</td>
))}
</tr>
<>
{title && <p>{title}:</p>}
<ul className="embed-data-table embed-data-list">
{columns.map((column) => (
<li key={column.key}>
{column.key}: {column.text}
</li>
))}
</tbody>
</table>
</ul>
</>
);
}

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,
Expand All @@ -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 (
<div
className="figure-data-table"
className="figure-info"
data-for-chart-id={block}
style={{ display: 'none' }}
>
<h3 className="page-title">{content.title}</h3>
{!!layout?.title?.text && (
<h4 className="chart-title">{stripHtml(layout.title.text)}</h4>
<p className="chart-title">
Visualization title: {stripHtml(layout.title.text)}
</p>
)}
{!!layout?.title?.subtitle?.text && (
<h4 className="chart-sub-title">
{stripHtml(layout.title.subtitle.text)}
</h4>
{!!subtitle && subtitle !== layout?.yaxis?.title?.text && (
<p className="chart-sub-title">
{layout?.yaxis?.title?.text
? 'Subtitle: '
: 'Secondary label (shown as chart subtitle; may also serve as the y-axis title): '}
{subtitle}
</p>
)}
{!!llmSummary && <p className="llm-summary">Summary: {llmSummary}</p>}
{!!layout?.xaxis?.title?.text && (
<p className="x-axis-label">{stripHtml(layout.xaxis.title.text)}</p>
<p className="x-axis-label">
X axis title: {stripHtml(layout.xaxis.title.text)}
</p>
)}
{!!layout?.yaxis?.title?.text && (
<p className="y-axis-label">{stripHtml(layout.yaxis.title.text)}</p>
<p className="y-axis-label">
Y axis title: {stripHtml(layout.yaxis.title.text)}
</p>
)}

<Table rows={array} />
<p className="data-reading-note">
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.
</p>

{metadataFlags.hasDataProvenance && (
<>
<h4>Data Provenance</h4>
<Table rows={metadataArrays.data_provenance_array} />
</>
)}
{metadataFlags.hasOtherOrganisation && (
<>
<h4>Other Organisations</h4>
<Table rows={metadataArrays.other_organisation_array} />
</>
)}
{metadataFlags.hasTemporalCoverage && (
<>
<h4>Temporal Coverage</h4>
<Table rows={metadataArrays.temporal_coverage_array} />
</>
)}
{metadataFlags.hasGeoCoverage && (
<>
<h4>Geographical Coverage</h4>
<Table rows={metadataArrays.geo_coverage_array} />
</>
)}
{metadataFlags.hasPublisher && (
<>
<h4>Publisher</h4>
<Table rows={metadataArrays.publisher_array} />
</>
)}
<Table title="Data" rows={array} />

<Table
title="Data Provenance"
rows={metadataArrays.data_provenance_array}
/>
<Table
title="Other Organisations"
rows={metadataArrays.other_organisation_array}
/>
<Table
title="Temporal Coverage"
rows={metadataArrays.temporal_coverage_array}
/>
<Table
title="Geographical Coverage"
rows={metadataArrays.geo_coverage_array}
/>
<Table title="Publisher" rows={metadataArrays.publisher_array} />

<div>{readme}</div>
</div>
Expand Down
Loading