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/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index d4c5d34..397fd40 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -48,15 +48,16 @@ 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 Typography from "@mui/material/Typography"; -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"; @@ -165,7 +166,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element 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); @@ -325,9 +330,9 @@ 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); }; @@ -469,7 +474,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { let linkBase = metaData.getTablePath(table); linkBase += linkBase.endsWith("/") ? "" : "/"; - const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase); + const columns = DataGridUtils.setupGridColumns(tableMetaData, linkBase, metaData); setColumnsModel(columns); /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -520,6 +525,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } const qFilter = buildQFilter(tableMetaData, localFilterModel); + qFilter.skip = pageNumber * rowsPerPage; + qFilter.limit = rowsPerPage; ////////////////////////////////////////// // figure out joins to use in the query // @@ -562,7 +569,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }); } - qController.query(tableName, qFilter, rowsPerPage, pageNumber * rowsPerPage, queryJoins).then((results) => + qController.query(tableName, qFilter, queryJoins).then((results) => { console.log(`Received results for query ${thisQueryId}`); queryResults[thisQueryId] = results; @@ -642,6 +649,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element delete queryResults[latestQueryId]; setLatestQueryResults(results); + /////////////////////////////////////////////////////////// + // 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); @@ -748,19 +768,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"); } @@ -940,11 +963,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (selectFullFilterState === "filter") { - // todo - distinct? + if(isJoinMany(tableMetaData, getVisibleJoinTables())) + { + return (distinctRecords); + } return (totalRecords); } - // todo - distinct? return (selectedIds.length); } @@ -955,6 +980,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(",")}`; @@ -969,6 +999,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(",")); @@ -1067,7 +1101,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let distinctPart = isJoinMany(tableMetaData, getVisibleJoinTables()) ? (  ({distinctRecords} distinct - info_outlined + info_outlined ) ) : <>; @@ -1116,8 +1150,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ( function MyCustomColumnsPanel(props: GridColumnsPanelProps, ref) { @@ -1313,7 +1349,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setOpenGroups(JSON.parse(JSON.stringify(openGroups))); }; - console.log("re-render"); return (
@@ -1395,14 +1430,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 (
-
@@ -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} + ); + } }); }