/*
* 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 {Alert, Button, Link} 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 FormData from "form-data";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
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 {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
interface Props
{
qController: QController;
metaData: QInstance;
tableMetaData: QTableMetaData;
currentSavedView: QRecord;
tableDefaultView: RecordQueryView;
view?: RecordQueryView;
viewAsJson?: string;
viewOnChangeCallback?: (selectedSavedViewId: number) => void;
loadingSavedView: boolean
}
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, 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 [isSubmitting, setIsSubmitting] = 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 {accentColor, accentColorLight} = useContext(QContext);
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);
});
}, [location, tableMetaData])
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view);
let viewIsModified = false;
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("");
closeSavedViewsMenu();
setSaveFilterPopupOpen(true);
setIsSaveFilterAs(false);
setIsRenameFilter(false);
setIsDeleteFilter(false)
switch(optionName)
{
case SAVE_OPTION:
if(currentSavedView == null)
{
setSavedViewNameInputValue("");
}
break;
case DUPLICATE_OPTION:
setSavedViewNameInputValue("");
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
{
setPopupAlertContent("");
setIsSubmitting(true);
const formData = new FormData();
if (isDeleteFilter)
{
formData.append("id", currentSavedView.values.get("id"));
await makeSavedViewRequest("deleteSavedView", formData);
setSaveFilterPopupOpen(false);
setSaveOptionsOpen(false);
await(async() =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
/////////////////////////////////////////////////////////////////////////////////////////////////
// clone view via json serialization/deserialization //
// then replace the viewJson 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]);
}
})();
}
setSaveFilterPopupOpen(false);
setSaveOptionsOpen(false);
}
catch (e: any)
{
let message = JSON.stringify(e);
if(typeof e == "string")
{
message = e;
}
else if(typeof e == "object" && e.message)
{
message = e.message;
}
setPopupAlertContent(message);
console.log(`Setting error: ${message}`);
}
finally
{
setIsSubmitting(false);
}
}
/*******************************************************************************
** 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 && (
);
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}
{
!currentSavedView && viewIsModified && <>
Unsaved 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 */}
>
}
{
}
>
) : null
);
}
export default SavedViews;