diff --git a/src/qqq/components/query/CustomColumnsPanel.tsx b/src/qqq/components/query/CustomColumnsPanel.tsx new file mode 100644 index 0000000..b398dd1 --- /dev/null +++ b/src/qqq/components/query/CustomColumnsPanel.tsx @@ -0,0 +1,399 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2023. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {Box, FormControlLabel, FormGroup} from "@mui/material"; +import Button from "@mui/material/Button"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import {GridColDef, GridSlotsComponentsProps, useGridApiContext, useGridSelector} from "@mui/x-data-grid-pro"; +import {GridColumnsPanelProps} from "@mui/x-data-grid/components/panel/GridColumnsPanel"; +import {gridColumnDefinitionsSelector, gridColumnVisibilityModelSelector} from "@mui/x-data-grid/hooks/features/columns/gridColumnsSelector"; +import React, {createRef, forwardRef, useEffect, useReducer, useState} from "react"; + +declare module "@mui/x-data-grid" +{ + interface ColumnsPanelPropsOverrides + { + tableMetaData: QTableMetaData; + initialOpenedGroups: { [name: string]: boolean }; + openGroupsChanger: (openedGroups: { [name: string]: boolean }) => void; + initialFilterText: string; + filterTextChanger: (filterText: string) => void; + } +} + +export const CustomColumnsPanel = forwardRef( + function MyCustomColumnsPanel(props: GridSlotsComponentsProps["columnsPanel"], ref) + { + const apiRef = useGridApiContext(); + const columns = useGridSelector(apiRef, gridColumnDefinitionsSelector); + const columnVisibilityModel = useGridSelector(apiRef, gridColumnVisibilityModelSelector); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + const someRef = createRef(); + + const [openGroups, setOpenGroups] = useState(props.initialOpenedGroups || {}); + const openGroupsBecauseOfFilter = {} as { [name: string]: boolean }; + const [lastScrollTop, setLastScrollTop] = useState(0); + const [filterText, setFilterText] = useState(props.initialFilterText); + + ///////////////////////////////////////////////////////////////////// + // set up the list of tables - e.g., main table plus exposed joins // + ///////////////////////////////////////////////////////////////////// + const tables: QTableMetaData[] = []; + tables.push(props.tableMetaData); + + console.log(`Open groups: ${JSON.stringify(openGroups)}`); + + if (props.tableMetaData.exposedJoins) + { + for (let i = 0; i < props.tableMetaData.exposedJoins.length; i++) + { + tables.push(props.tableMetaData.exposedJoins[i].joinTable); + } + } + + const isCheckboxColumn = (column: GridColDef): boolean => + { + return (column.headerName == "Checkbox selection"); + }; + + const doesColumnMatchFilterText = (column: GridColDef): boolean => + { + if (isCheckboxColumn(column)) + { + ////////////////////////////////////////// + // let's never show the checkbox column // + ////////////////////////////////////////// + return (false); + } + + if (filterText == "") + { + return (true); + } + + const columnLabelMinusTable = column.headerName.replace(/.*: /, ""); + if (columnLabelMinusTable.toLowerCase().startsWith(filterText.toLowerCase())) + { + return (true); + } + + try + { + //////////////////////////////////////////////////////////// + // try to match word-boundary followed by the filter text // + // e.g., "name" would match "First Name" or "Last Name" // + //////////////////////////////////////////////////////////// + const re = new RegExp("\\b" + filterText.toLowerCase()); + if (columnLabelMinusTable.toLowerCase().match(re)) + { + return (true); + } + } + catch(e) + { + ////////////////////////////////////////////////////////////////////////////////// + // in case text is an invalid regex... well, at least do a starts-with match... // + ////////////////////////////////////////////////////////////////////////////////// + if (columnLabelMinusTable.toLowerCase().startsWith(filterText.toLowerCase())) + { + return (true); + } + } + + return (false); + }; + + /////////////////////////////////////////////////////////////////////////////// + // build the map of list of fields, plus counts of columns & visible columns // + /////////////////////////////////////////////////////////////////////////////// + const tableFields: { [tableName: string]: GridColDef[] } = {}; + const noOfColumnsByTable: { [name: string]: number } = {}; + const noOfVisibleColumnsByTable: { [name: string]: number } = {}; + + for (let i = 0; i < tables.length; i++) + { + const tableName = tables[i].name; + tableFields[tableName] = []; + noOfColumnsByTable[tableName] = 0; + noOfVisibleColumnsByTable[tableName] = 0; + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // always sort columns by label. note, in future may offer different sorts - here's where to do it. // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + const sortedColumns = [... columns]; + sortedColumns.sort((a, b): number => + { + return a.headerName.localeCompare(b.headerName); + }) + + for (let i = 0; i < sortedColumns.length; i++) + { + const column = sortedColumns[i]; + if (isCheckboxColumn(column)) + { + //////////////////////////////////////////////////////////////// + // don't count the checkbox or put it in the list for display // + //////////////////////////////////////////////////////////////// + continue; + } + + let tableName = props.tableMetaData.name; + const fieldName = column.field; + if (fieldName.indexOf(".") > -1) + { + tableName = fieldName.split(".", 2)[0]; + } + + tableFields[tableName].push(column); + + if (doesColumnMatchFilterText(column)) + { + noOfColumnsByTable[tableName]++; + if (columnVisibilityModel[column.field] !== false) + { + noOfVisibleColumnsByTable[tableName]++; + } + } + + if (filterText != "") + { + /////////////////////////////////////////////////////////////////////////////////////////// + // if there's a filter, then force open any groups (tables) with a field that matches it // + /////////////////////////////////////////////////////////////////////////////////////////// + if (doesColumnMatchFilterText(column)) + { + openGroupsBecauseOfFilter[tableName] = true; + } + } + } + + useEffect(() => + { + if (someRef && someRef.current) + { + console.log(`Trying to set scroll top to: ${lastScrollTop}`); + // @ts-ignore + someRef.current.scrollTop = lastScrollTop; + } + }, [lastScrollTop]); + + /******************************************************************************* + ** event handler for toggling the open/closed status of a group (table) + *******************************************************************************/ + const toggleColumnGroupOpen = (groupName: string) => + { + ///////////////////////////////////////////////////////////// + // if there's a filter, we don't do the normal toggling... // + ///////////////////////////////////////////////////////////// + if (filterText != "") + { + return; + } + + openGroups[groupName] = !!!openGroups[groupName]; + + const newOpenGroups = JSON.parse(JSON.stringify(openGroups)); + setOpenGroups(newOpenGroups); + props.openGroupsChanger(newOpenGroups); + + forceUpdate(); + }; + + /******************************************************************************* + ** event handler for toggling visibility state of one column + *******************************************************************************/ + const onColumnVisibilityChange = (fieldName: string) => + { + // @ts-ignore + setLastScrollTop(someRef.current.scrollTop); + + apiRef.current.setColumnVisibility(fieldName, columnVisibilityModel[fieldName] === false); + }; + + /******************************************************************************* + ** event handler for clicking table-visibility switch + *******************************************************************************/ + const onTableVisibilityClick = (event: React.MouseEvent, tableName: string) => + { + event.stopPropagation(); + + // @ts-ignore + setLastScrollTop(someRef.current.scrollTop); + + let newValue = true; + if (noOfVisibleColumnsByTable[tableName] == noOfColumnsByTable[tableName]) + { + newValue = false; + } + + for (let i = 0; i < columns.length; i++) + { + const column = columns[i]; + if (isCheckboxColumn(column)) + { + ///////////////////////////////// + // never turn the checkbox off // + ///////////////////////////////// + columnVisibilityModel[column.field] = true; + } + else + { + const fieldName = column.field; + if (fieldName.indexOf(".") > -1) + { + if (tableName === fieldName.split(".", 2)[0] && doesColumnMatchFilterText(column)) + { + columnVisibilityModel[fieldName] = newValue; + } + } + else if (tableName == props.tableMetaData.name && doesColumnMatchFilterText(column)) + { + columnVisibilityModel[fieldName] = newValue; + } + } + } + + ////////////////////////////////////////////////////////////////////////////// + // not too sure what this is doing... kinda got it from toggleAllColumns in // + // ./@mui/x-data-grid/components/panel/GridColumnsPanel.js // + ////////////////////////////////////////////////////////////////////////////// + const currentModel = gridColumnVisibilityModelSelector(apiRef); + const newModel = JSON.parse(JSON.stringify(currentModel)); + apiRef.current.setColumnVisibilityModel(newModel); + }; + + /******************************************************************************* + ** event handler for reset button - turn on only all columns from main table + *******************************************************************************/ + const resetClicked = () => + { + // @ts-ignore + setLastScrollTop(someRef.current.scrollTop); + + for (let i = 0; i < columns.length; i++) + { + const column = columns[i]; + const fieldName = column.field; + if (fieldName.indexOf(".") > -1) + { + columnVisibilityModel[fieldName] = false; + } + else + { + columnVisibilityModel[fieldName] = true; + } + } + + const currentModel = gridColumnVisibilityModelSelector(apiRef); + const newModel = JSON.parse(JSON.stringify(currentModel)); + apiRef.current.setColumnVisibilityModel(newModel); + }; + + const changeFilterText = (newValue: string) => + { + setFilterText(newValue); + props.filterTextChanger(newValue) + }; + + const filterTextChanged = (event: React.ChangeEvent) => + { + changeFilterText(event.target.value); + }; + + return ( + + + filterTextChanged(event)} + > + { + filterText != "" && + { + changeFilterText(""); + document.getElementById("findColumn").focus(); + }}>close + } + + + + + + {tables.map((table: QTableMetaData) => + ( + + toggleColumnGroupOpen(table.name)} + sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5}} + disableRipple={true} + > + {filterText != "" ? "horizontal_rule" : openGroups[table.name] ? "expand_more" : "expand_less"} + + 0} + onClick={(event) => onTableVisibilityClick(event, table.name)} + size="small" /> + + + {table.label} fields  + ({noOfVisibleColumnsByTable[table.name]} / {noOfColumnsByTable[table.name]}) + + + + {(openGroups[table.name] || openGroupsBecauseOfFilter[table.name]) && tableFields[table.name].map((gridColumn: any) => + { + if (doesColumnMatchFilterText(gridColumn)) + { + return ( + + onColumnVisibilityChange(gridColumn.field)} + size="small" />} + label={{gridColumn.headerName.replace(/.*: /, "")}} /> + + ); + } + } + )} + + ))} + + + + + + + + ); + } +); + diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index a28910b..7e01c35 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -46,12 +46,9 @@ import ListItemIcon from "@mui/material/ListItemIcon"; import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; -import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowProps, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro"; -import {GridColumnsPanelProps} from "@mui/x-data-grid/components/panel/GridColumnsPanel"; -import {gridColumnDefinitionsSelector, gridColumnVisibilityModelSelector} from "@mui/x-data-grid/hooks/features/columns/gridColumnsSelector"; import {GridRowModel} from "@mui/x-data-grid/models/gridRows"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; @@ -60,6 +57,7 @@ import QContext from "QContext"; import {QActionsMenuButton, QCancelButton, QCreateNewButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import MenuButton from "qqq/components/buttons/MenuButton"; import SavedFilters from "qqq/components/misc/SavedFilters"; +import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -163,6 +161,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [density, setDensity] = useState(defaultDensity); const [pinnedColumns, setPinnedColumns] = useState(defaultPinnedColumns); + const initialColumnChooserOpenGroups = {} as { [name: string]: boolean }; + initialColumnChooserOpenGroups[tableName] = true; + const [columnChooserOpenGroups, setColumnChooserOpenGroups] = useState(initialColumnChooserOpenGroups); + const [columnChooserFilterText, setColumnChooserFilterText] = useState(""); + const [tableState, setTableState] = useState(""); const [metaData, setMetaData] = useState(null as QInstance); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); @@ -316,7 +319,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /////////////////////////////////////////////////////////////////////// if (tableMetaData && tableMetaData.name !== tableName) { - console.log(" it looks like we changed tables - try to reload the things"); setTableMetaData(null); setColumnSortModel([]); setColumnVisibilityModel({}); @@ -833,10 +835,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setColumnVisibilityModel(columnVisibilityModel); if (columnVisibilityLocalStorageKey) { - localStorage.setItem( - columnVisibilityLocalStorageKey, - JSON.stringify(columnVisibilityModel), - ); + localStorage.setItem(columnVisibilityLocalStorageKey, JSON.stringify(columnVisibilityModel)); } }; @@ -1357,95 +1356,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); }); - //////////////////////////////////////////////////////////////////////////// - // this is a WIP example of how we could do a custom "columns" panel/menu // - //////////////////////////////////////////////////////////////////////////// - const CustomColumnsPanel = forwardRef( - function MyCustomColumnsPanel(props: GridColumnsPanelProps, ref) - { - const apiRef = useGridApiContext(); - const columns = useGridSelector(apiRef, gridColumnDefinitionsSelector); - const columnVisibilityModel = useGridSelector(apiRef, gridColumnVisibilityModelSelector); - - const [openGroups, setOpenGroups] = useState({} as { [name: string]: boolean }); - - const groups = ["Order", "Line Item"]; - - const onColumnVisibilityChange = (fieldName: string) => - { - /* - if(columnVisibilityModel[fieldName] === undefined) - { - columnVisibilityModel[fieldName] = true; - } - columnVisibilityModel[fieldName] = !columnVisibilityModel[fieldName]; - setColumnVisibilityModel(JSON.parse(JSON.stringify(columnVisibilityModel))) - */ - - console.log(`${fieldName} = ${columnVisibilityModel[fieldName]}`); - // columnVisibilityModel[fieldName] = Math.random() < 0.5; - apiRef.current.setColumnVisibility(fieldName, columnVisibilityModel[fieldName] === false); - // handleColumnVisibilityChange(JSON.parse(JSON.stringify(columnVisibilityModel))); - }; - - const toggleColumnGroup = (groupName: string) => - { - if (openGroups[groupName] === undefined) - { - openGroups[groupName] = true; - } - openGroups[groupName] = !openGroups[groupName]; - setOpenGroups(JSON.parse(JSON.stringify(openGroups))); - }; - - return ( -
- - - - - - - - {groups.map((groupName: string) => - ( - <> - toggleColumnGroup(groupName)} - sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem"}} - disableRipple={true} - > - {openGroups[groupName] === false ? "expand_less" : "expand_more"} - {groupName} fields - - - {openGroups[groupName] !== false && columnsModel.map((gridColumn: any) => ( - onColumnVisibilityChange(gridColumn.field)} - sx={{width: "100%", justifyContent: "flex-start", fontSize: "0.875rem", pl: "1.375rem"}} - disableRipple={true} - > - {columnVisibilityModel[gridColumn.field] === false ? "visibility_off" : "visibility"} - {gridColumn.headerName} - - ))} - - ))} - - - - - - - -
- ); - } - ); const safeToLocaleString = (n: Number): string => { @@ -1853,7 +1763,23 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element