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

@ -1,594 +1,179 @@
import { useMemo, useCallback, useRef, useState } from '@wordpress/element';
import { Popover, Button, Modal, SelectControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useState, useRef, useEffect } from '@wordpress/element';
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}`;
};
const LCPDataGrid = () => {
const gridData = [
{ Department2: 'Sheriffs Office 2', Budget: '150000' },
{ Department2: 'Treasurer2', Budget: '10000' },
{ Department2: 'Assessor2', Budget: '40000' },
];
// 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';
};
// Helper function to convert index to letters, accounting for 'A1', 'B1', 'C1', etc.
const getColumnLabel = (index) => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
let label = '';
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>
);
};
while (index >= 0) {
label = alphabet[index % 26] + label;
index = Math.floor(index / 26) - 1;
}
return label;
};
const ParentCellRenderer = (props) => {
if (!props.data || !props.data.ID) {
return null;
function lcpDataTypeParser(params) {
if (params.node.rowPinned) {
return params.newValue;
} else {
return Number(params.newValue);
}
}
// 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
// Create columnDefs dynamically
const [columnDefs, setColumnDefs] = useState([]);
const [pinnedTopRowData, setPinnedTopRowData] = useState([]);
useEffect(() => {
if (columnDefs.length > 0) return; // Prevent rerun if columnDefs already set
if (gridData.length > 0) {
const keys = Object.keys(gridData[0]); // Get the keys from the first row (Department2, Budget, etc.)
const columns = keys.map((key, index) => {
return {
headerName: getColumnLabel(index), // 'A', 'B', 'C', ...
field: key, // Field will match the key (Department2, Budget, etc.)
valueParser: lcpDataTypeParser,
};
});
}
});
const handleChange = (newValue) => {
if (props.context && props.context.handleParentChange) {
props.context.handleParentChange(props.data, newValue);
}
};
// Set the pinned top row data with the actual field names (like 'Department2', 'Budget')
const topRowData = keys.reduce((acc, key) => {
acc[key] = key; // Set key as the value in top row (use field names)
return acc;
}, {});
return (
<SelectControl
value={props.data.Parent || ''}
options={options}
onChange={handleChange}
/>
);
};
// Ensure pinnedTopRowData is set with correct data
setPinnedTopRowData([topRowData]);
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
// Add the row number column as the first column
const rowNumberColumn = {
headerName: '',
valueGetter: (params) => {
const rowIndex = params.node.rowPinned ? params.node.rowIndex + 1 : params.node.rowIndex + 2;
return rowIndex;
},
type: currentType,
editable: key !== 'ID',
sortable: true,
filter: true
suppressMovable: true,
editable: false,
filter: false,
sortable: false,
width: 45,
resizable: false,
flex: 0,
cellStyle: { backgroundColor: '#e0e0e0' },
};
});
// 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
};
setColumnDefs([rowNumberColumn, ...columns]); // Set columns along with row number column
}
}, [gridData, columnDefs]); // R
return [...regularColumns, parentColumn];
}, [chartData, migratedColumnTypes, handleColumnTypeChange, handleHeaderChange, guessColumnType, handleParentChange]);
const [rowData] = useState(gridData);
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,
minWidth: 100,
editable: true,
minWidth: 45,
editable: (params) => {
if (params.node.rowPinned) {
return false;
} else {
return 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');
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
});
suppressMovable: true,
cellDataType: false, //By default column can be any data type
cellStyle: (params) => {
if (params.node.rowPinned) {
return { backgroundColor: 'rgb(197, 219, 229)' };
}
}
}, []);
};
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) {
const columns = Array.from(new Set(
chartData.flatMap(item => Object.keys(item))
));
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] = '';
}
});
const gridOptions = {
rowDragManaged: true,
animateRows: true,
rowHeight: 35,
getRowStyle: (params) => {
if (params.node.rowPinned) {
return { backgroundColor: 'rgb(197, 219, 229)' }; // Light gray for pinned rows
}
return null; // No special style for non-pinned rows
}
};
switch (action) {
case 'add-above':
newData.splice(rowIndex, 0, emptyRow);
break;
case 'add-below':
newData.splice(rowIndex + 1, 0, emptyRow);
break;
case 'delete':
newData.splice(rowIndex, 1);
break;
}
const onGridReady = (params) => {
// Store the grid API when the grid is ready
setGridApi(params.api);
};
onDataChange(newData);
setContextMenu(null);
}, [chartData, onDataChange, contextMenu]);
// Reset row number on sorted
const onSortChanged = (e) => {
e.api.refreshCells();
};
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;
const newData = chartData.map(item =>
item.ID === params.data.ID ? params.data : item
);
onDataChange(newData);
}, [chartData, onDataChange]);
// Reset row number on filtered
const onFilterChanged = (e) => {
e.api.refreshCells();
};
return (
<div
className="ag-theme-alpine"
style={{ height: '400px', width: '100%' }}
onContextMenu={handleContextMenu}
>
<div>
<div
id="lcp-data-grid"
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
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.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>
)}
{/* Button outside the grid */}
<div>
<button onClick={addRow}>Add Row</button>
</div>
</div>
);
};
export default LCPDataGrid;
export default LCPDataGrid;