500 lines
17 KiB
JavaScript
500 lines
17 KiB
JavaScript
import { useMemo, useCallback, useRef, useState } from '@wordpress/element';
|
|
import { Popover, Button, Modal, SelectControl } from '@wordpress/components';
|
|
import { __ } from '@wordpress/i18n';
|
|
import { AgGridReact } from 'ag-grid-react';
|
|
import 'ag-grid-community/styles/ag-grid.css';
|
|
import 'ag-grid-community/styles/ag-theme-alpine.css';
|
|
|
|
const generateId = () => {
|
|
const randomStr = Math.random().toString(36).substring(2, 8);
|
|
return `lcpDatapoint-${randomStr}`;
|
|
};
|
|
|
|
// Helper function to determine text color based on background
|
|
const getContrastColor = (hexcolor) => {
|
|
if (!hexcolor) return 'inherit';
|
|
const r = parseInt(hexcolor.slice(1, 3), 16);
|
|
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) => {
|
|
if (!props.value) return '';
|
|
return (
|
|
<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>
|
|
);
|
|
};
|
|
|
|
|
|
const ParentCellRenderer = (props) => {
|
|
if (!props.data || !props.data.ID) {
|
|
return null;
|
|
}
|
|
|
|
// Create options from all available rows except current
|
|
const options = [
|
|
{ label: __('None', 'lcp-visualize'), value: '' }
|
|
];
|
|
|
|
// Get all rows from the grid
|
|
props.api.forEachNode(node => {
|
|
if (node.data &&
|
|
node.data.ID &&
|
|
node.data.ID !== props.data.ID) {
|
|
options.push({
|
|
label: node.data.Label || node.data.ID,
|
|
value: node.data.ID
|
|
});
|
|
}
|
|
});
|
|
|
|
const handleChange = (newValue) => {
|
|
if (props.context && props.context.handleParentChange) {
|
|
props.context.handleParentChange(props.data, newValue);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SelectControl
|
|
value={props.data.Parent || ''}
|
|
options={options}
|
|
onChange={handleChange}
|
|
/>
|
|
);
|
|
};
|
|
|
|
|
|
|
|
|
|
const ColumnHeaderComponent = (props) => {
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [headerName, setHeaderName] = useState(props.displayName);
|
|
|
|
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,
|
|
editable: key !== 'ID',
|
|
sortable: true,
|
|
filter: true
|
|
};
|
|
});
|
|
|
|
// Add Parent column with ParentCellRenderer
|
|
const parentColumn = {
|
|
field: 'Parent',
|
|
headerName: __('Parent', 'lcp-visualize'),
|
|
cellRenderer: ParentCellRenderer,
|
|
cellRendererParams: {
|
|
context: {
|
|
handleParentChange
|
|
}
|
|
},
|
|
editable: false,
|
|
sortable: true,
|
|
filter: true,
|
|
minWidth: 200
|
|
};
|
|
|
|
return [...regularColumns, parentColumn];
|
|
}, [chartData, migratedColumnTypes, handleColumnTypeChange, handleHeaderChange, guessColumnType, handleParentChange]);
|
|
|
|
const defaultColDef = useMemo(() => ({
|
|
flex: 1,
|
|
minWidth: 100,
|
|
editable: true,
|
|
sortable: true,
|
|
filter: true,
|
|
resizable: true
|
|
}), []);
|
|
|
|
const rowData = useMemo(() => {
|
|
return chartData.map(point => ({
|
|
...point,
|
|
ID: point.ID || generateId()
|
|
}));
|
|
}, [chartData]);
|
|
|
|
const handleContextMenu = useCallback((event) => {
|
|
event.preventDefault();
|
|
const target = event.target;
|
|
const rowCell = target.closest('.ag-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()
|
|
});
|
|
}, []);
|
|
|
|
const handleRowAction = useCallback((action, rowIndex) => {
|
|
const newData = [...chartData];
|
|
|
|
// 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
|
|
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) {
|
|
case 'add-above':
|
|
newData.splice(rowIndex - 1, 0, emptyRow);
|
|
break;
|
|
case 'add-below':
|
|
newData.splice(rowIndex, 0, emptyRow);
|
|
break;
|
|
case 'delete':
|
|
newData.splice(rowIndex - 1, 1);
|
|
break;
|
|
}
|
|
|
|
onDataChange(newData);
|
|
setContextMenu(null);
|
|
}, [chartData, onDataChange]);
|
|
|
|
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 (
|
|
<div className="ag-theme-alpine" style={{ height: '400px', width: '100%' }}>
|
|
<AgGridReact
|
|
ref={gridRef}
|
|
rowData={rowData}
|
|
columnDefs={columnDefs}
|
|
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.mouseEvent.clientY,
|
|
left: contextMenu.mouseEvent.clientX,
|
|
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>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LCPDataGrid; |