codekingpro/portable-devtools
114k
1/////////////////////////////////////////////////////////////2//3// pgAdmin 4 - PostgreSQL Tools4//5// Copyright (C) 2013 - 2024, The pgAdmin Development Team6// This software is released under the PostgreSQL Licence7//8//////////////////////////////////////////////////////////////9 10/* The DataGridView component is based on react-table component */11 12import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';13import { Box } from '@mui/material';14import { makeStyles } from '@mui/styles';15import { PgIconButton } from '../components/Buttons';16import AddIcon from '@mui/icons-material/AddOutlined';17import { MappedCellControl } from './MappedControl';18import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';19import EditRoundedIcon from '@mui/icons-material/EditRounded';20import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';21import { useTable, useFlexLayout, useResizeColumns, useSortBy, useExpanded, useGlobalFilter } from 'react-table';22import clsx from 'clsx';23import PropTypes from 'prop-types';24import _ from 'lodash';25import { DndProvider, useDrag, useDrop } from 'react-dnd';26import {HTML5Backend} from 'react-dnd-html5-backend';27 28import gettext from 'sources/gettext';29import { SCHEMA_STATE_ACTIONS, StateUtilsContext } from '.';30import FormView, { getFieldMetaData } from './FormView';31import CustomPropTypes from 'sources/custom_prop_types';32import { evalFunc } from 'sources/utils';33import { DepListenerContext } from './DepListener';34import { useIsMounted } from '../custom_hooks';35import { InputText } from '../components/FormComponents';36import { usePgAdmin } from '../BrowserComponent';37import { requestAnimationAndFocus } from '../utils';38 39const useStyles = makeStyles((theme)=>({40 grid: {41 ...theme.mixins.panelBorder,42 backgroundColor: theme.palette.background.default,43 },44 gridHeader: {45 display: 'flex',46 ...theme.mixins.panelBorder.bottom,47 backgroundColor: theme.otherVars.headerBg,48 },49 gridHeaderText: {50 padding: theme.spacing(0.5, 1),51 fontWeight: theme.typography.fontWeightBold,52 },53 gridControls: {54 marginLeft: 'auto',55 },56 gridControlsButton: {57 border: 0,58 borderRadius: 0,59 ...theme.mixins.panelBorder.left,60 },61 gridRowButton: {62 border: 0,63 borderRadius: 0,64 padding: 0,65 minWidth: 0,66 backgroundColor: 'inherit',67 '&.Mui-disabled': {68 border: 0,69 },70 },71 gridTableContainer: {72 overflow: 'auto',73 width: '100%',74 },75 table: {76 borderSpacing: 0,77 width: '100%',78 overflow: 'auto',79 backgroundColor: theme.otherVars.tableBg,80 },81 tableRowHovered: {82 position: 'relative',83 '& .hover-overlay': {84 backgroundColor: theme.palette.primary.light,85 position: 'absolute',86 inset: 0,87 opacity: 0.75,88 }89 },90 tableCell: {91 margin: 0,92 padding: theme.spacing(0.5),93 ...theme.mixins.panelBorder.bottom,94 ...theme.mixins.panelBorder.right,95 position: 'relative',96 overflow: 'hidden',97 textOverflow: 'ellipsis',98 whiteSpace: 'nowrap',99 },100 tableCellHeader: {101 fontWeight: theme.typography.fontWeightBold,102 padding: theme.spacing(1, 0.5),103 textAlign: 'left',104 },105 tableContentWidth: {106 width: 'calc(100% - 3px)',107 },108 btnCell: {109 padding: theme.spacing(0.5, 0),110 textAlign: 'center',111 },112 btnReorder: {113 cursor: 'move',114 },115 resizer: {116 display: 'inline-block',117 width: '5px',118 height: '100%',119 position: 'absolute',120 right: 0,121 top: 0,122 transform: 'translateX(50%)',123 zIndex: 1,124 touchAction: 'none',125 },126 expandedForm: {127 borderTopWidth: theme.spacing(0.5),128 borderStyle: 'solid ',129 borderColor: theme.palette.grey[400],130 },131 expandedIconCell: {132 backgroundColor: theme.palette.grey[400],133 borderBottom: 'none',134 }135}));136 137function DataTableHeader({headerGroups, viewHelperProps, schema}) {138 const classes = useStyles();139 140 /* Using ref so that schema variable is not frozen in columns closure */141 const schemaRef = useRef(schema);142 143 const sortIcon = (isDesc) => {144 return isDesc ? ' 🔽' : ' 🔼';145 };146 return (147 <div className={classes.tableContentWidth}>148 {headerGroups.map((headerGroup, hi) => (149 <div key={hi} {...headerGroup.getHeaderGroupProps()}>150 {headerGroup.headers.map((column, ci) => {151 let {modeSupported} = column.field ? getFieldMetaData(column.field, schemaRef.current, {}, viewHelperProps) : {modeSupported: true};152 return( modeSupported &&153 <div key={ci} {...column.getHeaderProps()}>154 <div {...(column.sortable ? column.getSortByToggleProps() : {})} className={clsx(classes.tableCell, classes.tableCellHeader)}>155 {column.render('Header')}156 <span>157 {column.isSorted ? sortIcon(column.isSortedDesc) : ''}158 </span>159 </div>160 {!column.disableResizing &&161 <div162 {...column.getResizerProps()}163 className={classes.resizer}164 />}165 </div>166 );167 })}168 </div>169 ))}170 </div>171 );172}173 174DataTableHeader.propTypes = {175 headerGroups: PropTypes.array.isRequired,176 viewHelperProps: PropTypes.object.isRequired,177 schema: CustomPropTypes.schemaUI.isRequired,178};179 180function DataTableRow({index, row, totalRows, isResizing, isHovered, schema, schemaRef, accessPath, moveRow, setHoverIndex, viewHelperProps}) {181 const classes = useStyles();182 const [key, setKey] = useState(false);183 const depListener = useContext(DepListenerContext);184 const rowRef = useRef(null);185 const dragHandleRef = useRef(null);186 187 /* Memoize the row to avoid unnecessary re-render.188 * If table data changes, then react-table re-renders the complete tables189 * We can avoid re-render by if row data is not changed190 */191 let depsMap = _.values(row.values, Object.keys(row.values).filter((k)=>!k.startsWith('btn')));192 const externalDeps = useMemo(()=>{193 let retVal = [];194 /* Calculate the fields which depends on the current field195 deps has info on fields which the current field depends on. */196 schema.fields.forEach((field)=>{197 (evalFunc(null, field.deps) || []).forEach((dep)=>{198 let source = accessPath.concat(dep);199 if(_.isArray(dep)) {200 source = dep;201 /* If its an array, then dep is from the top schema and external */202 retVal.push(source);203 }204 });205 });206 return retVal;207 }, []);208 209 useEffect(()=>{210 schemaRef.current.fields.forEach((field)=>{211 /* Self change is also dep change */212 if(field.depChange || field.deferredDepChange) {213 depListener?.addDepListener(accessPath.concat(field.id), accessPath.concat(field.id), field.depChange, field.deferredDepChange);214 }215 (evalFunc(null, field.deps) || []).forEach((dep)=>{216 let source = accessPath.concat(dep);217 if(_.isArray(dep)) {218 source = dep;219 }220 if(field.depChange) {221 depListener?.addDepListener(source, accessPath.concat(field.id), field.depChange);222 }223 });224 });225 226 // Try autofocus on newly added row.227 requestAnimationAndFocus(rowRef.current?.querySelector('input'));228 229 return ()=>{230 /* Cleanup the listeners when unmounting */231 depListener?.removeDepListener(accessPath);232 };233 }, []);234 235 const [{ handlerId }, drop] = useDrop({236 accept: 'row',237 collect(monitor) {238 return {239 handlerId: monitor.getHandlerId(),240 };241 },242 hover(item, monitor) {243 if (!rowRef.current) {244 return;245 }246 item.hoverIndex = null;247 // Don't replace items with themselves248 if (item.index === index) {249 return;250 }251 // Determine rectangle on screen252 const hoverBoundingRect = rowRef.current?.getBoundingClientRect();253 // Determine mouse position254 const clientOffset = monitor.getClientOffset();255 // Get pixels to the top256 const hoverClientY = clientOffset.y - hoverBoundingRect.top;257 // Only perform the move when the mouse has crossed certain part of the items height258 // Dragging downwards259 if (item.index < index && hoverClientY < (hoverBoundingRect.bottom - hoverBoundingRect.top)/3) {260 return;261 }262 // Dragging upwards263 if (item.index > index && hoverClientY > ((hoverBoundingRect.bottom - hoverBoundingRect.top)*2/3)) {264 return;265 }266 setHoverIndex(index);267 item.hoverIndex = index;268 },269 });270 271 const [, drag] = useDrag({272 type: 'row',273 item: () => {274 return {index};275 },276 end: (item)=>{277 // Time to actually perform the action278 setHoverIndex(null);279 if(item.hoverIndex >= 0) {280 moveRow(item.index, item.hoverIndex);281 }282 }283 });284 285 /* External deps values are from top schema sess data */286 depsMap = depsMap.concat(externalDeps.map((source)=>_.get(schemaRef.current.top?.sessData, source)));287 depsMap = depsMap.concat([totalRows, row.isExpanded, key, isResizing, isHovered]);288 289 drag(dragHandleRef);290 drop(rowRef);291 292 return useMemo(()=>293 <div {...row.getRowProps()} ref={rowRef} data-handler-id={handlerId}294 className={isHovered ? classes.tableRowHovered : null}295 data-test='data-table-row'296 >297 {row.cells.map((cell, ci) => {298 let classNames = [classes.tableCell];299 300 let {modeSupported} = cell.column.field? getFieldMetaData(cell.column.field, schemaRef.current, {}, viewHelperProps) : {modeSupported: true};301 302 if(typeof(cell.column.id) == 'string' && cell.column.id.startsWith('btn-')) {303 classNames.push(classes.btnCell);304 }305 if(cell.column.id == 'btn-edit' && row.isExpanded) {306 classNames.push(classes.expandedIconCell);307 }308 return (modeSupported &&309 <div ref={cell.column.id == 'btn-reorder' ? dragHandleRef : null} key={ci} {...cell.getCellProps()} className={clsx(classNames)}>310 {cell.render('Cell', {311 reRenderRow: ()=>{setKey((currKey)=>!currKey);}312 })}313 </div>314 );315 })}316 <div className='hover-overlay'></div>317 </div>, depsMap);318}319 320export function DataGridHeader({label, canAdd, onAddClick, canSearch, onSearchTextChange}) {321 const classes = useStyles();322 const [searchText, setSearchText] = useState('');323 324 return (325 <Box className={classes.gridHeader}>326 { label &&327 <Box className={classes.gridHeaderText}>{label}</Box>328 }329 { canSearch &&330 <Box className={classes.gridHeaderText} width={'100%'}>331 <InputText value={searchText}332 onChange={(value)=>{333 onSearchTextChange(value);334 setSearchText(value);335 }}336 placeholder={gettext('Search')}>337 </InputText>338 </Box>339 }340 <Box className={classes.gridControls}>341 {canAdd && <PgIconButton data-test="add-row" title={gettext('Add row')} onClick={()=>{342 setSearchText('');343 onSearchTextChange('');344 onAddClick();345 }} icon={<AddIcon />} className={classes.gridControlsButton} />}346 </Box>347 </Box>348 );349}350DataGridHeader.propTypes = {351 label: PropTypes.string,352 canAdd: PropTypes.bool,353 onAddClick: PropTypes.func,354 canSearch: PropTypes.bool,355 onSearchTextChange: PropTypes.func,356};357 358export default function DataGridView({359 value, viewHelperProps, schema, accessPath, dataDispatch, containerClassName,360 fixedRows, ...props}) {361 const classes = useStyles();362 const stateUtils = useContext(StateUtilsContext);363 const checkIsMounted = useIsMounted();364 const [hoverIndex, setHoverIndex] = useState();365 const newRowIndex = useRef();366 const pgAdmin = usePgAdmin();367 368 /* Using ref so that schema variable is not frozen in columns closure */369 const schemaRef = useRef(schema);370 let columns = useMemo(371 ()=>{372 let cols = [];373 if(props.canReorder) {374 let colInfo = {375 Header: <> </>,376 id: 'btn-reorder',377 accessor: ()=>{/*This is intentional (SonarQube)*/},378 disableResizing: true,379 sortable: false,380 dataType: 'reorder',381 width: 26,382 minWidth: 26,383 maxWidth: 26,384 Cell: ()=>{385 return <div className={classes.btnReorder}>386 <DragIndicatorRoundedIcon fontSize="small" />387 </div>;388 }389 };390 colInfo.Cell.displayName = 'Cell';391 cols.push(colInfo);392 }393 if(props.canEdit) {394 let colInfo = {395 Header: <> </>,396 id: 'btn-edit',397 accessor: ()=>{/*This is intentional (SonarQube)*/},398 disableResizing: true,399 sortable: false,400 dataType: 'edit',401 width: 26,402 minWidth: 26,403 maxWidth: 26,404 Cell: ({row})=>{405 let canEditRow = true;406 if(props.canEditRow) {407 canEditRow = evalFunc(schemaRef.current, props.canEditRow, row.original || {});408 }409 return <PgIconButton data-test="expand-row" title={gettext('Edit row')} icon={<EditRoundedIcon fontSize="small" />} className={classes.gridRowButton}410 onClick={()=>{411 row.toggleRowExpanded(!row.isExpanded);412 }} disabled={!canEditRow}413 />;414 }415 };416 colInfo.Cell.displayName = 'Cell';417 colInfo.Cell.propTypes = {418 row: PropTypes.object.isRequired,419 };420 cols.push(colInfo);421 }422 if(props.canDelete) {423 let colInfo = {424 Header: <> </>,425 id: 'btn-delete',426 accessor: ()=>{/*This is intentional (SonarQube)*/},427 disableResizing: true,428 sortable: false,429 dataType: 'delete',430 width: 26,431 minWidth: 26,432 maxWidth: 26,433 Cell: ({row}) => {434 let canDeleteRow = true;435 if(props.canDeleteRow) {436 canDeleteRow = evalFunc(schemaRef.current, props.canDeleteRow, row.original || {});437 }438 439 return (440 <PgIconButton data-test="delete-row" title={gettext('Delete row')} icon={<DeleteRoundedIcon fontSize="small" />}441 onClick={()=>{442 const deleteRow = ()=> {443 dataDispatch({444 type: SCHEMA_STATE_ACTIONS.DELETE_ROW,445 path: accessPath,446 value: row.index,447 });448 return true;449 };450 451 if (props.onDelete){452 props.onDelete(row.original || {}, deleteRow);453 } else {454 pgAdmin.Browser.notifier.confirm(455 props.customDeleteTitle || gettext('Delete Row'),456 props.customDeleteMsg || gettext('Are you sure you wish to delete this row?'),457 deleteRow,458 function() {459 return true;460 }461 );462 }463 }} className={classes.gridRowButton} disabled={!canDeleteRow} />464 );465 }466 };467 colInfo.Cell.displayName = 'Cell';468 colInfo.Cell.propTypes = {469 row: PropTypes.object.isRequired,470 };471 cols.push(colInfo);472 }473 474 cols = cols.concat(475 schemaRef.current.fields.filter((f)=>{476 return _.isArray(props.columns) ? props.columns.indexOf(f.id) > -1 : true;477 }).sort((firstF, secondF)=>{478 if(_.isArray(props.columns)) {479 return props.columns.indexOf(firstF.id) < props.columns.indexOf(secondF.id) ? -1 : 1;480 }481 return 0;482 }).map((field)=>{483 let widthParms = {};484 if(field.width) {485 widthParms.width = field.width;486 widthParms.minWidth = field.width;487 } else {488 widthParms.width = 75;489 widthParms.minWidth = 75;490 }491 if(field.minWidth) {492 widthParms.minWidth = field.minWidth;493 }494 if(field.maxWidth) {495 widthParms.maxWidth = field.maxWidth;496 }497 widthParms.disableResizing = Boolean(field.disableResizing);498 499 let colInfo = {500 Header: field.label||<> </>,501 accessor: field.id,502 field: field,503 disableResizing: false,504 sortable: true,505 ...widthParms,506 Cell: ({value, row, ...other}) => {507 /* Make sure to take the latest field info from schema */508 field = _.find(schemaRef.current.fields, (f)=>f.id==field.id) || field;509 510 let {editable, disabled, modeSupported} = getFieldMetaData(field, schemaRef.current, row.original || {}, viewHelperProps);511 512 if(_.isUndefined(field.cell)) {513 console.error('cell is required ', field);514 }515 516 return modeSupported && <MappedCellControl rowIndex={row.index} value={value}517 row={row.original} {...field}518 readonly={!editable}519 disabled={disabled}520 visible={true}521 onCellChange={(changeValue)=>{522 if(field.radioType) {523 dataDispatch({524 type: SCHEMA_STATE_ACTIONS.BULK_UPDATE,525 path: accessPath,526 value: changeValue,527 id: field.id528 });529 }530 dataDispatch({531 type: SCHEMA_STATE_ACTIONS.SET_VALUE,532 path: accessPath.concat([row.index, field.id]),533 value: changeValue,534 });535 }}536 reRenderRow={other.reRenderRow}537 />;538 },539 };540 colInfo.Cell.displayName = 'Cell';541 colInfo.Cell.propTypes = {542 row: PropTypes.object.isRequired,543 value: PropTypes.any,544 onCellChange: PropTypes.func,545 };546 return colInfo;547 })548 );549 return cols;550 },[props.canEdit, props.canDelete, props.canReorder]551 );552 553 const onAddClick = useCallback(()=>{554 if(!props.canAddRow) {555 return;556 }557 558 let newRow = schemaRef.current.getNewData();559 if(props.expandEditOnAdd && props.canEdit) {560 newRowIndex.current = rows.length;561 }562 dataDispatch({563 type: SCHEMA_STATE_ACTIONS.ADD_ROW,564 path: accessPath,565 value: newRow,566 addOnTop: props.addOnTop567 });568 }, [props.canAddRow, rows?.length]);569 570 const defaultColumn = useMemo(()=>({571 }), []);572 573 let tablePlugins = [574 useGlobalFilter,575 useFlexLayout,576 useResizeColumns,577 useSortBy,578 useExpanded,579 ];580 581 const {582 getTableProps,583 getTableBodyProps,584 headerGroups,585 rows,586 prepareRow,587 setGlobalFilter,588 } = useTable(589 {590 columns,591 data: value,592 defaultColumn,593 manualSortBy: true,594 autoResetSortBy: false,595 autoResetExpanded: false,596 },597 ...tablePlugins,598 );599 600 useEffect(()=>{601 let rowsPromise = fixedRows;602 603 /* If fixedRows is defined, fetch the details */604 if(typeof rowsPromise === 'function') {605 rowsPromise = rowsPromise();606 }607 if(rowsPromise) {608 Promise.resolve(rowsPromise)609 .then((res)=>{610 /* If component unmounted, dont update state */611 if(checkIsMounted()) {612 stateUtils.initOrigData(accessPath, res);613 }614 });615 }616 }, []);617 618 useEffect(()=>{619 if(newRowIndex.current >= 0) {620 rows[newRowIndex.current]?.toggleRowExpanded(true);621 newRowIndex.current = null;622 }623 }, [rows?.length]);624 625 const moveRow = (dragIndex, hoverIndex) => {626 dataDispatch({627 type: SCHEMA_STATE_ACTIONS.MOVE_ROW,628 path: accessPath,629 oldIndex: dragIndex,630 newIndex: hoverIndex,631 });632 };633 634 const isResizing = _.flatMap(headerGroups, headerGroup => headerGroup.headers.map(col=>col.isResizing)).includes(true);635 636 if(!props.visible) {637 return <></>;638 }639 640 return (641 <Box className={containerClassName}>642 <Box className={classes.grid}>643 {(props.label || props.canAdd) && <DataGridHeader label={props.label} canAdd={props.canAdd} onAddClick={onAddClick}644 canSearch={props.canSearch}645 onSearchTextChange={(value)=>{646 setGlobalFilter(value || undefined);647 }}648 />}649 <DndProvider backend={HTML5Backend}>650 <div {...getTableProps(()=>({style: {minWidth: 'unset'}}))} className={classes.table} data-test="data-grid-view">651 <DataTableHeader headerGroups={headerGroups} viewHelperProps={viewHelperProps} schema={schema} />652 <div {...getTableBodyProps()} className={classes.tableContentWidth}>653 {rows.map((row, i) => {654 prepareRow(row);655 return <React.Fragment key={row.index}>656 <DataTableRow index={i} row={row} totalRows={rows.length} isResizing={isResizing}657 schema={schemaRef.current} schemaRef={schemaRef} accessPath={accessPath.concat([row.index])}658 moveRow={moveRow} isHovered={i == hoverIndex} setHoverIndex={setHoverIndex} viewHelperProps={viewHelperProps}/>659 {props.canEdit && row.isExpanded &&660 <FormView value={row.original} viewHelperProps={viewHelperProps} dataDispatch={dataDispatch}661 schema={schemaRef.current} accessPath={accessPath.concat([row.index])} isNested={true} className={classes.expandedForm}662 isDataGridForm={true} firstEleRef={(ele)=>{663 requestAnimationAndFocus(ele);664 }}/>665 }666 </React.Fragment>;667 })}668 </div>669 </div>670 </DndProvider>671 </Box>672 </Box>673 );674}675 676DataGridView.propTypes = {677 label: PropTypes.string,678 value: PropTypes.array,679 viewHelperProps: PropTypes.object,680 schema: CustomPropTypes.schemaUI,681 accessPath: PropTypes.array.isRequired,682 dataDispatch: PropTypes.func,683 containerClassName: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),684 fixedRows: PropTypes.oneOfType([PropTypes.array, PropTypes.instanceOf(Promise), PropTypes.func]),685 columns: PropTypes.array,686 canEdit: PropTypes.bool,687 canAdd: PropTypes.bool,688 canDelete: PropTypes.bool,689 canReorder: PropTypes.bool,690 visible: PropTypes.bool,691 canAddRow: PropTypes.oneOfType([692 PropTypes.bool, PropTypes.func,693 ]),694 canEditRow: PropTypes.oneOfType([695 PropTypes.bool, PropTypes.func,696 ]),697 canDeleteRow: PropTypes.oneOfType([698 PropTypes.bool, PropTypes.func,699 ]),700 expandEditOnAdd: PropTypes.bool,701 customDeleteTitle: PropTypes.string,702 customDeleteMsg: PropTypes.string,703 canSearch: PropTypes.bool,704 onDelete: PropTypes.func,705 addOnTop: PropTypes.bool706};707 