/* * 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 {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability"; import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {Alert} from "@mui/material"; import Avatar from "@mui/material/Avatar"; import Box from "@mui/material/Box"; import Card from "@mui/material/Card"; import Grid from "@mui/material/Grid"; import Icon from "@mui/material/Icon"; import {Form, Formik, useFormikContext} from "formik"; import React, {useContext, useEffect, useReducer, useState} from "react"; import {useLocation, useNavigate, useParams} from "react-router-dom"; import * as Yup from "yup"; import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import QDynamicForm from "qqq/components/forms/DynamicForm"; import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils"; import MDTypography from "qqq/components/legacy/MDTypography"; import HelpContent from "qqq/components/misc/HelpContent"; import QRecordSidebar from "qqq/components/misc/RecordSidebar"; import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; interface Props { id?: string; isModal: boolean; table?: QTableMetaData; closeModalHandler?: (event: object, reason: string) => void; defaultValues: { [key: string]: string }; disabledFields: { [key: string]: boolean } | string[]; isCopy?: boolean; } EntityForm.defaultProps = { id: null, isModal: false, table: null, closeModalHandler: null, defaultValues: {}, disabledFields: {}, isCopy: false }; function EntityForm(props: Props): JSX.Element { const qController = Client.getInstance(); const tableNameParam = useParams().tableName; const tableName = props.table === null ? tableNameParam : props.table.name; const {accentColor} = useContext(QContext); const [formTitle, setFormTitle] = useState(""); const [validations, setValidations] = useState({}); const [initialValues, setInitialValues] = useState({} as { [key: string]: any }); const [formFields, setFormFields] = useState(null as Map); const [t1section, setT1Section] = useState(null as QTableSection); const [t1sectionName, setT1SectionName] = useState(null as string); const [nonT1Sections, setNonT1Sections] = useState([] as QTableSection[]); const [alertContent, setAlertContent] = useState(""); const [warningContent, setWarningContent] = useState(""); const [asyncLoadInited, setAsyncLoadInited] = useState(false); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); const [record, setRecord] = useState(null as QRecord); const [tableSections, setTableSections] = useState(null as QTableSection[]); const [, forceUpdate] = useReducer((x) => x + 1, 0); const [notAllowedError, setNotAllowedError] = useState(null as string); const {pageHeader, setPageHeader} = useContext(QContext); const navigate = useNavigate(); const location = useLocation(); //////////////////////////////////////////////////////////////////// // first take defaultValues and disabledFields from props // // but, also allow them to be sent in the hash, in the format of: // // #/defaultValues={jsonName=value}/disabledFields={jsonName=any} // //////////////////////////////////////////////////////////////////// let defaultValues = props.defaultValues; let disabledFields = props.disabledFields; const hashParts = location.hash.split("/"); for (let i = 0; i < hashParts.length; i++) { try { const parts = hashParts[i].split("=") if (parts.length > 1 && parts[0] == "defaultValues") { defaultValues = JSON.parse(decodeURIComponent(parts[1])) as { [key: string]: any }; } if (parts.length > 1 && parts[0] == "disabledFields") { disabledFields = JSON.parse(decodeURIComponent(parts[1])) as { [key: string]: any }; } } catch (e) {} } function getFormSection(values: any, touched: any, formFields: any, errors: any): JSX.Element { const formData: any = {}; formData.values = values; formData.touched = touched; formData.errors = errors; formData.formFields = {}; for (let i = 0; i < formFields.length; i++) { formData.formFields[formFields[i].name] = formFields[i]; if (formFields[i].possibleValueProps) { formFields[i].possibleValueProps.otherValues = formFields[i].possibleValueProps.otherValues ?? new Map(); Object.keys(formFields).forEach((otherKey) => { formFields[i].possibleValueProps.otherValues.set(otherKey, values[otherKey]); }); } } if (!Object.keys(formFields).length) { return
Loading...
; } const helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] return ; } if (!asyncLoadInited) { setAsyncLoadInited(true); (async () => { const tableMetaData = await qController.loadTableMetaData(tableName); setTableMetaData(tableMetaData); ///////////////////////////////////////////////// // define the sections, e.g., for the left-bar // ///////////////////////////////////////////////// const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()]); setTableSections(tableSections); const fieldArray = [] as QFieldMetaData[]; const sortedKeys = [...tableMetaData.fields.keys()].sort(); sortedKeys.forEach((key) => { const fieldMetaData = tableMetaData.fields.get(key); fieldArray.push(fieldMetaData); }); ///////////////////////////////////////////////////////////////////////////////////////// // if doing an edit or copy, fetch the record and pre-populate the form values from it // ///////////////////////////////////////////////////////////////////////////////////////// let record: QRecord = null; let defaultDisplayValues = new Map(); if (props.id !== null) { record = await qController.get(tableName, props.id); setRecord(record); const titleVerb = props.isCopy ? "Copy" : "Edit"; setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`); if (!props.isModal) { setPageHeader(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`); } tableMetaData.fields.forEach((fieldMetaData, key) => { if (props.isCopy && fieldMetaData.name == tableMetaData.primaryKeyField) { return; } initialValues[key] = record.values.get(key); }); ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // these checks are only for updating records, if copying, it is actually an insert, which is checked after this block // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(! props.isCopy) { if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE)) { setNotAllowedError("Records may not be edited in this table"); } else if (!tableMetaData.editPermission) { setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`); } } } else { /////////////////////////////////////////// // else handle preparing to do an insert // /////////////////////////////////////////// setFormTitle(`Creating New ${tableMetaData?.label}`); if (!props.isModal) { setPageHeader(`Creating New ${tableMetaData?.label}`); } //////////////////////////////////////////////////////////////////////////////////////////////// // if default values were supplied for a new record, then populate initialValues, for formik. // //////////////////////////////////////////////////////////////////////////////////////////////// for (let i = 0; i < fieldArray.length; i++) { const fieldMetaData = fieldArray[i]; const fieldName = fieldMetaData.name; const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue; if (defaultValue) { initialValues[fieldName] = defaultValue; /////////////////////////////////////////////////////////////////////////////////////////// // we need to set the initialDisplayValue for possible value fields with a default value // // so, look them up here now if needed // /////////////////////////////////////////////////////////////////////////////////////////// if (fieldMetaData.possibleValueSourceName) { const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]]); if (results && results.length > 0) { defaultDisplayValues.set(fieldName, results[0].label); } } } } } ////////////////////////////////////// // check capabilities & permissions // ////////////////////////////////////// if (props.isCopy || !props.id) { if (!tableMetaData.capabilities.has(Capability.TABLE_INSERT)) { setNotAllowedError("Records may not be created in this table"); } else if (!tableMetaData.insertPermission) { setNotAllowedError(`You do not have permission to create ${tableMetaData.label} records`); } } else { if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE)) { setNotAllowedError("Records may not be edited in this table"); } else if (!tableMetaData.editPermission) { setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`); } } ///////////////////////////////////////////////////////////////////// // make sure all initialValues are properly formatted for the form // ///////////////////////////////////////////////////////////////////// for (let i = 0; i < fieldArray.length; i++) { const fieldMetaData = fieldArray[i]; if (fieldMetaData.type == QFieldType.DATE_TIME && initialValues[fieldMetaData.name]) { initialValues[fieldMetaData.name] = ValueUtils.formatDateTimeValueForForm(initialValues[fieldMetaData.name]); } } setInitialValues(initialValues); ///////////////////////////////////////////////////////// // get formField and formValidation objects for Formik // ///////////////////////////////////////////////////////// const { dynamicFormFields, formValidations, } = DynamicFormUtils.getFormData(fieldArray); DynamicFormUtils.addPossibleValueProps(dynamicFormFields, fieldArray, tableName, null, record ? record.displayValues : defaultDisplayValues); if(disabledFields) { if(Array.isArray(disabledFields)) { for (let i = 0; i < disabledFields.length; i++) { dynamicFormFields[disabledFields[i]].isEditable = false; } } else { for (let fieldName in disabledFields) { dynamicFormFields[fieldName].isEditable = false; } } } ///////////////////////////////////// // group the formFields by section // ///////////////////////////////////// const dynamicFormFieldsBySection = new Map(); let t1sectionName; let t1section; const nonT1Sections: QTableSection[] = []; for (let i = 0; i < tableSections.length; i++) { const section = tableSections[i]; const sectionDynamicFormFields: any[] = []; if (section.isHidden || !section.fieldNames) { continue; } for (let j = 0; j < section.fieldNames.length; j++) { const fieldName = section.fieldNames[j]; const field = tableMetaData.fields.get(fieldName); if(!field) { console.log(`Omitting un-found field ${fieldName} from form`); continue; } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if id !== null (and we're not copying) - means we're on the edit screen -- show all fields on the edit screen. // // || (or) we're on the insert screen in which case, only show editable fields. // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if ((props.id !== null && !props.isCopy) || field.isEditable) { sectionDynamicFormFields.push(dynamicFormFields[fieldName]); } } if (sectionDynamicFormFields.length === 0) { //////////////////////////////////////////////////////////////////////////////////////////////// // in case there are no active fields in this section, remove it from the tableSections array // //////////////////////////////////////////////////////////////////////////////////////////////// tableSections.splice(i, 1); i--; continue; } else { dynamicFormFieldsBySection.set(section.name, sectionDynamicFormFields); } ////////////////////////////////////// // capture the tier1 section's name // ////////////////////////////////////// if (section.tier === "T1") { t1sectionName = section.name; t1section = section; } else { nonT1Sections.push(section); } } setT1SectionName(t1sectionName); setT1Section(t1section); setNonT1Sections(nonT1Sections); setFormFields(dynamicFormFieldsBySection); setValidations(Yup.object().shape(formValidations)); forceUpdate(); })(); } const handleCancelClicked = () => { /////////////////////////////////////////////////////////////////////////////////////// // todo - we might have rather just done a navigate(-1) (to keep history clean) // // but if the user used the anchors on the page, this doesn't effectively cancel... // // what we have here pushed a new history entry (I think?), so could be better // /////////////////////////////////////////////////////////////////////////////////////// if (props.id !== null && props.isCopy) { const path = `${location.pathname.replace(/\/copy$/, "")}`; navigate(path, {replace: true}); } else if (props.id !== null) { const path = `${location.pathname.replace(/\/edit$/, "")}`; navigate(path, {replace: true}); } else { const path = `${location.pathname.replace(/\/create$/, "")}`; navigate(path, {replace: true}); } }; const handleSubmit = async (values: any, actions: any) => { actions.setSubmitting(true); await (async () => { ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // we will be manipulating the values sent to the backend, so clone values so they remained unchanged for the form widgets // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const valuesToPost = JSON.parse(JSON.stringify(values)); for(let fieldName of tableMetaData.fields.keys()) { const fieldMetaData = tableMetaData.fields.get(fieldName); ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // (1) convert date-time fields from user's time-zone into UTC // // (2) if there's an initial value which matches the value (e.g., from the form), then remove that field // // from the set of values that we'll submit to the backend. This is to deal with the fact that our // // date-times in the UI (e.g., the form field) only go to the minute - so they kinda always end up // // changing from, say, 12:15:30 to just 12:15:00... this seems to get around that, for cases when the // // user didn't change the value in the field (but if the user did change the value, then we will submit it) // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(fieldMetaData.type === QFieldType.DATE_TIME && valuesToPost[fieldName]) { console.log(`DateTime ${fieldName}: Initial value: [${initialValues[fieldName]}] -> [${valuesToPost[fieldName]}]`) if (initialValues[fieldName] == valuesToPost[fieldName]) { console.log(" - Is the same, so, deleting from the post"); delete (valuesToPost[fieldName]); } else { valuesToPost[fieldName] = ValueUtils.frontendLocalZoneDateTimeStringToUTCStringForBackend(valuesToPost[fieldName]); } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////// // for BLOB fields, there are 3 possible cases: // // 1) they are a File object - in which case, cool, send them through to the backend to have bytes stored. // // 2) they are null - in which case, cool, send them through to the backend to be set to null. // // 3) they are a String, which is their URL path to download them... in that case, don't submit them to // // the backend at all, so they'll stay what they were. do that by deleting them from the values object here. // //////////////////////////////////////////////////////////////////////////////////////////////////////////////// if(fieldMetaData.type === QFieldType.BLOB) { if(typeof valuesToPost[fieldName] === "string") { console.log(`${fieldName} value was a string, so, we're deleting it from the values array, to not submit it to the backend, to not change it.`); delete(valuesToPost[fieldName]); } else { valuesToPost[fieldName] = values[fieldName]; } } } if (props.id !== null && !props.isCopy) { // todo - audit that it's a dupe await qController .update(tableName, props.id, valuesToPost) .then((record) => { if (props.isModal) { props.closeModalHandler(null, "recordUpdated"); } else { const path = location.pathname.replace(/\/edit$/, ""); navigate(path, {state: {updateSuccess: true}}); } }) .catch((error) => { console.log("Caught:"); console.log(error); if(error.message.toLowerCase().startsWith("warning")) { const path = location.pathname.replace(/\/edit$/, ""); navigate(path, {state: {updateSuccess: true, warning: error.message}}); } else { setAlertContent(error.message); HtmlUtils.autoScroll(0); } }); } else { await qController .create(tableName, valuesToPost) .then((record) => { if (props.isModal) { props.closeModalHandler(null, "recordCreated"); } else { const path = props.isCopy ? location.pathname.replace(new RegExp(`/${props.id}/copy$`), "/" + record.values.get(tableMetaData.primaryKeyField)) : location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); navigate(path, {state: {createSuccess: true}}); } }) .catch((error) => { if(error.message.toLowerCase().startsWith("warning")) { const path = props.isCopy ? location.pathname.replace(new RegExp(`/${props.id}/copy$`), "/" + record.values.get(tableMetaData.primaryKeyField)) : location.pathname.replace(/create$/, record.values.get(tableMetaData.primaryKeyField)); navigate(path, {state: {createSuccess: true, warning: error.message}}); } else { setAlertContent(error.message); HtmlUtils.autoScroll(0); } }); } })(); }; const formId = props.id != null ? `edit-${tableMetaData?.name}-form` : `create-${tableMetaData?.name}-form`; let body; const getSectionHelp = (section: QTableSection) => { const helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] const formattedHelpContent = ; return formattedHelpContent && ( {formattedHelpContent} ) } if (notAllowedError) { body = ( {notAllowedError} {props.isModal && } ); } else { const cardElevation = props.isModal ? 3 : 0; body = ( { (alertContent || warningContent) && {alertContent ? ( setAlertContent(null)}>{alertContent} ) : ("")} {warningContent ? ( setWarningContent(null)}>{warningContent} ) : ("")} } { !props.isModal && } {({ values, errors, touched, isSubmitting, }) => (
{tableMetaData?.iconName} {formTitle} {t1section && getSectionHelp(t1section)} { t1sectionName && formFields ? ( {getFormSection(values, touched, formFields.get(t1sectionName), errors)} ) : null } {formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => ( {section.label} {getSectionHelp(section)} {getFormSection(values, touched, formFields.get(section.name), errors)} )) : null} )}
); } if (props.isModal) { return ( {body} ); } else { return (body); } } function ScrollToFirstError(): JSX.Element { const {submitCount, isValid} = useFormikContext() useEffect(() => { ///////////////////////////////////////////////////////////////////////////// // Wrap the code in setTimeout to make sure it runs after the DOM has been // // updated and has the error message elements. // ///////////////////////////////////////////////////////////////////////////// setTimeout(() => { //////////////////////////////////////// // Only run on submit or if not valid // //////////////////////////////////////// if (submitCount === 0 || isValid) { return; } ////////////////////////////////// // Find the first error message // ////////////////////////////////// const errorMessageSelector = "[data-field-error]"; const firstErrorMessage = document.querySelector(errorMessageSelector); if (!firstErrorMessage) { console.warn(`Form failed validation but no error field was found with selector: ${errorMessageSelector}`); return; } firstErrorMessage.scrollIntoView({block: "center"}); }, 100) }, [submitCount, isValid]) return null; } export default EntityForm;