/*
* 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} 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 QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import {BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
import Client from "qqq/utils/qqq/Client";
import {SavedBulkLoadProfileUtils} from "qqq/utils/qqq/SavedBulkLoadProfileUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation} from "react-router-dom";
interface Props
{
metaData: QInstance,
tableMetaData: QTableMetaData,
tableStructure: BulkLoadTableStructure,
currentSavedBulkLoadProfileRecord: QRecord,
currentMapping: BulkLoadMapping,
bulkLoadProfileOnChangeCallback?: (record: QRecord | null) => void,
allowSelectingProfile?: boolean,
fileDescription?: FileDescription,
bulkLoadProfileResetToSuggestedMappingCallback?: () => void
}
SavedBulkLoadProfiles.defaultProps = {
allowSelectingProfile: true
};
const qController = Client.getInstance();
/***************************************************************************
** menu-button, text elements, and modal(s) that let you work with saved
** bulk-load profiles.
***************************************************************************/
function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, currentSavedBulkLoadProfileRecord, bulkLoadProfileOnChangeCallback, currentMapping, allowSelectingProfile, fileDescription, bulkLoadProfileResetToSuggestedMappingCallback}: Props): JSX.Element
{
const [yourSavedBulkLoadProfiles, setYourSavedBulkLoadProfiles] = useState([] as QRecord[]);
const [bulkLoadProfilesSharedWithYou, setBulkLoadProfilesSharedWithYou] = useState([] as QRecord[]);
const [savedBulkLoadProfilesMenu, setSavedBulkLoadProfilesMenu] = useState(null);
const [savedBulkLoadProfilesHaveLoaded, setSavedBulkLoadProfilesHaveLoaded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePopupOpen, setSavePopupOpen] = useState(false);
const [isSaveAsAction, setIsSaveAsAction] = useState(false);
const [isRenameAction, setIsRenameAction] = useState(false);
const [isDeleteAction, setIsDeleteAction] = useState(false);
const [savedBulkLoadProfileNameInputValue, setSavedBulkLoadProfileNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const [savedSuccessMessage, setSavedSuccessMessage] = useState(null as string);
const [savedFailedMessage, setSavedFailedMessage] = useState(null as string);
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 Profile";
const RESET_TO_SUGGESTION = "Reset to Suggested Mapping";
const {accentColor, accentColorLight, userId: currentUserId} = useContext(QContext);
const openSavedBulkLoadProfilesMenu = (event: any) => setSavedBulkLoadProfilesMenu(event.currentTarget);
const closeSavedBulkLoadProfilesMenu = () => setSavedBulkLoadProfilesMenu(null);
////////////////////////////////////////////////////////////////////////
// load records on first run (if user is allowed to select a profile) //
////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (allowSelectingProfile)
{
loadSavedBulkLoadProfiles()
.then(() =>
{
setSavedBulkLoadProfilesHaveLoaded(true);
});
}
}, []);
const baseBulkLoadMapping: BulkLoadMapping = currentSavedBulkLoadProfileRecord ? BulkLoadMapping.fromSavedProfileRecord(tableStructure, currentSavedBulkLoadProfileRecord) : new BulkLoadMapping(tableStructure);
const bulkLoadProfileDiffs: any[] = SavedBulkLoadProfileUtils.diffBulkLoadMappings(tableStructure, fileDescription, baseBulkLoadMapping, currentMapping);
let bulkLoadProfileIsModified = false;
if (bulkLoadProfileDiffs.length > 0)
{
bulkLoadProfileIsModified = true;
}
/*******************************************************************************
** make request to load all saved profiles from backend
*******************************************************************************/
async function loadSavedBulkLoadProfiles()
{
if (!tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
const savedBulkLoadProfiles = await makeSavedBulkLoadProfileRequest("querySavedBulkLoadProfile", formData);
const yourSavedBulkLoadProfiles: QRecord[] = [];
const bulkLoadProfilesSharedWithYou: QRecord[] = [];
for (let i = 0; i < savedBulkLoadProfiles.length; i++)
{
const record = savedBulkLoadProfiles[i];
if (record.values.get("userId") == currentUserId)
{
yourSavedBulkLoadProfiles.push(record);
}
else
{
bulkLoadProfilesSharedWithYou.push(record);
}
}
setYourSavedBulkLoadProfiles(yourSavedBulkLoadProfiles);
setBulkLoadProfilesSharedWithYou(bulkLoadProfilesSharedWithYou);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedBulkLoadProfileRecordOnClick = async (record: QRecord) =>
{
setSavePopupOpen(false);
closeSavedBulkLoadProfilesMenu();
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(record);
}
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent("");
closeSavedBulkLoadProfilesMenu();
setSavePopupOpen(true);
setIsSaveAsAction(false);
setIsRenameAction(false);
setIsDeleteAction(false);
switch (optionName)
{
case SAVE_OPTION:
if (currentSavedBulkLoadProfileRecord == null)
{
setSavedBulkLoadProfileNameInputValue("");
}
break;
case DUPLICATE_OPTION:
setSavedBulkLoadProfileNameInputValue("");
setIsSaveAsAction(true);
break;
case CLEAR_OPTION:
setSavePopupOpen(false);
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(null);
}
break;
case RESET_TO_SUGGESTION:
setSavePopupOpen(false);
if(bulkLoadProfileResetToSuggestedMappingCallback)
{
bulkLoadProfileResetToSuggestedMappingCallback();
}
break;
case RENAME_OPTION:
if (currentSavedBulkLoadProfileRecord != null)
{
setSavedBulkLoadProfileNameInputValue(currentSavedBulkLoadProfileRecord.values.get("label"));
}
setIsRenameAction(true);
break;
case DELETE_OPTION:
setIsDeleteAction(true);
break;
}
};
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleDialogButtonOnClick()
{
try
{
setPopupAlertContent("");
setIsSubmitting(true);
const formData = new FormData();
if (isDeleteAction)
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
await makeSavedBulkLoadProfileRequest("deleteSavedBulkLoadProfile", formData);
setSavePopupOpen(false);
setSaveOptionsOpen(false);
await (async () =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
/////////////////////////////////////////////////////////////////////////////////////////
// convert the BulkLoadMapping object to a BulkLoadProfile - the thing that gets saved //
/////////////////////////////////////////////////////////////////////////////////////////
const bulkLoadProfile = currentMapping.toProfile();
const mappingJson = JSON.stringify(bulkLoadProfile.profile);
formData.append("mappingJson", mappingJson);
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
formData.append("label", savedBulkLoadProfileNameInputValue);
if (currentSavedBulkLoadProfileRecord != null && isRenameAction)
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
}
}
else
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
formData.append("label", currentSavedBulkLoadProfileRecord?.values.get("label"));
}
const recordList = await makeSavedBulkLoadProfileRequest("storeSavedBulkLoadProfile", formData);
await (async () =>
{
if (recordList && recordList.length > 0)
{
setSavedBulkLoadProfilesHaveLoaded(false);
setSavedSuccessMessage("Profile Saved.");
setTimeout(() => setSavedSuccessMessage(null), 2500);
if (allowSelectingProfile)
{
loadSavedBulkLoadProfiles();
handleSavedBulkLoadProfileRecordOnClick(recordList[0]);
}
else
{
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(recordList[0]);
}
}
}
})();
}
setSavePopupOpen(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);
}
}
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveDialogInputChange = (event: React.ChangeEvent) =>
{
setSavedBulkLoadProfileNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSavePopupClose = () =>
{
setSavePopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedBulkLoadProfile processes
*******************************************************************************/
async function makeSavedBulkLoadProfileRequest(processName: string, formData: FormData): Promise
{
/////////////////////////
// fetch saved records //
/////////////////////////
let savedBulkLoadProfiles = [] 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.savedBulkLoadProfileList)
{
for (let i = 0; i < result.values.savedBulkLoadProfileList.length; i++)
{
const qRecord = new QRecord(result.values.savedBulkLoadProfileList[i]);
savedBulkLoadProfiles.push(qRecord);
}
}
}
}
catch (e)
{
throw (e);
}
return (savedBulkLoadProfiles);
}
const hasStorePermission = metaData?.processes.has("storeSavedBulkLoadProfile");
const hasDeletePermission = metaData?.processes.has("deleteSavedBulkLoadProfile");
const hasQueryPermission = metaData?.processes.has("querySavedBulkLoadProfile");
const tooltipMaxWidth = (maxWidth: string) =>
{
return ({
slotProps: {
tooltip: {
sx: {
maxWidth: maxWidth
}
}
}
});
};
const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps;
let disabledBecauseNotOwner = false;
let notOwnerTooltipText = null;
if (currentSavedBulkLoadProfileRecord && currentSavedBulkLoadProfileRecord.values.get("userId") != currentUserId)
{
disabledBecauseNotOwner = true;
notOwnerTooltipText = "You may not save changes to this bulk load profile, because you are not its owner.";
}
const menuWidth = "300px";
const renderSavedBulkLoadProfilesMenu = tableMetaData && (
{
Bulk Load Profile Actions
}
{
!allowSelectingProfile &&
{
currentSavedBulkLoadProfileRecord ?
You are using the bulk load profile:{currentSavedBulkLoadProfileRecord.values.get("label")} You can manage this profile on this screen.
: You are not using a saved bulk load profile. You can save your profile on this screen.
}
}
{
!allowSelectingProfile &&
}
{
hasStorePermission &&
Save your current mapping, 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
{currentSavedBulkLoadProfileRecord ? "Save..." : "Save As..."}
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
handleDropdownOptionClick(RENAME_OPTION)}>
edit
Rename...
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
handleDropdownOptionClick(DUPLICATE_OPTION)}>
content_copy
Save As...
}
{
hasDeletePermission && currentSavedBulkLoadProfileRecord != null &&
handleDropdownOptionClick(DELETE_OPTION)}>
delete
Delete...
}
{
allowSelectingProfile &&
handleDropdownOptionClick(CLEAR_OPTION)}>
monitor
New Bulk Load Profile
}
{
allowSelectingProfile &&
{
}
Your Saved Bulk Load Profiles
{
yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? (
yourSavedBulkLoadProfiles.map((record: QRecord, index: number) =>
handleSavedBulkLoadProfileRecordOnClick(record)}>
{record.values.get("label")}
)
) : (
You do not have any saved bulk load profiles for this table.
)
}
Bulk Load Profiles Shared with you
{
bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? (
bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) =>
handleSavedBulkLoadProfileRecordOnClick(record)}>
{record.values.get("label")}
)
) : (
You do not have any bulk load profiles shared with you for this table.
)
}
}
);
let buttonText = "Saved Bulk Load Profiles";
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
if (currentSavedBulkLoadProfileRecord)
{
if (bulkLoadProfileIsModified)
{
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 = (savedBulkLoadProfileNameInputValue != null && savedBulkLoadProfileNameInputValue.trim() != "");
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
if (!haveInputText)
{
return (true);
}
}
return (false);
}
const linkButtonStyle = {
minWidth: "unset",
textTransform: "none",
fontSize: "0.875rem",
fontWeight: "500",
padding: "0.5rem"
};
return (
hasQueryPermission && tableMetaData ? (
<>
save
{buttonText}
keyboard_arrow_down
{renderSavedBulkLoadProfilesMenu}
{
savedSuccessMessage && {savedSuccessMessage}
}
{
savedFailedMessage && {savedFailedMessage}
}
{
!currentSavedBulkLoadProfileRecord /*&& bulkLoadProfileIsModified*/ && <>
{
<>
Unsaved Mapping
You are not using a saved bulk load profile.
{
/*bulkLoadProfileDiffs.map((s: string, i: number) => {s} )*/
}
>}>
handleDropdownOptionClick(SAVE_OPTION)}>Save Bulk Load Profile As…
{/* vertical rule */}
{allowSelectingProfile && }
>
}
{/* for the no-profile use-case, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
{allowSelectingProfile && <>
Reset to:
handleDropdownOptionClick(CLEAR_OPTION)}>Empty Mapping
handleDropdownOptionClick(RESET_TO_SUGGESTION)}>Suggested Mapping
>}
>
}
{
currentSavedBulkLoadProfileRecord && bulkLoadProfileIsModified && <>
Unsaved Changes
{
bulkLoadProfileDiffs.map((s: string, i: number) => {s} )
}
{
notOwnerTooltipText && {notOwnerTooltipText}
}
>}>
{bulkLoadProfileDiffs.length} Unsaved Change{bulkLoadProfileDiffs.length == 1 ? "" : "s"}
{disabledBecauseNotOwner ? <> > : handleDropdownOptionClick(SAVE_OPTION)}>Save… }
{/* vertical rule */}
{/* also, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
{/* partly because it isn't correctly resetting the values, but also because, it's a litle unclear that what, it would reset changes from other screens too?? */}
{
allowSelectingProfile && <>
handleSavedBulkLoadProfileRecordOnClick(currentSavedBulkLoadProfileRecord)}>Reset All Changes
>
}
>
}
{
{
////////////////////////////////////////////////////
// make user actually hit delete button //
// but for other modes, let Enter submit the form //
////////////////////////////////////////////////////
if (e.key == "Enter" && !isDeleteAction)
{
handleDialogButtonOnClick();
}
}}
>
{
currentSavedBulkLoadProfileRecord ? (
isDeleteAction ? (
Delete Bulk Load Profile
) : (
isSaveAsAction ? (
Save Bulk Load Profile As
) : (
isRenameAction ? (
Rename Bulk Load Profile
) : (
Update Existing Bulk Load Profile
)
)
)
) : (
Save New Bulk Load Profile
)
}
{popupAlertContent ? (
setPopupAlertContent("")}>{popupAlertContent}
) : ("")}
{
(!currentSavedBulkLoadProfileRecord || isSaveAsAction || isRenameAction) && !isDeleteAction ? (
{
isSaveAsAction ? (
Enter a name for this new saved bulk load profile.
) : (
Enter a new name for this saved bulk load profile.
)
}
{
event.target.select();
}}
/>
) : (
isDeleteAction ? (
Are you sure you want to delete the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?
) : (
Are you sure you want to update the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?
)
)
}
{
isDeleteAction ?
:
}
}
>
) : null
);
}
export default SavedBulkLoadProfiles;