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 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..072f277 100644 --- a/src/qqq/components/audits/AuditBody.tsx +++ b/src/qqq/components/audits/AuditBody.tsx @@ -194,7 +194,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element new QFilterOrderBy("timestamp", sortDirection), new QFilterOrderBy("id", sortDirection), new QFilterOrderBy("auditDetail.id", true) - ]); + ], "AND", 0, limit); /////////////////////////////// // fetch audits in try-catch // @@ -202,7 +202,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element let audits = [] as QRecord[] try { - audits = await qController.query("audit", filter, limit, 0, [new QueryJoin("auditDetail", true, "LEFT")]); + audits = await qController.query("audit", filter, [new QueryJoin("auditDetail", true, "LEFT")]); setAudits(audits); } catch(e) @@ -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/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index 7070f6b..f3e10fe 100644 --- a/src/qqq/components/buttons/DefaultButtons.tsx +++ b/src/qqq/components/buttons/DefaultButtons.tsx @@ -50,18 +50,20 @@ export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Elemen interface QSaveButtonProps { label?: string; + iconName?: string; onClickHandler?: any, disabled: boolean } QSaveButton.defaultProps = { - label: "Save" + label: "Save", + iconName: "save" }; -export function QSaveButton({label, onClickHandler, disabled}: QSaveButtonProps): JSX.Element +export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveButtonProps): JSX.Element { return ( - save} disabled={disabled}> + {iconName}} disabled={disabled}> {label} 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; + diff --git a/src/qqq/components/widgets/misc/DataBagViewer.tsx b/src/qqq/components/widgets/misc/DataBagViewer.tsx index b1ff735..c59cd37 100644 --- a/src/qqq/components/widgets/misc/DataBagViewer.tsx +++ b/src/qqq/components/widgets/misc/DataBagViewer.tsx @@ -100,8 +100,8 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element const criteria = [new QFilterCriteria("dataBagId", QCriteriaOperator.EQUALS, [dataBagId])]; const orderBys = [new QFilterOrderBy("sequenceNo", false)]; - const filter = new QQueryFilter(criteria, orderBys); - const versions = await qController.query("dataBagVersion", filter, 25, 0); + const filter = new QQueryFilter(criteria, orderBys, "AND", 0, 25); + const versions = await qController.query("dataBagVersion", filter); console.log("Fetched versions:"); console.log(versions); setVersionRecordList(versions); diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index fbb1f72..9e02e0d 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, null, "bySection"); //////////////////////////////////////////////////////////////// // do not not show the foreign-key column of the parent table // diff --git a/src/qqq/components/widgets/misc/ScriptViewer.tsx b/src/qqq/components/widgets/misc/ScriptViewer.tsx index 6c7fee8..36e45e4 100644 --- a/src/qqq/components/widgets/misc/ScriptViewer.tsx +++ b/src/qqq/components/widgets/misc/ScriptViewer.tsx @@ -133,8 +133,8 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc const criteria = [new QFilterCriteria("scriptId", QCriteriaOperator.EQUALS, [scriptId])]; const orderBys = [new QFilterOrderBy("sequenceNo", false)]; - const filter = new QQueryFilter(criteria, orderBys); - const versions = await qController.query("scriptRevision", filter, 25, 0); + const filter = new QQueryFilter(criteria, orderBys, "AND", 0, 25); + const versions = await qController.query("scriptRevision", filter); console.log("Fetched versions:"); console.log(versions); setVersionRecordList(versions); @@ -281,7 +281,8 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc { (async () => { - scriptLogs[scriptRevisionId] = await qController.query("scriptLog", new QQueryFilter([new QFilterCriteria("scriptRevisionId", QCriteriaOperator.EQUALS, [scriptRevisionId])]), 100, 0); + let filter = new QQueryFilter([new QFilterCriteria("scriptRevisionId", QCriteriaOperator.EQUALS, [scriptRevisionId])], [new QFilterOrderBy("id", false)], "AND", 0, 100); + scriptLogs[scriptRevisionId] = await qController.query("scriptLog", filter); setScriptLogs(scriptLogs); forceUpdate(); })(); 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..d540721 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, 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 09d23cd..113d0e8 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -26,7 +26,9 @@ 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, Box, Collapse, TablePagination} from "@mui/material"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -37,19 +39,27 @@ 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 {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"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; import QContext from "QContext"; -import {QActionsMenuButton, QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons"; +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 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"; @@ -83,10 +93,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(); @@ -103,7 +113,8 @@ 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 didDefaultVisibilityComeFromLocalStorage = false; let defaultRowsPerPage = 10; let defaultDensity = "standard" as GridDensity; let defaultPinnedColumns = {left: ["__check__", "id"]} as GridPinnedColumns; @@ -120,6 +131,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (localStorage.getItem(columnVisibilityLocalStorageKey)) { defaultVisibility = JSON.parse(localStorage.getItem(columnVisibilityLocalStorageKey)); + didDefaultVisibilityComeFromLocalStorage = true; } if (localStorage.getItem(pinnedColumnsLocalStorageKey)) { @@ -137,6 +149,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); @@ -150,8 +164,13 @@ 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 [distinctRecordsOnPageCount, setDistinctRecordsOnPageCount] = useState(null as number); + const [selectionSubsetSize, setSelectionSubsetSize] = useState(null as number); + const [selectionSubsetSizePromptOpen, setSelectionSubsetSizePromptOpen] = useState(false); + const [selectFullFilterState, setSelectFullFilterState] = useState("n/a" as "n/a" | "checked" | "filter" | "filterSubset"); + const [rowSelectionModel, setRowSelectionModel] = useState([]); const [columnsModel, setColumnsModel] = useState([] as GridColDef[]); const [rows, setRows] = useState([] as GridRowsProp[]); const [loading, setLoading] = useState(true); @@ -167,25 +186,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); @@ -210,7 +230,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? // @@ -229,12 +249,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)) { @@ -247,7 +267,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } } - if(currentSavedFilterId != null) + if (currentSavedFilterId != null) { (async () => { @@ -288,15 +308,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([]); @@ -310,11 +330,109 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // first time we call in here, we may not yet have set it in state (but will have fetched it async) // // so we'll pass in the local version of it! // ////////////////////////////////////////////////////////////////////////////////////////////////////// - const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel) => + const buildQFilter = (tableMetaData: QTableMetaData, filterModel: GridFilterModel, limit?: number) => { - const filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel); + const filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); 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]); + } + } + }); + + 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); + }; + + 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 {joinLabels.length} other table{joinLabels.length == 1 ? "" : "s"}: +
    + {joinLabels.map((name) =>
  • {name}
  • )} +
+
+ + return( +
+ {tableMetaData?.label} + + emergency + +
); + } + else + { + return (tableMetaData?.label); + } }; const updateTable = () => @@ -324,7 +442,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), // @@ -345,14 +485,42 @@ 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, metaData, "alphabetical"); setColumnsModel(columns); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // let the next render (since columnsModel is watched below) build the filter, using the new columnsModel (in case of joins) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + return; } + ////////////////////////////////////////////////////////////////////////////////////////////////// + // 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({ @@ -360,9 +528,39 @@ 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); + qFilter.skip = pageNumber * rowsPerPage; + qFilter.limit = rowsPerPage; + + ////////////////////////////////////////// + // 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")); + } + } + } ////////////////////////////////////////////////////////////////////////////////////////////////// // assign a new query id to the query being issued here. then run both the count & query async // @@ -374,15 +572,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, queryJoins).then((results) => { console.log(`Received results for query ${thisQueryId}`); queryResults[thisQueryId] = results; @@ -422,7 +624,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 // @@ -430,9 +632,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 // @@ -454,14 +664,26 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element delete queryResults[latestQueryId]; setLatestQueryResults(results); - const {rows, columnsToRender} = DataGridUtils.makeRows(results, tableMetaData); + /////////////////////////////////////////////////////////// + // count how many distinct primary keys are on this page // + /////////////////////////////////////////////////////////// + let distinctPrimaryKeySet = new Set(); + for(let i = 0; i < results.length; i++) + { + distinctPrimaryKeySet.add(results[i].values.get(tableMetaData.primaryKeyField) as string); + } + setDistinctRecordsOnPageCount(distinctPrimaryKeySet.size); + //////////////////////////////// + // make the rows for the grid // + //////////////////////////////// + const rows = DataGridUtils.makeRows(results, tableMetaData); setRows(rows); setLoading(false); setAlertContent(null); forceUpdate(); - }, [ receivedQueryTimestamp ]); + }, [receivedQueryTimestamp]); ///////////////////////// // display query error // @@ -483,7 +705,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setLoading(false); setAlertContent(errorMessage); - }, [ receivedQueryErrorTimestamp ]); + }, [receivedQueryErrorTimestamp]); const handlePageChange = (page: number) => @@ -542,7 +764,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]}`); } @@ -561,19 +783,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const selectionChanged = (selectionModel: GridSelectionModel, details: GridCallbackDetails) => { - const newSelectedIds: string[] = []; + //////////////////////////////////////////////////// + // since we manage this object, we must re-set it // + //////////////////////////////////////////////////// + setRowSelectionModel(selectionModel); + + let checkboxesChecked = 0; + let selectedPrimaryKeys = new Set(); selectionModel.forEach((value: GridRowId, index: number) => { - let valueToPush = value as string; - if(tableMetaData.primaryKeyField !== "id") - { - valueToPush = latestQueryResults[index].values.get(tableMetaData.primaryKeyField); - } - newSelectedIds.push(valueToPush as string); + checkboxesChecked++ + const valueToPush = latestQueryResults[value as number].values.get(tableMetaData.primaryKeyField); + selectedPrimaryKeys.add(valueToPush as string); }); - setSelectedIds(newSelectedIds); + setSelectedIds([...selectedPrimaryKeys.values()]); - if (newSelectedIds.length === rowsPerPage) + if (checkboxesChecked === rowsPerPage) { setSelectFullFilterState("checked"); } @@ -595,6 +820,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 @@ -666,7 +904,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element columnsModel.forEach((gridColumn) => { const fieldName = gridColumn.field; - // @ts-ignore if (columnVisibilityModel[fieldName] !== false) { visibleFields.push(fieldName); @@ -682,7 +919,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(",")}`; @@ -741,6 +978,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (selectFullFilterState === "filter") { + if(isJoinMany(tableMetaData, getVisibleJoinTables())) + { + return (distinctRecords); + } return (totalRecords); } @@ -754,6 +995,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(tableMetaData, filterModel))}`; } + if (selectFullFilterState === "filterSubset") + { + return `?recordsParam=filterJSON&filterJSON=${JSON.stringify(buildQFilter(tableMetaData, filterModel, selectionSubsetSize))}`; + } + if (selectedIds.length > 0) { return `?recordsParam=recordIds&recordIds=${selectedIds.join(",")}`; @@ -768,6 +1014,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setRecordIdsForProcess(buildQFilter(tableMetaData, filterModel)); } + else if (selectFullFilterState === "filterSubset") + { + setRecordIdsForProcess(buildQFilter(tableMetaData, filterModel, selectionSubsetSize)); + } else if (selectedIds.length > 0) { setRecordIdsForProcess(selectedIds.join(",")); @@ -859,13 +1109,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; } @@ -879,13 +1141,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..."); } }; @@ -895,10 +1165,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element handlePageChange(value)} onRowsPerPageChange={(event) => handleRowsPerPageChange(Number(event.target.value))} @@ -916,7 +1186,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); @@ -932,7 +1202,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(); @@ -959,30 +1229,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 @@ -991,13 +1261,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) @@ -1022,13 +1292,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element - + { hideMenu(e); - copyColumnValues(currentColumn) + copyColumnValues(currentColumn); }}> Copy values @@ -1053,6 +1323,96 @@ 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} + + ))} + + ))} + + + + + + + +
+ ); + } + ); + function CustomToolbar() { const handleMouseDown: GridEventListener<"cellMouseDown"> = ( @@ -1085,14 +1445,84 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setGridPreferencesWindow(preferencePanelState.openedPanelValue); }); + const joinIsMany = isJoinMany(tableMetaData, visibleJoinTables); + + const safeToLocaleString = (n: Number): string => + { + if(n != null && n != undefined) + { + return (n.toLocaleString()); + } + return (""); + } + + const selectionMenuOptions: string[] = []; + selectionMenuOptions.push(`This page (${safeToLocaleString(distinctRecordsOnPageCount)} ${joinIsMany ? "distinct " : ""}record${distinctRecordsOnPageCount == 1 ? "" : "s"})`); + selectionMenuOptions.push(`Full query result (${joinIsMany ? safeToLocaleString(distinctRecords) + ` distinct record${distinctRecords == 1 ? "" : "s"}` : safeToLocaleString(totalRecords) + ` record${totalRecords == 1 ? "" : "s"}`})`); + selectionMenuOptions.push(`Subset of the query result ${selectionSubsetSize ? `(${safeToLocaleString(selectionSubsetSize)} ${joinIsMany ? "distinct " : ""}record${selectionSubsetSize == 1 ? "" : "s"})` : "..."}`); + selectionMenuOptions.push("Clear selection"); + + function programmaticallySelectSomeOrAllRows(max?: number) + { + /////////////////////////////////////////////////////////////////////////////////////////// + // any time the user selects one of the options from our selection menu, // + // we want to check all the boxes on the screen - and - "select" all of the primary keys // + // unless they did the subset option - then we'll only go up to a 'max' number // + /////////////////////////////////////////////////////////////////////////////////////////// + const rowSelectionModel: GridSelectionModel = []; + let selectedPrimaryKeys = new Set(); + rows.forEach((value: GridRowModel, index: number) => + { + const primaryKeyValue = latestQueryResults[index].values.get(tableMetaData.primaryKeyField); + if(max) + { + if(selectedPrimaryKeys.size < max) + { + if(!selectedPrimaryKeys.has(primaryKeyValue)) + { + rowSelectionModel.push(value.__rowIndex); + selectedPrimaryKeys.add(primaryKeyValue as string); + } + } + } + else + { + rowSelectionModel.push(value.__rowIndex); + selectedPrimaryKeys.add(primaryKeyValue as string); + } + }); + setRowSelectionModel(rowSelectionModel); + setSelectedIds([...selectedPrimaryKeys.values()]); + } + + const selectionMenuCallback = (selectedIndex: number) => + { + if(selectedIndex == 0) + { + programmaticallySelectSomeOrAllRows(); + setSelectFullFilterState("checked") + } + else if(selectedIndex == 1) + { + programmaticallySelectSomeOrAllRows(); + setSelectFullFilterState("filter") + } + else if(selectedIndex == 2) + { + setSelectionSubsetSizePromptOpen(true); + } + else if(selectedIndex == 3) + { + setSelectFullFilterState("n/a") + setRowSelectionModel([]); + setSelectedIds([]); + } + }; + return (
-
@@ -1106,19 +1536,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? - - + }}/> @@ -1131,32 +1569,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 - {` ${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.
) } @@ -1170,28 +1643,28 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const pushDividerIfNeeded = (menuItems: JSX.Element[]) => { - if(menuItems.length > 0) + if (menuItems.length > 0) { menuItems.push(); } - } + }; const menuItems: JSX.Element[] = []; - if(table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) + if (table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission) { - menuItems.push(library_addBulk Load) + menuItems.push(library_addBulk Load); } - if(table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) + if (table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission) { - menuItems.push(editBulk Edit) + menuItems.push(editBulk Edit); } - if(table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) + if (table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission) { - menuItems.push(deleteBulk Delete) + menuItems.push(deleteBulk Delete); } const runRecordScriptProcess = metaData?.processes.get("runRecordScript"); - if(runRecordScriptProcess) + if (runRecordScriptProcess) { const process = runRecordScriptProcess; menuItems.push( processClicked(process)}>{process.iconName ?? "arrow_forward"}{process.label}); @@ -1199,7 +1672,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element menuItems.push( navigate("dev")}>codeDeveloper Mode); - if(tableProcesses && tableProcesses.length) + if (tableProcesses && tableProcesses.length) { pushDividerIfNeeded(menuItems); } @@ -1210,9 +1683,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element menuItems.push( processClicked(process)}>{process.iconName ?? "arrow_forward"}{process.label}); }); - if(menuItems.length === 0) + if (menuItems.length === 0) { - menuItems.push(blockNo actions available) + menuItems.push(blockNo actions available); } const renderActionsMenu = ( @@ -1240,7 +1713,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /////////////////////////////////////////////////////////////////////////////////////////// useEffect(() => { - if(latestQueryId > 0) + if (latestQueryId > 0) { //////////////////////////////////////////////////////////////////////////////////////// // to avoid both this useEffect and the one below from both doing an "initial query", // @@ -1248,7 +1721,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element //////////////////////////////////////////////////////////////////////////////////////// updateTable(); } - }, [ pageNumber, rowsPerPage, columnSortModel, currentSavedFilter ]); + }, [pageNumber, rowsPerPage, columnSortModel, currentSavedFilter]); /////////////////////////////////////////////////////////////////////////////////////////////////////////////// // for state changes that DO change the filter, call to update the table - and DO clear out the totalRecords // @@ -1256,16 +1729,17 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element useEffect(() => { setTotalRecords(null); + setDistinctRecords(null); updateTable(); - }, [ tableState, filterModel]); + }, [columnsModel, tableState, filterModel]); useEffect(() => { document.documentElement.scrollTop = 0; document.scrollingElement.scrollTop = 0; - }, [ pageNumber, rowsPerPage ]); + }, [pageNumber, rowsPerPage]); - if(tableMetaData && !tableMetaData.readPermission) + if (tableMetaData && !tableMetaData.readPermission) { return ( @@ -1327,7 +1801,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } - + @@ -1343,7 +1817,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")} + getRowId={(row) => row.__rowIndex} + selectionModel={rowSelectionModel} + hideFooterSelectedRowCount={true} /> @@ -1410,4 +1887,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 3f8024f..160e1aa 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 99c5d72..54d3243 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"; @@ -36,24 +38,39 @@ export default class DataGridUtils /******************************************************************************* ** *******************************************************************************/ - public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData): {rows: GridRowsProp[], columnsToRender: any} => + public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData): GridRowsProp[] => { const fields = [ ...tableMetaData.fields.values() ]; const rows = [] as any[]; - const columnsToRender = {} as any; + let rowIndex = 0; results.forEach((record: QRecord) => { const row: any = {}; + row.__rowIndex = rowIndex++; + fields.forEach((field) => { - const value = ValueUtils.getDisplayValue(field, record, "query"); - if (typeof value !== "string") - { - columnsToRender[field.name] = true; - } - row[field.name] = value; + row[field.name] = ValueUtils.getDisplayValue(field, record, "query"); }); + if(tableMetaData.exposedJoins) + { + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + + if(join?.joinTable?.fields?.values()) + { + const fields = [...join.joinTable.fields.values()]; + fields.forEach((field) => + { + let fieldName = join.joinTable.name + "." + field.name; + row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName); + }); + } + } + } + if(!row["id"]) { row["id"] = record.values.get(tableMetaData.primaryKeyField) ?? row[tableMetaData.primaryKeyField]; @@ -69,70 +86,106 @@ export default class DataGridUtils rows.push(row); }); - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // do this secondary check for columnsToRender - in case we didn't have any rows above, and our check for string isn't enough. // - // ... shouldn't this be just based on the field definition anyway... ? plus adornments? // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - fields.forEach((field) => - { - if(field.possibleValueSourceName) - { - columnsToRender[field.name] = true; - } - }); - - return ({rows, columnsToRender}); + return (rows); } /******************************************************************************* ** *******************************************************************************/ - public static setupGridColumns = (tableMetaData: QTableMetaData, columnsToRender: any, linkBase: string = ""): GridColDef[] => + public static setupGridColumns = (tableMetaData: QTableMetaData, linkBase: string = "", metaData?: QInstance, columnSort: "bySection" | "alphabetical" = "alphabetical"): GridColDef[] => { const columns = [] as GridColDef[]; - const sortedKeys: string[] = []; + this.addColumnsForTable(tableMetaData, linkBase, columns, columnSort, null, null); - for (let i = 0; i < tableMetaData.sections.length; i++) + if(tableMetaData.exposedJoins) { - const section = tableMetaData.sections[i]; - if(!section.fieldNames) + for (let i = 0; i < tableMetaData.exposedJoins.length; i++) { - continue; - } + const join = tableMetaData.exposedJoins[i]; - for (let j = 0; j < section.fieldNames.length; j++) - { - sortedKeys.push(section.fieldNames[j]); + let joinLinkBase = null; + if(metaData) + { + joinLinkBase = metaData.getTablePath(join.joinTable); + joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/"; + } + + if(join?.joinTable?.fields?.values()) + { + this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, join.joinTable.name + ".", join.label + ": "); + } } } - sortedKeys.forEach((key) => - { - const field = tableMetaData.fields.get(key); - const column = this.makeColumnFromField(field, tableMetaData, columnsToRender); - - if (key === tableMetaData.primaryKeyField && linkBase) - { - columns.splice(0, 0, column); - column.renderCell = (cellValues: any) => ( - {cellValues.value} - ); - } - else - { - columns.push(column); - } - }); - return (columns); }; + /******************************************************************************* + ** + *******************************************************************************/ + 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... // + //////////////////////////////////////////////////////////////////////// + if(columnSort === "bySection") + { + for (let i = 0; i < tableMetaData.sections.length; i++) + { + const section = tableMetaData.sections[i]; + if (!section.fieldNames) + { + continue; + } + + for (let j = 0; j < section.fieldNames.length; j++) + { + sortedKeys.push(section.fieldNames[j]); + } + } + } + else // columnSort = "alphabetical" + { + /////////////////////////// + // 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)) + }) + } + + sortedKeys.forEach((key) => + { + const field = tableMetaData.fields.get(key); + const column = this.makeColumnFromField(field, tableMetaData, namePrefix, labelPrefix); + + if(key === tableMetaData.primaryKeyField && linkBase && namePrefix == null) + { + columns.splice(0, 0, column); + } + else + { + columns.push(column); + } + + if (key === tableMetaData.primaryKeyField && linkBase) + { + column.renderCell = (cellValues: any) => ( + e.stopPropagation()}>{cellValues.value} + ); + } + }); + } /******************************************************************************* ** *******************************************************************************/ - public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, columnsToRender: any): GridColDef => + public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, namePrefix?: string, labelPrefix?: string): GridColDef => { let columnType = "string"; let columnWidth = 200; @@ -198,24 +251,21 @@ export default class DataGridUtils } } + let headerName = labelPrefix ? labelPrefix + field.label : field.label; + let fieldName = namePrefix ? namePrefix + field.name : field.name; + const column = { - field: field.name, + field: fieldName, type: columnType, - headerName: field.label, + headerName: headerName, width: columnWidth, renderCell: null as any, filterOperators: filterOperators, }; - ///////////////////////////////////////////////////////////////////////////////////////// - // looks like, maybe we can just always render all columns, and remove this parameter? // - ///////////////////////////////////////////////////////////////////////////////////////// - if (columnsToRender == null || columnsToRender[field.name]) - { - column.renderCell = (cellValues: any) => ( - (cellValues.value) - ); - } + column.renderCell = (cellValues: any) => ( + (cellValues.value) + ); return (column); } diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 8885d39..1c61364 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -514,7 +514,7 @@ class FilterUtils /******************************************************************************* ** build a qqq filter from a grid and column sort model *******************************************************************************/ - public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[]): QQueryFilter + public static buildQFilterFromGridFilter(tableMetaData: QTableMetaData, filterModel: GridFilterModel, columnSortModel: GridSortItem[], limit?: number): QQueryFilter { console.log("Building q filter with model:"); console.log(filterModel); @@ -528,6 +528,12 @@ class FilterUtils }); } + if (limit) + { + console.log("Setting limit to: " + limit); + qFilter.limit = limit; + } + if (filterModel) { let foundFilter = false; diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index e401814..d7e27fb 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -70,10 +70,12 @@ class ValueUtils ** When you have a field, and a record - call this method to get a string or ** element back to display the field's value. *******************************************************************************/ - public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view"): string | JSX.Element | JSX.Element[] + public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string): string | JSX.Element | JSX.Element[] { - const displayValue = record.displayValues ? record.displayValues.get(field.name) : undefined; - const rawValue = record.values ? record.values.get(field.name) : undefined; + const fieldName = overrideFieldName ?? field.name; + + const displayValue = record.displayValues ? record.displayValues.get(fieldName) : undefined; + const rawValue = record.values ? record.values.get(fieldName) : undefined; return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage); } 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);