Refactored LCPDataGrid

This commit is contained in:
Jeremy Rangel
2025-01-24 03:06:04 -08:00
parent 23944b0052
commit be3a4fb9ff
12 changed files with 427 additions and 689 deletions

View File

@ -13,14 +13,7 @@
"attributes": { "attributes": {
"columnTypes": { "columnTypes": {
"type": "object", "type": "object",
"default": { "default": {}
"ID": "lcpText",
"Label": "lcpText",
"Value": "lcpNumber",
"Color": "lcpColor",
"Content": "lcpText",
"Parent": "lcpText"
}
}, },
"enableStackedBars": { "enableStackedBars": {
"type": "boolean", "type": "boolean",
@ -67,6 +60,14 @@
} }
] ]
}, },
"enableGroupedBars": {
"type": "boolean",
"default": false
},
"groupedBarsColumn": {
"type": "string",
"default": ""
},
"chartHeight": { "chartHeight": {
"type": "string", "type": "string",
"default": "400px" "default": "400px"
@ -206,6 +207,14 @@
"yGridColor": { "yGridColor": {
"type": "string", "type": "string",
"default": "#e0e0e0" "default": "#e0e0e0"
},
"includeDataChart": {
"type": "boolean",
"default": false
},
"allowChartDataDownload": {
"type": "boolean",
"default": false
} }
}, },
"textdomain": "lcp-visualize", "textdomain": "lcp-visualize",

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '97d348e4919de980ad6b'); <?php return array('dependencies' => array('react', 'react-dom', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => 'a5fc7ad18ecfe8ed81b6');

File diff suppressed because one or more lines are too long

View File

@ -14,12 +14,7 @@
"columnTypes": { "columnTypes": {
"type": "object", "type": "object",
"default": { "default": {
"ID": "lcpText",
"Label": "lcpText",
"Value": "lcpNumber",
"Color": "lcpColor",
"Content": "lcpText",
"Parent": "lcpText"
} }
}, },
"enableStackedBars": { "enableStackedBars": {
@ -53,6 +48,14 @@
{ "ID": "lcpDatapoint-2", "Label": "Sample 2", "Value": 50, "Color": "#ff0000", "Content": "<p>Second item</p>", "Parent": "lcpDatapoint-1" } { "ID": "lcpDatapoint-2", "Label": "Sample 2", "Value": 50, "Color": "#ff0000", "Content": "<p>Second item</p>", "Parent": "lcpDatapoint-1" }
] ]
}, },
"enableGroupedBars": {
"type": "boolean",
"default": false
},
"groupedBarsColumn": {
"type": "string",
"default": ""
},
"chartHeight": { "chartHeight": {
"type": "string", "type": "string",
"default": "400px" "default": "400px"
@ -192,6 +195,14 @@
"yGridColor" :{ "yGridColor" :{
"type": "string", "type": "string",
"default": "#e0e0e0" "default": "#e0e0e0"
},
"includeDataChart" :{
"type": "boolean",
"default": false
},
"allowChartDataDownload" :{
"type": "boolean",
"default": false
} }
}, },
"textdomain": "lcp-visualize", "textdomain": "lcp-visualize",

View File

@ -18,34 +18,13 @@ export default function Edit({ attributes, setAttributes }) {
{ Label: "Category 1", color: "#ff0000" }, { Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" }, { Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" }, { Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" }, { Label: "Categokhkjhkjhkjhky 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" }, { Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" }, { Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" }, { Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" }, { Label: "Cat 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" }, { Label: "Coy 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 1", color: "#ff0000" },
{ Label: "Category 2", color: "#00ff00" },
{ Label: "Category 3", color: "#0000ff" }
]; ];
const { const {
@ -64,7 +43,9 @@ export default function Edit({ attributes, setAttributes }) {
hierarchicalSource = 'parent', hierarchicalSource = 'parent',
hierarchicalColumnOrder = '', hierarchicalColumnOrder = '',
renderLegend = false, renderLegend = false,
toolbarLocation = 'top' toolbarLocation = 'top',
enableGroupedBars = false,
groupedBarsColumn = ''
} = attributes; } = attributes;
const blockProps = useBlockProps(); const blockProps = useBlockProps();

View File

@ -28,7 +28,9 @@ const LCPChartBlockSettings = ({ attributes, setAttributes }) => {
renderYGrid, renderYGrid,
xGridColor, xGridColor,
yGridColor, yGridColor,
legendFontSize legendFontSize,
includeDataChart,
allowChartDataDownload
} = attributes; } = attributes;
// Set local state for legend location and alignment (for UI updates) // Set local state for legend location and alignment (for UI updates)
@ -53,7 +55,24 @@ const LCPChartBlockSettings = ({ attributes, setAttributes }) => {
// Render the component // Render the component
return ( return (
<div> <div>
{/* Grid Settings Panel */} {/* Front-end Chart Settings */}
<PanelBody title={__('Grid', 'lcp')} initialOpen={false}>
{/* Render the chart on the front-end */}
<ToggleControl
label={__('Include Data Chart', 'lcp')}
checked={includeDataChart}
onChange={(value) =>
setAttributes({ includeDataChart: value })}
/>
{/* Allow chart data download */}
<ToggleControl
label={__('Allow Chart Data Download', 'lcp')}
checked={allowChartDataDownload}
onChange={(value) =>
setAttributes({ allowChartDataDownload: value })}
/>
</PanelBody>
{/* Grid Settings Panel */}
<PanelBody title={__('Grid', 'lcp')} initialOpen={false}> <PanelBody title={__('Grid', 'lcp')} initialOpen={false}>
{/* Render X-Grid */} {/* Render X-Grid */}
<ToggleControl <ToggleControl

View File

@ -1,594 +1,179 @@
import { useMemo, useCallback, useRef, useState } from '@wordpress/element'; import { useState, useRef, useEffect } from '@wordpress/element';
import { Popover, Button, Modal, SelectControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css'; import 'ag-grid-community/styles/ag-theme-alpine.css';
const generateId = () => { const LCPDataGrid = () => {
const randomStr = Math.random().toString(36).substring(2, 8); const gridData = [
return `lcpDatapoint-${randomStr}`; { Department2: 'Sheriffs Office 2', Budget: '150000' },
}; { Department2: 'Treasurer2', Budget: '10000' },
{ Department2: 'Assessor2', Budget: '40000' },
];
// Helper function to determine text color based on background // Helper function to convert index to letters, accounting for 'A1', 'B1', 'C1', etc.
const getContrastColor = (hexcolor) => { const getColumnLabel = (index) => {
if (!hexcolor) return 'inherit'; const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const r = parseInt(hexcolor.slice(1, 3), 16); let label = '';
const g = parseInt(hexcolor.slice(3, 5), 16);
const b = parseInt(hexcolor.slice(5, 7), 16);
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
return (yiq >= 128) ? 'black' : 'white';
};
const ColorCellRenderer = (props) => { while (index >= 0) {
if (!props.value) return ''; label = alphabet[index % 26] + label;
return ( index = Math.floor(index / 26) - 1;
<div }
style={{
backgroundColor: props.value,
color: getContrastColor(props.value),
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4px',
borderRadius: '4px'
}}
>
{props.value}
</div>
);
};
return label;
};
const ParentCellRenderer = (props) => { function lcpDataTypeParser(params) {
if (!props.data || !props.data.ID) { if (params.node.rowPinned) {
return null; return params.newValue;
} else {
return Number(params.newValue);
}
} }
// Create options from all available rows except current // Create columnDefs dynamically
const options = [ const [columnDefs, setColumnDefs] = useState([]);
{ label: __('None', 'lcp-visualize'), value: '' } const [pinnedTopRowData, setPinnedTopRowData] = useState([]);
];
useEffect(() => {
// Get all rows from the grid if (columnDefs.length > 0) return; // Prevent rerun if columnDefs already set
props.api.forEachNode(node => {
if (node.data && if (gridData.length > 0) {
node.data.ID && const keys = Object.keys(gridData[0]); // Get the keys from the first row (Department2, Budget, etc.)
node.data.ID !== props.data.ID) {
options.push({ const columns = keys.map((key, index) => {
label: node.data.Label || node.data.ID, return {
value: node.data.ID headerName: getColumnLabel(index), // 'A', 'B', 'C', ...
field: key, // Field will match the key (Department2, Budget, etc.)
valueParser: lcpDataTypeParser,
};
}); });
}
});
const handleChange = (newValue) => { // Set the pinned top row data with the actual field names (like 'Department2', 'Budget')
if (props.context && props.context.handleParentChange) { const topRowData = keys.reduce((acc, key) => {
props.context.handleParentChange(props.data, newValue); acc[key] = key; // Set key as the value in top row (use field names)
} return acc;
}; }, {});
return ( // Ensure pinnedTopRowData is set with correct data
<SelectControl setPinnedTopRowData([topRowData]);
value={props.data.Parent || ''}
options={options}
onChange={handleChange}
/>
);
};
// Add the row number column as the first column
const rowNumberColumn = {
headerName: '',
const ColumnHeaderComponent = (props) => { valueGetter: (params) => {
const [isModalOpen, setIsModalOpen] = useState(false); const rowIndex = params.node.rowPinned ? params.node.rowIndex + 1 : params.node.rowIndex + 2;
const [headerName, setHeaderName] = useState(props.displayName); return rowIndex;
const dataTypeOptions = [
{ label: __('Text', 'lcp'), value: 'lcpText' },
{ label: __('Number', 'lcp'), value: 'lcpNumber' },
{ label: __('Color', 'lcp'), value: 'lcpColor' },
{ label: __('Date/Time', 'lcp'), value: 'lcpDate' }
];
const handleTypeChange = (newType) => {
props.setColumnType(props.column.colId, newType);
};
const handleHeaderChange = (newHeader) => {
if (props.onHeaderChange && newHeader !== props.displayName) {
props.onHeaderChange(props.column.colId, newHeader);
}
};
const handleSave = () => {
handleHeaderChange(headerName);
setIsModalOpen(false);
};
const handleCancel = () => {
setHeaderName(props.displayName);
setIsModalOpen(false);
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', height: '100%' }}>
<span>{props.displayName}</span>
<Button
isSmall
variant="tertiary"
onClick={() => setIsModalOpen(true)}
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
style={{ fill: 'currentColor' }}
>
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
}
/>
{isModalOpen && (
<Modal
title={__('Column Settings', 'lcp')}
onRequestClose={handleCancel}
style={{ width: '400px' }}
>
<div style={{ padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px' }}>
{__('Column Header', 'lcp')}
</label>
<input
type="text"
value={headerName}
onChange={(e) => setHeaderName(e.target.value)}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid #757575'
}}
disabled={props.column.colId === 'ID'}
/>
</div>
<SelectControl
label={__('Data Type', 'lcp')}
value={props.currentType || 'lcpText'}
options={dataTypeOptions}
onChange={handleTypeChange}
disabled={props.column.colId === 'ID'}
/>
<div style={{ marginTop: '20px', display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<Button
variant="secondary"
onClick={handleCancel}
>
{__('Cancel', 'lcp')}
</Button>
<Button
variant="primary"
onClick={handleSave}
>
{__('Save', 'lcp-visualize')}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
};
const LCPDataGrid = ({
chartData = [],
onDataChange,
columnTypes,
onColumnTypeChange,
attributes,
setAttributes
}) => {
const gridRef = useRef();
const [contextMenu, setContextMenu] = useState(null);
// Helper function to guess column type based on value
const guessColumnType = useCallback((value) => {
if (typeof value === 'number') return 'lcpNumber';
if (typeof value === 'string') {
if (/^#([0-9A-F]{3}){1,2}$/i.test(value)) return 'lcpColor';
if (!isNaN(Date.parse(value))) return 'lcpDate';
}
return 'lcpText';
}, []);
// Handle parent change
const handleParentChange = useCallback((data, newParent) => {
const newData = chartData.map(item =>
item.ID === data.ID ? { ...item, Parent: newParent } : item
);
onDataChange(newData);
}, [chartData, onDataChange]);
// Use columnTypes from attributes if available, otherwise use direct columnTypes prop
const effectiveColumnTypes = attributes?.columnTypes || columnTypes || {};
// Convert any old column types to new format
const migratedColumnTypes = useMemo(() => {
const typeMap = {
'textColumn': 'lcpText',
'numericColumn': 'lcpNumber',
'colorColumn': 'lcpColor',
'dateColumn': 'lcpDate'
};
return Object.entries(effectiveColumnTypes).reduce((acc, [key, value]) => {
acc[key] = typeMap[value] || value;
return acc;
}, {});
}, [effectiveColumnTypes]);
const handleColumnTypeChange = useCallback((colId, newType) => {
if (setAttributes) {
setAttributes({
columnTypes: {
...migratedColumnTypes,
[colId]: newType
}
});
} else if (onColumnTypeChange) {
onColumnTypeChange(colId, newType);
}
}, [migratedColumnTypes, setAttributes, onColumnTypeChange]);
// Define column type configurations
const gridColumnTypes = useMemo(() => ({
lcpNumber: {
filter: 'agNumberColumnFilter',
filterParams: {
buttons: ['apply', 'reset'],
closeOnApply: true
},
valueParser: params => {
if (!params.newValue) return '';
const parsed = parseFloat(params.newValue);
return isNaN(parsed) ? '' : parsed;
}
},
lcpText: {
filter: 'agTextColumnFilter',
filterParams: {
buttons: ['apply', 'reset'],
closeOnApply: true
}
},
lcpColor: {
filter: 'agTextColumnFilter',
filterParams: {
buttons: ['apply', 'reset'],
closeOnApply: true
},
cellRenderer: ColorCellRenderer,
valueParser: params => {
if (!params.newValue) return '';
return /^#([0-9A-F]{3}){1,2}$/i.test(params.newValue) ?
params.newValue.toUpperCase() : '';
}
},
lcpDate: {
filter: 'agDateColumnFilter',
filterParams: {
buttons: ['apply', 'reset'],
closeOnApply: true
},
valueParser: params => {
if (!params.newValue) return '';
const date = new Date(params.newValue);
return isNaN(date.getTime()) ? '' : date.toISOString();
}
}
}), []);
const handleHeaderChange = useCallback((oldHeader, newHeader) => {
if (oldHeader === newHeader || !chartData.length) return;
// Update the chartData with the new header
const newData = chartData.map(row => {
const { [oldHeader]: value, ...rest } = row;
return {
...rest,
[newHeader]: value
};
});
onDataChange(newData);
}, [chartData, onDataChange]);
// Generate column definitions based on chartData
const columnDefs = useMemo(() => {
if (!chartData.length) return [];
// Get all unique keys from the data
const allKeys = Array.from(new Set(
chartData.flatMap(item => Object.keys(item))
)).filter(key => key !== 'Parent'); // Remove Parent from regular columns
// Create column definitions for regular columns
const regularColumns = allKeys.map(key => {
const sampleValue = chartData.find(item => item[key] !== undefined)?.[key];
const defaultType = guessColumnType(sampleValue);
const currentType = migratedColumnTypes[key] || defaultType;
return {
field: key,
headerName: key,
hide: key === 'ID',
headerComponent: ColumnHeaderComponent,
headerComponentParams: {
displayName: key,
onHeaderChange: handleHeaderChange,
setColumnType: handleColumnTypeChange,
currentType: currentType
}, },
type: currentType, suppressMovable: true,
editable: key !== 'ID', editable: false,
sortable: true, filter: false,
filter: true sortable: false,
width: 45,
resizable: false,
flex: 0,
cellStyle: { backgroundColor: '#e0e0e0' },
}; };
});
// Add Parent column with ParentCellRenderer setColumnDefs([rowNumberColumn, ...columns]); // Set columns along with row number column
const parentColumn = { }
field: 'Parent', }, [gridData, columnDefs]); // R
headerName: __('Parent', 'lcp-visualize'),
cellRenderer: ParentCellRenderer,
cellRendererParams: {
context: {
handleParentChange
}
},
editable: false,
sortable: true,
filter: true,
minWidth: 200
};
return [...regularColumns, parentColumn]; const [rowData] = useState(gridData);
}, [chartData, migratedColumnTypes, handleColumnTypeChange, handleHeaderChange, guessColumnType, handleParentChange]);
const defaultColDef = useMemo(() => ({ // AG Grid instance reference
const gridRef = useRef(null);
const [gridApi, setGridApi] = useState(null); // Store grid API in state
// Button click handler to add a new row
const addRow = () => {
if (gridApi) {
const newRow = {}; // Empty Row
// Use the grid API to add the new row
gridApi.applyTransaction({ add: [newRow] });
} else {
console.log('Grid is not ready yet');
}
};
// Default Column Properties
const defaultColDef = {
flex: 1, flex: 1,
minWidth: 100, minWidth: 45,
editable: true, editable: (params) => {
if (params.node.rowPinned) {
return false;
} else {
return true;
}
},
sortable: true, sortable: true,
filter: true, filter: true,
resizable: true suppressMovable: true,
}), []); cellDataType: false, //By default column can be any data type
cellStyle: (params) => {
const rowData = useMemo(() => { if (params.node.rowPinned) {
return chartData.map(point => ({ return { backgroundColor: 'rgb(197, 219, 229)' };
...point, }
ID: point.ID || generateId()
}));
}, [chartData]);
const handleContextMenu = useCallback((event) => {
event.preventDefault();
const target = event.target;
const rowCell = target.closest('.ag-cell');
const headerCell = target.closest('.ag-header-cell');
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) => { const gridOptions = {
const newData = [...chartData]; rowDragManaged: true,
const rowIndex = contextMenu.rowIndex; animateRows: true,
rowHeight: 35,
// Create empty row with all existing columns getRowStyle: (params) => {
const emptyRow = { ID: generateId() }; if (params.node.rowPinned) {
if (chartData.length > 0) { return { backgroundColor: 'rgb(197, 219, 229)' }; // Light gray for pinned rows
const columns = Array.from(new Set( }
chartData.flatMap(item => Object.keys(item)) return null; // No special style for non-pinned rows
));
columns.forEach(col => {
if (col === 'ID') return;
const sampleValue = chartData.find(item => item[col] !== undefined)?.[col];
switch (typeof sampleValue) {
case 'number':
emptyRow[col] = 0;
break;
case 'boolean':
emptyRow[col] = false;
break;
default:
emptyRow[col] = '';
}
});
} }
};
switch (action) { const onGridReady = (params) => {
case 'add-above': // Store the grid API when the grid is ready
newData.splice(rowIndex, 0, emptyRow); setGridApi(params.api);
break; };
case 'add-below':
newData.splice(rowIndex + 1, 0, emptyRow);
break;
case 'delete':
newData.splice(rowIndex, 1);
break;
}
onDataChange(newData); // Reset row number on sorted
setContextMenu(null); const onSortChanged = (e) => {
}, [chartData, onDataChange, contextMenu]); e.api.refreshCells();
};
const handleColumnAction = useCallback((action) => { // Reset row number on filtered
if (!contextMenu || !contextMenu.colId) return; const onFilterChanged = (e) => {
e.api.refreshCells();
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;
const newData = chartData.map(item =>
item.ID === params.data.ID ? params.data : item
);
onDataChange(newData);
}, [chartData, onDataChange]);
return ( return (
<div <div>
className="ag-theme-alpine" <div
style={{ height: '400px', width: '100%' }} id="lcp-data-grid"
onContextMenu={handleContextMenu} className="ag-theme-alpine"
> style={{ width: '100%', height: '300px' }}
>
<AgGridReact
ref={gridRef}
columnDefs={columnDefs}
rowData={rowData}
defaultColDef={defaultColDef}
gridOptions={gridOptions}
pinnedTopRowData={pinnedTopRowData}
pinnedLeftColCount={1}
onSortChanged={onSortChanged}
onFilterChanged={onFilterChanged}
onGridReady={onGridReady}
/>
</div>
<AgGridReact {/* Button outside the grid */}
ref={gridRef} <div>
rowData={rowData} <button onClick={addRow}>Add Row</button>
columnDefs={columnDefs} </div>
defaultColDef={defaultColDef}
columnTypes={gridColumnTypes}
onCellValueChanged={handleCellValueChanged}
suppressRowClickSelection={true}
rowSelection="multiple"
animateRows={true}
context={{
handleParentChange
}}
/>
{contextMenu && (
<Popover
position="bottom left"
onClose={() => setContextMenu(null)}
anchorRect={{
top: contextMenu.position.top,
left: contextMenu.position.left,
width: 0,
height: 0,
}}
>
<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>
)}
</div> </div>
); );
}; };
export default LCPDataGrid; export default LCPDataGrid;

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from '@wordpress/element'; import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
import { Button, Modal, SelectControl, ToggleControl } from '@wordpress/components'; import { TextControl, Button, Modal, SelectControl, ToggleControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import LCPDataGrid from './LCPDataGrid'; import LCPDataGrid from './LCPDataGrid';
import LCPDataUploader from './LCPDataUploader'; import LCPDataUploader from './LCPDataUploader';
@ -8,16 +8,133 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
// List of CSS color names
const CSS_COLOR_NAMES = [
'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black',
'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse',
'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan',
'darkgoldenrod', 'darkgray', 'darkgreen', 'darkkhaki', 'darkmagenta', 'darkolivegreen',
'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue',
'darkslategray', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite',
'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'honeydew', 'hotpink', 'indianred',
'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon',
'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen',
'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightsteelblue',
'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue',
'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen',
'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin',
'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid',
'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff',
'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue',
'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue',
'slateblue', 'slategray', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle',
'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen'
];
// Helper function to check if a string is a valid hex color
const isHexColor = (str) => {
return /^#([0-9A-F]{3}){1,2}$/i.test(str) || /^#[0-9A-F]{6}$/i.test(str);
};
// Helper function to check if a string is a valid color name
const isColorName = (str) => {
return CSS_COLOR_NAMES.includes(str.toLowerCase());
};
// Helper function to guess column type based on value
const guessColumnType = useCallback((value) => {
if (value === null || value === undefined) return 'text';
// Handle numeric values
if (typeof value === 'number') return 'number';
if (typeof value === 'string') {
const trimmedValue = value.trim();
// Try parsing as number first
if (!isNaN(trimmedValue) && !isNaN(parseFloat(trimmedValue))) {
return 'number';
}
// Check for colors (hex or named)
if (isHexColor(trimmedValue) || isColorName(trimmedValue)) {
return 'lcpColor';
}
// Check for date
if (!isNaN(Date.parse(trimmedValue))) {
return 'lcpDate';
}
// Check for HTML
if (/<[a-z][\s\S]*>/i.test(trimmedValue)) {
return 'html';
}
}
return 'text';
}, []);
// Function to analyze and update column types
const analyzeColumnTypes = useCallback((data) => {
if (!data || !data.length) return {};
// Get all unique keys except special columns
const keys = Array.from(new Set(
data.flatMap(item => Object.keys(item))
)).filter(key => !['ID', 'Parent'].includes(key));
// For each key, collect all values and determine type
const newTypes = keys.reduce((acc, key) => {
const values = data.map(item => item[key]).filter(v => v != null);
// Try to determine the most appropriate type
const types = values.map(guessColumnType);
// If any value is a number, treat the whole column as number
if (types.includes('number')) {
acc[key] = 'number';
} else {
// Otherwise use the most common type
const typeCounts = types.reduce((counts, type) => {
counts[type] = (counts[type] || 0) + 1;
return counts;
}, {});
acc[key] = Object.entries(typeCounts)
.sort(([,a], [,b]) => b - a)[0][0];
}
return acc;
}, {});
return newTypes;
}, [guessColumnType]);
// Function to update chartData attribute with parsed JSON data from CSV // Function to update chartData attribute with parsed JSON data from CSV
const handleJsonDataUpdate = (newData) => { const handleJsonDataUpdate = (newData) => {
console.log('Received new data from CSV:', newData); console.log('Received new data from CSV:', newData);
// Update chartData
setAttributes({ chartData: newData }); setAttributes({ chartData: newData });
// Analyze and update column types
const newTypes = analyzeColumnTypes(newData);
setAttributes({ columnTypes: newTypes });
}; };
// Handle Edit Dataset button click
const handleEditClick = useCallback(() => {
// Update column types before opening the editor
if (chartData?.length > 0) {
const newTypes = analyzeColumnTypes(chartData);
setAttributes({ columnTypes: newTypes });
}
setIsOpen(true);
}, [chartData, analyzeColumnTypes, setAttributes]);
useEffect(() => { useEffect(() => {
if (chartData && chartData.length > 0) { if (chartData && chartData.length > 0) {
const columns = Object.keys(chartData[0]).filter(key => key !== 'lcpId'); const columns = getAvailableColumns();
setOptions(columns.map(col => ({ label: col, value: col }))); setOptions(columns.map(col => ({
label: col.name,
value: col.key
})));
} }
}, [chartData]); }, [chartData]);
@ -25,12 +142,12 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
const getAvailableColumns = () => { const getAvailableColumns = () => {
if (!chartData.length) return []; if (!chartData.length) return [];
// Get all unique keys from the data // Get the first row which contains our column names
const columns = Array.from(new Set( const columnNames = Object.entries(chartData[0])
chartData.flatMap(item => Object.keys(item)) .filter(([key]) => !['ID', 'Parent'].includes(key))
)).filter(key => key !== 'lcpId'); // Exclude lcpId column .map(([key, value]) => ({ key, name: value }));
return columns; return columnNames;
}; };
// Get column options based on type filter // Get column options based on type filter
@ -38,10 +155,17 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
return columns return columns
.filter(col => { .filter(col => {
if (!typeFilter) return true; if (!typeFilter) return true;
const columnType = attributes.columnTypes?.[col]; const columnType = attributes.columnTypes?.[col.key];
// Handle both 'number' and 'lcpNumber' for backward compatibility
if (typeFilter === 'number') {
return columnType === 'number';
}
return columnType === typeFilter; return columnType === typeFilter;
}) })
.map(col => ({ label: col, value: col })); .map(col => ({
label: col.name, // Use the column name from first row
value: col.key // Use the field key for internal reference
}));
}; };
const handleDataChange = (newData) => { const handleDataChange = (newData) => {
@ -97,7 +221,7 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
const allOptions = getColumnOptions(availableColumns); const allOptions = getColumnOptions(availableColumns);
// Get numeric column options for bar chart value // Get numeric column options for bar chart value
const numericOptions = getColumnOptions(availableColumns, 'lcpNumber'); const numericOptions = getColumnOptions(availableColumns, 'number');
// Determine which options to use for value column // Determine which options to use for value column
const valueColumnOptions = chartType === 'bar' ? numericOptions : allOptions; const valueColumnOptions = chartType === 'bar' ? numericOptions : allOptions;
@ -107,9 +231,9 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
return ( return (
<div> <div>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setIsOpen(true)} onClick={handleEditClick}
style={{ marginBottom: '10px', width: '100%' }} style={{ marginBottom: '10px', width: '100%' }}
> >
{__('Edit Dataset', 'lcp-visualize')} {__('Edit Dataset', 'lcp-visualize')}
@ -138,7 +262,7 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
columnTypes: newColumnTypes, columnTypes: newColumnTypes,
// If we're changing from a numeric type and this is the value column, reset it // If we're changing from a numeric type and this is the value column, reset it
valueColumn: field === attributes.valueColumn && valueColumn: field === attributes.valueColumn &&
type !== 'lcpNumber' && type !== 'number' &&
chartType === 'bar' ? '' : attributes.valueColumn chartType === 'bar' ? '' : attributes.valueColumn
}); });
}} }}
@ -176,6 +300,12 @@ const LCPDatasetBuilder = ({ chartData = [], onChange, attributes, setAttributes
options={options} options={options}
onChange={(value) => setAttributes({ labelsColumn: value })} onChange={(value) => setAttributes({ labelsColumn: value })}
/> />
<TextControl
label={__('Hierarchical Columns', 'lcp')}
help={__('Categorical columns', 'lcp')}
value={attributes.hierarchicalColumnOrder || ''}
onChange={(value) => setAttributes({ hierarchicalColumnOrder: value })}
/>
<LCPDataUploader onJsonDataUpdate={handleJsonDataUpdate} /> <LCPDataUploader onJsonDataUpdate={handleJsonDataUpdate} />
<SelectControl <SelectControl
label={__('Color Column', 'lcp-visualize')} label={__('Color Column', 'lcp-visualize')}

View File

@ -16,10 +16,18 @@ const LCPLegend = ({
// Update the container's width when it changes // Update the container's width when it changes
useEffect(() => { useEffect(() => {
if (svgRef.current) { const handleResize = () => {
setContainerWidth(svgRef.current.clientWidth); if (svgRef.current) {
} setContainerWidth(svgRef.current.clientWidth); // Measure the width of the container
}, [svgRef.current]); }
};
handleResize(); // Initial measurement
window.addEventListener('resize', handleResize); // Update on resize
return () => {
window.removeEventListener('resize', handleResize); // Cleanup on component unmount
};
}, []);
useEffect(() => { useEffect(() => {
if (!items.length || !svgRef.current || containerWidth === 0) return; if (!items.length || !svgRef.current || containerWidth === 0) return;
@ -27,19 +35,37 @@ const LCPLegend = ({
// Clear any existing content // Clear any existing content
d3.select(svgRef.current).selectAll("*").remove(); d3.select(svgRef.current).selectAll("*").remove();
// Variables for item dimensions and row management // Calculate item widths first
const rowHeight = itemSize + spacing; const itemWidths = items.map(item => {
let currentX = 0; const labelWidth = item.Label.length * fontSize * 0.6;
let currentY = 0; return itemSize + spacing + labelWidth;
});
// Calculate the maximum number of items that can fit per row // Calculate total width of all items
const itemWidth = itemSize + spacing + fontSize * 0.6; // Item width (rect + label) const totalWidth = itemWidths.reduce((sum, width) => sum + width + spacing, 0);
const itemsPerRow = Math.floor(containerWidth / itemWidth); // Items per row based on container width
// Calculate optimal number of rows
const availableWidth = containerWidth - (startX * 2);
const minRows = Math.ceil(totalWidth / availableWidth);
// Create arrays to store items for each row
const rows = Array(minRows).fill().map(() => []);
let rowWidths = Array(minRows).fill(0);
// Distribute items across rows to minimize empty space
itemWidths.forEach((width, index) => {
// Find the row with the most remaining space
const rowIndex = rowWidths
.map((rowWidth, i) => ({ width: rowWidth, index: i }))
.sort((a, b) => a.width - b.width)[0].index;
rows[rowIndex].push(index);
rowWidths[rowIndex] += width + spacing;
});
// Create the SVG container // Create the SVG container
const svg = d3.select(svgRef.current) const svg = d3.select(svgRef.current)
.attr('width', '100%') .attr('width', '100%')
.style('height', null) // Let height be dynamic
.style('overflow', 'visible'); .style('overflow', 'visible');
// Create the legend items group // Create the legend items group
@ -47,45 +73,39 @@ const LCPLegend = ({
.attr('class', 'legend-group') .attr('class', 'legend-group')
.attr('transform', `translate(${startX}, ${startY})`); .attr('transform', `translate(${startX}, ${startY})`);
// Loop over items to position them // Place items in their calculated positions
items.forEach((item, i) => { rows.forEach((rowItems, rowIndex) => {
// Calculate the width of each item (rect + label) let currentX = 0;
const labelWidth = item.Label.length * fontSize * 0.6; // Approximate label width const currentY = rowIndex * (itemSize + spacing);
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 rowItems.forEach((itemIndex) => {
if (i % itemsPerRow === 0 && i !== 0) { const item = items[itemIndex];
currentX = 0; // Reset X position const labelWidth = item.Label.length * fontSize * 0.6;
currentY += rowHeight; // Move down to next row
}
// Create the rectangle // Create the rectangle (swatch)
legend.append('rect') legend.append('rect')
.attr('x', currentX) .attr('x', currentX)
.attr('y', currentY) .attr('y', currentY)
.attr('width', itemSize) .attr('width', itemSize)
.attr('height', itemSize) .attr('height', itemSize)
.style('fill', item.color || '#cccccc'); .style('fill', item.color || '#cccccc');
// Create the label // Create the label
legend.append('text') legend.append('text')
.attr('x', currentX + itemSize + spacing) .attr('x', currentX + itemSize + spacing)
.attr('y', currentY + itemSize / 2) .attr('y', currentY + itemSize / 2)
.text(item.Label || '') .text(item.Label || '')
.style('font-family', fontFamily) .style('font-family', fontFamily)
.style('font-size', `${fontSize}px`) .style('font-size', `${fontSize}px`)
.style('dominant-baseline', 'middle') .style('dominant-baseline', 'middle')
.style('fill', '#333333'); .style('fill', '#333333');
// Update the current X position for the next item currentX += itemSize + spacing + labelWidth + spacing;
currentX += itemWidth; });
}); });
// Calculate the height of the SVG element based on the number of rows // Set the SVG height based on the number of rows
const totalRows = Math.ceil(items.length / itemsPerRow); const calculatedHeight = (minRows * (itemSize + spacing)) + (startY * 2);
const calculatedHeight = totalRows * rowHeight + startY * 2;
// Set the correct height based on totalRows
svg.attr('height', calculatedHeight); svg.attr('height', calculatedHeight);
}, [items, containerWidth, itemSize, spacing, startX, startY, fontFamily, fontSize]); }, [items, containerWidth, itemSize, spacing, startX, startY, fontFamily, fontSize]);

View File

@ -5,7 +5,7 @@
"version": "1.0.0", "version": "1.0.0",
"title": "Line Graph", "title": "Line Graph",
"category": "widgets", "category": "widgets",
"icon": "chart-bar", "icon": "chart-line",
"description": "Display data as a line graph using D3.js", "description": "Display data as a line graph using D3.js",
"supports": { "supports": {
"html": false "html": false
@ -48,24 +48,7 @@
}, },
"chartData": { "chartData": {
"type": "array", "type": "array",
"default": [ "default": []
{
"ID": "lcpDatapoint-1",
"Label": "Sample 1",
"Value": 100,
"Color": "#007cba",
"Content": "<p>First item</p>",
"Parent": ""
},
{
"ID": "lcpDatapoint-2",
"Label": "Sample 2",
"Value": 50,
"Color": "#ff0000",
"Content": "<p>Second item</p>",
"Parent": "lcpDatapoint-1"
}
]
}, },
"chartHeight": { "chartHeight": {
"type": "string", "type": "string",
@ -93,15 +76,15 @@
}, },
"colorColumn": { "colorColumn": {
"type": "string", "type": "string",
"default": "Color" "default": "color"
}, },
"popoverColumn": { "popoverColumn": {
"type": "string", "type": "string",
"default": "Content" "default": "content"
}, },
"renderLegend": { "renderLegend": {
"type": "boolean", "type": "boolean",
"default": true "default": false
}, },
"legendFontSize": { "legendFontSize": {
"type": "string", "type": "string",
@ -157,7 +140,7 @@
}, },
"toolbarLocation": { "toolbarLocation": {
"type": "string", "type": "string",
"default": "bottom" "default": "top"
}, },
"toolbarAlignment": { "toolbarAlignment": {
"type": "string", "type": "string",

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '7d02545764a85b66ef48'); <?php return array('dependencies' => array('react', 'react-dom', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '10f66260923a977161c2');

File diff suppressed because one or more lines are too long