diff --git a/src/qqq/components/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index 4af1237..23a65cc 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 @@ -127,24 +127,6 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP ); } -export function QSavedViewsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element -{ - return ( - - visibility} - > - Saved Views  - keyboard_arrow_down - - - ); -} - interface QCancelButtonProps { onClickHandler: any; diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx index 4ab6685..81237ea 100644 --- a/src/qqq/components/misc/SavedViews.tsx +++ b/src/qqq/components/misc/SavedViews.tsx @@ -25,10 +25,7 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; -import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; -import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {FiberManualRecord} from "@mui/icons-material"; -import {Alert} from "@mui/material"; +import {Alert, Button, Link} from "@mui/material"; import Box from "@mui/material/Box"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; @@ -42,15 +39,15 @@ import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import {TooltipProps} from "@mui/material/Tooltip/Tooltip"; -import Typography from "@mui/material/Typography"; import FormData from "form-data"; -import React, {useEffect, useRef, useState} from "react"; +import React, {useContext, useEffect, useRef, useState} from "react"; import {useLocation, useNavigate} from "react-router-dom"; -import {QCancelButton, QDeleteButton, QSaveButton, QSavedViewsMenuButton} from "qqq/components/buttons/DefaultButtons"; -import QQueryColumns from "qqq/models/query/QQueryColumns"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; +import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import RecordQueryView from "qqq/models/query/RecordQueryView"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; -import TableUtils from "qqq/utils/qqq/TableUtils"; +import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; interface Props { @@ -58,13 +55,14 @@ interface Props metaData: QInstance; tableMetaData: QTableMetaData; currentSavedView: QRecord; + tableDefaultView: RecordQueryView; view?: RecordQueryView; viewAsJson?: string; viewOnChangeCallback?: (selectedSavedViewId: number) => void; loadingSavedView: boolean } -function SavedViews({qController, metaData, tableMetaData, currentSavedView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element +function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element { const navigate = useNavigate(); @@ -91,6 +89,8 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie const CLEAR_OPTION = "New View"; const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION]; + const {accentColor, accentColorLight} = useContext(QContext); + const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget); const closeSavedViewsMenu = () => setSavedViewsMenu(null); @@ -107,385 +107,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie }, [location, tableMetaData]) - /******************************************************************************* - ** - *******************************************************************************/ - const fieldNameToLabel = (fieldName: string): string => - { - try - { - const [fieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); - if(fieldTable.name != tableMetaData.name) - { - return (tableMetaData.label + ": " + fieldMetaData.label); - } - - return (fieldMetaData.label); - } - catch(e) - { - return (fieldName); - } - } - - - /******************************************************************************* - ** - *******************************************************************************/ - const diffFilters = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => - { - try - { - //////////////////////////////////////////////////////////////////////////////// - // inner helper function for reporting on the number of criteria for a field. // - // e.g., will tell us "added criteria X" or "removed 2 criteria on Y" // - //////////////////////////////////////////////////////////////////////////////// - const diffCriteriaFunction = (base: QQueryFilter, compare: QQueryFilter, messagePrefix: string, isCheckForChanged = false) => - { - const baseCriteriaMap: { [name: string]: QFilterCriteria[] } = {}; - base?.criteria?.forEach((criteria) => - { - if(!baseCriteriaMap[criteria.fieldName]) - { - baseCriteriaMap[criteria.fieldName] = [] - } - baseCriteriaMap[criteria.fieldName].push(criteria) - }); - - const compareCriteriaMap: { [name: string]: QFilterCriteria[] } = {}; - compare?.criteria?.forEach((criteria) => - { - if(!compareCriteriaMap[criteria.fieldName]) - { - compareCriteriaMap[criteria.fieldName] = [] - } - compareCriteriaMap[criteria.fieldName].push(criteria) - }); - - for (let fieldName of Object.keys(compareCriteriaMap)) - { - const noBaseCriteria = baseCriteriaMap[fieldName]?.length ?? 0; - const noCompareCriteria = compareCriteriaMap[fieldName]?.length ?? 0; - - if(isCheckForChanged) - { - ///////////////////////////////////////////////////////////////////////////////////////////// - // first - if we're checking for changes to specific criteria (e.g., change id=5 to id<>5, // - // or change id=5 to id=6, or change id=5 to id<>7) // - // our "sweet spot" is if there's a single criteria on each side of the check // - ///////////////////////////////////////////////////////////////////////////////////////////// - if(noBaseCriteria == 1 && noCompareCriteria == 1) - { - const baseCriteria = baseCriteriaMap[fieldName][0] - const compareCriteria = compareCriteriaMap[fieldName][0] - const baseValuesJSON = JSON.stringify(baseCriteria.values ?? []) - const compareValuesJSON = JSON.stringify(compareCriteria.values ?? []) - if(baseCriteria.operator != compareCriteria.operator || baseValuesJSON != compareValuesJSON) - { - viewDiffs.push(`Changed a filter from ${FilterUtils.criteriaToHumanString(tableMetaData, baseCriteria)} to ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteria)}`) - } - } - else if(noBaseCriteria == noCompareCriteria) - { - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else - if the number of criteria on this field differs, that'll get caught in a non-isCheckForChanged call, so // - // todo, i guess - this is kinda weak - but if there's the same number of criteria on a field, then just ... do a shitty JSON compare between them... // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - const baseJSON = JSON.stringify(baseCriteriaMap[fieldName]) - const compareJSON = JSON.stringify(compareCriteriaMap[fieldName]) - if(baseJSON != compareJSON) - { - viewDiffs.push(`${messagePrefix} 1 or more filters on ${fieldNameToLabel(fieldName)}`); - } - } - } - else - { - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else - we're not checking for changes to individual criteria - rather - we're just checking if criteria were added or removed. // - // we'll do that by starting to see if the nubmer of criteria is different. // - // and, only do it in only 1 direction, assuming we'll get called twice, with the base & compare sides flipped // - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(noBaseCriteria < noCompareCriteria) - { - if (noBaseCriteria == 0 && noCompareCriteria == 1) - { - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // if the difference is 0 to 1 (1 to 0 when called in reverse), then we can report the full criteria that was added/removed // - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - viewDiffs.push(`${messagePrefix} filter: ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteriaMap[fieldName][0])}`) - } - else - { - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else, say 0 to 2, or 2 to 1 - just report on how many were changed... // - // todo this isn't great, as you might have had, say, (A,B), and now you have (C) - but all we'll say is "removed 1"... // - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - const noDiffs = noCompareCriteria - noBaseCriteria; - viewDiffs.push(`${messagePrefix} ${noDiffs} filters on ${fieldNameToLabel(fieldName)}`) - } - } - } - } - }; - - diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Added"); - diffCriteriaFunction(activeView.queryFilter, savedView.queryFilter, "Removed"); - diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Changed", true); - - ////////////////////// - // boolean operator // - ////////////////////// - if (savedView.queryFilter.booleanOperator != activeView.queryFilter.booleanOperator) - { - viewDiffs.push("Changed filter from 'And' to 'Or'") - } - - /////////////// - // order-bys // - /////////////// - const savedOrderBys = savedView.queryFilter.orderBys; - const activeOrderBys = activeView.queryFilter.orderBys; - if (savedOrderBys.length != activeOrderBys.length) - { - viewDiffs.push("Changed sort") - } - else if (savedOrderBys.length > 0) - { - const toWord = ((b: boolean) => b ? "ascending" : "descending"); - if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName && savedOrderBys[0].isAscending != activeOrderBys[0].isAscending) - { - viewDiffs.push(`Changed sort from ${fieldNameToLabel(savedOrderBys[0].fieldName)} ${toWord(savedOrderBys[0].isAscending)} to ${fieldNameToLabel(activeOrderBys[0].fieldName)} ${toWord(activeOrderBys[0].isAscending)}`) - } - else if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName) - { - viewDiffs.push(`Changed sort field from ${fieldNameToLabel(savedOrderBys[0].fieldName)} to ${fieldNameToLabel(activeOrderBys[0].fieldName)}`) - } - else if (savedOrderBys[0].isAscending != activeOrderBys[0].isAscending) - { - viewDiffs.push(`Changed sort direction from ${toWord(savedOrderBys[0].isAscending)} to ${toWord(activeOrderBys[0].isAscending)}`) - } - } - } - catch(e) - { - console.log(`Error looking for differences in filters ${e}`); - } - } - - - /******************************************************************************* - ** - *******************************************************************************/ - const diffColumns = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => - { - try - { - if(!savedView.queryColumns || !savedView.queryColumns.columns || savedView.queryColumns.columns.length == 0) - { - viewDiffs.push("This view did not previously have columns saved with it, so the next time you save it they will be initialized."); - return; - } - - //////////////////////////////////////////////////////////// - // nested function to help diff visible status of columns // - //////////////////////////////////////////////////////////// - const diffVisibilityFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => - { - const baseColumnsMap: { [name: string]: boolean } = {}; - base.columns.forEach((column) => - { - if (column.isVisible) - { - baseColumnsMap[column.name] = true; - } - }); - - const diffFields: string[] = []; - for (let i = 0; i < compare.columns.length; i++) - { - const column = compare.columns[i]; - if(column.isVisible) - { - if (!baseColumnsMap[column.name]) - { - diffFields.push(fieldNameToLabel(column.name)); - } - } - } - - if (diffFields.length > 0) - { - if (diffFields.length > 5) - { - viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); - } - else - { - viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); - } - } - }; - - /////////////////////////////////////////////////////////// - // nested function to help diff pinned status of columns // - /////////////////////////////////////////////////////////// - const diffPinsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => - { - const baseColumnsMap: { [name: string]: string } = {}; - base.columns.forEach((column) => baseColumnsMap[column.name] = column.pinned); - - const diffFields: string[] = []; - for (let i = 0; i < compare.columns.length; i++) - { - const column = compare.columns[i]; - if (baseColumnsMap[column.name] != column.pinned) - { - diffFields.push(fieldNameToLabel(column.name)); - } - } - - if (diffFields.length > 0) - { - if (diffFields.length > 5) - { - viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); - } - else - { - viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); - } - } - }; - - /////////////////////////////////////////////////// - // nested function to help diff width of columns // - /////////////////////////////////////////////////// - const diffWidthsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => - { - const baseColumnsMap: { [name: string]: number } = {}; - base.columns.forEach((column) => baseColumnsMap[column.name] = column.width); - - const diffFields: string[] = []; - for (let i = 0; i < compare.columns.length; i++) - { - const column = compare.columns[i]; - if (baseColumnsMap[column.name] != column.width) - { - diffFields.push(fieldNameToLabel(column.name)); - } - } - - if (diffFields.length > 0) - { - if (diffFields.length > 5) - { - viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); - } - else - { - viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); - } - } - }; - - diffVisibilityFunction(savedView.queryColumns, activeView.queryColumns, "Turned on "); - diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off "); - diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for "); - - if(savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(",")) - { - viewDiffs.push("Changed the order columns."); - } - - diffWidthsFunction(savedView.queryColumns, activeView.queryColumns, "Changed width for "); - } - catch (e) - { - console.log(`Error looking for differences in columns: ${e}`); - } - } - - /******************************************************************************* - ** - *******************************************************************************/ - const diffQuickFilterFieldNames = (savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => - { - try - { - const diffFunction = (base: string[], compare: string[], messagePrefix: string) => - { - const baseFieldNameMap: { [name: string]: boolean } = {}; - base.forEach((name) => baseFieldNameMap[name] = true); - const diffFields: string[] = []; - for (let i = 0; i < compare.length; i++) - { - const name = compare[i]; - if (!baseFieldNameMap[name]) - { - diffFields.push(fieldNameToLabel(name)); - } - } - - if (diffFields.length > 0) - { - viewDiffs.push(`${messagePrefix} basic filter${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); - } - } - - diffFunction(savedView.quickFilterFieldNames, activeView.quickFilterFieldNames, "Turned on"); - diffFunction(activeView.quickFilterFieldNames, savedView.quickFilterFieldNames, "Turned off"); - } - catch (e) - { - console.log(`Error looking for differences in quick filter field names: ${e}`); - } - } - - + const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView; + const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view); let viewIsModified = false; - let viewDiffs:string[] = []; - - if(currentSavedView != null) + if(viewDiffs.length > 0) { - const savedView = JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView; - const activeView = view; - - diffFilters(savedView, activeView, viewDiffs); - diffColumns(savedView, activeView, viewDiffs); - diffQuickFilterFieldNames(savedView, activeView, viewDiffs); - - if(savedView.mode != activeView.mode) - { - if(savedView.mode) - { - viewDiffs.push(`Mode changed from ${savedView.mode} to ${activeView.mode}`) - } - else - { - viewDiffs.push(`Mode set to ${activeView.mode}`) - } - } - - if(savedView.rowsPerPage != activeView.rowsPerPage) - { - if(savedView.rowsPerPage) - { - viewDiffs.push(`Rows per page changed from ${savedView.rowsPerPage} to ${activeView.rowsPerPage}`) - } - else - { - viewDiffs.push(`Rows per page set to ${activeView.rowsPerPage}`) - } - } - - if(viewDiffs.length > 0) - { - viewIsModified = true; - } + viewIsModified = true; } - /******************************************************************************* ** make request to load all saved filters from backend *******************************************************************************/ @@ -534,8 +163,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie switch(optionName) { case SAVE_OPTION: + if(currentSavedView == null) + { + setSavedViewNameInputValue(""); + } break; case DUPLICATE_OPTION: + setSavedViewNameInputValue(""); setIsSaveFilterAs(true); break; case CLEAR_OPTION: @@ -760,13 +394,13 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie keepMounted PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minHeight: "200px"}}} > - Actions + View Actions { hasStorePermission && Save your current filters, columns and settings, for quick re-use at a later time.

You will be prompted to enter a name if you choose this option.}> handleDropdownOptionClick(SAVE_OPTION)}> save - Save... + {currentSavedView ? "Save..." : "Save As..."}
} @@ -815,48 +449,150 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie ) ): ( - - No views have been saved for this table. + + You do not have any saved views for this table. ) } ); + let buttonText = "Views"; + let buttonBackground = "none"; + let buttonBorder = colors.grayLines.main; + let buttonColor = colors.gray.main; + + if(loadingSavedView) + { + buttonText = "Loading..."; + } + else if(currentSavedView) + { + buttonText = currentSavedView.values.get("label") + } + + if(currentSavedView) + { + if (viewIsModified) + { + buttonBackground = accentColorLight; + buttonBorder = buttonBackground; + buttonColor = accentColor; + } + else + { + buttonBackground = accentColor; + buttonBorder = buttonBackground; + buttonColor = "#FFFFFF"; + } + } + + const buttonStyles = { + border: `1px solid ${buttonBorder}`, + backgroundColor: buttonBackground, + color: buttonColor, + "&:focus:not(:hover)": { + color: buttonColor, + backgroundColor: buttonBackground, + }, + "&:hover": { + color: buttonColor, + backgroundColor: buttonBackground, + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + function isSaveButtonDisabled(): boolean + { + if(isSubmitting) + { + return (true); + } + + const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "") + + if(isSaveFilterAs || isRenameFilter || currentSavedView == null) + { + if(!haveInputText) + { + return (true); + } + } + + return (false); + } + + const linkButtonStyle = { + minWidth: "unset", + textTransform: "none", + fontSize: "0.875rem", + fontWeight: "500", + padding: "0.5rem" + }; + return ( hasQueryPermission && tableMetaData ? ( - - - {renderSavedViewsMenu} - + <> + + + {renderSavedViewsMenu} + + { - savedViewsHaveLoaded && currentSavedView && ( - Current View:  - + !currentSavedView && viewIsModified && <> + + Unsaved Changes +
    { - loadingSavedView - ? "..." - : - <> - {currentSavedView.values.get("label")} - { - viewIsModified && ( - The current view has been modified: -
      - { - viewDiffs.map((s: string, i: number) =>
    • {s}
    • ) - } -
    Click "Save..." to save the changes.}> - -
    - ) - } - + viewDiffs.map((s: string, i: number) =>
  • {s}
  • ) } - - - ) +
+ }> + +
+ + {/* vertical rule */} + + + + + } + { + currentSavedView && viewIsModified && <> + + Unsaved Changes +
    + { + viewDiffs.map((s: string, i: number) =>
  • {s}
  • ) + } +
}> + {viewDiffs.length} Unsaved Change{viewDiffs.length == 1 ? "" : "s"} +
+ + + + {/* vertical rule */} + + + + }
@@ -917,7 +653,6 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie autoFocus name="custom-delimiter-value" placeholder="View Name" - label="View Name" inputProps={{width: "100%", maxLength: 100}} value={savedViewNameInputValue} sx={{width: "100%"}} @@ -943,12 +678,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, vie isDeleteFilter ? : - + } } -
+ ) : null ); } diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx index 18617dd..2a6e563 100644 --- a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -27,8 +27,9 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT 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 {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {Badge, ToggleButton, ToggleButtonGroup, Typography} from "@mui/material"; +import {Badge, ToggleButton, ToggleButtonGroup} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Dialog from "@mui/material/Dialog"; @@ -37,15 +38,17 @@ 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 {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; -import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react"; +import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; -import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; +import FieldListMenu from "qqq/components/query/FieldListMenu"; import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter"; +import XIcon from "qqq/components/query/XIcon"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -54,6 +57,9 @@ interface BasicAndAdvancedQueryControlsProps metaData: QInstance; tableMetaData: QTableMetaData; + savedViewsComponent: JSX.Element; + columnMenuComponent: JSX.Element; + quickFilterFieldNames: string[]; setQuickFilterFieldNames: (names: string[]) => void; @@ -83,7 +89,7 @@ let debounceTimeout: string | number | NodeJS.Timeout; *******************************************************************************/ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) => { - const {metaData, tableMetaData, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props + const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props ///////////////////// // state variables // @@ -95,6 +101,8 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false); const [, forceUpdate] = useReducer((x) => x + 1, 0); + const {accentColor} = useContext(QContext); + ////////////////////////////////////////////////////////////////////////////////// // make some functions available to our parent - so it can tell us to do things // ////////////////////////////////////////////////////////////////////////////////// @@ -279,6 +287,11 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo const fieldName = newValue ? newValue.fieldName : null; if (fieldName) { + if(defaultQuickFilterFieldNameMap[fieldName]) + { + return; + } + if (quickFilterFieldNames.indexOf(fieldName) == -1) { ///////////////////////////////// @@ -309,7 +322,22 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo /******************************************************************************* - ** event handler for the Filter Buidler button - e.g., opens the parent's grid's + ** + *******************************************************************************/ + const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void => + { + let fullFieldName = field.name; + if(table && table.name != tableMetaData.name) + { + fullFieldName = `${table.name}.${field.name}`; + } + + addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu"); + } + + + /******************************************************************************* + ** event handler for the Filter Builder button - e.g., opens the parent's grid's ** filter panel *******************************************************************************/ const openFilterBuilder = (e: React.MouseEvent | React.MouseEvent) => @@ -326,11 +354,21 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo if (isYesButton || event.key == "Enter") { setShowClearFiltersWarning(false); - setQueryFilter(new QQueryFilter()); + setQueryFilter(new QQueryFilter([], queryFilter.orderBys)); } }; + /******************************************************************************* + ** + *******************************************************************************/ + const removeCriteriaByIndex = (index: number) => + { + queryFilter.criteria.splice(index, 1); + setQueryFilter(queryFilter); + } + + /******************************************************************************* ** format the current query as a string for showing on-screen as a preview. *******************************************************************************/ @@ -344,20 +382,20 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo let counter = 0; return ( - + {queryFilter.criteria.map((criteria, i) => { const {criteriaIsValid} = validateCriteria(criteria, null); if(criteriaIsValid) { - const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName); counter++; return ( - - {counter > 1 ? {queryFilter.booleanOperator}  : } + + {counter > 1 ? {queryFilter.booleanOperator}  : } {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} - + removeCriteriaByIndex(i)} /> + ); } else @@ -365,7 +403,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo return (); } })} - + ); }; @@ -427,12 +465,15 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo return; } - for (let i = 0; i < queryFilter?.criteria?.length; i++) + if(mode == "basic") { - const criteria = queryFilter.criteria[i]; - if (criteria && criteria.fieldName) + for (let i = 0; i < queryFilter?.criteria?.length; i++) { - addQuickFilterField(criteria, reason); + const criteria = queryFilter.criteria[i]; + if (criteria && criteria.fieldName) + { + addQuickFilterField(criteria, reason); + } } } } @@ -455,6 +496,70 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo return count; } + + /******************************************************************************* + ** Event handler for setting the sort from that menu + *******************************************************************************/ + const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void => + { + const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name; + queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)] + + setQueryFilter(queryFilter); + forceUpdate(); + } + + + /******************************************************************************* + ** event handler for a click on a field's up or down arrow in the sort menu + *******************************************************************************/ + const handleSetSortArrowClick = (field: QFieldMetaData, table: QTableMetaData, event: any): void => + { + event.stopPropagation(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // make sure this is an event handler for one of our icons (not something else in the dom here in our end-adornments) // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const isAscending = event.target.innerHTML == "arrow_upward"; + const isDescending = event.target.innerHTML == "arrow_downward"; + if(isAscending || isDescending) + { + handleSetSort(field, table, isAscending); + } + } + + + /******************************************************************************* + ** event handler for clicking the current sort up/down arrow, to toggle direction. + *******************************************************************************/ + function toggleSortDirection(event: React.MouseEvent): void + { + event.stopPropagation(); + try + { + queryFilter.orderBys[0].isAscending = !queryFilter.orderBys[0].isAscending; + setQueryFilter(queryFilter); + forceUpdate(); + } + catch(e) + { + console.log(`Error toggling sort: ${e}`) + } + } + + ///////////////////////////////// + // set up the sort menu button // + ///////////////////////////////// + let sortButtonContents = <>Sort... + if(queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0) + { + const orderBy = queryFilter.orderBys[0]; + const orderByFieldName = orderBy.fieldName; + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName); + const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`; + sortButtonContents = <>Sort: {fieldLabel} {orderBy.isAscending ? "arrow_upward" : "arrow_downward"} + } + //////////////////////////////////////////////////////////////////////////////////////////////////////////// // this is being used as a version of like forcing that we get re-rendered if the query filter changes... // //////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -481,140 +586,172 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo } + const borderGray = colors.grayLines.main; + + const sortMenuComponent = ( + arrow_upwardarrow_downward
} + handleAdornmentClick={handleSetSortArrowClick} + />); + return ( - - + + + {/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */} + + + {savedViewsComponent} + {columnMenuComponent} + + + + modeToggleClicked(newValue)} + size="small" + sx={{pl: 0.5, width: "10rem"}} + > + Basic + Advanced + + + + + + {/* Second row: Basic or advanced mode - with sort-by control on the right (of each) */} + { + /////////////////////////////////////////////////////////////////////////////////// + // basic mode - wrapping-list of fields & add-field button, then sort-by control // + /////////////////////////////////////////////////////////////////////////////////// mode == "basic" && - - <> - { - tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) => + + + <> { - const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); - let defaultOperator = getDefaultOperatorForField(field); + tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) => + { + const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); - return (); - }) - } - - { - tableMetaData && quickFilterFieldNames?.map((fieldName) => + return (); + }) + } + {/* vertical rule */} + { - const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); - let defaultOperator = getDefaultOperatorForField(field); + tableMetaData && quickFilterFieldNames?.map((fieldName) => + { + const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + let defaultOperator = getDefaultOperatorForField(field); - return (defaultQuickFilterFieldNameMap[fieldName] ? null : ); + }) + } + { + tableMetaData && ); - }) - } - { - tableMetaData && - <> - - - - - - addQuickFilterField(newValue, reason)} - autoFocus={true} - forceOpen={Boolean(addQuickFilterMenu)} - hiddenFieldNames={[...(defaultQuickFilterFieldNames??[]), ...(quickFilterFieldNames??[])]} - /> - - - - } - + fieldNamesToHide={[...(defaultQuickFilterFieldNames ?? []), ...(quickFilterFieldNames ?? [])]} + placeholder="Search Fields" + buttonProps={{sx: quickFilterButtonStyles, startIcon: (add)}} + buttonChildren={"Add Filter"} + isModeSelectOne={true} + handleSelectedField={handleFieldListMenuSelection} + /> + } + + + + {sortMenuComponent} + } { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// 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)} /> - - + + { + hasValidFilters && setShowClearFiltersWarning(true)} /> + } - ) - } -
- - Current Filter: + + setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}> + Confirm + + Are you sure you want to remove all conditions from the current filter? + + + setShowClearFiltersWarning(false)} /> + handleClearFiltersAction(null, true)} /> + + + + + {sortMenuComponent} + +
+ { - + {queryToAdvancedString()} } - - } - - - { - metaData && tableMetaData && - - - modeToggleClicked(newValue)} - size="small" - sx={{pl: 0.5, width: "10rem"}} - > - Basic - Advanced - - } @@ -668,4 +805,4 @@ export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[] return (defaultQuickFilterFieldNames); } -export default BasicAndAdvancedQueryControls; \ No newline at end of file +export default BasicAndAdvancedQueryControls; diff --git a/src/qqq/components/query/FieldListMenu.tsx b/src/qqq/components/query/FieldListMenu.tsx new file mode 100644 index 0000000..b277aa2 --- /dev/null +++ b/src/qqq/components/query/FieldListMenu.tsx @@ -0,0 +1,726 @@ +/* + * 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import List from "@mui/material/List/List"; +import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem"; +import Menu from "@mui/material/Menu"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import React, {useState} from "react"; + +interface FieldListMenuProps +{ + idPrefix: string; + heading?: string; + placeholder?: string; + tableMetaData: QTableMetaData; + showTableHeaderEvenIfNoExposedJoins: boolean; + fieldNamesToHide?: string[]; + buttonProps: any; + buttonChildren: JSX.Element | string; + + isModeSelectOne?: boolean; + handleSelectedField?: (field: QFieldMetaData, table: QTableMetaData) => void; + + isModeToggle?: boolean; + toggleStates?: {[fieldName: string]: boolean}; + handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void; + + fieldEndAdornment?: JSX.Element + handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent) => void; +} + +FieldListMenu.defaultProps = { + showTableHeaderEvenIfNoExposedJoins: false, + isModeSelectOne: false, + isModeToggle: false, +}; + +interface TableWithFields +{ + table?: QTableMetaData; + fields: QFieldMetaData[]; +} + +/******************************************************************************* + ** Component to render a list of fields from a table (and its join tables) + ** which can be interacted with... + *******************************************************************************/ +export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick}: FieldListMenuProps): JSX.Element +{ + const [menuAnchorElement, setMenuAnchorElement] = useState(null); + const [searchText, setSearchText] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(null as number); + + const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]); + const [collapsedTables, setCollapsedTables] = useState({} as {[tableName: string]: boolean}); + + const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0}); + const [timeOfLastArrow, setTimeOfLastArrow] = useState(0) + + ////////////////// + // check usages // + ////////////////// + if(isModeSelectOne) + { + if(!handleSelectedField) + { + throw("In FieldListMenu, if isModeSelectOne=true, then a callback for handleSelectedField must be provided."); + } + } + + if(isModeToggle) + { + if(!toggleStates) + { + throw("In FieldListMenu, if isModeToggle=true, then a model for toggleStates must be provided."); + } + if(!handleToggleField) + { + throw("In FieldListMenu, if isModeToggle=true, then a callback for handleToggleField must be provided."); + } + } + + ///////////////////// + // init some stuff // + ///////////////////// + if (fieldsByTable.length == 0) + { + collapsedTables[tableMetaData.name] = false; + + if (tableMetaData.exposedJoins?.length > 0) + { + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too // + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)}); + + for (let i = 0; i < tableMetaData.exposedJoins?.length; i++) + { + const joinTable = tableMetaData.exposedJoins[i].joinTable; + fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)}); + + collapsedTables[joinTable.name] = false; + } + } + else + { + /////////////////////////////////////////////////////////// + // no exposed joins - just the table (w/o its meta-data) // + /////////////////////////////////////////////////////////// + fieldsByTable.push({fields: getTableFieldsAsAlphabeticalArray(tableMetaData)}); + } + + setFieldsByTable(fieldsByTable); + setCollapsedTables(collapsedTables); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getTableFieldsAsAlphabeticalArray(table: QTableMetaData): QFieldMetaData[] + { + const fields: QFieldMetaData[] = []; + table.fields.forEach(field => + { + let fullFieldName = field.name; + if(table.name != tableMetaData.name) + { + fullFieldName = `${table.name}.${field.name}`; + } + + if(fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1) + { + return; + } + fields.push(field) + }); + fields.sort((a, b) => a.label.localeCompare(b.label)); + return (fields); + } + + const fieldsByTableToShow: TableWithFields[] = []; + let maxFieldIndex = 0; + fieldsByTable.forEach((tableWithFields) => + { + let fieldsToShowForThisTable = tableWithFields.fields.filter(doesFieldMatchSearchText); + if (fieldsToShowForThisTable.length > 0) + { + fieldsByTableToShow.push({table: tableWithFields.table, fields: fieldsToShowForThisTable}); + maxFieldIndex += fieldsToShowForThisTable.length; + } + }); + + + /******************************************************************************* + ** + *******************************************************************************/ + function getShownFieldAndTableByIndex(targetIndex: number): {field: QFieldMetaData, table: QTableMetaData} + { + let index = -1; + for (let i = 0; i < fieldsByTableToShow.length; i++) + { + const tableWithField = fieldsByTableToShow[i]; + for (let j = 0; j < tableWithField.fields.length; j++) + { + index++; + + if(index == targetIndex) + { + return {field: tableWithField.fields[j], table: tableWithField.table} + } + } + } + + return (null); + } + + + /******************************************************************************* + ** event handler for keys presses + *******************************************************************************/ + function keyDown(event: any) + { + // console.log(`Event key: ${event.key}`); + setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus()); + + if(isModeSelectOne && event.key == "Enter" && focusedIndex != null) + { + setTimeout(() => + { + event.stopPropagation(); + closeMenu(); + + const {field, table} = getShownFieldAndTableByIndex(focusedIndex); + if (field) + { + handleSelectedField(field, table ?? tableMetaData); + } + }); + return; + } + + const keyOffsetMap: { [key: string]: number } = { + "End": 10000, + "Home": -10000, + "ArrowDown": 1, + "ArrowUp": -1, + "PageDown": 5, + "PageUp": -5, + }; + + const offset = keyOffsetMap[event.key]; + if (offset) + { + event.stopPropagation(); + setTimeOfLastArrow(new Date().getTime()); + + if (isModeSelectOne) + { + let startIndex = focusedIndex; + if (offset > 0) + { + ///////////////// + // a down move // + ///////////////// + if(startIndex == null) + { + startIndex = -1; + } + + let goalIndex = startIndex + offset; + if(goalIndex > maxFieldIndex - 1) + { + goalIndex = maxFieldIndex - 1; + } + + doSetFocusedIndex(goalIndex, true); + } + else + { + //////////////// + // an up move // + //////////////// + let goalIndex = startIndex + offset; + if(goalIndex < 0) + { + goalIndex = 0; + } + + doSetFocusedIndex(goalIndex, true); + } + } + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void + { + if (isModeSelectOne) + { + setFocusedIndex(i); + console.log(`Setting index to ${i}`); + + if (tryToScrollIntoView) + { + const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`); + element?.scrollIntoView({block: "center"}); + } + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function setFocusedField(field: QFieldMetaData, table: QTableMetaData, tryToScrollIntoView: boolean) + { + let index = -1; + for (let i = 0; i < fieldsByTableToShow.length; i++) + { + const tableWithField = fieldsByTableToShow[i]; + for (let j = 0; j < tableWithField.fields.length; j++) + { + const loopField = tableWithField.fields[j]; + index++; + + const tableMatches = (table == null || table.name == tableWithField.table.name); + if (tableMatches && field.name == loopField.name) + { + doSetFocusedIndex(index, tryToScrollIntoView); + return; + } + } + } + } + + + /******************************************************************************* + ** event handler for mouse-over the menu + *******************************************************************************/ + function handleMouseOver(event: React.MouseEvent | React.MouseEvent | React.MouseEvent, field: QFieldMetaData, table: QTableMetaData) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, // + // where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. // + // the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) // + // but the keyboard last-arrow time that we capture, that's what's actually being useful in here // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y) + { + // console.log("mouse didn't move, so, doesn't count"); + return; + } + + const now = new Date().getTime(); + // console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`); + if(now < timeOfLastArrow + 300) + { + // console.log("An arrow event happened less than 300 mills ago, so doesn't count."); + return; + } + + // console.log("yay, mouse over..."); + setFocusedField(field, table, false); + setLastMouseOverXY({x: event.clientX, y: event.clientY}); + } + + + /******************************************************************************* + ** event handler for text input changes + *******************************************************************************/ + function updateSearch(event: React.ChangeEvent) + { + setSearchText(event?.target?.value ?? ""); + doSetFocusedIndex(0, true); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function doesFieldMatchSearchText(field: QFieldMetaData): boolean + { + if (searchText == "") + { + return (true); + } + + const columnLabelMinusTable = field.label.replace(/.*: /, ""); + if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase())) + { + return (true); + } + + try + { + //////////////////////////////////////////////////////////// + // try to match word-boundary followed by the filter text // + // e.g., "name" would match "First Name" or "Last Name" // + //////////////////////////////////////////////////////////// + const re = new RegExp("\\b" + searchText.toLowerCase()); + if (columnLabelMinusTable.toLowerCase().match(re)) + { + return (true); + } + } + catch (e) + { + ////////////////////////////////////////////////////////////////////////////////// + // in case text is an invalid regex... well, at least do a starts-with match... // + ////////////////////////////////////////////////////////////////////////////////// + if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase())) + { + return (true); + } + } + + const tableLabel = field.label.replace(/:.*/, ""); + if (tableLabel) + { + try + { + //////////////////////////////////////////////////////////// + // try to match word-boundary followed by the filter text // + // e.g., "name" would match "First Name" or "Last Name" // + //////////////////////////////////////////////////////////// + const re = new RegExp("\\b" + searchText.toLowerCase()); + if (tableLabel.toLowerCase().match(re)) + { + return (true); + } + } + catch (e) + { + ////////////////////////////////////////////////////////////////////////////////// + // in case text is an invalid regex... well, at least do a starts-with match... // + ////////////////////////////////////////////////////////////////////////////////// + if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase())) + { + return (true); + } + } + } + + return (false); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function openMenu(event: any) + { + setFocusedIndex(null); + setMenuAnchorElement(event.currentTarget); + setTimeout(() => + { + document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus(); + doSetFocusedIndex(0, true); + }); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function closeMenu() + { + setMenuAnchorElement(null); + } + + + /******************************************************************************* + ** Event handler for toggling a field in toggle mode + *******************************************************************************/ + function handleFieldToggle(event: React.ChangeEvent, field: QFieldMetaData, table: QTableMetaData) + { + event.stopPropagation(); + handleToggleField(field, table, event.target.checked); + } + + + /******************************************************************************* + ** Event handler for toggling a table in toggle mode + *******************************************************************************/ + function handleTableToggle(event: React.ChangeEvent, table: QTableMetaData) + { + event.stopPropagation(); + + const fieldsList = [...table.fields.values()]; + for (let i = 0; i < fieldsList.length; i++) + { + const field = fieldsList[i]; + if(doesFieldMatchSearchText(field)) + { + handleToggleField(field, table, event.target.checked); + } + } + } + + + ///////////////////////////////////////////////////////// + // compute the table-level toggle state & count values // + ///////////////////////////////////////////////////////// + const tableToggleStates: {[tableName: string]: boolean} = {}; + const tableToggleCounts: {[tableName: string]: number} = {}; + + if(isModeToggle) + { + const {allOn, count} = getTableToggleState(tableMetaData, true); + tableToggleStates[tableMetaData.name] = allOn; + tableToggleCounts[tableMetaData.name] = count; + + for (let i = 0; i < tableMetaData.exposedJoins?.length; i++) + { + const join = tableMetaData.exposedJoins[i]; + const {allOn, count} = getTableToggleState(join.joinTable, false); + tableToggleStates[join.joinTable.name] = allOn; + tableToggleCounts[join.joinTable.name] = count; + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getTableToggleState(table: QTableMetaData, isMainTable: boolean): {allOn: boolean, count: number} + { + const fieldsList = [...table.fields.values()]; + let allOn = true; + let count = 0; + for (let i = 0; i < fieldsList.length; i++) + { + const field = fieldsList[i]; + const name = isMainTable ? field.name : `${table.name}.${field.name}`; + if(!toggleStates[name]) + { + allOn = false; + } + else + { + count++; + } + } + + return ({allOn: allOn, count: count}); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function toggleCollapsedTable(tableName: string) + { + collapsedTables[tableName] = !collapsedTables[tableName] + setCollapsedTables(Object.assign({}, collapsedTables)); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function doHandleAdornmentClick(field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent) + { + console.log("In doHandleAdornmentClick"); + closeMenu(); + handleAdornmentClick(field, table, event); + } + + + let index = -1; + const textFieldId = `field-list-dropdown-${idPrefix}-textField`; + let listItemPadding = isModeToggle ? "0.125rem": "0.5rem"; + + return ( + <> + + + + { + heading && + + {heading} + + } + + + { + searchText != "" && + { + updateSearch(null); + document.getElementById(textFieldId).focus(); + }}>close + } + + + + { + fieldsByTableToShow.map((tableWithFields) => + { + let headerContents = null; + const headerTable = tableWithFields.table || tableMetaData; + if(tableWithFields.table || showTableHeaderEvenIfNoExposedJoins) + { + headerContents = ({headerTable.label} Fields); + } + + if(isModeToggle) + { + headerContents = ( handleTableToggle(event, headerTable)} + />} + label={{headerTable.label} Fields ({tableToggleCounts[headerTable.name]})} />) + } + + if(isModeToggle) + { + headerContents = ( + <> + toggleCollapsedTable(headerTable.name)} + sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}} + disableRipple={true} + > + {collapsedTables[headerTable.name] ? "expand_less" : "expand_more"} + + {headerContents} + + ) + } + + let marginLeft = "unset"; + if(isModeToggle) + { + marginLeft = "-1rem"; + } + + return ( + + <> + {headerContents && {headerContents}} + { + tableWithFields.fields.map((field) => + { + index++; + const key = `${tableWithFields.table?.name}-${field.name}` + + if(collapsedTables[headerTable.name]) + { + return (); + } + + let style = {}; + if (index == focusedIndex) + { + style = {backgroundColor: "#EFEFEF"}; + } + + const onClick: ListItemProps = {}; + if (isModeSelectOne) + { + onClick.onClick = () => + { + closeMenu(); + handleSelectedField(field, tableWithFields.table ?? tableMetaData); + } + } + + let label: JSX.Element | string = field.label; + const fullFieldName = tableWithFields.table && tableWithFields.table.name != tableMetaData.name ? `${tableWithFields.table.name}.${field.name}` : field.name; + + if(fieldEndAdornment) + { + label = + {label} + doHandleAdornmentClick(field, tableWithFields.table, event)}> + {fieldEndAdornment} + + ; + } + + let contents = <>{label}; + let paddingLeft = "0.5rem"; + + if (isModeToggle) + { + contents = ( handleFieldToggle(event, field, tableWithFields.table)} + />} + label={label} />); + paddingLeft = "2.5rem"; + } + + return handleMouseOver(event, field, tableWithFields.table)} + {...onClick} + >{contents}; + }) + } + + + ); + }) + } + { + index == -1 && No fields found. + } + + + + + + ); +} diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index efd41bc..2e88ea2 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -203,7 +203,7 @@ FilterCriteriaRow.defaultProps = { }; -export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string} +export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string} { let criteriaIsValid = true; let criteriaStatusTooltip = "This condition is fully defined and is part of your filter."; diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index 7cb5b96..ae88728 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.tsx @@ -37,6 +37,7 @@ import QContext from "QContext"; 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 XIcon from "qqq/components/query/XIcon"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -62,8 +63,17 @@ QuickFilter.defaultProps = let seedId = new Date().getTime() % 173237; export const quickFilterButtonStyles = { - fontSize: "0.75rem", color: "#757575", textTransform: "none", borderRadius: "2rem", border: "1px solid #757575", - minWidth: "3.5rem", minHeight: "auto", padding: "0.375rem 0.625rem", whiteSpace: "nowrap" + fontSize: "0.75rem", + fontWeight: 600, + color: "#757575", + textTransform: "none", + borderRadius: "2rem", + border: "1px solid #757575", + minWidth: "3.5rem", + minHeight: "auto", + padding: "0.375rem 0.625rem", + whiteSpace: "nowrap", + marginBottom: "0.5rem" } /******************************************************************************* @@ -439,23 +449,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData } } - ///////////////////////////////////////////////////////////////////////////////////// - // only show the 'x' if it's to clear out a valid criteria on the field, // - // or if we were given a callback to remove the quick-filter field from the screen // - ///////////////////////////////////////////////////////////////////////////////////// - let xIcon = ; - if(criteriaIsValid || handleRemoveQuickFilterField) - { - xIcon = close - } - ////////////////////////////// // return the button & menu // ////////////////////////////// @@ -463,7 +456,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData return ( <> {button} - {xIcon} + { + ///////////////////////////////////////////////////////////////////////////////////// + // only show the 'x' if it's to clear out a valid criteria on the field, // + // or if we were given a callback to remove the quick-filter field from the screen // + ///////////////////////////////////////////////////////////////////////////////////// + (criteriaIsValid || handleRemoveQuickFilterField) && + } { isOpen && @@ -488,7 +487,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData operatorOption={operatorSelectedValue} criteria={criteria} field={fieldMetaData} - table={tableMetaData} // todo - joins? + table={tableForField} valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)} initiallyOpenMultiValuePvs={true} // todo - maybe not? /> diff --git a/src/qqq/components/query/XIcon.tsx b/src/qqq/components/query/XIcon.tsx new file mode 100644 index 0000000..9c66869 --- /dev/null +++ b/src/qqq/components/query/XIcon.tsx @@ -0,0 +1,92 @@ +/* + * 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 Icon from "@mui/material/Icon"; +import IconButton from "@mui/material/IconButton"; +import React, {useContext} from "react"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; + +interface XIconProps +{ + onClick: (e: React.MouseEvent) => void; + position: "forQuickFilter" | "forAdvancedQueryPreview" | "default"; + shade: "default" | "accent" | "accentLight" +} + +XIcon.defaultProps = { + position: "default", + shade: "default" +}; + +export default function XIcon({onClick, position, shade}: XIconProps): JSX.Element +{ + const {accentColor, accentColorLight} = useContext(QContext) + + ////////////////////////// + // for default position // + ////////////////////////// + let rest: any = { + top: "-0.75rem", + left: "-0.5rem", + } + + if(position == "forQuickFilter") + { + rest = { + left: "-1.125rem", + } + } + else if(position == "forAdvancedQueryPreview") + { + rest = { + top: "-0.375rem", + left: "-0.75rem", + } + } + + let color; + switch (shade) + { + case "default": + color = colors.gray.main; + break; + case "accent": + color = accentColor; + break; + case "accentLight": + color = accentColorLight; + break; + } + + return ( + close + ) +} diff --git a/src/qqq/models/query/QQueryColumns.ts b/src/qqq/models/query/QQueryColumns.ts index 8ba2a8d..bf27de6 100644 --- a/src/qqq/models/query/QQueryColumns.ts +++ b/src/qqq/models/query/QQueryColumns.ts @@ -114,6 +114,59 @@ export default class QQueryColumns return fields; } + + /******************************************************************************* + ** + *******************************************************************************/ + public getVisibleColumnCount(): number + { + let rs = 0; + for (let i = 0; i < this.columns.length; i++) + { + if(this.columns[i].name == "__check__") + { + continue; + } + + if(this.columns[i].isVisible) + { + rs++; + } + } + return (rs); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public getVisibilityToggleStates(): { [name: string]: boolean } + { + const rs: {[name: string]: boolean} = {}; + for (let i = 0; i < this.columns.length; i++) + { + rs[this.columns[i].name] = this.columns[i].isVisible; + } + return (rs); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + public setIsVisible(name: string, isVisible: boolean) + { + for (let i = 0; i < this.columns.length; i++) + { + if(this.columns[i].name == name) + { + this.columns[i].isVisible = isVisible; + break; + } + } + } + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index f1f0c39..d8d1ddf 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -33,7 +33,7 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; -import {Alert, Collapse, Typography} from "@mui/material"; +import {Alert, Collapse, Menu, Typography} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; @@ -44,21 +44,22 @@ import LinearProgress from "@mui/material/LinearProgress"; import MenuItem from "@mui/material/MenuItem"; import Modal from "@mui/material/Modal"; import Tooltip from "@mui/material/Tooltip"; -import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} from "@mui/x-data-grid-pro"; +import {ColumnHeaderFilterIconButtonProps, DataGridPro, GridCallbackDetails, GridColDef, GridColumnHeaderParams, GridColumnHeaderSortIconProps, GridColumnMenuContainer, GridColumnMenuProps, GridColumnOrderChangeParams, GridColumnPinningMenuItems, GridColumnResizeParams, GridColumnsMenuItem, GridColumnVisibilityModel, GridDensity, GridEventListener, GridFilterMenuItem, GridPinnedColumns, gridPreferencePanelStateSelector, GridPreferencePanelsValue, GridRowId, GridRowParams, GridRowsProp, GridSelectionModel, GridSortItem, GridSortModel, GridState, GridToolbarColumnsButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExportContainer, HideGridColMenuItem, MuiEvent, SortGridMenuItems, useGridApiContext, useGridApiEventHandler, useGridApiRef, useGridSelector} 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 colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QCreateNewButton} from "qqq/components/buttons/DefaultButtons"; import MenuButton from "qqq/components/buttons/MenuButton"; import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog"; import SavedViews from "qqq/components/misc/SavedViews"; import BasicAndAdvancedQueryControls from "qqq/components/query/BasicAndAdvancedQueryControls"; -import {CustomColumnsPanel} from "qqq/components/query/CustomColumnsPanel"; import {CustomFilterPanel} from "qqq/components/query/CustomFilterPanel"; import CustomPaginationComponent from "qqq/components/query/CustomPaginationComponent"; import ExportMenuItem from "qqq/components/query/ExportMenuItem"; +import FieldListMenu from "qqq/components/query/FieldListMenu"; import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import QueryScreenActionMenu from "qqq/components/query/QueryScreenActionMenu"; import SelectionSubsetDialog from "qqq/components/query/SelectionSubsetDialog"; @@ -74,6 +75,7 @@ import DataGridUtils from "qqq/utils/DataGridUtils"; import Client from "qqq/utils/qqq/Client"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; +import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; @@ -246,14 +248,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const [pageState, setPageState] = useState("initial" as PageState) - /////////////////////////////////////////////////// - // state used by the custom column-chooser panel // - /////////////////////////////////////////////////// - const initialColumnChooserOpenGroups = {} as { [name: string]: boolean }; - initialColumnChooserOpenGroups[tableName] = true; - const [columnChooserOpenGroups, setColumnChooserOpenGroups] = useState(initialColumnChooserOpenGroups); - const [columnChooserFilterText, setColumnChooserFilterText] = useState(""); - ///////////////////////////////// // meta-data and derived state // ///////////////////////////////// @@ -285,6 +279,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [currentSavedView, setCurrentSavedView] = useState(null as QRecord); const [viewIdInLocation, setViewIdInLocation] = useState(null as number); const [loadingSavedView, setLoadingSavedView] = useState(false); + const [exportMenuAnchorElement, setExportMenuAnchorElement] = useState(null); + const [tableDefaultView, setTableDefaultView] = useState(new RecordQueryView()); ///////////////////////////////////////////////////// // state related to avoiding accidental row clicks // @@ -342,7 +338,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ///////////////////////////// // page context references // ///////////////////////////// - const {setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext); + const {accentColor, accentColorLight, setPageHeader, dotMenuOpen, keyboardHelpOpen} = useContext(QContext); + + ////////////////////////////////////////////////////////////////// + // we use our own header - so clear out the context page header // + ////////////////////////////////////////////////////////////////// + setPageHeader(null); ////////////////////// // ole' faithful... // @@ -416,6 +417,97 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return (false); } + + /******************************************************************************* + ** + *******************************************************************************/ + const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) => + { + const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator); + for (let i = 0; i < sourceFilter?.criteria?.length; i++) + { + const criteria = sourceFilter.criteria[i]; + const {criteriaIsValid} = validateCriteria(criteria, null); + if (criteriaIsValid) + { + if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) + { + /////////////////////////////////////////////////////////////////////////////////////////// + // do this to avoid submitting an empty-string argument for blank/not-blank operators... // + /////////////////////////////////////////////////////////////////////////////////////////// + filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, [])); + } + else + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName) + filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field))); + } + } + } + filterForBackend.skip = pageNumber * rowsPerPage; + filterForBackend.limit = rowsPerPage; + + return filterForBackend; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function openExportMenu(event: any) + { + setExportMenuAnchorElement(event.currentTarget); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function closeExportMenu() + { + setExportMenuAnchorElement(null); + } + + + /////////////////////////////////////////// + // build the export menu, for the header // + /////////////////////////////////////////// + let exportMenu = <> + try + { + const exportMenuItemRestProps = + { + tableMetaData: tableMetaData, + totalRecords: totalRecords, + columnsModel: columnsModel, + columnVisibilityModel: columnVisibilityModel, + queryFilter: prepQueryFilterForBackend(queryFilter) + } + + exportMenu = (<> + save_alt + + + + + + ); + } + catch(e) + { + console.log("Error preparing export menu for page header: " + e); + } + /******************************************************************************* ** *******************************************************************************/ @@ -459,9 +551,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return(
- {label} + {label} {exportMenu} - emergency + emergency {tableVariant && getTableVariantHeader(tableVariant)}
); @@ -470,7 +562,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { return (
- {label} + {label} {exportMenu} {tableVariant && getTableVariantHeader(tableVariant)}
); } @@ -569,7 +661,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // in case page-state has already advanced to "ready" (e.g., and we're dealing with a user // // hitting back & forth between filters), then do a load of the new saved-view right here // ///////////////////////////////////////////////////////////////////////////////////////////// - if(pageState == "ready") + if (pageState == "ready") { handleSavedViewChange(currentSavedViewId); } @@ -593,6 +685,19 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }, [location]); + + /******************************************************************************* + ** set the current view in state & local-storage - but do NOT update any + ** child-state data. + *******************************************************************************/ + const doSetView = (view: RecordQueryView): void => + { + setView(view); + setViewAsJson(JSON.stringify(view)); + localStorage.setItem(viewLocalStorageKey, JSON.stringify(view)); + } + + /******************************************************************************* ** *******************************************************************************/ @@ -607,6 +712,40 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element forceUpdate(); }; + + /******************************************************************************* + ** function called by columns menu to turn a column on or off + *******************************************************************************/ + const handleChangeOneColumnVisibility = (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => + { + /////////////////////////////////////// + // set the field's value in the view // + /////////////////////////////////////// + let fieldName = field.name; + if(table && table.name != tableMetaData.name) + { + fieldName = `${table.name}.${field.name}`; + } + + view.queryColumns.setIsVisible(fieldName, newValue) + + ///////////////////// + // update the grid // + ///////////////////// + setColumnVisibilityModel(queryColumns.toColumnVisibilityModel()); + + ///////////////////////////////////////////////// + // update the view (e.g., write local storage) // + ///////////////////////////////////////////////// + doSetView(view) + + /////////////////// + // ole' faithful // + /////////////////// + forceUpdate(); + } + + /******************************************************************************* ** *******************************************************************************/ @@ -656,43 +795,32 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /******************************************************************************* - ** + ** return array of table names that need ... added to query *******************************************************************************/ - const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) => + const ensureOrderBysFromJoinTablesAreVisibleTables = (queryFilter: QQueryFilter, visibleJoinTablesParam?: Set): string[] => { - const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.booleanOperator); - for (let i = 0; i < sourceFilter?.criteria?.length; i++) + const rs: string[] = []; + const vjtToUse = visibleJoinTablesParam ?? visibleJoinTables; + + for (let i = 0; i < queryFilter?.orderBys?.length; i++) { - const criteria = sourceFilter.criteria[i]; - const {criteriaIsValid} = validateCriteria(criteria, null); - if (criteriaIsValid) + const fieldName = queryFilter.orderBys[i].fieldName; + if(fieldName.indexOf(".") > -1) { - if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) + const joinTableName = fieldName.replaceAll(/\..*/g, ""); + if(!vjtToUse.has(joinTableName)) { - /////////////////////////////////////////////////////////////////////////////////////////// - // do this to avoid submitting an empty-string argument for blank/not-blank operators... // - /////////////////////////////////////////////////////////////////////////////////////////// - filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, [])); - } - else - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName) - filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field))); + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + handleChangeOneColumnVisibility(field, fieldTable, true); + rs.push(fieldTable.name); } } } - filterForBackend.skip = pageNumber * rowsPerPage; - filterForBackend.limit = rowsPerPage; - // FilterUtils.convertFilterPossibleValuesToIds(filterForBackend); - // todo - expressions? - // todo - utc - return filterForBackend; + return (rs); } + /******************************************************************************* ** This is the method that actually executes a query to update the data in the table. *******************************************************************************/ @@ -729,6 +857,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (tableMetaData?.exposedJoins) { const visibleJoinTables = getVisibleJoinTables(); + const tablesToAdd = ensureOrderBysFromJoinTablesAreVisibleTables(queryFilter, visibleJoinTables); + + tablesToAdd?.forEach(t => visibleJoinTables.add(t)); + queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables); } @@ -1062,15 +1194,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element doSetQueryFilter(queryFilter); }; + /******************************************************************************* - ** set the current view in state & local-storage - but do NOT update any - ** child-state data. + ** *******************************************************************************/ - const doSetView = (view: RecordQueryView): void => + const handleColumnHeaderClick = (params: GridColumnHeaderParams, event: MuiEvent, details: GridCallbackDetails): void => { - setView(view); - setViewAsJson(JSON.stringify(view)); - localStorage.setItem(viewLocalStorageKey, JSON.stringify(view)); + event.defaultMuiPrevented = true; } @@ -1112,6 +1242,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { console.log(`Setting a new query filter: ${JSON.stringify(queryFilter)}`); + /////////////////////////////////////////////////// + // when we have a new filter, go back to page 0. // + /////////////////////////////////////////////////// + setPageNumber(0); + /////////////////////////////////////////////////// // in case there's no orderBys, set default here // /////////////////////////////////////////////////// @@ -1121,6 +1256,15 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element view.queryFilter = queryFilter; } + //////////////////////////////////////////////////////////////////////////////////////////////// + // in case the order-by is from a join table, and that table doesn't have any visible fields, // + // then activate the order-by field itself // + //////////////////////////////////////////////////////////////////////////////////////////////// + ensureOrderBysFromJoinTablesAreVisibleTables(queryFilter); + + ////////////////////////////// + // set the filter state var // + ////////////////////////////// setQueryFilter(queryFilter); /////////////////////////////////////////////////////// @@ -1423,6 +1567,22 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } } + + /******************************************************************************* + ** + *******************************************************************************/ + const buildTableDefaultView = (): RecordQueryView => + { + const newDefaultView = new RecordQueryView(); + newDefaultView.queryFilter = new QQueryFilter([], [new QFilterOrderBy(tableMetaData.primaryKeyField, false)]); + newDefaultView.queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); + newDefaultView.viewIdentity = "empty"; + newDefaultView.rowsPerPage = defaultRowsPerPage; + newDefaultView.quickFilterFieldNames = []; + newDefaultView.mode = defaultMode; + return newDefaultView; + } + /******************************************************************************* ** event handler for SavedViews component, to handle user selecting a view ** (or clearing / selecting new) @@ -1453,18 +1613,10 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setCurrentSavedView(null); localStorage.removeItem(currentSavedViewLocalStorageKey); - ///////////////////////////////////////////////////// - // go back to a default query filter for the table // - ///////////////////////////////////////////////////// - doSetQueryFilter(new QQueryFilter()); - - const queryColumns = QQueryColumns.buildDefaultForTable(tableMetaData); - doSetQueryColumns(queryColumns) - - ///////////////////////////////////////////////////// - // also reset the (user-added) quick-filter fields // - ///////////////////////////////////////////////////// - doSetQuickFilterFieldNames([]); + /////////////////////////////////////////////// + // activate a new default view for the table // + /////////////////////////////////////////////// + activateView(buildTableDefaultView()) } } @@ -1848,36 +2000,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element } }; - ////////////////////////////////////////////////////////////////// - // props that get passed into all of the ExportMenuItem's below // - ////////////////////////////////////////////////////////////////// - const exportMenuItemRestProps = - { - tableMetaData: tableMetaData, - totalRecords: totalRecords, - columnsModel: columnsModel, - columnVisibilityModel: columnVisibilityModel, - queryFilter: prepQueryFilterForBackend(queryFilter) - } - return (
- +
- {/* @ts-ignore */} -
{/* @ts-ignore */} - {/* @ts-ignore */} - - - - -
@@ -1990,15 +2120,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ); } - /******************************************************************************* - ** maybe something to do with how page header is in a context, but, it didn't - ** work to check pageLoadingState.isLoadingSlow inside an element that we put - ** in the page header, so, this works instead. - *******************************************************************************/ - const setPageHeaderToLoadingSlow = (): void => - { - setPageHeader("Loading...") - } ///////////////////////////////////////////////////////////////////////////////// // use this to make changes to the queryFilter more likely to re-run the query // @@ -2024,12 +2145,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setPageState("loadingMetaData"); pageLoadingState.setLoading(); - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // reset the page header to blank, and tell the pageLoadingState object that if it becomes slow, to show 'Loading' // - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - setPageHeader(""); - pageLoadingState.setUponSlowCallback(setPageHeaderToLoadingSlow); - (async () => { const metaData = await qController.loadMetaData(); @@ -2056,6 +2171,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element (async () => { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // now that we know the table - build a default view - initially, only used by SavedViews component, for showing if there's anything to be saved. // + // but also used when user selects new-view from the view menu // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const newDefaultView = buildTableDefaultView(); + setTableDefaultView(newDefaultView); + ////////////////////////////////////////////////////////////////////////////////////////////// // once we've loaded meta data, let's check the location to see if we should open a process // ////////////////////////////////////////////////////////////////////////////////////////////// @@ -2212,7 +2334,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element (async () => { const visibleJoinTables = getVisibleJoinTables(); - setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, tableVariant)); ///////////////////////////////////////////////////////////////////////////////////////////////////////////////// // todo - we used to be able to set "warnings" here (i think, like, for if a field got deleted from a table... // @@ -2362,11 +2483,135 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element return (getLoadingScreen()); } + let savedViewsComponent = null; + if(metaData && metaData.processes.has("querySavedView")) + { + savedViewsComponent = (); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + const buildColumnMenu = () => + { + ////////////////////////////////////////// + // default (no saved view, and "clean") // + ////////////////////////////////////////// + let buttonBackground = "none"; + let buttonBorder = colors.grayLines.main; + let buttonColor = colors.gray.main; + + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // diff the current view with either the current saved one, if there's one active, else the table default // + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView; + const viewDiffs: string[] = []; + SavedViewUtils.diffColumns(tableMetaData, baseView, view, viewDiffs) + + if(viewDiffs.length == 0 && currentSavedView) + { + ///////////////////////////////////////////////////////////////// + // if 's a saved view, and it's "clean", show it in main style // + ///////////////////////////////////////////////////////////////// + buttonBackground = accentColor; + buttonBorder = accentColor; + buttonColor = "#FFFFFF"; + } + else if(viewDiffs.length > 0) + { + /////////////////////////////////////////////////// + // else if there are diffs, show alt/light style // + /////////////////////////////////////////////////// + buttonBackground = accentColorLight; + buttonBorder = accentColorLight; + buttonColor = accentColor; + } + + const columnMenuButtonStyles = { + borderRadius: "0.75rem", + border: `1px solid ${buttonBorder}`, + color: buttonColor, + textTransform: "none", + fontWeight: 500, + fontSize: "0.875rem", + p: "0.5rem", + backgroundColor: buttonBackground, + "&:focus:not(:hover)": { + color: buttonColor, + backgroundColor: buttonBackground, + }, + "&:hover": { + color: buttonColor, + backgroundColor: buttonBackground, + } + } + + return ( + view_week_outline Columns ({view.queryColumns.getVisibleColumnCount()}) keyboard_arrow_down} + isModeToggle={true} + toggleStates={view.queryColumns.getVisibilityToggleStates()} + handleToggleField={handleChangeOneColumnVisibility} + /> + ); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // these numbers help set the height of the grid (so page won't scroll) based on spcae above & below it // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + let spaceBelowGrid = 40; + let spaceAboveGrid = 205; + if(tableMetaData?.usesVariants) + { + spaceAboveGrid += 30; + } + + if(mode == "advanced") + { + spaceAboveGrid += 60; + } + //////////////////////// // main screen render // //////////////////////// return ( + + + + {pageLoadingState.isLoading() && ""} + {pageLoadingState.isLoadingSlow() && "Loading..."} + {pageLoadingState.isNotLoading() && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)} + + + + + + { + tableMetaData && + + } + + { + table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && + + } + +
{/* // see code in ExportMenuItem that would use this @@ -2411,34 +2656,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ) : null } - - - { - metaData && metaData.processes.has("querySavedView") && - - } - - - - - { - tableMetaData && - - } - - { - table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && - - } - { metaData && tableMetaData && @@ -2454,6 +2671,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element gridApiRef={gridApiRef} mode={mode} setMode={doSetMode} + savedViewsComponent={savedViewsComponent} + columnMenuComponent={buildColumnMenu()} /> } @@ -2466,20 +2685,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element Pagination: CustomPagination, LoadingOverlay: CustomLoadingOverlay, ColumnMenu: CustomColumnMenu, - ColumnsPanel: CustomColumnsPanel, FilterPanel: CustomFilterPanel, + // @ts-ignore - this turns these off, whether TS likes it or not... + ColumnsPanel: "", ColumnSortedDescendingIcon: "", ColumnSortedAscendingIcon: "", ColumnUnsortedIcon: "", ColumnHeaderFilterIconButton: CustomColumnHeaderFilterIconButton, }} componentsProps={{ - columnsPanel: - { - tableMetaData: tableMetaData, - metaData: metaData, - initialOpenedGroups: columnChooserOpenGroups, - openGroupsChanger: setColumnChooserOpenGroups, - initialFilterText: columnChooserFilterText, - filterTextChanger: setColumnChooserFilterText - }, filterPanel: { tableMetaData: tableMetaData, @@ -2522,12 +2733,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element onSelectionModelChange={handleSelectionChanged} onSortModelChange={handleSortChange} sortingOrder={["asc", "desc"]} - sortModel={columnSortModel} + onColumnHeaderClick={handleColumnHeaderClick} getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")} getRowId={(row) => row.__rowIndex} selectionModel={rowSelectionModel} hideFooterSelectedRowCount={true} - sx={{border: 0, height: tableMetaData?.usesVariants ? "calc(100vh - 300px)" : "calc(100vh - 270px)"}} + sx={{border: 0, height: `calc(100vh - ${spaceAboveGrid + spaceBelowGrid}px)`}} /> @@ -2548,7 +2759,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { setTableVariantPromptOpen(false); setTableVariant(value); - setPageHeader(getPageHeader(tableMetaData, visibleJoinTables, value)); }} /> } diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 0642ab8..b327e62 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -29,6 +29,13 @@ min-height: calc(100vh - 450px) !important; } +/* we want to leave columns w/ the sortable attribute (so they have it in the column menu), +but we've turned off the click-to-sort function, so remove hand cursor */ +.recordQuery .MuiDataGrid-columnHeader--sortable +{ + cursor: default !important; +} + /* Disable red outlines on clicked cells */ .MuiDataGrid-cell:focus, .MuiDataGrid-columnHeader:focus, @@ -402,7 +409,8 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } margin-right: 8px; } -.custom-columns-panel .MuiSwitch-thumb +.custom-columns-panel .MuiSwitch-thumb, +.fieldListMenuBody .MuiSwitch-thumb { width: 15px !important; height: 15px !important; @@ -428,7 +436,7 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } { /* overwrite what the grid tries to do here, where it changes based on density... we always want the same. */ /* transform: translate(274px, 305px) !important; */ - transform: translate(274px, 276px) !important; + transform: translate(274px, 264px) !important; } /* within the data-grid, the filter-panel's container has a max-height. mirror that, and set an overflow-y */ diff --git a/src/qqq/utils/qqq/FilterUtils.tsx b/src/qqq/utils/qqq/FilterUtils.tsx index fe7c45e..703e594 100644 --- a/src/qqq/utils/qqq/FilterUtils.tsx +++ b/src/qqq/utils/qqq/FilterUtils.tsx @@ -30,6 +30,7 @@ import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFil import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; +import Box from "@mui/material/Box"; import {GridSortModel} from "@mui/x-data-grid-pro"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; @@ -539,9 +540,14 @@ class FilterUtils if(styled) { - return (<> - {fieldLabel} {FilterUtils.operatorToHumanString(criteria, field)} {valuesString}  - ); + return ( + + {fieldLabel} + {FilterUtils.operatorToHumanString(criteria, field)} + {valuesString && {valuesString}} +   + + ) } else { diff --git a/src/qqq/utils/qqq/SavedViewUtils.ts b/src/qqq/utils/qqq/SavedViewUtils.ts new file mode 100644 index 0000000..a40b962 --- /dev/null +++ b/src/qqq/utils/qqq/SavedViewUtils.ts @@ -0,0 +1,418 @@ +/* + * 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 {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import QQueryColumns from "qqq/models/query/QQueryColumns"; +import RecordQueryView from "qqq/models/query/RecordQueryView"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import TableUtils from "qqq/utils/qqq/TableUtils"; + +/******************************************************************************* + ** Utility class for working with QQQ Saved Views + ** + *******************************************************************************/ +export class SavedViewUtils +{ + + /******************************************************************************* + ** + *******************************************************************************/ + public static fieldNameToLabel = (tableMetaData: QTableMetaData, fieldName: string): string => + { + try + { + const [fieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, fieldName); + if (fieldTable.name != tableMetaData.name) + { + return (fieldTable.label + ": " + fieldMetaData.label); + } + + return (fieldMetaData.label); + } + catch (e) + { + return (fieldName); + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public static diffFilters = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + //////////////////////////////////////////////////////////////////////////////// + // inner helper function for reporting on the number of criteria for a field. // + // e.g., will tell us "added criteria X" or "removed 2 criteria on Y" // + //////////////////////////////////////////////////////////////////////////////// + const diffCriteriaFunction = (base: QQueryFilter, compare: QQueryFilter, messagePrefix: string, isCheckForChanged = false) => + { + const baseCriteriaMap: { [name: string]: QFilterCriteria[] } = {}; + base?.criteria?.forEach((criteria) => + { + if (validateCriteria(criteria).criteriaIsValid) + { + if (!baseCriteriaMap[criteria.fieldName]) + { + baseCriteriaMap[criteria.fieldName] = []; + } + baseCriteriaMap[criteria.fieldName].push(criteria); + } + }); + + const compareCriteriaMap: { [name: string]: QFilterCriteria[] } = {}; + compare?.criteria?.forEach((criteria) => + { + if (validateCriteria(criteria).criteriaIsValid) + { + if (!compareCriteriaMap[criteria.fieldName]) + { + compareCriteriaMap[criteria.fieldName] = []; + } + compareCriteriaMap[criteria.fieldName].push(criteria); + } + }); + + for (let fieldName of Object.keys(compareCriteriaMap)) + { + const noBaseCriteria = baseCriteriaMap[fieldName]?.length ?? 0; + const noCompareCriteria = compareCriteriaMap[fieldName]?.length ?? 0; + + if (isCheckForChanged) + { + ///////////////////////////////////////////////////////////////////////////////////////////// + // first - if we're checking for changes to specific criteria (e.g., change id=5 to id<>5, // + // or change id=5 to id=6, or change id=5 to id<>7) // + // our "sweet spot" is if there's a single criteria on each side of the check // + ///////////////////////////////////////////////////////////////////////////////////////////// + if (noBaseCriteria == 1 && noCompareCriteria == 1) + { + const baseCriteria = baseCriteriaMap[fieldName][0]; + const compareCriteria = compareCriteriaMap[fieldName][0]; + const baseValuesJSON = JSON.stringify(baseCriteria.values ?? []); + const compareValuesJSON = JSON.stringify(compareCriteria.values ?? []); + if (baseCriteria.operator != compareCriteria.operator || baseValuesJSON != compareValuesJSON) + { + viewDiffs.push(`Changed a filter from ${FilterUtils.criteriaToHumanString(tableMetaData, baseCriteria)} to ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteria)}`); + } + } + else if (noBaseCriteria == noCompareCriteria) + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else - if the number of criteria on this field differs, that'll get caught in a non-isCheckForChanged call, so // + // todo, i guess - this is kinda weak - but if there's the same number of criteria on a field, then just ... do a shitty JSON compare between them... // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const baseJSON = JSON.stringify(baseCriteriaMap[fieldName]); + const compareJSON = JSON.stringify(compareCriteriaMap[fieldName]); + if (baseJSON != compareJSON) + { + viewDiffs.push(`${messagePrefix} 1 or more filters on ${SavedViewUtils.fieldNameToLabel(tableMetaData, fieldName)}`); + } + } + } + else + { + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else - we're not checking for changes to individual criteria - rather - we're just checking if criteria were added or removed. // + // we'll do that by starting to see if the nubmer of criteria is different. // + // and, only do it in only 1 direction, assuming we'll get called twice, with the base & compare sides flipped // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if (noBaseCriteria < noCompareCriteria) + { + if (noBaseCriteria == 0 && noCompareCriteria == 1) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if the difference is 0 to 1 (1 to 0 when called in reverse), then we can report the full criteria that was added/removed // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + viewDiffs.push(`${messagePrefix} filter: ${FilterUtils.criteriaToHumanString(tableMetaData, compareCriteriaMap[fieldName][0])}`); + } + else + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else, say 0 to 2, or 2 to 1 - just report on how many were changed... // + // todo this isn't great, as you might have had, say, (A,B), and now you have (C) - but all we'll say is "removed 1"... // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const noDiffs = noCompareCriteria - noBaseCriteria; + viewDiffs.push(`${messagePrefix} ${noDiffs} filters on ${SavedViewUtils.fieldNameToLabel(tableMetaData, fieldName)}`); + } + } + } + } + }; + + diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Added"); + diffCriteriaFunction(activeView.queryFilter, savedView.queryFilter, "Removed"); + diffCriteriaFunction(savedView.queryFilter, activeView.queryFilter, "Changed", true); + + ////////////////////// + // boolean operator // + ////////////////////// + if (savedView.queryFilter.booleanOperator != activeView.queryFilter.booleanOperator) + { + viewDiffs.push("Changed filter from 'And' to 'Or'"); + } + + /////////////// + // order-bys // + /////////////// + const savedOrderBys = savedView.queryFilter.orderBys; + const activeOrderBys = activeView.queryFilter.orderBys; + if (savedOrderBys.length != activeOrderBys.length) + { + viewDiffs.push("Changed sort"); + } + else if (savedOrderBys.length > 0) + { + const toWord = ((b: boolean) => b ? "ascending" : "descending"); + if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName && savedOrderBys[0].isAscending != activeOrderBys[0].isAscending) + { + viewDiffs.push(`Changed sort from ${SavedViewUtils.fieldNameToLabel(tableMetaData, savedOrderBys[0].fieldName)} ${toWord(savedOrderBys[0].isAscending)} to ${SavedViewUtils.fieldNameToLabel(tableMetaData, activeOrderBys[0].fieldName)} ${toWord(activeOrderBys[0].isAscending)}`); + } + else if (savedOrderBys[0].fieldName != activeOrderBys[0].fieldName) + { + viewDiffs.push(`Changed sort field from ${SavedViewUtils.fieldNameToLabel(tableMetaData, savedOrderBys[0].fieldName)} to ${SavedViewUtils.fieldNameToLabel(tableMetaData, activeOrderBys[0].fieldName)}`); + } + else if (savedOrderBys[0].isAscending != activeOrderBys[0].isAscending) + { + viewDiffs.push(`Changed sort direction from ${toWord(savedOrderBys[0].isAscending)} to ${toWord(activeOrderBys[0].isAscending)}`); + } + } + } + catch (e) + { + console.log(`Error looking for differences in filters ${e}`); + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public static diffColumns = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + if (!savedView.queryColumns || !savedView.queryColumns.columns || savedView.queryColumns.columns.length == 0) + { + viewDiffs.push("This view did not previously have columns saved with it, so the next time you save it they will be initialized."); + return; + } + + //////////////////////////////////////////////////////////// + // nested function to help diff visible status of columns // + //////////////////////////////////////////////////////////// + const diffVisibilityFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: boolean } = {}; + base.columns.forEach((column) => + { + if (column.isVisible) + { + baseColumnsMap[column.name] = true; + } + }); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if (column.isVisible) + { + if (!baseColumnsMap[column.name]) + { + diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name)); + } + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + /////////////////////////////////////////////////////////// + // nested function to help diff pinned status of columns // + /////////////////////////////////////////////////////////// + const diffPinsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: string } = {}; + base.columns.forEach((column) => baseColumnsMap[column.name] = column.pinned); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if (baseColumnsMap[column.name] != column.pinned) + { + diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name)); + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + /////////////////////////////////////////////////// + // nested function to help diff width of columns // + /////////////////////////////////////////////////// + const diffWidthsFunction = (base: QQueryColumns, compare: QQueryColumns, messagePrefix: string) => + { + const baseColumnsMap: { [name: string]: number } = {}; + base.columns.forEach((column) => baseColumnsMap[column.name] = column.width); + + const diffFields: string[] = []; + for (let i = 0; i < compare.columns.length; i++) + { + const column = compare.columns[i]; + if (baseColumnsMap[column.name] != column.width) + { + diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, column.name)); + } + } + + if (diffFields.length > 0) + { + if (diffFields.length > 5) + { + viewDiffs.push(`${messagePrefix} ${diffFields.length} columns.`); + } + else + { + viewDiffs.push(`${messagePrefix} column${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + } + }; + + diffVisibilityFunction(savedView.queryColumns, activeView.queryColumns, "Turned on "); + diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off "); + diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for "); + + if (savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(",")) + { + viewDiffs.push("Changed the order of columns."); + } + + diffWidthsFunction(savedView.queryColumns, activeView.queryColumns, "Changed width for "); + } + catch (e) + { + console.log(`Error looking for differences in columns: ${e}`); + } + }; + + /******************************************************************************* + ** + *******************************************************************************/ + public static diffQuickFilterFieldNames = (tableMetaData: QTableMetaData, savedView: RecordQueryView, activeView: RecordQueryView, viewDiffs: string[]): void => + { + try + { + const diffFunction = (base: string[], compare: string[], messagePrefix: string) => + { + const baseFieldNameMap: { [name: string]: boolean } = {}; + base.forEach((name) => baseFieldNameMap[name] = true); + const diffFields: string[] = []; + for (let i = 0; i < compare.length; i++) + { + const name = compare[i]; + if (!baseFieldNameMap[name]) + { + diffFields.push(SavedViewUtils.fieldNameToLabel(tableMetaData, name)); + } + } + + if (diffFields.length > 0) + { + viewDiffs.push(`${messagePrefix} basic filter${diffFields.length == 1 ? "" : "s"}: ${diffFields.join(", ")}`); + } + }; + + diffFunction(savedView.quickFilterFieldNames, activeView.quickFilterFieldNames, "Turned on"); + diffFunction(activeView.quickFilterFieldNames, savedView.quickFilterFieldNames, "Turned off"); + } + catch (e) + { + console.log(`Error looking for differences in quick filter field names: ${e}`); + } + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + public static diffViews = (tableMetaData: QTableMetaData, baseView: RecordQueryView, activeView: RecordQueryView): string[] => + { + const viewDiffs: string[] = []; + + SavedViewUtils.diffFilters(tableMetaData, baseView, activeView, viewDiffs); + SavedViewUtils.diffColumns(tableMetaData, baseView, activeView, viewDiffs); + SavedViewUtils.diffQuickFilterFieldNames(tableMetaData, baseView, activeView, viewDiffs); + + if (baseView.mode != activeView.mode) + { + if (baseView.mode) + { + viewDiffs.push(`Mode changed from ${baseView.mode} to ${activeView.mode}`); + } + else + { + viewDiffs.push(`Mode set to ${activeView.mode}`); + } + } + + if (baseView.rowsPerPage != activeView.rowsPerPage) + { + if (baseView.rowsPerPage) + { + viewDiffs.push(`Rows per page changed from ${baseView.rowsPerPage} to ${activeView.rowsPerPage}`); + } + else + { + viewDiffs.push(`Rows per page set to ${activeView.rowsPerPage}`); + } + } + return viewDiffs; + }; + +} \ No newline at end of file