????
Current Path : C:/opt/pgsql/pgAdmin 4/web/pgadmin/static/js/SchemaView/ |
Current File : C:/opt/pgsql/pgAdmin 4/web/pgadmin/static/js/SchemaView/DataGridView.jsx |
///////////////////////////////////////////////////////////// // // pgAdmin 4 - PostgreSQL Tools // // Copyright (C) 2013 - 2024, The pgAdmin Development Team // This software is released under the PostgreSQL Licence // ////////////////////////////////////////////////////////////// /* The DataGridView component is based on react-table component */ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Box } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { PgIconButton } from '../components/Buttons'; import AddIcon from '@mui/icons-material/AddOutlined'; import { MappedCellControl } from './MappedControl'; import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded'; import EditRoundedIcon from '@mui/icons-material/EditRounded'; import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; import { useTable, useFlexLayout, useResizeColumns, useSortBy, useExpanded, useGlobalFilter } from 'react-table'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import _ from 'lodash'; import { DndProvider, useDrag, useDrop } from 'react-dnd'; import {HTML5Backend} from 'react-dnd-html5-backend'; import gettext from 'sources/gettext'; import { SCHEMA_STATE_ACTIONS, StateUtilsContext } from '.'; import FormView, { getFieldMetaData } from './FormView'; import CustomPropTypes from 'sources/custom_prop_types'; import { evalFunc } from 'sources/utils'; import { DepListenerContext } from './DepListener'; import { useIsMounted } from '../custom_hooks'; import { InputText } from '../components/FormComponents'; import { usePgAdmin } from '../BrowserComponent'; import { requestAnimationAndFocus } from '../utils'; const useStyles = makeStyles((theme)=>({ grid: { ...theme.mixins.panelBorder, backgroundColor: theme.palette.background.default, }, gridHeader: { display: 'flex', ...theme.mixins.panelBorder.bottom, backgroundColor: theme.otherVars.headerBg, }, gridHeaderText: { padding: theme.spacing(0.5, 1), fontWeight: theme.typography.fontWeightBold, }, gridControls: { marginLeft: 'auto', }, gridControlsButton: { border: 0, borderRadius: 0, ...theme.mixins.panelBorder.left, }, gridRowButton: { border: 0, borderRadius: 0, padding: 0, minWidth: 0, backgroundColor: 'inherit', '&.Mui-disabled': { border: 0, }, }, gridTableContainer: { overflow: 'auto', width: '100%', }, table: { borderSpacing: 0, width: '100%', overflow: 'auto', backgroundColor: theme.otherVars.tableBg, }, tableRowHovered: { position: 'relative', '& .hover-overlay': { backgroundColor: theme.palette.primary.light, position: 'absolute', inset: 0, opacity: 0.75, } }, tableCell: { margin: 0, padding: theme.spacing(0.5), ...theme.mixins.panelBorder.bottom, ...theme.mixins.panelBorder.right, position: 'relative', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, tableCellHeader: { fontWeight: theme.typography.fontWeightBold, padding: theme.spacing(1, 0.5), textAlign: 'left', }, tableContentWidth: { width: 'calc(100% - 3px)', }, btnCell: { padding: theme.spacing(0.5, 0), textAlign: 'center', }, btnReorder: { cursor: 'move', }, resizer: { display: 'inline-block', width: '5px', height: '100%', position: 'absolute', right: 0, top: 0, transform: 'translateX(50%)', zIndex: 1, touchAction: 'none', }, expandedForm: { borderTopWidth: theme.spacing(0.5), borderStyle: 'solid ', borderColor: theme.palette.grey[400], }, expandedIconCell: { backgroundColor: theme.palette.grey[400], borderBottom: 'none', } })); function DataTableHeader({headerGroups, viewHelperProps, schema}) { const classes = useStyles(); /* Using ref so that schema variable is not frozen in columns closure */ const schemaRef = useRef(schema); const sortIcon = (isDesc) => { return isDesc ? ' 🔽' : ' 🔼'; }; return ( <div className={classes.tableContentWidth}> {headerGroups.map((headerGroup, hi) => ( <div key={hi} {...headerGroup.getHeaderGroupProps()}> {headerGroup.headers.map((column, ci) => { let {modeSupported} = column.field ? getFieldMetaData(column.field, schemaRef.current, {}, viewHelperProps) : {modeSupported: true}; return( modeSupported && <div key={ci} {...column.getHeaderProps()}> <div {...(column.sortable ? column.getSortByToggleProps() : {})} className={clsx(classes.tableCell, classes.tableCellHeader)}> {column.render('Header')} <span> {column.isSorted ? sortIcon(column.isSortedDesc) : ''} </span> </div> {!column.disableResizing && <div {...column.getResizerProps()} className={classes.resizer} />} </div> ); })} </div> ))} </div> ); } DataTableHeader.propTypes = { headerGroups: PropTypes.array.isRequired, viewHelperProps: PropTypes.object.isRequired, schema: CustomPropTypes.schemaUI.isRequired, }; function DataTableRow({index, row, totalRows, isResizing, isHovered, schema, schemaRef, accessPath, moveRow, setHoverIndex, viewHelperProps}) { const classes = useStyles(); const [key, setKey] = useState(false); const depListener = useContext(DepListenerContext); const rowRef = useRef(null); const dragHandleRef = useRef(null); /* Memoize the row to avoid unnecessary re-render. * If table data changes, then react-table re-renders the complete tables * We can avoid re-render by if row data is not changed */ let depsMap = _.values(row.values, Object.keys(row.values).filter((k)=>!k.startsWith('btn'))); const externalDeps = useMemo(()=>{ let retVal = []; /* Calculate the fields which depends on the current field deps has info on fields which the current field depends on. */ schema.fields.forEach((field)=>{ (evalFunc(null, field.deps) || []).forEach((dep)=>{ let source = accessPath.concat(dep); if(_.isArray(dep)) { source = dep; /* If its an array, then dep is from the top schema and external */ retVal.push(source); } }); }); return retVal; }, []); useEffect(()=>{ schemaRef.current.fields.forEach((field)=>{ /* Self change is also dep change */ if(field.depChange || field.deferredDepChange) { depListener?.addDepListener(accessPath.concat(field.id), accessPath.concat(field.id), field.depChange, field.deferredDepChange); } (evalFunc(null, field.deps) || []).forEach((dep)=>{ let source = accessPath.concat(dep); if(_.isArray(dep)) { source = dep; } if(field.depChange) { depListener?.addDepListener(source, accessPath.concat(field.id), field.depChange); } }); }); // Try autofocus on newly added row. requestAnimationAndFocus(rowRef.current?.querySelector('input')); return ()=>{ /* Cleanup the listeners when unmounting */ depListener?.removeDepListener(accessPath); }; }, []); const [{ handlerId }, drop] = useDrop({ accept: 'row', collect(monitor) { return { handlerId: monitor.getHandlerId(), }; }, hover(item, monitor) { if (!rowRef.current) { return; } item.hoverIndex = null; // Don't replace items with themselves if (item.index === index) { return; } // Determine rectangle on screen const hoverBoundingRect = rowRef.current?.getBoundingClientRect(); // Determine mouse position const clientOffset = monitor.getClientOffset(); // Get pixels to the top const hoverClientY = clientOffset.y - hoverBoundingRect.top; // Only perform the move when the mouse has crossed certain part of the items height // Dragging downwards if (item.index < index && hoverClientY < (hoverBoundingRect.bottom - hoverBoundingRect.top)/3) { return; } // Dragging upwards if (item.index > index && hoverClientY > ((hoverBoundingRect.bottom - hoverBoundingRect.top)*2/3)) { return; } setHoverIndex(index); item.hoverIndex = index; }, }); const [, drag] = useDrag({ type: 'row', item: () => { return {index}; }, end: (item)=>{ // Time to actually perform the action setHoverIndex(null); if(item.hoverIndex >= 0) { moveRow(item.index, item.hoverIndex); } } }); /* External deps values are from top schema sess data */ depsMap = depsMap.concat(externalDeps.map((source)=>_.get(schemaRef.current.top?.sessData, source))); depsMap = depsMap.concat([totalRows, row.isExpanded, key, isResizing, isHovered]); drag(dragHandleRef); drop(rowRef); return useMemo(()=> <div {...row.getRowProps()} ref={rowRef} data-handler-id={handlerId} className={isHovered ? classes.tableRowHovered : null} data-test='data-table-row' > {row.cells.map((cell, ci) => { let classNames = [classes.tableCell]; let {modeSupported} = cell.column.field? getFieldMetaData(cell.column.field, schemaRef.current, {}, viewHelperProps) : {modeSupported: true}; if(typeof(cell.column.id) == 'string' && cell.column.id.startsWith('btn-')) { classNames.push(classes.btnCell); } if(cell.column.id == 'btn-edit' && row.isExpanded) { classNames.push(classes.expandedIconCell); } return (modeSupported && <div ref={cell.column.id == 'btn-reorder' ? dragHandleRef : null} key={ci} {...cell.getCellProps()} className={clsx(classNames)}> {cell.render('Cell', { reRenderRow: ()=>{setKey((currKey)=>!currKey);} })} </div> ); })} <div className='hover-overlay'></div> </div>, depsMap); } export function DataGridHeader({label, canAdd, onAddClick, canSearch, onSearchTextChange}) { const classes = useStyles(); const [searchText, setSearchText] = useState(''); return ( <Box className={classes.gridHeader}> { label && <Box className={classes.gridHeaderText}>{label}</Box> } { canSearch && <Box className={classes.gridHeaderText} width={'100%'}> <InputText value={searchText} onChange={(value)=>{ onSearchTextChange(value); setSearchText(value); }} placeholder={gettext('Search')}> </InputText> </Box> } <Box className={classes.gridControls}> {canAdd && <PgIconButton data-test="add-row" title={gettext('Add row')} onClick={()=>{ setSearchText(''); onSearchTextChange(''); onAddClick(); }} icon={<AddIcon />} className={classes.gridControlsButton} />} </Box> </Box> ); } DataGridHeader.propTypes = { label: PropTypes.string, canAdd: PropTypes.bool, onAddClick: PropTypes.func, canSearch: PropTypes.bool, onSearchTextChange: PropTypes.func, }; export default function DataGridView({ value, viewHelperProps, schema, accessPath, dataDispatch, containerClassName, fixedRows, ...props}) { const classes = useStyles(); const stateUtils = useContext(StateUtilsContext); const checkIsMounted = useIsMounted(); const [hoverIndex, setHoverIndex] = useState(); const newRowIndex = useRef(); const pgAdmin = usePgAdmin(); /* Using ref so that schema variable is not frozen in columns closure */ const schemaRef = useRef(schema); let columns = useMemo( ()=>{ let cols = []; if(props.canReorder) { let colInfo = { Header: <> </>, id: 'btn-reorder', accessor: ()=>{/*This is intentional (SonarQube)*/}, disableResizing: true, sortable: false, dataType: 'reorder', width: 26, minWidth: 26, maxWidth: 26, Cell: ()=>{ return <div className={classes.btnReorder}> <DragIndicatorRoundedIcon fontSize="small" /> </div>; } }; colInfo.Cell.displayName = 'Cell'; cols.push(colInfo); } if(props.canEdit) { let colInfo = { Header: <> </>, id: 'btn-edit', accessor: ()=>{/*This is intentional (SonarQube)*/}, disableResizing: true, sortable: false, dataType: 'edit', width: 26, minWidth: 26, maxWidth: 26, Cell: ({row})=>{ let canEditRow = true; if(props.canEditRow) { canEditRow = evalFunc(schemaRef.current, props.canEditRow, row.original || {}); } return <PgIconButton data-test="expand-row" title={gettext('Edit row')} icon={<EditRoundedIcon fontSize="small" />} className={classes.gridRowButton} onClick={()=>{ row.toggleRowExpanded(!row.isExpanded); }} disabled={!canEditRow} />; } }; colInfo.Cell.displayName = 'Cell'; colInfo.Cell.propTypes = { row: PropTypes.object.isRequired, }; cols.push(colInfo); } if(props.canDelete) { let colInfo = { Header: <> </>, id: 'btn-delete', accessor: ()=>{/*This is intentional (SonarQube)*/}, disableResizing: true, sortable: false, dataType: 'delete', width: 26, minWidth: 26, maxWidth: 26, Cell: ({row}) => { let canDeleteRow = true; if(props.canDeleteRow) { canDeleteRow = evalFunc(schemaRef.current, props.canDeleteRow, row.original || {}); } return ( <PgIconButton data-test="delete-row" title={gettext('Delete row')} icon={<DeleteRoundedIcon fontSize="small" />} onClick={()=>{ const deleteRow = ()=> { dataDispatch({ type: SCHEMA_STATE_ACTIONS.DELETE_ROW, path: accessPath, value: row.index, }); return true; }; if (props.onDelete){ props.onDelete(row.original || {}, deleteRow); } else { pgAdmin.Browser.notifier.confirm( props.customDeleteTitle || gettext('Delete Row'), props.customDeleteMsg || gettext('Are you sure you wish to delete this row?'), deleteRow, function() { return true; } ); } }} className={classes.gridRowButton} disabled={!canDeleteRow} /> ); } }; colInfo.Cell.displayName = 'Cell'; colInfo.Cell.propTypes = { row: PropTypes.object.isRequired, }; cols.push(colInfo); } cols = cols.concat( schemaRef.current.fields.filter((f)=>{ return _.isArray(props.columns) ? props.columns.indexOf(f.id) > -1 : true; }).sort((firstF, secondF)=>{ if(_.isArray(props.columns)) { return props.columns.indexOf(firstF.id) < props.columns.indexOf(secondF.id) ? -1 : 1; } return 0; }).map((field)=>{ let widthParms = {}; if(field.width) { widthParms.width = field.width; widthParms.minWidth = field.width; } else { widthParms.width = 75; widthParms.minWidth = 75; } if(field.minWidth) { widthParms.minWidth = field.minWidth; } if(field.maxWidth) { widthParms.maxWidth = field.maxWidth; } widthParms.disableResizing = Boolean(field.disableResizing); let colInfo = { Header: field.label||<> </>, accessor: field.id, field: field, disableResizing: false, sortable: true, ...widthParms, Cell: ({value, row, ...other}) => { /* Make sure to take the latest field info from schema */ field = _.find(schemaRef.current.fields, (f)=>f.id==field.id) || field; let {editable, disabled, modeSupported} = getFieldMetaData(field, schemaRef.current, row.original || {}, viewHelperProps); if(_.isUndefined(field.cell)) { console.error('cell is required ', field); } return modeSupported && <MappedCellControl rowIndex={row.index} value={value} row={row.original} {...field} readonly={!editable} disabled={disabled} visible={true} onCellChange={(changeValue)=>{ if(field.radioType) { dataDispatch({ type: SCHEMA_STATE_ACTIONS.BULK_UPDATE, path: accessPath, value: changeValue, id: field.id }); } dataDispatch({ type: SCHEMA_STATE_ACTIONS.SET_VALUE, path: accessPath.concat([row.index, field.id]), value: changeValue, }); }} reRenderRow={other.reRenderRow} />; }, }; colInfo.Cell.displayName = 'Cell'; colInfo.Cell.propTypes = { row: PropTypes.object.isRequired, value: PropTypes.any, onCellChange: PropTypes.func, }; return colInfo; }) ); return cols; },[props.canEdit, props.canDelete, props.canReorder] ); const onAddClick = useCallback(()=>{ if(!props.canAddRow) { return; } let newRow = schemaRef.current.getNewData(); if(props.expandEditOnAdd && props.canEdit) { newRowIndex.current = rows.length; } dataDispatch({ type: SCHEMA_STATE_ACTIONS.ADD_ROW, path: accessPath, value: newRow, addOnTop: props.addOnTop }); }, [props.canAddRow, rows?.length]); const defaultColumn = useMemo(()=>({ }), []); let tablePlugins = [ useGlobalFilter, useFlexLayout, useResizeColumns, useSortBy, useExpanded, ]; const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, setGlobalFilter, } = useTable( { columns, data: value, defaultColumn, manualSortBy: true, autoResetSortBy: false, autoResetExpanded: false, }, ...tablePlugins, ); useEffect(()=>{ let rowsPromise = fixedRows; /* If fixedRows is defined, fetch the details */ if(typeof rowsPromise === 'function') { rowsPromise = rowsPromise(); } if(rowsPromise) { Promise.resolve(rowsPromise) .then((res)=>{ /* If component unmounted, dont update state */ if(checkIsMounted()) { stateUtils.initOrigData(accessPath, res); } }); } }, []); useEffect(()=>{ if(newRowIndex.current >= 0) { rows[newRowIndex.current]?.toggleRowExpanded(true); newRowIndex.current = null; } }, [rows?.length]); const moveRow = (dragIndex, hoverIndex) => { dataDispatch({ type: SCHEMA_STATE_ACTIONS.MOVE_ROW, path: accessPath, oldIndex: dragIndex, newIndex: hoverIndex, }); }; const isResizing = _.flatMap(headerGroups, headerGroup => headerGroup.headers.map(col=>col.isResizing)).includes(true); if(!props.visible) { return <></>; } return ( <Box className={containerClassName}> <Box className={classes.grid}> {(props.label || props.canAdd) && <DataGridHeader label={props.label} canAdd={props.canAdd} onAddClick={onAddClick} canSearch={props.canSearch} onSearchTextChange={(value)=>{ setGlobalFilter(value || undefined); }} />} <DndProvider backend={HTML5Backend}> <div {...getTableProps(()=>({style: {minWidth: 'unset'}}))} className={classes.table} data-test="data-grid-view"> <DataTableHeader headerGroups={headerGroups} viewHelperProps={viewHelperProps} schema={schema} /> <div {...getTableBodyProps()} className={classes.tableContentWidth}> {rows.map((row, i) => { prepareRow(row); return <React.Fragment key={row.index}> <DataTableRow index={i} row={row} totalRows={rows.length} isResizing={isResizing} schema={schemaRef.current} schemaRef={schemaRef} accessPath={accessPath.concat([row.index])} moveRow={moveRow} isHovered={i == hoverIndex} setHoverIndex={setHoverIndex} viewHelperProps={viewHelperProps}/> {props.canEdit && row.isExpanded && <FormView value={row.original} viewHelperProps={viewHelperProps} dataDispatch={dataDispatch} schema={schemaRef.current} accessPath={accessPath.concat([row.index])} isNested={true} className={classes.expandedForm} isDataGridForm={true} firstEleRef={(ele)=>{ requestAnimationAndFocus(ele); }}/> } </React.Fragment>; })} </div> </div> </DndProvider> </Box> </Box> ); } DataGridView.propTypes = { label: PropTypes.string, value: PropTypes.array, viewHelperProps: PropTypes.object, schema: CustomPropTypes.schemaUI, accessPath: PropTypes.array.isRequired, dataDispatch: PropTypes.func, containerClassName: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), fixedRows: PropTypes.oneOfType([PropTypes.array, PropTypes.instanceOf(Promise), PropTypes.func]), columns: PropTypes.array, canEdit: PropTypes.bool, canAdd: PropTypes.bool, canDelete: PropTypes.bool, canReorder: PropTypes.bool, visible: PropTypes.bool, canAddRow: PropTypes.oneOfType([ PropTypes.bool, PropTypes.func, ]), canEditRow: PropTypes.oneOfType([ PropTypes.bool, PropTypes.func, ]), canDeleteRow: PropTypes.oneOfType([ PropTypes.bool, PropTypes.func, ]), expandEditOnAdd: PropTypes.bool, customDeleteTitle: PropTypes.string, customDeleteMsg: PropTypes.string, canSearch: PropTypes.bool, onDelete: PropTypes.func, addOnTop: PropTypes.bool };