Files
qqq-frontend-material-dashb…/src/qqq/pages/processes/ProcessRun.tsx

1295 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QComponentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QComponentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFrontendComponent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendComponent";
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QJobRunning} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobRunning";
import {QJobStarted} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobStarted";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Button, CircularProgress, Icon, TablePagination} from "@mui/material";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid";
import Link from "@mui/material/Link";
import Step from "@mui/material/Step";
import StepLabel from "@mui/material/StepLabel";
import Stepper from "@mui/material/Stepper";
import Typography from "@mui/material/Typography";
import {DataGridPro, GridColDef} from "@mui/x-data-grid-pro";
import FormData from "form-data";
import {Form, Formik} from "formik";
import MDTypography from "qqq/components/legacy/MDTypography";
import React, {useContext, useEffect, useState} from "react";
import {useLocation, useNavigate, useParams} from "react-router-dom";
import * as Yup from "yup";
import QContext from "QContext";
import {QCancelButton, QSubmitButton} from "qqq/components/buttons/DefaultButtons";
import QDynamicForm from "qqq/components/forms/DynamicForm";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDButton from "qqq/components/legacy/MDButton";
import MDProgress from "qqq/components/legacy/MDProgress";
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
import {GoogleDriveFolderPickerWrapper} from "qqq/components/processes/GoogleDriveFolderPickerWrapper";
import ProcessSummaryResults from "qqq/components/processes/ProcessSummaryResults";
import ValidationReview from "qqq/components/processes/ValidationReview";
import BaseLayout from "qqq/layouts/BaseLayout";
import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface Props
{
process?: QProcessMetaData;
defaultProcessValues?: any;
isModal?: boolean;
isWidget?: boolean;
recordIds?: string | QQueryFilter;
closeModalHandler?: (event: object, reason: string) => void;
forceReInit?: number;
}
const INITIAL_RETRY_MILLIS = 1_500;
const RETRY_MAX_MILLIS = 12_000;
const BACKOFF_AMOUNT = 1.5;
function ProcessRun({process, defaultProcessValues, isModal, isWidget, recordIds, closeModalHandler, forceReInit}: Props): JSX.Element
{
const processNameParam = useParams().processName;
const processName = process === null ? processNameParam : process.name;
///////////////////
// process state //
///////////////////
const [processUUID, setProcessUUID] = useState(null as string);
const [retryMillis, setRetryMillis] = useState(INITIAL_RETRY_MILLIS);
const [jobUUID, setJobUUID] = useState(null as string);
const [qJobRunning, setQJobRunning] = useState(null as QJobRunning);
const [qJobRunningDate, setQJobRunningDate] = useState(null as Date);
const [activeStepIndex, setActiveStepIndex] = useState(0);
const [activeStep, setActiveStep] = useState(null as QFrontendStepMetaData);
const [newStep, setNewStep] = useState(null);
const [steps, setSteps] = useState([] as QFrontendStepMetaData[]);
const [needInitialLoad, setNeedInitialLoad] = useState(true);
const [lastForcedReInit, setLastForcedReInit] = useState(null as number);
const [processMetaData, setProcessMetaData] = useState(null);
const [tableMetaData, setTableMetaData] = useState(null);
const [tableSections, setTableSections] = useState(null as QTableSection[]);
const [qInstance, setQInstance] = useState(null as QInstance);
const [processValues, setProcessValues] = useState({} as any);
const [processError, _setProcessError] = useState(null as string);
const [isUserFacingError, setIsUserFacingError] = useState(false);
const [needToCheckJobStatus, setNeedToCheckJobStatus] = useState(false);
const [lastProcessResponse, setLastProcessResponse] = useState(
null as QJobStarted | QJobComplete | QJobError | QJobRunning,
);
const [showErrorDetail, setShowErrorDetail] = useState(false);
const [showFullHelpText, setShowFullHelpText] = useState(false);
const {pageHeader, setPageHeader} = useContext(QContext);
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for setting the processError state - call this function, which will also set the isUserFacingError state //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
const setProcessError = (message: string, isUserFacing: boolean = false) =>
{
_setProcessError(message);
setIsUserFacingError(isUserFacing);
};
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// the validation screen - it can change whether next is actually the final step or not... so, use this state field to track that. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [overrideOnLastStep, setOverrideOnLastStep] = useState(null as boolean);
const onLastStep = activeStepIndex === steps.length - 2;
const noMoreSteps = activeStepIndex === steps.length - 1;
////////////////
// form state //
////////////////
const [formId, setFormId] = useState("");
const [formFields, setFormFields] = useState({});
const [initialValues, setInitialValues] = useState({});
const [validationScheme, setValidationScheme] = useState(null);
const [validationFunction, setValidationFunction] = useState(null);
const [formError, setFormError] = useState(null as string);
///////////////////////
// record list state //
///////////////////////
const [needRecords, setNeedRecords] = useState(false);
const [recordConfig, setRecordConfig] = useState({} as any);
const [pageNumber, setPageNumber] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [records, setRecords] = useState([] as QRecord[]);
//////////////////////////////
// state for bulk edit form //
//////////////////////////////
const [disabledBulkEditFields, setDisabledBulkEditFields] = useState({} as any);
const navigate = useNavigate();
const location = useLocation();
const doesStepHaveComponent = (step: QFrontendStepMetaData, type: QComponentType): boolean =>
{
if (step.components)
{
for (let i = 0; i < step.components.length; i++)
{
if (step.components[i].type === type)
{
return (true);
}
}
}
return (false);
};
// @ts-ignore
const defaultLabelDisplayedRows = ({from, to, count}) => `${from.toLocaleString()}${to.toLocaleString()} of ${count !== -1 ? count.toLocaleString() : `more than ${to.toLocaleString()}`}`;
// @ts-ignore
// eslint-disable-next-line react/no-unstable-nested-components
function CustomPagination()
{
return (
<TablePagination
component="div"
count={recordConfig.totalRecords}
page={pageNumber}
rowsPerPageOptions={[10, 25, 50]}
rowsPerPage={rowsPerPage}
onPageChange={(event, value) => recordConfig.handlePageChange(value)}
onRowsPerPageChange={(event) => recordConfig.handleRowsPerPageChange(Number(event.target.value))}
labelDisplayedRows={defaultLabelDisplayedRows}
/>
);
}
//////////////////////////////////////////////////////////////
// event handler for the bulk-edit field-enabled checkboxes //
//////////////////////////////////////////////////////////////
const bulkEditSwitchChanged = (name: string, switchValue: boolean) =>
{
const newDisabledBulkEditFields = JSON.parse(JSON.stringify(disabledBulkEditFields));
newDisabledBulkEditFields[name] = !switchValue;
setDisabledBulkEditFields(newDisabledBulkEditFields);
};
const toggleShowErrorDetail = () =>
{
setShowErrorDetail(!showErrorDetail);
};
const toggleShowFullHelpText = () =>
{
setShowFullHelpText(!showFullHelpText);
};
////////////////////////////////////////////////////
// generate the main form body content for a step //
////////////////////////////////////////////////////
const getDynamicStepContent = (
stepIndex: number,
step: any,
formData: any,
processError: string,
processValues: any,
recordConfig: any,
setFieldValue: any,
): JSX.Element =>
{
if (processError)
{
return (
<>
<MDTypography color="error" variant="h3" component="div">
Error
</MDTypography>
<MDTypography color="body" variant="button">
An error occurred while running the process:
{" "}
{process.label}
{
isUserFacingError ? (
<Box mt={1}>
<b>{processError}</b>
</Box>
) : (
<Box mt={3} display="flex" justifyContent="center">
<Box display="flex" flexDirection="column" alignItems="center">
<Button onClick={toggleShowErrorDetail} startIcon={<Icon>{showErrorDetail ? "expand_less" : "expand_more"}</Icon>}>
{showErrorDetail ? "Hide " : "Show "}
detailed error message
</Button>
<Box mt={1} style={{display: showErrorDetail ? "block" : "none"}}>
{processError}
</Box>
</Box>
</Box>
)
}
</MDTypography>
<Box component="div" py={3}>
<Grid container justifyContent="flex-end" spacing={3}>
{isModal ? <QCancelButton onClickHandler={handleCancelClicked} disabled={false} label="Close" />
: <QCancelButton onClickHandler={handleCancelClicked} disabled={false} />
}
</Grid>
</Box>
</>
);
}
if (qJobRunning || step === null)
{
return (
<Grid m={3} mt={9} container>
<Grid item xs={0} lg={3} />
<Grid item xs={12} lg={6}>
<Card elevation={5}>
<Box p={3}>
<MDTypography variant="h5" component="div">
Working
</MDTypography>
<Grid container>
<Grid item padding={2}>
<CircularProgress color="info" />
</Grid>
<Grid item padding={1}>
<MDTypography color="body" variant="button">
{qJobRunning?.message}
<br />
{qJobRunning?.current && qJobRunning?.total && (
<>
<div>{`${qJobRunning.current.toLocaleString()} of ${qJobRunning.total.toLocaleString()}`}</div>
<Box width="20rem">
<MDProgress variant="gradient" value={100 * (qJobRunning.current / qJobRunning.total)} color="success" />
</Box>
</>
)}
{
qJobRunningDate && (<i>{`Updated at ${qJobRunningDate?.toLocaleTimeString()}`}</i>)
}
</MDTypography>
</Grid>
</Grid>
</Box>
</Card>
</Grid>
</Grid>
);
}
const {formFields, values, errors, touched} = formData;
let localTableSections = tableSections;
if(localTableSections == null)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////
// if the table sections (ones that actually have fields to edit) haven't been built yet, do so now //
//////////////////////////////////////////////////////////////////////////////////////////////////////
localTableSections = tableMetaData ? TableUtils.getSectionsForRecordSidebar(tableMetaData, Object.keys(formFields)) : null;
setTableSections(localTableSections);
}
return (
<>
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// hide label on widgets - the Widget component itself provides the label //
// for modals, show the process label, but not for full-screen processes (for them, it is in the breadcrumb) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
!isWidget &&
<MDTypography variant={isWidget ? "h6" : "h5"} component="div" fontWeight="bold">
{(isModal) ? `${process.label}: ` : ""}
{step?.label}
</MDTypography>
}
{
//////////////////////////////////////////////////
// render all of the components for this screen //
//////////////////////////////////////////////////
step.components && (step.components.map((component: QFrontendComponent, index: number) => (
<div key={index}>
{
component.type === QComponentType.HELP_TEXT && (
component.values.previewText ?
<>
<Box mt={1}>
<Button onClick={toggleShowFullHelpText} startIcon={<Icon>{showFullHelpText ? "expand_less" : "expand_more"}</Icon>} sx={{pl: 1}}>
{showFullHelpText ? "Hide " : "Show "}
{component.values.previewText}
</Button>
</Box>
<Box mt={1} style={{display: showFullHelpText ? "block" : "none"}}>
<Typography variant="body2" color="info">
{ValueUtils.breakTextIntoLines(component.values.text)}
</Typography>
</Box>
</>
:
<MDTypography variant="button" color="info">
{ValueUtils.breakTextIntoLines(component.values.text)}
</MDTypography>
)
}
{
component.type === QComponentType.BULK_EDIT_FORM && (
tableMetaData && localTableSections ?
<Grid container spacing={3} mt={2}>
<Grid item xs={12} lg={3}>
<QRecordSidebar tableSections={localTableSections} stickyTop="20px" />
</Grid>
<Grid item xs={12} lg={9}>
{localTableSections.map((section: QTableSection, index: number) =>
{
const name = section.name
if(section.isHidden)
{
return ;
}
const sectionFormFields = {};
for(let i = 0; i<section.fieldNames.length; i++)
{
const fieldName = section.fieldNames[i];
if(formFields[fieldName])
{
// @ts-ignore
sectionFormFields[fieldName] = formFields[fieldName];
}
}
if(Object.keys(sectionFormFields).length > 0)
{
const sectionFormData = {
formFields: sectionFormFields,
values: values,
errors: errors,
touched: touched
};
return (
<Box key={name} pb={3}>
<Card id={name} sx={{scrollMarginTop: "20px"}} elevation={5}>
<MDTypography variant="h5" p={3} pb={1}>
{section.label}
</MDTypography>
<Box px={2}>
<QDynamicForm formData={sectionFormData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} />
</Box>
</Card>
</Box>
);
}
else
{
return (<br />);
}
}
)}
</Grid>
</Grid>
: <QDynamicForm formData={formData} bulkEditMode bulkEditSwitchChangeHandler={bulkEditSwitchChanged} />
)
}
{
component.type === QComponentType.EDIT_FORM && (
<QDynamicForm formData={formData} />
)
}
{
component.type === QComponentType.VIEW_FORM && step.viewFields && (
<div>
{step.viewFields.map((field: QFieldMetaData) => (
field.hasAdornment(AdornmentType.ERROR) ? (
processValues[field.name] && (
<Box key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="regular">
{ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")}
</MDTypography>
</Box>
)
) : (
<Box key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold">
{field.label}
: &nbsp;
</MDTypography>
<MDTypography variant="button" fontWeight="regular" color="text">
{ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")}
</MDTypography>
</Box>
)))
}
</div>
)
}
{
component.type === QComponentType.DOWNLOAD_FORM && (
<Grid container display="flex" justifyContent="center">
<Grid item xs={12} sm={12} xl={8} m={3} p={3} sx={{border: "1px solid gray", borderRadius: "1rem"}}>
<Box mt={-5} mb={1} p={1} sx={{width: "fit-content", borderRadius: ".25em"}} width="initial" color="white">
<Box display="flex" alignItems="center" color="white">
Download
</Box>
</Box>
<Box display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold">
<Link target="_blank" download href={`/download/${processValues.downloadFileName}?filePath=${processValues.serverFilePath}`} display="flex" alignItems="center">
<Icon fontSize="large">download_for_offline</Icon>
<Box pl={1}>
{processValues.downloadFileName}
</Box>
</Link>
</MDTypography>
</Box>
</Grid>
</Grid>
)
}
{
component.type === QComponentType.VALIDATION_REVIEW_SCREEN && (
<ValidationReview
qInstance={qInstance}
process={processMetaData}
table={tableMetaData}
processValues={processValues}
step={step}
previewRecords={records}
formValues={formData.values}
doFullValidationRadioChangedHandler={(event: any) =>
{
const {value} = event.currentTarget;
//////////////////////////////////////////////////////////////
// call the formik function to set the value in this field. //
//////////////////////////////////////////////////////////////
setFieldValue("doFullValidation", value);
setOverrideOnLastStep(value !== "true");
}}
/>
)
}
{
component.type === QComponentType.PROCESS_SUMMARY_RESULTS && (
<ProcessSummaryResults qInstance={qInstance} process={processMetaData} table={tableMetaData} processValues={processValues} step={step} />
)
}
{
component.type === QComponentType.GOOGLE_DRIVE_SELECT_FOLDER && (
// todo - make these booleans configurable (values on the component)
<GoogleDriveFolderPickerWrapper showSharedDrivesView={true} showDefaultFoldersView={false} qInstance={qInstance} />
)
}
{
component.type === QComponentType.RECORD_LIST && step.recordListFields && recordConfig.columns && (
<div>
<MDTypography variant="button" fontWeight="bold">Records</MDTypography>
{" "}
<br />
<Box height="100%">
<DataGridPro
components={{Pagination: CustomPagination}}
page={recordConfig.pageNo}
disableSelectionOnClick
autoHeight
rows={recordConfig.rows}
columns={recordConfig.columns}
rowBuffer={10}
rowCount={recordConfig.totalRecords}
pageSize={recordConfig.rowsPerPage}
rowsPerPageOptions={[10, 25, 50]}
onPageSizeChange={recordConfig.handleRowsPerPageChange}
onPageChange={recordConfig.handlePageChange}
onRowClick={recordConfig.handleRowClick}
getRowId={(row) => row.__idForDataGridPro__}
paginationMode="server"
pagination
density="compact"
loading={recordConfig.loading}
disableColumnFilter
/>
</Box>
</div>
)
}
</div>
)))}
</>
);
};
const handlePageChange = (page: number) =>
{
setPageNumber(page);
};
const handleRowsPerPageChange = (size: number) =>
{
setRowsPerPage(size);
};
function buildNewRecordConfig()
{
const newRecordConfig = {} as any;
newRecordConfig.pageNo = pageNumber;
newRecordConfig.rowsPerPage = rowsPerPage;
newRecordConfig.columns = [] as GridColDef[];
newRecordConfig.rows = [];
newRecordConfig.totalRecords = 0;
newRecordConfig.handleRowsPerPageChange = handleRowsPerPageChange;
newRecordConfig.handlePageChange = handlePageChange;
newRecordConfig.handleRowClick = null;
newRecordConfig.loading = true;
return (newRecordConfig);
}
const getFullFieldList = (activeStep: QFrontendStepMetaData, processValues: any) =>
{
let rs: QFieldMetaData[] = [];
if (activeStep && activeStep.formFields)
{
for (let i = 0; i < activeStep.formFields.length; i++)
{
rs.push(activeStep.formFields[i]);
}
}
if (processValues.inputFieldList)
{
for (let i = 0; i < processValues.inputFieldList.length; i++)
{
let inputField = new QFieldMetaData(processValues.inputFieldList[i]);
rs.push(inputField);
}
}
return (rs);
};
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle moving to another step in the process - e.g., after the backend told us what screen to show next. //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (!processMetaData)
{
console.log("No process meta data yet, so returning early");
return;
}
if(! isWidget)
{
setPageHeader(processMetaData.label);
}
let newIndex = null;
if (typeof newStep === "number")
{
newIndex = newStep as number;
}
else if (typeof newStep === "string")
{
for (let i = 0; i < steps.length; i++)
{
if (steps[i].name === newStep)
{
newIndex = i;
break;
}
}
}
if (newIndex === null)
{
setProcessError(`Unknown process step ${newStep}.`);
return;
}
setActiveStepIndex(newIndex);
setOverrideOnLastStep(null);
if (steps)
{
const activeStep = steps[newIndex];
setActiveStep(activeStep);
setFormId(activeStep.name);
let dynamicFormFields: any = {};
let formValidations: any = {};
let initialValues: any = {};
///////////////////////////////////////////////////
// if this step has form fields, set up the form //
///////////////////////////////////////////////////
if (activeStep.formFields || processValues.inputFieldList)
{
let fullFieldList = getFullFieldList(activeStep, processValues);
const formData = DynamicFormUtils.getFormData(fullFieldList);
if(tableMetaData)
{
DynamicFormUtils.addPossibleValueProps(formData.dynamicFormFields, fullFieldList, tableMetaData.name, null);
}
dynamicFormFields = formData.dynamicFormFields;
formValidations = formData.formValidations;
fullFieldList.forEach((field) =>
{
initialValues[field.name] = processValues[field.name];
});
////////////////////////////////////////////////////
// disable all fields if this is a bulk edit form //
////////////////////////////////////////////////////
if (doesStepHaveComponent(activeStep, QComponentType.BULK_EDIT_FORM))
{
const newDisabledBulkEditFields: any = {};
fullFieldList.forEach((field) =>
{
newDisabledBulkEditFields[field.name] = true;
dynamicFormFields[field.name].isRequired = false;
formValidations[field.name] = null;
});
setDisabledBulkEditFields(newDisabledBulkEditFields);
}
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// define an inner function here, for adding more fields to the form, if any components have form fields built into them //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const addField = (fieldName: string, dynamicFormValue: any, initialValue: any, validation: any) =>
{
dynamicFormFields[fieldName] = dynamicFormValue;
initialValues[fieldName] = initialValue;
formValidations[fieldName] = validation;
};
if (doesStepHaveComponent(activeStep, QComponentType.VALIDATION_REVIEW_SCREEN))
{
addField("doFullValidation", {type: "radio"}, "true", null);
setOverrideOnLastStep(false);
}
if (doesStepHaveComponent(activeStep, QComponentType.GOOGLE_DRIVE_SELECT_FOLDER))
{
addField("googleDriveAccessToken", {type: "hidden", omitFromQDynamicForm: true}, "", null);
addField("googleDriveFolderId", {type: "hidden", omitFromQDynamicForm: true}, "", null);
addField("googleDriveFolderName", {type: "hidden", omitFromQDynamicForm: true}, "", null);
}
if (Object.keys(dynamicFormFields).length > 0)
{
///////////////////////////////////////////
// if there are form fields, set them up //
///////////////////////////////////////////
setFormFields(dynamicFormFields);
setInitialValues(initialValues);
setValidationScheme(Yup.object().shape(formValidations));
setValidationFunction(null);
}
else
{
/////////////////////////////////////////////////////////////////////////
// if there are no form fields, set a null validationScheme (Yup), and //
// instead use a validation function that always says true. //
/////////////////////////////////////////////////////////////////////////
setValidationScheme(null);
setValidationFunction(() => true);
}
}
}, [newStep]);
/////////////////////////////////////////////////////////////////////////////////////////////
// if there are records to load: build a record config, and set the needRecords state flag //
/////////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (activeStep && activeStep.recordListFields)
{
const newRecordConfig = buildNewRecordConfig();
activeStep.recordListFields.forEach((field) =>
{
newRecordConfig.columns.push({
field: field.name, headerName: field.label, width: 200, sortable: false,
});
});
setRecordConfig(newRecordConfig);
setNeedRecords(true);
}
}, [activeStep, rowsPerPage, pageNumber]);
/////////////////////////////////////////////////////
// handle a bulk-edit enabled-switch being checked //
/////////////////////////////////////////////////////
useEffect(() =>
{
if (activeStep && (activeStep.formFields || processValues.inputFieldList))
{
let fullFieldList = getFullFieldList(activeStep, processValues);
const newDynamicFormFields: any = {};
const newFormValidations: any = {};
fullFieldList.forEach((field) =>
{
const fieldName = field.name;
const isDisabled = disabledBulkEditFields[fieldName];
newDynamicFormFields[field.name] = DynamicFormUtils.getDynamicField(field);
newFormValidations[field.name] = DynamicFormUtils.getValidationForField(field);
if (isDisabled)
{
newDynamicFormFields[field.name].isRequired = false;
newFormValidations[field.name] = null;
}
});
DynamicFormUtils.addPossibleValueProps(newDynamicFormFields, fullFieldList, tableMetaData.name, null);
setFormFields(newDynamicFormFields);
setValidationScheme(Yup.object().shape(newFormValidations));
}
}, [disabledBulkEditFields]);
////////////////////////////////////////////////
// when we need to load records, do so, async //
////////////////////////////////////////////////
useEffect(() =>
{
if (needRecords)
{
setNeedRecords(false);
(async () =>
{
const response = await Client.getInstance().processRecords(
processName,
processUUID,
recordConfig.rowsPerPage * recordConfig.pageNo,
recordConfig.rowsPerPage,
);
const {records} = response;
setRecords(records);
/////////////////////////////////////////////////////////////////////////////////////////
// re-construct the recordConfig object, so the setState call triggers a new rendering //
/////////////////////////////////////////////////////////////////////////////////////////
const newRecordConfig = buildNewRecordConfig();
newRecordConfig.loading = false;
newRecordConfig.columns = recordConfig.columns;
newRecordConfig.rows = [];
let rowId = 0;
records.forEach((record) =>
{
const row = Object.fromEntries(record.values.entries());
row.__idForDataGridPro__ = ++rowId;
newRecordConfig.rows.push(row);
});
newRecordConfig.totalRecords = response.totalRecords;
setRecordConfig(newRecordConfig);
})();
}
}, [needRecords]);
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle a response from the server - e.g., after starting a backend job, or getting its status/result //
//////////////////////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (lastProcessResponse)
{
setLastProcessResponse(null);
setRetryMillis(INITIAL_RETRY_MILLIS);
if (lastProcessResponse instanceof QJobComplete)
{
const qJobComplete = lastProcessResponse as QJobComplete;
setJobUUID(null);
setNewStep(qJobComplete.nextStep);
setProcessValues(qJobComplete.values);
setQJobRunning(null);
if (activeStep && activeStep.recordListFields)
{
setNeedRecords(true);
}
}
else if (lastProcessResponse instanceof QJobStarted)
{
const qJobStarted = lastProcessResponse as QJobStarted;
setJobUUID(qJobStarted.jobUUID);
setNeedToCheckJobStatus(true);
}
else if (lastProcessResponse instanceof QJobRunning)
{
const qJobRunning = lastProcessResponse as QJobRunning;
setQJobRunning(qJobRunning);
setQJobRunningDate(new Date());
setNeedToCheckJobStatus(true);
}
else if (lastProcessResponse instanceof QJobError)
{
const qJobError = lastProcessResponse as QJobError;
console.log(`Got an error from the backend... ${qJobError.error} : ${qJobError.userFacingError}`);
setJobUUID(null);
if (qJobError.userFacingError)
{
setProcessError(qJobError.userFacingError, true);
}
else
{
setProcessError(qJobError.error);
}
setQJobRunning(null);
}
}
}, [lastProcessResponse]);
/////////////////////////////////////////////////////////////////////////
// while a backend async job is running, periodically check its status //
/////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (needToCheckJobStatus)
{
setNeedToCheckJobStatus(false);
if (!processUUID || !jobUUID)
{
console.log(`Missing processUUID[${processUUID}] or jobUUID[${jobUUID}], so returning without checking job status`);
return;
}
(async () =>
{
setTimeout(async () =>
{
try
{
const processResponse = await Client.getInstance().processJobStatus(
processName,
processUUID,
jobUUID,
);
setLastProcessResponse(processResponse);
}
catch (e)
{
if (e instanceof QException)
{
const qException = e as QException;
const status = Number(qException.status);
if (status !== undefined && !Number.isNaN(status) && status >= 500 && status <= 600)
{
if (retryMillis < RETRY_MAX_MILLIS)
{
console.log(`500 error, attempting to retry in ${retryMillis + retryMillis} millis`);
setRetryMillis(retryMillis * BACKOFF_AMOUNT);
setNeedToCheckJobStatus(true);
return;
}
console.log(`Retry millis [${retryMillis}] is greater or equal to the max retry limit [${RETRY_MAX_MILLIS}], giving up...`);
setProcessError("Could not connect to server");
}
}
throw (e);
}
}, retryMillis);
})();
}
}, [needToCheckJobStatus, retryMillis]);
//////////////////////////////////////////////////////////////////////////////////////////
// do the initial load of data for the process - that is, meta data, plus the init step //
// also - allow the component that contains this component to force a re-init, by //
// changing the value in the forceReInit property //
//////////////////////////////////////////////////////////////////////////////////////////
if (needInitialLoad || forceReInit != lastForcedReInit)
{
setNeedInitialLoad(false);
setLastForcedReInit(forceReInit);
(async () =>
{
const urlSearchParams = new URLSearchParams(location.search);
let queryStringPairsForInit = [];
if (urlSearchParams.get("recordIds"))
{
queryStringPairsForInit.push("recordsParam=recordIds");
queryStringPairsForInit.push(`recordIds=${urlSearchParams.get("recordIds")}`);
}
else if (urlSearchParams.get("filterJSON"))
{
queryStringPairsForInit.push("recordsParam=filterJSON");
queryStringPairsForInit.push(`filterJSON=${urlSearchParams.get("filterJSON")}`);
}
// todo once saved filters exist
//else if(urlSearchParams.get("filterId")) {
// queryStringPairsForInit.push("recordsParam=filterId");
// queryStringPairsForInit.push(`filterId=${urlSearchParams.get("filterId")}`);
// }
else if (recordIds)
{
if (typeof recordIds === "string")
{
queryStringPairsForInit.push("recordsParam=recordIds");
queryStringPairsForInit.push(`recordIds=${recordIds}`);
}
else if (recordIds instanceof QQueryFilter)
{
queryStringPairsForInit.push("recordsParam=filterJSON");
queryStringPairsForInit.push(`filterJSON=${JSON.stringify(recordIds)}`);
}
}
try
{
const qInstance = await Client.getInstance().loadMetaData();
ValueUtils.qInstance = qInstance;
setQInstance(qInstance);
}
catch (e)
{
setProcessError("Error loading process definition.");
return;
}
try
{
const processMetaData = await Client.getInstance().loadProcessMetaData(processName);
setProcessMetaData(processMetaData);
setSteps(processMetaData.frontendSteps);
if (processMetaData.tableName)
{
try
{
const tableMetaData = await Client.getInstance().loadTableMetaData(processMetaData.tableName);
setTableMetaData(tableMetaData);
}
catch (e)
{
setProcessError("Error loading process's table definition.");
return;
}
}
}
catch (e)
{
setProcessError("Error loading process definition.");
return;
}
if (defaultProcessValues)
{
for (let key in defaultProcessValues)
{
queryStringPairsForInit.push(`${key}=${encodeURIComponent(defaultProcessValues[key])}`);
}
}
try
{
const processResponse = await Client.getInstance().processInit(processName, queryStringPairsForInit.join("&"));
setProcessUUID(processResponse.processUUID);
setLastProcessResponse(processResponse);
}
catch (e)
{
setProcessError("Error initializing process.");
}
})();
}
//////////////////////////////////////////////////////////////////////////////////////////////////////
// handle the back button - todo - not really done at all //
// e.g., qqq needs to say when back is or isn't allowed, and we need to hit the backend upon backs. //
//////////////////////////////////////////////////////////////////////////////////////////////////////
const handleBack = () =>
{
setNewStep(activeStepIndex - 1);
};
//////////////////////////////////////////////////////////////////////////////////////////
// handle user submitting the form - which in qqq means moving forward from any screen. //
//////////////////////////////////////////////////////////////////////////////////////////
const handleSubmit = async (values: any, actions: any) =>
{
setFormError(null);
const formData = new FormData();
Object.keys(values).forEach((key) =>
{
formData.append(key, values[key]);
});
if (doesStepHaveComponent(activeStep, QComponentType.BULK_EDIT_FORM))
{
const bulkEditEnabledFields: string[] = [];
let fullFieldList = getFullFieldList(activeStep, processValues);
fullFieldList.forEach((field) =>
{
if (!disabledBulkEditFields[field.name])
{
bulkEditEnabledFields.push(field.name);
}
});
if (bulkEditEnabledFields.length === 0)
{
setFormError("You must edit at least one field to continue.");
return;
}
formData.append("bulkEditEnabledFields", bulkEditEnabledFields.join(","));
}
const formDataHeaders = {
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
};
setProcessValues({});
setRecords([]);
setOverrideOnLastStep(null);
setLastProcessResponse(new QJobRunning({message: "Working..."}));
setTimeout(async () =>
{
const processResponse = await Client.getInstance().processStep(
processName,
processUUID,
activeStep.name,
formData,
formDataHeaders,
);
setLastProcessResponse(processResponse);
});
};
const handleCancelClicked = () =>
{
if (isModal && closeModalHandler)
{
closeModalHandler(null, "cancelClicked");
return;
}
const pathParts = location.pathname.split(/\//);
pathParts.pop();
const path = pathParts.join("/");
navigate(path, {replace: true});
};
const mainCardStyles: any = {};
const formStyles: any = {};
mainCardStyles.minHeight = `calc(100vh - ${isModal ? 150 : 400}px)`;
if (!processError && (qJobRunning || activeStep === null) && !isModal && !isWidget)
{
mainCardStyles.background = "none";
mainCardStyles.boxShadow = "none";
}
if(isWidget)
{
mainCardStyles.background = "none";
mainCardStyles.boxShadow = "none";
mainCardStyles.minHeight = "";
mainCardStyles.alignItems = "stretch";
mainCardStyles.flexGrow = 1;
mainCardStyles.display = "flex";
formStyles.display = "flex";
formStyles.flexGrow = 1;
}
let nextButtonLabel = "Next";
let nextButtonIcon = "arrow_forward";
if (overrideOnLastStep !== null)
{
if (overrideOnLastStep)
{
nextButtonLabel = "Submit";
nextButtonIcon = "check";
}
}
else if (onLastStep)
{
nextButtonLabel = "Submit";
nextButtonIcon = "check";
}
const form = (
<Formik
enableReinitialize
initialValues={initialValues}
validationSchema={validationScheme}
validation={validationFunction}
onSubmit={handleSubmit}
>
{({
values, errors, touched, isSubmitting, setFieldValue,
}) => (
<Form style={formStyles} id={formId} autoComplete="off">
<Card sx={mainCardStyles}>
{
!isWidget && (
<Box mx={2} mt={-3}>
<Stepper activeStep={activeStepIndex} alternativeLabel>
{steps.map((step) => (
<Step key={step.name}>
<StepLabel>{step.label}</StepLabel>
</Step>
))}
</Stepper>
</Box>
)
}
<Box p={3}>
<Box>
{/***************************************************************************
** step content - e.g., the appropriate form or other screen for the step **
***************************************************************************/}
{getDynamicStepContent(
activeStepIndex,
activeStep,
{
values,
touched,
formFields,
errors,
},
processError,
processValues,
recordConfig,
setFieldValue,
)}
{/********************************
** back &| next/submit buttons **
********************************/}
<Box mt={6} width="100%" display="flex" justifyContent="space-between">
{true || activeStepIndex === 0 ? (
<Box />
) : (
<MDButton variant="gradient" color="light" onClick={handleBack}>back</MDButton>
)}
{processError || qJobRunning || !activeStep ? (
<Box />
) : (
<>
{formError && (
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" align="right" fullWidth>
{formError}
</MDTypography>
)}
{
noMoreSteps && <QCancelButton
onClickHandler={handleCancelClicked}
label={isModal ? "Close" : "Return"}
iconName={isModal ? "cancel" : "arrow_back"}
disabled={isSubmitting} />
}
{
!noMoreSteps && (
<Box component="div" py={3}>
<Grid container justifyContent="flex-end" spacing={3}>
{
! isWidget && (
<QCancelButton onClickHandler={handleCancelClicked} disabled={isSubmitting} />
)
}
<QSubmitButton label={nextButtonLabel} iconName={nextButtonIcon} disabled={isSubmitting} />
</Grid>
</Box>
)
}
</>
)}
</Box>
</Box>
</Box>
</Card>
</Form>
)}
</Formik>
);
const body = (
<Box py={3} mb={20}>
<Grid container justifyContent="center" alignItems="center" sx={{height: "100%", mt: 8}}>
<Grid item xs={12} lg={10} xl={8}>
{form}
</Grid>
</Grid>
</Box>
);
if (isModal)
{
return (
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
{body}
</Box>
);
}
else if (isWidget)
{
return (
<Box sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px"}}>
{form}
</Box>
);
}
{
return (
<BaseLayout>
{body}
</BaseLayout>
);
}
}
ProcessRun.defaultProps = {
process: null,
defaultProcessValues: {},
isModal: false,
isWidget: false,
recordIds: null,
closeModalHandler: null,
forceReInit: 0
};
export default ProcessRun;