From d901404d2535c3d63fe6e578a242d939e1fcb11b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 29 Jan 2024 19:56:09 -0600 Subject: [PATCH] CE-793 - Redo SavedFilters component as SavedViews --- src/qqq/components/misc/SavedFilters.tsx | 511 ------------- src/qqq/components/misc/SavedViews.tsx | 918 +++++++++++++++++++++++ 2 files changed, 918 insertions(+), 511 deletions(-) delete mode 100644 src/qqq/components/misc/SavedFilters.tsx create mode 100644 src/qqq/components/misc/SavedViews.tsx diff --git a/src/qqq/components/misc/SavedFilters.tsx b/src/qqq/components/misc/SavedFilters.tsx deleted file mode 100644 index eead528..0000000 --- a/src/qqq/components/misc/SavedFilters.tsx +++ /dev/null @@ -1,511 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2022. 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; -import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; -import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; -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 {FiberManualRecord} from "@mui/icons-material"; -import {Alert} from "@mui/material"; -import Box from "@mui/material/Box"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import Divider from "@mui/material/Divider"; -import Icon from "@mui/material/Icon"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import {GridFilterModel, GridSortItem} from "@mui/x-data-grid-pro"; -import FormData from "form-data"; -import React, {useEffect, useRef, useState} from "react"; -import {useLocation, useNavigate} from "react-router-dom"; -import {QCancelButton, QDeleteButton, QSaveButton, QSavedFiltersMenuButton} from "qqq/components/buttons/DefaultButtons"; -import FilterUtils from "qqq/utils/qqq/FilterUtils"; - -interface Props -{ - qController: QController; - metaData: QInstance; - tableMetaData: QTableMetaData; - currentSavedFilter: QRecord; - filterModel?: GridFilterModel; - columnSortModel?: GridSortItem[]; - filterOnChangeCallback?: (selectedSavedFilterId: number) => void; -} - -function SavedFilters({qController, metaData, tableMetaData, currentSavedFilter, filterModel, columnSortModel, filterOnChangeCallback}: Props): JSX.Element -{ - const navigate = useNavigate(); - - const [savedFilters, setSavedFilters] = useState([] as QRecord[]); - const [savedFiltersMenu, setSavedFiltersMenu] = useState(null); - const [savedFiltersHaveLoaded, setSavedFiltersHaveLoaded] = useState(false); - const [filterIsModified, setFilterIsModified] = useState(false); - - const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false); - const [isSaveFilterAs, setIsSaveFilterAs] = useState(false); - const [isRenameFilter, setIsRenameFilter] = useState(false); - const [isDeleteFilter, setIsDeleteFilter] = useState(false); - const [savedFilterNameInputValue, setSavedFilterNameInputValue] = useState(null as string); - const [popupAlertContent, setPopupAlertContent] = useState(""); - - const anchorRef = useRef(null); - const location = useLocation(); - const [saveOptionsOpen, setSaveOptionsOpen] = useState(false); - - const SAVE_OPTION = "Save..."; - const DUPLICATE_OPTION = "Duplicate..."; - const RENAME_OPTION = "Rename..."; - const DELETE_OPTION = "Delete..."; - const CLEAR_OPTION = "Clear Current Filter"; - const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION]; - - const openSavedFiltersMenu = (event: any) => setSavedFiltersMenu(event.currentTarget); - const closeSavedFiltersMenu = () => setSavedFiltersMenu(null); - - ////////////////////////////////////////////////////////////////////////// - // load filters on first run, then monitor location or metadata changes // - ////////////////////////////////////////////////////////////////////////// - useEffect(() => - { - loadSavedFilters() - .then(() => - { - if (currentSavedFilter != null) - { - let qFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel); - setFilterIsModified(JSON.stringify(qFilter) !== currentSavedFilter.values.get("filterJson")); - } - - setSavedFiltersHaveLoaded(true); - }); - }, [location , tableMetaData, currentSavedFilter, filterModel, columnSortModel]) - - - - /******************************************************************************* - ** make request to load all saved filters from backend - *******************************************************************************/ - async function loadSavedFilters() - { - if (! tableMetaData) - { - return; - } - - const formData = new FormData(); - formData.append("tableName", tableMetaData.name); - - let savedFilters = await makeSavedFilterRequest("querySavedFilter", formData); - setSavedFilters(savedFilters); - } - - - - /******************************************************************************* - ** fired when a saved record is clicked from the dropdown - *******************************************************************************/ - const handleSavedFilterRecordOnClick = async (record: QRecord) => - { - setSaveFilterPopupOpen(false); - closeSavedFiltersMenu(); - filterOnChangeCallback(record.values.get("id")); - navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedFilter/${record.values.get("id")}`); - }; - - - - /******************************************************************************* - ** fired when a save option is selected from the save... button/dropdown combo - *******************************************************************************/ - const handleDropdownOptionClick = (optionName: string) => - { - setSaveOptionsOpen(false); - setPopupAlertContent(null); - closeSavedFiltersMenu(); - setSaveFilterPopupOpen(true); - setIsSaveFilterAs(false); - setIsRenameFilter(false); - setIsDeleteFilter(false) - - switch(optionName) - { - case SAVE_OPTION: - break; - case DUPLICATE_OPTION: - setIsSaveFilterAs(true); - break; - case CLEAR_OPTION: - setSaveFilterPopupOpen(false) - filterOnChangeCallback(null); - navigate(metaData.getTablePathByName(tableMetaData.name)); - break; - case RENAME_OPTION: - if(currentSavedFilter != null) - { - setSavedFilterNameInputValue(currentSavedFilter.values.get("label")); - } - setIsRenameFilter(true); - break; - case DELETE_OPTION: - setIsDeleteFilter(true) - break; - } - } - - - - /******************************************************************************* - ** fired when save or delete button saved on confirmation dialogs - *******************************************************************************/ - async function handleFilterDialogButtonOnClick() - { - try - { - const formData = new FormData(); - if (isDeleteFilter) - { - formData.append("id", currentSavedFilter.values.get("id")); - await makeSavedFilterRequest("deleteSavedFilter", formData); - await(async() => - { - handleDropdownOptionClick(CLEAR_OPTION); - })(); - } - else - { - formData.append("tableName", tableMetaData.name); - formData.append("filterJson", JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel)))); - - if (isSaveFilterAs || isRenameFilter || currentSavedFilter == null) - { - formData.append("label", savedFilterNameInputValue); - if(currentSavedFilter != null && isRenameFilter) - { - formData.append("id", currentSavedFilter.values.get("id")); - } - } - else - { - formData.append("id", currentSavedFilter.values.get("id")); - formData.append("label", currentSavedFilter?.values.get("label")); - } - const recordList = await makeSavedFilterRequest("storeSavedFilter", formData); - await(async() => - { - if (recordList && recordList.length > 0) - { - setSavedFiltersHaveLoaded(false); - loadSavedFilters(); - handleSavedFilterRecordOnClick(recordList[0]); - } - })(); - } - } - catch (e: any) - { - setPopupAlertContent(JSON.stringify(e.message)); - } - } - - - - /******************************************************************************* - ** hides/shows the save options - *******************************************************************************/ - const handleToggleSaveOptions = () => - { - setSaveOptionsOpen((prevOpen) => !prevOpen); - }; - - - - /******************************************************************************* - ** closes save options menu (on clickaway) - *******************************************************************************/ - const handleSaveOptionsMenuClose = (event: Event) => - { - if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) - { - return; - } - - setSaveOptionsOpen(false); - }; - - - - /******************************************************************************* - ** stores the current dialog input text to state - *******************************************************************************/ - const handleSaveFilterInputChange = (event: React.ChangeEvent) => - { - setSavedFilterNameInputValue(event.target.value); - }; - - - - /******************************************************************************* - ** closes current dialog - *******************************************************************************/ - const handleSaveFilterPopupClose = () => - { - setSaveFilterPopupOpen(false); - }; - - - - /******************************************************************************* - ** make a request to the backend for various savedFilter processes - *******************************************************************************/ - async function makeSavedFilterRequest(processName: string, formData: FormData): Promise - { - ///////////////////////// - // fetch saved filters // - ///////////////////////// - let savedFilters = [] as QRecord[] - try - { - ////////////////////////////////////////////////////////////////// - // we don't want this job to go async, so, pass a large timeout // - ////////////////////////////////////////////////////////////////// - formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); - const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders()); - if (processResult instanceof QJobError) - { - const jobError = processResult as QJobError; - throw(jobError.error); - } - else - { - const result = processResult as QJobComplete; - if(result.values.savedFilterList) - { - for (let i = 0; i < result.values.savedFilterList.length; i++) - { - const qRecord = new QRecord(result.values.savedFilterList[i]); - savedFilters.push(qRecord); - } - } - } - } - catch (e) - { - throw(e); - } - - return (savedFilters); - } - - const hasStorePermission = metaData?.processes.has("storeSavedFilter"); - const hasDeletePermission = metaData?.processes.has("deleteSavedFilter"); - const hasQueryPermission = metaData?.processes.has("querySavedFilter"); - - const renderSavedFiltersMenu = tableMetaData && ( - - Filter Actions - { - hasStorePermission && - handleDropdownOptionClick(SAVE_OPTION)}> - save - Save... - - } - { - hasStorePermission && - handleDropdownOptionClick(RENAME_OPTION)}> - edit - Rename... - - } - { - hasStorePermission && - handleDropdownOptionClick(DUPLICATE_OPTION)}> - content_copy - Duplicate... - - } - { - hasDeletePermission && - handleDropdownOptionClick(DELETE_OPTION)}> - delete - Delete... - - } - { - handleDropdownOptionClick(CLEAR_OPTION)}> - clear - Clear Current Filter - - } - - Your Filters - { - savedFilters && savedFilters.length > 0 ? ( - savedFilters.map((record: QRecord, index: number) => - handleSavedFilterRecordOnClick(record)}> - {record.values.get("label")} - - ) - ): ( - - No filters have been saved for this table. - - ) - } - - ); - - return ( - hasQueryPermission && tableMetaData ? ( - - - {renderSavedFiltersMenu} - - - { - savedFiltersHaveLoaded && currentSavedFilter && ( - Current Filter:  - - {currentSavedFilter.values.get("label")} - { - filterIsModified && ( - - - - ) - } - - - ) - } - - - { - - { - if (e.key == "Enter") - { - handleFilterDialogButtonOnClick(); - } - }} - > - { - currentSavedFilter ? ( - isDeleteFilter ? ( - Delete Filter - ) : ( - isSaveFilterAs ? ( - Save Filter As - ):( - isRenameFilter ? ( - Rename Filter - ):( - Update Existing Filter - ) - ) - ) - ):( - Save New Filter - ) - } - - { - (! currentSavedFilter || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? ( - - { - isSaveFilterAs ? ( - Enter a name for this new saved filter. - ):( - Enter a new name for this saved filter. - ) - } - - { - event.target.select(); - }} - /> - - ):( - isDeleteFilter ? ( - Are you sure you want to delete the filter {`'${currentSavedFilter?.values.get("label")}'`}? - ):( - Are you sure you want to update the filter {`'${currentSavedFilter?.values.get("label")}'`} with the current filter criteria? - ) - ) - } - {popupAlertContent ? ( - - {popupAlertContent} - - ) : ("")} - - - - { - isDeleteFilter ? - - : - - } - - - } - - ) : null - ); -} - -export default SavedFilters; diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx new file mode 100644 index 0000000..89c58cb --- /dev/null +++ b/src/qqq/components/misc/SavedViews.tsx @@ -0,0 +1,918 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +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 Box from "@mui/material/Box"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import Divider from "@mui/material/Divider"; +import Icon from "@mui/material/Icon"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import Menu from "@mui/material/Menu"; +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 {useLocation, useNavigate} from "react-router-dom"; +import {QCancelButton, QDeleteButton, QSaveButton, QSavedViewsMenuButton} from "qqq/components/buttons/DefaultButtons"; +import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; +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"; + +interface Props +{ + qController: QController; + metaData: QInstance; + tableMetaData: QTableMetaData; + currentSavedView: QRecord; + view?: RecordQueryView; + viewAsJson?: string; + viewOnChangeCallback?: (selectedSavedViewId: number) => void; + loadingSavedView: boolean +} + +function SavedViews({qController, metaData, tableMetaData, currentSavedView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element +{ + const navigate = useNavigate(); + + const [savedViews, setSavedViews] = useState([] as QRecord[]); + const [savedViewsMenu, setSavedViewsMenu] = useState(null); + const [savedViewsHaveLoaded, setSavedViewsHaveLoaded] = useState(false); + // const [viewIsModified, setViewIsModified] = useState(false); + + const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false); + const [isSaveFilterAs, setIsSaveFilterAs] = useState(false); + const [isRenameFilter, setIsRenameFilter] = useState(false); + const [isDeleteFilter, setIsDeleteFilter] = useState(false); + const [savedViewNameInputValue, setSavedViewNameInputValue] = useState(null as string); + const [popupAlertContent, setPopupAlertContent] = useState(""); + + const anchorRef = useRef(null); + const location = useLocation(); + const [saveOptionsOpen, setSaveOptionsOpen] = useState(false); + + const SAVE_OPTION = "Save..."; + const DUPLICATE_OPTION = "Duplicate..."; + const RENAME_OPTION = "Rename..."; + const DELETE_OPTION = "Delete..."; + const CLEAR_OPTION = "New View"; + const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION]; + + const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget); + const closeSavedViewsMenu = () => setSavedViewsMenu(null); + + ////////////////////////////////////////////////////////////////////////// + // load filters on first run, then monitor location or metadata changes // + ////////////////////////////////////////////////////////////////////////// + useEffect(() => + { + loadSavedViews() + .then(() => + { + setSavedViewsHaveLoaded(true); + /* + if (currentSavedView != null) + { + const isModified = JSON.stringify(view) !== currentSavedView.values.get("viewJson"); + console.log(`Is view modified? ${isModified}\n${JSON.stringify(view)}\n${currentSavedView.values.get("viewJson")}`); + setViewIsModified(isModified); + } + */ + }); + }, [location, tableMetaData, currentSavedView, view]) // todo#elimGrid does this monitoring work?? + + + /******************************************************************************* + ** + *******************************************************************************/ + 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 + { + //////////////////////////////////////////////////////////// + // 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 visibility for "); + diffVisibilityFunction(activeView.queryColumns, savedView.queryColumns, "Turned off visibility for "); + diffPinsFunction(savedView.queryColumns, activeView.queryColumns, "Changed pinned state for "); + + // console.log(`Saved: ${savedView.queryColumns.columns.map(c => c.name).join(",")}`); + // console.log(`Active: ${activeView.queryColumns.columns.map(c => c.name).join(",")}`); + if(savedView.queryColumns.columns.map(c => c.name).join(",") != activeView.queryColumns.columns.map(c => c.name).join(",")) + { + viewDiffs.push("Changed the order of 1 or more 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}`); + } + } + + + let viewIsModified = false; + let viewDiffs:string[] = []; + + if(currentSavedView != null) + { + 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) + { + viewDiffs.push(`Mode changed from ${savedView.mode} to ${activeView.mode}`) + } + + if(savedView.rowsPerPage != activeView.rowsPerPage) + { + viewDiffs.push(`Rows per page changed from ${savedView.rowsPerPage} to ${activeView.rowsPerPage}`) + } + + if(viewDiffs.length > 0) + { + viewIsModified = true; + } + } + + + /******************************************************************************* + ** make request to load all saved filters from backend + *******************************************************************************/ + async function loadSavedViews() + { + if (! tableMetaData) + { + return; + } + + const formData = new FormData(); + formData.append("tableName", tableMetaData.name); + + let savedViews = await makeSavedViewRequest("querySavedView", formData); + setSavedViews(savedViews); + } + + + + /******************************************************************************* + ** fired when a saved record is clicked from the dropdown + *******************************************************************************/ + const handleSavedViewRecordOnClick = async (record: QRecord) => + { + setSaveFilterPopupOpen(false); + closeSavedViewsMenu(); + viewOnChangeCallback(record.values.get("id")); + navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`); + }; + + + + /******************************************************************************* + ** fired when a save option is selected from the save... button/dropdown combo + *******************************************************************************/ + const handleDropdownOptionClick = (optionName: string) => + { + setSaveOptionsOpen(false); + setPopupAlertContent(null); + closeSavedViewsMenu(); + setSaveFilterPopupOpen(true); + setIsSaveFilterAs(false); + setIsRenameFilter(false); + setIsDeleteFilter(false) + + switch(optionName) + { + case SAVE_OPTION: + break; + case DUPLICATE_OPTION: + setIsSaveFilterAs(true); + break; + case CLEAR_OPTION: + setSaveFilterPopupOpen(false) + viewOnChangeCallback(null); + navigate(metaData.getTablePathByName(tableMetaData.name)); + break; + case RENAME_OPTION: + if(currentSavedView != null) + { + setSavedViewNameInputValue(currentSavedView.values.get("label")); + } + setIsRenameFilter(true); + break; + case DELETE_OPTION: + setIsDeleteFilter(true) + break; + } + } + + + + /******************************************************************************* + ** fired when save or delete button saved on confirmation dialogs + *******************************************************************************/ + async function handleFilterDialogButtonOnClick() + { + try + { + const formData = new FormData(); + if (isDeleteFilter) + { + formData.append("id", currentSavedView.values.get("id")); + await makeSavedViewRequest("deleteSavedView", formData); + await(async() => + { + handleDropdownOptionClick(CLEAR_OPTION); + })(); + } + else + { + formData.append("tableName", tableMetaData.name); + + //////////////////////////////////////////////////////////////////////////////////////////////////// + // clone view via json serialization/deserialization // + // then replace the queryFilter in it with a copy that has had its possible values changed to ids // + // then stringify that for the backend // + //////////////////////////////////////////////////////////////////////////////////////////////////// + const viewObject = JSON.parse(JSON.stringify(view)); + viewObject.queryFilter = JSON.parse(JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(viewObject.queryFilter))); + formData.append("viewJson", JSON.stringify(viewObject)); + + if (isSaveFilterAs || isRenameFilter || currentSavedView == null) + { + formData.append("label", savedViewNameInputValue); + if(currentSavedView != null && isRenameFilter) + { + formData.append("id", currentSavedView.values.get("id")); + } + } + else + { + formData.append("id", currentSavedView.values.get("id")); + formData.append("label", currentSavedView?.values.get("label")); + } + const recordList = await makeSavedViewRequest("storeSavedView", formData); + await(async() => + { + if (recordList && recordList.length > 0) + { + setSavedViewsHaveLoaded(false); + loadSavedViews(); + handleSavedViewRecordOnClick(recordList[0]); + } + })(); + } + } + catch (e: any) + { + setPopupAlertContent(JSON.stringify(e.message)); + } + } + + + + /******************************************************************************* + ** hides/shows the save options + *******************************************************************************/ + const handleToggleSaveOptions = () => + { + setSaveOptionsOpen((prevOpen) => !prevOpen); + }; + + + + /******************************************************************************* + ** closes save options menu (on clickaway) + *******************************************************************************/ + const handleSaveOptionsMenuClose = (event: Event) => + { + if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) + { + return; + } + + setSaveOptionsOpen(false); + }; + + + + /******************************************************************************* + ** stores the current dialog input text to state + *******************************************************************************/ + const handleSaveFilterInputChange = (event: React.ChangeEvent) => + { + setSavedViewNameInputValue(event.target.value); + }; + + + + /******************************************************************************* + ** closes current dialog + *******************************************************************************/ + const handleSaveFilterPopupClose = () => + { + setSaveFilterPopupOpen(false); + }; + + + + /******************************************************************************* + ** make a request to the backend for various savedView processes + *******************************************************************************/ + async function makeSavedViewRequest(processName: string, formData: FormData): Promise + { + ///////////////////////// + // fetch saved filters // + ///////////////////////// + let savedViews = [] as QRecord[] + try + { + ////////////////////////////////////////////////////////////////// + // we don't want this job to go async, so, pass a large timeout // + ////////////////////////////////////////////////////////////////// + formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000); + const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders()); + if (processResult instanceof QJobError) + { + const jobError = processResult as QJobError; + throw(jobError.error); + } + else + { + const result = processResult as QJobComplete; + if(result.values.savedViewList) + { + for (let i = 0; i < result.values.savedViewList.length; i++) + { + const qRecord = new QRecord(result.values.savedViewList[i]); + savedViews.push(qRecord); + } + } + } + } + catch (e) + { + throw(e); + } + + return (savedViews); + } + + const hasStorePermission = metaData?.processes.has("storeSavedView"); + const hasDeletePermission = metaData?.processes.has("deleteSavedView"); + const hasQueryPermission = metaData?.processes.has("querySavedView"); + + const tooltipMaxWidth = (maxWidth: string) => + { + return ({slotProps: { + tooltip: { + sx: { + maxWidth: maxWidth + } + } + }}) + } + + const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps; + + const renderSavedViewsMenu = tableMetaData && ( + + 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... + +
+ } + { + hasStorePermission && + + handleDropdownOptionClick(RENAME_OPTION)}> + edit + Rename... + + + } + { + hasStorePermission && + + handleDropdownOptionClick(DUPLICATE_OPTION)}> + content_copy + Duplicate... + + + } + { + hasDeletePermission && + + handleDropdownOptionClick(DELETE_OPTION)}> + delete + Delete... + + + } + { + + handleDropdownOptionClick(CLEAR_OPTION)}> + monitor + New View + + + } + + Your Saved Views + { + savedViews && savedViews.length > 0 ? ( + savedViews.map((record: QRecord, index: number) => + handleSavedViewRecordOnClick(record)}> + {record.values.get("label")} + + ) + ): ( + + No views have been saved for this table. + + ) + } +
+ ); + + return ( + hasQueryPermission && tableMetaData ? ( + + + {renderSavedViewsMenu} + + + { + savedViewsHaveLoaded && currentSavedView && ( + Current View:  + + { + 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.}> + +
+ ) + } + + } +
+
+ ) + } +
+
+ { + + { + if (e.key == "Enter") + { + handleFilterDialogButtonOnClick(); + } + }} + > + { + currentSavedView ? ( + isDeleteFilter ? ( + Delete View + ) : ( + isSaveFilterAs ? ( + Save View As + ):( + isRenameFilter ? ( + Rename View + ):( + Update Existing View + ) + ) + ) + ):( + Save New View + ) + } + + { + (! currentSavedView || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? ( + + { + isSaveFilterAs ? ( + Enter a name for this new saved view. + ):( + Enter a new name for this saved view. + ) + } + + { + event.target.select(); + }} + /> + + ):( + isDeleteFilter ? ( + Are you sure you want to delete the view {`'${currentSavedView?.values.get("label")}'`}? + ):( + Are you sure you want to update the view {`'${currentSavedView?.values.get("label")}'`}? + ) + ) + } + {popupAlertContent ? ( + + {popupAlertContent} + + ) : ("")} + + + + { + isDeleteFilter ? + + : + + } + + + } +
+ ) : null + ); +} + +export default SavedViews;