Added basics of LCPLegend and more controls for blocks. Added boilerplate for lcp/line-graph

This commit is contained in:
Jeremy Rangel
2025-01-22 02:34:10 -08:00
parent dd4bd0caf5
commit 23944b0052
29 changed files with 24323 additions and 126 deletions

View File

@ -1,11 +1,11 @@
import React, { useState, useEffect } from 'react';
import { Button, Icon, PanelBody, ToggleControl } from '@wordpress/components';
import { Button, Icon, PanelBody, ToggleControl, TextControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import LCPDimensionControl from './LCPDimensionControl';
import LCPHTMLModal from './LCPHTMLModal.js';
const LCPChartBlockSettings = ({ attributes, setAttributes }) => {
// Use `legendAlignment` from props (block attributes)
// Attributes from parent edit.js file
const { renderLegend,
legendLocation,
legendAlignment,
@ -18,38 +18,58 @@ const LCPChartBlockSettings = ({ attributes, setAttributes }) => {
footerContent,
chartTitle,
chartSubtitle,
toolbarLocation,
toolbarAlignment
toolbarLocation = 'top',
toolbarAlignment,
showXAxisLabel,
xAxisLabel,
showYAxisLabel,
yAxisLabel,
renderXGrid,
renderYGrid,
xGridColor,
yGridColor,
legendFontSize
} = attributes;
// Set local state for legend location (for UI updates)
// Set local state for legend location and alignment (for UI updates)
const [selectedLegendLocation, setSelectedLegendLocation] = useState(legendLocation);
// Update block attribute when location changes
useEffect(() => {
setAttributes({ legendLocation: selectedLegendLocation });
}, [selectedLegendLocation, setAttributes]);
// Set local state for alignment (for UI updates)
const [selectedLegendAlignment, setSelectedLegendAlignment] = useState(legendAlignment);
// Update block attribute when alignment changes
// Update block attributes when location or alignment changes
useEffect(() => {
setAttributes({ legendAlignment: selectedLegendAlignment });
}, [selectedLegendAlignment, setAttributes]);
setAttributes({ legendLocation: selectedLegendLocation, legendAlignment: selectedLegendAlignment });
}, [selectedLegendLocation, selectedLegendAlignment, setAttributes]);
// Set local state for alignment (for UI updates)
// Set local state for toolbar location and alignment (for UI updates)
const [selectedToolbarLocation, setSelectedToolbarLocation] = useState(toolbarLocation);
const [selectedToolbarAlignment, setSelectedToolbarAlignment] = useState(toolbarAlignment);
// Update block attribute when alignment changes
// Update block attributes when toolbar location or alignment changes
useEffect(() => {
setAttributes({ toolbarAlignment: selectedToolbarAlignment });
}, [selectedToolbarAlignment, setAttributes]);
setAttributes({ toolbarLocation: selectedToolbarLocation, toolbarAlignment: selectedToolbarAlignment });
}, [selectedToolbarLocation, selectedToolbarAlignment, setAttributes]);
// Render the component
return (
<div>
{/* Grid Settings Panel */}
<PanelBody title={__('Grid', 'lcp')} initialOpen={false}>
{/* Render X-Grid */}
<ToggleControl
label={__('Show X Grid', 'lcp')}
checked={renderXGrid}
onChange={(value) =>
setAttributes({ renderXGrid: value })}
/>
{/* Render Y-Grid */}
<ToggleControl
label={__('Show Y Grid', 'lcp')}
checked={renderYGrid}
onChange={(value) =>
setAttributes({ renderYGrid: value })}
/>
</PanelBody>
{/* Legend Settings Panel */}
<PanelBody title={__('Legend', 'lcp')} initialOpen={false}>
<ToggleControl
@ -103,15 +123,72 @@ const LCPChartBlockSettings = ({ attributes, setAttributes }) => {
>
{__('Bottom', 'lcp')}
</Button>
<LCPDimensionControl
unitTypes={['px']}
label={__('Legend Font Size', 'lcp')}
value={legendFontSize}
onChange={(value) => setAttributes({ legendFontSize: value })}
/>
</div>
</div>
)}
</PanelBody>
{/* Labels Settings Panel */}
<PanelBody title={__('Labels', 'lcp')} initialOpen={false}>
{/* Show X-Axis Label*/}
<ToggleControl
label={__('Show X-Axis', 'lcp')}
checked={showXAxisLabel}
onChange={(value) => setAttributes({ showXAxisLabel: value })}
/>
{/* X-Axis Label */}
<TextControl
label={__('X Axis Label', 'lcp')}
value={xAxisLabel}
onChange={(value) => setAttributes({ xAxisLabel: value })}
placeholder={__('Enter X-axis label...', 'lcp')}
/>
{/* Show Y-Axis */}
<ToggleControl
label={__('Show Y-Axis Label', 'lcp')}
checked={showYAxisLabel}
onChange={(value) => setAttributes({ showYAxisLabel: value })}
/>
{/* Y-Axis Label */}
<TextControl
label={__('Y Axis Label', 'lcp')}
value={yAxisLabel}
onChange={(value) => setAttributes({ xAxisLabel: value })}
placeholder={__('Enter X-axis label', 'lcp')}
/>
</PanelBody>
{/* Controls Settings Panel */}
<PanelBody title={__('Controls', 'lcp')} initialOpen={false}>
<div>
{/* Top Location Button */}
<Button
isPrimary={selectedToolbarLocation === 'top'}
onClick={() => setSelectedToolbarLocation('top')}
>
{__('Top', 'lcp')}
</Button>
{/* Bottom Location Button */}
<Button
isPrimary={selectedToolbarLocation === 'bottom'}
onClick={() => setSelectedToolbarLocation('bottom')}
>
{__('Bottom', 'lcp')}
</Button>
<LCPDimensionControl
unitTypes={['px']}
label={__('Legend Font Size', 'lcp')}
value={legendFontSize}
onChange={(value) => setAttributes({ legendFontSize: value })}
/>
</div>
<div>
{/* Left Alignment Button */}
<Button
@ -172,6 +249,7 @@ const LCPChartBlockSettings = ({ attributes, setAttributes }) => {
onChange={(value) => setAttributes({ allowDownloadJson: value })}
/>
</PanelBody>
{/* Tooltips and Popups Panel */}
<PanelBody title={__('Tooltips and Popups', 'lcp')} initialOpen={false}>
<LCPHTMLModal />

View File

@ -379,37 +379,51 @@ const LCPDataGrid = ({
event.preventDefault();
const target = event.target;
const rowCell = target.closest('.ag-cell');
const headerCell = target.closest('.ag-header-cell');
if (!rowCell) return;
const rect = rowCell.getBoundingClientRect();
const rowNode = gridRef.current.api.getRowNode(rowCell.parentElement.getAttribute('row-index'));
if (!rowNode || !rowNode.data || !rowNode.data.ID) return;
setContextMenu({
type: 'row',
position: { top: rect.bottom, left: rect.left },
rowIndex: rowNode.rowIndex,
totalRows: gridRef.current.api.getDisplayedRowCount()
});
if (rowCell) {
const rect = rowCell.getBoundingClientRect();
const rowNode = gridRef.current.api.getRowNode(rowCell.parentElement.getAttribute('row-index'));
if (!rowNode || !rowNode.data || !rowNode.data.ID) return;
setContextMenu({
type: 'row',
position: {
top: event.clientY,
left: event.clientX,
},
rowIndex: rowNode.rowIndex,
totalRows: gridRef.current.api.getDisplayedRowCount()
});
} else if (headerCell) {
const rect = headerCell.getBoundingClientRect();
const colId = headerCell.getAttribute('col-id');
if (!colId) return;
setContextMenu({
type: 'column',
position: {
top: event.clientY,
left: event.clientX,
},
colId: colId
});
}
}, []);
const handleRowAction = useCallback((action, rowIndex) => {
const handleRowAction = useCallback((action) => {
const newData = [...chartData];
const rowIndex = contextMenu.rowIndex;
// Create empty row with all existing columns
const emptyRow = { ID: generateId() };
if (chartData.length > 0) {
// Get all columns from existing data
const columns = Array.from(new Set(
chartData.flatMap(item => Object.keys(item))
));
// Initialize each column with an appropriate empty value
columns.forEach(col => {
if (col === 'ID') return; // Skip Id as it's already set
// Get a sample value to determine appropriate empty value
if (col === 'ID') return;
const sampleValue = chartData.find(item => item[col] !== undefined)?.[col];
switch (typeof sampleValue) {
@ -427,19 +441,59 @@ const LCPDataGrid = ({
switch (action) {
case 'add-above':
newData.splice(rowIndex - 1, 0, emptyRow);
break;
case 'add-below':
newData.splice(rowIndex, 0, emptyRow);
break;
case 'add-below':
newData.splice(rowIndex + 1, 0, emptyRow);
break;
case 'delete':
newData.splice(rowIndex - 1, 1);
newData.splice(rowIndex, 1);
break;
}
onDataChange(newData);
setContextMenu(null);
}, [chartData, onDataChange]);
}, [chartData, onDataChange, contextMenu]);
const handleColumnAction = useCallback((action) => {
if (!contextMenu || !contextMenu.colId) return;
const newData = [...chartData];
const colId = contextMenu.colId;
switch (action) {
case 'delete':
// Remove the column from all rows
newData.forEach(row => {
const { [colId]: removed, ...rest } = row;
Object.assign(row, rest);
});
break;
case 'add-left':
case 'add-right':
// Get all existing column names
const existingColumns = Object.keys(chartData[0] || {});
// Find the next available column number
let nextNumber = 1;
while (existingColumns.includes(`Column${nextNumber}`)) {
nextNumber++;
}
const newColId = `Column${nextNumber}`;
const insertIndex = columnDefs.findIndex(col => col.field === colId);
const finalIndex = action === 'add-right' ? insertIndex + 1 : insertIndex;
// Add the new column to all rows
newData.forEach(row => {
row[newColId] = '';
});
break;
}
onDataChange(newData);
setContextMenu(null);
}, [chartData, onDataChange, contextMenu, columnDefs]);
const handleCellValueChanged = useCallback((params) => {
if (!params.data || !params.data.ID) return;
@ -451,8 +505,15 @@ const LCPDataGrid = ({
onDataChange(newData);
}, [chartData, onDataChange]);
return (
<div className="ag-theme-alpine" style={{ height: '400px', width: '100%' }}>
<div
className="ag-theme-alpine"
style={{ height: '400px', width: '100%' }}
onContextMenu={handleContextMenu}
>
<AgGridReact
ref={gridRef}
rowData={rowData}
@ -472,24 +533,57 @@ const LCPDataGrid = ({
position="bottom left"
onClose={() => setContextMenu(null)}
anchorRect={{
top: contextMenu.mouseEvent.clientY,
left: contextMenu.mouseEvent.clientX,
top: contextMenu.position.top,
left: contextMenu.position.left,
width: 0,
height: 0,
}}
>
<div style={{ padding: '8px' }}>
<Button
variant="secondary"
onClick={() => {
const selectedRows = gridRef.current.api.getSelectedRows();
const newData = chartData.filter(row => !selectedRows.includes(row));
onDataChange(newData);
setContextMenu(null);
}}
>
{__('Delete Selected Rows', 'lcp-visualize')}
</Button>
<div style={{ padding: '8px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
{contextMenu.type === 'row' && (
<>
<Button
variant="secondary"
onClick={() => handleRowAction('add-above')}
>
{__('Add Row Above', 'lcp-visualize')}
</Button>
<Button
variant="secondary"
onClick={() => handleRowAction('add-below')}
>
{__('Add Row Below', 'lcp-visualize')}
</Button>
<Button
variant="secondary"
onClick={() => handleRowAction('delete')}
>
{__('Delete Row', 'lcp-visualize')}
</Button>
</>
)}
{contextMenu.type === 'column' && (
<>
<Button
variant="secondary"
onClick={() => handleColumnAction('add-left')}
>
{__('Add Column Left', 'lcp-visualize')}
</Button>
<Button
variant="secondary"
onClick={() => handleColumnAction('add-right')}
>
{__('Add Column Right', 'lcp-visualize')}
</Button>
<Button
variant="secondary"
onClick={() => handleColumnAction('delete')}
>
{__('Delete Column', 'lcp-visualize')}
</Button>
</>
)}
</div>
</Popover>
)}

View File

@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import Papa from 'papaparse';
const LCPDataUploader = ({ onJsonDataUpdate }) => {
const [error, setError] = useState(null);
const [csvFile, setCsvFile] = useState(null);
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
setCsvFile(file);
// Clear any previous errors
setError(null);
// Log the file info for debugging
console.log('Selected file:', file);
// Parse the CSV using PapaParse
Papa.parse(file, {
complete: (result) => {
console.log('CSV parsing result:', result); // Log the result object
try {
const jsonData = convertCsvToJson(result.data);
console.log('Converted JSON:', jsonData); // Log the converted JSON data
onJsonDataUpdate(jsonData); // Pass the parsed JSON data to the parent component
} catch (parseError) {
console.error('Error during CSV conversion:', parseError);
setError('Error during CSV conversion');
}
},
error: (error) => {
console.error('Error parsing CSV:', error);
setError('Error parsing CSV file');
},
});
};
// Helper function to convert CSV rows into JSON format
const convertCsvToJson = (csvData) => {
console.log('Converting CSV to JSON. Raw CSV data:', csvData);
// Get the headers from the first row of the CSV data
const headers = csvData[0];
// Define a new array to hold the processed JSON
const jsonData = [];
// Process the rows (skip the first row as it contains headers)
csvData.slice(1).forEach((row, index) => {
const obj = {};
// Map each column in the row to an object key based on the headers
headers.forEach((header, headerIndex) => {
obj[header] = row[headerIndex] || ''; // Set the value, default to an empty string if not available
});
// Add a unique ID to each row (optional, just to ensure uniqueness)
obj.ID = `lcpDatapoint-${index}`;
console.log('Processed row:', obj); // Log each row's conversion
jsonData.push(obj);
});
console.log('Final JSON Data:', jsonData);
return jsonData;
};
return (
<div>
<Button isPrimary onClick={() => document.getElementById('csv-upload-input').click()}>
{__('Upload CSV', 'lcp')}
</Button>
<input
type="file"
id="csv-upload-input"
style={{ display: 'none' }}
accept=".csv"
onChange={handleFileUpload}
/>
{error && <p style={{ color: 'red' }}>{error}</p>}
{csvFile && <p>{__('File selected: ', 'lcp')}{csvFile.name}</p>}
</div>
);
};
export default LCPDataUploader;

View File

@ -1,12 +1,19 @@
import { useState, useEffect, useMemo } from '@wordpress/element';
import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
import { Button, Modal, SelectControl, ToggleControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import LCPDataGrid from './LCPDataGrid';
import LCPDataUploader from './LCPDataUploader';
const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes, chartType }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState([]);
// Function to update chartData attribute with parsed JSON data from CSV
const handleJsonDataUpdate = (newData) => {
console.log('Received new data from CSV:', newData);
setAttributes({ chartData: newData });
};
useEffect(() => {
if (chartData && chartData.length > 0) {
const columns = Object.keys(chartData[0]).filter(key => key !== 'lcpId');
@ -42,9 +49,11 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
onChange(newData);
};
const downloadCSV = () => {
// Get all columns except lcpId
const columns = getAvailableColumns().map(col => col.value);
const downloadCSV = useCallback(() => {
if (!chartData || chartData.length === 0) return;
// Get all columns except internal ones
const columns = Object.keys(chartData[0]).filter(key => !['ID', 'Parent'].includes(key));
// Create CSV header
const header = columns.join(',');
@ -53,14 +62,18 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
const rows = chartData.map(row =>
columns.map(col => {
let value = row[col] || '';
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
// Handle special characters in CSV
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
value = `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
).join('\n');
// Combine header and rows
const csv = `${header}\n${rows}`;
// Create and trigger download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
@ -70,7 +83,7 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
}, [chartData]);
// Get default value for valueColumn
const defaultValueColumn = useMemo(() => {
@ -163,6 +176,7 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
options={options}
onChange={(value) => setAttributes({ labelsColumn: value })}
/>
<LCPDataUploader onJsonDataUpdate={handleJsonDataUpdate} />
<SelectControl
label={__('Color Column', 'lcp-visualize')}
help={__('Column containing bar colors (optional)', 'lcp-visualize')}

View File

@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from '@wordpress/element';
import * as d3 from 'd3';
import PropTypes from 'prop-types';
const LCPLegend = ({
items = [],
itemSize = 20,
spacing = 5,
startX = 20,
startY = 20,
fontFamily = 'Arial',
fontSize = 14
}) => {
const svgRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
// Update the container's width when it changes
useEffect(() => {
if (svgRef.current) {
setContainerWidth(svgRef.current.clientWidth);
}
}, [svgRef.current]);
useEffect(() => {
if (!items.length || !svgRef.current || containerWidth === 0) return;
// Clear any existing content
d3.select(svgRef.current).selectAll("*").remove();
// Variables for item dimensions and row management
const rowHeight = itemSize + spacing;
let currentX = 0;
let currentY = 0;
// Calculate the maximum number of items that can fit per row
const itemWidth = itemSize + spacing + fontSize * 0.6; // Item width (rect + label)
const itemsPerRow = Math.floor(containerWidth / itemWidth); // Items per row based on container width
// Create the SVG container
const svg = d3.select(svgRef.current)
.attr('width', '100%')
.style('height', null) // Let height be dynamic
.style('overflow', 'visible');
// Create the legend items group
const legend = svg.append('g')
.attr('class', 'legend-group')
.attr('transform', `translate(${startX}, ${startY})`);
// Loop over items to position them
items.forEach((item, i) => {
// Calculate the width of each item (rect + label)
const labelWidth = item.Label.length * fontSize * 0.6; // Approximate label width
const itemWidth = itemSize + spacing + labelWidth; // Total width of the item (rect + label)
// If the item doesn't fit in the current row, move to the next row
if (i % itemsPerRow === 0 && i !== 0) {
currentX = 0; // Reset X position
currentY += rowHeight; // Move down to next row
}
// Create the rectangle
legend.append('rect')
.attr('x', currentX)
.attr('y', currentY)
.attr('width', itemSize)
.attr('height', itemSize)
.style('fill', item.color || '#cccccc');
// Create the label
legend.append('text')
.attr('x', currentX + itemSize + spacing)
.attr('y', currentY + itemSize / 2)
.text(item.Label || '')
.style('font-family', fontFamily)
.style('font-size', `${fontSize}px`)
.style('dominant-baseline', 'middle')
.style('fill', '#333333');
// Update the current X position for the next item
currentX += itemWidth;
});
// Calculate the height of the SVG element based on the number of rows
const totalRows = Math.ceil(items.length / itemsPerRow);
const calculatedHeight = totalRows * rowHeight + startY * 2;
// Set the correct height based on totalRows
svg.attr('height', calculatedHeight);
}, [items, containerWidth, itemSize, spacing, startX, startY, fontFamily, fontSize]);
return (
<svg
ref={svgRef}
className="lcp-legend"
style={{
width: '100%' // Ensure width is 100% of the parent container
}}
/>
);
};
LCPLegend.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
Label: PropTypes.string.isRequired,
color: PropTypes.string.isRequired
})),
itemSize: PropTypes.number,
spacing: PropTypes.number,
startX: PropTypes.number,
startY: PropTypes.number,
fontFamily: PropTypes.string,
fontSize: PropTypes.number
};
export default LCPLegend;