From 8ed5a71f19997dc69a76c6ed89c0e48818a42049 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 14 Apr 2023 10:05:40 -0500 Subject: [PATCH 01/13] WIP on showing tables (joins) in columns panel --- src/qqq/pages/records/query/RecordQuery.tsx | 102 +++++++++++++++++++- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 78ff247..7bdce1f 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -38,13 +38,19 @@ import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import Divider from "@mui/material/Divider"; import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; import LinearProgress from "@mui/material/LinearProgress"; 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, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro"; +import Typography from "@mui/material/Typography"; +import {DataGridPro, GridCallbackDetails, GridColDef, gridColumnGroupingSelector, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, 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 FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; @@ -104,7 +110,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const columnVisibilityLocalStorageKey = `${COLUMN_VISIBILITY_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; const filterLocalStorageKey = `${FILTER_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; let defaultSort = [] as GridSortItem[]; - let defaultVisibility = {}; + let defaultVisibility = {} as { [index: string]: boolean }; let defaultRowsPerPage = 10; let defaultDensity = "standard" as GridDensity; let defaultPinnedColumns = {left: ["__check__", "id"]} as GridPinnedColumns; @@ -185,6 +191,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [ queryErrors, setQueryErrors ] = useState({} as any); const [ receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp ] = useState(new Date()); + const {setPageHeader} = useContext(QContext); const [ , forceUpdate ] = useReducer((x) => x + 1, 0); @@ -667,7 +674,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element columnsModel.forEach((gridColumn) => { const fieldName = gridColumn.field; - // @ts-ignore if (columnVisibilityModel[fieldName] !== false) { visibleFields.push(fieldName); @@ -1054,6 +1060,94 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); }); + 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))); + } + + console.log("re-render"); + 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} + + ))} + + ))} + + + + + + + +
+ ); + } + ) + function CustomToolbar() { const handleMouseDown: GridEventListener<"cellMouseDown"> = ( @@ -1344,7 +1438,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Date: Mon, 24 Apr 2023 12:30:19 -0500 Subject: [PATCH 02/13] Joins on Record Query; Count action w/ distinct input/output; JSX Element option for pageHeader --- package.json | 2 +- src/App.tsx | 4 +- src/QContext.tsx | 4 +- src/qqq/components/audits/AuditBody.tsx | 4 +- .../widgets/misc/RecordGridWidget.tsx | 4 +- src/qqq/pages/apps/Home.tsx | 2 +- src/qqq/pages/records/query/ColumnStats.tsx | 4 +- src/qqq/pages/records/query/RecordQuery.tsx | 394 ++++++++++++++---- src/qqq/utils/DataGridUtils.tsx | 96 +++-- src/qqq/utils/qqq/ValueUtils.tsx | 8 +- 10 files changed, 380 insertions(+), 142 deletions(-) diff --git a/package.json b/package.json index 3d122a7..cf508cf 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.57", + "@kingsrook/qqq-frontend-core": "1.0.58", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/App.tsx b/src/App.tsx index 425d381..98321cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -549,7 +549,7 @@ export default function App() }, ); - const [pageHeader, setPageHeader] = useState(""); + const [pageHeader, setPageHeader] = useState("" as string | JSX.Element); const [accentColor, setAccentColor] = useState("#0062FF"); return ( @@ -557,7 +557,7 @@ export default function App() setPageHeader(header), + setPageHeader: (header: string | JSX.Element) => setPageHeader(header), setAccentColor: (accentColor: string) => setAccentColor(accentColor) }}> diff --git a/src/QContext.tsx b/src/QContext.tsx index 673476e..aaf83b2 100644 --- a/src/QContext.tsx +++ b/src/QContext.tsx @@ -24,8 +24,8 @@ import {createContext} from "react"; interface QContext { - pageHeader: string; - setPageHeader?: (header: string) => void; + pageHeader: string | JSX.Element; + setPageHeader?: (header: string | JSX.Element) => void; accentColor: string; setAccentColor?: (header: string) => void; } diff --git a/src/qqq/components/audits/AuditBody.tsx b/src/qqq/components/audits/AuditBody.tsx index e26c99f..90cb0db 100644 --- a/src/qqq/components/audits/AuditBody.tsx +++ b/src/qqq/components/audits/AuditBody.tsx @@ -222,8 +222,8 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element // if we fetched the limit if (audits.length == limit) { - const count = await qController.count("audit", filter); - setTotal(count); + const [count, distinctCount] = await qController.count("audit", filter, null, true); // todo validate distinct working here! + setTotal(distinctCount); } ////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index fbb1f72..c167468 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -60,13 +60,13 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element } const tableMetaData = new QTableMetaData(data.childTableMetaData); - const {rows, columnsToRender} = DataGridUtils.makeRows(records, tableMetaData); + const rows = DataGridUtils.makeRows(records, tableMetaData); ///////////////////////////////////////////////////////////////////////////////// // note - tablePath may be null, if the user doesn't have access to the table. // ///////////////////////////////////////////////////////////////////////////////// const childTablePath = data.tablePath ? data.tablePath + (data.tablePath.endsWith("/") ? "" : "/") : data.tablePath; - const columns = DataGridUtils.setupGridColumns(tableMetaData, columnsToRender, childTablePath); + const columns = DataGridUtils.setupGridColumns(tableMetaData, childTablePath); //////////////////////////////////////////////////////////////// // do not not show the foreign-key column of the parent table // diff --git a/src/qqq/pages/apps/Home.tsx b/src/qqq/pages/apps/Home.tsx index 055f6b7..db4f326 100644 --- a/src/qqq/pages/apps/Home.tsx +++ b/src/qqq/pages/apps/Home.tsx @@ -127,7 +127,7 @@ function AppHome({app}: Props): JSX.Element let countResult = null; if(tableMetaData.capabilities.has(Capability.TABLE_COUNT) && tableMetaData.readPermission) { - countResult = await qController.count(table.name); + [countResult] = await qController.count(table.name); if (countResult !== null && countResult !== undefined) { diff --git a/src/qqq/pages/records/query/ColumnStats.tsx b/src/qqq/pages/records/query/ColumnStats.tsx index c42c91e..36f4dd5 100644 --- a/src/qqq/pages/records/query/ColumnStats.tsx +++ b/src/qqq/pages/records/query/ColumnStats.tsx @@ -126,8 +126,8 @@ function ColumnStats({tableMetaData, fieldMetaData, filter}: Props): JSX.Element fakeTableMetaData.sections = [] as QTableSection[]; fakeTableMetaData.sections.push(new QTableSection({fieldNames: [fieldMetaData.name, "count"]})); - const {rows, columnsToRender} = DataGridUtils.makeRows(valueCounts, fakeTableMetaData); - const columns = DataGridUtils.setupGridColumns(fakeTableMetaData, columnsToRender); + const rows = DataGridUtils.makeRows(valueCounts, fakeTableMetaData); + const columns = DataGridUtils.setupGridColumns(fakeTableMetaData); columns.forEach((c) => { c.width = 200; diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 7bdce1f..1aba942 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -27,6 +27,7 @@ import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJo import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin"; import {Alert, Collapse, TablePagination} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; @@ -48,7 +49,7 @@ import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import {DataGridPro, GridCallbackDetails, GridColDef, gridColumnGroupingSelector, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, GridToolbarFilterButton, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector} from "@mui/x-data-grid-pro"; +import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridExportMenuItemProps, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, 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 FormData from "form-data"; @@ -57,6 +58,7 @@ import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; import QContext from "QContext"; import {QActionsMenuButton, QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons"; import SavedFilters from "qqq/components/misc/SavedFilters"; +import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; import ColumnStats from "qqq/pages/records/query/ColumnStats"; @@ -90,10 +92,10 @@ const qController = Client.getInstance(); function RecordQuery({table, launchProcess}: Props): JSX.Element { const tableName = table.name; - const [ searchParams ] = useSearchParams(); + const [searchParams] = useSearchParams(); const [showSuccessfullyDeletedAlert, setShowSuccessfullyDeletedAlert] = useState(searchParams.has("deleteSuccess")); - const [successAlert, setSuccessAlert] = useState(null as string) + const [successAlert, setSuccessAlert] = useState(null as string); const location = useLocation(); const navigate = useNavigate(); @@ -111,6 +113,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const filterLocalStorageKey = `${FILTER_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; let defaultSort = [] as GridSortItem[]; let defaultVisibility = {} as { [index: string]: boolean }; + let didDefaultVisibilityComeFromLocalStorage = false; let defaultRowsPerPage = 10; let defaultDensity = "standard" as GridDensity; let defaultPinnedColumns = {left: ["__check__", "id"]} as GridPinnedColumns; @@ -127,6 +130,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (localStorage.getItem(columnVisibilityLocalStorageKey)) { defaultVisibility = JSON.parse(localStorage.getItem(columnVisibilityLocalStorageKey)); + didDefaultVisibilityComeFromLocalStorage = true; } if (localStorage.getItem(pinnedColumnsLocalStorageKey)) { @@ -144,6 +148,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); const [columnSortModel, setColumnSortModel] = useState(defaultSort); const [columnVisibilityModel, setColumnVisibilityModel] = useState(defaultVisibility); + const [didDefaultVisibilityModelComeFromLocalStorage, setDidDefaultVisibilityModelComeFromLocalStorage] = useState(didDefaultVisibilityComeFromLocalStorage); + const [visibleJoinTables, setVisibleJoinTables] = useState(new Set()); const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage); const [density, setDensity] = useState(defaultDensity); const [pinnedColumns, setPinnedColumns] = useState(defaultPinnedColumns); @@ -157,6 +163,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]); const [pageNumber, setPageNumber] = useState(0); const [totalRecords, setTotalRecords] = useState(null); + const [distinctRecords, setDistinctRecords] = useState(null); const [selectedIds, setSelectedIds] = useState([] as string[]); const [selectFullFilterState, setSelectFullFilterState] = useState("n/a" as "n/a" | "checked" | "filter"); const [columnsModel, setColumnsModel] = useState([] as GridColDef[]); @@ -174,26 +181,26 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData); const [launchingProcess, setLaunchingProcess] = useState(launchProcess); const [recordIdsForProcess, setRecordIdsForProcess] = useState(null as string | QQueryFilter); - const [columnStatsFieldName, setColumnStatsFieldName] = useState(null as string) - const [filterForColumnStats, setFilterForColumnStats] = useState(null as QQueryFilter) + const [columnStatsFieldName, setColumnStatsFieldName] = useState(null as string); + const [filterForColumnStats, setFilterForColumnStats] = useState(null as QQueryFilter); const instance = useRef({timer: null}); //////////////////////////////////////////////////////////////////////////////////////////////////////////////// // use all these states to avoid showing results from an "old" query, that finishes loading after a newer one // //////////////////////////////////////////////////////////////////////////////////////////////////////////////// - const [ latestQueryId, setLatestQueryId ] = useState(0); - const [ countResults, setCountResults ] = useState({} as any); - const [ receivedCountTimestamp, setReceivedCountTimestamp ] = useState(new Date()); - const [ queryResults, setQueryResults ] = useState({} as any); - const [ latestQueryResults, setLatestQueryResults ] = useState(null as QRecord[]); - const [ receivedQueryTimestamp, setReceivedQueryTimestamp ] = useState(new Date()); - const [ queryErrors, setQueryErrors ] = useState({} as any); - const [ receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp ] = useState(new Date()); + const [latestQueryId, setLatestQueryId] = useState(0); + const [countResults, setCountResults] = useState({} as any); + const [receivedCountTimestamp, setReceivedCountTimestamp] = useState(new Date()); + const [queryResults, setQueryResults] = useState({} as any); + const [latestQueryResults, setLatestQueryResults] = useState(null as QRecord[]); + const [receivedQueryTimestamp, setReceivedQueryTimestamp] = useState(new Date()); + const [queryErrors, setQueryErrors] = useState({} as any); + const [receivedQueryErrorTimestamp, setReceivedQueryErrorTimestamp] = useState(new Date()); const {setPageHeader} = useContext(QContext); - const [ , forceUpdate ] = useReducer((x) => x + 1, 0); + const [, forceUpdate] = useReducer((x) => x + 1, 0); const openActionsMenu = (event: any) => setActionsMenu(event.currentTarget); const closeActionsMenu = () => setActionsMenu(null); @@ -218,7 +225,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setActiveModalProcess(processList[0]); return; } - else if(metaData?.processes.has(processName)) + else if (metaData?.processes.has(processName)) { /////////////////////////////////////////////////////////////////////////////////////// // check for generic processes - should this be a specific attribute on the process? // @@ -237,12 +244,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // so if path has '/savedFilter/' get last parsed string // ///////////////////////////////////////////////////////////////////// let currentSavedFilterId = null as number; - if(location.pathname.indexOf("/savedFilter/") != -1) + if (location.pathname.indexOf("/savedFilter/") != -1) { const parts = location.pathname.split("/"); currentSavedFilterId = Number.parseInt(parts[parts.length - 1]); } - else if(!searchParams.has("filter")) + else if (!searchParams.has("filter")) { if (localStorage.getItem(currentSavedFilterLocalStorageKey)) { @@ -255,7 +262,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } } - if(currentSavedFilterId != null) + if (currentSavedFilterId != null) { (async () => { @@ -296,15 +303,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element //////////////////////////////////////////////////////////////////////////////////// setActiveModalProcess(null); - }, [location , tableMetaData]); + }, [location, tableMetaData]); /////////////////////////////////////////////////////////////////////// // any time these are out of sync, it means we need to reload things // /////////////////////////////////////////////////////////////////////// - if(tableMetaData && tableMetaData.name !== tableName) + if (tableMetaData && tableMetaData.name !== tableName) { console.log(" it looks like we changed tables - try to reload the things"); - setTableMetaData(null) + setTableMetaData(null); setColumnSortModel([]); setColumnVisibilityModel({}); setColumnsModel([]); @@ -322,7 +329,90 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { const filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel); setHasValidFilters(filter.criteria && filter.criteria.length > 0); - return(filter); + return (filter); + }; + + const getVisibleJoinTables = (): Set => + { + const visibleJoinTables = new Set(); + columnsModel.forEach((gridColumn) => + { + const fieldName = gridColumn.field; + if (columnVisibilityModel[fieldName] !== false) + { + if (fieldName.indexOf(".") > -1) + { + visibleJoinTables.add(fieldName.split(".")[0]); + } + } + }); + return (visibleJoinTables); + }; + + const isJoinMany = (tableMetaData: QTableMetaData, visibleJoinTables: Set): boolean => + { + if (tableMetaData?.exposedJoins) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + if (visibleJoinTables.has(join.joinTable.name)) + { + if(join.isMany) + { + return (true); + } + } + } + } + return (false); + } + + const getPageHeader = (tableMetaData: QTableMetaData, visibleJoinTables: Set): string | JSX.Element => + { + if (visibleJoinTables.size > 0) + { + let joinLabels = []; + if (tableMetaData?.exposedJoins) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + if (visibleJoinTables.has(join.joinTable.name)) + { + joinLabels.push(join.label); + } + } + } + + let joinLabelsString = joinLabels.join(", "); + if(joinLabels.length == 2) + { + let lastCommaIndex = joinLabelsString.lastIndexOf(","); + joinLabelsString = joinLabelsString.substring(0, lastCommaIndex) + " and " + joinLabelsString.substring(lastCommaIndex + 1); + } + if(joinLabels.length > 2) + { + let lastCommaIndex = joinLabelsString.lastIndexOf(","); + joinLabelsString = joinLabelsString.substring(0, lastCommaIndex) + ", and " + joinLabelsString.substring(lastCommaIndex + 1); + } + + let tooltipHTML =
+ You are viewing results from the {tableMetaData.label} table joined with the {joinLabelsString} table{joinLabels.length == 1 ? "" : "s"} +
+ + return( +
+ {tableMetaData?.label} + + emergency + +
); + } + else + { + return (tableMetaData?.label); + } }; const updateTable = () => @@ -332,7 +422,29 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element (async () => { const tableMetaData = await qController.loadTableMetaData(tableName); - setPageHeader(tableMetaData.label); + + const visibleJoinTables = getVisibleJoinTables(); + setPageHeader(getPageHeader(tableMetaData, visibleJoinTables)); + + if (!didDefaultVisibilityModelComeFromLocalStorage) + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if we didn't load the column visibility from local storage, then by default, it'll be an empty array, and all fields will be visible. // + // but - if the table has join tables, we don't want them on by default, so, flip them off! // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (tableMetaData?.exposedJoins) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + for (let fieldName of join.joinTable.fields.keys()) + { + columnVisibilityModel[`${join.joinTable.name}.${fieldName}`] = false; + } + } + } + setColumnVisibilityModel(columnVisibilityModel); + } //////////////////////////////////////////////////////////////////////////////////////////////// // we need the table meta data to look up the default filter (if it comes from query string), // @@ -353,14 +465,37 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setTableMetaData(tableMetaData); setTableLabel(tableMetaData.label); - if(columnsModel.length == 0) + if (columnsModel.length == 0) { - let linkBase = metaData.getTablePath(table) + let linkBase = metaData.getTablePath(table); linkBase += linkBase.endsWith("/") ? "" : "/"; - const columns = DataGridUtils.setupGridColumns(tableMetaData, null, linkBase); + const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase); setColumnsModel(columns); } + ////////////////////////////////////////////////////////////////////////////////////////////////// + // make sure that any if any sort columns are from a join table, that the join table is visible // + ////////////////////////////////////////////////////////////////////////////////////////////////// + let resetColumnSortModel = false; + for (let i = 0; i < columnSortModel.length; i++) + { + const gridSortItem = columnSortModel[i]; + if (gridSortItem.field.indexOf(".") > -1) + { + const tableName = gridSortItem.field.split(".")[0]; + if (!visibleJoinTables?.has(tableName)) + { + columnSortModel.splice(i, 1); + setColumnSortModel(columnSortModel); + resetColumnSortModel = true; + i--; + } + } + } + + /////////////////////////////////////////////////////////// + // if there's no column sort, make a default - pkey desc // + /////////////////////////////////////////////////////////// if (columnSortModel.length === 0) { columnSortModel.push({ @@ -368,10 +503,48 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element sort: "desc", }); setColumnSortModel(columnSortModel); + resetColumnSortModel = true; + } + + if (resetColumnSortModel && latestQueryId > 0) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // let the next render (since columnSortModel is watched below) build the filter, using the new columnSort // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + return; } const qFilter = buildQFilter(tableMetaData, localFilterModel); + ////////////////////////////////////////// + // figure out joins to use in the query // + ////////////////////////////////////////// + let queryJoins = null; + if (tableMetaData?.exposedJoins) + { + const visibleJoinTables = getVisibleJoinTables(); + + queryJoins = []; + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + if (visibleJoinTables.has(join.joinTable.name)) + { + queryJoins.push(new QueryJoin(join.joinTable.name, true, "LEFT")); + } + } + } + + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // before we can issue the query, we must have the columns model (to figure out if we need to join). // + // so, if we don't have it, then return and let a later call do it. // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + if(!columnsModel || columnsModel.length == 0) + { + console.log("Returning before issuing query, because no columnsModel."); + return; + } + ////////////////////////////////////////////////////////////////////////////////////////////////// // assign a new query id to the query being issued here. then run both the count & query async // // and when they load, store their results associated with this id. // @@ -382,15 +555,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(`Issuing query: ${thisQueryId}`); if (tableMetaData.capabilities.has(Capability.TABLE_COUNT)) { - qController.count(tableName, qFilter).then((count) => + let includeDistinct = isJoinMany(tableMetaData, getVisibleJoinTables()); + qController.count(tableName, qFilter, queryJoins, includeDistinct).then(([count, distinctCount]) => { - countResults[thisQueryId] = count; + console.log(`Received count results for query ${thisQueryId}: ${count} ${distinctCount}`); + countResults[thisQueryId] = []; + countResults[thisQueryId].push(count); + countResults[thisQueryId].push(distinctCount); setCountResults(countResults); setReceivedCountTimestamp(new Date()); }); } - qController.query(tableName, qFilter, rowsPerPage, pageNumber * rowsPerPage).then((results) => + qController.query(tableName, qFilter, rowsPerPage, pageNumber * rowsPerPage, queryJoins).then((results) => { console.log(`Received results for query ${thisQueryId}`); queryResults[thisQueryId] = results; @@ -430,7 +607,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /////////////////////////// useEffect(() => { - if (countResults[latestQueryId] === null) + if (countResults[latestQueryId] == null || countResults[latestQueryId].length == 0) { /////////////////////////////////////////////// // see same idea in displaying query results // @@ -438,9 +615,17 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(`No count results for id ${latestQueryId}...`); return; } - setTotalRecords(countResults[latestQueryId]); - delete countResults[latestQueryId]; - }, [ receivedCountTimestamp ]); + try + { + setTotalRecords(countResults[latestQueryId][0]); + setDistinctRecords(countResults[latestQueryId][1]); + delete countResults[latestQueryId]; + } + catch(e) + { + console.log(e); + } + }, [receivedCountTimestamp]); /////////////////////////// // display query results // @@ -462,14 +647,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element delete queryResults[latestQueryId]; setLatestQueryResults(results); - const {rows, columnsToRender} = DataGridUtils.makeRows(results, tableMetaData); - + const rows = DataGridUtils.makeRows(results, tableMetaData); setRows(rows); setLoading(false); setAlertContent(null); forceUpdate(); - }, [ receivedQueryTimestamp ]); + }, [receivedQueryTimestamp]); ///////////////////////// // display query error // @@ -491,7 +675,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setLoading(false); setAlertContent(errorMessage); - }, [ receivedQueryErrorTimestamp ]); + }, [receivedQueryErrorTimestamp]); const handlePageChange = (page: number) => @@ -550,7 +734,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element clearTimeout(instance.current.timer); instance.current.timer = setTimeout(() => { - if(table.primaryKeyField !== "id") + if (table.primaryKeyField !== "id") { navigate(`${metaData.getTablePathByName(tableName)}/${params.row[tableMetaData.primaryKeyField]}`); } @@ -573,7 +757,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element selectionModel.forEach((value: GridRowId, index: number) => { let valueToPush = value as string; - if(tableMetaData.primaryKeyField !== "id") + if (tableMetaData.primaryKeyField !== "id") { valueToPush = latestQueryResults[index].values.get(tableMetaData.primaryKeyField); } @@ -603,6 +787,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if, after a column was turned on or off, the set of visibleJoinTables is changed, then update the table // + // check this on each render - it should only be different if there was a change. note that putting this // + // in handleColumnVisibilityChange "didn't work" - it was always "behind by one" (like, maybe data grid // + // calls that function before it updates the visible model or some-such). // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + const newVisibleJoinTables = getVisibleJoinTables(); + if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) + { + updateTable(); + setVisibleJoinTables(newVisibleJoinTables); + } + const handleColumnOrderChange = (columnOrderChangeParams: GridColumnOrderChangeParams) => { // TODO: make local storaged @@ -689,7 +886,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // construct the url for the export // ////////////////////////////////////// const d = new Date(); - const dateString = `${d.getFullYear()}-${zp(d.getMonth()+1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; + const dateString = `${d.getFullYear()}-${zp(d.getMonth() + 1)}-${zp(d.getDate())} ${zp(d.getHours())}${zp(d.getMinutes())}`; const filename = `${tableMetaData.label} Export ${dateString}.${format}`; const url = `/data/${tableMetaData.name}/export/${filename}?filter=${encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel)))}&fields=${visibleFields.join(",")}`; @@ -748,9 +945,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (selectFullFilterState === "filter") { + // todo - distinct? return (totalRecords); } + // todo - distinct? return (selectedIds.length); } @@ -866,13 +1065,25 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // @ts-ignore const defaultLabelDisplayedRows = ({from, to, count}) => { - if(tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT)) + const tooltipHTML = <> + The number of rows shown on this screen may be greater than the number of {tableMetaData?.label} records + that match your query, because you have included fields from other tables which may have + more than one record associated with each {tableMetaData?.label}. + + let distinctPart = isJoinMany(tableMetaData, getVisibleJoinTables()) ? ( +  ({distinctRecords} distinct + info_outlined + + ) + ) : <>; + + if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT)) { //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // to avoid a non-countable table showing (this is what data-grid did) 91-100 even if there were only 95 records, // // we'll do this... not quite good enough, but better than the original // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(rows.length > 0 && rows.length < to - from) + if (rows.length > 0 && rows.length < to - from) { to = from + rows.length; } @@ -886,13 +1097,21 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (count === 0) { - return (loading ? "Counting records..." : "No rows"); + return (loading ? "Counting..." : "No rows"); } - return (`Showing ${from.toLocaleString()} to ${to.toLocaleString()} of ${count !== -1 ? `${count.toLocaleString()} records` : `more than ${to.toLocaleString()} records`}`); + + return <> + Showing {from.toLocaleString()} to {to.toLocaleString()} of + { + count == -1 ? + <>more than {to.toLocaleString()} + : <> {count.toLocaleString()}{distinctPart} + } + ; } else { - return ("Counting records..."); + return ("Counting..."); } }; @@ -903,9 +1122,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element component="div" // note - passing null here makes the 'to' param in the defaultLabelDisplayedRows also be null, // so pass some sentinel value... + // todo - distinct? count={totalRecords === null || totalRecords === undefined ? -1 : totalRecords} page={pageNumber} - rowsPerPageOptions={[ 10, 25, 50, 100, 250 ]} + rowsPerPageOptions={[10, 25, 50, 100, 250]} rowsPerPage={rowsPerPage} onPageChange={(event, value) => handlePageChange(value)} onRowsPerPageChange={(event) => handleRowsPerPageChange(Number(event.target.value))} @@ -923,7 +1143,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element async function handleSavedFilterChange(selectedSavedFilterId: number) { - if(selectedSavedFilterId != null) + if (selectedSavedFilterId != null) { const qRecord = await fetchSavedFilter(selectedSavedFilterId); const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null); @@ -939,7 +1159,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } } - async function fetchSavedFilter(filterId: number):Promise + async function fetchSavedFilter(filterId: number): Promise { let qRecord = null; const formData = new FormData(); @@ -966,30 +1186,30 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element qRecord = new QRecord(result.values.savedFilterList[0]); } - return(qRecord); + return (qRecord); } const copyColumnValues = async (column: GridColDef) => { let data = ""; let counter = 0; - if(latestQueryResults && latestQueryResults.length) + if (latestQueryResults && latestQueryResults.length) { let qFieldMetaData = tableMetaData.fields.get(column.field); - for(let i = 0; i < latestQueryResults.length; i++) + for (let i = 0; i < latestQueryResults.length; i++) { let record = latestQueryResults[i] as QRecord; const value = ValueUtils.getUnadornedValueForDisplay(qFieldMetaData, record.values.get(qFieldMetaData.name), record.displayValues.get(qFieldMetaData.name)); - if(value !== null && value !== undefined && String(value) !== "") + if (value !== null && value !== undefined && String(value) !== "") { data += value + "\n"; counter++; } } - if(counter > 0) + if (counter > 0) { - await navigator.clipboard.writeText(data) + await navigator.clipboard.writeText(data); setSuccessAlert(`Copied ${counter} ${qFieldMetaData.label} value${counter == 1 ? "" : "s"}.`); } else @@ -998,13 +1218,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } setTimeout(() => setSuccessAlert(null), 3000); } - } + }; const openColumnStatistics = async (column: GridColDef) => { setFilterForColumnStats(buildQFilter(tableMetaData, filterModel)); setColumnStatsFieldName(column.field); - } + }; const CustomColumnMenu = forwardRef( function GridColumnMenu(props: GridColumnMenuProps, ref) @@ -1029,13 +1249,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element - + { hideMenu(e); - copyColumnValues(currentColumn) + copyColumnValues(currentColumn); }}> Copy values @@ -1067,7 +1287,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const columns = useGridSelector(apiRef, gridColumnDefinitionsSelector); const columnVisibilityModel = useGridSelector(apiRef, gridColumnVisibilityModelSelector); - const [openGroups, setOpenGroups] = useState({} as {[name: string]: boolean}); + const [openGroups, setOpenGroups] = useState({} as { [name: string]: boolean }); const groups = ["Order", "Line Item"]; @@ -1082,21 +1302,21 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setColumnVisibilityModel(JSON.parse(JSON.stringify(columnVisibilityModel))) */ - console.log(`${fieldName} = ${columnVisibilityModel[fieldName]}`) + 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) + if (openGroups[groupName] === undefined) { openGroups[groupName] = true; } openGroups[groupName] = !openGroups[groupName]; setOpenGroups(JSON.parse(JSON.stringify(openGroups))); - } + }; console.log("re-render"); return ( @@ -1146,7 +1366,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } - ) + ); function CustomToolbar() { @@ -1235,6 +1455,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element records on this page are selected. @@ -1245,6 +1466,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element selectFullFilterState === "filter" && (
All + {/* todo - distinct? */} {` ${totalRecords ? totalRecords.toLocaleString() : ""} `} records matching this query are selected.
@@ -1416,19 +1521,27 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setShowClearFiltersWarning(true)}>clear - setShowClearFiltersWarning(false)}> - Confirm + setShowClearFiltersWarning(false)} onKeyPress={(e) => + { + if (e.key == "Enter") + { + setShowClearFiltersWarning(false) + navigate(metaData.getTablePathByName(tableName)); + handleFilterChange({items: []} as GridFilterModel); + } + }}> + Confirm Are you sure you want to clear all filters? - - + }}/> @@ -1441,34 +1554,67 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element + +
+ + + { + setSelectionSubsetSizePromptOpen(false); + + if(value !== undefined) + { + if(typeof value === "number" && value > 0) + { + programmaticallySelectSomeOrAllRows(value); + setSelectionSubsetSize(value); + setSelectFullFilterState("filterSubset") + } + else + { + setAlertContent("Unexpected value: " + value); + } + } + }} /> +
+
{ selectFullFilterState === "checked" && (
The {` ${selectedIds.length.toLocaleString()} `} - records on this page are selected. - + {joinIsMany ? " distinct " : ""} + record{selectedIds.length == 1 ? "" : "s"} on this page {selectedIds.length == 1 ? "is" : "are"} selected.
) } { selectFullFilterState === "filter" && (
- All - {/* todo - distinct? */} - {` ${totalRecords ? totalRecords.toLocaleString() : ""} `} - records matching this query are selected. - + { + (joinIsMany + ? ( + distinctRecords == 1 + ? (<>The only 1 distinct record matching this query is selected.) + : (<>All {(distinctRecords ? distinctRecords.toLocaleString() : "")} distinct records matching this query are selected.) + ) + : (<>All {totalRecords ? totalRecords.toLocaleString() : ""} records matching this query are selected.) + ) + } +
+ ) + } + { + selectFullFilterState === "filterSubset" && ( +
+ The setSelectionSubsetSizePromptOpen(true)} style={{cursor: "pointer"}}>first {safeToLocaleString(selectionSubsetSize)} {joinIsMany ? "distinct" : ""} record{selectionSubsetSize == 1 ? "" : "s"} matching this query {selectionSubsetSize == 1 ? "is" : "are"} selected. +
+ ) + } + { + (selectFullFilterState === "n/a" && selectedIds.length > 0) && ( +
+ {safeToLocaleString(selectedIds.length)} {joinIsMany ? "distinct" : ""} {selectedIds.length == 1 ? "record is" : "records are"} selected.
) } @@ -1671,7 +1817,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // getRowHeight={() => "auto"} // maybe nice? wraps values in cells... columns={columnsModel} rowBuffer={10} - rowCount={/*todo - distinct?*/totalRecords === null || totalRecords === undefined ? 0 : totalRecords} + rowCount={totalRecords === null || totalRecords === undefined ? 0 : totalRecords} onPageSizeChange={handleRowsPerPageChange} onRowClick={handleRowClick} onStateChange={handleStateChange} @@ -1688,6 +1834,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element sortModel={columnSortModel} getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")} getRowId={(row) => row.__rowIndex} + selectionModel={rowSelectionModel} + hideFooterSelectedRowCount={true} /> @@ -1724,4 +1872,49 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } +////////////////////////////////////////////////////////////////////////////////// +// mini-component that is the dialog for the user to enter the selection-subset // +////////////////////////////////////////////////////////////////////////////////// +function SelectionSubsetDialog(props: {isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void}) +{ + const [value, setValue] = useState(props.initialValue) + + const handleChange = (newValue: string) => + { + setValue(parseInt(newValue)) + } + + const keyPressed = (e: React.KeyboardEvent) => + { + if(e.key == "Enter" && value) + { + props.closeHandler(value); + } + } + + return ( + props.closeHandler()} onKeyPress={(e) => keyPressed(e)}> + Subset of the Query Result + + How many records do you want to select? + handleChange(e.target.value)} + value={value} + sx={{width: "100%"}} + onFocus={event => event.target.select()} + /> + + + props.closeHandler()} /> + props.closeHandler(value)} /> + + + ) +} + + + export default RecordQuery; diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 13393ff..4e61b6f 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -124,7 +124,6 @@ .MuiDataGrid-toolbarContainer .selectionTool { - margin-left: 40px; font-size: 14px; } diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index f0dfc6c..f9c8d48 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -22,10 +22,12 @@ import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType"; import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {getGridDateOperators, GridColDef, GridRowsProp} from "@mui/x-data-grid-pro"; import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator"; +import React from "react"; import {Link} from "react-router-dom"; import {buildQGridPvsOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; @@ -87,7 +89,7 @@ export default class DataGridUtils /******************************************************************************* ** *******************************************************************************/ - public static setupGridColumns = (tableMetaData: QTableMetaData, linkBase: string = ""): GridColDef[] => + public static setupGridColumns = (tableMetaData: QTableMetaData, linkBase: string = "", metaData?: QInstance): GridColDef[] => { const columns = [] as GridColDef[]; this.addColumnsForTable(tableMetaData, linkBase, columns, null); @@ -97,8 +99,15 @@ export default class DataGridUtils for (let i = 0; i < tableMetaData.exposedJoins.length; i++) { const join = tableMetaData.exposedJoins[i]; - // todo - link base here - link to the join table - this.addColumnsForTable(join.joinTable, null, columns, join.joinTable.name + ".", join.label + ": "); + + let joinLinkBase = null; + if(metaData) + { + joinLinkBase = metaData.getTablePath(join.joinTable); + joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/"; + } + + this.addColumnsForTable(join.joinTable, joinLinkBase, columns, join.joinTable.name + ".", join.label + ": "); } } @@ -111,6 +120,10 @@ export default class DataGridUtils *******************************************************************************/ private static addColumnsForTable(tableMetaData: QTableMetaData, linkBase: string, columns: GridColDef[], namePrefix?: string, labelPrefix?: string) { + /* + //////////////////////////////////////////////////////////////////////// + // this sorted by sections - e.g., manual sorting by the meta-data... // + //////////////////////////////////////////////////////////////////////// const sortedKeys: string[] = []; for (let i = 0; i < tableMetaData.sections.length; i++) { @@ -125,23 +138,37 @@ export default class DataGridUtils sortedKeys.push(section.fieldNames[j]); } } + */ + + /////////////////////////// + // sort by labels... mmm // + /////////////////////////// + const sortedKeys = [...tableMetaData.fields.keys()]; + sortedKeys.sort((a: string, b: string): number => + { + return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label)) + }) sortedKeys.forEach((key) => { const field = tableMetaData.fields.get(key); const column = this.makeColumnFromField(field, tableMetaData, namePrefix, labelPrefix); - if (key === tableMetaData.primaryKeyField && linkBase) + if(key === tableMetaData.primaryKeyField && linkBase && namePrefix == null) { columns.splice(0, 0, column); - column.renderCell = (cellValues: any) => ( - {cellValues.value} - ); } else { columns.push(column); } + + if (key === tableMetaData.primaryKeyField && linkBase) + { + column.renderCell = (cellValues: any) => ( + e.stopPropagation()}>{cellValues.value} + ); + } }); } From 4d297de91dc152d5aa5b9cab75d3e9e6dfb1021f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Apr 2023 11:50:56 -0500 Subject: [PATCH 06/13] Initial checkin --- src/qqq/components/buttons/MenuButton.tsx | 144 ++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/qqq/components/buttons/MenuButton.tsx diff --git a/src/qqq/components/buttons/MenuButton.tsx b/src/qqq/components/buttons/MenuButton.tsx new file mode 100644 index 0000000..869f2f6 --- /dev/null +++ b/src/qqq/components/buttons/MenuButton.tsx @@ -0,0 +1,144 @@ +/* + * 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 {ClickAwayListener, Grow, MenuList, Paper, Popper} from "@mui/material"; +import Button from "@mui/material/Button/Button"; +import Icon from "@mui/material/Icon"; +import MenuItem from "@mui/material/MenuItem"; +import React, {useEffect, useRef, useState} from "react"; + + +interface Props +{ + label: string; + iconName?: string + options: string[]; + disabled?: boolean; + callback: (selectedIndex: number) => void; +} + +MenuButton.defaultProps = +{ + disabled: false +}; + +function MenuButton({label, iconName, options, disabled, callback}: Props) +{ + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + + const handleToggle = () => + { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event: Event | React.SyntheticEvent) => + { + if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) + { + return; + } + + setOpen(false); + }; + + function handleListKeyDown(event: React.KeyboardEvent) + { + if (event.key === "Tab") + { + event.preventDefault(); + setOpen(false); + } + else if (event.key === "Escape") + { + setOpen(false); + } + } + + // return focus to the button when we transitioned from !open -> open + const prevOpen = useRef(open); + useEffect(() => + { + if (prevOpen.current === true && open === false) + { + anchorRef.current!.focus(); + } + + prevOpen.current = open; + }, [open]); + + + const menuItemClicked = (e: React.MouseEvent, newIndex: number) => + { + callback(newIndex); + handleClose(e); + } + + const menuItems: JSX.Element[] = [] + options.map((option, index) => + { + menuItems.push( menuItemClicked(e, index)}> + {option} + ); + }) + + return ( +
+ + + {({TransitionProps, placement}) => ( + + + + + {menuItems} + + + + + )} + +
+ ); +} + +export default MenuButton; + From 4f90b500014eea7c2a445ebc8f9b14be971acc80 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Apr 2023 12:10:44 -0500 Subject: [PATCH 07/13] Fix test (json slightly different now for boolean operator in filter); update javalin for seleniums --- pom.xml | 2 +- .../kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 95b6091..167d08b 100644 --- a/pom.xml +++ b/pom.xml @@ -85,7 +85,7 @@ io.javalin javalin - 5.1.4 + 5.4.2 test diff --git a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java index f1157e3..2d1d9b4 100755 --- a/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java +++ b/src/test/java/com/kingsrook/qqq/materialdashboard/tests/QueryScreenTest.java @@ -128,7 +128,7 @@ public class QueryScreenTest extends QBaseSeleniumTest String expectedFilterContents1 = """ {"fieldName":"firstName","operator":"CONTAINS","values":["Jam"]}"""; String expectedFilterContents2 = """ - "booleanOperator":"OR"}"""; + "booleanOperator":"OR\""""; qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents0); qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectedFilterContents1); From 47463ad31578961656afe83b1486d7ad1c7fdb3d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Apr 2023 12:49:35 -0500 Subject: [PATCH 08/13] Keep columnStats, RecordGridWidget columns sorted by-section, not alpha. --- .../widgets/misc/RecordGridWidget.tsx | 2 +- src/qqq/pages/records/query/ColumnStats.tsx | 2 +- src/qqq/pages/records/query/RecordQuery.tsx | 2 +- src/qqq/utils/DataGridUtils.tsx | 49 ++++++++++--------- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index c167468..9e02e0d 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -66,7 +66,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element // note - tablePath may be null, if the user doesn't have access to the table. // ///////////////////////////////////////////////////////////////////////////////// const childTablePath = data.tablePath ? data.tablePath + (data.tablePath.endsWith("/") ? "" : "/") : data.tablePath; - const columns = DataGridUtils.setupGridColumns(tableMetaData, childTablePath); + const columns = DataGridUtils.setupGridColumns(tableMetaData, childTablePath, null, "bySection"); //////////////////////////////////////////////////////////////// // do not not show the foreign-key column of the parent table // diff --git a/src/qqq/pages/records/query/ColumnStats.tsx b/src/qqq/pages/records/query/ColumnStats.tsx index 36f4dd5..d540721 100644 --- a/src/qqq/pages/records/query/ColumnStats.tsx +++ b/src/qqq/pages/records/query/ColumnStats.tsx @@ -127,7 +127,7 @@ function ColumnStats({tableMetaData, fieldMetaData, filter}: Props): JSX.Element fakeTableMetaData.sections.push(new QTableSection({fieldNames: [fieldMetaData.name, "count"]})); const rows = DataGridUtils.makeRows(valueCounts, fakeTableMetaData); - const columns = DataGridUtils.setupGridColumns(fakeTableMetaData); + const columns = DataGridUtils.setupGridColumns(fakeTableMetaData, null, null, "bySection"); columns.forEach((c) => { c.width = 200; diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 397fd40..597667a 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -474,7 +474,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { let linkBase = metaData.getTablePath(table); linkBase += linkBase.endsWith("/") ? "" : "/"; - const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase, metaData); + const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase, metaData, "alphabetical"); setColumnsModel(columns); /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index f9c8d48..f9df0e1 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -89,10 +89,10 @@ export default class DataGridUtils /******************************************************************************* ** *******************************************************************************/ - public static setupGridColumns = (tableMetaData: QTableMetaData, linkBase: string = "", metaData?: QInstance): GridColDef[] => + public static setupGridColumns = (tableMetaData: QTableMetaData, linkBase: string = "", metaData?: QInstance, columnSort: "bySection" | "alphabetical" = "alphabetical"): GridColDef[] => { const columns = [] as GridColDef[]; - this.addColumnsForTable(tableMetaData, linkBase, columns, null); + this.addColumnsForTable(tableMetaData, linkBase, columns, columnSort, null, null); if(tableMetaData.exposedJoins) { @@ -107,7 +107,7 @@ export default class DataGridUtils joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/"; } - this.addColumnsForTable(join.joinTable, joinLinkBase, columns, join.joinTable.name + ".", join.label + ": "); + this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, join.joinTable.name + ".", join.label + ": "); } } @@ -118,36 +118,39 @@ export default class DataGridUtils /******************************************************************************* ** *******************************************************************************/ - private static addColumnsForTable(tableMetaData: QTableMetaData, linkBase: string, columns: GridColDef[], namePrefix?: string, labelPrefix?: string) + private static addColumnsForTable(tableMetaData: QTableMetaData, linkBase: string, columns: GridColDef[], columnSort: "bySection" | "alphabetical" = "alphabetical", namePrefix?: string, labelPrefix?: string) { - /* + const sortedKeys: string[] = []; + //////////////////////////////////////////////////////////////////////// // this sorted by sections - e.g., manual sorting by the meta-data... // //////////////////////////////////////////////////////////////////////// - const sortedKeys: string[] = []; - for (let i = 0; i < tableMetaData.sections.length; i++) + if(columnSort === "bySection") { - const section = tableMetaData.sections[i]; - if (!section.fieldNames) + for (let i = 0; i < tableMetaData.sections.length; i++) { - continue; - } + const section = tableMetaData.sections[i]; + if (!section.fieldNames) + { + continue; + } - for (let j = 0; j < section.fieldNames.length; j++) - { - sortedKeys.push(section.fieldNames[j]); + for (let j = 0; j < section.fieldNames.length; j++) + { + sortedKeys.push(section.fieldNames[j]); + } } } - */ - - /////////////////////////// - // sort by labels... mmm // - /////////////////////////// - const sortedKeys = [...tableMetaData.fields.keys()]; - sortedKeys.sort((a: string, b: string): number => + else // columnSort = "alphabetical" { - return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label)) - }) + /////////////////////////// + // sort by labels... mmm // + /////////////////////////// + sortedKeys.sort((a: string, b: string): number => + { + return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label)) + }) + } sortedKeys.forEach((key) => { From 0349bcd6335004b6255fce7202e0151abb8f5192 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Apr 2023 12:49:44 -0500 Subject: [PATCH 09/13] Update revision to 0.14.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 167d08b..30d9dbd 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ jar - 0.13.0-SNAPSHOT + 0.14.0-SNAPSHOT UTF-8 UTF-8 From 116aca3c5cb8d4b9439beb75bf42ed680f79b0a3 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Apr 2023 14:27:08 -0500 Subject: [PATCH 10/13] Downgrade javalin - maybe made test fails? --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 30d9dbd..3cc75a6 100644 --- a/pom.xml +++ b/pom.xml @@ -85,7 +85,7 @@ io.javalin javalin - 5.4.2 + 5.1.4 test From 2b391381529cccc6e98bcc8df34541760fb2c20c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Apr 2023 14:50:28 -0500 Subject: [PATCH 11/13] Fix sorting columns by labels (had empty list, and a "bad time") --- src/qqq/utils/DataGridUtils.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index f9df0e1..d4b9f80 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -146,6 +146,7 @@ export default class DataGridUtils /////////////////////////// // sort by labels... mmm // /////////////////////////// + sortedKeys.push(...tableMetaData.fields.keys()) sortedKeys.sort((a: string, b: string): number => { return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label)) From ca8834dec2bcac6cae767f6473b0234aebf81f78 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 26 Apr 2023 14:50:39 -0500 Subject: [PATCH 12/13] Revert "Downgrade javalin - maybe made test fails?" This reverts commit 116aca3c5cb8d4b9439beb75bf42ed680f79b0a3. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 3cc75a6..30d9dbd 100644 --- a/pom.xml +++ b/pom.xml @@ -85,7 +85,7 @@ io.javalin javalin - 5.1.4 + 5.4.2 test From 74f0876de4478b121a2d332212e34269fb9a3946 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 1 May 2023 15:12:51 -0500 Subject: [PATCH 13/13] Fixes for joins --- src/qqq/pages/records/query/RecordQuery.tsx | 19 ++++++++++++++++++- src/qqq/utils/DataGridUtils.tsx | 18 ++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 597667a..252cf55 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -26,6 +26,7 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin"; import {Alert, Collapse, TablePagination} from "@mui/material"; @@ -52,6 +53,7 @@ import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, G 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 {finance} from "faker"; import FormData from "form-data"; import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; @@ -351,6 +353,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } } }); + + filterModel.items.forEach((item) => + { + // todo - some test if there is a value? see FilterUtils.buildQFilterFromGridFilter (re-use if needed) + + const fieldName = item.columnField; + if(fieldName.indexOf(".") > -1) + { + visibleJoinTables.add(fieldName.split(".")[0]); + } + }); + return (visibleJoinTables); }; @@ -403,7 +417,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } let tooltipHTML =
- You are viewing results from the {tableMetaData.label} table joined with the {joinLabelsString} table{joinLabels.length == 1 ? "" : "s"} + You are viewing results from the {tableMetaData.label} table joined with {joinLabels.length} other table{joinLabels.length == 1 ? "" : "s"}: +
    + {joinLabels.map((name) =>
  • {name}
  • )} +
return( diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index d4b9f80..54d3243 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -59,12 +59,15 @@ export default class DataGridUtils { const join = tableMetaData.exposedJoins[i]; - const fields = [ ...join.joinTable.fields.values() ]; - fields.forEach((field) => + if(join?.joinTable?.fields?.values()) { - let fieldName = join.joinTable.name + "." + field.name; - row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName); - }); + const fields = [...join.joinTable.fields.values()]; + fields.forEach((field) => + { + let fieldName = join.joinTable.name + "." + field.name; + row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName); + }); + } } } @@ -107,7 +110,10 @@ export default class DataGridUtils joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/"; } - this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, join.joinTable.name + ".", join.label + ": "); + if(join?.joinTable?.fields?.values()) + { + this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, join.joinTable.name + ".", join.label + ": "); + } } }