diff --git a/package.json b/package.json index 339b4a0..cea87a5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "react-ace": "10.1.0", "react-chartjs-2": "3.0.4", "react-cookie": "4.1.1", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", "react-dom": "18.0.0", "react-ga4": "2.1.0", "react-github-btn": "1.2.1", diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java index d40cdb0..96ff230 100644 --- a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/MaterialDashboardTableMetaData.java @@ -22,6 +22,7 @@ package com.kingsrook.qqq.frontend.materialdashboard.model.metadata; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -30,6 +31,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule; /******************************************************************************* @@ -37,8 +40,11 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils; *******************************************************************************/ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData { + public static final String TYPE = "materialDashboard"; + private List> gotoFieldNames; - private List defaultQuickFilterFieldNames; + private List defaultQuickFilterFieldNames; + private List fieldRules; /******************************************************************************* @@ -58,10 +64,25 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData @Override public String getType() { - return ("materialDashboard"); + return (TYPE); } + /******************************************************************************* + ** + *******************************************************************************/ + public static MaterialDashboardTableMetaData ofOrWithNew(QTableMetaData table) + { + MaterialDashboardTableMetaData supplementalMetaData = (MaterialDashboardTableMetaData) table.getSupplementalMetaData(TYPE); + if(supplementalMetaData == null) + { + supplementalMetaData = new MaterialDashboardTableMetaData(); + table.withSupplementalMetaData(supplementalMetaData); + } + + return (supplementalMetaData); + } + /******************************************************************************* ** Getter for gotoFieldNames @@ -110,6 +131,22 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: "); } validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: "); + + for(FieldRule fieldRule : CollectionUtils.nonNullList(fieldRules)) + { + qInstanceValidator.assertCondition(fieldRule.getTrigger() != null, prefix + "has a fieldRule without a trigger"); + qInstanceValidator.assertCondition(fieldRule.getAction() != null, prefix + "has a fieldRule without an action"); + + if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getSourceField()), prefix + "has a fieldRule without a sourceField")) + { + qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getSourceField()), prefix + "has a fieldRule with an unrecognized sourceField: " + fieldRule.getSourceField()); + } + + if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getTargetField()), prefix + "has a fieldRule without a targetField")) + { + qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getTargetField()), prefix + "has a fieldRule with an unrecognized targetField: " + fieldRule.getTargetField()); + } + } } @@ -124,7 +161,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData { if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName)) { - qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + " has a duplicated field name: " + fieldName); + qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + "has a duplicated field name: " + fieldName); usedNames.add(fieldName); } } @@ -161,4 +198,51 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData return (this); } + + /******************************************************************************* + ** Getter for fieldRules + *******************************************************************************/ + public List getFieldRules() + { + return (this.fieldRules); + } + + + + /******************************************************************************* + ** Setter for fieldRules + *******************************************************************************/ + public void setFieldRules(List fieldRules) + { + this.fieldRules = fieldRules; + } + + + + /******************************************************************************* + ** Fluent setter for fieldRules + *******************************************************************************/ + public MaterialDashboardTableMetaData withFieldRules(List fieldRules) + { + this.fieldRules = fieldRules; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for fieldRules + *******************************************************************************/ + public MaterialDashboardTableMetaData withFieldRule(FieldRule fieldRule) + { + if(this.fieldRules == null) + { + this.fieldRules = new ArrayList<>(); + } + + this.fieldRules.add(fieldRule); + + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRule.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRule.java new file mode 100644 index 0000000..a4722eb --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRule.java @@ -0,0 +1,165 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules; + + +import java.io.Serializable; + + +/******************************************************************************* + ** definition of rules for how UI fields should behave. + ** + ** e.g., one field being changed causing different things to be needed in another + ** field. + *******************************************************************************/ +public class FieldRule implements Serializable +{ + private FieldRuleTrigger trigger; + private String sourceField; + private FieldRuleAction action; + private String targetField; + + + + /******************************************************************************* + ** Getter for trigger + *******************************************************************************/ + public FieldRuleTrigger getTrigger() + { + return (this.trigger); + } + + + + /******************************************************************************* + ** Setter for trigger + *******************************************************************************/ + public void setTrigger(FieldRuleTrigger trigger) + { + this.trigger = trigger; + } + + + + /******************************************************************************* + ** Fluent setter for trigger + *******************************************************************************/ + public FieldRule withTrigger(FieldRuleTrigger trigger) + { + this.trigger = trigger; + return (this); + } + + + + /******************************************************************************* + ** Getter for sourceField + *******************************************************************************/ + public String getSourceField() + { + return (this.sourceField); + } + + + + /******************************************************************************* + ** Setter for sourceField + *******************************************************************************/ + public void setSourceField(String sourceField) + { + this.sourceField = sourceField; + } + + + + /******************************************************************************* + ** Fluent setter for sourceField + *******************************************************************************/ + public FieldRule withSourceField(String sourceField) + { + this.sourceField = sourceField; + return (this); + } + + + + /******************************************************************************* + ** Getter for action + *******************************************************************************/ + public FieldRuleAction getAction() + { + return (this.action); + } + + + + /******************************************************************************* + ** Setter for action + *******************************************************************************/ + public void setAction(FieldRuleAction action) + { + this.action = action; + } + + + + /******************************************************************************* + ** Fluent setter for action + *******************************************************************************/ + public FieldRule withAction(FieldRuleAction action) + { + this.action = action; + return (this); + } + + + + /******************************************************************************* + ** Getter for targetField + *******************************************************************************/ + public String getTargetField() + { + return (this.targetField); + } + + + + /******************************************************************************* + ** Setter for targetField + *******************************************************************************/ + public void setTargetField(String targetField) + { + this.targetField = targetField; + } + + + + /******************************************************************************* + ** Fluent setter for targetField + *******************************************************************************/ + public FieldRule withTargetField(String targetField) + { + this.targetField = targetField; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleAction.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleAction.java new file mode 100644 index 0000000..cc112c0 --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleAction.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules; + + +/******************************************************************************* + ** possible actions associated with field rules + *******************************************************************************/ +public enum FieldRuleAction +{ + CLEAR_TARGET_FIELD +} diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleTrigger.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleTrigger.java new file mode 100644 index 0000000..53d747e --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/model/metadata/fieldrules/FieldRuleTrigger.java @@ -0,0 +1,31 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules; + + +/******************************************************************************* + ** possible triggers associated with field rules + *******************************************************************************/ +public enum FieldRuleTrigger +{ + ON_CHANGE +} diff --git a/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/savedreports/SavedReportTableFrontendMaterialDashboardEnricher.java b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/savedreports/SavedReportTableFrontendMaterialDashboardEnricher.java new file mode 100644 index 0000000..5827812 --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/materialdashboard/savedreports/SavedReportTableFrontendMaterialDashboardEnricher.java @@ -0,0 +1,59 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.frontend.materialdashboard.savedreports; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardTableMetaData; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleAction; +import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleTrigger; + + +/******************************************************************************* + ** Add frontend material dashboard enhacements to saved report table + *******************************************************************************/ +public class SavedReportTableFrontendMaterialDashboardEnricher +{ + + /******************************************************************************* + ** + *******************************************************************************/ + public static void enrich(QTableMetaData tableMetaData) + { + MaterialDashboardTableMetaData materialDashboardTableMetaData = MaterialDashboardTableMetaData.ofOrWithNew(tableMetaData); + + ///////////////////////////////////////////////////////////////////////// + // make changes to the tableName field clear the value in these fields // + ///////////////////////////////////////////////////////////////////////// + for(String targetField : List.of("queryFilterJson", "columnsJson", "pivotTableJson")) + { + materialDashboardTableMetaData.withFieldRule(new FieldRule() + .withSourceField("tableName") + .withTrigger(FieldRuleTrigger.ON_CHANGE) + .withAction(FieldRuleAction.CLEAR_TARGET_FIELD) + .withTargetField(targetField)); + } + } + +} diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index af83903..cc7cdce 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -43,7 +43,10 @@ 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 PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget"; import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget"; +import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget"; +import {FieldRule, FieldRuleAction, FieldRuleTrigger} from "qqq/models/fields/FieldRules"; import HtmlUtils from "qqq/utils/HtmlUtils"; import Client from "qqq/utils/qqq/Client"; import TableUtils from "qqq/utils/qqq/TableUtils"; @@ -63,7 +66,7 @@ interface Props disabledFields: { [key: string]: boolean } | string[]; isCopy?: boolean; onSubmitCallback?: (values: any) => void; - overrideHeading?: string + overrideHeading?: string; } EntityForm.defaultProps = { @@ -77,6 +80,15 @@ EntityForm.defaultProps = { onSubmitCallback: null, }; + +//////////////////////////////////////////////////////////////////////////// +// define a function that we can make referenes to, which we'll overwrite // +// with formik's setFieldValue function, once we're inside formik. // +//////////////////////////////////////////////////////////////////////////// +let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void => +{ +} + function EntityForm(props: Props): JSX.Element { const qController = Client.getInstance(); @@ -97,17 +109,21 @@ function EntityForm(props: Props): JSX.Element const [asyncLoadInited, setAsyncLoadInited] = useState(false); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); + const [fieldRules, setFieldRules] = useState([] as FieldRule[]); const [metaData, setMetaData] = useState(null as QInstance); const [record, setRecord] = useState(null as QRecord); const [tableSections, setTableSections] = useState(null as QTableSection[]); - const [renderedWidgetSections, setRenderedWidgetSections] = useState({} as {[name: string]: JSX.Element}); - const [childListWidgetData, setChildListWidgetData] = useState({} as {[name: string]: ChildRecordListData}); + const [renderedWidgetSections, setRenderedWidgetSections] = useState({} as { [name: string]: JSX.Element }); + const [childListWidgetData, setChildListWidgetData] = useState({} as { [name: string]: ChildRecordListData }); const [, forceUpdate] = useReducer((x) => x + 1, 0); const [showEditChildForm, setShowEditChildForm] = useState(null as any); const [notAllowedError, setNotAllowedError] = useState(null as string); + const [formValuesJSON, setFormValuesJSON] = useState(""); + const [formValues, setFormValues] = useState({} as {[name: string]: any}); + const {pageHeader, setPageHeader} = useContext(QContext); const navigate = useNavigate(); @@ -128,23 +144,28 @@ function EntityForm(props: Props): JSX.Element { try { - const parts = hashParts[i].split("=") - if (parts.length > 1 && parts[0] == "defaultValues") + const parts = hashParts[i].split("="); + if (parts.length > 1) { - defaultValues = JSON.parse(decodeURIComponent(parts[1])) as { [key: string]: any }; - } + const name = parts[0].replace(/^#/, ""); + const value = parts[1]; + if (name == "defaultValues") + { + defaultValues = JSON.parse(decodeURIComponent(value)) as { [key: string]: any }; + } - if (parts.length > 1 && parts[0] == "disabledFields") - { - disabledFields = JSON.parse(decodeURIComponent(parts[1])) as { [key: string]: any }; + if (name == "disabledFields") + { + disabledFields = JSON.parse(decodeURIComponent(value)) as { [key: string]: any }; + } } } catch (e) - {} + { + } } - /******************************************************************************* ** *******************************************************************************/ @@ -153,7 +174,7 @@ function EntityForm(props: Props): JSX.Element let defaultValues = widgetData.defaultValuesForNewChildRecords; let disabledFields = widgetData.disabledFieldsForNewChildRecords; - if(!disabledFields) + if (!disabledFields) { disabledFields = widgetData.defaultValuesForNewChildRecords; } @@ -170,7 +191,7 @@ function EntityForm(props: Props): JSX.Element let defaultValues = widgetData.queryOutput.records[rowIndex].values; let disabledFields = widgetData.disabledFieldsForNewChildRecords; - if(!disabledFields) + if (!disabledFields) { disabledFields = widgetData.defaultValuesForNewChildRecords; } @@ -234,16 +255,16 @@ function EntityForm(props: Props): JSX.Element const metaData = await qController.loadMetaData(); const widgetMetaData = metaData.widgets.get(widgetName); - const newChildListWidgetData: {[name: string]: ChildRecordListData} = Object.assign({}, childListWidgetData) - if(!newChildListWidgetData[widgetName].queryOutput.records) + const newChildListWidgetData: { [name: string]: ChildRecordListData } = Object.assign({}, childListWidgetData); + if (!newChildListWidgetData[widgetName].queryOutput.records) { newChildListWidgetData[widgetName].queryOutput.records = []; } - switch(action) + switch (action) { case "insert": - newChildListWidgetData[widgetName].queryOutput.records.push({values: values}) + newChildListWidgetData[widgetName].queryOutput.records.push({values: values}); break; case "edit": newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values}; @@ -255,7 +276,7 @@ function EntityForm(props: Props): JSX.Element newChildListWidgetData[widgetName].totalRows = newChildListWidgetData[widgetName].queryOutput.records.length; setChildListWidgetData(newChildListWidgetData); - const newRenderedWidgetSections = Object.assign({}, renderedWidgetSections) + const newRenderedWidgetSections = Object.assign({}, renderedWidgetSections); newRenderedWidgetSections[widgetName] = getWidgetSection(widgetMetaData, newChildListWidgetData[widgetName]); setRenderedWidgetSections(newRenderedWidgetSections); forceUpdate(); @@ -264,6 +285,21 @@ function EntityForm(props: Props): JSX.Element } + /******************************************************************************* + ** Watch the record values - if they change, re-render widgets + *******************************************************************************/ + useEffect(() => + { + const newRenderedWidgetSections: {[name: string]: JSX.Element} = {}; + for (let widgetName in renderedWidgetSections) + { + const widgetMetaData = metaData.widgets.get(widgetName); + newRenderedWidgetSections[widgetName] = getWidgetSection(widgetMetaData, childListWidgetData[widgetName]); + } + setRenderedWidgetSections(newRenderedWidgetSections); + }, [formValuesJSON]); + + /******************************************************************************* ** render a section (full of fields) as a form *******************************************************************************/ @@ -290,14 +326,14 @@ function EntityForm(props: Props): JSX.Element if (!Object.keys(formFields).length) { - return
Error: No form fields in section {section.name}
; + return
Error: No form fields in section {section.name}
; } - const helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] + const helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"]; - if(omitWrapper) + if (omitWrapper) { - return + return ; } return @@ -310,7 +346,21 @@ function EntityForm(props: Props): JSX.Element - + ; + } + + + + /******************************************************************************* + ** if we have a widget that wants to set form-field values, they can take this + ** function in as a callback, and then call it with their values. + *******************************************************************************/ + function setFormFieldValuesFromWidget(values: {[name: string]: any}) + { + for (let key in values) + { + formikSetFieldValueFunction(key, values[key]); + } } @@ -319,20 +369,47 @@ function EntityForm(props: Props): JSX.Element *******************************************************************************/ function getWidgetSection(widgetMetaData: QWidgetMetaData, widgetData: any): JSX.Element { - widgetData.viewAllLink = null; - widgetMetaData.showExportButton = false; + if(widgetMetaData.type == "childRecordList") + { + widgetData.viewAllLink = null; + widgetMetaData.showExportButton = false; - return openAddChildRecord(widgetMetaData.name, widgetData)} - editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)} - deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)} - /> + return openAddChildRecord(widgetMetaData.name, widgetData)} + editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)} + deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)} + />; + } + + if(widgetMetaData.type == "reportSetup") + { + return + } + + if(widgetMetaData.type == "pivotTableSetup") + { + return + } + + return (Unsupported widget type: {widgetMetaData.type}) } @@ -341,17 +418,43 @@ function EntityForm(props: Props): JSX.Element *******************************************************************************/ function renderSection(section: QTableSection, values: FormikValues | Value, touched: FormikTouched | Value, formFields: Map, errors: FormikErrors | Value) { - if(section.fieldNames && section.fieldNames.length > 0) + if (section.fieldNames && section.fieldNames.length > 0) { return getFormSection(section, values, touched, formFields.get(section.name), errors); } else { - return renderedWidgetSections[section.widgetName] ?? Loading {section.label}... + return renderedWidgetSections[section.widgetName] ?? Loading {section.label}...; } } + /******************************************************************************* + ** + *******************************************************************************/ + function setupFieldRules(tableMetaData: QTableMetaData) + { + const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard"); + if(!mdbMetaData) + { + return; + } + + if(mdbMetaData.fieldRules) + { + const newFieldRules: FieldRule[] = []; + for (let i = 0; i < mdbMetaData.fieldRules.length; i++) + { + newFieldRules.push(mdbMetaData.fieldRules[i]); + } + setFieldRules(newFieldRules); + } + } + + + ////////////////// + // initial load // + ////////////////// if (!asyncLoadInited) { setAsyncLoadInited(true); @@ -361,6 +464,8 @@ function EntityForm(props: Props): JSX.Element setTableMetaData(tableMetaData); recordAnalytics({location: window.location, title: (props.isCopy ? "Copy" : props.id ? "Edit" : "New") + ": " + tableMetaData.label}); + setupFieldRules(tableMetaData); + const metaData = await qController.loadMetaData(); setMetaData(metaData); @@ -369,7 +474,21 @@ function EntityForm(props: Props): JSX.Element ///////////////////////////////////////////////// const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) => { - return section.widgetName && metaData.widgets.get(section.widgetName)?.type == "childRecordList" && metaData.widgets.get(section.widgetName)?.defaultValues?.has("manageAssociationName") + const widget = metaData.widgets.get(section.widgetName); + if(widget) + { + if(widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName")) + { + return (true); + } + + if(widget.type == "reportSetup" || widget.type == "pivotTableSetup") + { + return (true); + } + } + + return (false); }); setTableSections(tableSections); @@ -412,7 +531,7 @@ function EntityForm(props: Props): JSX.Element ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // these checks are only for updating records, if copying, it is actually an insert, which is checked after this block // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(! props.isCopy) + if (!props.isCopy) { if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE)) { @@ -468,7 +587,7 @@ function EntityForm(props: Props): JSX.Element /////////////////////////////////////////////////// // if an override heading was passed in, use it. // /////////////////////////////////////////////////// - if(props.overrideHeading) + if (props.overrideHeading) { setFormTitle(props.overrideHeading); if (!props.isModal) @@ -533,8 +652,8 @@ function EntityForm(props: Props): JSX.Element let t1sectionName; let t1section; const nonT1Sections: QTableSection[] = []; - const newRenderedWidgetSections: {[name: string]: JSX.Element} = {}; - const newChildListWidgetData: {[name: string]: ChildRecordListData} = {}; + const newRenderedWidgetSections: { [name: string]: JSX.Element } = {}; + const newChildListWidgetData: { [name: string]: ChildRecordListData } = {}; for (let i = 0; i < tableSections.length; i++) { @@ -547,12 +666,6 @@ function EntityForm(props: Props): JSX.Element } const hasFields = section.fieldNames && section.fieldNames.length > 0; - const hasChildRecordListWidget = section.widgetName && metaData.widgets.get(section.widgetName)?.type == "childRecordList" - if(!hasFields && !hasChildRecordListWidget) - { - continue; - } - if(hasFields) { for (let j = 0; j < section.fieldNames.length; j++) @@ -597,6 +710,7 @@ function EntityForm(props: Props): JSX.Element newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData); newChildListWidgetData[section.widgetName] = widgetData; } + ////////////////////////////////////// // capture the tier1 section's name // ////////////////////////////////////// @@ -629,10 +743,10 @@ function EntityForm(props: Props): JSX.Element ////////////////////////////////////////////////////////////////// useEffect(() => { - if(childListWidgetData) + if (childListWidgetData) { - const newRenderedWidgetSections: {[name: string]: JSX.Element} = {}; - for(let name in childListWidgetData) + const newRenderedWidgetSections: { [name: string]: JSX.Element } = {}; + for (let name in childListWidgetData) { const widgetMetaData = metaData.widgets.get(name); newRenderedWidgetSections[name] = getWidgetSection(widgetMetaData, childListWidgetData[name]); @@ -677,7 +791,7 @@ function EntityForm(props: Props): JSX.Element ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there anre return. // ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - if(props.onSubmitCallback) + if (props.onSubmitCallback) { props.onSubmitCallback(values); return; @@ -690,7 +804,7 @@ function EntityForm(props: Props): JSX.Element ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const valuesToPost = JSON.parse(JSON.stringify(values)); - for(let fieldName of tableMetaData.fields.keys()) + for (let fieldName of tableMetaData.fields.keys()) { const fieldMetaData = tableMetaData.fields.get(fieldName); @@ -702,9 +816,9 @@ function EntityForm(props: Props): JSX.Element // 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]) + if (fieldMetaData.type === QFieldType.DATE_TIME && valuesToPost[fieldName]) { - console.log(`DateTime ${fieldName}: Initial value: [${initialValues[fieldName]}] -> [${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"); @@ -723,12 +837,12 @@ function EntityForm(props: Props): JSX.Element // 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 (fieldMetaData.type === QFieldType.BLOB) { - if(typeof valuesToPost[fieldName] === "string") + 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]); + delete (valuesToPost[fieldName]); } else { @@ -737,23 +851,23 @@ function EntityForm(props: Props): JSX.Element } } - const associationsToPost: any = {} + const associationsToPost: any = {}; let haveAssociationsToPost = false; for (let name of Object.keys(childListWidgetData)) { - const manageAssociationName = metaData.widgets.get(name)?.defaultValues?.get("manageAssociationName") - if(!manageAssociationName) + const manageAssociationName = metaData.widgets.get(name)?.defaultValues?.get("manageAssociationName"); + if (!manageAssociationName) { console.log(`Cannot send association data to backend - missing a manageAssociationName defaultValue in widget meta data for widget name ${name}`); } associationsToPost[manageAssociationName] = []; haveAssociationsToPost = true; - for(let i=0; i 0) + if (record.warnings && record.warnings.length && record.warnings.length > 0) { warningMessage = record.warnings[0]; } @@ -790,7 +904,7 @@ function EntityForm(props: Props): JSX.Element console.log("Caught:"); console.log(error); - if(error.message.toLowerCase().startsWith("warning")) + if (error.message.toLowerCase().startsWith("warning")) { const path = location.pathname.replace(/\/edit$/, ""); navigate(path, {state: {updateSuccess: true, warning: error.message}}); @@ -821,7 +935,7 @@ function EntityForm(props: Props): JSX.Element else { let warningMessage = null; - if(record.warnings && record.warnings.length && record.warnings.length > 0) + if (record.warnings && record.warnings.length && record.warnings.length > 0) { warningMessage = record.warnings[0]; } @@ -834,7 +948,7 @@ function EntityForm(props: Props): JSX.Element }) .catch((error) => { - if(error.message.toLowerCase().startsWith("warning")) + if (error.message.toLowerCase().startsWith("warning")) { const path = props.isCopy ? location.pathname.replace(new RegExp(`/${props.id}/copy$`), "/" + record.values.get(tableMetaData.primaryKeyField)) @@ -851,21 +965,42 @@ function EntityForm(props: Props): JSX.Element })(); }; + + /******************************************************************************* + ** process a form-field having a changed value (e.g., apply field rules). + *******************************************************************************/ + function handleChangedFieldValue(fieldName: string, oldValue: any, newValue: any, valueChangesToMake: {[fieldName: string]: any}) + { + for (let fieldRule of fieldRules) + { + if(fieldRule.trigger == FieldRuleTrigger.ON_CHANGE && fieldRule.sourceField == fieldName) + { + switch (fieldRule.action) + { + case FieldRuleAction.CLEAR_TARGET_FIELD: + console.log(`Clearing value from [${fieldRule.targetField}] due to change in [${fieldName}]`); + valueChangesToMake[fieldRule.targetField] = null; + break; + } + } + } + } + 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 helpRoles = [props.id ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"]; const formattedHelpContent = ; return formattedHelpContent && ( {formattedHelpContent} - ) - } + ); + }; if (notAllowedError) { @@ -889,7 +1024,7 @@ function EntityForm(props: Props): JSX.Element else { body = ( - + { (alertContent || warningContent) && @@ -926,51 +1061,116 @@ function EntityForm(props: Props): JSX.Element errors, touched, isSubmitting, - }) => ( -
- + setFieldValue, + dirty + }) => + { + ///////////////////////////////////////////////// + // if we have values from formik, look at them // + ///////////////////////////////////////////////// + if(values) + { + //////////////////////////////////////////////////////////////////////// + // use stringified values as cheap/easy way to see if any are changed // + //////////////////////////////////////////////////////////////////////// + const newFormValuesJSON = JSON.stringify(values); + if(formValuesJSON != newFormValuesJSON) + { + const valueChangesToMake: {[fieldName: string]: any} = {}; - - - - - - - {tableMetaData?.iconName} - - - - - {formTitle} - - - {t1section && getSectionHelp(t1section)} + //////////////////////////////////////////////////////////////////// + // if the form is dirty (e.g., we're not doing the initial load), // + // then process rules for any changed fields // + //////////////////////////////////////////////////////////////////// + if(dirty) + { + for (let fieldName in values) { - t1sectionName && formFields ? ( - - - {getFormSection(t1section, values, touched, formFields.get(t1sectionName), errors, true)} - - - ) : null + if (formValues[fieldName] != values[fieldName]) + { + handleChangedFieldValue(fieldName, formValues[fieldName], values[fieldName], valueChangesToMake); + } + formValues[fieldName] = values[fieldName]; } - - - {formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => ( - - {renderSection(section, values, touched, formFields, errors)} + } + else + { + ///////////////////////////////////////////////////////////////////////////////////// + // if the form is clean, make sure the formValues object has all form values in it // + ///////////////////////////////////////////////////////////////////////////////////// + for (let fieldName in values) + { + formValues[fieldName] = values[fieldName]; + } + } + + ///////////////////////////////////////////////////////////////////////////// + // if there were any changes to be made from the rule evaluation, // + // make those changes in the formValues map, and in formik (setFieldValue) // + ///////////////////////////////////////////////////////////////////////////// + for (let fieldName in valueChangesToMake) + { + formValues[fieldName] = valueChangesToMake[fieldName]; + setFieldValue(fieldName, valueChangesToMake[fieldName], false); + } + + setFormValues(formValues) + setFormValuesJSON(JSON.stringify(values)); + } + } + + /////////////////////////////////////////////////////////////////// + // once we're in the formik form, use its setFieldValue function // + // over top of the default one we created globally // + /////////////////////////////////////////////////////////////////// + formikSetFieldValueFunction = setFieldValue; + + return ( + + + + + + + + + + {tableMetaData?.iconName} + + + + + {formTitle} + + + {t1section && getSectionHelp(t1section)} + { + t1sectionName && formFields ? ( + + + {getFormSection(t1section, values, touched, formFields.get(t1sectionName), errors, true)} + + + ) : null + } + - )) : null} + {formFields && nonT1Sections.length ? nonT1Sections.map((section: QTableSection) => ( + + {renderSection(section, values, touched, formFields, errors)} + + )) : null} - - - - - - + + + + + + - - )} + + ); + }} { @@ -1014,7 +1214,7 @@ function EntityForm(props: Props): JSX.Element function ScrollToFirstError(): JSX.Element { - const {submitCount, isValid} = useFormikContext() + const {submitCount, isValid} = useFormikContext(); useEffect(() => { @@ -1044,8 +1244,8 @@ function ScrollToFirstError(): JSX.Element } firstErrorMessage.scrollIntoView({block: "center"}); - }, 100) - }, [submitCount, isValid]) + }, 100); + }, [submitCount, isValid]); return null; } diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx index 4f789ae..2cd297b 100644 --- a/src/qqq/components/misc/FieldAutoComplete.tsx +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -23,9 +23,11 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {Box} from "@mui/material"; import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete"; +import Icon from "@mui/material/Icon"; import TextField from "@mui/material/TextField"; -import React, {ReactNode} from "react"; +import React, {ReactNode, useState} from "react"; interface FieldAutoCompleteProps { @@ -33,10 +35,17 @@ interface FieldAutoCompleteProps metaData: QInstance; tableMetaData: QTableMetaData; handleFieldChange: (event: any, newValue: any, reason: string) => void; - defaultValue?: {field: QFieldMetaData, table: QTableMetaData, fieldName: string}; + defaultValue?: { field: QFieldMetaData, table: QTableMetaData, fieldName: string }; autoFocus?: boolean; forceOpen?: boolean; hiddenFieldNames?: string[]; + availableFieldNames?: string[]; + variant?: "standard" | "filled" | "outlined"; + label?: string; + textFieldSX?: any; + autocompleteSlotProps?: any; + hasError?: boolean; + noOptionsText?: string; } FieldAutoComplete.defaultProps = @@ -44,17 +53,29 @@ FieldAutoComplete.defaultProps = defaultValue: null, autoFocus: false, forceOpen: null, - hiddenFieldNames: [] + hiddenFieldNames: [], + availableFieldNames: [], + variant: "standard", + label: "Field", + textFieldSX: null, + autocompleteSlotProps: null, + hasError: false, + noOptionsText: "No options", }; -function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[]) +function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string) { const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label)); for (let i = 0; i < sortedFields.length; i++) { const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; - if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1) + if (hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName) + { + continue; + } + + if (availableFieldNames?.length && availableFieldNames.indexOf(fieldName) == -1) { continue; } @@ -63,10 +84,16 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a } } -export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames}: FieldAutoCompleteProps): JSX.Element + +/******************************************************************************* + ** Component for rendering a list of field names from a table as an auto-complete. + *******************************************************************************/ +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText}: FieldAutoCompleteProps): JSX.Element { + const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null); + const fieldOptions: any[] = []; - makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames); + makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName); let fieldsGroupBy = null; if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0) @@ -77,7 +104,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi if (metaData.tables.has(exposedJoin.joinTable.name)) { fieldsGroupBy = (option: any) => `${option.table.label} fields`; - makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames); + makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames, availableFieldNames, selectedFieldName); } } } @@ -130,27 +157,48 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi // seems like, if we always add the open attribute, then if its false or null, then the autocomplete // // doesn't open at all... so, only add the attribute at all, if forceOpen is true // /////////////////////////////////////////////////////////////////////////////////////////////////////// - const alsoOpen: {[key: string]: any} = {} - if(forceOpen) + const alsoOpen: { [key: string]: any } = {}; + if (forceOpen) { alsoOpen["open"] = forceOpen; } + + /******************************************************************************* + ** + *******************************************************************************/ + function onChange(event: any, newValue: any, reason: string) + { + setSelectedFieldName(newValue ? newValue.fieldName : null); + handleFieldChange(event, newValue, reason); + } + return ( ()} + renderInput={(params) => + { + const inputProps = params.InputProps; + const originalEndAdornment = inputProps.endAdornment; + inputProps.endAdornment = + {hasError && error_outline} + {originalEndAdornment} + ; + + return () + }} // @ts-ignore defaultValue={defaultValue} options={fieldOptions} - onChange={handleFieldChange} + onChange={onChange} isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)} groupBy={fieldsGroupBy} getOptionLabel={(option) => getFieldOptionLabel(option)} renderOption={(props, option, state) => renderFieldOption(props, option, state)} autoSelect={true} autoHighlight={true} - slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} + slotProps={autocompleteSlotProps ?? {}} + noOptionsText={noOptionsText} {...alsoOpen} /> diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx index 9407d3c..668cea1 100644 --- a/src/qqq/components/misc/SavedViews.tsx +++ b/src/qqq/components/misc/SavedViews.tsx @@ -44,6 +44,7 @@ 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 {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; import React, {useContext, useEffect, useRef, useState} from "react"; @@ -60,9 +61,10 @@ interface Props viewAsJson?: string; viewOnChangeCallback?: (selectedSavedViewId: number) => void; loadingSavedView: boolean + queryScreenUsage: QueryScreenUsage; } -function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView}: Props): JSX.Element +function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView, queryScreenUsage}: Props): JSX.Element { const navigate = useNavigate(); @@ -87,10 +89,18 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab const RENAME_OPTION = "Rename..."; const DELETE_OPTION = "Delete..."; const CLEAR_OPTION = "New View"; - const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION]; + const NEW_REPORT_OPTION = "Create Report from Current View"; const {accentColor, accentColorLight} = useContext(QContext); + ///////////////////////////////////////////////////////////////////////////////////////// + // this component is used by - but that component has different usages - // + // e.g., the full-fledged query screen, but also, within other screens (e.g., a modal // + // under the ReportSetupWidget). So, there are some behaviors we only want when we're // + // on the full-fledged query screen, such as changing the URL with saved view ids. // + ///////////////////////////////////////////////////////////////////////////////////////// + const isQueryScreen = queryScreenUsage == "queryScreen"; + const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget); const closeSavedViewsMenu = () => setSavedViewsMenu(null); @@ -142,7 +152,10 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab setSaveFilterPopupOpen(false); closeSavedViewsMenu(); viewOnChangeCallback(record.values.get("id")); - navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`); + if(isQueryScreen) + { + navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`); + } }; @@ -175,7 +188,10 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab case CLEAR_OPTION: setSaveFilterPopupOpen(false) viewOnChangeCallback(null); - navigate(metaData.getTablePathByName(tableMetaData.name)); + if(isQueryScreen) + { + navigate(metaData.getTablePathByName(tableMetaData.name)); + } break; case RENAME_OPTION: if(currentSavedView != null) @@ -187,10 +203,30 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab case DELETE_OPTION: setIsDeleteFilter(true) break; + case NEW_REPORT_OPTION: + createNewReport(); + break; } } + /******************************************************************************* + ** + *******************************************************************************/ + function createNewReport() + { + const defaultValues: {[key: string]: any} = {}; + defaultValues.tableName = tableMetaData.name; + + let filterForBackend = JSON.parse(JSON.stringify(view.queryFilter)); + filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend); + + defaultValues.queryFilterJson = JSON.stringify(filterForBackend); + defaultValues.columnsJson = JSON.stringify(view.queryColumns); + navigate(`${metaData.getTablePathByName("savedReport")}/create#defaultValues=${encodeURIComponent(JSON.stringify(defaultValues))}`); + } + + /******************************************************************************* ** fired when save or delete button saved on confirmation dialogs @@ -376,6 +412,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab const hasStorePermission = metaData?.processes.has("storeSavedView"); const hasDeletePermission = metaData?.processes.has("deleteSavedView"); const hasQueryPermission = metaData?.processes.has("querySavedView"); + const hasSavedReportsPermission = metaData?.tables.has("savedReport"); const tooltipMaxWidth = (maxWidth: string) => { @@ -398,11 +435,14 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab open={Boolean(savedViewsMenu)} onClose={closeSavedViewsMenu} keepMounted - PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minHeight: "200px"}}} + PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: "300px"}}} > - View Actions { - hasStorePermission && + isQueryScreen && + View Actions + } + { + isQueryScreen && hasStorePermission && Save your current filters, columns and settings, for quick re-use at a later time.

You will be prompted to enter a name if you choose this option.}> handleDropdownOptionClick(SAVE_OPTION)}> save @@ -411,7 +451,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
} { - hasStorePermission && currentSavedView != null && + isQueryScreen && hasStorePermission && currentSavedView != null && handleDropdownOptionClick(RENAME_OPTION)}> edit @@ -420,7 +460,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { - hasStorePermission && currentSavedView != null && + isQueryScreen && hasStorePermission && currentSavedView != null && handleDropdownOptionClick(DUPLICATE_OPTION)}> content_copy @@ -429,7 +469,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { - hasStorePermission && currentSavedView != null && + isQueryScreen && hasDeletePermission && currentSavedView != null && handleDropdownOptionClick(DELETE_OPTION)}> delete @@ -438,6 +478,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { + isQueryScreen && handleDropdownOptionClick(CLEAR_OPTION)}> monitor @@ -445,7 +486,18 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } - + { + isQueryScreen && hasSavedReportsPermission && + + handleDropdownOptionClick(NEW_REPORT_OPTION)}> + article + Create Report from Current View + + + } + { + isQueryScreen && + } Your Saved Views { savedViews && savedViews.length > 0 ? ( @@ -455,7 +507,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab ) ): ( - + You do not have any saved views for this table. ) @@ -554,25 +606,29 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab { !currentSavedView && viewIsModified && <> - - Unsaved Changes -
    - { - viewDiffs.map((s: string, i: number) =>
  • {s}
  • ) - } -
- }> - -
+ { + isQueryScreen && <> + + Unsaved Changes +
    + { + viewDiffs.map((s: string, i: number) =>
  • {s}
  • ) + } +
+ }> + +
- {/* vertical rule */} - + {/* vertical rule */} + + + } } { - currentSavedView && viewIsModified && <> + isQueryScreen && currentSavedView && viewIsModified && <> Unsaved Changes
    @@ -591,6 +647,34 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } + { + !isQueryScreen && currentSavedView && + + + {currentSavedView.values.get("label")} + + + { + viewIsModified && + <> + + Changes +
      + { + viewDiffs.map((s: string, i: number) =>
    • {s}
    • ) + } +
    }> + with {viewDiffs.length} Change{viewDiffs.length == 1 ? "" : "s"} +
    + + + } + + {/* vertical rule */} + + + + }
    { diff --git a/src/qqq/components/query/AdvancedQueryPreview.tsx b/src/qqq/components/query/AdvancedQueryPreview.tsx new file mode 100644 index 0000000..f951da2 --- /dev/null +++ b/src/qqq/components/query/AdvancedQueryPreview.tsx @@ -0,0 +1,153 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import Box from "@mui/material/Box"; +import colors from "qqq/assets/theme/base/colors"; +import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; +import XIcon from "qqq/components/query/XIcon"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import React, {useState} from "react"; + +interface AdvancedQueryPreviewProps +{ + tableMetaData: QTableMetaData; + queryFilter: QQueryFilter; + isEditable: boolean; + isQueryTooComplex: boolean; + removeCriteriaByIndexCallback: (index: number) => void; +} + +/******************************************************************************* + ** Box shown on query screen (and more??) to preview what a query looks like, + ** as an "advanced" style/precursor-to-writing-your-own-query thing. + *******************************************************************************/ +export default function AdvancedQueryPreview({tableMetaData, queryFilter, isEditable, isQueryTooComplex, removeCriteriaByIndexCallback}: AdvancedQueryPreviewProps): JSX.Element +{ + const [mouseOverElement, setMouseOverElement] = useState(null as string); + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOverElement(name: string) + { + setMouseOverElement(name); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleMouseOutElement() + { + setMouseOverElement(null); + } + + + + /******************************************************************************* + ** format the current query as a string for showing on-screen as a preview. + *******************************************************************************/ + const queryToAdvancedString = (thisQueryFilter: QQueryFilter) => + { + if (queryFilter == null || !queryFilter.criteria) + { + return (); + } + + let counter = 0; + + return ( + + {thisQueryFilter.criteria?.map((criteria, i) => + { + const {criteriaIsValid} = validateCriteria(criteria, null); + if (criteriaIsValid) + { + counter++; + return ( + handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}> + {counter > 1 ? {thisQueryFilter.booleanOperator}  : } + {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} + {isEditable && !isQueryTooComplex && ( + mouseOverElement == `queryPreview-${i}` && + removeCriteriaByIndexCallback(i)} /> + )} + {counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? {thisQueryFilter.booleanOperator}  : } + + ); + } + else + { + return (); + } + })} + + {thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) => + { + return ( + + {j > 0 ? {thisQueryFilter.booleanOperator}  : } + ( + {queryToAdvancedString(filter)} + ) + + ); + }))} + + ); + }; + + const moreSX = isEditable ? + { + borderTop: `1px solid ${colors.grayLines.main}`, + boxShadow: "inset 0px 0px 4px 2px #EFEFED", + borderRadius: "0 0 0.75rem 0.75rem", + } : + { + borderRadius: "0.75rem", + border: `1px solid ${colors.grayLines.main}`, + } + + return ( + + { + + + {queryToAdvancedString(queryFilter)} + + + } + + ) +} diff --git a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx index 563d002..7c13dff 100644 --- a/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx +++ b/src/qqq/components/query/BasicAndAdvancedQueryControls.tsx @@ -44,11 +44,13 @@ import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro"; import QContext from "QContext"; import colors from "qqq/assets/theme/base/colors"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview"; import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel"; import FieldListMenu from "qqq/components/query/FieldListMenu"; import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter"; import XIcon from "qqq/components/query/XIcon"; +import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react"; @@ -75,12 +77,34 @@ interface BasicAndAdvancedQueryControlsProps ///////////////////////////////////////////////////////////////////////////////////////////// queryFilterJSON: string; + queryScreenUsage: QueryScreenUsage; + mode: string; setMode: (mode: string) => void; } let debounceTimeout: string | number | NodeJS.Timeout; + +/******************************************************************************* + ** function to generate an element that says how a filter is sorted. + *******************************************************************************/ +export function getCurrentSortIndicator(queryFilter: QQueryFilter, tableMetaData: QTableMetaData, toggleSortDirection: (event: React.MouseEvent) => void) +{ + if (queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0) + { + const orderBy = queryFilter.orderBys[0]; + const orderByFieldName = orderBy.fieldName; + const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName); + const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`; + return <>Sort: {fieldLabel} {orderBy.isAscending ? "arrow_upward" : "arrow_downward"}; + } + else + { + return <>Sort...; + } +} + /******************************************************************************* ** Component to provide the basic & advanced query-filter controls for the ** RecordQueryOrig screen. @@ -397,60 +421,6 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo }; - /******************************************************************************* - ** format the current query as a string for showing on-screen as a preview. - *******************************************************************************/ - const queryToAdvancedString = (thisQueryFilter: QQueryFilter) => - { - if (queryFilter == null || !queryFilter.criteria) - { - return (); - } - - let counter = 0; - - return ( - - {thisQueryFilter.criteria?.map((criteria, i) => - { - const {criteriaIsValid} = validateCriteria(criteria, null); - if (criteriaIsValid) - { - counter++; - return ( - handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}> - {counter > 1 ? {thisQueryFilter.booleanOperator}  : } - {FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)} - {!isQueryTooComplex && ( - mouseOverElement == `queryPreview-${i}` && - removeCriteriaByIndex(i)} /> - )} - {counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? {thisQueryFilter.booleanOperator}  : } - - ); - } - else - { - return (); - } - })} - - {thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) => - { - return ( - - {j > 0 ? {thisQueryFilter.booleanOperator}  : } - ( - {queryToAdvancedString(filter)} - ) - - ); - }))} - - ); - }; - - /******************************************************************************* ** event handler for toggling between modes - basic & advanced. *******************************************************************************/ @@ -608,15 +578,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo ///////////////////////////////// // set up the sort menu button // ///////////////////////////////// - let sortButtonContents = <>Sort...; - if (queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0) - { - const orderBy = queryFilter.orderBys[0]; - const orderByFieldName = orderBy.fieldName; - const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName); - const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`; - sortButtonContents = <>Sort: {fieldLabel} {orderBy.isAscending ? "arrow_upward" : "arrow_downward"}; - } + let sortButtonContents = getCurrentSortIndicator(queryFilter, tableMetaData, toggleSortDirection); //////////////////////////////////////////////////////////////////////////////////////////////////////////// // this is being used as a version of like forcing that we get re-rendered if the query filter changes... // @@ -807,26 +769,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo {sortMenuComponent} - - { - - - {queryToAdvancedString(queryFilter)} - - - } - + } diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index c792b38..ff720a2 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -33,10 +33,10 @@ import MenuItem from "@mui/material/MenuItem"; import Select, {SelectChangeEvent} from "@mui/material/Select/Select"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; -import React, {ReactNode, SyntheticEvent, useState} from "react"; import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues"; import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import React, {ReactNode, SyntheticEvent, useState} from "react"; export enum ValueMode @@ -484,7 +484,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, : } - + diff --git a/src/qqq/components/widgets/DashboardWidgets.tsx b/src/qqq/components/widgets/DashboardWidgets.tsx index cd2bf0a..20bc4fa 100644 --- a/src/qqq/components/widgets/DashboardWidgets.tsx +++ b/src/qqq/components/widgets/DashboardWidgets.tsx @@ -19,6 +19,7 @@ */ import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {Alert, Skeleton} from "@mui/material"; import Box from "@mui/material/Box"; import Grid from "@mui/material/Grid"; @@ -38,8 +39,10 @@ import CompositeWidget from "qqq/components/widgets/CompositeWidget"; import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer"; import DividerWidget from "qqq/components/widgets/misc/Divider"; import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget"; +import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget"; import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart"; import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget"; +import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget"; import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer"; import StepperCard from "qqq/components/widgets/misc/StepperCard"; import USMapWidget from "qqq/components/widgets/misc/USMapWidget"; @@ -61,6 +64,7 @@ interface Props widgetMetaDataList: QWidgetMetaData[]; tableName?: string; entityPrimaryKey?: string; + record?: QRecord; omitWrappingGridContainer: boolean; areChildren?: boolean; childUrlParams?: string; @@ -79,7 +83,7 @@ DashboardWidgets.defaultProps = { wrapWidgetsInTabPanels: false, }; -function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element +function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element { const [widgetData, setWidgetData] = useState([] as any[]); const [widgetCounter, setWidgetCounter] = useState(0); @@ -248,6 +252,23 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit const widgetCount = widgetMetaDataList ? widgetMetaDataList.length : 0; + + /******************************************************************************* + ** helper function, to convert values from a QRecord values map to a regular old + ** js object + *******************************************************************************/ + function convertQRecordValuesFromMapToObject(record: QRecord): {[name: string]: any} + { + const rs: {[name: string]: any} = {}; + + if(record.values) + { + record.values.forEach((value, key) => rs[key] = value); + } + + return (rs); + } + const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element => { const labelAdditionalComponentsRight: LabelComponent[] = []; @@ -561,6 +582,20 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit ) } + { + widgetMetaData.type === "reportSetup" && ( + widgetData && widgetData[i] && widgetData[i].queryParams && + + {}} /> + ) + } + { + widgetMetaData.type === "pivotTableSetup" && ( + widgetData && widgetData[i] && widgetData[i].queryParams && + + {}} /> + ) + } ); }; diff --git a/src/qqq/components/widgets/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index 7f0d7bc..3bfca94 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -21,10 +21,12 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +import {InputLabel} from "@mui/material"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import Icon from "@mui/material/Icon"; +import Switch from "@mui/material/Switch"; import Tooltip from "@mui/material/Tooltip/Tooltip"; import Typography from "@mui/material/Typography"; import parse from "html-react-parser"; @@ -60,6 +62,7 @@ interface Props labelAdditionalComponentsLeft: LabelComponent[]; labelAdditionalElementsLeft: JSX.Element[]; labelAdditionalComponentsRight: LabelComponent[]; + labelAdditionalElementsRight: JSX.Element[]; labelBoxAdditionalSx?: any; widgetMetaData?: QWidgetMetaData; widgetData?: WidgetData; @@ -80,6 +83,7 @@ Widget.defaultProps = { labelAdditionalComponentsLeft: [], labelAdditionalElementsLeft: [], labelAdditionalComponentsRight: [], + labelAdditionalElementsRight: [], labelBoxAdditionalSx: {}, omitPadding: false, }; @@ -160,6 +164,79 @@ export class HeaderIcon extends LabelComponent } +/******************************************************************************* + ** a link (actually a button) for in a widget's header + *******************************************************************************/ +interface HeaderLinkButtonComponentProps +{ + label: string; + onClickCallback: () => void; + disabled?: boolean; + disabledTooltip?: string; +} + +HeaderLinkButtonComponent.defaultProps = { + disabled: false, + disabledTooltip: null +}; + +export function HeaderLinkButtonComponent({label, onClickCallback, disabled, disabledTooltip}: HeaderLinkButtonComponentProps): JSX.Element +{ + return ( + + + + + + ); +} + + + + +/******************************************************************************* + ** + *******************************************************************************/ +interface HeaderToggleComponentProps +{ + label: string; + getValue: () => boolean; + onClickCallback: () => void; + disabled?: boolean; + disabledTooltip?: string; +} + +HeaderToggleComponent.defaultProps = { + disabled: false, + disabledTooltip: null +}; + +export function HeaderToggleComponent({label, getValue, onClickCallback, disabled, disabledTooltip}: HeaderToggleComponentProps): JSX.Element +{ + const onClick = () => + { + onClickCallback(); + } + + return ( + + + + + {label} + + + + + ); +} + + + /******************************************************************************* ** *******************************************************************************/ @@ -573,6 +650,8 @@ function Widget(props: React.PropsWithChildren): JSX.Element localLabelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick)); } + let localLabelAdditionalElementsRight = [...props.labelAdditionalElementsRight]; + const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; const isSet = (v: any): boolean => @@ -589,6 +668,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0); needLabelBox ||= (localLabelAdditionalElementsLeft && localLabelAdditionalElementsLeft.length > 0); needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0); + needLabelBox ||= (localLabelAdditionalElementsRight && localLabelAdditionalElementsRight.length > 0); needLabelBox ||= isSet(props.widgetData?.icon); needLabelBox ||= isSet(props.widgetData?.label); needLabelBox ||= isSet(props.widgetMetaData?.label); @@ -720,6 +800,7 @@ function Widget(props: React.PropsWithChildren): JSX.Element }) ) } + {localLabelAdditionalElementsRight} } diff --git a/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx b/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx new file mode 100644 index 0000000..ea0c7c6 --- /dev/null +++ b/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx @@ -0,0 +1,209 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Icon from "@mui/material/Icon"; +import type {Identifier, XYCoord} from "dnd-core"; +import colors from "qqq/assets/theme/base/colors"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; +import {DragItemTypes, fieldAutoCompleteTextFieldSX, getSelectedFieldForAutoComplete, xIconButtonSX} from "qqq/components/widgets/misc/PivotTableSetupWidget"; +import {PivotTableDefinition, PivotTableGroupBy} from "qqq/models/misc/PivotTableDefinitionModels"; +import React, {FC, useRef} from "react"; +import {useDrag, useDrop} from "react-dnd"; + + +/******************************************************************************* + ** component props + *******************************************************************************/ +export interface PivotTableGroupByElementProps +{ + id: string; + index: number; + dragCallback: (rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) => void; + metaData: QInstance; + tableMetaData: QTableMetaData; + pivotTableDefinition: PivotTableDefinition; + usedGroupByFieldNames: string[]; + availableFieldNames: string[]; + isEditable: boolean; + groupBy: PivotTableGroupBy; + rowsOrColumns: "rows" | "columns"; + callback: () => void; + attemptedSubmit?: boolean; +} + + +/******************************************************************************* + ** item to support react-dnd + *******************************************************************************/ +interface DragItem +{ + index: number; + id: string; + type: string; +} + +/******************************************************************************* + ** + *******************************************************************************/ +export const PivotTableGroupByElement: FC = ({id, index, dragCallback, rowsOrColumns, metaData, tableMetaData, pivotTableDefinition, groupBy, usedGroupByFieldNames, availableFieldNames, isEditable, callback, attemptedSubmit}) => +{ + //////////////////////////////////////////////////////////////////////////// + // credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple // + //////////////////////////////////////////////////////////////////////////// + const ref = useRef(null); + const [{handlerId}, drop] = useDrop( + { + accept: rowsOrColumns == "rows" ? DragItemTypes.ROW : DragItemTypes.COLUMN, + collect(monitor) + { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item: DragItem, monitor) + { + if (!ref.current) + { + return; + } + const dragIndex = item.index; + const hoverIndex = index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) + { + return; + } + + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + + // Get vertical middle + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + + // Get pixels to the top + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) + { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) + { + return; + } + + // Time to actually perform the action + dragCallback(rowsOrColumns, dragIndex, hoverIndex); + + // Note: we're mutating the monitor item here! Generally it's better to avoid mutations, + // but it's good here for the sake of performance to avoid expensive index searches. + item.index = hoverIndex; + }, + }); + + const [{isDragging}, drag, preview] = useDrag({ + type: rowsOrColumns == "rows" ? DragItemTypes.ROW : DragItemTypes.COLUMN, + item: () => + { + return {id, index}; + }, + collect: (monitor: any) => ({ + isDragging: monitor.isDragging(), + }), + }); + + + /******************************************************************************* + ** + *******************************************************************************/ + const handleFieldChange = (event: any, newValue: any, reason: string) => + { + groupBy.fieldName = newValue ? newValue.fieldName : null; + callback(); + }; + + + /******************************************************************************* + ** + *******************************************************************************/ + function removeGroupBy(index: number, rowsOrColumns: "rows" | "columns") + { + pivotTableDefinition[rowsOrColumns].splice(index, 1); + callback(); + } + + if (!isEditable) + { + const selectedField = getSelectedFieldForAutoComplete(tableMetaData, groupBy.fieldName); + if (selectedField) + { + const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label; + return ({label}); + } + + return (); + } + + preview(drop(ref)); + + const showError = attemptedSubmit && !groupBy.fieldName; + + return ( + + drag_indicator + + + + + + + + ); +}; diff --git a/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx new file mode 100644 index 0000000..7b243e4 --- /dev/null +++ b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx @@ -0,0 +1,870 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +import Alert from "@mui/material/Alert"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import Grid from "@mui/material/Grid"; +import Icon from "@mui/material/Icon"; +import Modal from "@mui/material/Modal"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip/Tooltip"; +import Typography from "@mui/material/Typography"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; +import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; +import {PivotTableGroupByElement} from "qqq/components/widgets/misc/PivotTableGroupByElement"; +import {PivotTableValueElement} from "qqq/components/widgets/misc/PivotTableValueElement"; +import {buttonSX, unborderedButtonSX} from "qqq/components/widgets/misc/ReportSetupWidget"; +import Widget, {HeaderToggleComponent} from "qqq/components/widgets/Widget"; +import {PivotObjectKey, PivotTableDefinition, PivotTableFunction, pivotTableFunctionLabels, PivotTableGroupBy, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels"; +import QQueryColumns from "qqq/models/query/QQueryColumns"; +import Client from "qqq/utils/qqq/Client"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import React, {useCallback, useContext, useEffect, useReducer, useState} from "react"; +import {DndProvider} from "react-dnd"; +import {HTML5Backend} from "react-dnd-html5-backend"; + +export const DragItemTypes = + { + ROW: "row", + COLUMN: "column", + VALUE: "value" + }; + +export const xIconButtonSX = + { + border: `1px solid ${colors.grayLines.main} !important`, + borderRadius: "0.75rem", + textTransform: "none", + fontSize: "1rem", + fontWeight: "400", + width: "40px", + minWidth: "40px", + paddingLeft: 0, + paddingRight: 0, + color: colors.error.main, + "&:hover": {color: colors.error.main}, + "&:focus": {color: colors.error.main}, + "&:focus:not(:hover)": {color: colors.error.main}, + }; + +export const fieldAutoCompleteTextFieldSX = + { + "& .MuiInputBase-input": {fontSize: "1rem", padding: "0 !important"} + }; + + +/******************************************************************************* + ** + *******************************************************************************/ +export function getSelectedFieldForAutoComplete(tableMetaData: QTableMetaData, fieldName: string) +{ + if (fieldName) + { + let [field, fieldTable] = FilterUtils.getField(tableMetaData, fieldName); + if (field && fieldTable) + { + return ({field: field, table: fieldTable, fieldName: fieldName}); + } + } + + return (null); +} + + +/******************************************************************************* + ** component props + *******************************************************************************/ +interface PivotTableSetupWidgetProps +{ + isEditable: boolean; + widgetMetaData: QWidgetMetaData; + recordValues: { [name: string]: any }; + onSaveCallback?: (values: { [name: string]: any }) => void; +} + + +/******************************************************************************* + ** default values for props + *******************************************************************************/ +PivotTableSetupWidget.defaultProps = { + onSaveCallback: null +}; + + +const qController = Client.getInstance(); + +/******************************************************************************* + ** Component to edit the setup of a Pivot Table - rows, columns, values! + *******************************************************************************/ +export default function PivotTableSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: PivotTableSetupWidgetProps): JSX.Element +{ + const [metaData, setMetaData] = useState(null as QInstance); + const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); + + const [modalOpen, setModalOpen] = useState(false); + const [enabled, setEnabled] = useState(!!recordValues["usePivotTable"]); + const [attemptedSubmit, setAttemptedSubmit] = useState(false); + const [errorAlert, setErrorAlert] = useState(null as string); + + const [pivotTableDefinition, setPivotTableDefinition] = useState(null as PivotTableDefinition); + + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + /////////////////////////////////////////////////////////////////////////////////// + // this is a copy of pivotTableDefinition, that we'll render in the modal. // + // then on-save, we'll move it to pivotTableDefinition, e.g., the actual record. // + /////////////////////////////////////////////////////////////////////////////////// + const [modalPivotTableDefinition, setModalPivotTableDefinition] = useState(null as PivotTableDefinition); + + const [usedGroupByFieldNames, setUsedGroupByFieldNames] = useState([] as string[]); + const [usedValueFieldNames, setUsedValueByFieldNames] = useState([] as string[]); + const [availableFieldNames, setAvailableFieldNames] = useState([] as string[]); + + const {helpHelpActive} = useContext(QContext); + + ////////////////// + // initial load // + ////////////////// + useEffect(() => + { + if (!pivotTableDefinition) + { + let originalPivotTableDefinition = recordValues["pivotTableJson"] && JSON.parse(recordValues["pivotTableJson"]) as PivotTableDefinition; + if (originalPivotTableDefinition) + { + setEnabled(true); + } + else if (!originalPivotTableDefinition) + { + originalPivotTableDefinition = new PivotTableDefinition(); + } + + for (let i = 0; i < originalPivotTableDefinition?.rows?.length; i++) + { + if (!originalPivotTableDefinition?.rows[i].key) + { + originalPivotTableDefinition.rows[i].key = PivotObjectKey.next(); + } + } + + for (let i = 0; i < originalPivotTableDefinition?.columns?.length; i++) + { + if (!originalPivotTableDefinition?.columns[i].key) + { + originalPivotTableDefinition.columns[i].key = PivotObjectKey.next(); + } + } + + for (let i = 0; i < originalPivotTableDefinition?.values?.length; i++) + { + if (!originalPivotTableDefinition?.values[i].key) + { + originalPivotTableDefinition.values[i].key = PivotObjectKey.next(); + } + } + + setPivotTableDefinition(originalPivotTableDefinition); + updateUsedGroupByFieldNames(originalPivotTableDefinition); + updateUsedValueFieldNames(modalPivotTableDefinition); + } + + if (recordValues["columnsJson"]) + { + updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns); + } + + (async () => + { + setMetaData(await qController.loadMetaData()); + })(); + }); + + ///////////////////////////////////////////////////////////////////// + // handle the table name changing - load current table's meta-data // + ///////////////////////////////////////////////////////////////////// + useEffect(() => + { + if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"])) + { + (async () => + { + const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"]); + setTableMetaData(tableMetaData); + })(); + } + }, [recordValues]); + + + const helpRoles = isEditable ? [recordValues["id"] ? "EDIT_SCREEN" : "INSERT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"] : ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"]; + + /******************************************************************************* + ** + *******************************************************************************/ + function showHelp(slot: string) + { + return (helpHelpActive || hasHelpContent(widgetMetaData?.helpContent?.get(slot), helpRoles)); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getHelpContent(slot: string) + { + const key = `widget:${widgetMetaData.name};slot:${slot}`; + return ; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function toggleEnabled() + { + const newEnabled = !!!getEnabled(); + setEnabled(newEnabled); + onSaveCallback({usePivotTable: newEnabled}); + + if (!newEnabled) + { + onSaveCallback({pivotTableJson: null}); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function getEnabled() + { + return (enabled); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function addGroupBy(rowsOrColumns: "rows" | "columns") + { + if (!modalPivotTableDefinition[rowsOrColumns]) + { + modalPivotTableDefinition[rowsOrColumns] = []; + } + + modalPivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy()); + validateForm() + forceUpdate(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function childElementChangedCallback() + { + updateUsedGroupByFieldNames(modalPivotTableDefinition); + updateUsedValueFieldNames(modalPivotTableDefinition); + validateForm() + forceUpdate(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function addValue() + { + if (!modalPivotTableDefinition.values) + { + modalPivotTableDefinition.values = []; + } + + modalPivotTableDefinition.values.push(new PivotTableValue()); + validateForm() + forceUpdate(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function removeValue(index: number) + { + modalPivotTableDefinition.values.splice(index, 1); + validateForm() + forceUpdate(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function updateUsedGroupByFieldNames(ptd: PivotTableDefinition = pivotTableDefinition) + { + const usedFieldNames: string[] = []; + + for (let i = 0; i < ptd?.rows?.length; i++) + { + usedFieldNames.push(ptd?.rows[i].fieldName); + } + + for (let i = 0; i < ptd?.columns?.length; i++) + { + usedFieldNames.push(ptd?.columns[i].fieldName); + } + + setUsedGroupByFieldNames(usedFieldNames); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function updateUsedValueFieldNames(ptd: PivotTableDefinition = pivotTableDefinition) + { + const usedFieldNames: string[] = []; + + for (let i = 0; i < ptd?.values?.length; i++) + { + usedFieldNames.push(ptd?.values[i].fieldName); + } + + setUsedValueByFieldNames(usedFieldNames); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function updateAvailableFieldNames(columns: QQueryColumns) + { + const fieldNames: string[] = []; + for (let i = 0; i < columns?.columns?.length; i++) + { + if (columns.columns[i].isVisible) + { + fieldNames.push(columns.columns[i].name); + } + } + setAvailableFieldNames(fieldNames); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function renderOneValue(value: PivotTableValue, index: number) + { + if (!isEditable) + { + const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName); + if (selectedField && value.function) + { + const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label; + return ({pivotTableFunctionLabels[value.function]} of {label}); + } + + return (); + } + + const handleFieldChange = (event: any, newValue: any, reason: string) => + { + value.fieldName = newValue ? newValue.fieldName : null; + }; + + const handleFunctionChange = (event: any, newValue: any, reason: string) => + { + value.function = newValue ? newValue.id : null; + }; + + const functionOptions: any[] = []; + let defaultFunctionValue = null; + for (let pivotTableFunctionKey in PivotTableFunction) + { + // @ts-ignore any? + const label = "" + pivotTableFunctionLabels[pivotTableFunctionKey]; + const option = {id: pivotTableFunctionKey, label: label}; + functionOptions.push(option); + + if (option.id == value.function) + { + defaultFunctionValue = option; + } + } + + // maybe cursor:grab (and then change to "grabbing") + return ( + + drag_indicator + + + + + + ()} + // @ts-ignore + defaultValue={defaultFunctionValue} + options={functionOptions} + onChange={handleFunctionChange} + isOptionEqualToValue={(option, value) => option.id === value.id} + getOptionLabel={(option) => option.label} + // todo? renderOption={(props, option, state) => renderFieldOption(props, option, state)} + autoSelect={true} + autoHighlight={true} + disableClearable + // slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} + // {...alsoOpen} + /> + + + + + ); + } + + + /******************************************************************************* + ** drag & drop callback to move one of the pivot-table group-bys (rows/columns) + *******************************************************************************/ + const moveGroupBy = useCallback((rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) => + { + const array = modalPivotTableDefinition[rowsOrColumns]; + const dragItem = array[dragIndex]; + array.splice(dragIndex, 1); + array.splice(hoverIndex, 0, dragItem); + + forceUpdate(); + }, [modalPivotTableDefinition]); + + + /******************************************************************************* + ** drag & drop callback to move one of the pivot-table values + *******************************************************************************/ + const moveValue = useCallback((dragIndex: number, hoverIndex: number) => + { + const array = modalPivotTableDefinition.values; + const dragItem = array[dragIndex]; + array.splice(dragIndex, 1); + array.splice(hoverIndex, 0, dragItem); + + forceUpdate(); + }, [modalPivotTableDefinition]); + + + const noTable = (tableMetaData == null); + const noColumns = (!availableFieldNames || availableFieldNames.length == 0); + + const selectTableFirstTooltipTitle = noTable ? "You must select a table before you can set up your pivot table" : null; + const selectColumnsFirstTooltipTitle = noColumns ? "You must set up your report's Columns before you can set up your Pivot Table" : null; + const editPopupDisabled = noTable || noColumns; + + ///////////////////////////////////////////////////////////// + // add toggle component to widget header for editable mode // + ///////////////////////////////////////////////////////////// + const labelAdditionalElementsRight: JSX.Element[] = []; + if (isEditable) + { + labelAdditionalElementsRight.push( enabled} onClickCallback={toggleEnabled} />); + } + + + /******************************************************************************* + ** render a group-by (row or column) + *******************************************************************************/ + const renderGroupBy = useCallback((groupBy: PivotTableGroupBy, rowsOrColumns: "rows" | "columns", index: number, forModal: boolean) => + { + return ( + + ); + }, + [tableMetaData, usedGroupByFieldNames, availableFieldNames], + ); + + + /******************************************************************************* + ** render a pivot-table value (row or column) + *******************************************************************************/ + const renderValue = useCallback((value: PivotTableValue, index: number, forModal: boolean) => + { + return ( + + ); + }, + [tableMetaData, usedGroupByFieldNames, availableFieldNames], + ); + + + /******************************************************************************* + ** + *******************************************************************************/ + function openEditor() + { + if (recordValues["tableName"]) + { + setModalPivotTableDefinition(Object.assign({}, pivotTableDefinition)); + setModalOpen(true); + setAttemptedSubmit(false); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown") + { + if (reason == "backdropClick" || reason == "escapeKeyDown") + { + return; + } + + setModalOpen(false); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function renderGroupBys(forModal: boolean, rowsOrColumns: "rows" | "columns") + { + const ptd = forModal ? modalPivotTableDefinition : pivotTableDefinition; + + return <> +
    {rowsOrColumns == "rows" ? "Rows" : "Columns"}
    + + { + tableMetaData && (
    {ptd[rowsOrColumns]?.map((groupBy, i) => renderGroupBy(groupBy, rowsOrColumns, i, forModal))}
    ) + } +
    + { + (forModal || (isEditable && !ptd[rowsOrColumns]?.length)) && + + + + + + } + { + !isEditable && !forModal && !ptd[rowsOrColumns]?.length && + Your pivot table has no {rowsOrColumns}. + } + ; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function renderValues(forModal: boolean) + { + const ptd = forModal ? modalPivotTableDefinition : pivotTableDefinition; + + return <> +
    Values
    + + { + tableMetaData && (
    {ptd?.values?.map((value, i) => renderValue(value, i, forModal))}
    ) + } +
    + { + (forModal || (isEditable && !ptd?.values?.length)) && + + + + + + } + { + !isEditable && !forModal && !ptd?.values?.length && + Your pivot table has no values. + } + ; + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function validateForm(submitting: boolean = false) + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if this isn't a call from the on-submit handler, and we haven't previously attempted a submit, then return w/o setting any alerts // + // this is like a version of considering "touched"... // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(!submitting && !attemptedSubmit) + { + return; + } + + let missingValues = 0; + + for (let i = 0; i < modalPivotTableDefinition?.rows?.length; i++) + { + if (!modalPivotTableDefinition.rows[i].fieldName) + { + missingValues++; + } + } + + for (let i = 0; i < modalPivotTableDefinition?.columns?.length; i++) + { + if (!modalPivotTableDefinition.columns[i].fieldName) + { + missingValues++; + } + } + + for (let i = 0; i < modalPivotTableDefinition?.values?.length; i++) + { + if (!modalPivotTableDefinition.values[i].fieldName) + { + missingValues++; + } + if (!modalPivotTableDefinition.values[i].function) + { + missingValues++; + } + } + + if (missingValues == 0) + { + setErrorAlert(null); + + //////////////////////////////////////////////////////////////////////////////////// + // this is to catch the case of - user attempted to submit, and there were errors // + // now they've fixed 'em - so go back to a 'clean' state - so if they add more // + // boxes, they won't immediately show errors, until a re-submit // + //////////////////////////////////////////////////////////////////////////////////// + if(attemptedSubmit) + { + setAttemptedSubmit(false); + } + return (false); + } + + setErrorAlert(`Missing value in ${missingValues} field${missingValues == 1 ? "" : "s"}.`); + return (true); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function saveClicked() + { + setAttemptedSubmit(true); + + if (validateForm(true)) + { + return; + } + + if (!onSaveCallback) + { + console.log("onSaveCallback was not defined"); + return; + } + + setPivotTableDefinition(Object.assign({}, modalPivotTableDefinition)); + updateUsedGroupByFieldNames(modalPivotTableDefinition); + updateUsedValueFieldNames(modalPivotTableDefinition); + + onSaveCallback({pivotTableJson: JSON.stringify(modalPivotTableDefinition)}); + + closeEditor(); + } + + + //////////// + // render // + //////////// + return ( + { + + + { + enabled && + + + { + showHelp("sectionSubhead") && + + {getHelpContent("sectionSubhead")} + + } + + { + isEditable && + + + + + + } + + } + { + (!enabled || !pivotTableDefinition) && !isEditable && + Your report does not use a Pivot Table. + } + { + enabled && pivotTableDefinition && + <> + + + {renderGroupBys(false, "rows")} + {renderGroupBys(false, "columns")} + {renderValues(false)} + + + { + modalOpen && + closeEditor(event, reason)}> +
    + + +

    Edit Pivot Table

    + { + showHelp("modalSubheader") && + + {getHelpContent("modalSubheader")} + + } + { + errorAlert && error_outline} color="error" onClose={() => setErrorAlert(null)}>{errorAlert} + } + + + {renderGroupBys(true, "rows")} + {renderGroupBys(true, "columns")} + {renderValues(true)} + + + + + + + + +
    +
    +
    +
    + } + + } +
    +
    + } +
    ); +} + +/* this was a rough-draft of what a preview of a pivot could look like... + +
    Preview
    + + + + + + { + pivotTableDefinition?.columns?.map((column, i) => + ( + + + + + )) + } + + + { + pivotTableDefinition?.values?.map((value, i) => + ( + + )) + } + + { + pivotTableDefinition?.rows?.map((row, i) => + ( + + + + )) + } +
    Column Labels
    {column.fieldName}
    Row Labels{value.function} of {value.fieldName}
    {row.fieldName}
    +
    +*/ diff --git a/src/qqq/components/widgets/misc/PivotTableValueElement.tsx b/src/qqq/components/widgets/misc/PivotTableValueElement.tsx new file mode 100644 index 0000000..a293285 --- /dev/null +++ b/src/qqq/components/widgets/misc/PivotTableValueElement.tsx @@ -0,0 +1,319 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; +import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Icon from "@mui/material/Icon"; +import TextField from "@mui/material/TextField"; +import type {Identifier, XYCoord} from "dnd-core"; +import colors from "qqq/assets/theme/base/colors"; +import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete"; +import {DragItemTypes, fieldAutoCompleteTextFieldSX, getSelectedFieldForAutoComplete, xIconButtonSX} from "qqq/components/widgets/misc/PivotTableSetupWidget"; +import {functionsPerFieldType, PivotTableDefinition, pivotTableFunctionLabels, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels"; +import React, {FC, useReducer, useRef, useState} from "react"; +import {useDrag, useDrop} from "react-dnd"; + + +/******************************************************************************* + ** component props + *******************************************************************************/ +export interface PivotTableValueElementProps +{ + id: string; + index: number; + dragCallback: (dragIndex: number, hoverIndex: number) => void; + metaData: QInstance; + tableMetaData: QTableMetaData; + pivotTableDefinition: PivotTableDefinition; + availableFieldNames: string[]; + usedGroupByFieldNames: string[]; + isEditable: boolean; + value: PivotTableValue; + callback: () => void; + attemptedSubmit?: boolean; +} + + +/******************************************************************************* + ** item to support react-dnd + *******************************************************************************/ +interface DragItem +{ + index: number; + id: string; + type: string; +} + + +/******************************************************************************* + ** Element to render 1 pivot-table value. + *******************************************************************************/ +export const PivotTableValueElement: FC = ({id, index, dragCallback, metaData, tableMetaData, pivotTableDefinition, availableFieldNames, usedGroupByFieldNames, value, isEditable, callback, attemptedSubmit}) => +{ + const [defaultFunctionValue, setDefaultFunctionValue] = useState(null); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + //////////////////////////////////////////////////////////////////////////// + // credit: https://react-dnd.github.io/react-dnd/examples/sortable/simple // + //////////////////////////////////////////////////////////////////////////// + const ref = useRef(null); + const [{handlerId}, drop] = useDrop( + { + accept: DragItemTypes.VALUE, + collect(monitor) + { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item: DragItem, monitor) + { + if (!ref.current) + { + return; + } + const dragIndex = item.index; + const hoverIndex = index; + + // Don't replace items with themselves + if (dragIndex === hoverIndex) + { + return; + } + + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + + // Get vertical middle + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + + // Get pixels to the top + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) + { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) + { + return; + } + + // Time to actually perform the action + dragCallback(dragIndex, hoverIndex); + + // Note: we're mutating the monitor item here! Generally it's better to avoid mutations, + // but it's good here for the sake of performance to avoid expensive index searches. + item.index = hoverIndex; + }, + }); + + const [{isDragging}, drag] = useDrag({ + type: DragItemTypes.VALUE, + item: () => + { + return {id, index}; + }, + collect: (monitor: any) => ({ + isDragging: monitor.isDragging(), + }), + }); + + + /******************************************************************************* + ** + *******************************************************************************/ + function getFunctionsForField(field: QFieldMetaData) + { + if(field) + { + let type = field.type; + if (field.possibleValueSourceName) + { + type = QFieldType.STRING; + } + + if(functionsPerFieldType[type]) + { + return (functionsPerFieldType[type]); + } + } + + ////////////////////////////////////// + // return broadest list if no field // + ////////////////////////////////////// + return (functionsPerFieldType[QFieldType.INTEGER]); + } + + + /******************************************************************************* + ** event handler for user selecting a field + *******************************************************************************/ + function handleFieldChange(event: any, newValue: any, reason: string) + { + value.fieldName = newValue ? newValue.fieldName : null; + + if(newValue) + { + ///////////////////////////////////////////////////////////////////////////////////////// + // if newly selected field doesn't have the currently selected function, then clear it // + ///////////////////////////////////////////////////////////////////////////////////////// + const newSelectedField = getSelectedFieldForAutoComplete(tableMetaData, newValue.fieldName); + if (newSelectedField) + { + if(getFunctionsForField(newSelectedField.field).indexOf(value.function) == -1) + { + setDefaultFunctionValue(null); + handleFunctionChange(null, null, null); + forceUpdate(); + } + } + } + + callback(); + } + + + /******************************************************************************* + ** event handler for user selecting a function + *******************************************************************************/ + function handleFunctionChange(event: any, newValue: any, reason: string) + { + value.function = newValue ? newValue.id : null; + callback(); + } + + + /******************************************************************************* + ** event handler for clicking remove button + *******************************************************************************/ + function removeValue(index: number) + { + pivotTableDefinition.values.splice(index, 1); + callback(); + } + + const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName); + + ///////////////////////////////////////////////////////////////////// + // if we're not on an edit screen, return a simpler read-only view // + ///////////////////////////////////////////////////////////////////// + if (!isEditable) + { + let label = "--"; + if (selectedField && value.function) + { + label = pivotTableFunctionLabels[value.function] + " of " + (selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label); + } + + return ({label}); + } + + /////////////////////////////////////////////////////////////////////////////// + // figure out functions to display in drop down, plus selected/default value // + /////////////////////////////////////////////////////////////////////////////// + const functionOptions: any[] = []; + const availableFunctions = getFunctionsForField(selectedField?.field); + for (let pivotTableFunction of availableFunctions) + { + const label = pivotTableFunctionLabels[pivotTableFunction]; + const option = {id: pivotTableFunction, label: label}; + functionOptions.push(option); + + if (option.id == value.function && JSON.stringify(option) != JSON.stringify(defaultFunctionValue)) + { + setDefaultFunctionValue(option); + } + } + + drag(drop(ref)); + + const showValueError = attemptedSubmit && !value.fieldName; + const showFunctionError = attemptedSubmit && !value.function; + + return ( + + drag_indicator + + + + + + + { + const inputProps = params.InputProps; + const originalEndAdornment = inputProps.endAdornment; + inputProps.endAdornment = + {showFunctionError && error_outline} + {originalEndAdornment} + ; + + return () + }} + // @ts-ignore + value={defaultFunctionValue} + inputValue={defaultFunctionValue?.label ?? ""} + options={functionOptions} + onChange={handleFunctionChange} + isOptionEqualToValue={(option, value) => option.id === value.id} + getOptionLabel={(option) => option.label} + autoSelect={true} + autoHighlight={true} + disableClearable + /> + + + + + ); + +}; diff --git a/src/qqq/components/widgets/misc/ReportSetupWidget.tsx b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx new file mode 100644 index 0000000..3466088 --- /dev/null +++ b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx @@ -0,0 +1,374 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; +import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; +import {Alert, Collapse} from "@mui/material"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import Modal from "@mui/material/Modal"; +import Tooltip from "@mui/material/Tooltip/Tooltip"; +import QContext from "QContext"; +import colors from "qqq/assets/theme/base/colors"; +import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; +import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; +import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview"; +import {getCurrentSortIndicator} from "qqq/components/query/BasicAndAdvancedQueryControls"; +import Widget, {HeaderLinkButtonComponent} from "qqq/components/widgets/Widget"; +import QQueryColumns, {Column} from "qqq/models/query/QQueryColumns"; +import RecordQuery from "qqq/pages/records/query/RecordQuery"; +import Client from "qqq/utils/qqq/Client"; +import FilterUtils from "qqq/utils/qqq/FilterUtils"; +import React, {useContext, useEffect, useRef, useState} from "react"; + +interface ReportSetupWidgetProps +{ + isEditable: boolean; + widgetMetaData: QWidgetMetaData; + recordValues: {[name: string]: any}; + onSaveCallback?: (values: {[name: string]: any}) => void; +} + +ReportSetupWidget.defaultProps = { + onSaveCallback: null +}; + +export const buttonSX = + { + border: `1px solid ${colors.grayLines.main} !important`, + borderRadius: "0.75rem", + textTransform: "none", + fontSize: "1rem", + fontWeight: "400", + paddingLeft: "1rem", + paddingRight: "1rem", + opacity: "1", + color: colors.dark.main, + "&:hover": {color: colors.dark.main}, + "&:focus": {color: colors.dark.main}, + "&:focus:not(:hover)": {color: colors.dark.main}, + }; + +export const unborderedButtonSX = Object.assign({}, buttonSX); +unborderedButtonSX.border = "none !important"; +unborderedButtonSX.opacity = "0.7"; + + +const qController = Client.getInstance(); + +/******************************************************************************* + ** Component for editing the main setup of a report - that is: filter & columns + *******************************************************************************/ +export default function ReportSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: ReportSetupWidgetProps): JSX.Element +{ + const [modalOpen, setModalOpen] = useState(false); + const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); + + const [alertContent, setAlertContent] = useState(null as string); + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // we'll actually keep 2 copies of the query filter around here - // + // the one in the record (as json) is one that the backend likes (e.g., possible values as ids) // + // this "frontend" one is one that the frontend can use (possible values as objects w/ labels). // + ////////////////////////////////////////////////////////////////////////////////////////////////// + const [frontendQueryFilter, setFrontendQueryFilter] = useState(null as QQueryFilter); + + const {helpHelpActive} = useContext(QContext); + + const recordQueryRef = useRef(); + + ///////////////////////////// + // load values from record // + ///////////////////////////// + let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter; + let usingDefaultEmptyFilter = false; + if(!queryFilter) + { + queryFilter = new QQueryFilter(); + usingDefaultEmptyFilter = true; + } + + let columns: QQueryColumns = null; + if(recordValues["columnsJson"]) + { + columns = QQueryColumns.buildFromJSON(recordValues["columnsJson"]); + } + + ////////////////////////////////////////////////////////////////////// + // load tableMetaData initially, and if/when selected table changes // + ////////////////////////////////////////////////////////////////////// + useEffect(() => + { + if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"])) + { + (async () => + { + const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"]) + setTableMetaData(tableMetaData); + + const queryFilterForFrontend = Object.assign({}, queryFilter); + await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend) + setFrontendQueryFilter(queryFilterForFrontend) + })(); + } + }, [recordValues]); + + + /******************************************************************************* + ** + *******************************************************************************/ + function openEditor() + { + if(recordValues["tableName"]) + { + setModalOpen(true); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function saveClicked() + { + if(!onSaveCallback) + { + console.log("onSaveCallback was not defined"); + return; + } + + // @ts-ignore possibly 'undefined'. + const view = recordQueryRef?.current?.getCurrentView(); + + view.queryColumns.sortColumnsFixingPinPositions(); + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // keep the query filter that came from the recordQuery screen as the front-end version (w/ possible value objects) // + // but prep a copy of it for the backend, to stringify as json in the record being edited // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + setFrontendQueryFilter(view.queryFilter); + const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter); + + onSaveCallback({queryFilterJson: JSON.stringify(filter), columnsJson: JSON.stringify(view.queryColumns)}); + + closeEditor(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown") + { + if(reason == "backdropClick" || reason == "escapeKeyDown") + { + return; + } + + setModalOpen(false); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function renderColumn(column: Column): JSX.Element + { + const [field, table] = FilterUtils.getField(tableMetaData, column.name) + + if(!column || !column.isVisible || column.name == "__check__" || !field) + { + return (); + } + + const tableLabelPart = table.name != tableMetaData.name ? table.label + ": " : ""; + + return ( + {tableLabelPart}{field.label} + ); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function mayShowQueryPreview(): boolean + { + if(tableMetaData) + { + if(frontendQueryFilter?.criteria?.length > 0 || frontendQueryFilter?.subFilters?.length > 0) + { + return (true); + } + } + + return (false); + } + + /******************************************************************************* + ** + *******************************************************************************/ + function mayShowColumnsPreview(): boolean + { + if(tableMetaData) + { + for(let i = 0; i; + } + + ///////////////////////////////////////////////// + // add link to widget header for opening modal // + ///////////////////////////////////////////////// + const selectTableFirstTooltipTitle = tableMetaData ? null : "You must select a table before you can set up your report filters and columns"; + const labelAdditionalElementsRight: JSX.Element[] = [] + if(isEditable) + { + labelAdditionalElementsRight.push() + } + + + return ( + + { + showHelp("sectionSubhead") && + + {getHelpContent("sectionSubhead")} + + } + + setAlertContent(null)}>{alertContent} + + + +
    Query Filter
    + {mayShowQueryPreview() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)} +
    + { + mayShowQueryPreview() && + 0} removeCriteriaByIndexCallback={null} /> + } + { + !mayShowQueryPreview() && + + { + isEditable && + + + + } + { + !isEditable && Your report has no filters. + } + + } +
    + +
    Columns
    + + { + mayShowColumnsPreview() && + columns.columns.map((column, i) => {renderColumn(column)}) + } + { + !mayShowColumnsPreview() && + + { + isEditable && + + + + } + { + !isEditable && Your report has no columns. + } + + } + +
    + { + modalOpen && + closeEditor(event, reason)}> +
    + + +

    Edit Filters and Columns

    + { + showHelp("modalSubheader") && + + {getHelpContent("modalSubheader")} + + } + { + tableMetaData && + } + + + + + + + +
    +
    +
    +
    + } +
    +
    ); +} diff --git a/src/qqq/models/fields/FieldRules.ts b/src/qqq/models/fields/FieldRules.ts new file mode 100644 index 0000000..9812bdb --- /dev/null +++ b/src/qqq/models/fields/FieldRules.ts @@ -0,0 +1,50 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + + +/******************************************************************************* + ** + *******************************************************************************/ +export interface FieldRule +{ + trigger: FieldRuleTrigger; + sourceField: string; + action: FieldRuleAction; + targetField: string; +} + + +/******************************************************************************* + ** + *******************************************************************************/ +export enum FieldRuleTrigger +{ + ON_CHANGE = "ON_CHANGE" +} + + +/******************************************************************************* + ** + *******************************************************************************/ +export enum FieldRuleAction +{ + CLEAR_TARGET_FIELD = "CLEAR_TARGET_FIELD" +} diff --git a/src/qqq/models/misc/PivotTableDefinitionModels.ts b/src/qqq/models/misc/PivotTableDefinitionModels.ts new file mode 100644 index 0000000..655d8cc --- /dev/null +++ b/src/qqq/models/misc/PivotTableDefinitionModels.ts @@ -0,0 +1,159 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; + + +/******************************************************************************* + ** put a unique key value in all the pivot table group-by and value objects, + ** to help react rendering be sane. + *******************************************************************************/ +export class PivotObjectKey +{ + private static value = new Date().getTime(); + + static next(): number + { + return PivotObjectKey.value++; + } +} + + +/******************************************************************************* + ** Full definition of pivot table + *******************************************************************************/ +export class PivotTableDefinition +{ + rows: PivotTableGroupBy[]; + columns: PivotTableGroupBy[]; + values: PivotTableValue[]; +} + + +/******************************************************************************* + ** A field that the pivot table is grouped by, either as a row or column + *******************************************************************************/ +export class PivotTableGroupBy +{ + fieldName: string; + key: number; + + constructor() + { + this.key = PivotObjectKey.next(); + } +} + + +/******************************************************************************* + ** A field & function that serves as the computed values in the pivot table + *******************************************************************************/ +export class PivotTableValue +{ + fieldName: string; + function: PivotTableFunction; + + key: number; + + constructor() + { + this.key = PivotObjectKey.next(); + } +} + + +/******************************************************************************* + ** Functions that can be applied to pivot table values + *******************************************************************************/ +export enum PivotTableFunction +{ + SUM = "SUM", + COUNT = "COUNT", + AVERAGE = "AVERAGE", + MAX = "MAX", + MIN = "MIN", + PRODUCT = "PRODUCT", + + /////////////////////////////////////////////////////////////////////////////// + // i don't think we have a useful version of count-nums --unless we allowed // + // it on string fields, and counted if they looked like numbers? is that // + // what we should do? ... leave here as zombie in case that request comes in // + /////////////////////////////////////////////////////////////////////////////// + // COUNT_NUMS = "COUNT_NUMS", + + STD_DEV = "STD_DEV", + STD_DEVP = "STD_DEVP", + VAR = "VAR", + VARP = "VARP", +} + +const allFunctions = [ + PivotTableFunction.SUM, + PivotTableFunction.COUNT, + PivotTableFunction.AVERAGE, + PivotTableFunction.MAX, + PivotTableFunction.MIN, + PivotTableFunction.PRODUCT, + // PivotTableFunction.COUNT_NUMS, + PivotTableFunction.STD_DEV, + PivotTableFunction.STD_DEVP, + PivotTableFunction.VAR, + PivotTableFunction.VARP +]; + +const onlyCount = [PivotTableFunction.COUNT]; + +const functionsForDates = [PivotTableFunction.COUNT, PivotTableFunction.AVERAGE, PivotTableFunction.MAX, PivotTableFunction.MIN]; + +export const functionsPerFieldType: { [type: string]: PivotTableFunction[] } = {}; +functionsPerFieldType[QFieldType.STRING] = onlyCount; +functionsPerFieldType[QFieldType.BOOLEAN] = onlyCount; +functionsPerFieldType[QFieldType.BLOB] = onlyCount; +functionsPerFieldType[QFieldType.HTML] = onlyCount; +functionsPerFieldType[QFieldType.PASSWORD] = onlyCount; +functionsPerFieldType[QFieldType.TEXT] = onlyCount; +functionsPerFieldType[QFieldType.TIME] = onlyCount; + +functionsPerFieldType[QFieldType.INTEGER] = allFunctions; +functionsPerFieldType[QFieldType.DECIMAL] = allFunctions; +// functionsPerFieldType[QFieldType.LONG] = allFunctions; + +functionsPerFieldType[QFieldType.DATE] = functionsForDates; +functionsPerFieldType[QFieldType.DATE_TIME] = functionsForDates; + + +////////////////////////////////////// +// labels for pivot table functions // +////////////////////////////////////// +export const pivotTableFunctionLabels = + { + "SUM": "Sum", + "COUNT": "Count", + "AVERAGE": "Average", + "MAX": "Max", + "MIN": "Min", + "PRODUCT": "Product", + // "COUNT_NUMS": "Count Numbers", + "STD_DEV": "StdDev", + "STD_DEVP": "StdDevp", + "VAR": "Var", + "VARP": "Varp" + }; diff --git a/src/qqq/models/query/QQueryColumns.ts b/src/qqq/models/query/QQueryColumns.ts index 2a7f2c6..8b5dccb 100644 --- a/src/qqq/models/query/QQueryColumns.ts +++ b/src/qqq/models/query/QQueryColumns.ts @@ -23,14 +23,13 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {GridPinnedColumns} from "@mui/x-data-grid-pro"; -import quickSightChart from "qqq/components/widgets/misc/QuickSightChart"; import DataGridUtils from "qqq/utils/DataGridUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; /******************************************************************************* ** member object *******************************************************************************/ -interface Column +export interface Column { name: string; isVisible: boolean; @@ -81,11 +80,19 @@ export default class QQueryColumns fields.forEach((field) => { const column: Column = {name: field.name, isVisible: true, width: DataGridUtils.getColumnWidthForField(field, table)}; - queryColumns.columns.push(column); if (field.name == table.primaryKeyField) { column.pinned = "left"; + + ////////////////////////////////////////////////// + // insert the primary key field after __check__ // + ////////////////////////////////////////////////// + queryColumns.columns.splice(1, 0, column); + } + else + { + queryColumns.columns.push(column); } }); @@ -393,6 +400,42 @@ export default class QQueryColumns return columnVisibilityModel; }; + + /******************************************************************************* + ** sort the columns list, so that pinned columns go to the front (left) or back + ** (right) of the list. + *******************************************************************************/ + public sortColumnsFixingPinPositions = (): void => + { + ///////////////////////////////////////////////////////////////////////////////////////////// + // do a sort to push pinned-left columns to the start, and pinned-right columns to the end // + // and otherwise, leave everything alone // + ///////////////////////////////////////////////////////////////////////////////////////////// + this.columns = this.columns.sort((a: Column, b: Column) => + { + if(a.pinned == "left" && b.pinned != "left") + { + return -1; + } + else if(b.pinned == "left" && a.pinned != "left") + { + return 1; + } + else if(a.pinned == "right" && b.pinned != "right") + { + return 1; + } + else if(b.pinned == "right" && a.pinned != "right") + { + return -1; + } + else + { + return (0); + } + }); + } + } diff --git a/src/qqq/pages/processes/ProcessRun.tsx b/src/qqq/pages/processes/ProcessRun.tsx index 82b8c5f..d9bfdfc 100644 --- a/src/qqq/pages/processes/ProcessRun.tsx +++ b/src/qqq/pages/processes/ProcessRun.tsx @@ -226,8 +226,19 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is setShowFullHelpText(!showFullHelpText); }; - const download = (url: string, fileName: string) => + const download = (processValues: {[key: string]: string}) => { + let url; + let fileName = processValues.downloadFileName; + if(processValues.serverFilePath) + { + url = `/download/${encodeURIComponent(processValues.downloadFileName)}?filePath=${encodeURIComponent(processValues.serverFilePath)}`; + } + else if(processValues.storageTableName && processValues.storageReference) + { + url = `/download/${encodeURIComponent(processValues.downloadFileName)}?storageTableName=${encodeURIComponent(processValues.storageTableName)}&storageReference=${encodeURIComponent(processValues.storageReference)}`; + } + ///////////////////////////////////////////////////////////////////////////////////////////// // todo - this could be simplified, i think? // // it was originally built like this when we had to submit full access token to backend... // @@ -556,7 +567,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is Download - download(`/download/${processValues.downloadFileName}?filePath=${processValues.serverFilePath}`, processValues.downloadFileName)} sx={{cursor: "pointer"}}> + download(processValues)} sx={{cursor: "pointer"}}> download_for_offline {processValues.downloadFileName} diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 5bcedfe..05fc780 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -76,7 +76,7 @@ import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; -import React, {forwardRef, useContext, useEffect, useReducer, useRef, useState} from "react"; +import React, {forwardRef, useContext, useEffect, useImperativeHandle, useReducer, useRef, useState} from "react"; import {useLocation, useNavigate, useSearchParams} from "react-router-dom"; const CURRENT_SAVED_VIEW_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedViewId"; @@ -84,18 +84,18 @@ const DENSITY_LOCAL_STORAGE_KEY_ROOT = "qqq.density"; const VIEW_LOCAL_STORAGE_KEY_ROOT = "qqq.recordQueryView"; export const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant"; +export type QueryScreenUsage = "queryScreen" | "reportSetup" interface Props { table?: QTableMetaData; launchProcess?: QProcessMetaData; + usage?: QueryScreenUsage; + isModal?: boolean; + initialQueryFilter?: QQueryFilter; + initialColumns?: QQueryColumns; } -RecordQuery.defaultProps = { - table: null, - launchProcess: null -}; - /////////////////////////////////////////////////////// // define possible values for our pageState variable // /////////////////////////////////////////////////////// @@ -107,8 +107,13 @@ const qController = Client.getInstance(); ** function to produce standard version of the screen while we're "loading" ** like the main table meta data etc. *******************************************************************************/ -const getLoadingScreen = () => +const getLoadingScreen = (isModal: boolean) => { + if(isModal) + { + return ( ); + } + return (   ); @@ -120,7 +125,7 @@ const getLoadingScreen = () => ** ** Yuge component. The best. Lots of very smart people are saying so. *******************************************************************************/ -function RecordQuery({table, launchProcess}: Props): JSX.Element +const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, initialColumns}: Props, ref) => { const tableName = table.name; const [searchParams] = useSearchParams(); @@ -136,6 +141,44 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const [firstRender, setFirstRender] = useState(true); const [isFirstRenderAfterChangingTables, setIsFirstRenderAfterChangingTables] = useState(false); + const [loadedFilterFromInitialFilterParam, setLoadedFilterFromInitialFilterParam] = useState(false); + + const mayWriteLocalStorage = usage == "queryScreen"; + + + /******************************************************************************* + ** + *******************************************************************************/ + function localStorageSet(key: string, value: string) + { + if(mayWriteLocalStorage) + { + localStorage.setItem(key, value); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function localStorageRemove(key: string) + { + if(mayWriteLocalStorage) + { + localStorage.removeItem(key); + } + } + + useImperativeHandle(ref, () => + { + return { + getCurrentView(): RecordQueryView + { + return view; + } + } + }); + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // manage "state" being passed from some screens (like delete) into query screen - by grabbing, and then deleting // //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -180,7 +223,9 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element ///////////////////////////////////// const densityLocalStorageKey = `${DENSITY_LOCAL_STORAGE_KEY_ROOT}`; - // only load things out of local storage on the first render + /////////////////////////////////////////////////////////////// + // only load things out of local storage on the first render // + /////////////////////////////////////////////////////////////// if (firstRender) { console.log("This is firstRender, so reading defaults from local storage..."); @@ -211,6 +256,26 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element defaultView.mode = defaultMode; } + if(firstRender) + { + ///////////////////////////////////////////////////////////////////////// + // allow a caller to send in an initial filter & set of columns. // + // only to be used on "first render". // + // JSON.parse(JSON.stringify()) to do deep clone and keep object clean // + // unclear why not needed on initialColumns... // + ///////////////////////////////////////////////////////////////////////// + if (initialQueryFilter) + { + defaultView.queryFilter = JSON.parse(JSON.stringify(initialQueryFilter)); + setLoadedFilterFromInitialFilterParam(true); + } + + if (initialColumns) + { + defaultView.queryColumns = initialColumns; + } + } + ///////////////////////////////////////////////////////////////////////////////////////// // in case the view is missing any of these attributes, give them a reasonable default // ///////////////////////////////////////////////////////////////////////////////////////// @@ -418,51 +483,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }; - /******************************************************************************* - ** - *******************************************************************************/ - const prepQueryFilterForBackend = (sourceFilter: QQueryFilter) => - { - const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.subFilters, sourceFilter.booleanOperator); - for (let i = 0; i < sourceFilter?.criteria?.length; i++) - { - const criteria = sourceFilter.criteria[i]; - const {criteriaIsValid} = validateCriteria(criteria, null); - if (criteriaIsValid) - { - if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) - { - /////////////////////////////////////////////////////////////////////////////////////////// - // do this to avoid submitting an empty-string argument for blank/not-blank operators... // - /////////////////////////////////////////////////////////////////////////////////////////// - filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, [])); - } - else - { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName); - filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field))); - } - } - } - - ///////////////////////////////////////// - // recursively prep subfilters as well // - ///////////////////////////////////////// - let subFilters = [] as QQueryFilter[]; - for (let j = 0; j < sourceFilter?.subFilters?.length; j++) - { - subFilters.push(prepQueryFilterForBackend(sourceFilter.subFilters[j])); - } - - filterForBackend.subFilters = subFilters; - filterForBackend.skip = pageNumber * rowsPerPage; - filterForBackend.limit = rowsPerPage; - return filterForBackend; - }; - /******************************************************************************* ** @@ -494,7 +514,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element totalRecords: totalRecords, columnsModel: columnsModel, columnVisibilityModel: columnVisibilityModel, - queryFilter: prepQueryFilterForBackend(queryFilter) + queryFilter: FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter) }; exportMenu = (<> @@ -688,8 +708,11 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (localStorage.getItem(currentSavedViewLocalStorageKey)) { - currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); - navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + if(usage == "queryScreen") + { + currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); + navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + } } else { @@ -726,7 +749,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { FilterUtils.stripAwayIncompleteCriteria(viewForLocalStorage.queryFilter) } - localStorage.setItem(viewLocalStorageKey, JSON.stringify(viewForLocalStorage)); + localStorageSet(viewLocalStorageKey, JSON.stringify(viewForLocalStorage)); } catch(e) { @@ -861,7 +884,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /******************************************************************************* ** This is the method that actually executes a query to update the data in the table. *******************************************************************************/ - const updateTable = (reason?: string) => + const updateTable = (reason?: string, clearOutCount = true) => { if (pageState != "ready") { @@ -887,7 +910,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // copy the orderBys & operator into it - but we'll build its criteria one-by-one, // // as clones, as we'll need to tweak them a bit // ///////////////////////////////////////////////////////////////////////////////////// - const filterForBackend = prepQueryFilterForBackend(queryFilter); + const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter, pageNumber, rowsPerPage); ////////////////////////////////////////// // figure out joins to use in the query // @@ -913,6 +936,12 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(`Issuing query: ${thisQueryId}`); if (tableMetaData.capabilities.has(Capability.TABLE_COUNT)) { + if(clearOutCount) + { + setTotalRecords(null); + setDistinctRecords(null); + } + let includeDistinct = isJoinMany(tableMetaData, getVisibleJoinTables()); qController.count(tableName, filterForBackend, queryJoins, includeDistinct, tableVariant).then(([count, distinctCount]) => { @@ -945,7 +974,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element console.log(`Received error for query ${thisQueryId}`); console.log(error); - var errorMessage; + let errorMessage; if (error && error.message) { errorMessage = error.message; @@ -1110,7 +1139,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (state && state.density && state.density.value !== density) { setDensity(state.density.value); - localStorage.setItem(densityLocalStorageKey, JSON.stringify(state.density.value)); + localStorageSet(densityLocalStorageKey, JSON.stringify(state.density.value)); } }; @@ -1414,7 +1443,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (selectFullFilterState === "filter") { - const filterForBackend = prepQueryFilterForBackend(queryFilter); + const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter); filterForBackend.skip = 0; filterForBackend.limit = null; return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(filterForBackend))}`; @@ -1422,7 +1451,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (selectFullFilterState === "filterSubset") { - const filterForBackend = prepQueryFilterForBackend(queryFilter); + const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter); filterForBackend.skip = 0; filterForBackend.limit = selectionSubsetSize; return `?recordsParam=filterJSON&filterJSON=${encodeURIComponent(JSON.stringify(filterForBackend))}`; @@ -1445,14 +1474,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element { if (selectFullFilterState === "filter") { - const filterForBackend = prepQueryFilterForBackend(queryFilter); + const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter); filterForBackend.skip = 0; filterForBackend.limit = null; setRecordIdsForProcess(filterForBackend); } else if (selectFullFilterState === "filterSubset") { - const filterForBackend = prepQueryFilterForBackend(queryFilter); + const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter); filterForBackend.skip = 0; filterForBackend.limit = selectionSubsetSize; setRecordIdsForProcess(filterForBackend); @@ -1607,7 +1636,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element //////////////////////////////////////////////////////////////// // todo can/should/does this move into the view's "identity"? // //////////////////////////////////////////////////////////////// - localStorage.setItem(currentSavedViewLocalStorageKey, `${savedViewRecord.values.get("id")}`); + localStorageSet(currentSavedViewLocalStorageKey, `${savedViewRecord.values.get("id")}`); }; @@ -1617,7 +1646,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element const doClearCurrentSavedView = () => { setCurrentSavedView(null); - localStorage.removeItem(currentSavedViewLocalStorageKey); + localStorageRemove(currentSavedViewLocalStorageKey); }; @@ -1667,7 +1696,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // wipe away the saved view // ////////////////////////////// setCurrentSavedView(null); - localStorage.removeItem(currentSavedViewLocalStorageKey); + localStorageRemove(currentSavedViewLocalStorageKey); /////////////////////////////////////////////// // activate a new default view for the table // @@ -1913,7 +1942,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element *******************************************************************************/ const openColumnStatistics = async (column: GridColDef) => { - setFilterForColumnStats(prepQueryFilterForBackend(queryFilter)); + setFilterForColumnStats(FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter)); setColumnStatsFieldName(column.field); const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, column.field); @@ -2188,27 +2217,30 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element -
    - - - { - setSelectionSubsetSizePromptOpen(false); - - if (value !== undefined) + { + usage == "queryScreen" && +
    + + { - if (typeof value === "number" && value > 0) + setSelectionSubsetSizePromptOpen(false); + + if (value !== undefined) { - programmaticallySelectSomeOrAllRows(value); - setSelectionSubsetSize(value); - setSelectFullFilterState("filterSubset"); + if (typeof value === "number" && value > 0) + { + programmaticallySelectSomeOrAllRows(value); + setSelectionSubsetSize(value); + setSelectFullFilterState("filterSubset"); + } + else + { + setAlertContent("Unexpected value: " + value); + } } - else - { - setAlertContent("Unexpected value: " + value); - } - } - }} /> -
    + }} /> +
    + }
    { @@ -2271,7 +2303,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // to avoid both this useEffect and the one below from both doing an "initial query", // // only run this one if at least 1 query has already been ran // //////////////////////////////////////////////////////////////////////////////////////// - updateTable("useEffect(pageNumber,rowsPerPage)"); + updateTable("useEffect(pageNumber,rowsPerPage)", false); } }, [pageNumber, rowsPerPage]); @@ -2306,7 +2338,18 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (pageState == "ready") { - const newFilterHash = JSON.stringify(prepQueryFilterForBackend(queryFilter)); + const filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, queryFilter); + + /////////////////////////////////////////////////////////////////////// + // remove the skip & limit (e.g., pagination) from this hash - // + // as we have a specific useEffect watching these, specifically // + // so we can pass the dont-clear-count flag into updateTable, // + // to try to keep the count from flashing back & forth to "Counting" // + /////////////////////////////////////////////////////////////////////// + filterForBackend.skip = null; + filterForBackend.limit = null; + + const newFilterHash = JSON.stringify(filterForBackend); if (filterHash != newFilterHash) { setFilterHash(newFilterHash); @@ -2477,11 +2520,14 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element // if the last time we were on this table, a currentSavedView was written to local storage - // // then navigate back to that view's URL - unless - it looks like we're on a process! // /////////////////////////////////////////////////////////////////////////////////////////////// - if (localStorage.getItem(currentSavedViewLocalStorageKey) && !urlLooksLikeProcess()) + if (localStorage.getItem(currentSavedViewLocalStorageKey) && !urlLooksLikeProcess() && !loadedFilterFromInitialFilterParam) { const currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey)); console.log(`returning to previously active saved view ${currentSavedViewId}`); - navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + if(usage == "queryScreen") + { + navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`); + } setViewIdInLocation(currentSavedViewId); ///////////////////////////////////////////////////////////////////////////////////////////////////// @@ -2537,7 +2583,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element promptForTableVariantSelection(); } - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } //////////////////////////////////////////////////////////////////////// @@ -2571,7 +2617,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setRows([]); setIsFirstRenderAfterChangingTables(true); - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } ///////////////////////////////////////////////////////////////////////////////////////////// @@ -2615,7 +2661,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element if (pageState != "ready") { console.log(`page state is ${pageState}... no-op while those complete async's run...`); - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -2624,13 +2670,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element /////////////////////////////////////////////////////////////////////////////////////////// if (!tableMetaData) { - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } let savedViewsComponent = null; if (metaData && metaData.processes.has("querySavedView")) { - savedViewsComponent = (); + savedViewsComponent = (); } @@ -2707,7 +2753,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }; ////////////////////////////////////////////////////////////////////////////////////////////////////////// - // these numbers help set the height of the grid (so page won't scroll) based on spcae above & below it // + // these numbers help set the height of the grid (so page won't scroll) based on space above & below it // ////////////////////////////////////////////////////////////////////////////////////////////////////////// let spaceBelowGrid = 40; let spaceAboveGrid = 205; @@ -2721,40 +2767,48 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element spaceAboveGrid += 60; } + if(isModal) + { + spaceAboveGrid += 130; + } + //////////////////////// // main screen render // //////////////////////// - return ( - + const body = ( + {pageLoadingState.isLoading() && ""} {pageLoadingState.isLoadingSlow() && "Loading..."} - {pageLoadingState.isNotLoading() && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)} + {pageLoadingState.isNotLoading() && !isModal && getPageHeader(tableMetaData, visibleJoinTables, tableVariant)} - - - + { + !isModal && + + + + { + tableMetaData && + + } + { - tableMetaData && - + table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && + } - { - table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission && - - } - + }
    {/* @@ -2808,6 +2862,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element setQuickFilterFieldNames={doSetQuickFilterFieldNames} gridApiRef={gridApiRef} mode={mode} + queryScreenUsage={usage} setMode={doSetMode} savedViewsComponent={savedViewsComponent} columnMenuComponent={buildColumnMenu()} @@ -2848,7 +2903,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element sortingMode="server" filterMode="server" page={pageNumber} - checkboxSelection + checkboxSelection={usage == "queryScreen"} disableSelectionOnClick autoHeight={false} rows={rows} @@ -2857,7 +2912,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element rowBuffer={10} rowCount={totalRecords === null || totalRecords === undefined ? 0 : totalRecords} onPageSizeChange={handleRowsPerPageChange} - onRowClick={handleRowClick} + onRowClick={usage == "queryScreen" ? handleRowClick : null} onStateChange={handleStateChange} density={density} loading={loading} @@ -2915,8 +2970,28 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element }
    -
    + ); -} + + if(isModal) + { + return body; + } + + return ( + {body} + ) +}) + + +RecordQuery.defaultProps = { + table: null, + usage: "queryScreen", + launchProcess: null, + isModal: false, + initialQueryFilter: null, + initialColumns: null, +}; + export default RecordQuery; diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index e08a890..e8388a3 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -508,7 +508,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element sectionFieldElements.set(section.name, - + ); @@ -850,7 +850,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element return ( - + diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 2b6d772..6364e24 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -682,6 +682,12 @@ input[type="search"]::-webkit-search-results-decoration border: none; } +.entityForm h5, +.recordView h5 +{ + font-weight: 500; +} + .MuiPickersDay-root.Mui-selected, .MuiPickersDay-root.MuiPickersDay-dayWithMargin:hover { color: white; diff --git a/src/qqq/utils/qqq/FilterUtils.tsx b/src/qqq/utils/qqq/FilterUtils.tsx index 153fbcb..eaa8940 100644 --- a/src/qqq/utils/qqq/FilterUtils.tsx +++ b/src/qqq/utils/qqq/FilterUtils.tsx @@ -32,6 +32,7 @@ import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryF import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; import Box from "@mui/material/Box"; import {GridSortModel} from "@mui/x-data-grid-pro"; +import {validateCriteria} from "qqq/components/query/FilterCriteriaRow"; import TableUtils from "qqq/utils/qqq/TableUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; @@ -612,6 +613,58 @@ class FilterUtils } } + + /******************************************************************************* + ** make a new query filter, based on the input one, but w/ values good for the + ** backend. such as, possible values as just ids, not objects w/ a label; + ** date-times formatted properly and in UTC + *******************************************************************************/ + public static prepQueryFilterForBackend(tableMetaData: QTableMetaData, sourceFilter: QQueryFilter, pageNumber?: number, rowsPerPage?: number): QQueryFilter + { + const filterForBackend = new QQueryFilter([], sourceFilter.orderBys, sourceFilter.subFilters, sourceFilter.booleanOperator); + for (let i = 0; i < sourceFilter?.criteria?.length; i++) + { + const criteria = sourceFilter.criteria[i]; + const {criteriaIsValid} = validateCriteria(criteria, null); + if (criteriaIsValid) + { + if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK) + { + /////////////////////////////////////////////////////////////////////////////////////////// + // do this to avoid submitting an empty-string argument for blank/not-blank operators... // + /////////////////////////////////////////////////////////////////////////////////////////// + filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, [])); + } + else + { + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // else push a clone of the criteria - since it may get manipulated below (convertFilterPossibleValuesToIds) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////// + const [field] = FilterUtils.getField(tableMetaData, criteria.fieldName); + filterForBackend.criteria.push(new QFilterCriteria(criteria.fieldName, criteria.operator, FilterUtils.cleanseCriteriaValueForQQQ(criteria.values, field))); + } + } + } + + ///////////////////////////////////////// + // recursively prep subfilters as well // + ///////////////////////////////////////// + let subFilters = [] as QQueryFilter[]; + for (let j = 0; j < sourceFilter?.subFilters?.length; j++) + { + subFilters.push(FilterUtils.prepQueryFilterForBackend(tableMetaData, sourceFilter.subFilters[j])); + } + + filterForBackend.subFilters = subFilters; + + if(pageNumber !== undefined && rowsPerPage !== undefined) + { + filterForBackend.skip = pageNumber * rowsPerPage; + filterForBackend.limit = rowsPerPage; + } + + return filterForBackend; + }; } export default FilterUtils;