diff --git a/src/qqq/components/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index f3e10fe..c934f3f 100644 --- a/src/qqq/components/buttons/DefaultButtons.tsx +++ b/src/qqq/components/buttons/DefaultButtons.tsx @@ -37,7 +37,7 @@ interface QCreateNewButtonProps export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element { return ( - + add}> Create New diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx index 3a10805..4f789ae 100644 --- a/src/qqq/components/misc/FieldAutoComplete.tsx +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -34,14 +34,16 @@ interface FieldAutoCompleteProps tableMetaData: QTableMetaData; handleFieldChange: (event: any, newValue: any, reason: string) => void; defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string}; - autoFocus?: boolean - hiddenFieldNames?: string[] + autoFocus?: boolean; + forceOpen?: boolean; + hiddenFieldNames?: string[]; } FieldAutoComplete.defaultProps = { defaultValue: null, autoFocus: false, + forceOpen: null, hiddenFieldNames: [] }; @@ -61,7 +63,7 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a } } -export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element { const fieldOptions: any[] = []; makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames); @@ -124,6 +126,15 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi return option.fieldName === value.fieldName; } + /////////////////////////////////////////////////////////////////////////////////////////////////////// + // seems like, if we always add the open attribute, then if its false or null, then the autocomplete // + // doesn't open at all... so, only add the attribute at all, if forceOpen is true // + /////////////////////////////////////////////////////////////////////////////////////////////////////// + const alsoOpen: {[key: string]: any} = {} + if(forceOpen) + { + alsoOpen["open"] = forceOpen; + } return ( ); diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx new file mode 100644 index 0000000..a84d9a9 --- /dev/null +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -0,0 +1,664 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {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 {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; +import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; +import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {Badge, ToggleButton, ToggleButtonGroup, Typography} from "@mui/material"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import Icon from "@mui/material/Icon"; +import Menu from "@mui/material/Menu"; +import Tooltip from "@mui/material/Tooltip"; +import {GridFilterModel} from "@mui/x-data-grid-pro"; +import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; +import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; +import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import QuickFilter from "qqq/components/query/QuickFilter"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +interface BasicAndAdvancedQueryControlsProps +{ + metaData: QInstance; + tableMetaData: QTableMetaData; + queryFilter: QQueryFilter; + gridApiRef: React.MutableRefObject + + setQueryFilter: (queryFilter: QQueryFilter) => void; + handleFilterChange: (filterModel: GridFilterModel, doSetQueryFilter?: boolean, isChangeFromDataGrid?: boolean) => void; + + ///////////////////////////////////////////////////////////////////////////////////////////// + // this prop is used as a way to recognize changes in the query filter internal structure, // + // since the queryFilter object (reference) doesn't get updated // + ///////////////////////////////////////////////////////////////////////////////////////////// + queryFilterJSON: string; + + mode: string; + setMode: (mode: string) => void; +} + +let debounceTimeout: string | number | NodeJS.Timeout; + +/******************************************************************************* + ** Component to provide the basic & advanced query-filter controls for the + ** RecordQuery screen. + ** + ** Done as a forwardRef, so RecordQuery can call some functions, e.g., when user + ** does things on that screen, that we need to know about in here. + *******************************************************************************/ +const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) => +{ + const {metaData, tableMetaData, queryFilter, gridApiRef, setQueryFilter, handleFilterChange, queryFilterJSON, mode, setMode} = props + + ///////////////////////////////////////////////////////// + // get the quick-filter-field-names from local storage // + ///////////////////////////////////////////////////////// + const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames"; + const quickFilterFieldNamesLocalStorageKey = `${QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT}.${tableMetaData.name}`; + let defaultQuickFilterFieldNames: Set = new Set(); + if (localStorage.getItem(quickFilterFieldNamesLocalStorageKey)) + { + defaultQuickFilterFieldNames = new Set(JSON.parse(localStorage.getItem(quickFilterFieldNamesLocalStorageKey))); + } + + ///////////////////// + // state variables // + ///////////////////// + const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultQuickFilterFieldNames); + const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null) + const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0); + const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + ////////////////////////////////////////////////////////////////////////////////// + // make some functions available to our parent - so it can tell us to do things // + ////////////////////////////////////////////////////////////////////////////////// + useImperativeHandle(ref, () => + { + return { + ensureAllFilterCriteriaAreActiveQuickFilters(currentFilter: QQueryFilter, reason: string) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, currentFilter, reason); + }, + addField(fieldName: string) + { + addQuickFilterField({fieldName: fieldName}, "columnMenu"); + } + } + }); + + + /******************************************************************************* + ** for a given field, set its default operator for quick-filter dropdowns. + *******************************************************************************/ + function getDefaultOperatorForField(field: QFieldMetaData) + { + // todo - sometimes i want contains instead of equals on strings (client.name, for example...) + let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS; + if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE) + { + defaultOperator = QCriteriaOperator.GREATER_THAN; + } + else if (field?.type == QFieldType.BOOLEAN) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for booleans, if we set a default, since none of them have values, then they are ALWAYS selected, which isn't what we want. // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + defaultOperator = null; + } + return defaultOperator; + } + + + /******************************************************************************* + ** Callback passed into the QuickFilter component, to update the criteria + ** after user makes changes to it or to clear it out. + *******************************************************************************/ + const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) => + { + let found = false; + let foundIndex = null; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + if(queryFilter.criteria[i].fieldName == newCriteria.fieldName) + { + queryFilter.criteria[i] = newCriteria; + found = true; + foundIndex = i; + break; + } + } + + if(doClearCriteria) + { + if(found) + { + queryFilter.criteria.splice(foundIndex, 1); + setQueryFilter(queryFilter); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + } + return; + } + + if(!found) + { + if(!queryFilter.criteria) + { + queryFilter.criteria = []; + } + queryFilter.criteria.push(newCriteria); + found = true; + } + + if(found) + { + clearTimeout(debounceTimeout) + debounceTimeout = setTimeout(() => + { + setQueryFilter(queryFilter); + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); + handleFilterChange(gridFilterModel, false); + }, needDebounce ? 500 : 1); + + forceUpdate(); + } + }; + + + /******************************************************************************* + ** Get the QFilterCriteriaWithId object to pass in to the QuickFilter component + ** for a given field name. + *******************************************************************************/ + const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" => + { + const matches: QFilterCriteriaWithId[] = []; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + if(queryFilter.criteria[i].fieldName == fieldName) + { + matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId); + } + } + + if(matches.length == 0) + { + return (null); + } + else if(matches.length == 1) + { + return (matches[0]); + } + else + { + return "tooComplex"; + } + }; + + + /******************************************************************************* + ** set the quick-filter field names state variable and local-storage + *******************************************************************************/ + const storeQuickFilterFieldNames = () => + { + setQuickFilterFieldNames(new Set([...quickFilterFieldNames.values()])); + localStorage.setItem(quickFilterFieldNamesLocalStorageKey, JSON.stringify([...quickFilterFieldNames.values()])); + } + + + /******************************************************************************* + ** Event handler for QuickFilter component, to remove a quick filter field from + ** the screen. + *******************************************************************************/ + const handleRemoveQuickFilterField = (fieldName: string): void => + { + if(quickFilterFieldNames.has(fieldName)) + { + ////////////////////////////////////// + // remove this field from the query // + ////////////////////////////////////// + const criteria = new QFilterCriteria(fieldName, null, []); + updateQuickCriteria(criteria, false, true); + + quickFilterFieldNames.delete(fieldName); + storeQuickFilterFieldNames(); + } + }; + + + /******************************************************************************* + ** Event handler for button that opens the add-quick-filter menu + *******************************************************************************/ + const openAddQuickFilterMenu = (event: any) => + { + setAddQuickFilterMenu(event.currentTarget); + setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1); + } + + + /******************************************************************************* + ** Handle closing the add-quick-filter menu + *******************************************************************************/ + const closeAddQuickFilterMenu = () => + { + setAddQuickFilterMenu(null); + } + + + /******************************************************************************* + ** Add a quick-filter field to the screen, from either the user selecting one, + ** or from a new query being activated, etc. + *******************************************************************************/ + const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | string) => + { + console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`); + if (reason == "blur") + { + ////////////////////////////////////////////////////////////////// + // this keeps a click out of the menu from selecting the option // + ////////////////////////////////////////////////////////////////// + return; + } + + const fieldName = newValue ? newValue.fieldName : null; + if (fieldName) + { + if (!quickFilterFieldNames.has(fieldName)) + { + ///////////////////////////////// + // add the field if we need to // + ///////////////////////////////// + quickFilterFieldNames.add(fieldName); + storeQuickFilterFieldNames(); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected") + { + setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); + } + } + else if(reason == "columnMenu") + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5); + } + + closeAddQuickFilterMenu(); + } + }; + + + /******************************************************************************* + ** event handler for the Filter Buidler button - e.g., opens the parent's grid's + ** filter panel + *******************************************************************************/ + const openFilterBuilder = (e: React.MouseEvent | React.MouseEvent) => + { + gridApiRef.current.showFilterPanel(); + }; + + + /******************************************************************************* + ** event handler for the clear-filters modal + *******************************************************************************/ + const handleClearFiltersAction = (event: React.KeyboardEvent, isYesButton: boolean = false) => + { + if (isYesButton || event.key == "Enter") + { + setShowClearFiltersWarning(false); + handleFilterChange({items: []} as GridFilterModel); + } + }; + + + /******************************************************************************* + ** format the current query as a string for showing on-screen as a preview. + *******************************************************************************/ + const queryToAdvancedString = () => + { + if(queryFilter == null || !queryFilter.criteria) + { + return (); + } + + let counter = 0; + + return ( + + {queryFilter.criteria.map((criteria, i) => + { + if(criteria && criteria.fieldName && criteria.operator) + { + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName); + const valuesString = FilterUtils.getValuesString(field, criteria); + counter++; + + return ( + + {counter > 1 ? {queryFilter.booleanOperator}  : } + {field.label} {criteria.operator} {valuesString}  + + ); + } + else + { + return (); + } + })} + + ); + }; + + + /******************************************************************************* + ** event handler for toggling between modes - basic & advanced. + *******************************************************************************/ + const modeToggleClicked = (newValue: string | null) => + { + if (newValue) + { + if(newValue == "basic") + { + //////////////////////////////////////////////////////////////////////////////// + // we're always allowed to go to advanced - // + // but if we're trying to go to basic, make sure the filter isn't too complex // + //////////////////////////////////////////////////////////////////////////////// + const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + if (!canFilterWorkAsBasic) + { + console.log("Query cannot work as basic - so - not allowing toggle to basic.") + return; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // when going to basic, make sure all fields in the current query are active as quick-filters // + //////////////////////////////////////////////////////////////////////////////////////////////// + if (queryFilter && queryFilter.criteria) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked"); + } + } + + ////////////////////////////////////////////////////////////////////////////////////// + // note - this is a callback to the parent - as it is responsible for this state... // + ////////////////////////////////////////////////////////////////////////////////////// + setMode(newValue); + } + }; + + + /******************************************************************************* + ** make sure that all fields in the current query are on-screen as quick-filters + ** (that is, if the query can be basic) + *******************************************************************************/ + const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string) => + { + if(!tableMetaData || !queryFilter) + { + return; + } + + const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + if (!canFilterWorkAsBasic) + { + console.log("query is too complex for basic - so - switching to advanced"); + modeToggleClicked("advanced"); + forceUpdate(); + return; + } + + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + if (criteria && criteria.fieldName) + { + addQuickFilterField(criteria, reason); + } + } + } + + + ////////////////////////////////////////////////////////////////////////////// + // if there aren't any quick-filters turned on, get defaults from the table // + // only run this block upon a first-render // + ////////////////////////////////////////////////////////////////////////////// + const [firstRender, setFirstRender] = useState(true); + if(firstRender) + { + setFirstRender(false); + + if (defaultQuickFilterFieldNames == null || defaultQuickFilterFieldNames.size == 0) + { + defaultQuickFilterFieldNames = new Set(); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames // + ////////////////////////////////////////////////////////////////////////////////////////////////// + const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard"); + if (mdbMetaData) + { + if (mdbMetaData?.defaultQuickFilterFieldNames?.length) + { + for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++) + { + defaultQuickFilterFieldNames.add(mdbMetaData.defaultQuickFilterFieldNames[i]); + } + } + } + + ///////////////////////////////////////////// + // if still none, then look for T1 section // + ///////////////////////////////////////////// + if (defaultQuickFilterFieldNames.size == 0) + { + if (tableMetaData.sections) + { + const t1Sections = tableMetaData.sections.filter((s: QTableSection) => s.tier == "T1"); + if (t1Sections.length) + { + for (let i = 0; i < t1Sections.length; i++) + { + if (t1Sections[i].fieldNames) + { + for (let j = 0; j < t1Sections[i].fieldNames.length; j++) + { + defaultQuickFilterFieldNames.add(t1Sections[i].fieldNames[j]); + } + } + } + } + } + } + + setQuickFilterFieldNames(defaultQuickFilterFieldNames); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // this is being used as a version of like forcing that we get re-rendered if the query filter changes... // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [lastIndex, setLastIndex] = useState(queryFilterJSON); + if(queryFilterJSON != lastIndex) + { + ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded"); + setLastIndex(queryFilterJSON); + } + + /////////////////////////////////////////////////// + // set some status flags based on current filter // + /////////////////////////////////////////////////// + const hasValidFilters = queryFilter && queryFilter.criteria && queryFilter.criteria.length > 0; // todo - should be better (e.g., see if operator & values are set) + const {canFilterWorkAsBasic, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter); + let reasonWhyBasicIsDisabled = null; + if(reasonsWhyItCannot && reasonsWhyItCannot.length > 0) + { + reasonWhyBasicIsDisabled = <> + Your current Filter cannot be managed using BASIC mode because: +
    + {reasonsWhyItCannot.map((reason, i) =>
  • {reason}
  • )} +
+ + } + + return ( + + + { + mode == "basic" && + + { + tableMetaData && + [...quickFilterFieldNames.values()].map((fieldName) => + { + const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); + + return ( + field && + ); + }) + } + { + tableMetaData && + <> + + + + + + addQuickFilterField(newValue, reason)} + autoFocus={true} + forceOpen={Boolean(addQuickFilterMenu)} + hiddenFieldNames={[...quickFilterFieldNames.values()]} + /> + + + + } + + } + { + metaData && tableMetaData && mode == "advanced" && + <> + + + +
+ { + hasValidFilters && ( + <> + + setShowClearFiltersWarning(true)}>clear + + setShowClearFiltersWarning(true)} onKeyPress={(e) => handleClearFiltersAction(e)}> + Confirm + + Are you sure you want to remove all conditions from the current filter? + + + setShowClearFiltersWarning(true)} /> + handleClearFiltersAction(null, true)} /> + + + + ) + } +
+ + Current Filter: + { + + {queryToAdvancedString()} + + } + + + } +
+ + { + metaData && tableMetaData && + + Mode: + + modeToggleClicked(newValue)} + size="small" + sx={{pl: 0.5}} + > + Basic + Advanced + + + + } + +
+ ); +}); + +export default BasicAndAdvancedQueryControls; \ No newline at end of file diff --git a/src/qqq/components/query/ExportMenuItem.tsx b/src/qqq/components/query/ExportMenuItem.tsx new file mode 100644 index 0000000..f1a1004 --- /dev/null +++ b/src/qqq/components/query/ExportMenuItem.tsx @@ -0,0 +1,131 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import MenuItem from "@mui/material/MenuItem"; +import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro"; +import React from "react"; +import ValueUtils from "qqq/utils/qqq/ValueUtils"; + +interface QExportMenuItemProps extends GridExportMenuItemProps<{}> +{ + tableMetaData: QTableMetaData; + totalRecords: number + columnsModel: GridColDef[]; + columnVisibilityModel: { [index: string]: boolean }; + queryFilter: QQueryFilter; + format: string; +} + +/******************************************************************************* + ** Component to serve as an item in the Export menu + *******************************************************************************/ +export default function ExportMenuItem(props: QExportMenuItemProps) +{ + const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props; + + return ( + + { + /////////////////////////////////////////////////////////////////////////////// + // build the list of visible fields. note, not doing them in-order (in case // + // the user did drag & drop), because column order model isn't right yet // + // so just doing them to match columns (which were pKey, then sorted) // + /////////////////////////////////////////////////////////////////////////////// + const visibleFields: string[] = []; + columnsModel.forEach((gridColumn) => + { + const fieldName = gridColumn.field; + if (columnVisibilityModel[fieldName] !== false) + { + visibleFields.push(fieldName); + } + }); + + ////////////////////////////////////// + // construct the url for the export // + ////////////////////////////////////// + const dateString = ValueUtils.formatDateTimeForFileName(new Date()); + const filename = `${tableMetaData.label} Export ${dateString}.${format}`; + const url = `/data/${tableMetaData.name}/export/${filename}`; + + const encodedFilterJSON = encodeURIComponent(JSON.stringify(queryFilter)); + + ////////////////////////////////////////////////////////////////////////////////////// + // open a window (tab) with a little page that says the file is being generated. // + // then have that page load the url for the export. // + // If there's an error, it'll appear in that window. else, the file will download. // + ////////////////////////////////////////////////////////////////////////////////////// + const exportWindow = window.open("", "_blank"); + exportWindow.document.write(` + + + ${filename} + + + + Generating file ${filename}${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}... +
+ + +
+ + `); + + /* + // todo - probably better - generate the report in an iframe... + // only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped + // maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?) + const iframe = document.getElementById("exportIFrame"); + const form = iframe.querySelector("form"); + form.action = url; + form.target = "exportIFrame"; + (iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue(); + form.submit(); + */ + + /////////////////////////////////////////// + // Hide the export menu after the export // + /////////////////////////////////////////// + hideMenu?.(); + }} + > + Export + {` ${format.toUpperCase()}`} +
+ ); +} + diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index 1580b6b..4170aa6 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.tsx @@ -20,6 +20,7 @@ */ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; @@ -35,9 +36,10 @@ import React, {SyntheticEvent, useState} from "react"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import {getDefaultCriteriaValue, getOperatorOptions, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; -type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex"; +export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex"; interface QuickFilterProps { @@ -47,27 +49,64 @@ interface QuickFilterProps criteriaParam: CriteriaParamType; updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void; defaultOperator?: QCriteriaOperator; - toggleQuickFilterField?: (fieldName: string) => void; + handleRemoveQuickFilterField?: (fieldName: string) => void; } +QuickFilter.defaultProps = + { + defaultOperator: QCriteriaOperator.EQUALS, + handleRemoveQuickFilterField: null + }; + +let seedId = new Date().getTime() % 173237; + +/******************************************************************************* + ** Test if a CriteriaParamType represents an actual query criteria - or, if it's + ** null or the "tooComplex" placeholder. + *******************************************************************************/ const criteriaParamIsCriteria = (param: CriteriaParamType): boolean => { return (param != null && param != "tooComplex"); }; -QuickFilter.defaultProps = +/******************************************************************************* + ** Test of an OperatorOption equals a query Criteria - that is - that the + ** operators within them are equal - AND - if the OperatorOption has implicit + ** values (e.g., the booleans), then those options equal the criteria's options. + *******************************************************************************/ +const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteria: QFilterCriteriaWithId): boolean => +{ + if(operatorOption.value == criteria.operator) { - defaultOperator: QCriteriaOperator.EQUALS, - toggleQuickFilterField: null - }; + if(operatorOption.implicitValues) + { + if(JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values)) + { + return (true); + } + else + { + return (false); + } + } -let seedId = new Date().getTime() % 173237; + return (true); + } + return (false); +} + + +/******************************************************************************* + ** Get the object to use as the selected OperatorOption (e.g., value for that + ** autocomplete), given an array of options, the query's active criteria in this + ** field, and the default operator to use for this field + *******************************************************************************/ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption => { if(criteria) { - const filteredOptions = operatorOptions.filter(o => o.value == criteria.operator); + const filteredOptions = operatorOptions.filter(o => doesOperatorOptionEqualCriteria(o, criteria)); if(filteredOptions.length > 0) { return (filteredOptions[0]); @@ -83,10 +122,14 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q return (null); } -export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, toggleQuickFilterField}: QuickFilterProps): JSX.Element +/******************************************************************************* + ** Component to render a QuickFilter - that is - a button, with a Menu under it, + ** with Operator and Value controls. + *******************************************************************************/ +export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField}: QuickFilterProps): JSX.Element { const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : []; - const tableForField = tableMetaData; // todo!! const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName); + const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName); const [isOpen, setIsOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); @@ -97,17 +140,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator)); const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label); - const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator); - if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue)) - { - setOperatorSelectedValue(maybeNewOperatorSelectedValue) - setOperatorInputValue(maybeNewOperatorSelectedValue.label) - } + const [startIconName, setStartIconName] = useState("filter_alt"); - if(!fieldMetaData) - { - return (null); - } + const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) // @@ -117,16 +152,20 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData const newCriteria = criteriaParam as QFilterCriteriaWithId; setCriteria(newCriteria); const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0]; + console.log(`B: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`); setOperatorSelectedValue(operatorOption); setOperatorInputValue(operatorOption.label); } + /******************************************************************************* + ** Test if we need to construct a new criteria object + *******************************************************************************/ const criteriaNeedsReset = (): boolean => { if(criteria != null && criteriaParam == null) { const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; - if(criteria.operator !== defaultOperatorOption.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue())) + if(criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue())) { return (true); } @@ -135,44 +174,50 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData return (false); } + /******************************************************************************* + ** Construct a new criteria object - resetting the values tied to the oprator + ** autocomplete at the same time. + *******************************************************************************/ const makeNewCriteria = (): QFilterCriteria => { const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0]; - const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption.value, getDefaultCriteriaValue()); + const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption?.value, getDefaultCriteriaValue()); criteria.id = id; + console.log(`C: setOperatorSelectedValue [${JSON.stringify(operatorOption)}]`); setOperatorSelectedValue(operatorOption); - setOperatorInputValue(operatorOption.label); + setOperatorInputValue(operatorOption?.label); setCriteria(criteria); return(criteria); } - if (criteria == null || criteriaNeedsReset()) - { - makeNewCriteria(); - } - - const toggleOpen = (event: any) => + /******************************************************************************* + ** event handler to open the menu in response to the button being clicked. + *******************************************************************************/ + const handleOpenMenu = (event: any) => { setIsOpen(!isOpen); setAnchorEl(event.currentTarget); }; + /******************************************************************************* + ** handler for the Menu when being closed + *******************************************************************************/ const closeMenu = () => { setIsOpen(false); setAnchorEl(null); }; - ///////////////////////////////////////////// - // event handler for operator Autocomplete // - // todo - too dupe? - ///////////////////////////////////////////// + /******************************************************************************* + ** event handler for operator Autocomplete having its value changed + *******************************************************************************/ const handleOperatorChange = (event: any, newValue: any, reason: string) => { criteria.operator = newValue ? newValue.value : null; if (newValue) { + console.log(`D: setOperatorSelectedValue [${JSON.stringify(newValue)}]`); setOperatorSelectedValue(newValue); setOperatorInputValue(newValue.label); @@ -183,6 +228,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData } else { + console.log("E: setOperatorSelectedValue [null]"); setOperatorSelectedValue(null); setOperatorInputValue(""); } @@ -190,15 +236,18 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData updateCriteria(criteria, false, false); }; + /******************************************************************************* + ** implementation of isOptionEqualToValue for Autocomplete - compares both the + ** value (e.g., what operator it is) and the implicitValues within the option + *******************************************************************************/ function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption) { return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues)); } - ////////////////////////////////////////////////// - // event handler for value field (of all types) // - // todo - too dupe! - ////////////////////////////////////////////////// + /******************************************************************************* + ** event handler for the value field (of all types), when it changes + *******************************************************************************/ const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) => { // @ts-ignore @@ -221,48 +270,17 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData updateCriteria(criteria, true, false); }; + /******************************************************************************* + ** a noop event handler, e.g., for a too-complex + *******************************************************************************/ const noop = () => { }; - const getValuesString = (): string => - { - let valuesString = ""; - if (criteria.values && criteria.values.length) - { - let labels = [] as string[]; - - let maxLoops = criteria.values.length; - if (maxLoops > 5) - { - maxLoops = 3; - } - - for (let i = 0; i < maxLoops; i++) - { - if (criteria.values[i] && criteria.values[i].label) - { - labels.push(criteria.values[i].label); - } - else - { - labels.push(criteria.values[i]); - } - } - - if (maxLoops < criteria.values.length) - { - labels.push(" and " + (criteria.values.length - maxLoops) + " other values."); - } - - valuesString = (labels.join(", ")); - } - return valuesString; - } - - const [startIconName, setStartIconName] = useState("filter_alt"); - const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); - + /******************************************************************************* + ** event handler that responds to 'x' button that removes the criteria from the + ** quick-filter, resetting it to a new filter. + *******************************************************************************/ const resetCriteria = (e: React.MouseEvent) => { if(criteriaIsValid) @@ -274,6 +292,10 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData } } + /******************************************************************************* + ** event handler for mouse-over on the filter icon - that changes to an 'x' + ** if there's a valid criteria in the quick-filter + *******************************************************************************/ const startIconMouseOver = () => { if(criteriaIsValid) @@ -281,11 +303,56 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData setStartIconName("clear"); } } + + /******************************************************************************* + ** event handler for mouse-out on the filter icon - always resets it. + *******************************************************************************/ const startIconMouseOut = () => { setStartIconName("filter_alt"); } + /******************************************************************************* + ** event handler for clicking the (x) icon that turns off this quick filter field. + ** hands off control to the function that was passed in (e.g., from RecordQuery). + *******************************************************************************/ + const handleTurningOffQuickFilterField = () => + { + closeMenu() + handleRemoveQuickFilterField(criteria?.fieldName); + } + + //////////////////////////////////////////////////////////////////////////////////// + // if no field was input (e.g., record-query is still loading), return null early // + //////////////////////////////////////////////////////////////////////////////////// + if(!fieldMetaData) + { + return (null); + } + + ////////////////////////////////////////////////////////////////////////////////////////// + // if there should be a selected value in the operator autocomplete, and it's different // + // from the last selected one, then set the state vars that control that autocomplete // + ////////////////////////////////////////////////////////////////////////////////////////// + const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator); + if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue)) + { + console.log(`A: setOperatorSelectedValue [${JSON.stringify(maybeNewOperatorSelectedValue)}]`); + setOperatorSelectedValue(maybeNewOperatorSelectedValue) + setOperatorInputValue(maybeNewOperatorSelectedValue?.label) + } + + ///////////////////////////////////////////////////////////////////////////////////// + // if there wasn't a criteria, or we need to reset it (make a new one), then do so // + ///////////////////////////////////////////////////////////////////////////////////// + if (criteria == null || criteriaNeedsReset()) + { + makeNewCriteria(); + } + + ///////////////////////// + // build up the button // + ///////////////////////// const tooComplex = criteriaParam == "tooComplex"; const tooltipEnterDelay = 500; let startIcon = {startIconName} @@ -298,22 +365,29 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData if (criteriaIsValid) { buttonContent = ( - + {buttonContent} ); } let button = fieldMetaData && ; + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if the criteria on this field is the "tooComplex" sentinel, then wrap the button in a tooltip stating such, and return early. // + // note this was part of original design on this widget, but later deprecated... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if (tooComplex) { - // wrap button in span, so disabled button doesn't cause disabled tooltip + //////////////////////////////////////////////////////////////////////////// + // wrap button in span, so disabled button doesn't cause disabled tooltip // + //////////////////////////////////////////////////////////////////////////// return ( {button} @@ -321,12 +395,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData ); } - const doToggle = () => - { - closeMenu() - toggleQuickFilterField(criteria?.fieldName); - } - + ////////////////////////////// + // return the button & menu // + ////////////////////////////// const widthAndMaxWidth = 250 return ( <> @@ -334,9 +405,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData { isOpen && { - toggleQuickFilterField && + handleRemoveQuickFilterField && - highlight_off + highlight_off } diff --git a/src/qqq/components/query/SelectionSubsetDialog.tsx b/src/qqq/components/query/SelectionSubsetDialog.tsx new file mode 100644 index 0000000..d601f1c --- /dev/null +++ b/src/qqq/components/query/SelectionSubsetDialog.tsx @@ -0,0 +1,74 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import React, {useState} from "react"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; + + +/******************************************************************************* + ** Component that is the dialog for the user to enter the selection-subset + *******************************************************************************/ +export default 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)} /> + + + ); +} + diff --git a/src/qqq/components/query/TableVariantDialog.tsx b/src/qqq/components/query/TableVariantDialog.tsx new file mode 100644 index 0000000..b40efa5 --- /dev/null +++ b/src/qqq/components/query/TableVariantDialog.tsx @@ -0,0 +1,122 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant"; +import Autocomplete from "@mui/material/Autocomplete"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import React, {useEffect, useState} from "react"; +import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery"; +import Client from "qqq/utils/qqq/Client"; + +const qController = Client.getInstance(); + +/******************************************************************************* + ** Component that is the dialog for the user to select a variant on tables with variant backends // + *******************************************************************************/ +export default function TableVariantDialog(props: { isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void }) +{ + const [value, setValue] = useState(null); + const [dropDownOpen, setDropDownOpen] = useState(false); + const [variants, setVariants] = useState(null); + + const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) => + { + const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`; + if (value != null) + { + localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value)); + } + else + { + localStorage.removeItem(tableVariantLocalStorageKey); + } + props.closeHandler(value); + }; + + const keyPressed = (e: React.KeyboardEvent) => + { + if (e.key == "Enter" && value) + { + props.closeHandler(value); + } + }; + + useEffect(() => + { + console.log("queryVariants"); + try + { + (async () => + { + const variants = await qController.tableVariants(props.table.name); + console.log(JSON.stringify(variants)); + setVariants(variants); + })(); + } + catch (e) + { + console.log(e); + } + }, []); + + + return variants && ( + keyPressed(e)}> + {props.table.variantTableLabel} + + Select the {props.table.variantTableLabel} to be used on this table: + + { + setDropDownOpen(true); + }} + onClose={() => + { + setDropDownOpen(false); + }} + // @ts-ignore + onChange={handleVariantChange} + isOptionEqualToValue={(option, value) => option.id === value.id} + options={variants} + renderInput={(params) => } + getOptionLabel={(option) => + { + if (typeof option == "object") + { + return (option as QTableVariant).name; + } + return option; + }} + /> + + + ); +} + diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index b79d5b3..4a29d69 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -22,7 +22,6 @@ import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; 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 {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; @@ -30,19 +29,11 @@ import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTa 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 {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator"; -import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {Alert, Collapse, TablePagination, Typography} from "@mui/material"; -import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -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"; @@ -51,22 +42,23 @@ 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 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, useGridApiRef, GridPreferencePanelsValue, GridColumnResizeParams} from "@mui/x-data-grid-pro"; +import {DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridFilterModel, GridPinnedColumns, gridPreferencePanelStateSelector, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridSelector, useGridApiRef, GridPreferencePanelsValue, GridColumnResizeParams, ColumnHeaderFilterIconButtonProps} from "@mui/x-data-grid-pro"; 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, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import {QActionsMenuButton, QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons"; import MenuButton from "qqq/components/buttons/MenuButton"; -import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog"; import SavedFilters from "qqq/components/misc/SavedFilters"; +import BasicAndAdvancedQueryControls from "qqq/components/query/BasicAndAdvancedQueryControls"; import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; -import {CustomFilterPanel, QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; -import QuickFilter from "qqq/components/query/QuickFilter"; +import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; +import ExportMenuItem from "qqq/components/query/ExportMenuItem"; +import SelectionSubsetDialog from "qqq/components/query/SelectionSubsetDialog"; +import TableVariantDialog from "qqq/components/query/TableVariantDialog"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import BaseLayout from "qqq/layouts/BaseLayout"; import ProcessRun from "qqq/pages/processes/ProcessRun"; @@ -89,6 +81,7 @@ const COLUMN_WIDTHS_LOCAL_STORAGE_KEY_ROOT = "qqq.columnWidths"; const SEEN_JOIN_TABLES_LOCAL_STORAGE_KEY_ROOT = "qqq.seenJoinTables"; const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density"; const QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT = "qqq.quickFilterFieldNames"; +const MODE_LOCAL_STORAGE_KEY_ROOT = "qqq.queryScreenMode"; export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant"; @@ -104,7 +97,6 @@ RecordQuery.defaultProps = { }; const qController = Client.getInstance(); -let debounceTimeout: string | number | NodeJS.Timeout; function RecordQuery({table, launchProcess}: Props): JSX.Element { @@ -151,7 +143,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}`; const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; - const quickFilterFieldNamesLocalStorageKey = `${QUICK_FILTER_FIELD_NAMES_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; + const modeLocalStorageKey = `${MODE_LOCAL_STORAGE_KEY_ROOT}.${tableName}`; let defaultSort = [] as GridSortItem[]; let defaultVisibility = {} as { [index: string]: boolean }; let didDefaultVisibilityComeFromLocalStorage = false; @@ -162,7 +154,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element let defaultColumnWidths = {} as {[fieldName: string]: number}; let seenJoinTables: {[tableName: string]: boolean} = {}; let defaultTableVariant: QTableVariant = null; - let defaultQuickFilterFieldNames: Set = new Set(); + let defaultMode = "basic"; //////////////////////////////////////////////////////////////////////////////////// // set the to be not per table (do as above if we want per table) at a later port // @@ -206,13 +198,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { defaultTableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey)); } - if (localStorage.getItem(quickFilterFieldNamesLocalStorageKey)) + if (localStorage.getItem(modeLocalStorageKey)) { - defaultQuickFilterFieldNames = new Set(JSON.parse(localStorage.getItem(quickFilterFieldNamesLocalStorageKey))); + defaultMode = localStorage.getItem(modeLocalStorageKey); } const [filterModel, setFilterModel] = useState({items: []} as GridFilterModel); const [lastFetchedQFilterJSON, setLastFetchedQFilterJSON] = useState(""); + const [lastFetchedVariant, setLastFetchedVariant] = useState(null); const [columnSortModel, setColumnSortModel] = useState(defaultSort); const [queryFilter, setQueryFilter] = useState(new QQueryFilter()); const [tableVariant, setTableVariant] = useState(defaultTableVariant); @@ -254,8 +247,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [gridMouseDownX, setGridMouseDownX] = useState(0); const [gridMouseDownY, setGridMouseDownY] = useState(0); const [gridPreferencesWindow, setGridPreferencesWindow] = useState(undefined); - const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); - const [hasValidFilters, setHasValidFilters] = useState(false); const [currentSavedFilter, setCurrentSavedFilter] = useState(null as QRecord); const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData); @@ -266,9 +257,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [columnStatsFieldTableName, setColumnStatsFieldTableName] = useState(null as string) const [filterForColumnStats, setFilterForColumnStats] = useState(null as QQueryFilter); - const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null); - const [quickFilterFieldNames, setQuickFilterFieldNames] = useState(defaultQuickFilterFieldNames); - const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0); + const [mode, setMode] = useState(defaultMode); + const basicAndAdvancedQueryControlsRef = useRef(); const instance = useRef({timer: null}); @@ -321,7 +311,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element else if (! e.metaKey && e.key === "r") { e.preventDefault() - updateTable(); + updateTable("'r' keyboard event"); } else if (! e.metaKey && e.key === "c") { @@ -396,7 +386,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } else { - setCurrentSavedFilter(null); + doSetCurrentSavedFilter(null); } } @@ -417,7 +407,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { const result = processResult as QJobComplete; const qRecord = new QRecord(result.values.savedFilterList[0]); - setCurrentSavedFilter(qRecord); + doSetCurrentSavedFilter(qRecord); } })(); } @@ -473,7 +463,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { let filter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, limit); filter = FilterUtils.convertFilterPossibleValuesToIds(filter); - setHasValidFilters(filter.criteria && filter.criteria.length > 0); return (filter); }; @@ -588,14 +577,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element {tableMetaData?.variantTableLabel}: {tableVariant?.name} - settings + settings ); } - const updateTable = () => + const updateTable = (reason?: string) => { + console.log(`In updateTable for ${reason}`); setLoading(true); setRows([]); (async () => @@ -658,14 +648,25 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setColumnSortModel(models.sort); setWarningAlert(models.warning); - setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage)); + const newQueryFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage); + setQueryFilter(newQueryFilter); + + //////////////////////////////////////////////////////////////////////////////////////// + // this ref may not be defined on the initial render, so, make this call in a timeout // + //////////////////////////////////////////////////////////////////////////////////////// + setTimeout(() => + { + // @ts-ignore + basicAndAdvancedQueryControlsRef?.current?.ensureAllFilterCriteriaAreActiveQuickFilters(newQueryFilter, "defaultFilterLoaded") + }); + return; } setTableMetaData(tableMetaData); setTableLabel(tableMetaData.label); - if(tableMetaData?.usesVariants && ! tableVariant) + if (tableMetaData?.usesVariants && !tableVariant) { promptForTableVariantSelection(); return; @@ -802,6 +803,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } setLastFetchedQFilterJSON(JSON.stringify(qFilter)); + setLastFetchedVariant(tableVariant); qController.query(tableName, qFilter, queryJoins, tableVariant).then((results) => { console.log(`Received results for query ${thisQueryId}`); @@ -1021,7 +1023,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (JSON.stringify([...newVisibleJoinTables.keys()]) != JSON.stringify([...visibleJoinTables.keys()])) { console.log("calling update table for visible join table change"); - updateTable(); + updateTable("visible joins change"); setVisibleJoinTables(newVisibleJoinTables); } @@ -1117,111 +1119,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element })(); } - interface QExportMenuItemProps extends GridExportMenuItemProps<{}> - { - format: string; - } - - function ExportMenuItem(props: QExportMenuItemProps) - { - const {format, hideMenu} = props; - - return ( - - { - /////////////////////////////////////////////////////////////////////////////// - // build the list of visible fields. note, not doing them in-order (in case // - // the user did drag & drop), because column order model isn't right yet // - // so just doing them to match columns (which were pKey, then sorted) // - /////////////////////////////////////////////////////////////////////////////// - const visibleFields: string[] = []; - columnsModel.forEach((gridColumn) => - { - const fieldName = gridColumn.field; - if (columnVisibilityModel[fieldName] !== false) - { - visibleFields.push(fieldName); - } - }); - - /////////////////////// - // zero-pad function // - /////////////////////// - const zp = (value: number): string => (value < 10 ? `0${value}` : `${value}`); - - ////////////////////////////////////// - // construct the url for the export // - ////////////////////////////////////// - const dateString = ValueUtils.formatDateTimeForFileName(new Date()); - const filename = `${tableMetaData.label} Export ${dateString}.${format}`; - const url = `/data/${tableMetaData.name}/export/${filename}`; - - const encodedFilterJSON = encodeURIComponent(JSON.stringify(buildQFilter(tableMetaData, filterModel))); - - ////////////////////////////////////////////////////////////////////////////////////// - // open a window (tab) with a little page that says the file is being generated. // - // then have that page load the url for the export. // - // If there's an error, it'll appear in that window. else, the file will download. // - ////////////////////////////////////////////////////////////////////////////////////// - const exportWindow = window.open("", "_blank"); - exportWindow.document.write(` - - - ${filename} - - - - Generating file ${filename}${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}... -
- - -
- - `); - - /* - // todo - probably better - generate the report in an iframe... - // only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped - // maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?) - const iframe = document.getElementById("exportIFrame"); - const form = iframe.querySelector("form"); - form.action = url; - form.target = "exportIFrame"; - (iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue(); - form.submit(); - */ - - /////////////////////////////////////////// - // Hide the export menu after the export // - /////////////////////////////////////////// - hideMenu?.(); - }} - > - Export - {` ${format.toUpperCase()}`} -
- ); - } - function getNoOfSelectedRecords() { if (selectFullFilterState === "filter") { - if(isJoinMany(tableMetaData, getVisibleJoinTables())) + if (isJoinMany(tableMetaData, getVisibleJoinTables())) { return (distinctRecords); } @@ -1300,8 +1202,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element newPath.pop(); navigate(newPath.join("/")); - console.log("calling update table for close modal"); - updateTable(); + updateTable("close modal process"); }; const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete", processLabelPart: "Load" | "Edit" | "Delete") => @@ -1361,7 +1262,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element more than one record associated with each {tableMetaData?.label}. let distinctPart = isJoinMany(tableMetaData, getVisibleJoinTables()) ? ( -  ({safeToLocaleString(distinctRecords)} distinct +  ({ValueUtils.safeToLocaleString(distinctRecords)} distinct info_outlined ) @@ -1431,12 +1332,36 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } + function doSetCurrentSavedFilter(savedFilter: QRecord) + { + setCurrentSavedFilter(savedFilter); + + if(savedFilter) + { + (async () => + { + let localTableMetaData = tableMetaData; + if(!localTableMetaData) + { + localTableMetaData = await qController.loadTableMetaData(tableName); + } + + const models = await FilterUtils.determineFilterAndSortModels(qController, localTableMetaData, savedFilter.values.get("filterJson"), null, null, null); + const newQueryFilter = FilterUtils.buildQFilterFromGridFilter(localTableMetaData, models.filter, models.sort, rowsPerPage); + // todo?? ensureAllFilterCriteriaAreActiveQuickFilters(localTableMetaData, newQueryFilter, "savedFilterSelected") + + const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(localTableMetaData, newQueryFilter); + handleFilterChange(gridFilterModel, true); + })() + } + } + async function handleSavedFilterChange(selectedSavedFilterId: number) { if (selectedSavedFilterId != null) { const qRecord = await fetchSavedFilter(selectedSavedFilterId); - setCurrentSavedFilter(qRecord); // this fixed initial load not showing filter name + doSetCurrentSavedFilter(qRecord); // this fixed initial load not showing filter name const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null); handleFilterChange(models.filter); @@ -1533,7 +1458,28 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return ( - + + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + // in advanced mode, use the default GridFilterMenuItem, which punches into the advanced/filter-builder UI // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// + mode == "advanced" && + } + + { + /////////////////////////////////////////////////////////////////////////////////// + // for basic mode, use our own menu item to turn on this field as a quick-filter // + /////////////////////////////////////////////////////////////////////////////////// + mode == "basic" && + { + hideMenu(e); + // @ts-ignore !? + basicAndAdvancedQueryControlsRef.current.addField(currentColumn.field); + }}> + Filter (BASIC) TODO edit text + + } + @@ -1569,15 +1515,36 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); }); - - const safeToLocaleString = (n: Number): string => - { - if(n != null && n != undefined) + const CustomColumnHeaderFilterIconButton = forwardRef( + function ColumnHeaderFilterIconButton(props: ColumnHeaderFilterIconButtonProps, ref) { - return (n.toLocaleString()); - } - return (""); - } + if(mode == "basic") + { + let showFilter = false; + for (let i = 0; i < queryFilter?.criteria?.length; i++) + { + const criteria = queryFilter.criteria[i]; + if(criteria.fieldName == props.field && criteria.operator) + { + // todo - test values too right? + showFilter = true; + } + } + + if(showFilter) + { + return ( + { + // @ts-ignore !? + basicAndAdvancedQueryControlsRef.current.addField(props.field); + + event.stopPropagation(); + }}>filter_alt); + } + } + + return (<>); + }); function CustomToolbar() { @@ -1614,9 +1581,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const joinIsMany = isJoinMany(tableMetaData, visibleJoinTables); 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(`This page (${ValueUtils.safeToLocaleString(distinctRecordsOnPageCount)} ${joinIsMany ? "distinct " : ""}record${distinctRecordsOnPageCount == 1 ? "" : "s"})`); + selectionMenuOptions.push(`Full query result (${joinIsMany ? ValueUtils.safeToLocaleString(distinctRecords) + ` distinct record${distinctRecords == 1 ? "" : "s"}` : ValueUtils.safeToLocaleString(totalRecords) + ` record${totalRecords == 1 ? "" : "s"}`})`); + selectionMenuOptions.push(`Subset of the query result ${selectionSubsetSize ? `(${ValueUtils.safeToLocaleString(selectionSubsetSize)} ${joinIsMany ? "distinct " : ""}record${selectionSubsetSize == 1 ? "" : "s"})` : "..."}`); selectionMenuOptions.push("Clear selection"); function programmaticallySelectSomeOrAllRows(max?: number) @@ -1631,11 +1598,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element rows.forEach((value: GridRowModel, index: number) => { const primaryKeyValue = latestQueryResults[index].values.get(tableMetaData.primaryKeyField); - if(max) + if (max) { - if(selectedPrimaryKeys.size < max) + if (selectedPrimaryKeys.size < max) { - if(!selectedPrimaryKeys.has(primaryKeyValue)) + if (!selectedPrimaryKeys.has(primaryKeyValue)) { rowSelectionModel.push(value.__rowIndex); selectedPrimaryKeys.add(primaryKeyValue as string); @@ -1676,58 +1643,37 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; - const doClearFilter = (event: React.KeyboardEvent, isYesButton: boolean = false) => - { - if (isYesButton|| event.key == "Enter") + const exportMenuItemRestProps = { - setShowClearFiltersWarning(false); - handleFilterChange({items: []} as GridFilterModel); + tableMetaData: tableMetaData, + totalRecords: totalRecords, + columnsModel: columnsModel, + columnVisibilityModel: columnVisibilityModel, + queryFilter: queryFilter } - } return (
-
{/* @ts-ignore */}
- {/* @ts-ignore */} - - { - hasValidFilters && ( -
- - setShowClearFiltersWarning(true)}>clear - - setShowClearFiltersWarning(false)} onKeyPress={(e) => doClearFilter(e)}> - Confirm - - Are you sure you want to remove all conditions from the current filter? - - - setShowClearFiltersWarning(false)} /> - doClearFilter(null, true)}/> - - -
- ) - } {/* @ts-ignore */} {/* @ts-ignore */} - - - + + +
- + { setSelectionSubsetSizePromptOpen(false); @@ -1778,14 +1724,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { 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. + The setSelectionSubsetSizePromptOpen(true)} style={{cursor: "pointer"}}>first {ValueUtils.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. + {ValueUtils.safeToLocaleString(selectedIds.length)} {joinIsMany ? "distinct" : ""} {selectedIds.length == 1 ? "record is" : "records are"} selected.
) } @@ -1875,34 +1821,27 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // to avoid both this useEffect and the one below from both doing an "initial query", // // only run this one if at least 1 query has already been ran // //////////////////////////////////////////////////////////////////////////////////////// - updateTable(); + updateTable("useEffect(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 // /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - useEffect(() => - { - setTotalRecords(null); - setDistinctRecords(null); - updateTable(); - }, [columnsModel, tableState, tableVariant]); - useEffect(() => { const currentQFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel, rowsPerPage); currentQFilter.skip = pageNumber * rowsPerPage; const currentQFilterJSON = JSON.stringify(currentQFilter); + const currentVariantJSON = JSON.stringify(tableVariant); - if(currentQFilterJSON !== lastFetchedQFilterJSON) + if(currentQFilterJSON !== lastFetchedQFilterJSON || currentVariantJSON !== lastFetchedVariant) { setTotalRecords(null); setDistinctRecords(null); - updateTable(); + updateTable("useEffect(filterModel)"); } - - }, [filterModel]); + }, [filterModel, columnsModel, tableState, tableVariant]); useEffect(() => { @@ -1963,141 +1902,33 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } - const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) => + const doSetMode = (newValue: string) => { - let found = false; - let foundIndex = null; - for (let i = 0; i < queryFilter?.criteria?.length; i++) - { - if(queryFilter.criteria[i].fieldName == newCriteria.fieldName) - { - queryFilter.criteria[i] = newCriteria; - found = true; - foundIndex = i; - break; - } - } - - if(doClearCriteria) - { - if(found) - { - queryFilter.criteria.splice(foundIndex, 1); - setQueryFilter(queryFilter); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); - handleFilterChange(gridFilterModel, false); - } - return; - } - - if(!found) - { - if(!queryFilter.criteria) - { - queryFilter.criteria = []; - } - queryFilter.criteria.push(newCriteria); - found = true; - } - - if(found) - { - clearTimeout(debounceTimeout) - debounceTimeout = setTimeout(() => - { - setQueryFilter(queryFilter); - const gridFilterModel = FilterUtils.buildGridFilterFromQFilter(tableMetaData, queryFilter); - handleFilterChange(gridFilterModel, false); - }, needDebounce ? 500 : 1); - - forceUpdate(); - } - }; - - - const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" => - { - const matches: QFilterCriteriaWithId[] = []; - for (let i = 0; i < queryFilter?.criteria?.length; i++) - { - if(queryFilter.criteria[i].fieldName == fieldName) - { - matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId); - } - } - - if(matches.length == 0) - { - return (null); - } - else if(matches.length == 1) - { - return (matches[0]); - } - else - { - return "tooComplex"; - } + setMode(newValue); + localStorage.setItem(modeLocalStorageKey, newValue); } - const toggleQuickFilterField = (fieldName: string): void => + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // for basic mode, set a custom ColumnHeaderFilterIconButton - w/ action to activate basic-mode quick-filter // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + let restOfDataGridProCustomComponents: any = {} + if(mode == "basic") { - if(quickFilterFieldNames.has(fieldName)) - { - quickFilterFieldNames.delete(fieldName); - } - else - { - quickFilterFieldNames.add(fieldName); - } - setQuickFilterFieldNames(new Set([...quickFilterFieldNames.values()])) - localStorage.setItem(quickFilterFieldNamesLocalStorageKey, JSON.stringify([...quickFilterFieldNames.values()])); - - // damnit, not auto-updating in the filter panel... have to click twice most of the time w/o this hacky hack. - setTimeout(() => forceUpdate(), 10); - } - - const openAddQuickFilterMenu = (event: any) => - { - setAddQuickFilterMenu(event.currentTarget); - setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1); - } - - const closeAddQuickFilterMenu = () => - { - setAddQuickFilterMenu(null); - } - - function addQuickFilterField(event: any, newValue: any, reason: string) - { - if(reason == "blur") - { - ////////////////////////////////////////////////////////////////// - // this keeps a click out of the menu from selecting the option // - ////////////////////////////////////////////////////////////////// - return; - } - - const fieldName = newValue ? newValue.fieldName : null - if(fieldName) - { - toggleQuickFilterField(fieldName); - closeAddQuickFilterMenu(); - } + restOfDataGridProCustomComponents.ColumnHeaderFilterIconButton = CustomColumnHeaderFilterIconButton; } return (
{/* - // see above code that would use this + // see code in ExportMenuItem that would use this */} - + {alertContent ? ( - - - Fields that you frequently use for filter conditions can be added here for quick access.

- Use the - add_circle_outline - button to add a Quick Filter field.

- To remove a Quick Filter field, click the field name, and then use the - highlight_off - button. -
} placement="left"> - Quick Filter: - - { - metaData && tableMetaData && - <> - - openAddQuickFilterMenu(e)} size="small" disableRipple>add_circle_outline - - - - - - - - } - { - tableMetaData && - [...quickFilterFieldNames.values()].map((fieldName) => - { - // todo - join fields... - // todo - sometimes i want contains (client.name, for example...) - - const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName); - let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS - if(field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE) - { - defaultOperator = QCriteriaOperator.GREATER_THAN; - } - - return ( - field && - ) - }) - } -
+ { + metaData && tableMetaData && + + } @@ -2232,7 +2008,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element LoadingOverlay: Loading, ColumnMenu: CustomColumnMenu, ColumnsPanel: CustomColumnsPanel, - FilterPanel: CustomFilterPanel + FilterPanel: CustomFilterPanel, + ... restOfDataGridProCustomComponents }} componentsProps={{ columnsPanel: @@ -2250,9 +2027,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element metaData: metaData, queryFilter: queryFilter, updateFilter: updateFilterFromFilterPanel, - quickFilterFieldNames: quickFilterFieldNames, - showQuickFilterPin: true, - toggleQuickFilterField: toggleQuickFilterField, } }} localeText={{ @@ -2296,7 +2070,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element getRowId={(row) => row.__rowIndex} selectionModel={rowSelectionModel} hideFooterSelectedRowCount={true} - sx={{border: 0, height: "calc(100vh - 250px)"}} + sx={{border: 0, height: tableMetaData?.usesVariants ? "calc(100vh - 300px)" : "calc(100vh - 270px)"}} /> @@ -2342,136 +2116,4 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } -//////////////////////////////////////////////////////////////////////////////////////////////////////// -// mini-component that is the dialog for the user to select a variant on tables with variant backends // -//////////////////////////////////////////////////////////////////////////////////////////////////////// -function TableVariantDialog(props: {isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void}) -{ - const [value, setValue] = useState(null) - const [dropDownOpen, setDropDownOpen] = useState(false) - const [variants, setVariants] = useState(null); - - const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) => - { - const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`; - if(value != null) - { - localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value)); - } - else - { - localStorage.removeItem(tableVariantLocalStorageKey); - } - props.closeHandler(value); - }; - - const keyPressed = (e: React.KeyboardEvent) => - { - if(e.key == "Enter" && value) - { - props.closeHandler(value); - } - } - - useEffect(() => - { - console.log("queryVariants") - try - { - (async () => - { - const variants = await qController.tableVariants(props.table.name); - console.log(JSON.stringify(variants)); - setVariants(variants); - })(); - } - catch (e) - { - console.log(e); - } - }, []); - - - return variants && ( - keyPressed(e)}> - {props.table.variantTableLabel} - - Select the {props.table.variantTableLabel} to be used on this table: - - { - setDropDownOpen(true); - }} - onClose={() => - { - setDropDownOpen(false); - }} - // @ts-ignore - onChange={handleVariantChange} - isOptionEqualToValue={(option, value) => option.id === value.id} - options={variants} - renderInput={(params) => } - getOptionLabel={(option) => - { - if(typeof option == "object") - { - return (option as QTableVariant).name; - } - return option; - }} - /> - - - ) -} - -////////////////////////////////////////////////////////////////////////////////// -// 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 b9aba6b..b764304 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -424,6 +424,12 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } top: -60px !important; } +.MuiDataGrid-panel:has(.customFilterPanel) +{ + /* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */ + transform: translate(274px, 305px) !important; +} + /* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */ .MuiDataGrid-panel .customFilterPanel { diff --git a/src/qqq/utils/qqq/FilterUtils.ts b/src/qqq/utils/qqq/FilterUtils.ts index 6fbb489..2feb61d 100644 --- a/src/qqq/utils/qqq/FilterUtils.ts +++ b/src/qqq/utils/qqq/FilterUtils.ts @@ -358,9 +358,9 @@ class FilterUtils //////////////////////////////////////////////////////////////////////////////////////////////// if (fieldType === QFieldType.DATE_TIME) { - for(let i = 0; i { - if(item.value && item.value.length) + if (item.value && item.value.length) { for (let i = 0; i < item.value.length; i++) { - const expression = this.gridCriteriaValueToExpression(item.value[i]) - if(expression) + const expression = this.gridCriteriaValueToExpression(item.value[i]); + if (expression) { item.value[i] = expression; } @@ -525,8 +525,8 @@ class FilterUtils } else { - const expression = this.gridCriteriaValueToExpression(item.value) - if(expression) + const expression = this.gridCriteriaValueToExpression(item.value); + if (expression) { item.value = expression; } @@ -641,7 +641,7 @@ class FilterUtils let incomplete = false; if (item.operatorValue === "between" || item.operatorValue === "notBetween") { - if(!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1])) + if (!item.value || !item.value.length || item.value.length < 2 || this.isUnset(item.value[0]) || this.isUnset(item.value[1])) { incomplete = true; } @@ -747,6 +747,103 @@ class FilterUtils return (filter); } + + /******************************************************************************* + ** + *******************************************************************************/ + public static canFilterWorkAsBasic(tableMetaData: QTableMetaData, filter: QQueryFilter): { canFilterWorkAsBasic: boolean; reasonsWhyItCannot?: string[] } + { + const reasonsWhyItCannot: string[] = []; + + if(filter == null) + { + return ({canFilterWorkAsBasic: true}); + } + + if(filter.booleanOperator == "OR") + { + reasonsWhyItCannot.push("Filter uses the 'OR' operator.") + } + + if(filter.criteria) + { + const usedFields: {[name: string]: boolean} = {}; + const warnedFields: {[name: string]: boolean} = {}; + for (let i = 0; i < filter.criteria.length; i++) + { + const criteriaName = filter.criteria[i].fieldName; + if(!criteriaName) + { + continue; + } + + if(usedFields[criteriaName]) + { + if(!warnedFields[criteriaName]) + { + const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, criteriaName); + let fieldLabel = field.label; + if(tableForField.name != tableMetaData.name) + { + let fieldLabel = `${tableForField.label}: ${field.label}`; + } + reasonsWhyItCannot.push(`Filter contains more than 1 condition for the field: ${fieldLabel}`); + warnedFields[criteriaName] = true; + } + } + usedFields[criteriaName] = true; + } + } + + if(reasonsWhyItCannot.length == 0) + { + return ({canFilterWorkAsBasic: true}); + } + else + { + return ({canFilterWorkAsBasic: false, reasonsWhyItCannot: reasonsWhyItCannot}); + } + } + + /******************************************************************************* + ** get the values associated with a criteria as a string, e.g., for showing + ** in a tooltip. + *******************************************************************************/ + public static getValuesString(fieldMetaData: QFieldMetaData, criteria: QFilterCriteria, maxValuesToShow: number = 3): string + { + let valuesString = ""; + if (criteria.values && criteria.values.length && fieldMetaData.type !== QFieldType.BOOLEAN) + { + let labels = [] as string[]; + + let maxLoops = criteria.values.length; + if (maxLoops > (maxValuesToShow + 2)) + { + maxLoops = maxValuesToShow; + } + + for (let i = 0; i < maxLoops; i++) + { + if (criteria.values[i] && criteria.values[i].label) + { + labels.push(criteria.values[i].label); + } + else + { + labels.push(criteria.values[i]); + } + } + + if (maxLoops < criteria.values.length) + { + labels.push(" and " + (criteria.values.length - maxLoops) + " other values."); + } + + valuesString = (labels.join(", ")); + } + return valuesString; + } + } export default FilterUtils; diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 909ec04..e786336 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -462,6 +462,19 @@ class ValueUtils return (String(param).replaceAll(/"/g, "\"\"")); } + + /******************************************************************************* + ** + *******************************************************************************/ + public static safeToLocaleString(n: Number): string + { + if (n != null && n != undefined) + { + return (n.toLocaleString()); + } + return (""); + } + } ////////////////////////////////////////////////////////////////////////////////////////////////