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 6a1cd45..54f7b1b 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -46,6 +46,7 @@ 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"; @@ -108,6 +109,7 @@ 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[]); @@ -427,6 +429,32 @@ function EntityForm(props: Props): JSX.Element } + /******************************************************************************* + ** + *******************************************************************************/ + 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); @@ -435,6 +463,8 @@ function EntityForm(props: Props): JSX.Element const tableMetaData = await qController.loadTableMetaData(tableName); setTableMetaData(tableMetaData); + setupFieldRules(tableMetaData); + const metaData = await qController.loadMetaData(); setMetaData(metaData); @@ -929,15 +959,6 @@ function EntityForm(props: Props): JSX.Element }; - // todo - get from meta data! - const fieldRules = - [ - {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "columnsJson"}, - {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "queryFilterJson"}, - {trigger: "onChange", sourceField: "tableName", action: "clearOtherField", targetField: "pivotTableJson"} - ] - - /******************************************************************************* ** process a form-field having a changed value (e.g., apply field rules). *******************************************************************************/ @@ -945,11 +966,11 @@ function EntityForm(props: Props): JSX.Element { for (let fieldRule of fieldRules) { - if(fieldRule.trigger == "onChange" && fieldRule.sourceField == fieldName) + if(fieldRule.trigger == FieldRuleTrigger.ON_CHANGE && fieldRule.sourceField == fieldName) { switch (fieldRule.action) { - case "clearOtherField": + case FieldRuleAction.CLEAR_TARGET_FIELD: console.log(`Clearing value from [${fieldRule.targetField}] due to change in [${fieldName}]`); valueChangesToMake[fieldRule.targetField] = null; break; diff --git a/src/qqq/components/misc/FieldAutoComplete.tsx b/src/qqq/components/misc/FieldAutoComplete.tsx index 495f831..7f5e642 100644 --- a/src/qqq/components/misc/FieldAutoComplete.tsx +++ b/src/qqq/components/misc/FieldAutoComplete.tsx @@ -23,7 +23,9 @@ 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, useState} from "react"; @@ -33,14 +35,16 @@ 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 + variant?: "standard" | "filled" | "outlined"; + label?: string; + textFieldSX?: any; + autocompleteSlotProps?: any; + hasError?: boolean; } FieldAutoComplete.defaultProps = @@ -53,6 +57,8 @@ FieldAutoComplete.defaultProps = variant: "standard", label: "Field", textFieldSX: null, + autocompleteSlotProps: null, + hasError: false, }; function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string) @@ -62,12 +68,12 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a { const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name; - if(hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName) + if (hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName) { continue; } - if(availableFieldNames && availableFieldNames.indexOf(fieldName) == -1) + if (availableFieldNames && availableFieldNames.indexOf(fieldName) == -1) { continue; } @@ -80,9 +86,9 @@ function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: a /******************************************************************************* ** 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}: FieldAutoCompleteProps): JSX.Element +export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError}: FieldAutoCompleteProps): JSX.Element { - const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null) + const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null); const fieldOptions: any[] = []; makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName); @@ -149,8 +155,8 @@ 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; } @@ -161,14 +167,24 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi *******************************************************************************/ function onChange(event: any, newValue: any, reason: string) { - setSelectedFieldName(newValue ? newValue.fieldName : null) + 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} @@ -179,7 +195,7 @@ export default function FieldAutoComplete({id, metaData, tableMetaData, handleFi renderOption={(props, option, state) => renderFieldOption(props, option, state)} autoSelect={true} autoHighlight={true} - slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}} + slotProps={autocompleteSlotProps ?? {}} {...alsoOpen} /> diff --git a/src/qqq/components/misc/SavedViews.tsx b/src/qqq/components/misc/SavedViews.tsx index 000cca6..49d7ef6 100644 --- a/src/qqq/components/misc/SavedViews.tsx +++ b/src/qqq/components/misc/SavedViews.tsx @@ -431,9 +431,12 @@ 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 + { + 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.}> @@ -471,6 +474,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
} { + isQueryScreen && handleDropdownOptionClick(CLEAR_OPTION)}> monitor @@ -479,7 +483,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { - hasSavedReportsPermission && + isQueryScreen && hasSavedReportsPermission && handleDropdownOptionClick(NEW_REPORT_OPTION)}> article @@ -487,7 +491,9 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } - + { + isQueryScreen && + } Your Saved Views { savedViews && savedViews.length > 0 ? ( @@ -497,7 +503,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab ) ): ( - + You do not have any saved views for this table. ) @@ -606,7 +612,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } }> - + {/* vertical rule */} @@ -618,7 +624,7 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab } { - currentSavedView && viewIsModified && <> + isQueryScreen && currentSavedView && viewIsModified && <> Unsaved Changes
    @@ -637,6 +643,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 index 1ea81de..f951da2 100644 --- a/src/qqq/components/query/AdvancedQueryPreview.tsx +++ b/src/qqq/components/query/AdvancedQueryPreview.tsx @@ -138,7 +138,7 @@ export default function AdvancedQueryPreview({tableMetaData, queryFilter, isEdit display="inline-block" width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} - minHeight={"2.375rem"} + minHeight={"2.5rem"} p={"0.5rem"} pb={"0.125rem"} {...moreSX} 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/Widget.tsx b/src/qqq/components/widgets/Widget.tsx index ccf0d45..8756d15 100644 --- a/src/qqq/components/widgets/Widget.tsx +++ b/src/qqq/components/widgets/Widget.tsx @@ -206,9 +206,16 @@ interface HeaderToggleComponentProps label: string; getValue: () => boolean; onClickCallback: () => void; + disabled?: boolean; + disabledTooltip?: string; } -export function HeaderToggleComponent({label, getValue, onClickCallback}: HeaderToggleComponentProps): JSX.Element +HeaderToggleComponent.defaultProps = { + disabled: false, + disabledTooltip: null +}; + +export function HeaderToggleComponent({label, getValue, onClickCallback, disabled, disabledTooltip}: HeaderToggleComponentProps): JSX.Element { const onClick = () => { @@ -217,9 +224,13 @@ export function HeaderToggleComponent({label, getValue, onClickCallback}: Header return ( - - {label} - + + + + {label} + + + ); } diff --git a/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx b/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx index 56454aa..9e961e2 100644 --- a/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx +++ b/src/qqq/components/widgets/misc/PivotTableGroupByElement.tsx @@ -51,6 +51,7 @@ export interface PivotTableGroupByElementProps groupBy: PivotTableGroupBy; rowsOrColumns: "rows" | "columns"; callback: () => void; + attemptedSubmit?: boolean; } @@ -67,7 +68,7 @@ interface DragItem /******************************************************************************* ** *******************************************************************************/ -export const PivotTableGroupByElement: FC = ({id, index, dragCallback, rowsOrColumns, metaData, tableMetaData, pivotTableDefinition, groupBy, usedGroupByFieldNames, availableFieldNames, isEditable, callback}) => +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 // @@ -171,7 +172,7 @@ export const PivotTableGroupByElement: FC = ({id, if (selectedField) { const label = selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label; - return ({label}); + return ({label}); } return (); @@ -179,6 +180,8 @@ export const PivotTableGroupByElement: FC = ({id, preview(drop(ref)); + const showError = attemptedSubmit && !groupBy.fieldName; + return ( drag_indicator @@ -195,6 +198,7 @@ export const PivotTableGroupByElement: FC = ({id, hiddenFieldNames={usedGroupByFieldNames} availableFieldNames={availableFieldNames} defaultValue={getSelectedFieldForAutoComplete(tableMetaData, groupBy.fieldName)} + hasError={showError} /> diff --git a/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx index c7aa642..60370ea 100644 --- a/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx +++ b/src/qqq/components/widgets/misc/PivotTableSetupWidget.tsx @@ -23,19 +23,25 @@ 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"; @@ -52,22 +58,6 @@ export const DragItemTypes = VALUE: "value" }; -export const buttonSX = - { - border: `1px solid ${colors.grayLines.main} !important`, - borderRadius: "0.75rem", - textTransform: "none", - fontSize: "1rem", - fontWeight: "400", - width: "160px", - paddingLeft: 0, - paddingRight: 0, - color: colors.dark.main, - "&:hover": {color: colors.dark.main}, - "&:focus": {color: colors.dark.main}, - "&:focus:not(:hover)": {color: colors.dark.main}, - }; - export const xIconButtonSX = { border: `1px solid ${colors.grayLines.main} !important`, @@ -139,11 +129,20 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor 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); - const [pivotTableDefinition, setPivotTableDefinition] = useState(null as PivotTableDefinition); + /////////////////////////////////////////////////////////////////////////////////// + // 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 [availableFieldNames, setAvailableFieldNames] = useState([] as string[]); @@ -195,9 +194,9 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor updateUsedGroupByFieldNames(originalPivotTableDefinition); } - if(recordValues["columnsJson"]) + if (recordValues["columnsJson"]) { - updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns) + updateAvailableFieldNames(JSON.parse(recordValues["columnsJson"]) as QQueryColumns); } (async () => @@ -251,6 +250,11 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor const newEnabled = !!!getEnabled(); setEnabled(newEnabled); onSaveCallback({usePivotTable: newEnabled}); + + if (!newEnabled) + { + onSaveCallback({pivotTableJson: null}); + } } @@ -268,13 +272,13 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function addGroupBy(rowsOrColumns: "rows" | "columns") { - if (!pivotTableDefinition[rowsOrColumns]) + if (!modalPivotTableDefinition[rowsOrColumns]) { - pivotTableDefinition[rowsOrColumns] = []; + modalPivotTableDefinition[rowsOrColumns] = []; } - pivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy()); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + modalPivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy()); + validateForm() forceUpdate(); } @@ -284,8 +288,8 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function groupByChangedCallback() { - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); - updateUsedGroupByFieldNames(); + updateUsedGroupByFieldNames(modalPivotTableDefinition); + validateForm() forceUpdate(); } @@ -295,13 +299,13 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function addValue() { - if (!pivotTableDefinition.values) + if (!modalPivotTableDefinition.values) { - pivotTableDefinition.values = []; + modalPivotTableDefinition.values = []; } - pivotTableDefinition.values.push(new PivotTableValue()); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + modalPivotTableDefinition.values.push(new PivotTableValue()); + validateForm() forceUpdate(); } @@ -311,8 +315,8 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ function removeValue(index: number) { - pivotTableDefinition.values.splice(index, 1); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); + modalPivotTableDefinition.values.splice(index, 1); + validateForm() forceUpdate(); } @@ -346,7 +350,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor const fieldNames: string[] = []; for (let i = 0; i < columns?.columns?.length; i++) { - if(columns.columns[i].isVisible) + if (columns.columns[i].isVisible) { fieldNames.push(columns.columns[i].name); } @@ -375,13 +379,11 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor const handleFieldChange = (event: any, newValue: any, reason: string) => { value.fieldName = newValue ? newValue.fieldName : null; - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); }; const handleFunctionChange = (event: any, newValue: any, reason: string) => { value.function = newValue ? newValue.id : null; - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); }; const functionOptions: any[] = []; @@ -446,14 +448,13 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ const moveGroupBy = useCallback((rowsOrColumns: "rows" | "columns", dragIndex: number, hoverIndex: number) => { - const array = pivotTableDefinition[rowsOrColumns]; + const array = modalPivotTableDefinition[rowsOrColumns]; const dragItem = array[dragIndex]; array.splice(dragIndex, 1); array.splice(hoverIndex, 0, dragItem); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); forceUpdate(); - }, [pivotTableDefinition]); + }, [modalPivotTableDefinition]); /******************************************************************************* @@ -461,183 +462,388 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor *******************************************************************************/ const moveValue = useCallback((dragIndex: number, hoverIndex: number) => { - const array = pivotTableDefinition.values; + const array = modalPivotTableDefinition.values; const dragItem = array[dragIndex]; array.splice(dragIndex, 1); array.splice(hoverIndex, 0, dragItem); - onSaveCallback({pivotTableJson: JSON.stringify(pivotTableDefinition)}); forceUpdate(); - }, [pivotTableDefinition]); + }, [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} />); + labelAdditionalElementsRight.push( enabled} onClickCallback={toggleEnabled} />); } - const selectTableFirstTooltipTitle = tableMetaData ? null : "You must select a table before you can set up a pivot table"; - /******************************************************************************* ** render a group-by (row or column) *******************************************************************************/ - const renderGroupBy = useCallback( - (groupBy: PivotTableGroupBy, rowsOrColumns: "rows" | "columns", index: number) => - { - return ( - - ); - }, - [tableMetaData, usedGroupByFieldNames, availableFieldNames], + 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) => - { - return ( - - ); - }, - [tableMetaData, usedGroupByFieldNames, availableFieldNames], + const renderValue = useCallback((value: PivotTableValue, index: number, forModal: boolean) => + { + return ( + + ); + }, + [tableMetaData, usedGroupByFieldNames, availableFieldNames], ); - return ( - {enabled && pivotTableDefinition && - + /******************************************************************************* + ** + *******************************************************************************/ + 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"}
    + { - showHelp("sectionSubhead") && - - {getHelpContent("sectionSubhead")} - + tableMetaData && (
    {ptd[rowsOrColumns]?.map((groupBy, i) => renderGroupBy(groupBy, rowsOrColumns, i, forModal))}
    ) } - - - -
    Rows
    - - { - tableMetaData && (
    {pivotTableDefinition?.rows?.map((row, i) => renderGroupBy(row, "rows", i))}
    ) - } -
    - { - isEditable && - - - - - - } -
    - - -
    Columns
    - - { - tableMetaData && (
    {pivotTableDefinition?.columns?.map((column, i) => renderGroupBy(column, "columns", i))}
    ) - } -
    - { - isEditable && - - - - - - } -
    - - -
    Values
    - - { - tableMetaData && (
    {pivotTableDefinition?.values?.map((value, i) => renderValue(value, i))}
    ) - } -
    - { - isEditable && - - - - - - } -
    - -
    - {/* - -
    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}
    +
    + { + (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); + + 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 index 38c6704..e99fe49 100644 --- a/src/qqq/components/widgets/misc/PivotTableValueElement.tsx +++ b/src/qqq/components/widgets/misc/PivotTableValueElement.tsx @@ -20,6 +20,8 @@ */ +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"; @@ -31,8 +33,8 @@ 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, PivotTableFunction, pivotTableFunctionLabels, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels"; -import React, {FC, useRef} from "react"; +import {functionsPerFieldType, PivotTableDefinition, pivotTableFunctionLabels, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels"; +import React, {FC, useReducer, useRef, useState} from "react"; import {useDrag, useDrop} from "react-dnd"; @@ -51,6 +53,7 @@ export interface PivotTableValueElementProps isEditable: boolean; value: PivotTableValue; callback: () => void; + attemptedSubmit?: boolean; } @@ -68,8 +71,11 @@ interface DragItem /******************************************************************************* ** Element to render 1 pivot-table value. *******************************************************************************/ -export const PivotTableValueElement: FC = ({id, index, dragCallback, metaData, tableMetaData, pivotTableDefinition, availableFieldNames, value, isEditable, callback}) => +export const PivotTableValueElement: FC = ({id, index, dragCallback, metaData, tableMetaData, pivotTableDefinition, availableFieldNames, 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 // //////////////////////////////////////////////////////////////////////////// @@ -147,24 +153,68 @@ export const PivotTableValueElement: FC = ({id, ind }); + /******************************************************************************* + ** + *******************************************************************************/ + 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 *******************************************************************************/ - const handleFieldChange = (event: any, newValue: any, reason: string) => + 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 *******************************************************************************/ - const handleFunctionChange = (event: any, newValue: any, reason: string) => + function handleFunctionChange(event: any, newValue: any, reason: string) { value.function = newValue ? newValue.id : null; callback(); - }; + } /******************************************************************************* @@ -176,65 +226,43 @@ export const PivotTableValueElement: FC = ({id, ind callback(); } + const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName); ///////////////////////////////////////////////////////////////////// // if we're not on an edit screen, return a simpler read-only view // ///////////////////////////////////////////////////////////////////// if (!isEditable) { - const selectedField = getSelectedFieldForAutoComplete(tableMetaData, value.fieldName); + let label = "--"; 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}); + label = pivotTableFunctionLabels[value.function] + " of " + (selectedField.table.name == tableMetaData.name ? selectedField.field.label : selectedField.table.label + ": " + selectedField.field.label); } - return (); + return ({label}); } /////////////////////////////////////////////////////////////////////////////// // figure out functions to display in drop down, plus selected/default value // /////////////////////////////////////////////////////////////////////////////// const functionOptions: any[] = []; - let defaultFunctionValue = null; - for (let pivotTableFunctionKey in PivotTableFunction) + const availableFunctions = getFunctionsForField(selectedField?.field); + for (let pivotTableFunction of availableFunctions) { - // @ts-ignore any? - const label = "" + pivotTableFunctionLabels[pivotTableFunctionKey]; - const option = {id: pivotTableFunctionKey, label: label}; + const label = pivotTableFunctionLabels[pivotTableFunction]; + const option = {id: pivotTableFunction, label: label}; functionOptions.push(option); - if (option.id == value.function) + if (option.id == value.function && JSON.stringify(option) != JSON.stringify(defaultFunctionValue)) { - defaultFunctionValue = option; + setDefaultFunctionValue(option); } } drag(drop(ref)); - /* - return ( - - drag_indicator - - - - - - - - ); - */ + const showValueError = attemptedSubmit && !value.fieldName; + const showFunctionError = attemptedSubmit && !value.function; return ( @@ -250,25 +278,34 @@ export const PivotTableValueElement: FC = ({id, ind tableMetaData={tableMetaData} handleFieldChange={handleFieldChange} availableFieldNames={availableFieldNames} - defaultValue={getSelectedFieldForAutoComplete(tableMetaData, value.fieldName)} + defaultValue={selectedField} + hasError={showValueError} /> - + ()} + id={`values-function-${index}`} + renderInput={(params) => + { + const inputProps = params.InputProps; + const originalEndAdornment = inputProps.endAdornment; + inputProps.endAdornment = + {showFunctionError && error_outline} + {originalEndAdornment} + ; + + return () + }} // @ts-ignore - defaultValue={defaultFunctionValue} + value={defaultFunctionValue} + inputValue={defaultFunctionValue?.label ?? ""} 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} /> diff --git a/src/qqq/components/widgets/misc/ReportSetupWidget.tsx b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx index 39eb8f6..9cdf995 100644 --- a/src/qqq/components/widgets/misc/ReportSetupWidget.tsx +++ b/src/qqq/components/widgets/misc/ReportSetupWidget.tsx @@ -25,8 +25,8 @@ import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Q 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 Link from "@mui/material/Link"; import Modal from "@mui/material/Modal"; import Tooltip from "@mui/material/Tooltip/Tooltip"; import QContext from "QContext"; @@ -53,6 +53,27 @@ 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(); /******************************************************************************* @@ -126,6 +147,9 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal // @ts-ignore possibly 'undefined'. const view = recordQueryRef?.current?.getCurrentView(); + + view.queryColumns.sortColumnsFixingPinPositions(); + onSaveCallback({queryFilterJson: JSON.stringify(view.queryFilter), columnsJson: JSON.stringify(view.queryColumns)}); closeEditor(); @@ -189,9 +213,12 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal { if(tableMetaData) { - if(columns?.columns?.length > 0) + for(let i = 0; i - + Add Filters +
    } { @@ -274,11 +301,11 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal { isEditable && - + Add Columns + } { - !isEditable && Your report has no filters. + !isEditable && Your report has no columns. }
    } 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 index 3eeffc4..655d8cc 100644 --- a/src/qqq/models/misc/PivotTableDefinitionModels.ts +++ b/src/qqq/models/misc/PivotTableDefinitionModels.ts @@ -19,6 +19,8 @@ * 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, @@ -30,7 +32,7 @@ export class PivotObjectKey static next(): number { - return PivotObjectKey.value++ + return PivotObjectKey.value++; } } @@ -56,7 +58,7 @@ export class PivotTableGroupBy constructor() { - this.key = PivotObjectKey.next() + this.key = PivotObjectKey.next(); } } @@ -73,43 +75,85 @@ export class PivotTableValue constructor() { - this.key = PivotObjectKey.next() + this.key = PivotObjectKey.next(); } } /******************************************************************************* - ** Functions that can be appplied to pivot table values + ** Functions that can be applied to pivot table values *******************************************************************************/ export enum PivotTableFunction { - AVERAGE = "AVERAGE", + SUM = "SUM", COUNT = "COUNT", - COUNT_NUMS = "COUNT_NUMS", + 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", - SUM = "SUM", 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", - "COUNT": "Count Values (COUNTA)", - "COUNT_NUMS": "Count Numbers (COUNT)", "MAX": "Max", "MIN": "Min", "PRODUCT": "Product", + // "COUNT_NUMS": "Count Numbers", "STD_DEV": "StdDev", "STD_DEVP": "StdDevp", - "SUM": "Sum", "VAR": "Var", "VARP": "Varp" }; diff --git a/src/qqq/models/query/QQueryColumns.ts b/src/qqq/models/query/QQueryColumns.ts index 210805c..8b5dccb 100644 --- a/src/qqq/models/query/QQueryColumns.ts +++ b/src/qqq/models/query/QQueryColumns.ts @@ -80,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); } }); @@ -392,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/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index bfc2315..1c5e016 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -105,8 +105,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 (   ); @@ -2549,7 +2554,7 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => promptForTableVariantSelection(); } - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } //////////////////////////////////////////////////////////////////////// @@ -2583,7 +2588,7 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => setRows([]); setIsFirstRenderAfterChangingTables(true); - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } ///////////////////////////////////////////////////////////////////////////////////////////// @@ -2627,7 +2632,7 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => if (pageState != "ready") { console.log(`page state is ${pageState}... no-op while those complete async's run...`); - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -2636,7 +2641,7 @@ const RecordQuery = forwardRef(({table, usage, isModal}: Props, ref) => /////////////////////////////////////////////////////////////////////////////////////////// if (!tableMetaData) { - return (getLoadingScreen()); + return (getLoadingScreen(isModal)); } let savedViewsComponent = null;